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
This commit is contained in:
2025-12-01 21:14:14 +00:00
parent a037117b15
commit e51e88fddd
20 changed files with 432 additions and 33 deletions

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { deleteItem } from '@/lib/actions'
import { Trash2 } from 'lucide-react'
import { useToast } from './ToastContainer'
interface DeleteItemButtonProps {
itemId: string
@@ -13,6 +14,7 @@ interface DeleteItemButtonProps {
export default function DeleteItemButton({ itemId, listId, variant = 'full' }: DeleteItemButtonProps) {
const router = useRouter()
const { showToast } = useToast()
const [isDeleting, setIsDeleting] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
@@ -22,6 +24,7 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
setIsDeleting(true)
try {
await deleteItem(itemId)
showToast('Task deleted successfully', 'success')
if (listId) {
router.push(`/list/${listId}`)
} else {
@@ -29,6 +32,7 @@ export default function DeleteItemButton({ itemId, listId, variant = 'full' }: D
}
} catch (error) {
console.error('Error deleting item:', error)
showToast('Failed to delete task', 'error')
setIsDeleting(false)
setShowConfirm(false)
}

View File

@@ -5,6 +5,7 @@ import { Trash2 } from 'lucide-react'
import { deleteList } from '@/lib/actions'
import { useRouter } from 'next/navigation'
import Modal from './Modal'
import { useToast } from './ToastContainer'
interface DeleteListButtonProps {
listId: string
@@ -20,6 +21,7 @@ export default function DeleteListButton({
redirectAfterDelete = false
}: DeleteListButtonProps) {
const router = useRouter()
const { showToast } = useToast()
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
@@ -27,6 +29,7 @@ export default function DeleteListButton({
setIsDeleting(true)
try {
await deleteList(listId)
showToast('List deleted successfully', 'success')
setIsConfirmOpen(false)
if (redirectAfterDelete) {
router.push('/')
@@ -35,7 +38,7 @@ export default function DeleteListButton({
}
} catch (err) {
console.error('Failed to delete list:', err)
alert('Failed to delete list. Please try again.')
showToast('Failed to delete list', 'error')
} finally {
setIsDeleting(false)
}

41
components/EmptyState.tsx Normal file
View File

@@ -0,0 +1,41 @@
'use client'
import { ReactNode } from 'react'
import { Inbox, ListTodo, Search, Filter } from 'lucide-react'
interface EmptyStateProps {
icon?: 'inbox' | 'list' | 'search' | 'filter'
title: string
description?: string
action?: ReactNode
}
export default function EmptyState({ icon = 'inbox', title, description, action }: EmptyStateProps) {
const icons = {
inbox: <Inbox size={48} className="text-gray-300" />,
list: <ListTodo size={48} className="text-gray-300" />,
search: <Search size={48} className="text-gray-300" />,
filter: <Filter size={48} className="text-gray-300" />,
}
return (
<div className="flex flex-col items-center justify-center py-12 sm:py-16 px-4 text-center">
<div className="mb-4 sm:mb-6 opacity-50">
{icons[icon]}
</div>
<h3 className="text-lg sm:text-xl font-semibold text-gray-700 mb-2">
{title}
</h3>
{description && (
<p className="text-sm sm:text-base text-gray-500 mb-6 max-w-md">
{description}
</p>
)}
{action && (
<div className="mt-4">
{action}
</div>
)}
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createItem } from '@/lib/actions'
import { Plus } from 'lucide-react'
import { useToast } from './ToastContainer'
interface InlineItemCreateProps {
listId: string
@@ -11,6 +12,7 @@ interface InlineItemCreateProps {
export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
const router = useRouter()
const { showToast } = useToast()
const [isCreating, setIsCreating] = useState(false)
const [title, setTitle] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -26,11 +28,13 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
try {
await createItem(listId, title.trim())
showToast('Task added successfully', 'success')
setTitle('')
setIsCreating(false)
router.refresh()
} catch (error) {
console.error('Error creating item:', error)
showToast('Failed to add task', 'error')
} finally {
setIsSubmitting(false)
}

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { updateItem, createItem } from '@/lib/actions'
import { useToast } from './ToastContainer'
interface ItemFormProps {
listId: string
@@ -20,6 +21,7 @@ interface ItemFormProps {
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>>({})
@@ -79,14 +81,17 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
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)
}

View File

@@ -5,6 +5,7 @@ import ItemCard from './ItemCard'
import SearchBar from './SearchBar'
import FilterPanel from './FilterPanel'
import SearchResults from './SearchResults'
import EmptyState from './EmptyState'
import { searchItems, getItemsByFilter } from '@/lib/actions'
import { useSearch, useFilters, useScrollPosition } from '@/hooks'
@@ -138,7 +139,7 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
<div className="space-y-6">
{/* Incomplete Items */}
{incompleteItems.length > 0 && (
<div>
<div className="animate-fade-in">
<h2 className="text-lg font-semibold text-gray-900 mb-3">
To Do ({incompleteItems.length})
</h2>
@@ -152,7 +153,7 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
{/* Completed Items */}
{completedItems.length > 0 && (
<div>
<div className="animate-fade-in">
<h2 className="text-lg font-semibold text-gray-500 mb-3">
Completed ({completedItems.length})
</h2>
@@ -166,14 +167,15 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP
{/* Empty State */}
{displayItems.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<p className="text-gray-500 text-lg mb-2">No tasks found</p>
<p className="text-gray-400">
{filters.priority || filters.completed !== null || filters.tags.length > 0
? 'Try adjusting your filters'
: 'Add your first task to get started'}
</p>
</div>
<EmptyState
icon={filters.priority || filters.completed !== null || filters.tags.length > 0 ? 'filter' : 'inbox'}
title="No tasks found"
description={
filters.priority || filters.completed !== null || filters.tags.length > 0
? 'Try adjusting your filters to see more tasks'
: 'Add your first task using the button above'
}
/>
)}
</div>
)}

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { createList, updateList } from '@/lib/actions'
import { useRouter } from 'next/navigation'
import { useToast } from './ToastContainer'
interface ListFormProps {
list?: {
@@ -27,6 +28,7 @@ const PRESET_COLORS = [
export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
const router = useRouter()
const { showToast } = useToast()
const [title, setTitle] = useState(list?.title || '')
const [color, setColor] = useState(list?.color || PRESET_COLORS[0])
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -47,9 +49,11 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
if (list) {
// Update existing list
await updateList(list.id, { title: title.trim(), color })
showToast('List updated successfully', 'success')
} else {
// Create new list
await createList(title.trim(), color)
showToast('List created successfully', 'success')
}
if (onSuccess) {
@@ -59,6 +63,7 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
}
} catch (err) {
setError('Failed to save list. Please try again.')
showToast('Failed to save list', 'error')
console.error(err)
} finally {
setIsSubmitting(false)

View File

@@ -0,0 +1,33 @@
'use client'
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg'
className?: string
}
export default function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'w-4 h-4 border-2',
md: 'w-6 h-6 border-2',
lg: 'w-8 h-8 border-3',
}
return (
<div
className={`${sizeClasses[size]} border-gray-300 border-t-blue-500 rounded-full animate-spin ${className}`}
role="status"
aria-label="Loading"
/>
)
}
export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 backdrop-blur-sm">
<div className="bg-white rounded-lg p-6 sm:p-8 shadow-xl flex flex-col items-center gap-4">
<LoadingSpinner size="lg" />
<p className="text-gray-700 font-medium text-sm sm:text-base">{message}</p>
</div>
</div>
)
}

View File

@@ -42,15 +42,15 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps)
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center animate-fade-in">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity"
className="absolute inset-0 bg-black bg-opacity-50 transition-opacity backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform">
<div className="relative bg-white rounded-t-2xl sm:rounded-lg shadow-xl max-w-md w-full mx-0 sm:mx-4 max-h-[90vh] overflow-y-auto transform transition-transform animate-scale-in">
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-gray-200 sticky top-0 bg-white z-10">
<h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
<button

View File

@@ -3,6 +3,7 @@
import { highlightText } from '@/lib/utils'
import Link from 'next/link'
import { Calendar, Tag, AlertCircle } from 'lucide-react'
import EmptyState from './EmptyState'
interface SearchResultsProps {
results: Array<{
@@ -31,10 +32,11 @@ export default function SearchResults({ results, query, onClose }: SearchResults
if (results.length === 0) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center">
<p className="text-gray-500">No tasks found matching "{query}"</p>
<p className="text-sm text-gray-400 mt-2">Try a different search term</p>
</div>
<EmptyState
icon="search"
title={`No tasks found matching "${query}"`}
description="Try a different search term or check your spelling"
/>
)
}
@@ -45,7 +47,7 @@ export default function SearchResults({ results, query, onClose }: SearchResults
}
return (
<div className="space-y-2">
<div className="space-y-2 animate-fade-in">
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-600">
Found {results.length} {results.length === 1 ? 'task' : 'tasks'}

View File

@@ -0,0 +1,76 @@
'use client'
interface SkeletonProps {
className?: string
}
export function Skeleton({ className = '' }: SkeletonProps) {
return (
<div className={`animate-pulse bg-gray-200 rounded ${className}`} />
)
}
export function ListCardSkeleton() {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-5 sm:p-6 min-h-[140px] sm:min-h-[160px]">
<div className="flex items-start justify-between mb-4 gap-2">
<div className="flex items-center gap-2 sm:gap-3 flex-1">
<Skeleton className="w-3 h-3 sm:w-4 sm:h-4 rounded-full" />
<Skeleton className="h-6 w-32 sm:w-40" />
</div>
</div>
<div className="space-y-2.5 sm:space-y-3">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-8" />
</div>
<Skeleton className="h-2 sm:h-2.5 w-full rounded-full" />
</div>
</div>
)
}
export function ItemCardSkeleton() {
return (
<div className="bg-white rounded-lg border border-gray-200 p-4 sm:p-5">
<div className="flex items-start gap-3 sm:gap-4">
<Skeleton className="w-5 h-5 sm:w-6 sm:h-6 rounded-full flex-shrink-0 mt-0.5 sm:mt-1" />
<div className="flex-1 min-w-0 space-y-2">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mt-2">
<Skeleton className="h-6 w-16 rounded" />
<Skeleton className="h-6 w-20 rounded" />
<Skeleton className="h-6 w-24 rounded" />
</div>
</div>
<Skeleton className="w-5 h-5 rounded flex-shrink-0" />
</div>
</div>
)
}
export function ListDetailSkeleton() {
return (
<div className="space-y-4">
<ItemCardSkeleton />
<ItemCardSkeleton />
<ItemCardSkeleton />
</div>
)
}
export function SearchResultsSkeleton() {
return (
<div className="space-y-3">
<Skeleton className="h-5 w-32" />
<ItemCardSkeleton />
<ItemCardSkeleton />
</div>
)
}

51
components/Toast.tsx Normal file
View File

@@ -0,0 +1,51 @@
'use client'
import { useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, X } from 'lucide-react'
export type ToastType = 'success' | 'error' | 'info'
interface ToastProps {
message: string
type: ToastType
onClose: () => void
duration?: number
}
export default function Toast({ message, type, onClose, duration = 3000 }: ToastProps) {
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(onClose, duration)
return () => clearTimeout(timer)
}
}, [duration, onClose])
const icons = {
success: <CheckCircle size={20} className="flex-shrink-0" />,
error: <XCircle size={20} className="flex-shrink-0" />,
info: <AlertCircle size={20} className="flex-shrink-0" />,
}
const styles = {
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
info: 'bg-blue-50 border-blue-200 text-blue-800',
}
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border shadow-lg ${styles[type]} animate-slide-in-right`}
role="alert"
>
{icons[type]}
<p className="flex-1 text-sm font-medium">{message}</p>
<button
onClick={onClose}
className="text-current opacity-70 hover:opacity-100 transition-opacity tap-target-sm"
aria-label="Close notification"
>
<X size={18} />
</button>
</div>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import Toast, { ToastType } from './Toast'
interface ToastMessage {
id: string
message: string
type: ToastType
}
interface ToastContextType {
showToast: (message: string, type: ToastType) => void
}
const ToastContext = createContext<ToastContextType | undefined>(undefined)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([])
const showToast = useCallback((message: string, type: ToastType) => {
const id = Math.random().toString(36).substring(7)
setToasts((prev) => [...prev, { id, message, type }])
}, [])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full px-4 sm:px-0">
{toasts.map((toast) => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
)
}

View File

@@ -3,6 +3,7 @@
import { useState } from 'react'
import { toggleItem } from '@/lib/actions'
import { CheckCircle2, Circle } from 'lucide-react'
import { useToast } from './ToastContainer'
interface ToggleItemButtonProps {
itemId: string
@@ -11,6 +12,7 @@ interface ToggleItemButtonProps {
}
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
const { showToast } = useToast()
const [isToggling, setIsToggling] = useState(false)
const handleToggle = async (e: React.MouseEvent) => {
@@ -19,8 +21,10 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
setIsToggling(true)
try {
await toggleItem(itemId)
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
} catch (error) {
console.error('Error toggling item:', error)
showToast('Failed to update task', 'error')
} finally {
setIsToggling(false)
}