diff --git a/.gitignore b/.gitignore index b02a1ff..9564343 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ package-lock.json *.njsproj *.sln *.sw? + +.env \ No newline at end of file diff --git a/README.md b/README.md index d1c68b5..330e628 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# Todo \ No newline at end of file +# Todo + +Todo App is a mobile-first task manager built with React, Styled Components and Zustand for global state. It lets you quickly add, toggle and remove tasks in a clean, responsive UI—no prop-drilling required. diff --git a/package.json b/package.json index caf6289..298ed55 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/material": "^7.1.0", + "date-fns": "^4.1.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "styled-components": "^6.1.18", + "uuid": "^11.1.0", + "zustand": "^5.0.4" }, "devDependencies": { "@eslint/js": "^9.21.0", diff --git a/pull_request_template.md b/pull_request_template.md index 154c92e..332eb68 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -1 +1 @@ -Please include your Netlify link here. \ No newline at end of file +Netlify link here [zustandtodoapp.netlify.app] diff --git a/src/App.jsx b/src/App.jsx index 5427540..fde0680 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,29 @@ +import Header from './components/Header'; +import TaskForm from './components/TaskForm'; +import TaskView from './components/TaskView'; +import styled, { ThemeProvider } from 'styled-components'; +import useTaskStore from './store/useTaskStore'; +import { GlobalStyle, lightTheme, darkTheme } from '../styles/globalStyles'; + +const AppContainer = styled.div` + background: ${({ theme }) => theme.background}; + color: ${({ theme }) => theme.text}; + min-height: 100vh; + padding: 1rem; +`; + export const App = () => { + const themeMode = useTaskStore((s) => s.themeMode); + const theme = themeMode === 'light' ? lightTheme : darkTheme; + return ( -

React Boilerplate

