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:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
35
app/list/[id]/loading.tsx
Normal 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
24
app/loading.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
app/page.tsx
23
app/page.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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 { 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
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 { 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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user