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:
60
components/DeleteItemButton.tsx
Normal file
60
components/DeleteItemButton.tsx
Normal 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
163
components/ItemDetail.tsx
Normal 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
267
components/ItemForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
components/ToggleItemButton.tsx
Normal file
45
components/ToggleItemButton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user