diff --git a/index.html b/index.html index f7ac4e4..6c3f294 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,32 @@ - - - - - Todo - - -
- - - + + + + + + Todo + + + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index caf6289..a26bf1f 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "date-fns": "^4.1.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/src/App.jsx b/src/App.jsx index 5427540..45b78dc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,17 @@ -export const App = () => { - return ( -

React Boilerplate

- ) -} +import React from 'react'; +import TaskForm from './components/TaskForm'; +import TaskList from './components/TaskList'; +import TaskStats from './components/TaskStats'; +import ThemeToggle from './components/ThemeToggle'; + +const App = () => ( +
+ +

PlainTasks

+ + + +
+); + +export default App; diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx new file mode 100644 index 0000000..9b730b7 --- /dev/null +++ b/src/components/EmptyState.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const EmptyState = () => ( +

No tasks yet. Add one above to get started.

+); + +export default EmptyState; diff --git a/src/components/TaskForm.jsx b/src/components/TaskForm.jsx new file mode 100644 index 0000000..47a08f1 --- /dev/null +++ b/src/components/TaskForm.jsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; +import { useTaskStore } from '../store/taskStore'; + +const TaskForm = () => { + const [input, setInput] = useState(''); + const addTask = useTaskStore((state) => state.addTask); + + const handleSubmit = (e) => { + e.preventDefault(); + if (input.trim()) { + addTask(input.trim()); + setInput(''); + } + }; + + return ( +
+ setInput(e.target.value)} + placeholder="Enter a task" + aria-label="New task" + /> + +
+ ); +}; + +export default TaskForm; diff --git a/src/components/TaskItem.jsx b/src/components/TaskItem.jsx new file mode 100644 index 0000000..8d57db7 --- /dev/null +++ b/src/components/TaskItem.jsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { useTaskStore } from '../store/taskStore'; +import { formatDate } from '../utils/formatDate'; + +const TaskItem = ({ task }) => { + const toggleTask = useTaskStore((state) => state.toggleTask); + const removeTask = useTaskStore((state) => state.removeTask); + + return ( +
  • +
    + + +
    +
    + {task.isCompleted ? 'Completed' : 'Ongoing'} • {formatDate(task.createdAt)} +
    +
  • + ); +}; + +export default TaskItem; diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx new file mode 100644 index 0000000..0a1070c --- /dev/null +++ b/src/components/TaskList.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useTaskStore } from '../store/taskStore'; +import TaskItem from './TaskItem'; + +const TaskList = () => { + const tasks = useTaskStore((state) => state.tasks); + + if (tasks.length === 0) { + return

    No tasks yet. Add one!

    ; + } + + return ( + + ); +}; + +export default TaskList; diff --git a/src/components/TaskStats.jsx b/src/components/TaskStats.jsx new file mode 100644 index 0000000..d749e29 --- /dev/null +++ b/src/components/TaskStats.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useTaskStore } from '../store/taskStore'; + +const TaskStats = () => { + const tasks = useTaskStore((state) => state.tasks); + const clearCompleted = useTaskStore((state) => state.clearCompleted); + const remaining = tasks.filter((task) => !task.isCompleted).length; + + return ( +
    +

    Tasks left: {remaining}

    + +
    + ); +}; + +export default TaskStats; diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..1640e60 --- /dev/null +++ b/src/components/ThemeToggle.jsx @@ -0,0 +1,31 @@ +import React, { useEffect, useState } from 'react'; + +const ThemeToggle = () => { + const [theme, setTheme] = useState(() => localStorage.getItem('theme') || 'light'); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }, [theme]); + + return ( + + ); +}; + +export default ThemeToggle; diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9..e341f67 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +1,10 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' - -import { App } from './App.jsx' - -import './index.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles/global.css'; ReactDOM.createRoot(document.getElementById('root')).render( -) +); diff --git a/src/store/taskStore.js b/src/store/taskStore.js new file mode 100644 index 0000000..ff69708 --- /dev/null +++ b/src/store/taskStore.js @@ -0,0 +1,47 @@ +import { create } from 'zustand'; + +const getStoredTasks = () => { + const stored = localStorage.getItem('tasks'); + return stored ? JSON.parse(stored) : []; +}; + +export const useTaskStore = create((set) => ({ + tasks: getStoredTasks(), + + addTask: (title) => { + const newTask = { + id: Date.now(), + title, + isCompleted: false, + createdAt: new Date().toISOString(), + }; + set((state) => { + const updatedTasks = [newTask, ...state.tasks]; + localStorage.setItem('tasks', JSON.stringify(updatedTasks)); + return { tasks: updatedTasks }; + }); + }, + + toggleTask: (id) => + set((state) => { + const updatedTasks = state.tasks.map((task) => + task.id === id ? { ...task, isCompleted: !task.isCompleted } : task + ); + localStorage.setItem('tasks', JSON.stringify(updatedTasks)); + return { tasks: updatedTasks }; + }), + + removeTask: (id) => + set((state) => { + const updatedTasks = state.tasks.filter((task) => task.id !== id); + localStorage.setItem('tasks', JSON.stringify(updatedTasks)); + return { tasks: updatedTasks }; + }), + + clearCompleted: () => + set((state) => { + const updatedTasks = state.tasks.filter((task) => !task.isCompleted); + localStorage.setItem('tasks', JSON.stringify(updatedTasks)); + return { tasks: updatedTasks }; + }), +})); diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..5aa5d68 --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,180 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap'); + +:root { + --bg: #f9f9fb; + --card: #ffffff; + --text: #1e1e1e; + --muted: #6b7280; + --primary: #4f46e5; + --primary-hover: #4338ca; + --danger: #dc2626; + --soft-danger: #874356; + --soft-danger-hover: #703745; + + --border: #e5e7eb; + --radius: 12px; + --shadow: 0 6px 18px rgba(0, 0, 0, 0.05); + --font-size: 1rem; +} + +[data-theme='dark'] { + --bg: #121212; + --card: #1e1e1e; + --text: #f4f4f5; + --muted: #a1a1aa; + --primary: #818cf8; + --primary-hover: #6366f1; + --danger: #f87171; + --border: #27272a; + --shadow: 0 6px 18px rgba(255, 255, 255, 0.04); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Inter', sans-serif; + font-size: var(--font-size); + background: var(--bg); + color: var(--text); + display: flex; + justify-content: center; + padding: 2rem; + min-height: 100vh; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +main { + width: 100%; + max-width: 600px; + background: var(--card); + padding: 2rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +h1 { + font-size: 1.75rem; + font-weight: 600; + text-align: center; + color: var(--text); +} + +form { + display: flex; + flex-direction: row; + gap: 1rem; + flex-wrap: wrap; +} + +input[type="text"] { + flex: 1; + padding: 0.75rem; + font-size: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + background: white; + transition: border-color 0.2s ease; +} + +input[type="text"]:focus { + outline: none; + border-color: var(--primary); +} + +button { + padding: 0.75rem 1.25rem; + background-color: var(--primary); + color: white; + border: none; + border-radius: var(--radius); + font-weight: 500; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s ease; +} + +button:hover { + background-color: var(--primary-hover); +} + +ul { + list-style: none; + display: flex; + flex-direction: column; + gap: 1rem; + padding: 0; +} + +li { + background: var(--card); + border: 1px solid var(--border); + padding: 1rem; + border-radius: var(--radius); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 100px; + justify-content: space-between; +} + +.task-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.task-title { + font-weight: 500; + font-size: 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.task-title.completed { + text-decoration: line-through; + color: var(--muted); +} + +.task-meta { + font-size: 0.875rem; + color: var(--muted); +} + +li button.delete { + background: none; + border: none; + color: var(--danger); + font-size: 1.25rem; + cursor: pointer; + padding: 0; +} + +.stats { + text-align: center; + font-size: 0.95rem; + color: var(--muted); +} + +@media (max-width: 480px) { + body { + padding: 1rem; + } + + form { + flex-direction: column; + } + + button { + width: 100%; + } +} \ No newline at end of file diff --git a/src/utils/formatDate.js b/src/utils/formatDate.js new file mode 100644 index 0000000..a839eff --- /dev/null +++ b/src/utils/formatDate.js @@ -0,0 +1,5 @@ +import { format } from 'date-fns'; + +export const formatDate = (isoString) => { + return format(new Date(isoString), 'MMM d, yyyy HH:mm'); +};