feat: enhance list management UI with create, edit, and delete functionality

- Add ListForm component for creating and editing lists with color picker
- Add Modal component for reusable dialog functionality
- Add DeleteListButton with confirmation dialog
- Add NewListButton for creating new lists
- Update ListCard to show edit/delete buttons on hover
- Update home page to use new list creation flow
- All components include proper error handling and loading states

Requirements: 2.1, 2.2, 2.4, 2.5
This commit is contained in:
2025-12-01 19:18:54 +00:00
parent 282a0295ec
commit c1afb2cb6e
7 changed files with 402 additions and 31 deletions

View File

@@ -85,7 +85,7 @@
- **Property 1: Task creation preserves description** - **Property 1: Task creation preserves description**
- **Validates: Requirements 1.1** - **Validates: Requirements 1.1**
- [ ] 6. Enhance list management UI - [x] 6. Enhance list management UI
- Add ListForm component for creating and editing lists - Add ListForm component for creating and editing lists
- Implement list deletion with confirmation dialog - Implement list deletion with confirmation dialog
- Add list renaming functionality - Add list renaming functionality

View File

@@ -1,6 +1,6 @@
import { PlusIcon } from 'lucide-react'
import { getLists } from '@/lib/actions' import { getLists } from '@/lib/actions'
import ListCard from '@/components/ListCard' import ListCard from '@/components/ListCard'
import NewListButton from '@/components/NewListButton'
export default async function Home() { export default async function Home() {
const lists = await getLists() const lists = await getLists()
@@ -9,10 +9,7 @@ export default async function Home() {
<main className="container mx-auto px-4 py-8 max-w-4xl"> <main className="container mx-auto px-4 py-8 max-w-4xl">
<div className="flex items-center justify-between mb-8"> <div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold text-gray-900">Ultimate Lists</h1> <h1 className="text-3xl font-bold text-gray-900">Ultimate Lists</h1>
<button className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"> <NewListButton />
<PlusIcon size={20} />
New List
</button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">

View File

@@ -0,0 +1,88 @@
'use client'
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
import { deleteList } from '@/lib/actions'
import { useRouter } from 'next/navigation'
import Modal from './Modal'
interface DeleteListButtonProps {
listId: string
listTitle: string
itemCount: number
redirectAfterDelete?: boolean
}
export default function DeleteListButton({
listId,
listTitle,
itemCount,
redirectAfterDelete = false
}: DeleteListButtonProps) {
const router = useRouter()
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteList(listId)
setIsConfirmOpen(false)
if (redirectAfterDelete) {
router.push('/')
} else {
router.refresh()
}
} catch (err) {
console.error('Failed to delete list:', err)
alert('Failed to delete list. Please try again.')
} finally {
setIsDeleting(false)
}
}
return (
<>
<button
onClick={() => setIsConfirmOpen(true)}
className="text-red-600 hover:text-red-700 transition-colors p-2 hover:bg-red-50 rounded-lg"
aria-label="Delete list"
>
<Trash2 size={20} />
</button>
<Modal
isOpen={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
title="Delete List"
>
<div className="space-y-4">
<p className="text-gray-700">
Are you sure you want to delete <strong>{listTitle}</strong>?
</p>
{itemCount > 0 && (
<p className="text-red-600 text-sm">
This will permanently delete {itemCount} {itemCount === 1 ? 'item' : 'items'}.
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button
onClick={() => setIsConfirmOpen(false)}
disabled={isDeleting}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</Modal>
</>
)
}

View File

@@ -1,5 +1,11 @@
'use client'
import Link from 'next/link' import Link from 'next/link'
import { CheckCircle2, Circle } from 'lucide-react' import { useState } from 'react'
import { Edit2 } from 'lucide-react'
import Modal from './Modal'
import ListForm from './ListForm'
import DeleteListButton from './DeleteListButton'
interface List { interface List {
id: string id: string
@@ -12,38 +18,80 @@ interface List {
} }
export default function ListCard({ list }: { list: List }) { export default function ListCard({ list }: { list: List }) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
const completedCount = list.items.filter(item => item.completed).length const completedCount = list.items.filter(item => item.completed).length
const totalCount = list.items.length const totalCount = list.items.length
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0 const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
const handleEditClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
setIsEditModalOpen(true)
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
}
return ( return (
<Link href={`/list/${list.id}`}> <>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow cursor-pointer"> <Link href={`/list/${list.id}`}>
<div className="flex items-center gap-3 mb-4"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow cursor-pointer group">
<div <div className="flex items-center justify-between mb-4">
className="w-4 h-4 rounded-full" <div className="flex items-center gap-3 flex-1 min-w-0">
style={{ backgroundColor: list.color }} <div
/> className="w-4 h-4 rounded-full flex-shrink-0"
<h3 className="font-semibold text-gray-900 truncate">{list.title}</h3> style={{ backgroundColor: list.color }}
</div> />
<h3 className="font-semibold text-gray-900 truncate">{list.title}</h3>
<div className="space-y-3"> </div>
<div className="flex items-center justify-between text-sm text-gray-600"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={handleDeleteClick}>
<span>{completedCount} of {totalCount} completed</span> <button
<span>{Math.round(progress)}%</span> onClick={handleEditClick}
className="text-gray-600 hover:text-gray-900 transition-colors p-2 hover:bg-gray-100 rounded-lg"
aria-label="Edit list"
>
<Edit2 size={18} />
</button>
<DeleteListButton
listId={list.id}
listTitle={list.title}
itemCount={totalCount}
/>
</div>
</div> </div>
<div className="w-full bg-gray-200 rounded-full h-2"> <div className="space-y-3">
<div <div className="flex items-center justify-between text-sm text-gray-600">
className="h-2 rounded-full transition-all duration-300" <span>{completedCount} of {totalCount} completed</span>
style={{ <span>{Math.round(progress)}%</span>
width: `${progress}%`, </div>
backgroundColor: list.color
}} <div className="w-full bg-gray-200 rounded-full h-2">
/> <div
className="h-2 rounded-full transition-all duration-300"
style={{
width: `${progress}%`,
backgroundColor: list.color
}}
/>
</div>
</div> </div>
</div> </div>
</div> </Link>
</Link>
<Modal
isOpen={isEditModalOpen}
onClose={() => setIsEditModalOpen(false)}
title="Edit List"
>
<ListForm
list={list}
onCancel={() => setIsEditModalOpen(false)}
onSuccess={() => setIsEditModalOpen(false)}
/>
</Modal>
</>
) )
} }

134
components/ListForm.tsx Normal file
View File

@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { createList, updateList } from '@/lib/actions'
import { useRouter } from 'next/navigation'
interface ListFormProps {
list?: {
id: string
title: string
color: string
}
onCancel?: () => void
onSuccess?: () => void
}
const PRESET_COLORS = [
'#3B82F6', // blue
'#EF4444', // red
'#10B981', // green
'#F59E0B', // amber
'#8B5CF6', // purple
'#EC4899', // pink
'#06B6D4', // cyan
'#F97316', // orange
]
export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
const router = useRouter()
const [title, setTitle] = useState(list?.title || '')
const [color, setColor] = useState(list?.color || PRESET_COLORS[0])
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
setError('List title is required')
return
}
setIsSubmitting(true)
setError('')
try {
if (list) {
// Update existing list
await updateList(list.id, { title: title.trim(), color })
} else {
// Create new list
await createList(title.trim(), color)
}
if (onSuccess) {
onSuccess()
} else {
router.refresh()
}
} catch (err) {
setError('Failed to save list. Please try again.')
console.error(err)
} finally {
setIsSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
List Title
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter list title"
disabled={isSubmitting}
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Color
</label>
<div className="flex gap-2 flex-wrap">
{PRESET_COLORS.map((presetColor) => (
<button
key={presetColor}
type="button"
onClick={() => setColor(presetColor)}
className={`w-10 h-10 rounded-lg transition-all ${
color === presetColor
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
: 'hover:scale-105'
}`}
style={{ backgroundColor: presetColor }}
disabled={isSubmitting}
aria-label={`Select color ${presetColor}`}
/>
))}
</div>
</div>
{error && (
<div className="text-red-600 text-sm">{error}</div>
)}
<div className="flex gap-2 justify-end pt-2">
{onCancel && (
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="px-4 py-2 text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
)}
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Saving...' : list ? 'Update List' : 'Create List'}
</button>
</div>
</form>
)
}

71
components/Modal.tsx Normal file
View File

@@ -0,0 +1,71 @@
'use client'
import { useEffect } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: React.ReactNode
}
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'unset'
}
return () => {
document.body.style.overflow = 'unset'
}
}, [isOpen])
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
}
if (isOpen) {
document.addEventListener('keydown', handleEscape)
}
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen, onClose])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">{title}</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close modal"
>
<X size={24} />
</button>
</div>
<div className="p-4">
{children}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
'use client'
import { useState } from 'react'
import { PlusIcon } from 'lucide-react'
import Modal from './Modal'
import ListForm from './ListForm'
export default function NewListButton() {
const [isModalOpen, setIsModalOpen] = useState(false)
return (
<>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
>
<PlusIcon size={20} />
New List
</button>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
title="Create New List"
>
<ListForm
onCancel={() => setIsModalOpen(false)}
onSuccess={() => setIsModalOpen(false)}
/>
</Modal>
</>
)
}