Files
Todo/components/ItemForm.tsx
Paul Bailey e51e88fddd feat: Add visual feedback and polish (Task 15)
- Implement toast notification system for success/error messages
- Add loading states with skeleton loaders for async operations
- Create smooth CSS transitions and animations
- Enhance empty states with EmptyState component
- Add loading.tsx files for Next.js loading states
- Integrate toast notifications across all CRUD operations
- Add fade-in and scale-in animations for better UX
- Improve visual feedback for user actions
2025-12-01 21:14:14 +00:00

274 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { updateItem, createItem } from '@/lib/actions'
import { useToast } from './ToastContainer'
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 { showToast } = useToast()
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,
})
showToast('Task created successfully', 'success')
router.push(`/list/${listId}`)
} else if (item) {
await updateItem(item.id, data)
showToast('Task updated successfully', 'success')
router.push(`/list/${listId}/item/${item.id}`)
}
} catch (error) {
console.error('Error saving item:', error)
setErrors({ submit: 'Failed to save item. Please try again.' })
showToast('Failed to save task', 'error')
} 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-5 sm:space-y-6">
{/* Title */}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
Title *
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base ${
errors.title ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="Enter task title"
/>
{errors.title && (
<p className="mt-1.5 text-xs sm:text-sm text-red-600">{errors.title}</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base resize-y"
placeholder="Add a description"
/>
</div>
{/* Priority */}
<div>
<label htmlFor="priority" className="block text-sm font-medium text-gray-700 mb-2">
Priority
</label>
<select
id="priority"
value={priority}
onChange={(e) => setPriority(e.target.value as 'low' | 'medium' | 'high')}
className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
>
<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-2">
Due Date
</label>
<input
type="date"
id="dueDate"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base tap-target"
/>
</div>
{/* Tags */}
<div>
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-2">
Tags
</label>
<div className="flex gap-2 mb-2 sm:mb-3">
<input
type="text"
id="tags"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base"
placeholder="Add a tag"
/>
<button
type="button"
onClick={handleAddTag}
className="px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
>
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.5 px-3 py-1.5 bg-blue-100 text-blue-800 rounded-full text-xs sm:text-sm"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none"
aria-label={`Remove ${tag} tag`}
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Notes */}
<div>
<label htmlFor="notes" className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={5}
className="w-full px-3 sm:px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm sm:text-base resize-y"
placeholder="Add additional notes"
/>
</div>
{/* Error message */}
{errors.submit && (
<div className="p-3 sm:p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs sm:text-sm text-red-600">{errors.submit}</p>
</div>
)}
{/* Actions */}
<div className="flex flex-col-reverse sm:flex-row gap-2 sm:gap-3 pt-2">
<button
type="button"
onClick={() => router.back()}
className="w-full sm:w-auto px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 text-gray-700 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 disabled:bg-blue-300 text-white px-4 sm:px-5 py-3 rounded-lg transition-colors tap-target font-medium text-sm sm:text-base"
>
{isSubmitting ? 'Saving...' : mode === 'create' ? 'Create Task' : 'Save Changes'}
</button>
</div>
</form>
)
}