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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
41
components/EmptyState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
33
components/LoadingSpinner.tsx
Normal file
33
components/LoadingSpinner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
76
components/SkeletonLoader.tsx
Normal file
76
components/SkeletonLoader.tsx
Normal 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
51
components/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
components/ToastContainer.tsx
Normal file
53
components/ToastContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user