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
|
||||
|
||||
### 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]
|
||||
|
||||
|
||||
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 prisma from '@/lib/prisma'
|
||||
|
||||
// GET /api/labels - Get all unique labels
|
||||
// GET /api/labels - Get all labels
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const notes = await prisma.note.findMany({
|
||||
select: { labels: true }
|
||||
})
|
||||
|
||||
const labelsSet = new Set<string>()
|
||||
notes.forEach(note => {
|
||||
const labels = note.labels ? JSON.parse(note.labels) : null
|
||||
if (labels) {
|
||||
labels.forEach((label: string) => labelsSet.add(label))
|
||||
}
|
||||
const labels = await prisma.label.findMany({
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: Array.from(labelsSet).sort()
|
||||
data: labels
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('GET /api/labels error:', error)
|
||||
@ -28,3 +20,48 @@ export async function GET(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/labels - Create a new label
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { name, color } = body
|
||||
|
||||
if (!name || typeof name !== 'string') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if label already exists
|
||||
const existing = await prisma.label.findUnique({
|
||||
where: { name: name.trim() }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Label already exists' },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const label = await prisma.label.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
color: color || 'gray'
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: label
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('POST /api/labels error:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to create label' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { HeaderWrapper } from "@/components/header-wrapper";
|
||||
import { ToastProvider } from "@/components/ui/toast";
|
||||
import { LabelProvider } from "@/context/LabelContext";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@ -22,8 +23,10 @@ export default function RootLayout({
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<ToastProvider>
|
||||
<HeaderWrapper />
|
||||
{children}
|
||||
<LabelProvider>
|
||||
<HeaderWrapper />
|
||||
{children}
|
||||
</LabelProvider>
|
||||
</ToastProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -6,17 +6,20 @@ import { Note } from '@/lib/types'
|
||||
import { getNotes, searchNotes } from '@/app/actions/notes'
|
||||
import { NoteInput } from '@/components/note-input'
|
||||
import { NoteGrid } from '@/components/note-grid'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
export default function HomePage() {
|
||||
const searchParams = useSearchParams()
|
||||
const [notes, setNotes] = useState<Note[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const { labels } = useLabels()
|
||||
|
||||
useEffect(() => {
|
||||
const loadNotes = async () => {
|
||||
setIsLoading(true)
|
||||
const search = searchParams.get('search')
|
||||
const labelFilter = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const colorFilter = searchParams.get('color')
|
||||
|
||||
let allNotes = search ? await searchNotes(search) : await getNotes()
|
||||
|
||||
@ -27,12 +30,23 @@ export default function HomePage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Filter by color (filter notes that have labels with this color)
|
||||
if (colorFilter) {
|
||||
const labelNamesWithColor = labels
|
||||
.filter(label => label.color === colorFilter)
|
||||
.map(label => label.name)
|
||||
|
||||
allNotes = allNotes.filter(note =>
|
||||
note.labels?.some(label => labelNamesWithColor.includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
setNotes(allNotes)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
loadNotes()
|
||||
}, [searchParams])
|
||||
}, [searchParams, labels])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
|
||||
@ -2,12 +2,19 @@
|
||||
|
||||
import { Header } from './header'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
|
||||
export function HeaderWrapper() {
|
||||
interface HeaderWrapperProps {
|
||||
onColorFilterChange?: (color: string | null) => void
|
||||
}
|
||||
|
||||
export function HeaderWrapper({ onColorFilterChange }: HeaderWrapperProps) {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const { labels } = useLabels()
|
||||
|
||||
const selectedLabels = searchParams.get('labels')?.split(',').filter(Boolean) || []
|
||||
const selectedColor = searchParams.get('color') || null
|
||||
|
||||
const handleLabelFilterChange = (labels: string[]) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
@ -21,5 +28,25 @@ export function HeaderWrapper() {
|
||||
router.push(`/?${params.toString()}`)
|
||||
}
|
||||
|
||||
return <Header selectedLabels={selectedLabels} onLabelFilterChange={handleLabelFilterChange} />
|
||||
const handleColorFilterChange = (color: string | null) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
||||
if (color) {
|
||||
params.set('color', color)
|
||||
} else {
|
||||
params.delete('color')
|
||||
}
|
||||
|
||||
router.push(`/?${params.toString()}`)
|
||||
onColorFilterChange?.(color)
|
||||
}
|
||||
|
||||
return (
|
||||
<Header
|
||||
selectedLabels={selectedLabels}
|
||||
selectedColor={selectedColor}
|
||||
onLabelFilterChange={handleLabelFilterChange}
|
||||
onColorFilterChange={handleColorFilterChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,10 +19,12 @@ import { LabelFilter } from './label-filter'
|
||||
|
||||
interface HeaderProps {
|
||||
selectedLabels?: string[]
|
||||
selectedColor?: string | null
|
||||
onLabelFilterChange?: (labels: string[]) => void
|
||||
onColorFilterChange?: (color: string | null) => void
|
||||
}
|
||||
|
||||
export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps = {}) {
|
||||
export function Header({ selectedLabels = [], selectedColor, onLabelFilterChange, onColorFilterChange }: HeaderProps = {}) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>('light')
|
||||
@ -105,7 +107,9 @@ export function Header({ selectedLabels = [], onLabelFilterChange }: HeaderProps
|
||||
{onLabelFilterChange && (
|
||||
<LabelFilter
|
||||
selectedLabels={selectedLabels}
|
||||
selectedColor={selectedColor}
|
||||
onFilterChange={onLabelFilterChange}
|
||||
onColorChange={onColorFilterChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
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'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Filter, X } from 'lucide-react'
|
||||
import { getAllLabelColors, getLabelColor } from '@/lib/label-storage'
|
||||
import { Filter } from 'lucide-react'
|
||||
import { LABEL_COLORS } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels } from '@/context/LabelContext'
|
||||
import { LabelBadge } from './label-badge'
|
||||
|
||||
interface LabelFilterProps {
|
||||
selectedLabels: string[]
|
||||
selectedColor?: string | null
|
||||
onFilterChange: (labels: string[]) => void
|
||||
onColorChange?: (color: string | null) => void
|
||||
}
|
||||
|
||||
export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps) {
|
||||
const [allLabels, setAllLabels] = useState<string[]>([])
|
||||
export function LabelFilter({ selectedLabels, selectedColor, onFilterChange, onColorChange }: LabelFilterProps) {
|
||||
const { labels, loading, getLabelColor } = useLabels()
|
||||
const [allLabelNames, setAllLabelNames] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
// Load all labels from localStorage
|
||||
const labelColors = getAllLabelColors()
|
||||
setAllLabels(Object.keys(labelColors).sort())
|
||||
}, [])
|
||||
// Extract label names from labels array
|
||||
setAllLabelNames(labels.map((l: any) => l.name).sort())
|
||||
}, [labels])
|
||||
|
||||
const handleToggleLabel = (label: string) => {
|
||||
if (selectedLabels.includes(label)) {
|
||||
onFilterChange(selectedLabels.filter(l => l !== label))
|
||||
onFilterChange(selectedLabels.filter((l: string) => l !== label))
|
||||
} else {
|
||||
onFilterChange([...selectedLabels, label])
|
||||
}
|
||||
@ -40,9 +43,18 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
||||
|
||||
const handleClearAll = () => {
|
||||
onFilterChange([])
|
||||
onColorChange?.(null)
|
||||
}
|
||||
|
||||
if (allLabels.length === 0) return null
|
||||
const handleColorFilter = (color: string) => {
|
||||
if (selectedColor === color) {
|
||||
onColorChange?.(null)
|
||||
} else {
|
||||
onColorChange?.(color)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading || allLabelNames.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
@ -58,9 +70,9 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuContent align="end" className="w-80">
|
||||
<DropdownMenuLabel className="flex items-center justify-between">
|
||||
Filter by Labels
|
||||
<span>Filter by Labels</span>
|
||||
{selectedLabels.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -73,27 +85,54 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{allLabels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
const isSelected = selectedLabels.includes(label)
|
||||
|
||||
{/* Color Filter */}
|
||||
<div className="p-2">
|
||||
<p className="text-xs font-medium mb-2 text-gray-600 dark:text-gray-400">Filter by Color</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(LABEL_COLORS).map(([colorName, colorClasses]) => {
|
||||
const isSelected = selectedColor === colorName
|
||||
const labelCount = labels.filter((l: any) => l.color === colorName).length
|
||||
|
||||
return (
|
||||
<button
|
||||
key={colorName}
|
||||
onClick={() => handleColorFilter(colorName)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-lg border-2 transition-all hover:scale-105',
|
||||
isSelected ? 'ring-2 ring-blue-500' : 'border-gray-300 dark:border-gray-600'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full border-2',
|
||||
colorClasses.bg,
|
||||
isSelected ? 'border-blue-500 dark:border-blue-400' : colorClasses.border
|
||||
)}
|
||||
/>
|
||||
<span className="text-xs ml-2">{labelCount}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Label Filters */}
|
||||
{!loading && allLabelNames.map((labelName: string) => {
|
||||
const isSelected = selectedLabels.includes(labelName)
|
||||
const isColorFiltered = selectedColor && selectedColor !== 'gray'
|
||||
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={label}
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleLabel(label)}
|
||||
key={labelName}
|
||||
checked={isSelected && !isColorFiltered}
|
||||
onCheckedChange={() => handleToggleLabel(labelName)}
|
||||
>
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-xs border mr-2',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
<LabelBadge
|
||||
label={labelName}
|
||||
isDisabled={!!isColorFiltered}
|
||||
/>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
@ -101,28 +140,16 @@ export function LabelFilter({ selectedLabels, onFilterChange }: LabelFilterProps
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Active filters display */}
|
||||
{selectedLabels.length > 0 && (
|
||||
{!loading && selectedLabels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedLabels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer pr-1',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
onClick={() => handleToggleLabel(label)}
|
||||
>
|
||||
{label}
|
||||
<X className="h-3 w-3 ml-1" />
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
{selectedLabels.map((labelName: string) => (
|
||||
<LabelBadge
|
||||
key={labelName}
|
||||
label={labelName}
|
||||
variant="filter"
|
||||
onClick={() => handleToggleLabel(labelName)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -15,8 +15,8 @@ import {
|
||||
import { Badge } from './ui/badge'
|
||||
import { Tag, X, Plus, Palette } from 'lucide-react'
|
||||
import { LABEL_COLORS, LabelColorName } from '@/lib/types'
|
||||
import { getLabelColor, setLabelColor, deleteLabelColor, getAllLabelColors } from '@/lib/label-storage'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLabels, Label } from '@/context/LabelContext'
|
||||
|
||||
interface LabelManagerProps {
|
||||
existingLabels: string[]
|
||||
@ -24,30 +24,31 @@ interface LabelManagerProps {
|
||||
}
|
||||
|
||||
export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
const { labels, loading, addLabel, updateLabel, deleteLabel, getLabelColor } = useLabels()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>(existingLabels)
|
||||
const [allLabelsInStorage, setAllLabelsInStorage] = useState<string[]>([])
|
||||
const [editingColor, setEditingColor] = useState<string | null>(null)
|
||||
|
||||
// Load all labels from localStorage
|
||||
// Sync selected labels with existingLabels prop
|
||||
useEffect(() => {
|
||||
const allColors = getAllLabelColors()
|
||||
setAllLabelsInStorage(Object.keys(allColors))
|
||||
}, [open])
|
||||
setSelectedLabels(existingLabels)
|
||||
}, [existingLabels])
|
||||
|
||||
const handleAddLabel = () => {
|
||||
const handleAddLabel = async () => {
|
||||
const trimmed = newLabel.trim()
|
||||
if (trimmed && !selectedLabels.includes(trimmed)) {
|
||||
const updated = [...selectedLabels, trimmed]
|
||||
setSelectedLabels(updated)
|
||||
setNewLabel('')
|
||||
|
||||
// Set default color if doesn't exist
|
||||
if (getLabelColor(trimmed) === 'gray') {
|
||||
const colors = Object.keys(LABEL_COLORS) as LabelColorName[]
|
||||
const randomColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
setLabelColor(trimmed, randomColor)
|
||||
try {
|
||||
// Get existing label color or use random
|
||||
const existingLabel = labels.find(l => l.name === trimmed)
|
||||
const color = existingLabel?.color || (Object.keys(LABEL_COLORS) as LabelColorName[])[Math.floor(Math.random() * Object.keys(LABEL_COLORS).length)]
|
||||
|
||||
await addLabel(trimmed, color)
|
||||
const updated = [...selectedLabels, trimmed]
|
||||
setSelectedLabels(updated)
|
||||
setNewLabel('')
|
||||
} catch (error) {
|
||||
console.error('Failed to add label:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -64,12 +65,16 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangeColor = (label: string, color: LabelColorName) => {
|
||||
setLabelColor(label, color)
|
||||
setEditingColor(null)
|
||||
// Force re-render
|
||||
const allColors = getAllLabelColors()
|
||||
setAllLabelsInStorage(Object.keys(allColors))
|
||||
const handleChangeColor = async (label: string, color: LabelColorName) => {
|
||||
const labelObj = labels.find(l => l.name === label)
|
||||
if (labelObj) {
|
||||
try {
|
||||
await updateLabel(labelObj.id, { color })
|
||||
setEditingColor(null)
|
||||
} catch (error) {
|
||||
console.error('Failed to update label color:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
@ -130,13 +135,13 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
<h4 className="text-sm font-medium mb-2">Selected Labels</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedLabels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
const labelObj = labels.find(l => l.name === label)
|
||||
const colorClasses = labelObj ? LABEL_COLORS[labelObj.color] : LABEL_COLORS.gray
|
||||
const isEditing = editingColor === label
|
||||
|
||||
return (
|
||||
<div key={label} className="relative">
|
||||
{isEditing ? (
|
||||
{isEditing && labelObj ? (
|
||||
<div className="absolute z-10 top-8 left-0 bg-white dark:bg-zinc-900 border rounded-lg shadow-lg p-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(LABEL_COLORS) as LabelColorName[]).map((color) => {
|
||||
@ -147,7 +152,7 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
className={cn(
|
||||
'h-8 w-8 rounded-full border-2 transition-transform hover:scale-110',
|
||||
classes.bg,
|
||||
colorName === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
||||
labelObj.color === color ? 'border-gray-900 dark:border-gray-100' : 'border-gray-300 dark:border-gray-600'
|
||||
)}
|
||||
onClick={() => handleChangeColor(label, color)}
|
||||
title={color}
|
||||
@ -185,20 +190,19 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available labels from storage */}
|
||||
{allLabelsInStorage.length > 0 && (
|
||||
{/* Available labels from context */}
|
||||
{!loading && labels.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">All Labels</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allLabelsInStorage
|
||||
.filter(label => !selectedLabels.includes(label))
|
||||
{labels
|
||||
.filter(label => !selectedLabels.includes(label.name))
|
||||
.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
const colorClasses = LABEL_COLORS[label.color]
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
key={label.id}
|
||||
className={cn(
|
||||
'text-xs border cursor-pointer',
|
||||
colorClasses.bg,
|
||||
@ -206,9 +210,9 @@ export function LabelManager({ existingLabels, onUpdate }: LabelManagerProps) {
|
||||
colorClasses.border,
|
||||
'hover:opacity-80'
|
||||
)}
|
||||
onClick={() => handleSelectExisting(label)}
|
||||
onClick={() => handleSelectExisting(label.name)}
|
||||
>
|
||||
{label}
|
||||
{label.name}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
|
||||
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'
|
||||
|
||||
import { Note, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
|
||||
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@ -22,13 +21,13 @@ import {
|
||||
Trash2,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { deleteNote, toggleArchive, togglePin, updateColor, updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { getLabelColor } from '@/lib/label-storage'
|
||||
import { LabelBadge } from './label-badge'
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note
|
||||
@ -39,20 +38,8 @@ interface NoteCardProps {
|
||||
|
||||
export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [labelColors, setLabelColors] = useState<Record<string, string>>({})
|
||||
const colorClasses = NOTE_COLORS[note.color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
// Load label colors from localStorage
|
||||
useEffect(() => {
|
||||
if (note.labels) {
|
||||
const colors: Record<string, string> = {}
|
||||
note.labels.forEach(label => {
|
||||
colors[label] = getLabelColor(label)
|
||||
})
|
||||
setLabelColors(colors)
|
||||
}
|
||||
}, [note.labels])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (confirm('Are you sure you want to delete this note?')) {
|
||||
setIsDeleting(true)
|
||||
@ -232,23 +219,9 @@ export function NoteCard({ note, onEdit, isDragging, isDragOver }: NoteCardProps
|
||||
{/* Labels */}
|
||||
{note.labels && note.labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-3">
|
||||
{note.labels.map((label) => {
|
||||
const colorName = labelColors[label] || 'gray'
|
||||
const colorClasses = LABEL_COLORS[colorName as keyof typeof LABEL_COLORS] || LABEL_COLORS.gray
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'text-xs border',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
{note.labels.map((label) => (
|
||||
<LabelBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, LABEL_COLORS } from '@/lib/types'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -12,19 +12,18 @@ import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { X, Plus, Palette, Tag, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
|
||||
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye } from 'lucide-react'
|
||||
import { updateNote } from '@/app/actions/notes'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelManager } from './label-manager'
|
||||
import { getLabelColor } from '@/lib/label-storage'
|
||||
import { LabelBadge } from './label-badge'
|
||||
|
||||
interface NoteEditorProps {
|
||||
note: Note
|
||||
@ -308,29 +307,13 @@ export function NoteEditor({ note, onClose }: NoteEditorProps) {
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{labels.map((label) => {
|
||||
const colorName = getLabelColor(label)
|
||||
const colorClasses = LABEL_COLORS[colorName]
|
||||
return (
|
||||
<Badge
|
||||
key={label}
|
||||
className={cn(
|
||||
'gap-1 border',
|
||||
colorClasses.bg,
|
||||
colorClasses.text,
|
||||
colorClasses.border
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<button
|
||||
onClick={() => handleRemoveLabel(label)}
|
||||
className="hover:text-red-600"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
{labels.map((label) => (
|
||||
<LabelBadge
|
||||
key={label}
|
||||
label={label}
|
||||
onRemove={() => handleRemoveLabel(label)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -5,14 +5,14 @@ import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CheckSquare,
|
||||
X,
|
||||
Bell,
|
||||
Image,
|
||||
UserPlus,
|
||||
Palette,
|
||||
Archive,
|
||||
import {
|
||||
CheckSquare,
|
||||
X,
|
||||
Bell,
|
||||
Image,
|
||||
UserPlus,
|
||||
Palette,
|
||||
Archive,
|
||||
MoreVertical,
|
||||
Undo2,
|
||||
Redo2,
|
||||
@ -31,13 +31,14 @@ import {
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useToast } from '@/components/ui/toast'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { MarkdownContent } from './markdown-content'
|
||||
import { LabelSelector } from './label-selector'
|
||||
import { LabelBadge } from './label-badge'
|
||||
|
||||
interface HistoryState {
|
||||
title: string
|
||||
@ -58,6 +59,7 @@ export function NoteInput() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [color, setColor] = useState<NoteColor>('default')
|
||||
const [isArchived, setIsArchived] = useState(false)
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Simple state without complex undo/redo - like Google Keep
|
||||
@ -240,6 +242,7 @@ export function NoteInput() {
|
||||
images: images.length > 0 ? images : undefined,
|
||||
reminder: currentReminder,
|
||||
isMarkdown,
|
||||
labels: selectedLabels.length > 0 ? selectedLabels : undefined,
|
||||
})
|
||||
|
||||
// Reset form
|
||||
@ -256,6 +259,7 @@ export function NoteInput() {
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
setSelectedLabels([])
|
||||
|
||||
addToast('Note created successfully', 'success')
|
||||
} catch (error) {
|
||||
@ -288,12 +292,15 @@ export function NoteInput() {
|
||||
setContent('')
|
||||
setCheckItems([])
|
||||
setImages([])
|
||||
setIsMarkdown(false)
|
||||
setShowMarkdownPreview(false)
|
||||
setHistory([{ title: '', content: '' }])
|
||||
setHistoryIndex(0)
|
||||
setType('text')
|
||||
setColor('default')
|
||||
setIsArchived(false)
|
||||
setCurrentReminder(null)
|
||||
setSelectedLabels([])
|
||||
}
|
||||
|
||||
if (!isExpanded) {
|
||||
@ -364,6 +371,16 @@ export function NoteInput() {
|
||||
|
||||
{type === 'text' ? (
|
||||
<div className="space-y-2">
|
||||
{/* Labels selector */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<LabelSelector
|
||||
selectedLabels={selectedLabels}
|
||||
onLabelsChange={setSelectedLabels}
|
||||
triggerLabel="Tags"
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Markdown toggle button */}
|
||||
{isMarkdown && (
|
||||
<div className="flex justify-end gap-2">
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
color: LabelColorName;
|
||||
userId?: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
title: string | null;
|
||||
|
||||
Binary file not shown.
@ -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[]
|
||||
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?
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user