diff --git a/README.md b/README.md index d1c68b5..ece7b69 100644 --- a/README.md +++ b/README.md @@ -1 +1,24 @@ -# Todo \ No newline at end of file +## links +- github: https://github.com/alex91-html/js-project-todo +- netlify: https://alextodo-app.netlify.app/ + + + \ No newline at end of file diff --git a/package.json b/package.json index caf6289..b93f926 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@lottiefiles/dotlottie-react": "^0.13.5", + "date-fns": "^4.1.0", + "lottie-react": "^2.4.1", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.6.1", + "styled-components": "^6.1.18", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/pull_request_template.md b/pull_request_template.md index 154c92e..71e8fba 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +1,3 @@ -Please include your Netlify link here. \ No newline at end of file +## links +- github: https://github.com/alex91-html/js-project-todo +- netlify: https://alextodo-app.netlify.app/Please include your Netlify link here. \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 5427540..5428727 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,24 @@ +import { BrowserRouter as Router } from 'react-router-dom'; +import { GlobalStyle } from './styles/GlobalStyles'; +import { MainContainer } from './components/StyledComponents'; +import { useThemeStore } from './store/themeStore'; +import Header from './components/Header'; +import AddTask from './components/AddTask'; +import TaskList from './components/TaskList'; + export const App = () => { + const theme = useThemeStore((s) => s.theme); + return ( -

React Boilerplate

