diff --git a/README.md b/README.md index d1c68b5..467edd3 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -# Todo \ No newline at end of file +# Todo App +A simple todo app built in React using Zustand for state management. \ +Users are able to add, list, filter and remove tasks, and toggle whether a task is completed or not. + +## Features +* Each task has a time stamp when it was created +* A task can be marked as completed (or uncompleted) by clicking the task +* A task can be deleted by clicking the trash bin icon next to each task +* All tasks can be deleted by clicking the trash bin icon in the top control bar +* All tasks can be marked as completed by clicking the check box icon in the top control bar +* Uncompleted tasks can be filtered out by clicking the funnel icon in the top control bar +* Toggle between light/dark mode by clicking the toggle button in the header + +## Installation & Usage +Install the required dependencies by running the following command: +``` +npm install +``` +Start the server by running: +``` +npm run dev +``` + +## Link +https://task-completed.netlify.app/ + +## Screenshots + +Screenshot of todo app - dark mode. +Screenshot of todo app - light mode. + diff --git a/index.html b/index.html index f7ac4e4..1b7448b 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,39 @@ - - - - - Todo - - -
- - - + + + + + + + + + Todo + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index caf6289..b69d8bd 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,13 @@ "preview": "vite preview" }, "dependencies": { + "@tailwindcss/vite": "^4.1.7", + "date-fns": "^4.1.0", + "lucide-react": "^0.511.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwindcss": "^4.1.7", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/public/TodoApp-screenshot-light.png b/public/TodoApp-screenshot-light.png new file mode 100644 index 0000000..d18ddbb Binary files /dev/null and b/public/TodoApp-screenshot-light.png differ diff --git a/public/TodoApp-screenshot.png b/public/TodoApp-screenshot.png new file mode 100644 index 0000000..13102fa Binary files /dev/null and b/public/TodoApp-screenshot.png differ diff --git a/src/App.jsx b/src/App.jsx index 5427540..f4642c9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,16 @@ +import Footer from "./sections/Footer" +import Header from "./sections/Header" +import Main from "./sections/Main" + export const App = () => { return ( -

React Boilerplate

+ <> +
+
+
+
+ + ) } diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx new file mode 100644 index 0000000..b17ce2f --- /dev/null +++ b/src/components/Controls.jsx @@ -0,0 +1,60 @@ +import useTaskStore from "../stores/useTaskStore" +import { useState } from "react" +import ControlsButton from "./ControlsButton" +import { Trash2, Funnel, SquareCheck, Plus } from "lucide-react" + +const Controls = () => { + const addTask = useTaskStore(state => state.addTask) + const completeAll = useTaskStore(state => state.completeAll) + const deleteAll = useTaskStore(state => state.deleteAll) + const hideCompleted = useTaskStore(state => state.hideCompleted) + + const [newTask, setNewTask] = useState("") + const [inputVisible, setInputVisible] = useState(false) + + const toggleInputVisible = () => { + setInputVisible(!inputVisible) + } + + const handleChange = (event) => { + setNewTask(event.target.value) + } + + const handleSubmit = (event) => { + event.preventDefault() + + if (newTask) { + addTask(newTask.trim()) + setNewTask("") + setInputVisible(false) + } else { + setInputVisible(false) + } + } + + return ( +
+
+
+ {!inputVisible && + } + {inputVisible && + + } + {inputVisible && } + {!inputVisible && +
+ hideCompleted()} icon={Funnel} ariaLabel="Filter" /> + completeAll()} icon={SquareCheck} ariaLabel="Complete all" /> + deleteAll()} icon={Trash2} ariaLabel="Delete all" /> +
} +
+
+
+ ) +} + +export default Controls \ No newline at end of file diff --git a/src/components/ControlsButton.tsx b/src/components/ControlsButton.tsx new file mode 100644 index 0000000..ffe2add --- /dev/null +++ b/src/components/ControlsButton.tsx @@ -0,0 +1,25 @@ +type ControlsButtonProps = { + icon: React.ElementType + onClick?: () => void + ariaLabel?: string +} + +const ControlsButton = ({ + icon: Icon, + onClick, + ariaLabel = 'icon button', +}: ControlsButtonProps): JSX.Element => { + return ( + + ); +}; + +export default ControlsButton; + diff --git a/src/components/Counter.jsx b/src/components/Counter.jsx new file mode 100644 index 0000000..12abc20 --- /dev/null +++ b/src/components/Counter.jsx @@ -0,0 +1,14 @@ +import useTaskStore from "../stores/useTaskStore" + +const Counter = () => { + const tasks = useTaskStore(state => state.tasks) + const unCompletedTasks = tasks.filter(task => !task.isCompleted) + + return ( +
+

{unCompletedTasks.length} of {tasks.length} tasks remaining

+
+ ) +} + +export default Counter \ No newline at end of file diff --git a/src/components/Empty.jsx b/src/components/Empty.jsx new file mode 100644 index 0000000..77a5c12 --- /dev/null +++ b/src/components/Empty.jsx @@ -0,0 +1,11 @@ +const Empty = () => { + return ( +
+

🏖️

+

Nothing to do?

+

Enjoy it while it lasts.

+
+ ) +} + +export default Empty \ No newline at end of file diff --git a/src/components/SocialButton.tsx b/src/components/SocialButton.tsx new file mode 100644 index 0000000..80deeb5 --- /dev/null +++ b/src/components/SocialButton.tsx @@ -0,0 +1,26 @@ +type SocialButtonProps = { + icon: React.ElementType + link: string + ariaLabel?: string +} + +const SocialButton = ({ + icon: Icon, + link = "", + ariaLabel = 'icon button', +}: SocialButtonProps): JSX.Element => { + return ( +
  • + + + +
  • + ); +}; + +export default SocialButton; \ No newline at end of file diff --git a/src/components/Task.jsx b/src/components/Task.jsx new file mode 100644 index 0000000..2366933 --- /dev/null +++ b/src/components/Task.jsx @@ -0,0 +1,27 @@ +import { Trash2 } from "lucide-react" +import TaskButton from "./TaskButton" +import useTaskStore from "../stores/useTaskStore" +import { formatRelative } from "date-fns" + +const Task = ({ task }) => { + const toggleCompleted = useTaskStore(state => state.toggleCompleted) + const deleteTask = useTaskStore(state => state.deleteTask) + + return ( +
    + +
    + deleteTask(task.id)} icon={Trash2} ariaLabel="Delete task" /> +
    +
    + ) +} + +export default Task \ No newline at end of file diff --git a/src/components/TaskButton.tsx b/src/components/TaskButton.tsx new file mode 100644 index 0000000..b453718 --- /dev/null +++ b/src/components/TaskButton.tsx @@ -0,0 +1,25 @@ +type TaskButtonProps = { + icon: React.ElementType + onClick?: () => void + ariaLabel?: string +} + +const TaskButton = ({ + icon: Icon, + onClick, + ariaLabel = 'icon button', +}: TaskButtonProps): JSX.Element => { + return ( + + ) +} + +export default TaskButton + + diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx new file mode 100644 index 0000000..2cf8dc5 --- /dev/null +++ b/src/components/TaskList.jsx @@ -0,0 +1,21 @@ +import Task from "./Task" +import Empty from "./Empty" +import Counter from "./Counter" +import useTaskStore from "../stores/useTaskStore" + +const TaskList = () => { + const tasks = useTaskStore(state => state.tasks) + return ( +
    + {tasks.length > 0 && ( +
    + {tasks.map(task => )} +
    + )} + {tasks.length > 0 && } + {tasks.length === 0 && } +
    + ) +} + +export default TaskList \ No newline at end of file diff --git a/src/index.css b/src/index.css index f7c0aef..cd2ac2c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,28 @@ +@import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + font-family: "Work Sans", Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; } + +@theme { + --color-primary: #EFF6FF; + --color-secondary: #1E3A8A; + --color-background: #FFFFF; + --color-surface: #F9FAFB; + --color-border: #E5E7EB; + --color-text: #111827; + --color-text-secondary: #525967; + --color-accent: #064DC1; + --color-hover: #03378E; + --color-primary-dark: #1E3A8A; + --color-secondary-dark: #DBEAFE; + --color-background-dark: #1F2937; + --color-surface-dark: #111827; + --color-border-dark: #374151; + --color-text-dark: #F9FAFB; + --color-text-secondary-dark: #ADB4C0; + --color-accent-dark: #72B9FF; + --color-hover-dark: #BADDFF; + +} \ No newline at end of file diff --git a/src/sections/Footer.jsx b/src/sections/Footer.jsx new file mode 100644 index 0000000..cefb1df --- /dev/null +++ b/src/sections/Footer.jsx @@ -0,0 +1,22 @@ +import { Github, Linkedin } from 'lucide-react' +import SocialButton from '../components/SocialButton' +import useThemeStore from '../stores/useThemeStore' + +const Footer = () => { + const theme = useThemeStore(state => state.theme) + return ( + + ) +} + +export default Footer \ No newline at end of file diff --git a/src/sections/Header.jsx b/src/sections/Header.jsx new file mode 100644 index 0000000..a47ad1d --- /dev/null +++ b/src/sections/Header.jsx @@ -0,0 +1,19 @@ +import useThemeStore from "../stores/useThemeStore" +import { ToggleLeft } from "lucide-react" + +const Header = () => { + const { theme, toggleTheme } = useThemeStore() + return ( +
    +
    +

    TO DO LIST

    + +
    +
    + ) +} + +export default Header \ No newline at end of file diff --git a/src/sections/Main.jsx b/src/sections/Main.jsx new file mode 100644 index 0000000..2224556 --- /dev/null +++ b/src/sections/Main.jsx @@ -0,0 +1,15 @@ +import Controls from "../components/Controls" +import TaskList from "../components/TaskList" +import useThemeStore from "../stores/useThemeStore" + +const Main = () => { + const theme = useThemeStore(state => state.theme) + return ( +
    + + +
    + ) +} + +export default Main \ No newline at end of file diff --git a/src/stores/useTaskStore.jsx b/src/stores/useTaskStore.jsx new file mode 100644 index 0000000..d910d7c --- /dev/null +++ b/src/stores/useTaskStore.jsx @@ -0,0 +1,51 @@ +import { create } from "zustand" +import { devtools } from "zustand/middleware" + +const useTaskStore = create( + devtools((set) => ({ + tasks: [], + + addTask: (task) => { + const newTask = { + id: Date.now(), + task, + isCompleted: false + } + + set(state => ({ tasks: [newTask, ...state.tasks] })) + }, + + deleteTask: (id) => { + set(state => ({ tasks: state.tasks.filter(task => task.id !== id) })) + }, + + toggleCompleted: (id) => { + set(state => ({ + tasks: state.tasks.map(task => { + if (task.id === id) { + return { ...task, isCompleted: !task.isCompleted } + } + else { + return task + } + } + ) + })) + }, + + deleteAll: () => { set({ tasks: [] }) }, + + completeAll: () => set(state => ({ + tasks: state.tasks.map(task => { + return { ...task, isCompleted: true } + }) + })), + + hideCompleted: () => set(state => ({ + tasks: state.tasks.filter(task => !task.isCompleted) + })), + + })) +) + +export default useTaskStore \ No newline at end of file diff --git a/src/stores/useThemeStore.jsx b/src/stores/useThemeStore.jsx new file mode 100644 index 0000000..240ef39 --- /dev/null +++ b/src/stores/useThemeStore.jsx @@ -0,0 +1,14 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' + +const useThemeStore = create( + devtools((set) => ({ + theme: 'light', + + toggleTheme: () => set((state) => ({ + theme: state.theme === 'light' ? 'dark' : 'light' + })) + })) +) + +export default useThemeStore \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index ba24244..7fb2473 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,8 @@ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] + plugins: [react(), tailwindcss()] })