diff --git a/README.md b/README.md index d1c68b5..fa18b34 100644 --- a/README.md +++ b/README.md @@ -1 +1,4 @@ -# Todo \ No newline at end of file +# (O)FÄRDIG +A minimalistic, modern todo app with a notebook feel + +Check it out: https://nevercomplete.netlify.app/ diff --git a/index.html b/index.html index f7ac4e4..9542073 100644 --- a/index.html +++ b/index.html @@ -2,15 +2,24 @@ - + - Todo + + + + + + + (O)FÄRDIG
- + diff --git a/js-project-todo.code-workspace b/js-project-todo.code-workspace new file mode 100644 index 0000000..40fe023 --- /dev/null +++ b/js-project-todo.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": "." + }, + { + "path": "." + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index caf6289..c3e3a2f 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,12 @@ "preview": "vite preview" }, "dependencies": { + "date-fns": "^4.1.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "react-select": "^5.10.1", + "styled-components": "^6.1.18", + "zustand": "^5.0.4" }, "devDependencies": { "@eslint/js": "^9.21.0", @@ -22,6 +26,6 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", - "vite": "^6.2.0" + "vite": "^6.3.5" } } diff --git a/public/listat.svg b/public/listat.svg new file mode 100644 index 0000000..0edeff8 --- /dev/null +++ b/public/listat.svg @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/src/App.jsx b/src/App.jsx index 5427540..14b6e42 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,61 @@ -export const App = () => { +"use client"; + +import { useEffect } from "react"; +import { ThemeProvider } from "styled-components"; +import { AppContainer, NotebookContainer, Header, Title } from "./LayoutStyles"; +import { useStore } from "./store"; +import GlobalStyle from "./GlobalStyle"; +import TodoList from "./components/TodoList"; +import TodoForm from "./components/TodoForm"; +import TodoStats from "./components/TodoStats"; +import EmptyState from "./components/EmptyState"; +import ThemeToggle from "./components/ThemeToggle"; +import { lightTheme, darkTheme } from "./theme"; + +function App() { + const { tasks, darkMode, toggleDarkMode, loadTasks } = useStore(); + + useEffect(() => { + loadTasks(); + }, [loadTasks]); + return ( -

React Boilerplate

- ) + + + +
+

+ You will never be complete, but your tasks can be. +