- ) -} + + + +
+
+ + +
+ + + ); +}; \ No newline at end of file diff --git a/src/components/AddTask.jsx b/src/components/AddTask.jsx new file mode 100644 index 0000000..110c96d --- /dev/null +++ b/src/components/AddTask.jsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { useThemeStore } from '../store/themeStore'; + +import styled from 'styled-components'; +import AddTaskModal from './AddTaskModal'; + +const AddButton = styled.button` + position: fixed; + right: 24px; + bottom: 24px; + width: 56px; + height: 56px; + border-radius: 20%; + background: ${({ theme }) => theme === 'dark' ? '#b4b4b4' : '#111'}; + color: ${({ theme }) => theme === 'dark' ? '#4e4e4e' : '#fff'}; + font-size: 2.2rem; + font-weight: 200; + font-family: 'Arial', 'Helvetica Neue', Arial, sans-serif; + border: none; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + cursor: pointer; + z-index: 100; + transition: background 0.2s, color 0.2s, box-shadow 0.18s, transform 0.18s; + + +&:hover { + background: ${({ theme }) => theme === 'dark' ? '#FAE179' : '#40405c'}; + } + + @media (min-width: 1024px) { + position: absolute; + right: 40px; + bottom: 40px; + } + +`; + +const AddTask = () => { + const [open, setOpen] = useState(false); + const theme = useThemeStore((s) => s.theme); + + + return ( + <> + setOpen(true)} aria-label="Add task" theme={theme}> + + + + {open && setOpen(false)} />} + + ); +}; + +export default AddTask; \ No newline at end of file diff --git a/src/components/AddTaskModal.jsx b/src/components/AddTaskModal.jsx new file mode 100644 index 0000000..ddac962 --- /dev/null +++ b/src/components/AddTaskModal.jsx @@ -0,0 +1,192 @@ +import styled from 'styled-components'; +import { useState } from 'react'; +import { useTodoStore } from '../store/todoStore'; +import { useThemeStore } from '../store/themeStore'; + + +const ModalOverlay = styled.div` + position: fixed; + inset: 0; + background: rgba(0,0,0,0.45); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 200; + + @media (min-width: 1024px) { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + border-radius: 32px; + + } +`; + +const Modal = styled.div` + background: ${({ theme }) => theme === 'dark' ? '#23272f' : '#fff'}; + color: ${({ theme }) => theme === 'dark' ? '#fafafa' : '#222'}; + width: 100%; + max-width: 600px; + border-radius: 32px 32px 0 0; + padding: 32px 24px 24px 24px; + box-shadow: 0 -2px 24px rgba(0,0,0,0.08); + margin-bottom: 0; + +@media (min-width: 1024px) { + width: 100%; + max-width: 600px; + border-radius: 32px; + padding: 48px 40px; + box-shadow: 0 8px 32px rgba(0,0,0,0.08); +} + +`; + +const ModalBar = styled.div` + width: 60px; + height: 6px; + background: #e0e3e7; + border-radius: 3px; + margin: 0 auto 18px auto; +`; + +const ModalTitle = styled.h2` + color: ${({ theme }) => theme === 'dark' ? '#fafafa' : '#222'}; + text-align: center; + font-size: 2rem; + margin: 0 0 24px 0; +`; + +const Input = styled.input` + width: 100%; + padding: 18px; + border-radius: 16px; + border: 2px solid #b5cdfa; + background: #f7f8fa; + font-size: 1.15rem; + margin-bottom: 18px; + outline: none; + color: #222; + &::placeholder { + color: #aab2bb; + font-size: 1.1rem; + } +`; + +const Select = styled.select` + width: 100%; + padding: 16px; + border-radius: 12px; + border: none; + background: #f7f8fa; + font-size: 1.1rem; + margin-bottom: 28px; + color: #222; +`; + +const ButtonRow = styled.div` + display: flex; + gap: 16px; +`; + +const CancelButton = styled.button` + flex: 1; + padding: 18px 0; + border-radius: 16px; + border: none; + background: #f7f8fa; + color: #6b7280; + + &:hover { + background:#e57373; + color: #fff; + } + + +`; + +const SubmitButton = styled.button` + flex: 2; + padding: 18px 0; + border-radius: 16px; + border: none; + background: #888; + color: #fff; + + &:hover { + background: #40405c;; + } + +`; + +const AddTaskModal = ({ onClose }) => { + const [value, setValue] = useState(''); + const [category, setCategory] = useState('General'); + const addTask = useTodoStore((s) => s.addTask); + const theme = useThemeStore((s) => s.theme); + + + const handleSubmit = (e) => { + e.preventDefault(); + if (value.trim()) { + addTask({ + id: Date.now(), + title: value, + category, + completed: false, + createdAt: new Date().toISOString(), + }); + setValue(''); + setCategory('General'); + onClose(); + } + }; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) onClose(); + }; + + return ( + + + + Add New Task +
+ setValue(e.target.value)} + aria-label="Task name" + autoFocus + /> + + + + Cancel + + + Add Task + + +
+
+
+ ); +}; + +export default AddTaskModal; \ No newline at end of file diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..b9a72a9 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,96 @@ +import styled from 'styled-components'; +import { format } from 'date-fns'; +import { useTodoStore } from '../store/todoStore'; +import { useThemeStore } from '../store/themeStore'; + +const HeaderContainer = styled.header` + display: flex; + flex-direction: column; + padding: 20px; + background: ${({ theme }) => theme === 'dark' ? '#23272f' : '#fff'}; + + @media (min-width: 1024px) { + border-top-left-radius: 32px; + border-top-right-radius: 32px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08); + } +`; + +const DateText = styled.div` + font-size: 0.95rem; + color: #6a6a6a; + margin-top: 12px; +`; + +const DayText = styled.h1` + margin: 0; + margin-top: 2px; +`; +const TaskCount = styled.div` + font-size: 1rem; + color:#6a6a6a; + margin: 8px 0 0 0; +`; + +const ThemeSwitch = styled.button` + position: absolute; + top: 20px; + right: 20px; + width: 48px; + height: 28px; + background: ${({ theme }) => theme === 'dark' ? '#222' : '#eee'}; + border: none; + border-radius: 16px; + cursor: pointer; + display: flex; + align-items: center; + padding: 0 4px; + transition: background 0.2s; + outline: none; + z-index: 10; + + &:focus { + box-shadow: 0 0 0 2px #8882; + } +`; + +const SwitchKnob = styled.span` + display: block; + width: 22px; + height: 22px; + border-radius: 50%; + background: ${({ theme }) => theme === 'dark' ? '#ffe066' : '#333'}; + transform: ${({ theme }) => theme === 'dark' ? 'translateX(20px)' : 'translateX(0)'}; + transition: transform 0.2s, background 0.2s; + box-shadow: 0 2px 8px rgba(0,0,0,0.10); +`; + +const Header = () => { + const now = new Date(); + const tasks = useTodoStore((s) => s.tasks); + const uncompleted = tasks.filter(t => !t.completed).length; + const theme = useThemeStore((s) => s.theme); + const toggleTheme = useThemeStore((s) => s.toggleTheme); + + let taskCountText = ''; + if (uncompleted === 0) { + taskCountText = '0 tasks'; + } else if (uncompleted === 1) { + taskCountText = '1 task remaining'; + } else { + taskCountText = `${uncompleted} tasks remaining`; + } + + return ( + + + + + {format(now, "do MMM yyyy")} + {format(now, "EEEE")} + {taskCountText} + + ); +}; + +export default Header; \ No newline at end of file diff --git a/src/components/StyledComponents.jsx b/src/components/StyledComponents.jsx new file mode 100644 index 0000000..5268f28 --- /dev/null +++ b/src/components/StyledComponents.jsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +export const MainContainer = styled.div` + width: 100%; + min-height: 100vh; + background: ${({ theme }) => theme === 'dark' ? '#23272f' : '#fff'}; + display: flex; + flex-direction: column; + padding: 0; + + @media (min-width: 1024px) { + max-width: 600px; + min-height: 90vh; + margin: 40px auto; + border-radius: 32px; + box-shadow: 0 8px 32px rgba(0,0,0,0.08); + position: relative; + } +`; \ No newline at end of file diff --git a/src/components/TaskItem.jsx b/src/components/TaskItem.jsx new file mode 100644 index 0000000..024721d --- /dev/null +++ b/src/components/TaskItem.jsx @@ -0,0 +1,122 @@ +import styled from 'styled-components'; +import { useTodoStore } from '../store/todoStore'; +import { useThemeStore } from '../store/themeStore'; +import { FiTrash2 } from 'react-icons/fi'; +import { format } from 'date-fns'; + +const TaskRow = styled.div` + display: flex; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #eee; +`; + +const Checkbox = styled.input` + margin-right: 16px; + width: 22px; + height: 22px; +`; + +const TaskInfo = styled.div` + flex: 1; +`; + +const Title = styled.div` + font-size: 1.1rem; + color: ${({ completed, theme }) => + completed ? '#6a6a6a' : theme === 'dark' ? '#fafafa' : '#222'}; + text-decoration: ${({ completed }) => (completed ? 'line-through' : 'none')}; +`; + +const DeleteButton = styled.button` + background: none; + border: none; + color: #6a6a6a; + font-size: 1.3rem; + margin-left: 8px; + cursor: pointer; + align-self: center; + display: flex; + align-items: center; + transition: color 0.2s; + + &:hover, + &:focus { + color: #e57373; + } +`; + +const Category = styled.div` + font-size: 0.9rem; + color: ${({ theme }) => theme === 'dark' ? '#e0e0e0' : '#6a6a6a'}; + padding: 2px 6px; + border-radius: 8px; + margin-top: 2px; + background: ${({ color, theme }) => + theme === 'dark' + ? (color ? `${color}22` : '#333') + : (color || '#eee')}; + cursor: pointer; + text-decoration: underline; + display: inline-block; + text-decoration: none; + + &:hover { + background: #40405c; + color: #fff; + } +`; + +const categoryColors = { + General: '#ffe0ec', + Finance: '#e0f7fa', + Freelance: '#e0ffe0', + Design: '#e0e7ff', + 'Shopping List': '#fff9e0', + Personal: '#f3e0ff', + Health: '#e0fff4', +}; + +const Timestamp = styled.div` + font-size: 0.8rem; + color: #6a6a6a; + margin-top: 2px; +`; + +const TaskItem = ({ task, onCategoryClick }) => { + const toggleTask = useTodoStore((s) => s.toggleTask); + const removeTask = useTodoStore((s) => s.removeTask); + const theme = useThemeStore((s) => s.theme); + + + return ( + + toggleTask(task.id)} + aria-label={task.title} + /> + + {task.title} + onCategoryClick && onCategoryClick(task.category)} + > + {task.category} + + {task.createdAt && ( + + {format(new Date(task.createdAt), 'd MMM yyyy, HH:mm')} + + )} + + removeTask(task.id)}> + + + + ); +}; + +export default TaskItem; \ No newline at end of file diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx new file mode 100644 index 0000000..ea1b28a --- /dev/null +++ b/src/components/TaskList.jsx @@ -0,0 +1,127 @@ +import styled from 'styled-components'; +import { useTodoStore } from '../store/todoStore'; +import { useThemeStore } from '../store/themeStore'; +import TaskItem from './TaskItem'; +import { DotLottieReact } from '@lottiefiles/dotlottie-react'; +import { useState } from 'react'; + +const SectionTitle = styled.h2` + margin: 24px 0 8px 0; +`; + +const TaskListContainer = styled.div` + width: 100%; +`; + +const TaskListContent = styled.div` + padding: 0 20px; +`; + +const EmptyState = styled.div` +color: #bbb; +text-align: center; +margin: 0; +font-size: 1.2rem; +display: flex; +flex-direction: column; +align-items: center; +justify-content: center; +`; + +const DoneSection = styled.div` + background: ${({ theme }) => theme === 'dark' ? '#23272f' : '#f7f7f7'}; + padding: 18px 0 8px 0; + margin-top: 32px; +`; + +const ShowAllCategoriesButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + margin-top: 16px; + padding: 12px 24px; + border: none; + border-radius: 16px; + background: ${({ theme }) => theme === 'dark' ? '#23272f' : '#111'}; + color: ${({ theme }) => theme === 'dark' ? '#ffe066' : '#fff'}; + font-size: 1.1rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s, color 0.2s, box-shadow 0.18s, transform 0.18s; + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + + &:hover { + background: ${({ theme }) => theme === 'dark' ? '#333' : '#40405c'}; + color: #fff; + } +`; + +const TaskList = () => { + const [categoryFilter, setCategoryFilter] = useState(null); + const tasks = useTodoStore((s) => s.tasks); + const theme = useThemeStore((s) => s.theme); + const filteredTasks = categoryFilter + ? tasks.filter((t) => t.category === categoryFilter) + : tasks; + + const todos = filteredTasks.filter((t) => !t.completed); + const dones = filteredTasks.filter((t) => t.completed); + + return ( + + + {categoryFilter && ( + setCategoryFilter(null)} + > + Show all categories + + )} + {filteredTasks.length === 0 ? ( + + + No tasks yet. Add your first task! + + ) : ( + <> + {todos.length > 0 && ( + <> + Todo + {todos.map((task) => ( + + ))} + + )} + + )} + + {dones.length > 0 && ( + + + Done + {dones.map((task) => ( + + ))} + + + )} + + ); +}; + +export default TaskList; \ No newline at end of file diff --git a/src/store/themeStore.js b/src/store/themeStore.js new file mode 100644 index 0000000..f1e2d4e --- /dev/null +++ b/src/store/themeStore.js @@ -0,0 +1,9 @@ +import { create } from 'zustand'; + +export const useThemeStore = create((set) => ({ + theme: 'light', + toggleTheme: () => + set((state) => ({ + theme: state.theme === 'light' ? 'dark' : 'light', + })), +})); \ No newline at end of file diff --git a/src/store/todoStore.js b/src/store/todoStore.js new file mode 100644 index 0000000..d9550dc --- /dev/null +++ b/src/store/todoStore.js @@ -0,0 +1,27 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export const useTodoStore = create( + persist( + (set) => ({ + tasks: [], + addTask: (task) => + set((state) => ({ + tasks: [...state.tasks, task], + })), + toggleTask: (id) => + set((state) => ({ + tasks: state.tasks.map((t) => + t.id === id ? { ...t, completed: !t.completed } : t + ), + })), + removeTask: (id) => + set((state) => ({ + tasks: state.tasks.filter((t) => t.id !== id), + })), + }), + { + name: 'todo-storage', // name of item in localStorage + } + ) +); \ No newline at end of file diff --git a/src/styles/GlobalStyles.jsx b/src/styles/GlobalStyles.jsx new file mode 100644 index 0000000..367c351 --- /dev/null +++ b/src/styles/GlobalStyles.jsx @@ -0,0 +1,43 @@ +import { createGlobalStyle } from 'styled-components'; + +export const GlobalStyle = createGlobalStyle` + body { + margin: 0; + padding: 0; + font-family: 'Inter', Arial, sans-serif; + background: ${({ theme }) => theme === 'dark' ? '#222' : '#fafafa'}; + color: ${({ theme }) => theme === 'dark' ? '#fafafa' : '#222'}; + font-size: 1rem; + font-weight: 400; + transition: background 0.2s, color 0.2s; + } + + h1 { + font-size: 3.5rem; + font-weight: 400; + margin: 0; + } + h2 { + font-size: 1.5rem; + font-weight: 400; + margin: 0; + } + h1, h2 { + color: ${({ theme }) => theme === 'dark' ? '#fafafa' : '#222'}; + } + + + * { + box-sizing: border-box; + } + + button { + font-size: 1.5rem; + font-weight: 500; + font-family: inherit; + cursor: pointer; + } + + + +`; \ No newline at end of file