From dfa88c5b63f6c078c27021f50db1462f6e51f86c Mon Sep 17 00:00:00 2001 From: sepehr Date: Sun, 4 Jan 2026 22:47:54 +0100 Subject: [PATCH] feat: implement label management with color filtering --- .kilocode/mcp.json | 2 +- CHANGELOG.md | 25 +++- keep-notes/app/api/labels/[id]/route.ts | 88 ++++++++++++ keep-notes/app/api/labels/route.ts | 61 ++++++-- keep-notes/app/layout.tsx | 7 +- keep-notes/app/page.tsx | 16 ++- keep-notes/components/header-wrapper.tsx | 31 +++- keep-notes/components/header.tsx | 6 +- keep-notes/components/label-badge.tsx | 58 ++++++++ keep-notes/components/label-filter.tsx | 127 ++++++++++------- keep-notes/components/label-manager.tsx | 74 +++++----- keep-notes/components/label-selector.tsx | 74 ++++++++++ keep-notes/components/note-card.tsx | 39 +---- keep-notes/components/note-editor.tsx | 37 ++--- keep-notes/components/note-input.tsx | 35 +++-- keep-notes/context/LabelContext.tsx | 133 ++++++++++++++++++ keep-notes/lib/types.ts | 9 ++ keep-notes/prisma/dev.db | Bin 2904064 -> 2904064 bytes .../migration.sql | 16 +++ keep-notes/prisma/schema.prisma | 13 ++ 20 files changed, 674 insertions(+), 177 deletions(-) create mode 100644 keep-notes/app/api/labels/[id]/route.ts create mode 100644 keep-notes/components/label-badge.tsx create mode 100644 keep-notes/components/label-selector.tsx create mode 100644 keep-notes/context/LabelContext.tsx create mode 100644 keep-notes/prisma/migrations/20260104203746_add_labels_table/migration.sql diff --git a/.kilocode/mcp.json b/.kilocode/mcp.json index 859c764..0840017 100644 --- a/.kilocode/mcp.json +++ b/.kilocode/mcp.json @@ -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"]}}} \ No newline at end of file +{"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"]}}} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index a4cc84d..0abda0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/keep-notes/app/api/labels/[id]/route.ts b/keep-notes/app/api/labels/[id]/route.ts new file mode 100644 index 0000000..7479737 --- /dev/null +++ b/keep-notes/app/api/labels/[id]/route.ts @@ -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 } + ) + } +} diff --git a/keep-notes/app/api/labels/route.ts b/keep-notes/app/api/labels/route.ts index 524bcec..fa437cc 100644 --- a/keep-notes/app/api/labels/route.ts +++ b/keep-notes/app/api/labels/route.ts @@ -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() - 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 } + ) + } +} diff --git a/keep-notes/app/layout.tsx b/keep-notes/app/layout.tsx index f5ac8d6..8efd36e 100644 --- a/keep-notes/app/layout.tsx +++ b/keep-notes/app/layout.tsx @@ -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({ - - {children} + + + {children} + diff --git a/keep-notes/app/page.tsx b/keep-notes/app/page.tsx index 8107cc9..7f9291a 100644 --- a/keep-notes/app/page.tsx +++ b/keep-notes/app/page.tsx @@ -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([]) 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 (
diff --git a/keep-notes/components/header-wrapper.tsx b/keep-notes/components/header-wrapper.tsx index de36f3d..7b87163 100644 --- a/keep-notes/components/header-wrapper.tsx +++ b/keep-notes/components/header-wrapper.tsx @@ -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
+ 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 ( +
+ ) } diff --git a/keep-notes/components/header.tsx b/keep-notes/components/header.tsx index 0846dcd..0ad62f7 100644 --- a/keep-notes/components/header.tsx +++ b/keep-notes/components/header.tsx @@ -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 && ( )} diff --git a/keep-notes/components/label-badge.tsx b/keep-notes/components/label-badge.tsx new file mode 100644 index 0000000..8dec051 --- /dev/null +++ b/keep-notes/components/label-badge.tsx @@ -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 ( + + {label} + {onRemove && ( + + )} + + ) +} diff --git a/keep-notes/components/label-filter.tsx b/keep-notes/components/label-filter.tsx index d57392f..05d9598 100644 --- a/keep-notes/components/label-filter.tsx +++ b/keep-notes/components/label-filter.tsx @@ -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([]) +export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onColorChange }: LabelFilterProps) { + const { labels, loading, getLabelColor } = useLabels() + const [allLabelNames, setAllLabelNames] = useState([]) 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 (
@@ -58,9 +70,9 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps )} - + - Filter by Labels + Filter by Labels {selectedLabels.length > 0 && ( + ) + })} +
+ + + + {/* Label Filters */} + {!loading && allLabelNames.map((labelName: string) => { + const isSelected = selectedLabels.includes(labelName) + const isColorFiltered = selectedColor && selectedColor !== 'gray' return ( handleToggleLabel(label)} + key={labelName} + checked={isSelected && !isColorFiltered} + onCheckedChange={() => handleToggleLabel(labelName)} > - - {label} - + ) })} @@ -101,28 +140,16 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps {/* Active filters display */} - {selectedLabels.length > 0 && ( + {!loading && selectedLabels.length > 0 && (
- {selectedLabels.map((label) => { - const colorName = getLabelColor(label) - const colorClasses = LABEL_COLORS[colorName] - - return ( - handleToggleLabel(label)} - > - {label} - - - ) - })} + {selectedLabels.map((labelName: string) => ( + handleToggleLabel(labelName)} + /> + ))}
)} diff --git a/keep-notes/components/label-manager.tsx b/keep-notes/components/label-manager.tsx index 66b1998..7dc689c 100644 --- a/keep-notes/components/label-manager.tsx +++ b/keep-notes/components/label-manager.tsx @@ -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(existingLabels) - const [allLabelsInStorage, setAllLabelsInStorage] = useState([]) const [editingColor, setEditingColor] = useState(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) {

Selected Labels

{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 (
- {isEditing ? ( + {isEditing && labelObj ? (
{(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) {
)} - {/* Available labels from storage */} - {allLabelsInStorage.length > 0 && ( + {/* Available labels from context */} + {!loading && labels.length > 0 && (

All Labels

- {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 ( handleSelectExisting(label)} + onClick={() => handleSelectExisting(label.name)} > - {label} + {label.name} ) })} diff --git a/keep-notes/components/label-selector.tsx b/keep-notes/components/label-selector.tsx new file mode 100644 index 0000000..2f88d21 --- /dev/null +++ b/keep-notes/components/label-selector.tsx @@ -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 ( + + + + + +
+ {loading ? ( +
Loading...
+ ) : labels.length === 0 ? ( +
No labels yet
+ ) : ( + labels.map((label) => { + const isSelected = selectedLabels.includes(label.name) + + return ( + handleToggleLabel(label.name)} + className="flex items-center justify-between gap-2" + > + + + ) + }) + )} +
+
+
+ ) +} diff --git a/keep-notes/components/note-card.tsx b/keep-notes/components/note-card.tsx index a748986..65a40ff 100644 --- a/keep-notes/components/note-card.tsx +++ b/keep-notes/components/note-card.tsx @@ -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>({}) const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default - // Load label colors from localStorage - useEffect(() => { - if (note.labels) { - const colors: Record = {} - 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 && (
- {note.labels.map((label) => { - const colorName = labelColors[label] || 'gray' - const colorClasses = LABEL_COLORS[colorName as keyof typeof LABEL_COLORS] || LABEL_COLORS.gray - return ( - - {label} - - ) - })} + {note.labels.map((label) => ( + + ))}
)} diff --git a/keep-notes/components/note-editor.tsx b/keep-notes/components/note-editor.tsx index ee0b3f3..77f5e0a 100644 --- a/keep-notes/components/note-editor.tsx +++ b/keep-notes/components/note-editor.tsx @@ -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 && (
- {labels.map((label) => { - const colorName = getLabelColor(label) - const colorClasses = LABEL_COLORS[colorName] - return ( - - {label} - - - ) - })} + {labels.map((label) => ( + handleRemoveLabel(label)} + /> + ))}
)} diff --git a/keep-notes/components/note-input.tsx b/keep-notes/components/note-input.tsx index 824dc72..7f19cd2 100644 --- a/keep-notes/components/note-input.tsx +++ b/keep-notes/components/note-input.tsx @@ -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('default') const [isArchived, setIsArchived] = useState(false) + const [selectedLabels, setSelectedLabels] = useState([]) const fileInputRef = useRef(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' ? (
+ {/* Labels selector */} +
+ +
+ {/* Markdown toggle button */} {isMarkdown && (
diff --git a/keep-notes/context/LabelContext.tsx b/keep-notes/context/LabelContext.tsx new file mode 100644 index 0000000..9f4d4c9 --- /dev/null +++ b/keep-notes/context/LabelContext.tsx @@ -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 + updateLabel: (id: string, updates: Partial>) => Promise + deleteLabel: (id: string) => Promise + getLabelColor: (name: string) => LabelColorName + refreshLabels: () => Promise +} + +const LabelContext = createContext(undefined) + +export function LabelProvider({ children }: { children: ReactNode }) { + const [labels, setLabels] = useState([]) + 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>) => { + 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 {children} +} + +export function useLabels() { + const context = useContext(LabelContext) + if (context === undefined) { + throw new Error('useLabels must be used within a LabelProvider') + } + return context +} diff --git a/keep-notes/lib/types.ts b/keep-notes/lib/types.ts index d4f25eb..52c5dca 100644 --- a/keep-notes/lib/types.ts +++ b/keep-notes/lib/types.ts @@ -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; diff --git a/keep-notes/prisma/dev.db b/keep-notes/prisma/dev.db index 672093ea29926d4bede5e9a38bf116a1a5ca2114..d73a260634c775de0c8e5f938fb6feab401939f9 100644 GIT binary patch delta 2692 zcmb7_3vg7`8OQIrkA3H!O(44=0kTQuv4+5Ace9%?tsztzBMMOnrlFYK>~2b$hY=FT z^kHBe4li3TU2UwX^-e2xsxuY3lblhc4Xt7oR97$q+q z-@9QuEdoA5#goaV_IPuuePdI1ytzFkvgTxJ3ihiWyKYGIDg9~sx;{qNQKlQzNyC{U-(9+Y zzEV&Am#*!Zrx(@|_Zdl}BNDwxPty^4ioTJ_3A{ixRk0D8K2rO|zonA=B>WZX+K zG@DYKb>ZNg%o|x5&^7%|LNDq+(I3(K^<8Oq-KtEgE=%vzewzN2+2cR;-9Qb&3w-jhI8Ox1(zXS}feK42GTHlpoz|ggaZtb$Ox! zPU-0O5u31O$dfLEEqyrk<1i_`3oE2|&>d4S;lyNNoH~Z?%fdZ~6{KNw_n|u^+&#j5 z0^Qx{n!Dm2-l5ni4$#$BC5%7=c^9-3|#U-iH_4TdYBH<7eV+>)4o`D zIuNsD9*FrTFSCS~S;ETE+KDbp6oy_=7|5Vmq)t``d_R|`&ByF6nvDDLPRVCT|)y~3ctD@n~XgClEB_eQt z2U|Dxely$_YESu+t?{pKh<9|Pe04W$yfc||hUz=^_jJMd+AFtjN!h1Y zRc*f|*JjI=)@H+**(6)PuZApwA9{||fc08o!OrhYE`WSkV9&(Qu2uSJ zhsV~4MQM+;rX;%nONu|p(6mHHTj2lh^^;Z)M1FZrCGdQ#lj!qwk^X_cPCuT>Ip406 z#&qA6HN~@(D)L`{gW_!ev(;oq+C6bWu_*^0w30ch$(fgX6TUW@%0%$Mi*X1~h6kKvMAgOfd=Z}X5eiQ<&vBEz|KVEmkwQ}H%aAU` z=hLmbCA#0W*R{>H$a%!s=bYjndGNCEo;*cscuayOh2V2aPxm5 z4{jhU72<$xohW1mVTQ~Mg&8U{G-gM|KG1g14z4zM?j`F?;736}20a4$5oiGP zFzAP%hd}+HAAlYNJpf9Bz7K+LHo+6QAM`!YcR}}oc7o1>-s?AS^R(z>1o$`}gW9$> zGy_zkWtLB0+6k6a5N-w6CB*L=vUfKPDEjob-* za!^AI0iNR8`F!A;f&av{QREunQLY_D?gf5}Ys1K21s>zt9^_Ww(_Ax=Yk~jFH3PW> z_-$w(IS%|6Xdn3w;CG zg7G3(0~kN#st@BwTyIuM{h3j$Q=L4XEL2(UmG0<6%602}lnzz&TFaKO~8j|!DTCrxJhm|4WkVrG7U anb$CL4m0O6a~?D2Gt{0VxFzd{?Rk7{UU z(BR(3i(^aK;44du82x#|(kVjk(yMfeUZxX4qwRId(VK)bh?jF0saZNfLcdLj`n(?o zpUX?5;>lb=JYQ+mvV0!{BO@rGiK4|(B&Is_!B&Tk-$b?KnQU4>)Hy)hB390*7J=uD zx6@z1vuj=jaNFE+VAq^7U}xMK;Of{?V7s{lxWrTpToCgV@RP!7;OyukU|UonaB}1- z;6!5q@I1pZV3R%{*huq$H)&NX;Sr(C<>U;bV38wK$Q)0TiHXgGu(a}6;0W1r^sO4? zWCfiie@)T?zrKV6es%F2;0bjG{g`+X4WK;>^vLrmazZ+?#fTgqhpc-Nd6RYpJ~ZD! zR%aorGJw03D}mc0RKP9>WtKQ0lvz>@q0ADyY&md=RRdg*tiXU4;*u*9amkzoxMbRV zT+*6=OD?qFlJPt)Y1ZM2ky>0)r?F;n7(FLvLPK!!+I|@q(EXYD zrF7tsu#k!NY9e!!O5>EuXksv6RTGBe#EJXn=<1DLPphS(w`F77#*+tE_4asMdj|Zw z4+}AKT3Xv02fDo8)?sgbLw$pl*}HSwcol?kuw{4f+{{JU{0OjqaP!Au%Qvcs%FX@J z_oH!@dRPQkktzzFsFx=uL&rYniEu|cz~tm6Sr)?gCv^!4;oe`&>hJ0img^MI>&@}4;a)G@Vi}(08wh=krt+Wg4SYk;c)N>dhQ&?y zpJu6n%=0Hef;jzLc`hT~nlX#|2Y2K~;@P8M7P2_;xQ#iek}-AC1r;-&5#72B$);hZ zSUz+^@50@kq7!sNGU}OQUpdF~aE{VcJdKrKpts ziDb*{%oO7!N8$U}XPEl=BSNg`yKnUW?ud|Ul)gXt$2E6%+OpE!Z6i=5y;vm8J?-tw+T4v|$8@~f9v*pW z*ce{4_JL9S!}Q$HktRNNq+9B_sBb(e!(%6<&@f5=N5NUXso->dvYX=q>xDURIQj^z|Bv9^c@lo2G>g3lO<)#KAN&cb86e2f9e0CE5e-~bT-1waW< z0n`8uKnu_TcmR&o0}KEoAQBKI$;YDENp27Pfx9hf9l}#APs|X72@nGiq}pnsOzyu? zCp1eDwZayr@k8kQ0pNYWdw_QVHvu;Q*8%SUt^uwBt^j5L(}1@DmjRam7XcRlZvoB& z&H?@bI18BYf7dB=av#C!(c5dC0w6}Jb(;2aIu2e3dtcoEb@+xVtuZ- z4_J?b9N)8ib<43IMtcRLnz z1?VdhH;uX&^i_#FgL(w?HHn))UK4 z&2ggAtRR{dbQ@Lk&+<{apXugl_iLBHzcC3K*~n`DmVH8uIP+Pw|1ZB1OiZTVbwG$) zn)%3W6yT%+`whVG>WB067Yp+;%A;W;FvX0`TvQ_^y>bF#8>GIDITtn8HVpDC{;4aGV2Vuler;ZV{tH4hHRD9WvG8U` zibE5LCm}mHEGq5m!%{@|bdMUHHT{(>9^)HFHpt*MWP))nE#P@2byJNnV!;B?KLfUJ3f_oSE%P;YN^xFw=BOC%+Dm=jpneuv9BZ3>8`+{ zyeILS(d_8%w^o-pOUkgaXC;0N&HU=74eLD3HBIZm{9NJ#Xcl+ZrL7yWc~kMa-Iw^I zXgbU6*=a?~2g`9s4d8_lAkg1W{=Pv=@R<>R~#yn?Rk^+jnd>zanaV_b(sp%iDqG2Q@X9JuhkA$Sf++_qFG(D+Fp{;(^igQY9O6ps$07H vv)Tum*O&JC0v2B&!55hC3oP&j5`BS%zQ7`1V6iW-#1}~N1(JUqupa#{Sg)5s diff --git a/keep-notes/prisma/migrations/20260104203746_add_labels_table/migration.sql b/keep-notes/prisma/migrations/20260104203746_add_labels_table/migration.sql new file mode 100644 index 0000000..34ebce4 --- /dev/null +++ b/keep-notes/prisma/migrations/20260104203746_add_labels_table/migration.sql @@ -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"); diff --git a/keep-notes/prisma/schema.prisma b/keep-notes/prisma/schema.prisma index b038a99..bd8e365 100644 --- a/keep-notes/prisma/schema.prisma +++ b/keep-notes/prisma/schema.prisma @@ -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?