feat: implement item detail page and form components

- Created ItemForm component with fields for all metadata (priority, due date, tags, notes)
- Created ItemDetail component to display all item information
- Built /list/[id]/item/[itemId] page for viewing full item details
- Built /list/[id]/item/[itemId]/edit page for editing items
- Added form validation for item inputs (title required, length limits)
- Created DeleteItemButton and ToggleItemButton helper components
- Enhanced createItem action to support full metadata on creation
- Added getItem action to fetch individual items with list data

Requirements: 1.1, 1.2, 5.1, 5.2, 5.3, 5.4
This commit is contained in:
2025-12-01 19:16:37 +00:00
parent 3aedfb0954
commit 282a0295ec
11 changed files with 1309 additions and 4 deletions

View File

@@ -0,0 +1,60 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { deleteItem } from '@/lib/actions'
import { Trash2 } from 'lucide-react'
interface DeleteItemButtonProps {
itemId: string
listId: string
}
export default function DeleteItemButton({ itemId, listId }: DeleteItemButtonProps) {
const router = useRouter()
const [isDeleting, setIsDeleting] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const handleDelete = async () => {
setIsDeleting(true)
try {
await deleteItem(itemId)
router.push(`/list/${listId}`)
} catch (error) {
console.error('Error deleting item:', error)
setIsDeleting(false)
setShowConfirm(false)
}
}
if (showConfirm) {
return (
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-3 py-2 bg-red-500 hover:bg-red-600 disabled:bg-red-300 text-white rounded-lg text-sm transition-colors"
>
{isDeleting ? 'Deleting...' : 'Confirm'}
</button>
<button
onClick={() => setShowConfirm(false)}
disabled={isDeleting}
className="px-3 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg text-sm transition-colors"
>
Cancel
</button>
</div>
)
}
return (
<button
onClick={() => setShowConfirm(true)}
className="flex items-center gap-2 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
>
<Trash2 size={18} />
<span>Delete</span>
</button>
)
}

163
components/ItemDetail.tsx Normal file
View File

