diff --git a/README.md b/README.md index d1c68b5..09ea750 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# Todo \ No newline at end of file +https://lillebrorgrodatodos.netlify.app/ \ No newline at end of file diff --git a/index.html b/index.html index f7ac4e4..3ba2d95 100644 --- a/index.html +++ b/index.html @@ -1,16 +1,22 @@ - - - - - Todo - - -
- - - + + + + + + + + + Todo + + + +
+ + + + \ No newline at end of file diff --git a/package.json b/package.json index caf6289..b5b2be8 100644 --- a/package.json +++ b/package.json @@ -10,14 +10,22 @@ "preview": "vite preview" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "styled-components": "^6.1.18", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", - "@vitejs/plugin-react": "^4.3.4", + "@vitejs/plugin-react": "^4.4.1", + "babel-plugin-styled-components": "^2.1.4", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", diff --git a/src/App.jsx b/src/App.jsx index 5427540..84804d3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,18 @@ +import TodoList from "./components/TodoList" +import TodoListCount from "./components/TodoListCount" +import ClearTodos from "./components/ClearTodos" +import GlobalStyle from "./styles/GlobalStyle" + + export const App = () => { return ( -

React Boilerplate

+ <> + + + + + + + ) } diff --git a/src/components/ClearTodos.jsx b/src/components/ClearTodos.jsx new file mode 100644 index 0000000..7127905 --- /dev/null +++ b/src/components/ClearTodos.jsx @@ -0,0 +1,27 @@ +import useTodoStore from "../store/todoStore" +import styled from "styled-components" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faTrashCan } from "@fortawesome/free-solid-svg-icons" + +const ClearContainer = styled.div` + + bottom: 20px; + left: 20px; + + @media (min-width: 601px) { + position: fixed; + z-index: 1000; + } + ` + +const ClearTodos = () => { + const clearTodos = useTodoStore((state) => state.clearTodos) + + return ( + + + + ) +} + +export default ClearTodos \ No newline at end of file diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx new file mode 100644 index 0000000..cec603a --- /dev/null +++ b/src/components/TodoList.jsx @@ -0,0 +1,100 @@ +import { useState } from "react" +import useTodoStore from "../store/todoStore" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faTrashCan, faPlus, faClipboardList } from "@fortawesome/free-solid-svg-icons" +import MobileBox from "../styles/MobileCount.jsx" +import { TodoListContainer, StyledInput, StyledFilterButtons, FilterButton, TodoListStyled, TodoItem, EmptyState } from "../styles/TodoList.styles" + + +const formatDateFancy = (isoString) => { + const date = new Date(isoString) + const day = date.getDate(); + const month = date.toLocaleString("en-EN", { month: "long" }) + const hour = date.getHours().toString().padStart(2, "0"); + const minute = date.getMinutes().toString().padStart(2, "0"); + return `${day} ${month} kl.${hour}:${minute} ` +} + +const TodoList = () => { + const { todos, addTodo, removeTodo, toggleTodo, markAllComplete } = useTodoStore() + const [newTodo, setNewTodo] = useState("") + const [filter, setFilter] = useState("all") + + const handleAddTodo = () => { + if (newTodo.trim()) { + addTodo(newTodo) + setNewTodo("") + } + } + + return ( + +

Todo List

+ + + setNewTodo(event.target.value)} + placeholder="Add a new todo" + aria-label="add a new todo" /> + + + + + setFilter("all")}>All + setFilter("completed")}>Completed + setFilter("incomplete")}>Incomplete + + + + 📝 Todo: {todos.length} + + + + {todos.filter((todo) => { + if (filter === "completed") return todo.completed + if (filter === "incomplete") return !todo.completed + return true + }).length === 0 && ( + + +

Your todo list is empty – time to add something!

+
+ )} + + {todos.filter((todo) => { + if (filter === "completed") return todo.completed + if (filter === "incomplete") return !todo.completed + return true + }) + + .map((todo) => ( + + toggleTodo(todo.id)} + aria-label="todo checkbox" + /> + {todo.text} - {formatDateFancy(todo.createdAt)} + + + ))} +
+ + + +
+ ) +} +export default TodoList \ No newline at end of file diff --git a/src/components/TodoListCount.jsx b/src/components/TodoListCount.jsx new file mode 100644 index 0000000..8ce2fe4 --- /dev/null +++ b/src/components/TodoListCount.jsx @@ -0,0 +1,50 @@ +import useTodoStore from "../store/todoStore" +import styled from "styled-components" + +const CountContainer = styled.div` + position: fixed; + bottom: 20px; + right: 20px; + background-color: #fff; + border-radius: 10px; + padding: 20px 30px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); + z-index: 1000; + text-align: left; + + @media (max-width: 600px) { + display: none; + } +` + +const Heading = styled.h2` + margin-bottom: 15px; + color: #333; +` + +const CountText = styled.p` + margin: 5px 0; + font-size: 16px; + color: #444; + font-weight: 500; +` + +const TodoListCount = () => { + const todos = useTodoStore((state) => state.todos) + const todoCount = todos.length + const completedCount = todos.filter((todo) => todo.completed).length + const incompleteCount = todoCount - completedCount + + return ( + + + Stats + Total: {todoCount} + ✔ Done: {completedCount} + ⏳ Left: {incompleteCount} + + ) +} + +export default TodoListCount + diff --git a/src/store/todoStore.jsx b/src/store/todoStore.jsx new file mode 100644 index 0000000..f830082 --- /dev/null +++ b/src/store/todoStore.jsx @@ -0,0 +1,39 @@ +import { create } from "zustand" +import { persist } from "zustand/middleware" + +const useTodoStore = create(persist((set) => ({ + todos: [], + + addTodo: (todo) => set((state) => ({ + todos: [...state.todos, { + id: Date.now(), + text: todo, + completed: false, + createdAt: new Date().toISOString(), + }], + })), + + removeTodo: (id) => set((state) => ({ + todos: state.todos.filter((todo) => todo.id !== id), + })), + + toggleTodo: (id) => set((state) => ({ + todos: state.todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + })), + markAllComplete: () => set((state) => ({ + todos: state.todos.map((todo) => ({ ...todo, completed: true })), + })), + clearTodos: () => set(() => ({ + todos: [], + })), + +}), + + { + name: "todo-storage", + } + +)) +export default useTodoStore \ No newline at end of file diff --git a/src/styles/GlobalStyle.jsx b/src/styles/GlobalStyle.jsx new file mode 100644 index 0000000..cb09850 --- /dev/null +++ b/src/styles/GlobalStyle.jsx @@ -0,0 +1,49 @@ + +import { createGlobalStyle } from "styled-components" + + +const GlobalStyle = createGlobalStyle` + + * { + box-sizing: border-box; + + } + + body { + font-family: "Poppins", sans-serif; + background-color: #ddfff7; + color: #333; + display: flex; + flex-direction: column; + align-items: stretch; + /* line-height: 1.6; */ + } + + h1, h2 { + color: #333; + } + + button { + background-color: #ffa69e; + color: #242424; + border: none; + padding: 10px 10px; + cursor: pointer; + border-radius: 5px; + font-size: 16px; + + &:hover { + background-color: #ff5546; + } + } + + input[type="text"] { + padding: 10px; + border-radius: 5px; + border: 1px solid #ccc; + } + + + +` +export default GlobalStyle \ No newline at end of file diff --git a/src/styles/MobileCount.jsx b/src/styles/MobileCount.jsx new file mode 100644 index 0000000..50af01c --- /dev/null +++ b/src/styles/MobileCount.jsx @@ -0,0 +1,21 @@ + +import styled from 'styled-components' + + +const MobileBox = styled.div` + background-color: #ffa69e; + color: #242424; + border-radius: 50px; + padding: 10px 20px; + font-size: 16px; + + z-index: 1000; + + + @media (min-width: 601px) { + display: none; + + } +` + +export default MobileBox \ No newline at end of file diff --git a/src/styles/TodoList.styles.jsx b/src/styles/TodoList.styles.jsx new file mode 100644 index 0000000..475e256 --- /dev/null +++ b/src/styles/TodoList.styles.jsx @@ -0,0 +1,160 @@ +import styled from "styled-components" + +export const TodoListContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 15px; + padding: 10px; + gap: 10px; + background-color: #93e1d8; + border-radius: 10px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + + @media (min-width: 768px) { + padding: 10px; + h1 { + font-size: 22px; + } + } + + @media (min-width: 1024px) { + padding: 20px; + h1 { + font-size: 28px; + } + } + + @media (min-width: 1440px) { + padding: 30px; + h1 { + font-size: 32px; + } + } +` + +export const StyledFilterButtons = styled.div` + display: flex; + gap: 10px; + margin-bottom: 10px; +` + +export const FilterButton = styled.button` + background-color: ${({ $active }) => ($active ? "#ff5546" : "#ffa69e")}; + color: #242424; + border: none; + padding: 10px 10px; + cursor: pointer; + border-radius: 5px; + transition: background-color 0.3s ease; + font-size: 16px; + + &:hover { + background-color: ${({ $active }) => ($active ? "#ffa69e" : "#ff5546")}; + } +` + +export const StyledInput = styled.div` + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + + input[type="text"] { + flex: 1; + + } + + button { + background-color: #ffa69e; + border: none; + padding: 10px; + border-radius: 5px; + font-size: 18px; + cursor: pointer; + color: #242424; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: #ff5546; + } + } +` + +export const TodoListStyled = styled.ul` + list-style: none; + padding: 0; + width: 100%; + max-width: 600px; +` + +export const TodoItem = styled.li` + display: flex; + align-items: center; + justify-content: space-between; + background-color: #fff; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 10px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); + + span { + flex: 1; + margin-left: 10px; + color: ${({ $completed }) => ($completed ? "#999" : "#242424")}; + font-size: 16px; + text-decoration: ${({ $completed }) => ($completed ? "line-through" : "none")}; + transition: color 0.3s ease, text-decoration 0.3s ease; + font-style: ${({ $completed }) => ($completed ? "italic" : "normal")}; + word-break: break-all; + } + + input[type="checkbox"] { + transform: scale(1.2); + } + + button { + background: none; + border: none; + color: #242424; + cursor: pointer; + font-size: 18px; + + &:hover { + color: #ff5546; + background: none; + } + } +` + +export const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 40px; + color: #302f2f; + font-size: 18px; + animation: fadeIn 1s ease; + + svg { + font-size: 80px; + margin-bottom: 20px; + color: #ffa69e; + animation: float 3s ease-in-out infinite; + } + + @keyframes float { + 0% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } + 100% { transform: translateY(0px); } + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } +` diff --git a/vite.config.js b/vite.config.js index ba24244..315dcdd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,13 @@ -import react from '@vitejs/plugin-react' +// vite.config.js import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' -// https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] -}) + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-styled-components', { displayName: true }]] + } + }) + ] +}) \ No newline at end of file