Skip to content
Open

Todo #57

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
# Todo
TODO
https://js-project-todo.pages.dev/
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
"preview": "vite preview"
},
"dependencies": {
"date-fns": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
Expand Down
29 changes: 28 additions & 1 deletion src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import TaskInput from './components/TaskInput'
import TaskList from './components/TaskList'
import TaskStats from './components/TaskStats'
import FilterControls from './components/FilterControls'
import DarkModeToggle from './components/DarkModeToggle'
import useTaskStore from './store/useTaskStore'

export const App = () => {
const tasks = useTaskStore((state) => state.tasks)

return (
<h1>React Boilerplate</h1>
<div className="app-container">
<header className="app-header">
<h1 className="app-title">My Tasks</h1>
<DarkModeToggle />
</header>

<main>
<TaskInput />

{tasks.length > 0 && (
<>
<TaskStats />
<FilterControls />
</>
)}

<TaskList />
</main>
</div>
)
}
65 changes: 65 additions & 0 deletions src/components/DarkModeToggle.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect } from 'react'
import useTaskStore from '../store/useTaskStore'

const DarkModeToggle = () => {
const darkMode = useTaskStore((state) => state.darkMode)
const toggleDarkMode = useTaskStore((state) => state.toggleDarkMode)

useEffect(() => {
if (darkMode) {
document.documentElement.classList.add('dark-mode')
} else {
document.documentElement.classList.remove('dark-mode')
}
}, [darkMode])

return (
<button
onClick={toggleDarkMode}
className="dark-mode-toggle"
aria-label={`Switch to ${darkMode ? 'light' : 'dark'} mode`}
aria-pressed={darkMode}
>
{darkMode ? (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
<span className="toggle-label">{darkMode ? 'Light' : 'Dark'}</span>
</button>
)
}

export default DarkModeToggle
31 changes: 31 additions & 0 deletions src/components/EmptyState.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const EmptyState = () => {
return (
<div className="empty-state">
<div className="empty-state-icon" aria-hidden="true">
<svg
width="120"
height="120"
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="60" cy="60" r="50" stroke="currentColor" strokeWidth="2" opacity="0.2" />
<path
d="M40 60L50 70L80 40"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.3"
/>
</svg>
</div>
<h2 className="empty-state-title">No tasks yet</h2>
<p className="empty-state-description">
Start by adding your first task above. Stay organized and get things done!
</p>
</div>
)
}

export default EmptyState
29 changes: 29 additions & 0 deletions src/components/FilterControls.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import useTaskStore from '../store/useTaskStore'

const FilterControls = () => {
const filter = useTaskStore((state) => state.filter)
const setFilter = useTaskStore((state) => state.setFilter)

const filters = [
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
{ value: 'completed', label: 'Completed' },
]

return (
<div className="filter-controls" role="group" aria-label="Task filters">
{filters.map((f) => (
<button
key={f.value}
onClick={() => setFilter(f.value)}
className={`filter-button ${filter === f.value ? 'active' : ''}`}
aria-pressed={filter === f.value}
>
{f.label}
</button>
))}
</div>
)
}

export default FilterControls
52 changes: 52 additions & 0 deletions src/components/TaskInput.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useState } from 'react'
import useTaskStore from '../store/useTaskStore'

const TaskInput = () => {
const [text, setText] = useState('')
const [dueDate, setDueDate] = useState('')
const addTask = useTaskStore((state) => state.addTask)

const handleSubmit = (e) => {
e.preventDefault()
if (text.trim()) {
addTask(text.trim(), dueDate || null)
setText('')
setDueDate('')
}
}

return (
<form onSubmit={handleSubmit} className="task-input-form">
<div className="input-group">
<label htmlFor="task-text" className="visually-hidden">
Task description
</label>
<input
id="task-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
className="task-input"
aria-label="New task description"
/>
<label htmlFor="task-due-date" className="visually-hidden">
Due date (optional)
</label>
<input
id="task-due-date"
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
className="task-date-input"
aria-label="Task due date"
/>
<button type="submit" className="add-button" aria-label="Add task">
Add Task
</button>
</div>
</form>
)
}

export default TaskInput
54 changes: 54 additions & 0 deletions src/components/TaskItem.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// date-fns is a library for working with dates
// format: converts dates to readable strings like "Jan 15, 2024"
// isPast: checks if a date is in the past
// parseISO: converts ISO string ("2024-01-15T00:00:00.000Z") back to Date object
import { format, isPast, parseISO } from 'date-fns'
import useTaskStore from '../store/useTaskStore'

// Destructuring { task } from props - same as (props) => { const task = props.task }
const TaskItem = ({ task }) => {
// Destructuring from store - get both functions at once
// Unlike selectors, this subscribes to the whole store (less optimized but fine for actions)
const { toggleTask, removeTask } = useTaskStore()

const isOverdue = task.dueDate && !task.completed && isPast(parseISO(task.dueDate))

return (
<li className={`task-item ${task.completed ? 'completed' : ''} ${isOverdue ? 'overdue' : ''}`}>
<div className="task-content">
<label className="checkbox-container">
<input
type="checkbox"
checked={task.completed}
onChange={() => toggleTask(task.id)}
aria-label={`Mark task "${task.text}" as ${task.completed ? 'incomplete' : 'complete'}`}
/>
<span className="checkmark" aria-hidden="true"></span>
</label>
<div className="task-details">
<span className="task-text">{task.text}</span>
<div className="task-metadata">
<span className="task-created">
Created: {format(parseISO(task.createdAt), 'MMM d, yyyy')}
</span>
{task.dueDate && (
<span className={`task-due ${isOverdue ? 'overdue-label' : ''}`}>
Due: {format(parseISO(task.dueDate), 'MMM d, yyyy')}
{isOverdue && ' (Overdue)'}
</span>
)}
</div>
</div>
</div>
<button
onClick={() => removeTask(task.id)}
className="delete-button"
aria-label={`Delete task "${task.text}"`}
>
Delete
</button>
</li>
)
}

export default TaskItem
37 changes: 37 additions & 0 deletions src/components/TaskList.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import useTaskStore from '../store/useTaskStore'
import TaskItem from './TaskItem'
import EmptyState from './EmptyState'

const TaskList = () => {
const allTasks = useTaskStore((state) => state.tasks)
const filter = useTaskStore((state) => state.filter)

// Filter tasks based on the current filter
const filteredTasks = allTasks.filter((task) => {
if (filter === 'active') return !task.completed
if (filter === 'completed') return task.completed
return true
})

if (allTasks.length === 0) {
return <EmptyState />
}

if (filteredTasks.length === 0 && filter !== 'all') {
return (
<div className="empty-filter">
<p>No {filter} tasks found</p>
</div>
)
}

return (
<ul className="task-list" role="list">
{filteredTasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</ul>
)
}

export default TaskList
41 changes: 41 additions & 0 deletions src/components/TaskStats.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import useTaskStore from '../store/useTaskStore'

const TaskStats = () => {
const tasks = useTaskStore((state) => state.tasks)
const completeAll = useTaskStore((state) => state.completeAll)

// Calculate stats
const total = tasks.length
const completed = tasks.filter((task) => task.completed).length
const active = tasks.filter((task) => !task.completed).length

return (
<div className="task-stats">
<div className="stats-grid">
<div className="stat-item">
<span className="stat-value">{total}</span>
<span className="stat-label">Total Tasks</span>
</div>
<div className="stat-item">
<span className="stat-value">{active}</span>
<span className="stat-label">Active</span>
</div>
<div className="stat-item">
<span className="stat-value">{completed}</span>
<span className="stat-label">Completed</span>
</div>
</div>
{active > 0 && (
<button
onClick={completeAll}
className="complete-all-button"
aria-label="Mark all tasks as complete"
>
Complete All
</button>
)}
</div>
)
}

export default TaskStats
Loading