- Changed all input/textarea/select font-size from text-sm to text-base (16px minimum) - Added blur handler to reset viewport zoom on InlineItemCreate - Prevents iOS Safari from auto-zooming when focusing on form fields - Ensures zoom resets properly when closing forms
481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { updateItem, createItem } from '@/lib/actions'
|
||
import { useToast } from './ToastContainer'
|
||
import {
|
||
validateItemTitle,
|
||
validateDescription,
|
||
validateDateString,
|
||
validateTag,
|
||
validateTags,
|
||
validateNotes,
|
||
sanitizeString,
|
||
} from '@/lib/validation'
|
||
|
||
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'
|
||
canEdit?: boolean
|
||
}
|
||
|
||
export default function ItemForm({ listId, item, mode = 'edit', canEdit = true }: ItemFormProps) {
|
||
const router = useRouter()
|
||
const { showToast } = useToast()
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||
const [touched, setTouched] = useState<Record<string, boolean>>({})
|
||
|
||
// 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 || '')
|
||
|
||
// Inline validation for title
|
||
useEffect(() => {
|
||
if (touched.title) {
|
||
const result = validateItemTitle(title)
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
title: result.isValid ? '' : result.error || '',
|
||
}))
|
||
}
|
||
}, [title, touched.title])
|
||
|
||
// Inline validation for description
|
||
useEffect(() => {
|
||
if (touched.description) {
|
||
const result = validateDescription(description)
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
description: result.isValid ? '' : result.error || '',
|
||
}))
|
||
}
|
||
}, [description, touched.description])
|
||
|
||
// Inline validation for due date
|
||
useEffect(() => {
|
||
if (touched.dueDate && dueDate) {
|
||
const result = validateDateString(dueDate)
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
dueDate: result.isValid ? '' : result.error || '',
|
||
}))
|
||
} else if (touched.dueDate && !dueDate) {
|
||
setErrors((prev) => ({ ...prev, dueDate: '' }))
|
||
}
|
||
}, [dueDate, touched.dueDate])
|
||
|
||
// Inline validation for notes
|
||
useEffect(() => {
|
||
if (touched.notes) {
|
||
const result = validateNotes(notes)
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
notes: result.isValid ? '' : result.error || '',
|
||
}))
|
||
}
|
||
}, [notes, touched.notes])
|
||
|
||
// Validate tags whenever they change
|
||
useEffect(() => {
|
||
const result = validateTags(tags)
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
tags: result.isValid ? '' : result.error || '',
|
||
}))
|
||
}, [tags])
|
||
|
||
const handleBlur = (field: string) => {
|
||
setTouched((prev) => ({ ...prev, [field]: true }))
|
||
}
|
||
|
||
const validateForm = () => {
|
||
const newErrors: Record<string, string> = {}
|
||
|
||
// Validate title
|
||
const titleResult = validateItemTitle(title)
|
||
if (!titleResult.isValid) {
|
||
newErrors.title = titleResult.error || ''
|
||
}
|
||
|
||
// Validate description
|
||
const descResult = validateDescription(description)
|
||
if (!descResult.isValid) {
|
||
newErrors.description = descResult.error || ''
|
||
}
|
||
|
||
// Validate due date
|
||
if (dueDate) {
|
||
const dateResult = validateDateString(dueDate)
|
||
if (!dateResult.isValid) {
|
||
newErrors.dueDate = dateResult.error || ''
|
||
}
|
||
}
|
||
|
||
// Validate tags
|
||
const tagsResult = validateTags(tags)
|
||
if (!tagsResult.isValid) {
|
||
newErrors.tags = tagsResult.error || ''
|
||
}
|
||
|
||
// Validate notes
|
||
const notesResult = validateNotes(notes)
|
||
if (!notesResult.isValid) {
|
||
newErrors.notes = notesResult.error || ''
|
||
}
|
||
|
||
setErrors(newErrors)
|
||
setTouched({
|
||
title: true,
|
||
description: true,
|
||
dueDate: true,
|
||
notes: true,
|
||
})
|
||
return Object.keys(newErrors).length === 0
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
if (!canEdit) {
|
||
showToast('You do not have permission to edit this item', 'error')
|
||
return
|
||
}
|
||
|
||
if (!validateForm()) {
|
||
showToast('Please fix validation errors', 'error')
|
||
return
|
||
}
|
||
|
||
setIsSubmitting(true)
|
||
|
||
try {
|
||
const data = {
|
||
title: sanitizeString(title),
|
||
description: sanitizeString(description) || null,
|
||
priority,
|
||
dueDate: dueDate ? new Date(dueDate) : null,
|
||
tags: tags.map(sanitizeString),
|
||
notes: sanitizeString(notes) || 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)
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to save item. Please try again.'
|
||
setErrors({ submit: errorMessage })
|
||
showToast(errorMessage, 'error')
|
||
} finally {
|
||
setIsSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const handleAddTag = () => {
|
||
const tag = sanitizeString(tagInput)
|
||
|
||
// Validate tag
|
||
const tagResult = validateTag(tag)
|
||
if (!tagResult.isValid) {
|
||
setErrors((prev) => ({ ...prev, tagInput: tagResult.error || '' }))
|
||
return
|
||
}
|
||
|
||
// Check for duplicates
|
||
if (tags.includes(tag)) {
|
||
setErrors((prev) => ({ ...prev, tagInput: 'Tag already exists' }))
|
||
return
|
||
}
|
||
|
||
// Check max tags
|
||
if (tags.length >= 20) {
|
||
setErrors((prev) => ({ ...prev, tagInput: 'Maximum 20 tags allowed' }))
|
||
return
|
||
}
|
||
|
||
setTags([...tags, tag])
|
||
setTagInput('')
|
||
setErrors((prev) => ({ ...prev, tagInput: '' }))
|
||
}
|
||
|
||
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">
|
||
{!canEdit && (
|
||
<div className="p-3 sm:p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||
<p className="text-xs sm:text-sm text-yellow-800">
|
||
You have view-only access to this list. You cannot edit items.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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)}
|
||
onBlur={() => handleBlur('title')}
|
||
disabled={!canEdit}
|
||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
|
||
errors.title && touched.title
|
||
? 'border-red-500 focus:ring-red-500'
|
||
: 'border-gray-300 focus:ring-blue-500'
|
||
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||
placeholder="Enter task title"
|
||
maxLength={200}
|
||
aria-invalid={errors.title && touched.title ? 'true' : 'false'}
|
||
aria-describedby={errors.title && touched.title ? 'title-error' : undefined}
|
||
/>
|
||
{errors.title && touched.title && (
|
||
<p id="title-error" 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)}
|
||
onBlur={() => handleBlur('description')}
|
||
disabled={!canEdit}
|
||
rows={3}
|
||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
|
||
errors.description && touched.description
|
||
? 'border-red-500 focus:ring-red-500'
|
||
: 'border-gray-300 focus:ring-blue-500'
|
||
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||
placeholder="Add a description"
|
||
maxLength={2000}
|
||
aria-invalid={errors.description && touched.description ? 'true' : 'false'}
|
||
aria-describedby={errors.description && touched.description ? 'description-error' : undefined}
|
||
/>
|
||
{errors.description && touched.description && (
|
||
<p id="description-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
|
||
{errors.description}
|
||
</p>
|
||
)}
|
||
</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')}
|
||
disabled={!canEdit}
|
||
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-base tap-target ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||
>
|
||
<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)}
|
||
onBlur={() => handleBlur('dueDate')}
|
||
disabled={!canEdit}
|
||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base tap-target ${
|
||
errors.dueDate && touched.dueDate
|
||
? 'border-red-500 focus:ring-red-500'
|
||
: 'border-gray-300 focus:ring-blue-500'
|
||
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||
min="1900-01-01"
|
||
max="2100-12-31"
|
||
aria-invalid={errors.dueDate && touched.dueDate ? 'true' : 'false'}
|
||
aria-describedby={errors.dueDate && touched.dueDate ? 'dueDate-error' : undefined}
|
||
/>
|
||
{errors.dueDate && touched.dueDate && (
|
||
<p id="dueDate-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
|
||
{errors.dueDate}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Tags */}
|
||
<div>
|
||
<label htmlFor="tags" className="block text-sm font-medium text-gray-700 mb-2">
|
||
Tags {tags.length > 0 && <span className="text-gray-500">({tags.length}/20)</span>}
|
||
</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}
|
||
disabled={!canEdit}
|
||
className={`flex-1 px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base ${
|
||
errors.tagInput
|
||
? 'border-red-500 focus:ring-red-500'
|
||
: 'border-gray-300 focus:ring-blue-500'
|
||
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||
placeholder="Add a tag"
|
||
maxLength={50}
|
||
aria-invalid={errors.tagInput ? 'true' : 'false'}
|
||
aria-describedby={errors.tagInput ? 'tagInput-error' : undefined}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={handleAddTag}
|
||
disabled={tags.length >= 20 || !canEdit}
|
||
className="px-4 sm:px-5 py-3 bg-gray-200 hover:bg-gray-300 active:bg-gray-400 disabled:bg-gray-100 disabled:text-gray-400 text-gray-700 rounded-lg transition-colors tap-target text-sm sm:text-base font-medium"
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
{errors.tagInput && (
|
||
<p id="tagInput-error" className="mb-2 text-xs sm:text-sm text-red-600">
|
||
{errors.tagInput}
|
||
</p>
|
||
)}
|
||
{errors.tags && (
|
||
<p className="mb-2 text-xs sm:text-sm text-red-600">{errors.tags}</p>
|
||
)}
|
||
{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)}
|
||
disabled={!canEdit}
|
||
className="hover:text-blue-600 active:text-blue-700 tap-target-sm text-lg leading-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||
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)}
|
||
onBlur={() => handleBlur('notes')}
|
||
disabled={!canEdit}
|
||
rows={5}
|
||
className={`w-full px-3 sm:px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 text-base resize-y ${
|
||
errors.notes && touched.notes
|
||
? 'border-red-500 focus:ring-red-500'
|
||
: 'border-gray-300 focus:ring-blue-500'
|
||
} ${!canEdit ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
||
placeholder="Add additional notes"
|
||
maxLength={5000}
|
||
aria-invalid={errors.notes && touched.notes ? 'true' : 'false'}
|
||
aria-describedby={errors.notes && touched.notes ? 'notes-error' : undefined}
|
||
/>
|
||
{errors.notes && touched.notes && (
|
||
<p id="notes-error" className="mt-1.5 text-xs sm:text-sm text-red-600">
|
||
{errors.notes}
|
||
</p>
|
||
)}
|
||
</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"
|
||
>
|
||
{canEdit ? 'Cancel' : 'Back'}
|
||
</button>
|
||
{canEdit && (
|
||
<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>
|
||
)
|
||
}
|