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

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>
)
}