diff --git a/README.md b/README.md index d1c68b5..c47fa72 100644 --- a/README.md +++ b/README.md @@ -1 +1,34 @@ -# Todo \ No newline at end of file +# 📋 Todo App + +A simple and accessible Todo application built with React and Zustand. This project helps users organize their tasks with features like marking tasks as completed, starring important ones, and saving everything in local storage for persistence. + +🔗 **Live site:** [https://taskortoss.netlify.app](https://taskortoss.netlify.app) + +## ✨ Features + +- ✅ Add, complete, uncomplete, and delete tasks +- ⭐ Star your most important tasks +- 📅 Each task includes a creation date +- 📊 Task counter: shows completed and starred tasks +- 💾 Local storage support to keep your tasks between sessions +- 🎨 Responsive and clean UI (mobile to desktop) +- ♿ Follows web accessibility best practices + +## 🧠 What I Learned + +- Global state management with Zustand +- React component structure and props +- Handling user input and controlled forms +- Conditional rendering and sorting logic +- Accessibility techniques (semantic HTML, color contrast, screen reader labels) +- Working with local storage +- Clean code and folder structure + +## 🛠️ Tech Stack + +- **React** +- **Zustand** +- **Styled Components** +- **Moment.js** for date formatting +- **Vite** (development server & bundler) + diff --git a/eslint.config.js b/eslint.config.js index 2fd24fd..c7431e9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,33 +1,33 @@ -import js from '@eslint/js' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import globals from 'globals' +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; export default [ - { ignores: ['dist'] }, + { ignores: ["dist"] }, { - files: ['**/*.{js,jsx}'], + files: ["**/*.{js,jsx}"], languageOptions: { ecmaVersion: 2020, globals: globals.browser, parserOptions: { - ecmaVersion: 'latest', + ecmaVersion: "latest", ecmaFeatures: { jsx: true }, - sourceType: 'module' - } + sourceType: "module", + }, }, plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh + "react-hooks": reactHooks, + "react-refresh": reactRefresh, }, rules: { ...js.configs.recommended.rules, ...reactHooks.configs.recommended.rules, - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true } - ] - } - } -] + "no-unused-vars": ["error", { varsIgnorePattern: "^[A-Z_]" }], + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +]; diff --git a/index.html b/index.html index f7ac4e4..c868747 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,9 @@ - + - Todo + Todo list
diff --git a/package.json b/package.json index caf6289..1377700 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,14 @@ "preview": "vite preview" }, "dependencies": { + "moment": "^2.30.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-datepicker": "^8.4.0", + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.6.0", + "styled-components": "^6.1.18", + "zustand": "^5.0.4" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -19,9 +25,11 @@ "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.21.0", + "eslint-config-prettier": "^10.1.5", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "prettier": "^3.5.3", "vite": "^6.2.0" } } diff --git a/public/empty.png b/public/empty.png new file mode 100644 index 0000000..83ff1e6 Binary files /dev/null and b/public/empty.png differ diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..87004b9 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/pull_request_template.md b/pull_request_template.md index 154c92e..e69de29 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +0,0 @@ -Please include your Netlify link here. \ No newline at end of file diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..8093770 --- /dev/null +++ b/src/App.css @@ -0,0 +1,44 @@ +body { + background: white; + font-family: arial; +} + +p { + font-size: 18px; + margin: 0; +} + +.image-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + +.image-wrapper img { + width: 150px; + margin: 20px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.count-container { + display: flex; + flex-direction: column; + gap: 5px; +} + +@media (min-width: 768px) { + .count-container { + flex-direction: row; + justify-content: space-between; + } +} diff --git a/src/App.jsx b/src/App.jsx index 5427540..2db2951 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,30 @@ +import styled from "styled-components"; +import { TaskForm } from "./components/TaskForm"; +import { HeaderComponent } from "./components/Header"; +import { FooterComponent } from "./components/Footer"; +import "./App.css" + +const CardWrapper = styled.section` + background-color: rgb(237, 239, 239); + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + overflow: hidden; + max-width: 1000px; + margin: 50px auto; + + @media (max-width: 768px) { + margin: 0px; + } +`; + export const App = () => { return ( -

React Boilerplate

+ <> + + + + + + ) } diff --git a/src/components/Count.jsx b/src/components/Count.jsx new file mode 100644 index 0000000..b2d2416 --- /dev/null +++ b/src/components/Count.jsx @@ -0,0 +1,51 @@ +import styled from "styled-components"; +import { useTaskStore } from "../stores/useTaskStore"; +import moment from "moment"; + +const CountContainer = styled.div` + margin-top: 16px; + text-align: left; +`; + +const WarningText = styled.p` + font-weight: bold; + color: darkred; + padding-top: 16px; + + @media (min-width: 768px) { + text-align: center; + } +`; + +const InfoText = styled.p` + margin: 4px 0; +`; + +export const Count = () => { + const tasks = useTaskStore((state) => state.tasks); + + const completed = tasks.filter((task) => task.isCompleted).length; + const starredTasks = tasks.filter((task) => task.isStarred).length; + const overdueTasks = tasks.filter( + (task) => + task.dueDate && + moment(task.dueDate).isValid() && + moment(task.dueDate).isBefore(moment(), "day") + ).length; + const totalTasks = tasks.length; + + return ( + + + Completed tasks: {completed} / {totalTasks} + + Starred tasks: {starredTasks} + {overdueTasks > 0 && ( + + You have {overdueTasks} {overdueTasks === 1 ? "task" : "tasks"} that{" "} + {overdueTasks === 1 ? "has" : "have"} passed the due date. + + )} + + ); +}; diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx new file mode 100644 index 0000000..2f4503a --- /dev/null +++ b/src/components/Footer.jsx @@ -0,0 +1,56 @@ +import styled from "styled-components"; +import { FaGithub } from "react-icons/fa"; + +export const Footer = styled.footer` + padding: 20px; + background-color: rgb(237, 239, 239); + max-width: 600px; + margin-left: auto; + margin-right: auto; + margin-bottom: 50px; + box-sizing: border-box; + text-align: center; + font-size: 20px; + font-weight: bold; +`; + +const FooterText = styled.h2` + font-size: 16px; + text-align: center; + color: #333; + + a { + color: #333; + text-decoration: none; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 4px; + + &:hover { + text-decoration: none; + } + + svg { + font-size: 18px; + padding-right: 5px; + } + } +`; + +export const FooterComponent = () => { + return ( + + ); +}; \ No newline at end of file diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..9255612 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; + +export const Header = styled.header` + padding: 20px; + background-color:rgb(237, 239, 239); + max-width: 600px; + margin-left: auto; + margin-right: auto; + margin-top: 50px; + box-sizing: border-box; + + text-align: center; + font-size: 20px; + font-weight: bold; +` + +export const HeaderText = styled.h1` + font-size: 40px; + color:rgb(0, 0, 0); + margin: 0; + text-align: center; + letter-spacing: 5px; +` + +export const HeaderComponent = () => { + return ( +
+ TODO LIST +
+ ); +} \ No newline at end of file diff --git a/src/components/TaskForm.jsx b/src/components/TaskForm.jsx new file mode 100644 index 0000000..de7499d --- /dev/null +++ b/src/components/TaskForm.jsx @@ -0,0 +1,119 @@ +import { useState } from "react"; +import { useTaskStore } from "../stores/useTaskStore"; +import { TaskList } from "./TaskList"; +import { Count } from "./Count" + +import DatePicker from "react-datepicker" +import "react-datepicker/dist/react-datepicker.css" + +import { FaRegTrashAlt, FaCheckCircle, FaRegCheckCircle } from "react-icons/fa"; +import { + FormContainer, + StyledForm, + FormLabel, + FormInput, + AddButton, + IconWrapper, + StyledFunctionButton, + ButtonRow, + FormRow +} from "./TaskForm.styles.jsx"; + +export const TaskForm = () => { + const [taskValue, setTaskValue] = useState(""); + const [dueDate, setDueDate] = useState(null); + const addTask = useTaskStore((state) => state.addTask); + const completeAllTasks = useTaskStore((state) => state.completeAllTasks); + const uncompleteAllTasks = useTaskStore((state) => state.uncompleteAllTasks); + const removeAllTasks = useTaskStore((state) => state.removeAllTasks); + + const tasks = useTaskStore((state) => state.tasks); + + const handleSubmit = (e) => { + e.preventDefault(); + if (taskValue.trim()) { + const dueDateString = dueDate ? dueDate.toISOString() : null; + addTask(taskValue, dueDateString); + setTaskValue(""); + } + }; + + const handleToggleCompleteAll = () => { + const hasIncomplete = tasks.some(task => !task.isCompleted); + + if (hasIncomplete) { + completeAllTasks(); + } else { + uncompleteAllTasks(); + } + }; + + const handleDeleteAll = () => { + if (window.confirm("Are you sure you want to delete all tasks? This action cannot be undone.")) { + removeAllTasks(); + } + }; + + const allTasksCompleted = tasks.every(task => task.isCompleted); + const hasAnyTasks = tasks.length > 0; + + return ( + + + + Task input + + + + setTaskValue(e.target.value)} + placeholder="Add new task" + required + /> + + setDueDate(date)} + placeholderText="Due date" + dateFormat="yyyy-MM-dd" + className="date-picker-input" + /> + + + + + + + + + + + {hasAnyTasks && ( + + + + {allTasksCompleted ? ( + + ) : ( + + )} + + {allTasksCompleted ? "Unmark all" : "Complete all"} + + + {tasks.length > 0 && ( + + Delete all + + )} + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/TaskForm.styles.jsx b/src/components/TaskForm.styles.jsx new file mode 100644 index 0000000..bfb9942 --- /dev/null +++ b/src/components/TaskForm.styles.jsx @@ -0,0 +1,133 @@ + +import styled from "styled-components"; + +export const FormRow = styled.div` + display: flex; + gap: 12px; + align-items: stretch; + width: 100%; + flex-direction: column; + + input, + .date-picker-input { + flex: 1; + padding: 14px 16px; + border: 1px solid #ccc; + border-radius: 6px; + font-size: 16px; + min-height: 50px; + box-sizing: border-box; + color: #333333; + width: 100%; + } + + @media (min-width: 768px) { + flex-direction: row; + } +`; + +export const FormContainer = styled.div` + padding: 20px; + background-color:rgb(237, 239, 239); + max-width: 600px; + margin-left: auto; + margin-right: auto; + box-sizing: border-box; +`; + +export const StyledForm = styled.form` + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; + margin-bottom: 20px; +`; + +export const FormLabel = styled.label.attrs(() => ({ + className: "visually-hidden" +}))``; + +export const FormInput = styled.input` + flex-grow: 1; + padding: 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 16px; + height: 40px; + box-sizing: border-box; + color: #333333; + } +`; + +export const AddButton = styled.button` + background-color:rgb(75, 24, 203); + color: white; + width: 40px; + height: 40px; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 24px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + align-self: center; + flex-shrink: 0; + margin: 0 auto; + + &:hover { + background-color: gray; + } +`; + +export const IconWrapper = styled.span` + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +`; + +export const StyledFunctionButton = styled.button` + color: white; + width: 40%; + flex-grow: 1; + height: 40px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: background-color 0.2s ease-in-out; + + + background-color: ${props => props.type === "delete" ? "rgb(75, 24, 203);" : "rgb(75, 24, 203);"}; + + &:hover { + background-color: ${props => props.type === "delete" ? "rgb(75, 24, 203);" : "gray"}; + } + + ${IconWrapper} { + margin-right: 8px; + font-size: 20px; + } +`; +export const ButtonRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; + width: 100%; + + @media (max-width: 768px) { + flex-direction: column; + + button { + width: 100%; + } + } +`; \ No newline at end of file diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx new file mode 100644 index 0000000..17d50f1 --- /dev/null +++ b/src/components/TaskList.jsx @@ -0,0 +1,83 @@ +import { useTaskStore } from "../stores/useTaskStore"; + +import { sortTasks } from "../utils/sortTasks.js"; +import { FaRegTrashAlt, FaStar, FaRegStar } from "react-icons/fa"; +import moment from "moment"; + +import { + TaskListContainer, + TaskItemWrapper, + TaskText, + TaskActions, + DeleteButton, + StarredButton, + Checkbox, + EmptyListMessage, + TimestampText, + TaskTextWrapper +} from "./TaskList.styles.jsx"; + +export const TaskList = () => { + const tasks = useTaskStore((state) => state.tasks); + const deleteTask = useTaskStore((state) => state.deleteTask); + const completeTask = useTaskStore((state) => state.completeTask); + const toggleStarred = useTaskStore((state) => state.toggleStarred); + + + const sortedTasks = sortTasks(tasks); + + return ( + + {sortedTasks.length === 0 && ( + +
+ Picture of no tasks +
+ No task added yet. Add one to get started! +
+ )} + + {sortedTasks.map((task) => { + const checkboxId = `task-${task.id}`; + const isOverdue = task.dueDate && moment(task.dueDate).isBefore(moment(), "day"); + return ( + + + + completeTask(task.id)} + /> + {task.text || "Unnamed task"} + + {task.dueDate && moment(task.dueDate).isValid() && ( + + + {isOverdue ? "Passed" : "Duedate"}: {moment(task.dueDate).format("YYYY-MM-DD")} + + + + )} + + + + toggleStarred(task.id)} + $isStarred={task.isStarred} + title={task.isStarred ? "Remove star" : "Star this task"} + > + {task.isStarred ? : } + + + deleteTask(task.id)} title="Delete task"> + + + + + ); + })} +
+ ); +}; \ No newline at end of file diff --git a/src/components/TaskList.styles.jsx b/src/components/TaskList.styles.jsx new file mode 100644 index 0000000..df4c466 --- /dev/null +++ b/src/components/TaskList.styles.jsx @@ -0,0 +1,116 @@ +import styled from "styled-components"; + +export const TaskListContainer = styled.ul` + list-style: none; + padding: 0; + margin: 20px 0; + width: 100%; +`; + +export const TaskItemWrapper = styled.li` + display: flex; + align-items: center; + justify-content: space-between; + background-color: #f8f8f8; + border: 1px solid #ddd; + border-radius: 4px; + margin-bottom: 10px; + padding: 10px 15px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + transition: background-color 0.2s ease-in-out; + + &.completed { + background-color:#EDEFEF; + text-decoration: line-through; + font-style: italic; + color: #666; + } +`; + +export const TaskTextWrapper = styled.div` + display: flex; + flex-direction: column; + flex-grow: 1; + margin-right: 10px; +`; + +export const TaskText = styled.label` + display: flex; + align-items: center; + flex-grow: 1; + margin-right: 10px; + font-size: 18px; + word-break: break-word; + cursor: pointer; +`; + +export const TaskActions = styled.div` + display: flex; + gap: 8px; + align-items: center; +`; + +export const DeleteButton = styled.button` + background-color: darkred; + color: white; + border: none; + border-radius: 50%; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 16px; + flex-shrink: 0; + + &:hover { + background-color: #c82333; + } +`; + +export const StarredButton = styled.button` + background: none; + border: none; + color: ${props => props.$isStarred ? "#ba4a00" : "#333333"}; + cursor: pointer; + font-size: 22px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + padding: 0; + + svg { + display: block; + margin: 0; + padding: 0; + line-height: 1; + } +`; + +export const Checkbox = styled.input` + margin-right: 10px; + width: 20px; + height: 20px; + cursor: pointer; + flex-shrink: 0; + accent-color:rgb(75, 24, 203); +`; + +export const EmptyListMessage = styled.li` + text-align: center; + color: #666; + margin: 20px 0; +`; + +export const TimestampText = styled.span` + font-size: 16px; + color: #333; + margin-top: 5px; + + @media (min-width: 767px) { + font-size: 15px; + display: block; + } +`; \ No newline at end of file diff --git a/src/index.css b/src/index.css deleted file mode 100644 index f7c0aef..0000000 --- a/src/index.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; -} diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9..52cc5f9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,11 +1,9 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' +import React from "react" +import ReactDOM from "react-dom/client" -import { App } from './App.jsx' +import { App } from "./App.jsx" -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById("root")).render( diff --git a/src/stores/useTaskStore.js b/src/stores/useTaskStore.js new file mode 100644 index 0000000..dc3d2a0 --- /dev/null +++ b/src/stores/useTaskStore.js @@ -0,0 +1,60 @@ +// src/stores/useTaskStore.js +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export const useTaskStore = create( + persist( + (set) => ({ + tasks: [], + + addTask: (text, dueDate) => + set((state) => ({ + tasks: [ + ...state.tasks, + { + id: Date.now(), + text, + isCompleted: false, + isStarred: false, + createdAt: new Date().toISOString(), + dueDate: dueDate ? new Date(dueDate).toISOString() : null, + }, + ], + })), + + deleteTask: (id) => + set((state) => ({ + tasks: state.tasks.filter((task) => task.id !== id), + })), + + completeTask: (id) => + set((state) => ({ + tasks: state.tasks.map((task) => + task.id === id ? { ...task, isCompleted: !task.isCompleted } : task + ), + })), + + completeAllTasks: () => + set((state) => ({ + tasks: state.tasks.map((task) => ({ ...task, isCompleted: true })), + })), + + uncompleteAllTasks: () => + set((state) => ({ + tasks: state.tasks.map((task) => ({ ...task, isCompleted: false })), + })), + + removeAllTasks: () => set({ tasks: [] }), + + toggleStarred: (id) => + set((state) => ({ + tasks: state.tasks.map((task) => + task.id === id ? { ...task, isStarred: !task.isStarred } : task + ), + })), + }), + { + name: "task-storage", + } + ) +); diff --git a/src/utils/sortTasks.js b/src/utils/sortTasks.js new file mode 100644 index 0000000..d6c0c40 --- /dev/null +++ b/src/utils/sortTasks.js @@ -0,0 +1,17 @@ +export const sortTasks = (tasks) => { + return [...tasks].sort((a, b) => { + if (a.isCompleted && !b.isCompleted) return 1; + if (!a.isCompleted && b.isCompleted) return -1; + + if (!a.isCompleted && !b.isCompleted) { + if (a.isStarred && !b.isStarred) return -1; + if (!a.isStarred && b.isStarred) return 1; + } + + if (a.createdAt && b.createdAt) { + return new Date(b.createdAt) - new Date(a.createdAt); + } + + return 0; + }); +}; diff --git a/todo.txt b/todo.txt new file mode 100644 index 0000000..b917354 --- /dev/null +++ b/todo.txt @@ -0,0 +1,8 @@ +Todo: Todo + +[] implement timestamp +[] get delete and complete button to work with function +[] only show delete/complete when atleast 1 task in liste +[] add counter tho show how many task remaining/is +[] archived tab? +[] styling? diff --git a/vite.config.js b/vite.config.js index ba24244..1ff0da0 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,7 @@ -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] -}) + plugins: [react()], +});