- ) -} + + + +
+ + + + + ); +}; diff --git a/src/components/DateInputWithPicker.jsx b/src/components/DateInputWithPicker.jsx new file mode 100644 index 0000000..62ad257 --- /dev/null +++ b/src/components/DateInputWithPicker.jsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; + +export default function DateInputWithPicker() { + const [textValue, setTextValue] = useState(''); + const [dateValue, setDateValue] = useState(''); + const [showCalendar, setShowCalendar] = useState(false); + + const handleTextChange = (e) => { + setTextValue(e.target.value); + }; + + const handleDateChange = (e) => { + const val = e.target.value; // "2025-05-22" + setDateValue(val); + setTextValue(val); + }; + + const toggleCalendar = () => { + setShowCalendar((prev) => !prev); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + console.log('Datum skickas vidare:', textValue); + }; + + return ( +
+ + + + + {showCalendar && ( + + )} +
+ ); +} diff --git a/src/components/EmtyState.jsx b/src/components/EmtyState.jsx new file mode 100644 index 0000000..e3ddb7c --- /dev/null +++ b/src/components/EmtyState.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Message = styled.p` + text-align: center; + color: #666; + margin-top: 2rem; +`; + +export default function EmptyState({ text }) { + return {text}; +} diff --git a/src/components/FilterBar.jsx b/src/components/FilterBar.jsx new file mode 100644 index 0000000..ff71d59 --- /dev/null +++ b/src/components/FilterBar.jsx @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +export const Bar = styled.nav``; + +export const FilterButton = styled.button` + background: ${({ $isActive, theme }) => + $isActive ? theme.primary : theme.surface}; + color: ${({ $isActive, theme }) => ($isActive ? '#fff' : theme.text)}; +`; + +export default function FilterBar({ filterMode, onChange }) { + const modes = [ + { key: 'today', label: 'Today' }, + { key: 'weekly', label: 'Weekly' }, + { key: 'all', label: 'All Tasks' }, + ]; + + return ( + + {modes.map(({ key, label }) => ( + onChange(key)} + > + {label} + + ))} + + ); +} diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..e5ccc47 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import useTaskStore from '../store/useTaskStore'; +import styled from 'styled-components'; + +const HeaderContainer = styled.header` + background: ${({ theme }) => theme.surface}; + color: ${({ theme }) => theme.text}; +`; + +const ToggleButton = styled.button` + background: none; + border: none; + color: ${({ theme }) => theme.primary}; + cursor: pointer; +`; + +export default function Header() { + const totalCount = useTaskStore((state) => state.selectors.getTotalCount()); + const clearCompleted = useTaskStore((state) => state.actions.clearCompleted); + const notCompleted = useTaskStore((state) => + state.selectors.getRemainingCount() + ); + + const toggleTheme = useTaskStore((state) => state.actions.toggleTheme); + const themeMode = useTaskStore((state) => state.themeMode); + + return ( + +

My Zustand Todo App

+

Totalt: {totalCount} uppgifter

+

Remaining: {notCompleted} uppgiofter

+ + { + console.log('Toggle clicked!'); // ← ska synas i konsolen + toggleTheme(); + }} + > + {themeMode === 'light' ? 'Dark mode' : 'Light mode'} + +
+ ); +} diff --git a/src/components/TaskForm.jsx b/src/components/TaskForm.jsx new file mode 100644 index 0000000..d49a378 --- /dev/null +++ b/src/components/TaskForm.jsx @@ -0,0 +1,90 @@ +import useTaskStore from '../store/useTaskStore'; +import { useState } from 'react'; + +export default function TaskForm() { + const [inputText, setInputText] = useState(''); + const [dueDate, setDueDate] = useState(''); + const addTask = useTaskStore((state) => state.actions.addTask); + + const handleChange = (event) => { + setInputText(event.target.value); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + + const text = inputText.trim(); + + // 1) Ingen text alls? + if (!text) { + alert('Please enter a task'); + return; + } + + // 2) För kort? + if (text.length < 3) { + alert('Task is too short (min 3 characters)'); + return; + } + + // 3) För långt? + if (text.length > 100) { + alert('Task is too long (max 100 characters)'); + return; + } + + addTask(text, dueDate); + setInputText(''); + setDueDate(''); + }; + + /* + User skriver → onChange → handleChange → setInputText → React-state uppdateras → input.value ändras + */ + + return ( +
+ + + +
+ ); +} diff --git a/src/components/TaskItem.jsx b/src/components/TaskItem.jsx new file mode 100644 index 0000000..40b9d67 --- /dev/null +++ b/src/components/TaskItem.jsx @@ -0,0 +1,33 @@ +import useTaskStore from '../store/useTaskStore'; + +export default function TaskItem({ task }) { + const toggleTask = useTaskStore((state) => state.actions.toggleTask); + const removeTask = useTaskStore((state) => state.actions.removeTask); + + const handleToggle = () => { + toggleTask(task.id); + }; + + const handleRemove = () => { + removeTask(task.id); + }; + + return ( +
  • + + + +
  • + ); +} diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx new file mode 100644 index 0000000..0c4430a --- /dev/null +++ b/src/components/TaskList.jsx @@ -0,0 +1,276 @@ +import { useMemo } from 'react'; +import TaskItem from './TaskItem'; +import EmptyState from './EmtyState'; +import { isToday, isThisWeek } from 'date-fns'; +import useTaskStore from '../store/useTaskStore'; +import LinearProgress from '@mui/material/LinearProgress'; +import { Box, Typography } from '@mui/material'; +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + #checklist { + --background: #303952; + --text: #5d6474; + --check: #cc29f0; + --disabled: #d3c8de; + --width: 100px; + --height: 180px; + --border-radius: 10px; + background: var(--background); + width: var(--width); + height: var(--height); + border-radius: var(--border-radius); + position: relative; + box-shadow: 0 10px 30px rgba(65, 72, 86, 0.05); + padding: 30px 85px; + display: grid; + grid-template-columns: 30px auto; + align-items: center; + justify-content: center; + } + + #checklist label { + color: var(--text); + position: relative; + cursor: pointer; + display: grid; + align-items: center; + width: fit-content; + transition: color 0.3s ease; + margin-right: 20px; + } + + #checklist label::before, + #checklist label::after { + content: ''; + position: absolute; + } + + #checklist label::before { + height: 2px; + width: 8px; + left: -27px; + background: var(--check); + border-radius: 2px; + transition: background 0.3s ease; + } + + #checklist label:after { + height: 4px; + width: 4px; + top: 8px; + left: -25px; + border-radius: 50%; + } + + #checklist input[type='checkbox'] { + -webkit-appearance: none; + -moz-appearance: none; + position: relative; + height: 15px; + width: 15px; + outline: none; + border: 0; + margin: 0 15px 0 0; + cursor: pointer; + background: var(--background); + display: grid; + align-items: center; + margin-right: 20px; + } + + #checklist input[type='checkbox']::before, + #checklist input[type='checkbox']::after { + content: ''; + position: absolute; + height: 2px; + top: auto; + background: var(--check); + border-radius: 2px; + } + + #checklist input[type='checkbox']::before { + width: 0px; + right: 60%; + transform-origin: right bottom; + } + + #checklist input[type='checkbox']::after { + width: 0px; + left: 40%; + transform-origin: left bottom; + } + + #checklist input[type='checkbox']:checked::before { + animation: check-01 0.4s ease forwards; + } + + #checklist input[type='checkbox']:checked::after { + animation: check-02 0.4s ease forwards; + } + + #checklist input[type='checkbox']:checked + label { + color: var(--disabled); + animation: move 0.3s ease 0.1s forwards; + } + + #checklist input[type='checkbox']:checked + label::before { + background: var(--disabled); + animation: slice 0.4s ease forwards; + } + + #checklist input[type='checkbox']:checked + label::after { + animation: firework 0.5s ease forwards 0.1s; + } + + @keyframes move { + 50% { + padding-left: 8px; + padding-right: 0px; + } + + 100% { + padding-right: 4px; + } + } + + @keyframes slice { + 60% { + width: 100%; + left: 4px; + } + + 100% { + width: 100%; + left: -2px; + padding-left: 0; + } + } + + @keyframes check-01 { + 0% { + width: 4px; + top: auto; + transform: rotate(0); + } + + 50% { + width: 0px; + top: auto; + transform: rotate(0); + } + + 51% { + width: 0px; + top: 8px; + transform: rotate(45deg); + } + + 100% { + width: 5px; + top: 8px; + transform: rotate(45deg); + } + } + + @keyframes check-02 { + 0% { + width: 4px; + top: auto; + transform: rotate(0); + } + + 50% { + width: 0px; + top: auto; + transform: rotate(0); + } + + 51% { + width: 0px; + top: 8px; + transform: rotate(-45deg); + } + + 100% { + width: 10px; + top: 8px; + transform: rotate(-45deg); + } + } + + @keyframes firework { + 0% { + opacity: 1; + box-shadow: 0 0 0 -2px #4f29f0, 0 0 0 -2px #4f29f0, 0 0 0 -2px #4f29f0, + 0 0 0 -2px #4f29f0, 0 0 0 -2px #4f29f0, 0 0 0 -2px #4f29f0; + } + + 30% { + opacity: 1; + } + + 100% { + opacity: 0; + box-shadow: 0 -15px 0 0px #4f29f0, 14px -8px 0 0px #4f29f0, + 14px 8px 0 0px #4f29f0, 0 15px 0 0px #4f29f0, -14px 8px 0 0px #4f29f0, + -14px -8px 0 0px #4f29f0; + } + } +`; + +export default function TaskList({ filterMode }) { + // 1) Prenumerera på råa tasks + const allTasks = useTaskStore((state) => state.tasks); + + // 2) Derivera filteredTasks med useMemo (filteredTasks är en ny array som är en avledning (derivation) av allTasks utifrån något villkor (t.ex. “endast dagens tasks”). + const filteredTasks = useMemo(() => { + switch (filterMode) { + case 'all': + return allTasks; + case 'today': + return allTasks.filter((task) => task.dueDate && isToday(task.dueDate)); + case 'weekly': + return allTasks.filter( + (task) => + task.dueDate && isThisWeek(task.dueDate, { weekStartsOn: 1 }) + ); + case 'remaining': + return allTasks.filter((task) => !task.done); + case 'completed': + return allTasks.filter((task) => task.done); + default: // "all" + return allTasks; + } + }, [allTasks, filterMode]); + + const total = filteredTasks.length; + const completed = filteredTasks.filter((t) => t.done).length; + const percent = total ? Math.round((completed / total) * 100) : 0; + + // 3) Tom-lista-hantering + if (filteredTasks.length === 0) { + return ; + } + + return ( + <> + + + + + + {percent}% + + + +
      + {filteredTasks.map((task) => ( + + ))} +
    + + ); +} + +// ul, li, input > label diff --git a/src/components/TaskView.jsx b/src/components/TaskView.jsx new file mode 100644 index 0000000..d120b64 --- /dev/null +++ b/src/components/TaskView.jsx @@ -0,0 +1,17 @@ +import { useState } from 'react'; +import FilterBar from './FilterBar'; +import TaskList from './TaskList'; +import styled from 'styled-components'; + +export const TaskViewContainer = styled.div``; + +export default function TaskView() { + const [filter, setFilter] = useState('all'); + + return ( + + + + + ); +} diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9..779e752 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +1,13 @@ -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 './index.css'; +import useTaskStore from './store/useTaskStore'; // ← importera din store-hook -import { App } from './App.jsx' - -import './index.css' +window.taskStore = useTaskStore; ReactDOM.createRoot(document.getElementById('root')).render( -) +); diff --git a/src/store/useTaskStore.js b/src/store/useTaskStore.js new file mode 100644 index 0000000..eaa35eb --- /dev/null +++ b/src/store/useTaskStore.js @@ -0,0 +1,75 @@ +import { create } from 'zustand'; +import { devtools, persist } from 'zustand/middleware'; +import { v4 as uuidv4 } from 'uuid'; + +const useTaskStore = create( + devtools( + persist( + (set, get) => ({ + //STATE + tasks: [], + themeMode: 'light', + + //ACTIONS + actions: { + addTask: (text, dueDate) => { + const trimmed = text.trim(); + if (trimmed === '') return; + + const dueDataObject = dueDate ? new Date(dueDate) : null; + + const newTask = { + id: uuidv4(), + text: trimmed, + done: false, + createdAt: new Date().toISOString(), + dueDate: dueDataObject, + }; + set((state) => ({ + tasks: [newTask, ...state.tasks], + })); + }, + + toggleTask: (id) => + set((state) => ({ + tasks: state.tasks.map((task) => + task.id === id ? { ...task, done: !task.done } : task + ), + })), + + removeTask: (id) => + set((state) => ({ + tasks: state.tasks.filter((task) => task.id !== id), + })), + + clearCompleted: () => + set((state) => ({ + tasks: state.tasks.filter((task) => !task.done), + })), + + toggleTheme: () => + set((state) => ({ + themeMode: state.themeMode === 'light' ? 'dark' : 'light', + })), + }, + + //SELECTORS FÖR RÄKNARE + selectors: { + getTotalCount: () => get().tasks.length, + getRemainingCount: () => get().tasks.filter((t) => !t.done).length, + getCompletedCount: () => get().tasks.filter((t) => t.done).length, + }, + }), + { + name: 'todo-storage', // nyckel i localStorage + getStorage: () => localStorage, // default, men tydligt + partialize: (state) => ({ + tasks: state.tasks, // vi sparar bara tasks-arrayen + }), + } + ), + { name: 'task-store' } + ) +); + +export default useTaskStore; diff --git a/styles/globalStyles.js b/styles/globalStyles.js new file mode 100644 index 0000000..73bfca1 --- /dev/null +++ b/styles/globalStyles.js @@ -0,0 +1,31 @@ +import { createGlobalStyle } from 'styled-components'; + +export const lightTheme = { + background: '#ffffff', + text: '#333333', + primary: '#6200ee', + surface: '#f5f5f5', +}; + +export const darkTheme = { + background: '#121212', + text: '#e0e0e0', + primary: '#bb86fc', + surface: '#1e1e1e', +}; + +export const GlobalStyle = createGlobalStyle` + * { + box-sizing: border-box; + } + body { + margin: 0; + padding: 0; + background: ${({ theme }) => theme.background}; + color: ${({ theme }) => theme.text}; + font-family: sans-serif; + } + button { + font-family: inherit; + } +`;