+
+ (O)FÄRDIG + +
+
+ + + + + {tasks.length > 0 ? : } + + {/* */} +
+
+ ); } + +export default App; diff --git a/src/GlobalStyle.jsx b/src/GlobalStyle.jsx new file mode 100644 index 0000000..2daf691 --- /dev/null +++ b/src/GlobalStyle.jsx @@ -0,0 +1,38 @@ +import { createGlobalStyle } from "styled-components"; + +const GlobalStyle = createGlobalStyle` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + + html, body { + margin: 0; + padding: 0; + overflow-x: hidden; + } + + body { + font-family: 'Inter', sans-serif; + background-color: ${(props) => props.theme.background}; + color: ${(props) => props.theme.text}; + line-height: 1.6; + transition: all 0.3s ease; + } + + h1, h2, h3 { + font-family: 'Zen Kaku Gothic New', sans-serif; +} + + button, input, select { + font-family: 'Inter', sans-serif; + } + + small, em { + font-family: 'Libre Baskerville', serif; +} +`; + +export default GlobalStyle; diff --git a/src/LayoutStyles.jsx b/src/LayoutStyles.jsx new file mode 100644 index 0000000..ff3eb70 --- /dev/null +++ b/src/LayoutStyles.jsx @@ -0,0 +1,49 @@ +import styled from "styled-components"; + +export const AppContainer = styled.div` + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; + box-sizing: border-box; + + @media (max-width: 600px) { + padding: 1rem; + } +`; + +export const NotebookContainer = styled.div` + background-color: ${(props) => props.theme.paper}; + padding: 1rem 2rem; + position: relative; + box-sizing: border-box; +`; + +export const Header = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4rem; + padding-bottom: 1rem; +`; + +export const Title = styled.h1` + font-size: 8rem; + padding: 0 1.5rem; + font-weight: 700; + color: ${(props) => props.theme.title}; + letter-spacing: -2px; + word-break: break-word; + max-width: 100%; + + @media (max-width: 768px) { + font-size: 4rem; + } + + @media (max-width: 480px) { + font-size: 3rem; + } + + @media (max-width: 350px) { + font-size: 2rem; + } +`; diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx new file mode 100644 index 0000000..0e09167 --- /dev/null +++ b/src/components/EmptyState.jsx @@ -0,0 +1,42 @@ +import styled from "styled-components"; + +const EmptyContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 5rem 1rem; + text-align: center; +`; + +const EmptyIllustration = styled.div` + font-size: 3rem; + margin-bottom: 1rem; + color: ${(props) => props.theme.accent}; + opacity: 0.7; +`; + +const EmptyTitle = styled.h2` + font-size: 2rem; + font-weight: 500; + margin-bottom: 1rem; + color: ${(props) => props.theme.title}; +`; + +const EmptyText = styled.p` + color: ${(props) => props.theme.text}; + max-width: 400px; + margin: 0 auto; + font-size: 1rem; +`; + +const EmptyState = () => { + return ( + + No tasks yet + Add your first task using the form above. + + ); +}; + +export default EmptyState; diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..8271ec8 --- /dev/null +++ b/src/components/ThemeToggle.jsx @@ -0,0 +1,38 @@ +"use client"; + +import styled from "styled-components"; + +const ToggleButton = styled.button` + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + color: ${(props) => props.theme.text}; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + + &:hover { + opacity: 0.7; + } + + @media (max-width: 350px) { + font-size: 0.75rem; + } +`; + +const ThemeToggle = ({ darkMode, toggleDarkMode }) => { + return ( + + {darkMode ? "Light" : "Dark"} + + ); +}; + +export default ThemeToggle; diff --git a/src/components/TodoForm.jsx b/src/components/TodoForm.jsx new file mode 100644 index 0000000..cfeefdd --- /dev/null +++ b/src/components/TodoForm.jsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState } from "react"; +import styled, { useTheme } from "styled-components"; +import { useStore } from "../store"; +import Select from "react-select"; + +const FormContainer = styled.form` + margin: 2rem 0 3rem; + display: flex; + gap: 1rem; + + @media (max-width: 768px) { + flex-direction: column; + } +`; + +const InputRow = styled.div` + display: flex; + gap: 1rem; + flex: 1; + + @media (max-width: 768px) { + flex-direction: column; + } +`; + +const Input = styled.input` + flex: 1; + padding: 0.75rem 0; + font-size: 1rem; + border: none; + border-bottom: 1px solid ${(props) => props.theme.border}; + background-color: transparent; + color: ${(props) => props.theme.text}; + + &:focus { + outline: none; + border-color: ${(props) => props.theme.accent}; + } + + &::placeholder { + color: ${(props) => props.theme.completed}; + } +`; + +const Button = styled.button` + padding: 0.75rem 1.5rem; + font-size: 0.9rem; + background-color: transparent; + color: ${(props) => props.theme.text}; + border: 1px solid ${(props) => props.theme.accent}; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 1px; + + &:hover { + background-color: ${(props) => props.theme.accent}; + color: ${(props) => props.theme.paper}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +`; + +const CompleteAllButton = styled(Button)` + background-color: transparent; + border: 1px solid ${(props) => props.theme.accent}; + + &:hover { + background-color: ${(props) => props.theme.accent}; + color: ${(props) => props.theme.paper}; + } +`; + +const TodoForm = () => { + const { addTask, completeAllTasks, categories } = useStore(); + const [text, setText] = useState(""); + const [category, setCategory] = useState("Personal"); + const [dueDate, setDueDate] = useState(""); + const theme = useTheme(); + + const categoryOptions = categories.map((cat) => ({ + value: cat, + label: cat, + })); + + const handleSubmit = (e) => { + e.preventDefault(); + if (text.trim()) { + addTask(text.trim(), category, dueDate || null); + setText(""); + setDueDate(""); + } + }; + + const customStyles = { + control: (base) => ({ + ...base, + backgroundColor: "transparent", + border: "none", + borderBottom: `1px solid ${theme.border}`, + borderRadius: 0, + boxShadow: "none", + fontSize: "1rem", + color: theme.text, + fontFamily: "Zen Kaku Gothic New, sans-serif", + padding: "0.5rem 0", + cursor: "pointer", + }), + menu: (base) => ({ + ...base, + backgroundColor: theme.paper, + border: `1px solid ${theme.border}`, + borderRadius: 0, + boxShadow: "none", + fontFamily: "Zen Kaku Gothic New, sans-serif", + }), + option: (base, { isFocused, isSelected }) => ({ + ...base, + backgroundColor: isSelected + ? theme.text + : isFocused + ? theme.completed + : "transparent", + color: isSelected ? theme.paper : theme.text, + cursor: "pointer", + fontWeight: isSelected ? "600" : "400", + padding: "0.5rem 1rem", + }), + singleValue: (base) => ({ + ...base, + color: theme.text, + }), + placeholder: (base) => ({ + ...base, + color: theme.completed, + }), + indicatorSeparator: () => ({ + display: "none", + }), + }; + + return ( + + + setText(e.target.value)} + aria-label="Task description" + /> + +
+ setDueDate(e.target.value)} + aria-label="Due date" + /> + + + + + Complete All + + + + ); +}; + +export default TodoForm; diff --git a/src/components/TodoItem.jsx b/src/components/TodoItem.jsx new file mode 100644 index 0000000..1bdf28e --- /dev/null +++ b/src/components/TodoItem.jsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +import styled, { css, keyframes } from "styled-components"; +import { useStore } from "../store"; +import { format } from "date-fns"; + +const fadeIn = keyframes` + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +`; + +const ItemContainer = styled.li` + display: flex; + align-items: center; + gap: 12px; + padding: 1.5rem 0; + border-bottom: 1px solid ${(props) => props.theme.border}; + animation: ${fadeIn} 0.3s ease; + position: relative; +`; + +const Checkbox = styled.div` + width: 18px; + height: 18px; + border: 1px solid ${(props) => props.theme.accent}; + margin-right: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + ${(props) => + props.checked && + css` + background-color: ${(props) => props.theme.accent}; + + &::after { + content: "✓"; + color: ${(props) => props.theme.paper}; + font-size: 12px; + } + `} +`; + +const ContentContainer = styled.div` + flex: 1; +`; + +const TaskText = styled.p` + font-size: 2rem; + font-weight: 600; + margin-bottom: 0.5rem; + position: relative; + + ${(props) => + props.$completed && + css` + color: ${(props) => props.theme.completed}; + text-decoration: line-through; + `} +`; + +const TaskMeta = styled.div` + display: flex; + font-size: 0.85rem; + color: ${(props) => props.theme.completed}; + flex-wrap: wrap; + gap: 12px; +`; + +const TaskDate = styled.span` + display: inline-block; + color: ${(props) => props.theme.categoryText}; +`; + +const TaskDueDate = styled.span` + display: inline-block; + color: ${(props) => + props.$overdue ? props.theme.categoryText : props.theme.completed}; + font-weight: ${(props) => (props.$overdue ? "bold" : "normal")}; +`; + +const TaskCategory = styled.span` + display: inline-block; + color: ${(props) => props.theme.categoryText}; + font-size: 0.8rem; + font-weight: 500; +`; + +const DeleteButton = styled.button` + background: none; + border: none; + color: ${(props) => props.theme.completed}; + cursor: pointer; + font-size: 1.2rem; + opacity: 0.6; + transition: opacity 0.2s; + margin-left: 8px; + + &:hover { + opacity: 1; + } +`; + +const TodoItem = ({ task }) => { + const { toggleTask, removeTask, getFormattedDate, isTaskOverdue } = + useStore(); + const [showDelete, setShowDelete] = useState(false); + + const handleToggle = () => { + toggleTask(task.id); + }; + + const overdue = task.dueDate && isTaskOverdue(task); + + return ( + setShowDelete(true)} + onMouseLeave={() => setShowDelete(false)} + > + + + + {task.text} + + + Created: {getFormattedDate(task.createdAt)} + + {task.dueDate && ( + + Due: {format(new Date(task.dueDate), "MMM d, yyyy")} + {overdue && " (overdue)"} + + )} + + {task.category} + + + + {showDelete && ( + removeTask(task.id)} + aria-label="Delete task" + > + × + + )} + + ); +}; + +export default TodoItem; diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx new file mode 100644 index 0000000..b7df6ca --- /dev/null +++ b/src/components/TodoList.jsx @@ -0,0 +1,53 @@ +import styled from "styled-components"; +import { useStore } from "../store"; +import TodoItem from "./TodoItem"; + +const ListContainer = styled.ul` + list-style: none; + margin: 2rem 0; +`; + +const CategorySection = styled.div` + margin-bottom: 3rem; +`; + +const CategoryTitle = styled.h2` + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 1rem; + color: ${(props) => props.theme.title}; + text-transform: uppercase; + letter-spacing: 1px; +`; + +const TodoList = () => { + const { tasks, categories } = useStore(); + + // Group tasks by category + const tasksByCategory = categories.reduce((acc, category) => { + const categoryTasks = tasks + .filter((task) => task.category === category) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); // nyast först + if (categoryTasks.length > 0) { + acc[category] = categoryTasks; + } + return acc; + }, {}); + + return ( + <> + {Object.entries(tasksByCategory).map(([category, categoryTasks]) => ( + + {category} +
    + {categoryTasks.map((task) => ( + + ))} +
+
+ ))} + + ); +}; + +export default TodoList; diff --git a/src/components/TodoStats.jsx b/src/components/TodoStats.jsx new file mode 100644 index 0000000..fb2a246 --- /dev/null +++ b/src/components/TodoStats.jsx @@ -0,0 +1,54 @@ +import styled from "styled-components"; +import { useStore } from "../store"; + +const StatsContainer = styled.div` + display: flex; + justify-content: space-between; + padding: 1rem 0; + font-size: 0.9rem; + color: ${(props) => props.theme.text}; + border-top: 1px solid ${(props) => props.theme.border}; + border-bottom: 1px solid ${(props) => props.theme.border}; + margin: 2rem 0; + text-transform: uppercase; + letter-spacing: 1px; + + @media (max-width: 600px) { + flex-direction: column; + gap: 0.5rem; + } +`; + +const StatItem = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; +`; + +const StatValue = styled.span` + font-weight: 500; + color: ${(props) => props.theme.accent}; +`; + +const TodoStats = () => { + const { getUncompletedCount, getCompletedCount, getTotalCount } = useStore(); + + return ( + + + Tasks: + {getTotalCount()} + + + Completed: + {getCompletedCount()} + + + Uncompleted: + {getUncompletedCount()} + + + ); +}; + +export default TodoStats; 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/js-project-todo.code-workspace b/src/js-project-todo.code-workspace new file mode 100644 index 0000000..1d122d3 --- /dev/null +++ b/src/js-project-todo.code-workspace @@ -0,0 +1,10 @@ +{ + "folders": [ + { + "path": ".." + }, + { + "path": ".." + } + ] +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9..0d1758a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,12 +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"; -import { App } from './App.jsx' - -import './index.css' - -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById("root")).render( -) +); diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..d57f5ed --- /dev/null +++ b/src/store.js @@ -0,0 +1,98 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { format } from "date-fns"; + +const useStore = create( + persist( + (set, get) => ({ + tasks: [], + darkMode: false, + categories: ["Personal", "Relationships", "Work", "Home"], + + // Load tasks from localStorage + loadTasks: () => { + // This is handled by the persist middleware + }, + + // Add a new task + addTask: (text, category = "Personal", dueDate = null) => { + const newTask = { + id: Date.now().toString(), + text, + completed: false, + createdAt: new Date().toISOString(), + category, + dueDate: dueDate ? new Date(dueDate).toISOString() : null, + }; + + set((state) => ({ + tasks: [...state.tasks, newTask], + })); + }, + + // Toggle task completion + toggleTask: (id) => { + set((state) => ({ + tasks: state.tasks.map((task) => + task.id === id ? { ...task, completed: !task.completed } : task + ), + })); + }, + + // Remove a task + removeTask: (id) => { + set((state) => ({ + tasks: state.tasks.filter((task) => task.id !== id), + })); + }, + + // Complete all tasks + completeAllTasks: () => { + set((state) => ({ + tasks: state.tasks.map((task) => ({ ...task, completed: true })), + })); + }, + + // Toggle dark mode + toggleDarkMode: () => { + set((state) => ({ darkMode: !state.darkMode })); + }, + + // Get formatted date + getFormattedDate: (dateString) => { + return format(new Date(dateString), "MMM d, yyyy h:mm a"); + }, + + // Check if task is overdue + isTaskOverdue: (task) => { + if (!task.dueDate || task.completed) return false; + return new Date(task.dueDate) < new Date(); + }, + + // Get tasks by category + getTasksByCategory: (category) => { + return get().tasks.filter((task) => task.category === category); + }, + + // Get uncompleted tasks count + getUncompletedCount: () => { + return get().tasks.filter((task) => !task.completed).length; + }, + + // Get completed tasks count + getCompletedCount: () => { + return get().tasks.filter((task) => task.completed).length; + }, + + // Get total tasks count + getTotalCount: () => { + return get().tasks.length; + }, + }), + { + name: "notebook-todo-storage", + } + ) +); + +export { useStore }; diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..94eaf50 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,55 @@ +export const lightTheme = { + background: "#f2f2f0", + paper: "#f8f8f6", + text: "#000000", + title: "#000000", + accent: "#000000", + border: "#d0d0d0", + completed: "#333333", + margin: "#e0e0e0", + buttonBg: "#f0f0f0", + buttonText: "#000000", + buttonHover: "#e0e0e0", + paperLines: "none", + overdue: "#000000", + categoryBg: "#f0f0f0", + categoryText: "#333333", + select: { + background: "#f8f8f6", + border: "#d0d0d0", + text: "#000000", + placeholder: "#888888", + optionBg: "#f5f5f3", + optionHover: "#e0e0e0", + optionSelected: "#000000", + optionSelectedText: "#ffffff", + }, +}; + +export const darkTheme = { + background: "#1a1a1a", + paper: "#222222", + text: "#f0f0f0", + title: "#ffffff", + accent: "#ffffff", + border: "#444444", + completed: "#aaaaaa", + margin: "#444444", + buttonBg: "#333333", + buttonText: "#f0f0f0", + buttonHover: "#444444", + paperLines: "none", + overdue: "#dddddd", + categoryBg: "#333333", + categoryText: "#eeeeee", + select: { + background: "#222222", + border: "#555555", + text: "#f0f0f0", + placeholder: "#888888", + optionBg: "#1c1c1c", + optionHover: "#444444", + optionSelected: "#ffffff", + optionSelectedText: "#000000", + }, +}; 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()], +});