From 239f3ac32ecf0c2f139d2afd3d99077b77f3c1fc Mon Sep 17 00:00:00 2001 From: Emil Floren Date: Sat, 17 Jan 2026 14:43:08 +0100 Subject: [PATCH 1/2] TODO --- package.json | 4 +- src/App.jsx | 29 +- src/components/DarkModeToggle.jsx | 65 ++++ src/components/EmptyState.jsx | 31 ++ src/components/FilterControls.jsx | 29 ++ src/components/TaskInput.jsx | 52 +++ src/components/TaskItem.jsx | 54 ++++ src/components/TaskList.jsx | 37 +++ src/components/TaskStats.jsx | 41 +++ src/index.css | 508 ++++++++++++++++++++++++++++++ src/store/useTaskStore.js | 69 ++++ 11 files changed, 917 insertions(+), 2 deletions(-) create mode 100644 src/components/DarkModeToggle.jsx create mode 100644 src/components/EmptyState.jsx create mode 100644 src/components/FilterControls.jsx create mode 100644 src/components/TaskInput.jsx create mode 100644 src/components/TaskItem.jsx create mode 100644 src/components/TaskList.jsx create mode 100644 src/components/TaskStats.jsx create mode 100644 src/store/useTaskStore.js diff --git a/package.json b/package.json index caf62893..f6ba0cf0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index 54275400..b711022f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ( -

React Boilerplate

+
+
+

My Tasks

+ +
+ +
+ + + {tasks.length > 0 && ( + <> + + + + )} + + +
+
) } diff --git a/src/components/DarkModeToggle.jsx b/src/components/DarkModeToggle.jsx new file mode 100644 index 00000000..c001893c --- /dev/null +++ b/src/components/DarkModeToggle.jsx @@ -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 ( + + ) +} + +export default DarkModeToggle diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx new file mode 100644 index 00000000..b0584eb0 --- /dev/null +++ b/src/components/EmptyState.jsx @@ -0,0 +1,31 @@ +const EmptyState = () => { + return ( +
+ +

No tasks yet

+

+ Start by adding your first task above. Stay organized and get things done! +

+
+ ) +} + +export default EmptyState diff --git a/src/components/FilterControls.jsx b/src/components/FilterControls.jsx new file mode 100644 index 00000000..60ff1951 --- /dev/null +++ b/src/components/FilterControls.jsx @@ -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 ( +
+ {filters.map((f) => ( + + ))} +
+ ) +} + +export default FilterControls diff --git a/src/components/TaskInput.jsx b/src/components/TaskInput.jsx new file mode 100644 index 00000000..754419f0 --- /dev/null +++ b/src/components/TaskInput.jsx @@ -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 ( +
+
+ + setText(e.target.value)} + placeholder="What needs to be done?" + className="task-input" + aria-label="New task description" + /> + + setDueDate(e.target.value)} + className="task-date-input" + aria-label="Task due date" + /> + +
+
+ ) +} + +export default TaskInput diff --git a/src/components/TaskItem.jsx b/src/components/TaskItem.jsx new file mode 100644 index 00000000..6b63c3ae --- /dev/null +++ b/src/components/TaskItem.jsx @@ -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 ( +
  • +
    + +
    + {task.text} +
    + + Created: {format(parseISO(task.createdAt), 'MMM d, yyyy')} + + {task.dueDate && ( + + Due: {format(parseISO(task.dueDate), 'MMM d, yyyy')} + {isOverdue && ' (Overdue)'} + + )} +
    +
    +
    + +
  • + ) +} + +export default TaskItem diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx new file mode 100644 index 00000000..72168bc0 --- /dev/null +++ b/src/components/TaskList.jsx @@ -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 + } + + if (filteredTasks.length === 0 && filter !== 'all') { + return ( +
    +

    No {filter} tasks found

    +
    + ) + } + + return ( + + ) +} + +export default TaskList diff --git a/src/components/TaskStats.jsx b/src/components/TaskStats.jsx new file mode 100644 index 00000000..8fd6f50d --- /dev/null +++ b/src/components/TaskStats.jsx @@ -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 ( +
    +
    +
    + {total} + Total Tasks +
    +
    + {active} + Active +
    +
    + {completed} + Completed +
    +
    + {active > 0 && ( + + )} +
    + ) +} + +export default TaskStats diff --git a/src/index.css b/src/index.css index f7c0aef5..0ea6a0cc 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,511 @@ :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + --text-primary: #212529; + --text-secondary: #6c757d; + --border-color: #dee2e6; + --accent-color: #4f46e5; + --accent-hover: #4338ca; + --success-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); +} + +.dark-mode { + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --bg-tertiary: #3d3d3d; + --text-primary: #f1f3f5; + --text-secondary: #adb5bd; + --border-color: #495057; + --accent-color: #6366f1; + --accent-hover: #4f46e5; + --success-color: #34d399; + --danger-color: #f87171; + --warning-color: #fbbf24; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background-color: var(--bg-secondary); + color: var(--text-primary); + line-height: 1.6; + transition: background-color 0.3s ease, color 0.3s ease; +} + +#root { + min-height: 100vh; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.app-container { + max-width: 800px; + margin: 0 auto; + padding: 1rem; + min-height: 100vh; +} + +.app-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding: 1.5rem 0; +} + +.app-title { + font-size: 2rem; + font-weight: 700; + color: var(--text-primary); +} + +.dark-mode-toggle { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background-color: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + font-weight: 500; +} + +.dark-mode-toggle:hover { + background-color: var(--accent-color); + color: white; + transform: translateY(-1px); +} + +.dark-mode-toggle:focus { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.task-input-form { + margin-bottom: 2rem; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.task-input, +.task-date-input { + padding: 0.875rem 1rem; + border: 2px solid var(--border-color); + border-radius: 8px; + font-size: 1rem; + background-color: var(--bg-primary); + color: var(--text-primary); + transition: border-color 0.2s ease; + width: 100%; +} + +.task-input:focus, +.task-date-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1); +} + +.add-button { + padding: 0.875rem 1.5rem; + background-color: var(--accent-color); + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.add-button:hover { + background-color: var(--accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.add-button:focus { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.add-button:active { + transform: translateY(0); +} + +.task-stats { + background-color: var(--bg-primary); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1rem; +} + +.stat-item { + text-align: center; + padding: 0.75rem; + background-color: var(--bg-secondary); + border-radius: 8px; +} + +.stat-value { + display: block; + font-size: 1.75rem; + font-weight: 700; + color: var(--accent-color); + margin-bottom: 0.25rem; +} + +.stat-label { + display: block; + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +.complete-all-button { + width: 100%; + padding: 0.75rem; + background-color: var(--success-color); + color: white; + border: none; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.complete-all-button:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.complete-all-button:focus { + outline: 2px solid var(--success-color); + outline-offset: 2px; +} + +.filter-controls { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + background-color: var(--bg-primary); + padding: 0.5rem; + border-radius: 10px; + box-shadow: var(--shadow-sm); +} + +.filter-button { + flex: 1; + padding: 0.625rem 1rem; + background-color: transparent; + color: var(--text-secondary); + border: none; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.filter-button:hover { + background-color: var(--bg-secondary); + color: var(--text-primary); +} + +.filter-button.active { + background-color: var(--accent-color); + color: white; +} + +.filter-button:focus { + outline: 2px solid var(--accent-color); + outline-offset: -2px; +} + + +.task-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.task-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.25rem; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 10px; + transition: all 0.2s ease; + box-shadow: var(--shadow-sm); +} + +.task-item:hover { + box-shadow: var(--shadow-md); + transform: translateY(-1px); +} + +.task-item.completed { + opacity: 0.7; +} + +.task-item.overdue { + border-left: 4px solid var(--danger-color); +} + +.task-content { + display: flex; + align-items: flex-start; + gap: 1rem; + flex: 1; +} + +.checkbox-container { + position: relative; + display: inline-block; + cursor: pointer; + flex-shrink: 0; +} + +.checkbox-container input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; +} + +.checkmark { + display: block; + width: 24px; + height: 24px; + border: 2px solid var(--border-color); + border-radius: 6px; + background-color: var(--bg-primary); + transition: all 0.2s ease; +} + +.checkbox-container:hover .checkmark { + border-color: var(--accent-color); +} + +.checkbox-container input:checked ~ .checkmark { + background-color: var(--accent-color); + border-color: var(--accent-color); +} + +.checkbox-container input:checked ~ .checkmark::after { + content: ''; + position: absolute; + left: 8px; + top: 4px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.checkbox-container input:focus ~ .checkmark { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +.task-details { + flex: 1; +} + +.task-text { + display: block; + font-size: 1rem; + color: var(--text-primary); + margin-bottom: 0.5rem; + word-break: break-word; +} + +.task-item.completed .task-text { + text-decoration: line-through; +} + +.task-metadata { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--text-secondary); +} + +.task-due { + color: var(--accent-color); + font-weight: 500; +} + +.task-due.overdue-label { + color: var(--danger-color); + font-weight: 600; +} + +.delete-button { + padding: 0.5rem 1rem; + background-color: transparent; + color: var(--danger-color); + border: 1px solid var(--danger-color); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + flex-shrink: 0; +} + +.delete-button:hover { + background-color: var(--danger-color); + color: white; +} + +.delete-button:focus { + outline: 2px solid var(--danger-color); + outline-offset: 2px; +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + background-color: var(--bg-primary); + border-radius: 12px; + box-shadow: var(--shadow-sm); +} + +.empty-state-icon { + color: var(--text-secondary); + margin-bottom: 1.5rem; +} + +.empty-state-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.75rem; +} + +.empty-state-description { + font-size: 1rem; + color: var(--text-secondary); + max-width: 400px; + margin: 0 auto; +} + +.empty-filter { + text-align: center; + padding: 3rem 2rem; + background-color: var(--bg-primary); + border-radius: 12px; + box-shadow: var(--shadow-sm); +} + +.empty-filter p { + font-size: 1rem; + color: var(--text-secondary); +} + +@media (min-width: 480px) { + .input-group { + flex-direction: row; + align-items: stretch; + } + + .task-input { + flex: 1; + } + + .task-date-input { + width: auto; + min-width: 160px; + } + + .add-button { + width: auto; + } +} + +@media (min-width: 640px) { + .app-container { + padding: 2rem; + } + + .app-title { + font-size: 2.5rem; + } + + .task-metadata { + font-size: 0.813rem; + } +} + +@media (min-width: 768px) { + .app-container { + padding: 3rem 2rem; + } + + .stats-grid { + gap: 1.5rem; + } + + .stat-item { + padding: 1rem; + } +} + +@media (min-width: 1024px) { + .app-header { + margin-bottom: 2.5rem; + } +} + +@media (min-width: 1600px) { + .app-container { + max-width: 900px; + } } diff --git a/src/store/useTaskStore.js b/src/store/useTaskStore.js new file mode 100644 index 00000000..ee3f5782 --- /dev/null +++ b/src/store/useTaskStore.js @@ -0,0 +1,69 @@ +// Zustand is a state management library +// 'create' holds your app's state +import { create } from 'zustand' + +// 'persist' is middleware that automatically saves state to localStorage +// so data survives page refreshes +import { persist } from 'zustand/middleware' + +const useTaskStore = create( + persist( + // 'set' is a function to update state, 'get' retrieves current state + (set, get) => ({ + tasks: [], + filter: 'all', // all, active, completed + darkMode: false, + + // Action to add a new task + addTask: (text, dueDate = null) => { + const newTask = { + id: Date.now(), + text, + completed: false, + createdAt: new Date().toISOString(), + dueDate: dueDate ? new Date(dueDate).toISOString() : null, + } + // set() receives current state and returns new state + // Spread operator (...) creates a new array with old tasks + new task + set((state) => ({ tasks: [...state.tasks, newTask] })) + }, + + removeTask: (id) => { + set((state) => ({ + tasks: state.tasks.filter((task) => task.id !== id), + })) + }, + + // map() creates a new array by transforming each item + // For the matching task: spread all properties and flip 'completed' + toggleTask: (id) => { + set((state) => ({ + tasks: state.tasks.map((task) => + task.id === id ? { ...task, completed: !task.completed } : task + ), + })) + }, + + // Mark all tasks as completed using map() + completeAll: () => { + set((state) => ({ + tasks: state.tasks.map((task) => ({ ...task, completed: true })), + })) + }, + + setFilter: (filter) => { + set({ filter }) + }, + + toggleDarkMode: () => { + set((state) => ({ darkMode: !state.darkMode })) + }, + }), + { + // This key is used in localStorage to save/load the state + name: 'todo-storage', + } + ) +) + +export default useTaskStore From ad5a9e99702dc0a30367ddd21de41cf7ff3bd29f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emil=20Flor=C3=A9n?= Date: Sat, 17 Jan 2026 14:48:49 +0100 Subject: [PATCH 2/2] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d1c68b57..b87d0d0a 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ -# Todo \ No newline at end of file +TODO +https://js-project-todo.pages.dev/