@@ -0,0 +1,163 @@
import { Calendar, Tag, FileText, AlertCircle, CheckCircle2, Circle } from 'lucide-react'
import Link from 'next/link'
interface ItemDetailProps {
item: {
id: string
title: string
description: string | null
completed: boolean
priority: string
dueDate: Date | null
tags: any
notes: string | null
createdAt: Date
updatedAt: Date
listId: string
}
listTitle: string
}
export default function ItemDetail({ item, listTitle }: ItemDetailProps) {
const tags = Array.isArray(item.tags) ? item.tags : []
const isOverdue = item.dueDate && new Date(item.dueDate) < new Date() && !item.completed
const priorityColors = {
low: 'bg-green-100 text-green-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-red-100 text-red-800',
}
const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
return (
<div className="space-y-6">
{/* Header */}
<div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
<Link href={`/list/${item.listId}`} className="hover:text-blue-600">
{listTitle}
</Link>
<span>/</span>
<span>Task Details</span>
</div>
<div className="flex items-start gap-3">
<div className="pt-1">
{item.completed ? (
<CheckCircle2 className="text-green-500" size={24} />
) : (
<Circle className="text-gray-400" size={24} />
)}
</div>
<div className="flex-1">
<h1 className={`text-2xl font-bold ${item.completed ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{item.title}
</h1>
{item.completed && (
<p className="text-sm text-green-600 mt-1">Completed</p>
)}
</div>
</div>
</div>
{/* Metadata Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Priority */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-sm text-gray-600 mb-1">
<AlertCircle size={16} />
<span className="font-medium">Priority</span>
</div>
<span className={`inline-block px-3 py-1 rounded-full text-sm font-medium ${priorityColor}`}>
{item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}
</span>
</div>
{/* Due Date */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-sm text-gray-600 mb-1">
<Calendar size={16} />
<span className="font-medium">Due Date</span>
</div>
{item.dueDate ? (
<div>
<p className={`text-gray-900 ${isOverdue ? 'text-red-600 font-semibold' : ''}`}>
{new Date(item.dueDate).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
{isOverdue && (
<p className="text-sm text-red-600 mt-1">Overdue</p>
)}
</div>
) : (
<p className="text-gray-500">No due date</p>
)}
</div>
</div>
{/* Description */}
{item.description && (
<div className="bg-gray-50 rounded-lg p-4">
<h2 className="text-sm font-medium text-gray-600 mb-2">Description</h2>
<p className="text-gray-900 whitespace-pre-wrap">{item.description}</p>
</div>
)}
{/* Tags */}
{tags.length > 0 && (
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 mb-3">
<Tag size={16} />
<span>Tags</span>
</div>
<div className="flex flex-wrap gap-2">
{tags.map((tag: string) => (
<span
key={tag}
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</div>
)}
{/* Notes */}
{item.notes && (
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center gap-2 text-sm font-medium text-gray-600 mb-3">
<FileText size={16} />
<span>Notes</span>
</div>
<p className="text-gray-900 whitespace-pre-wrap">{item.notes}</p>
</div>
)}
{/* Timestamps */}
<div className="border-t pt-4 text-sm text-gray-500 space-y-1">
<p>
Created: {new Date(item.createdAt).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p>
Updated: {new Date(item.updatedAt).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
)
}

267
components/ItemForm.tsx Normal file
View File

@@ -0,0 +1,267 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { updateItem, createItem } from '@/lib/actions'
interface ItemFormProps {
listId: string
item?: {
id: string
title: string
description: string | null
priority: string
dueDate: Date | null
tags: any
notes: string | null
}
mode?: 'create' | 'edit'
}
export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
// Form state
const [title, setTitle] = useState(item?.title || '')
const [description, setDescription] = useState(item?.description || '')
const [priority, setPriority] = useState<'low' | 'medium' | 'high'>(
(item?.priority as 'low' | 'medium' | 'high') || 'medium'
)
const [dueDate, setDueDate] = useState(
item?.dueDate ? new Date(item.dueDate).toISOString().split('T')[0] : ''
)
const [tags, setTags] = useState<string[]>(
Array.isArray(item?.tags) ? item.tags : []
)
const [tagInput, setTagInput] = useState('')
const [notes, setNotes] = useState(item?.notes || '')
const validateForm = () => {
const newErrors: Record<string, string> = {}
if (!title.trim()) {
newErrors.title = 'Title is required'
}
if (title.length > 200) {
newErrors.title = 'Title must be less than 200 characters'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validateForm()) {
return
}
setIsSubmitting(true)
try {
const data = {
title: title.trim(),
description: description.trim() || null,
priority,
dueDate: dueDate ? new Date(dueDate) : null,
tags,
notes: notes.trim() || null,
}
if (mode === 'create') {
await createItem(listId, data.title, data.description || undefined, {
priority: data.priority,
dueDate: data.dueDate,
tags: data.tags,
notes: data.notes,
})
router.push(`/list/${listId}`)
} else if (item) {
await updateItem(item.id, data)
router.push(`/list/${listId}/item/${item.id}`)
}
} catch (error) {
console.error('Error saving item:', error)
setErrors({ submit: 'Failed to save item. Please try again.' })
} finally {
setIsSubmitting(false)
}
}
const handleAddTag = () => {
const tag = tagInput.trim()
if (tag && !tags.includes(tag)) {
setTags([...tags, tag])
setTagInput('')
}
}
const handleRemoveTag = (tagToRemove: string) => {
setTags(tags.filter((tag) => tag !== tagToRemove))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddTag()
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Title *
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.title ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter task title"
/>
{errors.title && (
<p className="mt-1 text-sm text-red-600">{errors.title}</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a description"
/>
</div>
{/* Priority */}
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-1">
Priority
</label>
<select
id="priority"
value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
{/* Due Date */}
<div>
<label htmlFor="dueDate" className="block text-sm font-medium text-gray-700 mb-1">
Due Date
</label>
<input
type="date"
id="dueDate"
value={dueDate}
onChange={(e) => setDueDate(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"
/>
</div>
{/* Tags */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-1">
Tags
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add a tag"
/>
<button
type="button"
onClick={handleAddTag}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors"
>
Add
</button>
</div>
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={5}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add additional notes"
/>
</div>
{/* Error message */}
{errors.submit && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{errors.submit}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300 text-white px-4 py-2 rounded-lg transition-colors"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
</button>
<button
type="button"
onClick={() => router.back()}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { useState } from 'react'
import { toggleItem } from '@/lib/actions'
import { CheckCircle2, Circle } from 'lucide-react'
interface ToggleItemButtonProps {
itemId: string
completed: boolean
}
export default function ToggleItemButton({ itemId, completed }: ToggleItemButtonProps) {
const [isToggling, setIsToggling] = useState(false)
const handleToggle = async () => {
setIsToggling(true)
try {
await toggleItem(itemId)
} catch (error) {
console.error('Error toggling item:', error)
} finally {
setIsToggling(false)
}
}
return (
<button
onClick={handleToggle}
disabled={isToggling}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 disabled:bg-gray-50 text-gray-700 rounded-lg transition-colors"
>
{completed ? (
<>
<CheckCircle2 size={18} className="text-green-500" />
<span>Mark Incomplete</span>
</>
) : (
<>
<Circle size={18} />
<span>Mark Complete</span>
</>
)}
</button>
)
}