diff --git a/README.md b/README.md index d1c68b5..6eb24d2 100644 --- a/README.md +++ b/README.md @@ -1 +1,31 @@ -# Todo \ No newline at end of file +# 📝 To-Do List App + +This is a simple and responsive to-do list web application built with **React**, **TypeScript**, **Zustand**, and **Tailwind CSS**. It makes use of the component library **neobrutalism.dev**, which is built on top of **shadcn/ui** for a minimalistic, stylish design. The app allows users to manage tasks with features like adding, completing, and deleting tasks. + +--- + +## ✨ Features + +- ✅ **Add tasks** to your task list +- 🔁 **Toggle task completion** +- ❌ **Delete individual tasks** in a dedicated delete mode +- 📊 **Task counter** shows how many tasks you have on your list +- 📱 **Responsive design** that works across all screen sizes + +--- + +## 🛠️ Tech Stack + +- **React** (with Vite) +- **TypeScript** +- **Zustand** for global state management +- **Tailwind CSS** for styling +- **shadcn/ui** and [neobrutalism.dev](https://neobrutalism.dev) components + +--- + +## 🔗 Link + +https://neobrutalist-todo.netlify.app/ + +--- diff --git a/components.json b/components.json new file mode 100644 index 0000000..7dfce35 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 2fd24fd..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,33 +0,0 @@ -import js from '@eslint/js' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import globals from 'globals' - -export default [ - { ignores: ['dist'] }, - { - files: ['**/*.{js,jsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - parserOptions: { - ecmaVersion: 'latest', - ecmaFeatures: { jsx: true }, - sourceType: 'module' - } - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh - }, - rules: { - ...js.configs.recommended.rules, - ...reactHooks.configs.recommended.rules, - 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true } - ] - } - } -] diff --git a/eslint.config.ts b/eslint.config.ts new file mode 100644 index 0000000..2909050 --- /dev/null +++ b/eslint.config.ts @@ -0,0 +1,55 @@ +import js from '@eslint/js' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import globals from 'globals' +import * as tseslint from '@typescript-eslint/eslint-plugin' +import parser from '@typescript-eslint/parser' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + parser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true } + }, + globals: globals.browser + }, + plugins: { + '@typescript-eslint': tseslint + }, + rules: { + ...js.configs.recommended.rules, + ...tseslint.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] + } + }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 'latest', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true } + }, + globals: globals.browser + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }] + } + } +] diff --git a/index.html b/index.html index f7ac4e4..9a8c545 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Todo + To Do List
diff --git a/package.json b/package.json index caf6289..c412e7b 100644 --- a/package.json +++ b/package.json @@ -10,18 +10,32 @@ "preview": "vite preview" }, "dependencies": { + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.1.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.511.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwind-merge": "^3.3.0", + "tailwindcss": "^4.1.7", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/js": "^9.21.0", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", + "@types/node": "^22.15.21", + "@types/react": "^19.1.5", + "@types/react-dom": "^19.1.5", + "@typescript-eslint/eslint-plugin": "^8.32.1", + "@typescript-eslint/parser": "^8.32.1", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.21.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^15.15.0", + "tw-animate-css": "^1.3.0", + "typescript": "^5.8.3", "vite": "^6.2.0" } } diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 5427540..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,5 +0,0 @@ -export const App = () => { - return ( -

React Boilerplate

- ) -} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..d0a1600 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,11 @@ +import Header from "./components/header/Header" +import Main from "./components/main/Main" + +export const App = () => { + return ( +
+
+
+
+ ) +} diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx new file mode 100644 index 0000000..8345733 --- /dev/null +++ b/src/components/header/Header.tsx @@ -0,0 +1,9 @@ +const Header = () => { + return ( +
+

To-Do List

+
+ ) +} + +export default Header \ No newline at end of file diff --git a/src/components/main/EmptyState.tsx b/src/components/main/EmptyState.tsx new file mode 100644 index 0000000..ceb2026 --- /dev/null +++ b/src/components/main/EmptyState.tsx @@ -0,0 +1,9 @@ +const EmptyState = () => { + return ( +
+

Hooray, no active tasks!

+
+ ) +} + +export default EmptyState \ No newline at end of file diff --git a/src/components/main/ListItem.tsx b/src/components/main/ListItem.tsx new file mode 100644 index 0000000..b42671c --- /dev/null +++ b/src/components/main/ListItem.tsx @@ -0,0 +1,38 @@ +import { Checkbox } from "@/components/ui/checkbox" + +type ListItemProps = { + id: number; + text: string; + completed: boolean; + onToggle: (id: number) => void; + onDelete: (id: number) => void; + deleteMode: boolean; +}; + +const ListItem = ({ id, text, completed, onToggle, onDelete, deleteMode }: ListItemProps) => { + return ( +
+
+ {!deleteMode ? ( + onToggle(id)} + /> + ) : ( + + )} +
+

+ {text} +

+
+) +} + +export default ListItem \ No newline at end of file diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx new file mode 100644 index 0000000..da8d5ad --- /dev/null +++ b/src/components/main/Main.tsx @@ -0,0 +1,84 @@ +import { Button } from "@/components/ui/button" +import { Card } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { useState } from "react" +import useTodoStore from "../../stores/useTodoStore" +import EmptyState from "./EmptyState" +import ListItem from "./ListItem" + +const Main = () => { + + const [text, setText] = useState(""); + const todos = useTodoStore((state) => state.todos); + const addTodo = useTodoStore((state) => state.addTodo); + const toggleTodo = useTodoStore((state) => state.toggleTodo); + const toggleDeleteMode = useTodoStore((state) => state.toggleDeleteMode); + const deleteMode = useTodoStore((state) => state.deleteMode); + const removeTodo = useTodoStore((state) => state.removeTodo); + + const handleAdd = () => { + if (text.trim()) { + addTodo(text); + setText(""); + } + }; + + return ( +
+ +

Tasks ({todos.length})

+ +
+ + setText(e.target.value)} + /> + +
+ + + + + +
+ +
+ + + {todos.length === 0 ? ( + + ) : ( +
+ {todos.map((todo) => ( + + ))} +
+ )} +
+
+ ) +} + +export default Main \ No newline at end of file diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..f91affd --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-base text-sm font-base ring-offset-white transition-all gap-2 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "text-main-foreground bg-main border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none", + noShadow: "text-main-foreground bg-main border-2 border-border", + neutral: + "bg-secondary-background text-foreground border-2 border-border shadow-shadow hover:translate-x-boxShadowX hover:translate-y-boxShadowY hover:shadow-none", + reverse: + "text-main-foreground bg-main border-2 border-border hover:translate-x-reverseBoxShadowX hover:translate-y-reverseBoxShadowY hover:shadow-shadow", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 px-3", + lg: "h-11 px-8", + icon: "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..d5a3851 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardDescription, + CardContent, + CardAction, +} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..9c11cde --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..08ca29a --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/index.css b/src/index.css index f7c0aef..fe75a64 100644 --- a/src/index.css +++ b/src/index.css @@ -1,3 +1,92 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&display=swap'); +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + --background: oklch(93.46% 0.0305 255.11); + --secondary-background: oklch(100% 0 0); + --foreground: oklch(0% 0 0); + --main-foreground: oklch(0% 0 0); + --main: oklch(67.47% 0.1726 259.49); + --border: oklch(0% 0 0); + --ring: oklch(0% 0 0); + --overlay: oklch(0% 0 0 / 0.8); + --shadow: 4px 4px 0px 0px var(--border); + --chart-1: #5294FF; + --chart-2: #FF4D50; + --chart-3: #FACC00; + --chart-4: #05E17A; + --chart-5: #7A83FF; + --chart-active-dot: #000; } + +.dark { + --background: oklch(29.23% 0.0626 270.49); + --secondary-background: oklch(23.93% 0 0); + --foreground: oklch(92.49% 0 0); + --main-foreground: oklch(0% 0 0); + --main: oklch(67.47% 0.1726 259.49); + --border: oklch(0% 0 0); + --ring: oklch(100% 0 0); + --shadow: 4px 4px 0px 0px var(--border); + --chart-1: #5294FF; + --chart-2: #FF6669; + --chart-3: #E0B700; + --chart-4: #04C86D; + --chart-5: #7A83FF; + --chart-active-dot: #fff; +} + +@theme inline { + --color-main: var(--main); + --color-background: var(--background); + --color-secondary-background: var(--secondary-background); + --color-foreground: var(--foreground); + --color-main-foreground: var(--main-foreground); + --color-border: var(--border); + --color-overlay: var(--overlay); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + + --spacing-boxShadowX: 4px; + --spacing-boxShadowY: 4px; + --spacing-reverseBoxShadowX: -4px; + --spacing-reverseBoxShadowY: -4px; + --radius-base: 5px; + --shadow-shadow: var(--shadow); + --font-weight-base: 500; + --font-weight-heading: 700; + --font-base: DM Sans, "sans-serif"; + --font-heading: DM Sans, "sans-serif"; +} + +@layer base { + body { + @apply text-foreground font-base bg-secondary-background; + } + + h1, h2, h3, h4, h5, h6{ + @apply font-heading; + font-weight: var(--font-weight-heading) + } +} + +@layer utilities { + .bg-grid-light { + background-image: + repeating-linear-gradient(0deg, #e5e7eb 0, #e5e7eb 1px, transparent 1px, transparent 40px), + repeating-linear-gradient(90deg, #e5e7eb 0, #e5e7eb 1px, transparent 1px, transparent 40px); + background-size: 40px 40px; + background-position: 0 0; + } + .break-word { + overflow-wrap: break-word; + word-break: break-word; + } +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/main.jsx b/src/main.tsx similarity index 61% rename from src/main.jsx rename to src/main.tsx index 1b8ffe9..82c5feb 100644 --- a/src/main.jsx +++ b/src/main.tsx @@ -1,11 +1,11 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { App } from './App.jsx' +import { App } from './App' import './index.css' -ReactDOM.createRoot(document.getElementById('root')).render( +ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/src/stores/useTodoStore.ts b/src/stores/useTodoStore.ts new file mode 100644 index 0000000..05cf649 --- /dev/null +++ b/src/stores/useTodoStore.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' + +type Todo = { + id: number; + text: string; + completed: boolean; +}; + +type TodoState = { + todos: Todo[]; + deleteMode: boolean; + addTodo: (text: string) => void; + removeTodo: (id: number) => void; + toggleTodo: (id: number) => void; + toggleDeleteMode: () => void; +}; + +const useTodoStore = create((set) => ({ + todos: [], + deleteMode: false, + toggleDeleteMode: () => + set((state: TodoState): Partial => ({ + deleteMode: !state.deleteMode + })), + addTodo: (text) => + set((state) => ({ + todos: [{id: Date.now(), text, completed: false}, ...state.todos], + deleteMode: false, + })), + removeTodo: (id) => + set((state) => { + const updatedTodos = state.todos.filter((todo) => todo.id !== id); + return { + todos: updatedTodos, + deleteMode: updatedTodos.length === 0 ? false : state.deleteMode, + }; + }), + toggleTodo: (id) => + set((state) => ({ + todos: state.todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ), + })), +})) + +export default useTodoStore \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5b5311f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index ba24244..0000000 --- a/vite.config.js +++ /dev/null @@ -1,7 +0,0 @@ -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vite' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()] -}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..8d7e074 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,14 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' +import path from "path" + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}) \ No newline at end of file