feat: implement label management with color filtering
This commit is contained in:
parent
a154192410
commit
dfa88c5b63
@ -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"]}}}
|
||||||
25
CHANGELOG.md
25
CHANGELOG.md
@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [Unreleased] - 2026-01-04
|
## [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
|
### Fixed
|
||||||
- **Tests**: Fixed Playwright drag-and-drop tests to work with dynamically generated note IDs
|
- **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"]`)
|
- 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
|
- Replaced UI-based cleanup with API-based cleanup using `request.delete()` for more reliable test cleanup
|
||||||
|
|
||||||
### Database
|
### 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"
|
- Retained only essential notes: "test" and 2x "New AI Framework Released"
|
||||||
|
- Added migration `20260104203746_add_labels_table` for new Label model
|
||||||
|
|
||||||
### Technical Details
|
### Technical Details
|
||||||
- The drag-and-drop functionality itself was working correctly
|
- **Label Management**:
|
||||||
- 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`)
|
- Labels now stored in database with colors, eliminating localStorage duplication
|
||||||
- Tests now properly handle the dynamic note generation system
|
- 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]
|
## [Previous Versions]
|
||||||
|
|
||||||
|
|||||||
88
keep-notes/app/api/labels/[id]/route.ts
Normal file
88
keep-notes/app/api/labels/[id]/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,24 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
// GET /api/labels - Get all unique labels
|
// GET /api/labels - Get all labels
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const notes = await prisma.note.findMany({
|
const labels = await prisma.label.findMany({
|
||||||
select: { labels: true }
|
orderBy: { name: 'asc' }
|
||||||
})
|
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: Array.from(labelsSet).sort()
|
data: labels
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('GET /api/labels error:', 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { HeaderWrapper } from "@/components/header-wrapper";
|
import { HeaderWrapper } from "@/components/header-wrapper";
|
||||||
import { ToastProvider } from "@/components/ui/toast";
|
import { ToastProvider } from "@/components/ui/toast";
|
||||||
|
import { LabelProvider } from "@/context/LabelContext";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@ -22,8 +23,10 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<HeaderWrapper />
|
<LabelProvider>
|
||||||
{children}
|
<HeaderWrapper />
|
||||||
|
{children}
|
||||||
|
</LabelProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -6,17 +6,20 @@ import { Note } from '@/lib/types'
|
|||||||
import { getNotes, searchNotes } from '@/app/actions/notes'
|
import { getNotes, searchNotes } from '@/app/actions/notes'
|
||||||
import { NoteInput } from '@/components/note-input'
|
import { NoteInput } from '@/components/note-input'
|
||||||
import { NoteGrid } from '@/components/note-grid'
|
import { NoteGrid } from '@/components/note-grid'
|
||||||
|
import { useLabels } from '@/context/LabelContext'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const [notes, setNotes] = useState<Note[]>([])
|
const [notes, setNotes] = useState<Note[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const { labels } = useLabels()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const search = searchParams.get('search')
|
const search = searchParams.get('search')
|
||||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
|
const colorFilter = searchParams.get('color')
|
||||||
|
|
||||||
let allNotes = search ? await searchNotes(search) : await getNotes()
|
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)
|
setNotes(allNotes)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
loadNotes()
|
loadNotes()
|
||||||
}, [searchParams])
|
}, [searchParams, labels])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||||
|
|||||||
@ -2,12 +2,19 @@
|
|||||||
|
|
||||||
import { Header } from './header'
|
import { Header } from './header'
|
||||||
import { useSearchParams, useRouter } from 'next/navigation'
|
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 searchParams = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { labels } = useLabels()
|
||||||
|
|
||||||
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||||
|
const selectedColor = searchParams.get('color') || null
|
||||||
|
|
||||||
const handleLabelFilterChange = (labels: string[]) => {
|
const handleLabelFilterChange = (labels: string[]) => {
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
@ -21,5 +28,25 @@ export function HeaderWrapper() {
|
|||||||
router.push(`/?${params.toString()}`)
|
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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,10 +19,12 @@ import { LabelFilter } from './label-filter'
|
|||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
selectedLabels?: string[]
|
selectedLabels?: string[]
|
||||||
|
selectedColor?: string | null
|
||||||
onLabelFilterChange?: (labels: string[]) => void
|
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 [searchQuery, setSearchQuery] = useState('')
|
||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||||
@ -105,7 +107,9 @@ export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps
|
|||||||
{onLabelFilterChange && (
|
{onLabelFilterChange && (
|
||||||
<LabelFilter
|
<LabelFilter
|
||||||
selectedLabels={selectedLabels}
|
selectedLabels={selectedLabels}
|
||||||
|
selectedColor={selectedColor}
|
||||||
onFilterChange={onLabelFilterChange}
|
onFilterChange={onLabelFilterChange}
|
||||||
|
onColorChange={onColorFilterChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
58
keep-notes/components/label-badge.tsx
Normal file
58
keep-notes/components/label-badge.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -11,28 +11,31 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Filter, X } from 'lucide-react'
|
import { Filter } from 'lucide-react'
|
||||||
import { getAllLabelColors, getLabelColor } from '@/lib/label-storage'
|
|
||||||
import { LABEL_COLORS } from '@/lib/types'
|
import { LABEL_COLORS } from '@/lib/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLabels } from '@/context/LabelContext'
|
||||||
|
import { LabelBadge } from './label-badge'
|
||||||
|
|
||||||
interface LabelFilterProps {
|
interface LabelFilterProps {
|
||||||
selectedLabels: string[]
|
selectedLabels: string[]
|
||||||
|
selectedColor?: string | null
|
||||||
onFilterChange: (labels: string[]) => void
|
onFilterChange: (labels: string[]) => void
|
||||||
|
onColorChange?: (color: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
|
export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onColorChange }: LabelFilterProps) {
|
||||||
const [allLabels, setAllLabels] = useState<string[]>([])
|
const { labels, loading, getLabelColor } = useLabels()
|
||||||
|
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load all labels from localStorage
|
// Extract label names from labels array
|
||||||
const labelColors = getAllLabelColors()
|
setAllLabelNames(labels.map((l: any) => l.name).sort())
|
||||||
setAllLabels(Object.keys(labelColors).sort())
|
}, [labels])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleToggleLabel = (label: string) => {
|
const handleToggleLabel = (label: string) => {
|
||||||
if (selectedLabels.includes(label)) {
|
if (selectedLabels.includes(label)) {
|
||||||
onFilterChange(selectedLabels.filter(l => l !== label))
|
onFilterChange(selectedLabels.filter((l: string) => l !== label))
|
||||||
} else {
|
} else {
|
||||||
onFilterChange([...selectedLabels, label])
|
onFilterChange([...selectedLabels, label])
|
||||||
}
|
}
|
||||||
@ -40,9 +43,18 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
|||||||
|
|
||||||
const handleClearAll = () => {
|
const handleClearAll = () => {
|
||||||
onFilterChange([])
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -58,9 +70,9 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-64">
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
<DropdownMenuLabel className="flex items-center justify-between">
|
<DropdownMenuLabel className="flex items-center justify-between">
|
||||||
Filter by Labels
|
<span>Filter by Labels</span>
|
||||||
{selectedLabels.length > 0 && (
|
{selectedLabels.length > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -73,27 +85,54 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
|||||||
)}
|
)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{allLabels.map((label) => {
|
|
||||||
const colorName = getLabelColor(label)
|
{/* Color Filter */}
|
||||||
const colorClasses = LABEL_COLORS[colorName]
|
<div className="p-2">
|
||||||
const isSelected = selectedLabels.includes(label)
|
<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 (
|
return (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={label}
|
key={labelName}
|
||||||
checked={isSelected}
|
checked={isSelected && !isColorFiltered}
|
||||||
onCheckedChange={() => handleToggleLabel(label)}
|
onCheckedChange={() => handleToggleLabel(labelName)}
|
||||||
>
|
>
|
||||||
<Badge
|
<LabelBadge
|
||||||
className={cn(
|
label={labelName}
|
||||||
'text-xs border mr-2',
|
isDisabled={!!isColorFiltered}
|
||||||
colorClasses.bg,
|
/>
|
||||||
colorClasses.text,
|
|
||||||
colorClasses.border
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Badge>
|
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -101,28 +140,16 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
{/* Active filters display */}
|
{/* Active filters display */}
|
||||||
{selectedLabels.length > 0 && (
|
{!loading && selectedLabels.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{selectedLabels.map((label) => {
|
{selectedLabels.map((labelName: string) => (
|
||||||
const colorName = getLabelColor(label)
|
<LabelBadge
|
||||||
const colorClasses = LABEL_COLORS[colorName]
|
key={labelName}
|
||||||
|
label={labelName}
|
||||||
return (
|
variant="filter"
|
||||||
<Badge
|
onClick={() => handleToggleLabel(labelName)}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -15,8 +15,8 @@ import {
|
|||||||
import { Badge } from './ui/badge'
|
import { Badge } from './ui/badge'
|
||||||
import { Tag, X, Plus, Palette } from 'lucide-react'
|
import { Tag, X, Plus, Palette } from 'lucide-react'
|
||||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||||
import { getLabelColor, setLabelColor, deleteLabelColor, getAllLabelColors } from '@/lib/label-storage'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLabels, Label } from '@/context/LabelContext'
|
||||||
|
|
||||||
interface LabelManagerProps {
|
interface LabelManagerProps {
|
||||||
existingLabels: string[]
|
existingLabels: string[]
|
||||||
@ -24,30 +24,31 @@ interface LabelManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||||
|
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [newLabel, setNewLabel] = useState('')
|
const [newLabel, setNewLabel] = useState('')
|
||||||
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
|
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
|
||||||
const [allLabelsInStorage, setAllLabelsInStorage] = useState<string[]>([])
|
|
||||||
const [editingColor, setEditingColor] = useState<string | null>(null)
|
const [editingColor, setEditingColor] = useState<string | null>(null)
|
||||||
|
|
||||||
// Load all labels from localStorage
|
// Sync selected labels with existingLabels prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const allColors = getAllLabelColors()
|
setSelectedLabels(existingLabels)
|
||||||
setAllLabelsInStorage(Object.keys(allColors))
|
}, [existingLabels])
|
||||||
}, [open])
|
|
||||||
|
|
||||||
const handleAddLabel = () => {
|
const handleAddLabel = async () => {
|
||||||
const trimmed = newLabel.trim()
|
const trimmed = newLabel.trim()
|
||||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||||
const updated = [...selectedLabels, trimmed]
|
try {
|
||||||
setSelectedLabels(updated)
|
// Get existing label color or use random
|
||||||
setNewLabel('')
|
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)]
|
||||||
// Set default color if doesn't exist
|
|
||||||
if (getLabelColor(trimmed) === 'gray') {
|
await addLabel(trimmed, color)
|
||||||
const colors = Object.keys(LABEL_COLORS) as LabelColorName[]
|
const updated = [...selectedLabels, trimmed]
|
||||||
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
setSelectedLabels(updated)
|
||||||
setLabelColor(trimmed, randomColor)
|
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) => {
|
const handleChangeColor = async (label: string, color: LabelColorName) => {
|
||||||
setLabelColor(label, color)
|
const labelObj = labels.find(l => l.name === label)
|
||||||
setEditingColor(null)
|
if (labelObj) {
|
||||||
// Force re-render
|
try {
|
||||||
const allColors = getAllLabelColors()
|
await updateLabel(labelObj.id, { color })
|
||||||
setAllLabelsInStorage(Object.keys(allColors))
|
setEditingColor(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update label color:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@ -130,13 +135,13 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
|||||||
<h4 className="text-sm font-medium mb-2">Selected Labels</h4>
|
<h4 className="text-sm font-medium mb-2">Selected Labels</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{selectedLabels.map((label) => {
|
{selectedLabels.map((label) => {
|
||||||
const colorName = getLabelColor(label)
|
const labelObj = labels.find(l => l.name === label)
|
||||||
const colorClasses = LABEL_COLORS[colorName]
|
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
|
||||||
const isEditing = editingColor === label
|
const isEditing = editingColor === label
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label} className="relative">
|
<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="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">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||||
@ -147,7 +152,7 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||||
classes.bg,
|
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)}
|
onClick={() => handleChangeColor(label, color)}
|
||||||
title={color}
|
title={color}
|
||||||
@ -185,20 +190,19 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Available labels from storage */}
|
{/* Available labels from context */}
|
||||||
{allLabelsInStorage.length > 0 && (
|
{!loading && labels.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium mb-2">All Labels</h4>
|
<h4 className="text-sm font-medium mb-2">All Labels</h4>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{allLabelsInStorage
|
{labels
|
||||||
.filter(label => !selectedLabels.includes(label))
|
.filter(label => !selectedLabels.includes(label.name))
|
||||||
.map((label) => {
|
.map((label) => {
|
||||||
const colorName = getLabelColor(label)
|
const colorClasses = LABEL_COLORS[label.color]
|
||||||
const colorClasses = LABEL_COLORS[colorName]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
key={label}
|
key={label.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs border cursor-pointer',
|
'text-xs border cursor-pointer',
|
||||||
colorClasses.bg,
|
colorClasses.bg,
|
||||||
@ -206,9 +210,9 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
|||||||
colorClasses.border,
|
colorClasses.border,
|
||||||
'hover:opacity-80'
|
'hover:opacity-80'
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSelectExisting(label)}
|
onClick={() => handleSelectExisting(label.name)}
|
||||||
>
|
>
|
||||||
{label}
|
{label.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
74
keep-notes/components/label-selector.tsx
Normal file
74
keep-notes/components/label-selector.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,10 +1,9 @@
|
|||||||
'use client'
|
'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 { Card } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -22,13 +21,13 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Bell,
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import { fr } from 'date-fns/locale'
|
import { fr } from 'date-fns/locale'
|
||||||
import { MarkdownContent } from './markdown-content'
|
import { MarkdownContent } from './markdown-content'
|
||||||
import { getLabelColor } from '@/lib/label-storage'
|
import { LabelBadge } from './label-badge'
|
||||||
|
|
||||||
interface NoteCardProps {
|
interface NoteCardProps {
|
||||||
note: Note
|
note: Note
|
||||||
@ -39,20 +38,8 @@ interface NoteCardProps {
|
|||||||
|
|
||||||
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
const [labelColors, setLabelColors] = useState<Record<string, string>>({})
|
|
||||||
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
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 () => {
|
const handleDelete = async () => {
|
||||||
if (confirm('Are you sure you want to delete this note?')) {
|
if (confirm('Are you sure you want to delete this note?')) {
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
@ -232,23 +219,9 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
|
|||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{note.labels && note.labels.length > 0 && (
|
{note.labels && note.labels.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 mt-3">
|
<div className="flex flex-wrap gap-1 mt-3">
|
||||||
{note.labels.map((label) => {
|
{note.labels.map((label) => (
|
||||||
const colorName = labelColors[label] || 'gray'
|
<LabelBadge key={label} label={label} />
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react'
|
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -12,19 +12,18 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 { updateNote } from '@/app/actions/notes'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/toast'
|
import { useToast } from '@/components/ui/toast'
|
||||||
import { MarkdownContent } from './markdown-content'
|
import { MarkdownContent } from './markdown-content'
|
||||||
import { LabelManager } from './label-manager'
|
import { LabelManager } from './label-manager'
|
||||||
import { getLabelColor } from '@/lib/label-storage'
|
import { LabelBadge } from './label-badge'
|
||||||
|
|
||||||
interface NoteEditorProps {
|
interface NoteEditorProps {
|
||||||
note: Note
|
note: Note
|
||||||
@ -308,29 +307,13 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
|||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{labels.length > 0 && (
|
{labels.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{labels.map((label) => {
|
{labels.map((label) => (
|
||||||
const colorName = getLabelColor(label)
|
<LabelBadge
|
||||||
const colorClasses = LABEL_COLORS[colorName]
|
key={label}
|
||||||
return (
|
label={label}
|
||||||
<Badge
|
onRemove={() => handleRemoveLabel(label)}
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -5,14 +5,14 @@ import { Card } from '@/components/ui/card'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
X,
|
X,
|
||||||
Bell,
|
Bell,
|
||||||
Image,
|
Image,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Palette,
|
Palette,
|
||||||
Archive,
|
Archive,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Undo2,
|
Undo2,
|
||||||
Redo2,
|
Redo2,
|
||||||
@ -31,13 +31,14 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/toast'
|
import { useToast } from '@/components/ui/toast'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { MarkdownContent } from './markdown-content'
|
import { MarkdownContent } from './markdown-content'
|
||||||
|
import { LabelSelector } from './label-selector'
|
||||||
|
import { LabelBadge } from './label-badge'
|
||||||
|
|
||||||
interface HistoryState {
|
interface HistoryState {
|
||||||
title: string
|
title: string
|
||||||
@ -58,6 +59,7 @@ export function NoteInput() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [color, setColor] = useState<NoteColor>('default')
|
const [color, setColor] = useState<NoteColor>('default')
|
||||||
const [isArchived, setIsArchived] = useState(false)
|
const [isArchived, setIsArchived] = useState(false)
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// Simple state without complex undo/redo - like Google Keep
|
// Simple state without complex undo/redo - like Google Keep
|
||||||
@ -240,6 +242,7 @@ export function NoteInput() {
|
|||||||
images: images.length > 0 ? images : undefined,
|
images: images.length > 0 ? images : undefined,
|
||||||
reminder: currentReminder,
|
reminder: currentReminder,
|
||||||
isMarkdown,
|
isMarkdown,
|
||||||
|
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
@ -256,6 +259,7 @@ export function NoteInput() {
|
|||||||
setColor('default')
|
setColor('default')
|
||||||
setIsArchived(false)
|
setIsArchived(false)
|
||||||
setCurrentReminder(null)
|
setCurrentReminder(null)
|
||||||
|
setSelectedLabels([])
|
||||||
|
|
||||||
addToast('Note created successfully', 'success')
|
addToast('Note created successfully', 'success')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -288,12 +292,15 @@ export function NoteInput() {
|
|||||||
setContent('')
|
setContent('')
|
||||||
setCheckItems([])
|
setCheckItems([])
|
||||||
setImages([])
|
setImages([])
|
||||||
|
setIsMarkdown(false)
|
||||||
|
setShowMarkdownPreview(false)
|
||||||
setHistory([{ title: '', content: '' }])
|
setHistory([{ title: '', content: '' }])
|
||||||
setHistoryIndex(0)
|
setHistoryIndex(0)
|
||||||
setType('text')
|
setType('text')
|
||||||
setColor('default')
|
setColor('default')
|
||||||
setIsArchived(false)
|
setIsArchived(false)
|
||||||
setCurrentReminder(null)
|
setCurrentReminder(null)
|
||||||
|
setSelectedLabels([])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
@ -364,6 +371,16 @@ export function NoteInput() {
|
|||||||
|
|
||||||
{type === 'text' ? (
|
{type === 'text' ? (
|
||||||
<div className="space-y-2">
|
<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 */}
|
{/* Markdown toggle button */}
|
||||||
{isMarkdown && (
|
{isMarkdown && (
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
|
|||||||
133
keep-notes/context/LabelContext.tsx
Normal file
133
keep-notes/context/LabelContext.tsx
Normal 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
|
||||||
|
}
|
||||||
@ -4,6 +4,15 @@ export interface CheckItem {
|
|||||||
checked: boolean;
|
checked: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Label {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: LabelColorName;
|
||||||
|
userId?: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
|||||||
Binary file not shown.
@ -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");
|
||||||
@ -19,6 +19,7 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
notes Note[]
|
notes Note[]
|
||||||
|
labels Label[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@ -62,6 +63,18 @@ model VerificationToken {
|
|||||||
@@id([identifier, token])
|
@@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 {
|
model Note {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String?
|
title String?
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user