diff --git a/README.md b/README.md index d1c68b5..ea51cdd 100644 --- a/README.md +++ b/README.md @@ -1 +1,56 @@ -# Todo \ No newline at end of file +# klar Todo App + +[https://talotodo.netlify.app/](https://talotodo.netlify.app/) + + +**A Bauhaus-inspired Todo application built with React, Zustand, and Tailwind CSS.** + + +## Key Features + +**Task Management** + Add, list, toggle (mark done/undo), and remove tasks with ease. + +**Global State** + Powered by Zustand for zero-prop-drilling and efficient updates. + +**Bulk Actions** + “Complete All” button to mark every task as completed instantly. + +**Empty-State UX** + Engaging empty-state screen to encourage task creation. + +**Responsive Design** + Mobile-first layout scaling gracefully from 320px to 1920px with Tailwind CSS. + +**Bauhaus! Zustand! Klar!** + Bauhaus-inspired styling, font and colors with custom CSS variables and utility classes for a minimalist aesthetic. + +**Dark/Light Theme** + Toggle between light and dark modes, with preferences persisted in local storage. + +**Persistence** + Todos and theme choice saved in local storage for continuity across sessions. + +**Timestamps** + Task creation dates formatted with Day.js. + +**Due-Date Support** + Optional due-date input with visual styling for overdue tasks. + +**Category Tags** + Add comma-separated tags to tasks, displayed as badges. + +**Advanced Filtering** + Filter tasks by status (All/Active/Completed), creation date, tags, and project. + +**Project Grouping** + Organize tasks under named projects. + + **Drag-and-Drop** + Reorder tasks within each project group via drag-and-drop. + +**Accessibility** + Semantic HTML, ARIA roles, focus management, SEO friendly, and Lighthouse scores ≥95. + +WIP - WORK IN PROGRESS - it's called klar but it ain't klart yet! - WIP diff --git a/index.html b/index.html index f7ac4e4..44482d6 100644 --- a/index.html +++ b/index.html @@ -2,9 +2,14 @@ + - Todo + + klar
diff --git a/package.json b/package.json index caf6289..da264e4 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,25 @@ "preview": "vite preview" }, "dependencies": { + "@hello-pangea/dnd": "^18.0.1", + "@tailwindcss/vite": "^4.1.7", + "dayjs": "^1.11.13", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "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", + "autoprefixer": "^10.4.21", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.7", "vite": "^6.2.0" } } diff --git a/public/moon.svg b/public/moon.svg new file mode 100644 index 0000000..1609936 --- /dev/null +++ b/public/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/sun.svg b/public/sun.svg new file mode 100644 index 0000000..eb6fb59 --- /dev/null +++ b/public/sun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 5427540..212db73 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,91 @@ -export const App = () => { +import React, { useEffect, useState } from 'react' +import dayjs from 'dayjs' +import { useTodos } from './store/useTodos' +import { useTheme } from './store/useTheme' +import Header from './components/Header' +import TodoForm from './components/TodoForm' +import FilterBar from './components/FilterBar' +import TodoList from './components/TodoList' +import EmptyState from './components/EmptyState' +import Footer from './components/Footer' + +export default function App() { + const theme = useTheme((s) => s.theme) + const allTodos = useTodos((s) => s.todos) + + // build the unique project list + const projects = Array.from(new Set(allTodos.map((t) => t.project))) + + // full filter state + const [filters, setFilters] = useState({ + status: 'all', + createdAfter: '', + tagFilter: '', + projectFilter:'all', + }) + + // apply dark/light theme + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme) + }, [theme]) + + // helper to parse comma‐lists + const parseTags = (str) => + str.split(',').map((t) => t.trim()).filter((t) => t !== '') + + // derive the visible todos based on filters + const visibleTodos = allTodos + .filter((todo) => { + // project filter + if ( + filters.projectFilter !== 'all' && + todo.project !== filters.projectFilter + ) + return false + + // status filter + if (filters.status === 'active' && todo.done) return false + if (filters.status === 'completed' && !todo.done) return false + + // created-after filter + if (filters.createdAfter) { + const created = dayjs(todo.createdAt) + const after = dayjs(filters.createdAfter) + if (created.isBefore(after, 'day')) return false + } + + // tag filter + if (filters.tagFilter) { + const wantedTags = parseTags(filters.tagFilter) + const hasMatch = wantedTags.some((tag) => + todo.tags.includes(tag) + ) + if (!hasMatch) return false + } + + return true + }) + return ( -

React Boilerplate

+
+
+ +
+ + + + + {visibleTodos.length > 0 ? ( + + ) : ( + + )} +
+ +
) -} +} \ No newline at end of file diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx new file mode 100644 index 0000000..9edb1a4 --- /dev/null +++ b/src/components/EmptyState.jsx @@ -0,0 +1,31 @@ +import React from 'react' + +export default function EmptyState() { // EmptyState component + return ( +
+ + +

+ No tasks yet! +

+

+ Looks like you don’t have any tasks. Let’s add one. +

+ {/* 📋 */} +
+ ) +} \ No newline at end of file diff --git a/src/components/FilterBar.jsx b/src/components/FilterBar.jsx new file mode 100644 index 0000000..26d1d2f --- /dev/null +++ b/src/components/FilterBar.jsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react' + +export default function FilterBar({ projects, onFilterChange }) { + const [status, setStatus] = useState('all') + const [createdAfter, setCreatedAfter] = useState('') + const [tagFilter, setTagFilter] = useState('') + const [projectFilter, setProjectFilter] = useState('all') + + const handleStatusChange = (e) => { + const newStatus = e.target.value + setStatus(newStatus) + onFilterChange({ + status: newStatus, + createdAfter, + tagFilter, + projectFilter, + }) + } + + const handleDateChange = (e) => { + const newDate = e.target.value + setCreatedAfter(newDate) + onFilterChange({ + status, + createdAfter: newDate, + tagFilter, + projectFilter, + }) + } + + const handleTagChange = (e) => { + const newTagFilter = e.target.value + setTagFilter(newTagFilter) + onFilterChange({ + status, + createdAfter, + tagFilter: newTagFilter, + projectFilter, + }) + } + + const handleProjectChange = (e) => { + const newProject = e.target.value + setProjectFilter(newProject) + onFilterChange({ + status, + createdAfter, + tagFilter, + projectFilter: newProject, + }) + } + + return ( +
+ {/* Project filter */} + + + {/* Status filter */} + + + {/* Created-after filter */} + + + {/* Tag filter */} + +
+) +} \ No newline at end of file diff --git a/src/components/Footer.jsx b/src/components/Footer.jsx new file mode 100644 index 0000000..591bb9d --- /dev/null +++ b/src/components/Footer.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import Stats from './Stats' + +export default function Footer() { + return ( + + ) +} \ No newline at end of file diff --git a/src/components/Header.jsx b/src/components/Header.jsx new file mode 100644 index 0000000..9a1ada7 --- /dev/null +++ b/src/components/Header.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ThemeToggle from './ThemeToggle' + +export default function Header() { + return ( +
+

klar

+ +
+ ); +} \ No newline at end of file diff --git a/src/components/Stats.jsx b/src/components/Stats.jsx new file mode 100644 index 0000000..35e1556 --- /dev/null +++ b/src/components/Stats.jsx @@ -0,0 +1,37 @@ +import React from 'react' +import { useTodos } from '../store/useTodos' + +export default function Stats() { // Stats component + // Zustand store for todos + const todos = useTodos((s) => s.todos) // Get todos from Zustand store + const completeAll = useTodos((s) => s.completeAll) // Get completeAll function from Zustand store + const totalCount = todos.length // Total number of todos + const remainingCount = todos.filter((t) => !t.done).length // Number of remaining todos + + // If no tasks, render nothing + if (totalCount === 0) return null + + return ( +
+ + {totalCount} task{totalCount !== 1 ? 's' : ''}, {remainingCount} remaining + + {remainingCount > 0 && ( + + )} +
+ ) +} \ No newline at end of file diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..e7e8810 --- /dev/null +++ b/src/components/ThemeToggle.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import { useTheme } from '../store/useTheme' + +export default function ThemeToggle() { + const theme = useTheme((s) => s.theme) + const toggle = useTheme((s) => s.toggleTheme) + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/TodoForm.jsx b/src/components/TodoForm.jsx new file mode 100644 index 0000000..9f8d443 --- /dev/null +++ b/src/components/TodoForm.jsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' +import { useTodos } from '../store/useTodos' + +export default function TodoForm() { + const [text, setText] = useState('') + const [dueDate, setDueDate] = useState('') + const [tags, setTags] = useState('') + const [project, setProject] = useState('') // Default project + const addTodo = useTodos((s) => s.addTodo) + + const handleSubmit = (e) => { + e.preventDefault() + const trimmed = text.trim() + if (!trimmed) return + + // Build an array of non-empty, trimmed tags + const tagsArray = tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t !== '') + + // Use provided project or fallback to "General" + const projectName = project.trim() || 'General' + + addTodo(trimmed, dueDate, tagsArray, projectName) + + // Reset form fields + setText('') + setDueDate('') + setTags('') + setProject('General') + } + + return ( +
+ setText(e.target.value)} + placeholder="What needs doing?" + className="flex-1 p-2 border border-bauhaus rounded focus:outline-none focus:ring-accent-blue" + /> + + setDueDate(e.target.value)} + className="p-2 border border-bauhaus rounded focus:outline-none focus:ring-accent-blue" + aria-label="Optional due date" + /> + + setTags(e.target.value)} + placeholder="Tags, comma-separated" + className="p-2 border border-bauhaus rounded focus:outline-none focus:ring-accent-blue" + aria-label="Optional tags" + /> + + setProject(e.target.value)} + placeholder="Project (default: General)" + className="p-2 border border-bauhaus rounded focus:outline-none focus:ring-accent-blue" + aria-label="Project" + /> + + +
+ ) +} \ No newline at end of file diff --git a/src/components/TodoItem.jsx b/src/components/TodoItem.jsx new file mode 100644 index 0000000..71c4cfd --- /dev/null +++ b/src/components/TodoItem.jsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function TodoItem() { + return ( +
+

Todo Item

+
+ ); +} \ No newline at end of file diff --git a/src/components/TodoList.jsx b/src/components/TodoList.jsx new file mode 100644 index 0000000..771c08b --- /dev/null +++ b/src/components/TodoList.jsx @@ -0,0 +1,139 @@ +import React from 'react' +import dayjs from 'dayjs' +import { useTodos } from '../store/useTodos' +import { + DragDropContext, + Droppable, + Draggable, +} from '@hello-pangea/dnd' + +export default function TodoList({ todos: propTodos }) { + const allTodos = useTodos((s) => s.todos) + const todos = propTodos ?? allTodos + const toggleTodo = useTodos((s) => s.toggleTodo) + const removeTodo = useTodos((s) => s.removeTodo) + const reorderInGroup = useTodos((s) => s.reorderInGroup) + const today = dayjs() + + // Unique project names, preserving order + const projects = [...new Set(todos.map((t) => t.project))] + + const handleDragEnd = (result) => { + const { source, destination } = result + if (!destination) return + if (source.droppableId !== destination.droppableId) return + + reorderInGroup( + source.droppableId, + source.index, + destination.index + ) + } + + return ( + +
+ {projects.map((project) => { + const projectTasks = todos.filter((t) => t.project === project) + + return ( +
+

{project}

+ + + {(provided) => ( +
    + {projectTasks.map((todo, index) => { + const due = todo.dueDate ? dayjs(todo.dueDate) : null + const isOverdue = + due && due.isBefore(today, 'day') && !todo.done + + return ( + + {(prov) => ( +
  • +
    + + {todo.text} + + + Created: {dayjs(todo.createdAt).format('MMM D, YYYY')} + + {due && ( + + Due: {due.format('MMM D, YYYY')} + + )} + {todo.tags?.length > 0 && ( +
    + {todo.tags.map((tag) => ( + + {tag} + + ))} +
    + )} +
    + +
    + + +
    +
  • + )} +
    + ) + })} + + {/* Wrap placeholder so
      has only
    • children */} +
    • +
    + )} + +
+ ) + })} +
+
+ ) +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index f7c0aef..bb9ca5c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,49 @@ +@import "tailwindcss"; + +/* Bauhaus Light Theme Variables */ :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + --bauhaus-bg: #fbfbfb; + --bauhaus-text: #1A1A1A; + --bauhaus-muted: #4F4F4F; + --bauhaus-accent-red: #D72638; + --bauhaus-accent-blue: #002FA7; + /* --bauhaus-accent-blue: #3F88C5; */ + --bauhaus-accent-yellow: #F2C14E; + --bauhaus-border: #CCC5B9; + --bauhaus-completed: #9C9C9C; } + +@layer base { + body { + background-color: var(--bauhaus-bg); + color: var(--bauhaus-text); + font-family: "neue-kabel", sans-serif; + } +} + +/* Bauhaus Dark Theme Variables */ +[data-theme="dark"] { + --bauhaus-bg: #0A0A0A; + --bauhaus-text: #F5F0E6; + --bauhaus-muted: #A9A9A9; + --bauhaus-accent-red: #F94144; + --bauhaus-accent-blue: #277DA1; + --bauhaus-accent-yellow: #F9C74F; + --bauhaus-border: #2E2E2E; + --bauhaus-completed: #555555; +} + +@layer utilities { + .bg-bauhaus { background-color: var(--bauhaus-bg); } + .text-bauhaus-text { color: var(--bauhaus-text); } + .text-muted { color: var(--bauhaus-muted); } + .border-bauhaus { border-color: var(--bauhaus-border); } + .text-completed { color: var(--bauhaus-completed); } + + .bg-accent-red { background-color: var(--bauhaus-accent-red); } + .text-accent-red { color: var(--bauhaus-accent-red); } + .bg-accent-blue { background-color: var(--bauhaus-accent-blue); } + .text-accent-blue { color: var(--bauhaus-accent-blue); } + .bg-accent-yellow { background-color: var(--bauhaus-accent-yellow); } + .text-accent-yellow { color: var(--bauhaus-accent-yellow); } +} \ No newline at end of file diff --git a/src/main.jsx b/src/main.jsx index 1b8ffe9..92e76b0 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,7 +1,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { App } from './App.jsx' +import App from './App.jsx' import './index.css' diff --git a/src/store/useTheme.js b/src/store/useTheme.js new file mode 100644 index 0000000..836fa4c --- /dev/null +++ b/src/store/useTheme.js @@ -0,0 +1,19 @@ +// src/store/useTheme.js +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export const useTheme = create( + persist( + (set) => ({ + theme: 'light', + toggleTheme: () => + set((state) => ({ + theme: state.theme === 'light' ? 'dark' : 'light', + })), + }), + { + name: 'theme-storage', // key in localStorage + getStorage: () => localStorage + } + ) +) \ No newline at end of file diff --git a/src/store/useTodos.js b/src/store/useTodos.js new file mode 100644 index 0000000..e47e108 --- /dev/null +++ b/src/store/useTodos.js @@ -0,0 +1,108 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export const useTodos = create( + persist( + (set) => ({ + todos: [ + { + id: '1', + text: 'Learn Zustand', + done: false, + createdAt: new Date(), + dueDate: null, + tags: [], + project: 'General', + }, + { + id: '2', + text: 'Build a todo app', + done: false, + createdAt: new Date(), + dueDate: null, + tags: [], + project: 'General', + }, + ], + + addTodo: (text, dueDate = null, tags = [], project = 'General') => + set((state) => ({ + todos: [ + ...state.todos, + { + id: Date.now().toString(), + text, + done: false, + createdAt: new Date(), + dueDate: dueDate ? new Date(dueDate) : null, + tags, + project, + }, + ], + })), + + removeTodo: (id) => + set((state) => ({ + todos: state.todos.filter((t) => t.id !== id), + })), + + toggleTodo: (id) => + set((state) => ({ + todos: state.todos.map((t) => + t.id === id ? { ...t, done: !t.done } : t + ), + })), + + completeAll: () => + set((state) => ({ + todos: state.todos.map((t) => ({ ...t, done: true })), + })), + + addTag: (id, tag) => + set((state) => ({ + todos: state.todos.map((t) => + t.id === id + ? { ...t, tags: t.tags.includes(tag) ? t.tags : [...t.tags, tag] } + : t + ), + })), + + removeTag: (id, tag) => + set((state) => ({ + todos: state.todos.map((t) => + t.id === id + ? { ...t, tags: t.tags.filter((existing) => existing !== tag) } + : t + ), + })), + + changeProject: (id, project) => + set((state) => ({ + todos: state.todos.map((t) => + t.id === id ? { ...t, project } : t + ), + })), + + // New action for drag-and-drop reordering within a project + reorderInGroup: (project, fromIndex, toIndex) => + set((state) => { + const groupTasks = state.todos.filter((t) => t.project === project) + if (fromIndex === toIndex) return { todos: state.todos } + + const newGroup = Array.from(groupTasks) + const [moved] = newGroup.splice(fromIndex, 1) + newGroup.splice(toIndex, 0, moved) + + const firstIndex = state.todos.findIndex((t) => t.project === project) + const newTodos = Array.from(state.todos) + newTodos.splice(firstIndex, groupTasks.length, ...newGroup) + + return { todos: newTodos } + }), + }), + { + name: 'todos-storage', + getStorage: () => localStorage, + } + ) +) \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..8cf685c --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,13 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: 'class', + content: ['./src/**/*.{js,jsx,ts,tsx}'], + theme: { + extend: { + fontFamily: { + 'neue-kabel': ['neue-kabel', 'sans-serif'], + }, + }, + }, + plugins: [], +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index ba24244..4542111 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,9 @@ 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: [ + tailwindcss(), react()] })