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

@@ -191,7 +191,7 @@
- Optimize layout for mobile and desktop - Optimize layout for mobile and desktop
- _Requirements: 7.1, 7.2_ - _Requirements: 7.1, 7.2_
- [ ] 15. Add visual feedback and polish - [x] 15. Add visual feedback and polish
- Implement loading states for async operations - Implement loading states for async operations
- Add success/error toast notifications - Add success/error toast notifications
- Create smooth transitions between views - Create smooth transitions between views

View File

@@ -30,6 +30,57 @@ mark {
} }
} }
/* Animations for visual feedback */
@layer utilities {
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scale-in {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}
.animate-scale-in {
animation: scale-in 0.2s ease-out;
}
/* Smooth transitions for interactive elements */
.transition-smooth {
transition: all 0.2s ease-in-out;
}
}
/* Smooth scrolling for better mobile experience */ /* Smooth scrolling for better mobile experience */
@layer base { @layer base {
html { html {

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration' import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
import OfflineIndicator from '@/components/OfflineIndicator' import OfflineIndicator from '@/components/OfflineIndicator'
import { ToastProvider } from '@/components/ToastContainer'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Ultimate Lists', title: 'Ultimate Lists',
@@ -67,9 +68,11 @@ export default function RootLayout({
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head> </head>
<body className="bg-gray-50 min-h-screen"> <body className="bg-gray-50 min-h-screen">
<ServiceWorkerRegistration /> <ToastProvider>
<OfflineIndicator /> <ServiceWorkerRegistration />
{children} <OfflineIndicator />
{children}
</ToastProvider>
</body> </body>
</html> </html>
) )

35
app/list/[id]/loading.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { ListDetailSkeleton } from '@/components/SkeletonLoader'
import { ArrowLeft } from 'lucide-react'
import Link from 'next/link'
export default function Loading() {
return (
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-6xl">
{/* Header Skeleton */}
<div className="mb-6 sm:mb-8">
<Link
href="/"
className="inline-flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4 transition-colors tap-target-sm text-sm sm:text-base"
>
<ArrowLeft size={20} className="flex-shrink-0" />
<span>Back to Lists</span>
</Link>
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-gray-200 animate-pulse" />
<div className="h-8 sm:h-10 w-48 bg-gray-200 rounded animate-pulse" />
</div>
<div className="h-5 w-32 bg-gray-200 rounded animate-pulse" />
</div>
{/* Inline Item Creation Skeleton */}
<div className="mb-6 sm:mb-8">
<div className="h-14 bg-gray-100 border-2 border-dashed border-gray-300 rounded-lg animate-pulse" />
</div>
{/* Items List Skeleton */}
<ListDetailSkeleton />
</main>
)
}

24
app/loading.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { ListCardSkeleton } from '@/components/SkeletonLoader'
export default function Loading() {
return (
<main className="container mx-auto px-4 sm:px-6 py-6 sm:py-8 max-w-7xl">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6 sm:mb-8">
<div className="h-10 w-48 bg-gray-200 rounded animate-pulse" />
<div className="h-12 w-32 bg-gray-200 rounded-lg animate-pulse" />
</div>
{/* Global Search Skeleton */}
<div className="mb-6 sm:mb-8">
<div className="h-12 bg-gray-100 border border-gray-300 rounded-lg animate-pulse" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6">
<ListCardSkeleton />
<ListCardSkeleton />
<ListCardSkeleton />
<ListCardSkeleton />
</div>
</main>
)
}

View File

@@ -2,6 +2,7 @@ import { getLists } from '@/lib/actions'
import ListCard from '@/components/ListCard' import ListCard from '@/components/ListCard'
import NewListButton from '@/components/NewListButton' import NewListButton from '@/components/NewListButton'
import GlobalSearch from '@/components/GlobalSearch' import GlobalSearch from '@/components/GlobalSearch'
import EmptyState from '@/components/EmptyState'
export default async function Home() { export default async function Home() {
const lists = await getLists() const lists = await getLists()
@@ -18,16 +19,18 @@ export default async function Home() {
<GlobalSearch /> <GlobalSearch />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6"> {lists.length === 0 ? (
{lists.map((list) => ( <EmptyState
<ListCard key={list.id} list={list} /> icon="list"
))} title="No lists yet"
</div> description="Create your first list to start organizing your tasks"
action={<NewListButton />}
{lists.length === 0 && ( />
<div className="text-center py-12 sm:py-16"> ) : (
<p className="text-gray-500 text-base sm:text-lg mb-3 sm:mb-4">No lists yet</p> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 animate-fade-in">
<p className="text-gray-400 text-sm sm:text-base">Create your first list to get started</p> {lists.map((list) => (
<ListCard key={list.id} list={list} />
))}
</div> </div>
)} )}
</main> </main>

View File

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

View File

@@ -5,6 +5,7 @@ import { Trash2 } from 'lucide-react'
import { deleteList } from '@/lib/actions' import { deleteList } from '@/lib/actions'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Modal from './Modal' import Modal from './Modal'
import { useToast } from './ToastContainer'
interface DeleteListButtonProps { interface DeleteListButtonProps {
listId: string listId: string
@@ -20,6 +21,7 @@ export default function DeleteListButton({
redirectAfterDelete = false redirectAfterDelete = false
}: DeleteListButtonProps) { }: DeleteListButtonProps) {
const router = useRouter() const router = useRouter()
const { showToast } = useToast()
const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -27,6 +29,7 @@ export default function DeleteListButton({
setIsDeleting(true) setIsDeleting(true)
try { try {
await deleteList(listId) await deleteList(listId)
showToast('List deleted successfully', 'success')
setIsConfirmOpen(false) setIsConfirmOpen(false)
if (redirectAfterDelete) { if (redirectAfterDelete) {
router.push('/') router.push('/')
@@ -35,7 +38,7 @@ export default function DeleteListButton({
} }
} catch (err) { } catch (err) {
console.error('Failed to delete list:', err) console.error('Failed to delete list:', err)
alert('Failed to delete list. Please try again.') showToast('Failed to delete list', 'error')
} finally { } finally {
setIsDeleting(false) 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 { useRouter } from 'next/navigation'
import { createItem } from '@/lib/actions' import { createItem } from '@/lib/actions'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { useToast } from './ToastContainer'
interface InlineItemCreateProps { interface InlineItemCreateProps {
listId: string listId: string
@@ -11,6 +12,7 @@ interface InlineItemCreateProps {
export default function InlineItemCreate({ listId }: InlineItemCreateProps) { export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
const router = useRouter() const router = useRouter()
const { showToast } = useToast()
const [isCreating, setIsCreating] = useState(false) const [isCreating, setIsCreating] = useState(false)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -26,11 +28,13 @@ export default function InlineItemCreate({ listId }: InlineItemCreateProps) {
try { try {
await createItem(listId, title.trim()) await createItem(listId, title.trim())
showToast('Task added successfully', 'success')
setTitle('') setTitle('')
setIsCreating(false) setIsCreating(false)
router.refresh() router.refresh()
} catch (error) { } catch (error) {
console.error('Error creating item:', error) console.error('Error creating item:', error)
showToast('Failed to add task', 'error')
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

@@ -3,6 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { updateItem, createItem } from '@/lib/actions' import { updateItem, createItem } from '@/lib/actions'
import { useToast } from './ToastContainer'
interface ItemFormProps { interface ItemFormProps {
listId: string listId: string
@@ -20,6 +21,7 @@ interface ItemFormProps {
export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) { export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps) {
const router = useRouter() const router = useRouter()
const { showToast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
@@ -79,14 +81,17 @@ export default function ItemForm({ listId, item, mode = 'edit' }: ItemFormProps)
tags: data.tags, tags: data.tags,
notes: data.notes, notes: data.notes,
}) })
showToast('Task created successfully', 'success')
router.push(`/list/${listId}`) router.push(`/list/${listId}`)
} else if (item) { } else if (item) {
await updateItem(item.id, data) await updateItem(item.id, data)
showToast('Task updated successfully', 'success')
router.push(`/list/${listId}/item/${item.id}`) router.push(`/list/${listId}/item/${item.id}`)
} }
} catch (error) { } catch (error) {
console.error('Error saving item:', error) console.error('Error saving item:', error)
setErrors({ submit: 'Failed to save item. Please try again.' }) setErrors({ submit: 'Failed to save item. Please try again.' })
showToast('Failed to save task', 'error')
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }

View File

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

View File

@@ -3,6 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import { createList, updateList } from '@/lib/actions' import { createList, updateList } from '@/lib/actions'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useToast } from './ToastContainer'
interface ListFormProps { interface ListFormProps {
list?: { list?: {
@@ -27,6 +28,7 @@ const PRESET_COLORS = [
export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) { export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
const router = useRouter() const router = useRouter()
const { showToast } = useToast()
const [title, setTitle] = useState(list?.title || '') const [title, setTitle] = useState(list?.title || '')
const [color, setColor] = useState(list?.color || PRESET_COLORS[0]) const [color, setColor] = useState(list?.color || PRESET_COLORS[0])
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -47,9 +49,11 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
if (list) { if (list) {
// Update existing list // Update existing list
await updateList(list.id, { title: title.trim(), color }) await updateList(list.id, { title: title.trim(), color })
showToast('List updated successfully', 'success')
} else { } else {
// Create new list // Create new list
await createList(title.trim(), color) await createList(title.trim(), color)
showToast('List created successfully', 'success')
} }
if (onSuccess) { if (onSuccess) {
@@ -59,6 +63,7 @@ export default function ListForm({ list, onCancel, onSuccess }: ListFormProps) {
} }
} catch (err) { } catch (err) {
setError('Failed to save list. Please try again.') setError('Failed to save list. Please try again.')
showToast('Failed to save list', 'error')
console.error(err) console.error(err)
} finally { } finally {
setIsSubmitting(false) 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 if (!isOpen) return null
return ( 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 */} {/* Backdrop */}
<div <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} onClick={onClose}
/> />
{/* Modal */} {/* 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"> <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> <h2 className="text-lg sm:text-xl font-semibold text-gray-900">{title}</h2>
<button <button

View File

@@ -3,6 +3,7 @@
import { highlightText } from '@/lib/utils' import { highlightText } from '@/lib/utils'
import Link from 'next/link' import Link from 'next/link'
import { Calendar, Tag, AlertCircle } from 'lucide-react' import { Calendar, Tag, AlertCircle } from 'lucide-react'
import EmptyState from './EmptyState'
interface SearchResultsProps { interface SearchResultsProps {
results: Array<{ results: Array<{
@@ -31,10 +32,11 @@ export default function SearchResults({ results, query, onClose }: SearchResults
if (results.length === 0) { if (results.length === 0) {
return ( return (
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center"> <EmptyState
<p className="text-gray-500">No tasks found matching "{query}"</p> icon="search"
<p className="text-sm text-gray-400 mt-2">Try a different search term</p> title={`No tasks found matching "${query}"`}
</div> description="Try a different search term or check your spelling"
/>
) )
} }
@@ -45,7 +47,7 @@ export default function SearchResults({ results, query, onClose }: SearchResults
} }
return ( return (
<div className="space-y-2"> <div className="space-y-2 animate-fade-in">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Found {results.length} {results.length === 1 ? 'task' : 'tasks'} 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 { useState } from 'react'
import { toggleItem } from '@/lib/actions' import { toggleItem } from '@/lib/actions'
import { CheckCircle2, Circle } from 'lucide-react' import { CheckCircle2, Circle } from 'lucide-react'
import { useToast } from './ToastContainer'
interface ToggleItemButtonProps { interface ToggleItemButtonProps {
itemId: string itemId: string
@@ -11,6 +12,7 @@ interface ToggleItemButtonProps {
} }
export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) { export default function ToggleItemButton({ itemId, completed, variant = 'full' }: ToggleItemButtonProps) {
const { showToast } = useToast()
const [isToggling, setIsToggling] = useState(false) const [isToggling, setIsToggling] = useState(false)
const handleToggle = async (e: React.MouseEvent) => { const handleToggle = async (e: React.MouseEvent) => {
@@ -19,8 +21,10 @@ export default function ToggleItemButton({ itemId, completed, variant = 'full' }
setIsToggling(true) setIsToggling(true)
try { try {
await toggleItem(itemId) await toggleItem(itemId)
showToast(completed ? 'Task marked incomplete' : 'Task completed', 'success')
} catch (error) { } catch (error) {
console.error('Error toggling item:', error) console.error('Error toggling item:', error)
showToast('Failed to update task', 'error')
} finally { } finally {
setIsToggling(false) setIsToggling(false)
} }