feat: implement label management with color filtering

This commit is contained in:
sepehr 2026-01-04 22:47:54 +01:00
parent a154192410
commit dfa88c5b63
20 changed files with 674 additions and 177 deletions

View File

@ -1 +1 @@
{"mcpServers":{"playwright":{"command":"npx","args":["-y","@playwright/mcp@0.0.38"],"alwaysAllow":["browser_evaluate","browser_navigate","browser_take_screenshot","browser_console_messages","browser_click","browser_wait_for"]}}}
{"mcpServers":{"playwright":{"command":"npx","args":["-y","@playwright/mcp@0.0.38"],"alwaysAllow":["browser_evaluate","browser_navigate","browser_take_screenshot","browser_console_messages","browser_click","browser_wait_for","browser_snapshot","browser_close"]}}}

View File

@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
## [Unreleased] - 2026-01-04
### Added
- **Label Management System**: Complete redesign of label/tag management
- Added `Label` model to Prisma schema with centralized database storage
- Created `LabelContext` for global state management with React Context API
- Added `useLabels` hook for easy access to label functionality
- Implemented full CRUD API for labels (GET, POST, PUT, DELETE)
- Updated all components to use the new context instead of localStorage
### Fixed
- **Tests**: Fixed Playwright drag-and-drop tests to work with dynamically generated note IDs
- Changed selectors from hardcoded text (`text=Note 1`) to flexible attribute selectors (`[data-draggable="true"]`)
@ -11,13 +19,22 @@ All notable changes to this project will be documented in this file.
- Replaced UI-based cleanup with API-based cleanup using `request.delete()` for more reliable test cleanup
### Database
- Cleaned up 38 accumulated test notes from the database using MCP memento tool
- Cleaned up 38 accumulated test notes from database using MCP memento tool
- Retained only essential notes: "test" and 2x "New AI Framework Released"
- Added migration `20260104203746_add_labels_table` for new Label model
### Technical Details
- The drag-and-drop functionality itself was working correctly
- The issue was in the Playwright tests which expected exact text matches but notes were created with unique IDs (e.g., `test-1767557327567-Note 1`)
- Tests now properly handle the dynamic note generation system
- **Label Management**:
- Labels now stored in database with colors, eliminating localStorage duplication
- All label operations are centralized through LabelContext
- Automatic synchronization across all components
- Better performance with single source of truth
- Support for user-specific labels (userId field for future auth)
- **Tests**:
- The drag-and-drop functionality itself was working correctly
- The issue was in Playwright tests which expected exact text matches but notes were created with unique IDs (e.g., `test-1767557327567-Note 1`)
- Tests now properly handle the dynamic note generation system
## [Previous Versions]

View File

@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// GET /api/labels/[id] - Get a specific label
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const label = await prisma.label.findUnique({
where: { id }
})
if (!label) {
return NextResponse.json(
{ success: false, error: 'Label not found' },
{ status: 404 }
)
}
return NextResponse.json({
success: true,
data: label
})
} catch (error) {
console.error('GET /api/labels/[id] error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to fetch label' },
{ status: 500 }
)
}
}
// PUT /api/labels/[id] - Update a label
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const body = await request.json()
const { name, color } = body
const label = await prisma.label.update({
where: { id },
data: {
...(name && { name: name.trim() }),
...(color && { color })
}
})
return NextResponse.json({
success: true,
data: label
})
} catch (error) {
console.error('PUT /api/labels/[id] error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to update label' },
{ status: 500 }
)
}
}
// DELETE /api/labels/[id] - Delete a label
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await prisma.label.delete({
where: { id }
})
return NextResponse.json({
success: true,
message: 'Label deleted successfully'
})
} catch (error) {
console.error('DELETE /api/labels/[id] error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to delete label' },
{ status: 500 }
)
}
}

View File

