From e51e88fdddd519d3f10171e5c59a0700a9e8715d Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Mon, 1 Dec 2025 21:14:14 +0000 Subject: [PATCH] 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 --- .kiro/specs/ultimate-lists-app/tasks.md | 2 +- app/globals.css | 51 +++++++++++++++++ app/layout.tsx | 9 ++- app/list/[id]/loading.tsx | 35 ++++++++++++ app/loading.tsx | 24 ++++++++ app/page.tsx | 23 ++++---- components/DeleteItemButton.tsx | 4 ++ components/DeleteListButton.tsx | 5 +- components/EmptyState.tsx | 41 +++++++++++++ components/InlineItemCreate.tsx | 4 ++ components/ItemForm.tsx | 5 ++ components/ListDetailView.tsx | 22 +++---- components/ListForm.tsx | 5 ++ components/LoadingSpinner.tsx | 33 +++++++++++ components/Modal.tsx | 6 +- components/SearchResults.tsx | 12 ++-- components/SkeletonLoader.tsx | 76 +++++++++++++++++++++++++ components/Toast.tsx | 51 +++++++++++++++++ components/ToastContainer.tsx | 53 +++++++++++++++++ components/ToggleItemButton.tsx | 4 ++ 20 files changed, 432 insertions(+), 33 deletions(-) create mode 100644 app/list/[id]/loading.tsx create mode 100644 app/loading.tsx create mode 100644 components/EmptyState.tsx create mode 100644 components/LoadingSpinner.tsx create mode 100644 components/SkeletonLoader.tsx create mode 100644 components/Toast.tsx create mode 100644 components/ToastContainer.tsx diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md index 1c2645d..3f94799 100644 --- a/.kiro/specs/ultimate-lists-app/tasks.md +++ b/.kiro/specs/ultimate-lists-app/tasks.md @@ -191,7 +191,7 @@ - Optimize layout for mobile and desktop - _Requirements: 7.1, 7.2_ -- [ ] 15. Add visual feedback and polish +- [x] 15. Add visual feedback and polish - Implement loading states for async operations - Add success/error toast notifications - Create smooth transitions between views diff --git a/app/globals.css b/app/globals.css index 5c9332e..964246d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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 */ @layer base { html { diff --git a/app/layout.tsx b/app/layout.tsx index be1d6e5..6f98ef1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import './globals.css' import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration' import OfflineIndicator from '@/components/OfflineIndicator' +import { ToastProvider } from '@/components/ToastContainer' export const metadata: Metadata = { title: 'Ultimate Lists', @@ -67,9 +68,11 @@ export default function RootLayout({ - - - {children} + + + + {children} + ) diff --git a/app/list/[id]/loading.tsx b/app/list/[id]/loading.tsx new file mode 100644 index 0000000..c1204bc --- /dev/null +++ b/app/list/[id]/loading.tsx @@ -0,0 +1,35 @@ +import { ListDetailSkeleton } from '@/components/SkeletonLoader' +import { ArrowLeft } from 'lucide-react' +import Link from 'next/link' + +export default function Loading() { + return ( +
+ {/* Header Skeleton */} +
+ + + Back to Lists + + +
+
+
+
+ +
+
+ + {/* Inline Item Creation Skeleton */} +
+
+
+ + {/* Items List Skeleton */} + +
+ ) +} diff --git a/app/loading.tsx b/app/loading.tsx new file mode 100644 index 0000000..d276757 --- /dev/null +++ b/app/loading.tsx @@ -0,0 +1,24 @@ +import { ListCardSkeleton } from '@/components/SkeletonLoader' + +export default function Loading() { + return ( +
+
+
+
+
+ + {/* Global Search Skeleton */} +
+
+
+ +
+ + + + +
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 1949114..8acda24 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,6 +2,7 @@ import { getLists } from '@/lib/actions' import ListCard from '@/components/ListCard' import NewListButton from '@/components/NewListButton' import GlobalSearch from '@/components/GlobalSearch' +import EmptyState from '@/components/EmptyState' export default async function Home() { const lists = await getLists() @@ -18,16 +19,18 @@ export default async function Home() { -
- {lists.map((list) => ( - - ))} -
- - {lists.length === 0 && ( -
-

No lists yet

-

Create your first list to get started

+ {lists.length === 0 ? ( + } + /> + ) : ( +
+ {lists.map((list) => ( + + ))}
)} diff --git a/components/DeleteItemButton.tsx b/components/DeleteItemButton.tsx index 55aa840..d435e2c 100644 --- a/components/DeleteItemButton.tsx +++ b/components/DeleteItemButton.tsx @@ -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) } diff --git a/components/DeleteListButton.tsx b/components/DeleteListButton.tsx index 03e604c..dbfc1ad 100644 --- a/components/DeleteListButton.tsx +++ b/components/DeleteListButton.tsx @@ -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) } diff --git a/components/EmptyState.tsx b/components/EmptyState.tsx new file mode 100644 index 0000000..f46c68d --- /dev/null +++ b/components/EmptyState.tsx @@ -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: , + list: , + search: , + filter: , + } + + return ( +
+
+ {icons[icon]} +
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} + {action && ( +
+ {action} +
+ )} +
+ ) +} diff --git a/components/InlineItemCreate.tsx b/components/InlineItemCreate.tsx index 72b8730..bf757a5 100644 --- a/components/InlineItemCreate.tsx +++ b/components/InlineItemCreate.tsx @@ -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) } diff --git a/components/ItemForm.tsx b/components/ItemForm.tsx index 8e691ed..5bc24df 100644 --- a/components/ItemForm.tsx +++ b/components/ItemForm.tsx @@ -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>({}) @@ -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) } diff --git a/components/ListDetailView.tsx b/components/ListDetailView.tsx index a64067b..d2877a8 100644 --- a/components/ListDetailView.tsx +++ b/components/ListDetailView.tsx @@ -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
{/* Incomplete Items */} {incompleteItems.length > 0 && ( -
+

To Do ({incompleteItems.length})

@@ -152,7 +153,7 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP {/* Completed Items */} {completedItems.length > 0 && ( -
+

Completed ({completedItems.length})

@@ -166,14 +167,15 @@ export default function ListDetailView({ listId, initialItems }: ListDetailViewP {/* Empty State */} {displayItems.length === 0 && ( -
-

No tasks found

-

- {filters.priority || filters.completed !== null || filters.tags.length > 0 - ? 'Try adjusting your filters' - : 'Add your first task to get started'} -

-
+ 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' + } + /> )}
)} diff --git a/components/ListForm.tsx b/components/ListForm.tsx index 66a79e9..d7ed548 100644 --- a/components/ListForm.tsx +++ b/components/ListForm.tsx @@ -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) diff --git a/components/LoadingSpinner.tsx b/components/LoadingSpinner.tsx new file mode 100644 index 0000000..2bcf009 --- /dev/null +++ b/components/LoadingSpinner.tsx @@ -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 ( +
+ ) +} + +export function LoadingOverlay({ message = 'Loading...' }: { message?: string }) { + return ( +
+
+ +

{message}

+
+
+ ) +} diff --git a/components/Modal.tsx b/components/Modal.tsx index 8d459dc..efda7b1 100644 --- a/components/Modal.tsx +++ b/components/Modal.tsx @@ -42,15 +42,15 @@ export default function Modal({ isOpen, onClose, title, children }: ModalProps) if (!isOpen) return null return ( -
+
{/* Backdrop */}
{/* Modal */} -
+

{title}

+ ) } @@ -45,7 +47,7 @@ export default function SearchResults({ results, query, onClose }: SearchResults } return ( -
+

Found {results.length} {results.length === 1 ? 'task' : 'tasks'} diff --git a/components/SkeletonLoader.tsx b/components/SkeletonLoader.tsx new file mode 100644 index 0000000..10cbd64 --- /dev/null +++ b/components/SkeletonLoader.tsx @@ -0,0 +1,76 @@ +'use client' + +interface SkeletonProps { + className?: string +} + +export function Skeleton({ className = '' }: SkeletonProps) { + return ( +

+ ) +} + +export function ListCardSkeleton() { + return ( +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ ) +} + +export function ItemCardSkeleton() { + return ( +
+
+ + +
+ + + + +
+ + + +
+
+ + +
+
+ ) +} + +export function ListDetailSkeleton() { + return ( +
+ + + +
+ ) +} + +export function SearchResultsSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/components/Toast.tsx b/components/Toast.tsx new file mode 100644 index 0000000..18b9cac --- /dev/null +++ b/components/Toast.tsx @@ -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: , + error: , + info: , + } + + 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 ( +
+ {icons[type]} +

{message}

+ +
+ ) +} diff --git a/components/ToastContainer.tsx b/components/ToastContainer.tsx new file mode 100644 index 0000000..6a37309 --- /dev/null +++ b/components/ToastContainer.tsx @@ -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(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([]) + + 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 ( + + {children} +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
+
+ ) +} diff --git a/components/ToggleItemButton.tsx b/components/ToggleItemButton.tsx index c7116ea..e1c04f1 100644 --- a/components/ToggleItemButton.tsx +++ b/components/ToggleItemButton.tsx @@ -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) }