@ -1,24 +1,16 @@
import { NextRequest, NextResponse } from 'next/server'
import prisma from '@/lib/prisma'
// GET /api/labels - Get all unique labels
// GET /api/labels - Get all labels
export async function GET(request: NextRequest) {
try {
const notes = await prisma.note.findMany({
select: { labels: true }
})
const labelsSet = new Set<string>()
notes.forEach(note => {
const labels = note.labels ? JSON.parse(note.labels) : null
if (labels) {
labels.forEach((label: string) => labelsSet.add(label))
}
const labels = await prisma.label.findMany({
orderBy: { name: 'asc' }
})
return NextResponse.json({
success: true,
data: Array.from(labelsSet).sort()
data: labels
})
} catch (error) {
console.error('GET /api/labels error:', error)
@ -28,3 +20,48 @@ export async function GET(request: NextRequest) {
)
}
}
// POST /api/labels - Create a new label
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { name, color } = body
if (!name || typeof name !== 'string') {
return NextResponse.json(
{ success: false, error: 'Label name is required' },
{ status: 400 }
)
}
// Check if label already exists
const existing = await prisma.label.findUnique({
where: { name: name.trim() }
})
if (existing) {
return NextResponse.json(
{ success: false, error: 'Label already exists' },
{ status: 409 }
)
}
const label = await prisma.label.create({
data: {
name: name.trim(),
color: color || 'gray'
}
})
return NextResponse.json({
success: true,
data: label
})
} catch (error) {
console.error('POST /api/labels error:', error)
return NextResponse.json(
{ success: false, error: 'Failed to create label' },
{ status: 500 }
)
}
}

View File

@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { HeaderWrapper } from "@/components/header-wrapper";
import { ToastProvider } from "@/components/ui/toast";
import { LabelProvider } from "@/context/LabelContext";
const inter = Inter({
subsets: ["latin"],
@ -22,8 +23,10 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<ToastProvider>
<HeaderWrapper />
{children}
<LabelProvider>
<HeaderWrapper />
{children}
</LabelProvider>
</ToastProvider>
</body>
</html>

View File

@ -6,17 +6,20 @@ import { Note } from '@/lib/types'
import { getNotes, searchNotes } from '@/app/actions/notes'
import { NoteInput } from '@/components/note-input'
import { NoteGrid } from '@/components/note-grid'
import { useLabels } from '@/context/LabelContext'
export default function HomePage() {
const searchParams = useSearchParams()
const [notes, setNotes] = useState<Note[]>([])
const [isLoading, setIsLoading] = useState(true)
const { labels } = useLabels()
useEffect(() => {
const loadNotes = async () => {
setIsLoading(true)
const search = searchParams.get('search')
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
const colorFilter = searchParams.get('color')
let allNotes = search ? await searchNotes(search) : await getNotes()
@ -27,12 +30,23 @@ export default function HomePage() {
)
}
// Filter by color (filter notes that have labels with this color)
if (colorFilter) {
const labelNamesWithColor = labels
.filter(label => label.color === colorFilter)
.map(label => label.name)
allNotes = allNotes.filter(note =>
note.labels?.some(label => labelNamesWithColor.includes(label))
)
}
setNotes(allNotes)
setIsLoading(false)
}
loadNotes()
}, [searchParams])
}, [searchParams, labels])
return (
<main className="container mx-auto px-4 py-8 max-w-7xl">

View File

@ -2,12 +2,19 @@
import { Header } from './header'
import { useSearchParams, useRouter } from 'next/navigation'
import { useLabels } from '@/context/LabelContext'
export function HeaderWrapper() {
interface HeaderWrapperProps {
onColorFilterChange?: (color: string | null) => void
}
export function HeaderWrapper({ onColorFilterChange }: HeaderWrapperProps) {
const searchParams = useSearchParams()
const router = useRouter()
const { labels } = useLabels()
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
const selectedColor = searchParams.get('color') || null
const handleLabelFilterChange = (labels: string[]) => {
const params = new URLSearchParams(searchParams.toString())
@ -21,5 +28,25 @@ export function HeaderWrapper() {
router.push(`/?${params.toString()}`)
}
return <Header selectedLabels={selectedLabels} onLabelFilterChange={handleLabelFilterChange} />
const handleColorFilterChange = (color: string | null) => {
const params = new URLSearchParams(searchParams.toString())
if (color) {
params.set('color', color)
} else {
params.delete('color')
}
router.push(`/?${params.toString()}`)
onColorFilterChange?.(color)
}
return (
<Header
selectedLabels={selectedLabels}
selectedColor={selectedColor}
onLabelFilterChange={handleLabelFilterChange}
onColorFilterChange={handleColorFilterChange}
/>
)
}

View File

@ -19,10 +19,12 @@ import { LabelFilter } from './label-filter'
interface HeaderProps {
selectedLabels?: string[]
selectedColor?: string | null
onLabelFilterChange?: (labels: string[]) => void
onColorFilterChange?: (color: string | null) => void
}
export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps = {}) {
export function Header({ selectedLabels = [], selectedColor, onLabelFilterChange, onColorFilterChange }: HeaderProps = {}) {
const [searchQuery, setSearchQuery] = useState('')
const [isSearching, setIsSearching] = useState(false)
const [theme, setTheme] = useState<'light' | 'dark'>('light')
@ -105,7 +107,9 @@ export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps
{onLabelFilterChange && (
<LabelFilter
selectedLabels={selectedLabels}
selectedColor={selectedColor}
onFilterChange={onLabelFilterChange}
onColorChange={onColorFilterChange}
/>
)}
</div>

View File

@ -0,0 +1,58 @@
'use client'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { LABEL_COLORS } from '@/lib/types'
import { useLabels } from '@/context/LabelContext'
interface LabelBadgeProps {
label: string
onRemove?: () => void
variant?: 'default' | 'filter' | 'clickable'
onClick?: () => void
isSelected?: boolean
isDisabled?: boolean
}
export function LabelBadge({
label,
onRemove,
variant = 'default',
onClick,
isSelected = false,
isDisabled = false,
}: LabelBadgeProps) {
const { getLabelColor } = useLabels()
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName] || LABEL_COLORS.gray
return (
<Badge
className={cn(
'text-xs border gap-1',
colorClasses.bg,
colorClasses.text,
colorClasses.border,
variant === 'filter' && 'cursor-pointer hover:opacity-80',
variant === 'clickable' && 'cursor-pointer',
isDisabled && 'opacity-50',
isSelected && 'ring-2 ring-blue-500'
)}
onClick={onClick}
>
{label}
{onRemove && (
<button
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="hover:text-red-600"
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
)
}

View File

@ -11,28 +11,31 @@ import {
} 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 { Filter } from 'lucide-react'
import { LABEL_COLORS } from '@/lib/types'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
interface LabelFilterProps {
selectedLabels: string[]
selectedColor?: string | null
onFilterChange: (labels: string[]) => void
onColorChange?: (color: string | null) => void
}
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
const [allLabels, setAllLabels] = useState<string[]>([])
export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onColorChange }: LabelFilterProps) {
const { labels, loading, getLabelColor } = useLabels()
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
useEffect(() => {
// Load all labels from localStorage
const labelColors = getAllLabelColors()
setAllLabels(Object.keys(labelColors).sort())
}, [])
// Extract label names from labels array
setAllLabelNames(labels.map((l: any) => l.name).sort())
}, [labels])
const handleToggleLabel = (label: string) => {
if (selectedLabels.includes(label)) {
onFilterChange(selectedLabels.filter(l => l !== label))
onFilterChange(selectedLabels.filter((l: string) => l !== label))
} else {
onFilterChange([...selectedLabels, label])
}
@ -40,9 +43,18 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
const handleClearAll = () => {
onFilterChange([])
onColorChange?.(null)
}
if (allLabels.length === 0) return null
const handleColorFilter = (color: string) => {
if (selectedColor === color) {
onColorChange?.(null)
} else {
onColorChange?.(color)
}
}
if (loading || allLabelNames.length === 0) return null
return (
<div className="flex items-center gap-2">
@ -58,9 +70,9 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="flex items-center justify-between">
Filter by Labels
<span>Filter by Labels</span>
{selectedLabels.length > 0 && (
<Button
variant="ghost"
@ -73,27 +85,54 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{allLabels.map((label) => {
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName]
const isSelected = selectedLabels.includes(label)
{/* Color Filter */}
<div className="p-2">
<p className="text-xs font-medium mb-2 text-gray-600 dark:text-gray-400">Filter by Color</p>
<div className="grid grid-cols-3 gap-2">
{Object.entries(LABEL_COLORS).map(([colorName, colorClasses]) => {
const isSelected = selectedColor === colorName
const labelCount = labels.filter((l: any) => l.color === colorName).length
return (
<button
key={colorName}
onClick={() => handleColorFilter(colorName)}
className={cn(
'flex items-center gap-2 p-2 rounded-lg border-2 transition-all hover:scale-105',
isSelected ? 'ring-2 ring-blue-500' : 'border-gray-300 dark:border-gray-600'
)}
>
<div
className={cn(
'w-6 h-6 rounded-full border-2',
colorClasses.bg,
isSelected ? 'border-blue-500 dark:border-blue-400' : colorClasses.border
)}
/>
<span className="text-xs ml-2">{labelCount}</span>
</button>
)
})}
</div>
</div>
<DropdownMenuSeparator />
{/* Label Filters */}
{!loading && allLabelNames.map((labelName: string) => {
const isSelected = selectedLabels.includes(labelName)
const isColorFiltered = selectedColor && selectedColor !== 'gray'
return (
<DropdownMenuCheckboxItem
key={label}
checked={isSelected}
onCheckedChange={() => handleToggleLabel(label)}
key={labelName}
checked={isSelected && !isColorFiltered}
onCheckedChange={() => handleToggleLabel(labelName)}
>
<Badge
className={cn(
'text-xs border mr-2',
colorClasses.bg,
colorClasses.text,
colorClasses.border
)}
>
{label}
</Badge>
<LabelBadge
label={labelName}
isDisabled={!!isColorFiltered}
/>
</DropdownMenuCheckboxItem>
)
})}
@ -101,28 +140,16 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
</DropdownMenu>
{/* Active filters display */}
{selectedLabels.length > 0 && (
{!loading && 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>
)
})}
{selectedLabels.map((labelName: string) => (
<LabelBadge
key={labelName}
label={labelName}
variant="filter"
onClick={() => handleToggleLabel(labelName)}
/>
))}
</div>
)}
</div>

View File

@ -15,8 +15,8 @@ import {
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'
import { useLabels, Label } from '@/context/LabelContext'
interface LabelManagerProps {
existingLabels: string[]
@ -24,30 +24,31 @@ interface LabelManagerProps {
}
export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
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
// Sync selected labels with existingLabels prop
useEffect(() => {
const allColors = getAllLabelColors()
setAllLabelsInStorage(Object.keys(allColors))
}, [open])
setSelectedLabels(existingLabels)
}, [existingLabels])
const handleAddLabel = () => {
const handleAddLabel = async () => {
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)
try {
// Get existing label color or use random
const existingLabel = labels.find(l => l.name === trimmed)
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
await addLabel(trimmed, color)
const updated = [...selectedLabels, trimmed]
setSelectedLabels(updated)
setNewLabel('')
} catch (error) {
console.error('Failed to add label:', error)
}
}
}
@ -64,12 +65,16 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
}
}
const handleChangeColor = (label: string, color: LabelColorName) => {
setLabelColor(label, color)
setEditingColor(null)
// Force re-render
const allColors = getAllLabelColors()
setAllLabelsInStorage(Object.keys(allColors))
const handleChangeColor = async (label: string, color: LabelColorName) => {
const labelObj = labels.find(l => l.name === label)
if (labelObj) {
try {
await updateLabel(labelObj.id, { color })
setEditingColor(null)
} catch (error) {
console.error('Failed to update label color:', error)
}
}
}
const handleSave = () => {
@ -130,13 +135,13 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
<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 labelObj = labels.find(l => l.name === label)
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
const isEditing = editingColor === label
return (
<div key={label} className="relative">
{isEditing ? (
{isEditing && labelObj ? (
<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) => {
@ -147,7 +152,7 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
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'
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
)}
onClick={() => handleChangeColor(label, color)}
title={color}
@ -185,20 +190,19 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
</div>
)}
{/* Available labels from storage */}
{allLabelsInStorage.length > 0 && (
{/* Available labels from context */}
{!loading && labels.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))
{labels
.filter(label => !selectedLabels.includes(label.name))
.map((label) => {
const colorName = getLabelColor(label)
const colorClasses = LABEL_COLORS[colorName]
const colorClasses = LABEL_COLORS[label.color]
return (
<Badge
key={label}
key={label.id}
className={cn(
'text-xs border cursor-pointer',
colorClasses.bg,
@ -206,9 +210,9 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
colorClasses.border,
'hover:opacity-80'
)}
onClick={() => handleSelectExisting(label)}
onClick={() => handleSelectExisting(label.name)}
>
{label}
{label.name}
</Badge>
)
})}

View File

@ -0,0 +1,74 @@
'use client'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tag } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLabels } from '@/context/LabelContext'
import { LabelBadge } from './label-badge'
interface LabelSelectorProps {
selectedLabels: string[]
onLabelsChange: (labels: string[]) => void
variant?: 'default' | 'compact'
triggerLabel?: string
align?: 'start' | 'center' | 'end'
}
export function LabelSelector({
selectedLabels,
onLabelsChange,
variant = 'default',
triggerLabel = 'Tags',
align = 'start',
}: LabelSelectorProps) {
const { labels, loading } = useLabels()
const handleToggleLabel = (labelName: string) => {
if (selectedLabels.includes(labelName)) {
onLabelsChange(selectedLabels.filter((l) => l !== labelName))
} else {
onLabelsChange([...selectedLabels, labelName])
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
<Tag className="h-4 w-4 mr-2" />
{triggerLabel}
{selectedLabels.length > 0 && (
<Badge variant="secondary" className="ml-2 h-5 min-w-5 px-1.5">
{selectedLabels.length}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-64">
<div className="max-h-64 overflow-y-auto">
{loading ? (
<div className="p-4 text-sm text-gray-500">Loading...</div>
) : labels.length === 0 ? (
<div className="p-4 text-sm text-gray-500">No labels yet</div>
) : (
labels.map((label) => {
const isSelected = selectedLabels.includes(label.name)
return (
<DropdownMenuItem
key={label.id}
onClick={() => handleToggleLabel(label.name)}
className="flex items-center justify-between gap-2"
>
<LabelBadge label={label.name} isSelected={isSelected} />
</DropdownMenuItem>
)
})
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,10 +1,9 @@
'use client'
import { Note, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
@ -22,13 +21,13 @@ import {
Trash2,
Bell,
} from 'lucide-react'
import { useState, useEffect } from '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'
import { getLabelColor } from '@/lib/label-storage'
import { LabelBadge } from './label-badge'
interface NoteCardProps {
note: Note
@ -39,20 +38,8 @@ interface 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)
@ -232,23 +219,9 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
{/* Labels */}
{note.labels && note.labels.length > 0 && (
<div className="flex flex-wrap gap-1 mt-3">
{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>
)
})}
{note.labels.map((label) => (
<LabelBadge key={label} label={label} />
))}
</div>
)}

View File

@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import {
Dialog,
DialogContent,
@ -12,19 +12,18 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
import { X, Plus, Palette, 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'
import { LabelManager } from './label-manager'
import { getLabelColor } from '@/lib/label-storage'
import { LabelBadge } from './label-badge'
interface NoteEditorProps {
note: Note
@ -308,29 +307,13 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
{/* Labels */}
{labels.length > 0 && (
<div className="flex flex-wrap gap-2">
{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
)}
>
{label}
<button
onClick={() => handleRemoveLabel(label)}
className="hover:text-red-600"
>
<X className="h-3 w-3" />
</button>
</Badge>
)
})}
{labels.map((label) => (
<LabelBadge
key={label}
label={label}
onRemove={() => handleRemoveLabel(label)}
/>
))}
</div>
)}

View File

@ -5,14 +5,14 @@ import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import {
CheckSquare,
X,
Bell,
Image,
UserPlus,
Palette,
Archive,
import {
CheckSquare,
X,
Bell,
Image,
UserPlus,
Palette,
Archive,
MoreVertical,
Undo2,
Redo2,
@ -31,13 +31,14 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} 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'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
interface HistoryState {
title: string
@ -58,6 +59,7 @@ export function NoteInput() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Simple state without complex undo/redo - like Google Keep
@ -240,6 +242,7 @@ export function NoteInput() {
images: images.length > 0 ? images : undefined,
reminder: currentReminder,
isMarkdown,
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
})
// Reset form
@ -256,6 +259,7 @@ export function NoteInput() {
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
addToast('Note created successfully', 'success')
} catch (error) {
@ -288,12 +292,15 @@ export function NoteInput() {
setContent('')
setCheckItems([])
setImages([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setType('text')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
setSelectedLabels([])
}
if (!isExpanded) {
@ -364,6 +371,16 @@ export function NoteInput() {
{type === 'text' ? (
<div className="space-y-2">
{/* Labels selector */}
<div className="flex items-center gap-2 mb-2">
<LabelSelector
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
triggerLabel="Tags"
align="start"
/>
</div>
{/* Markdown toggle button */}
{isMarkdown && (
<div className="flex justify-end gap-2">

View File

@ -0,0 +1,133 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import { LabelColorName, LABEL_COLORS } from '@/lib/types'
export interface Label {
id: string
name: string
color: LabelColorName
userId?: string | null
createdAt: Date
updatedAt: Date
}
interface LabelContextType {
labels: Label[]
loading: boolean
addLabel: (name: string, color?: LabelColorName) => Promise<void>
updateLabel: (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => Promise<void>
deleteLabel: (id: string) => Promise<void>
getLabelColor: (name: string) => LabelColorName
refreshLabels: () => Promise<void>
}
const LabelContext = createContext<LabelContextType | undefined>(undefined)
export function LabelProvider({ children }: { children: ReactNode }) {
const [labels, setLabels] = useState<Label[]>([])
const [loading, setLoading] = useState(true)
// Fetch labels from API
const fetchLabels = async () => {
try {
setLoading(true)
const response = await fetch('/api/labels')
const data = await response.json()
if (data.success && data.data) {
setLabels(data.data)
}
} catch (error) {
console.error('Failed to fetch labels:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchLabels()
}, [])
const addLabel = async (name: string, color?: LabelColorName) => {
try {
// Get existing label color if not provided
const existingColor = getLabelColorHelper(name)
const labelColor = color || existingColor
const response = await fetch('/api/labels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, color: labelColor }),
})
const data = await response.json()
if (data.success && data.data) {
setLabels(prev => [...prev, data.data])
}
} catch (error) {
console.error('Failed to add label:', error)
throw error
}
}
const updateLabel = async (id: string, updates: Partial<Pick<Label, 'name' | 'color'>>) => {
try {
const response = await fetch(`/api/labels/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
const data = await response.json()
if (data.success && data.data) {
setLabels(prev => prev.map(label =>
label.id === id ? { ...label, ...data.data } : label
))
}
} catch (error) {
console.error('Failed to update label:', error)
throw error
}
}
const deleteLabel = async (id: string) => {
try {
const response = await fetch(`/api/labels/${id}`, {
method: 'DELETE',
})
if (response.ok) {
setLabels(prev => prev.filter(label => label.id !== id))
}
} catch (error) {
console.error('Failed to delete label:', error)
throw error
}
}
const getLabelColorHelper = (name: string): LabelColorName => {
const label = labels.find(l => l.name === name)
return label?.color || 'gray'
}
const refreshLabels = async () => {
await fetchLabels()
}
const value: LabelContextType = {
labels,
loading,
addLabel,
updateLabel,
deleteLabel,
getLabelColor: getLabelColorHelper,
refreshLabels,
}
return <LabelContext.Provider value={value}>{children}</LabelContext.Provider>
}
export function useLabels() {
const context = useContext(LabelContext)
if (context === undefined) {
throw new Error('useLabels must be used within a LabelProvider')
}
return context
}

View File

@ -4,6 +4,15 @@ export interface CheckItem {
checked: boolean;
}
export interface Label {
id: string;
name: string;
color: LabelColorName;
userId?: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface Note {
id: string;
title: string | null;

Binary file not shown.

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "Label" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'gray',
"userId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Label_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Label_name_key" ON "Label"("name");
-- CreateIndex
CREATE INDEX "Label_userId_idx" ON "Label"("userId");

View File

@ -19,6 +19,7 @@ model User {
accounts Account[]
sessions Session[]
notes Note[]
labels Label[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@ -62,6 +63,18 @@ model VerificationToken {
@@id([identifier, token])
}
model Label {
id String @id @default(cuid())
name String @unique
color String @default("gray")
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
model Note {
id String @id @default(cuid())
title String?