commit 41fd139f11e941af298cdaa11516818419292557
parent 1d6e5b29cb7610ea03d85121e220b7e789080ee2
Author: massi <git@massi.world>
Date: Thu, 10 Apr 2025 19:21:28 -0700
basic todo app
Diffstat:
3 files changed, 194 insertions(+), 0 deletions(-)
diff --git a/src/app/page.tsx b/src/app/page.tsx
@@ -1,4 +1,5 @@
import Image from "next/image";
+import Link from "next/link";
export default function Home() {
return (
@@ -50,6 +51,14 @@ export default function Home() {
Read our docs
</a>
</div>
+ <div className="flex items-center flex-col sm:flex-row">
+ <Link
+ className="rounded-[50%] border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex basis-[100%] items-center justify-center bg-[salmon] hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-[100%] sm:w-auto"
+ href="/todo-list"
+ >
+ Go to this cool route with a todo app in it
+ </Link>
+ </div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
diff --git a/src/app/todo-list/page.tsx b/src/app/todo-list/page.tsx
@@ -0,0 +1,159 @@
+"use client";
+import {
+ ChangeEvent,
+ HTMLInputTypeAttribute,
+ InputHTMLAttributes,
+ KeyboardEventHandler,
+ ReactEventHandler,
+ useState,
+} from "react";
+
+import { useLocalStorage } from "@/lib/useLocalStorage";
+
+const MAX_TASK_LENGTH = 32;
+
+type TodoItem = {
+ description: string;
+ isDone: boolean;
+};
+
+function mkTodo(description: string): TodoItem {
+ return {
+ description,
+ isDone: false,
+ };
+}
+
+type CheckboxProps = {
+ isChecked: boolean;
+ onToggle: () => void;
+};
+
+function Checkbox(props: CheckboxProps) {
+ const { isChecked, onToggle } = props;
+ return (
+ <div
+ className="cursor-pointer w-[28px] h-[28px] bg-gray-800 relative leading-none hover:bg-gray-700 select-none"
+ onClick={onToggle}
+ >
+ {isChecked ? "✅" : ""}
+ </div>
+ );
+}
+
+type TodoInputProps = {
+ onEnter: (desc: string) => void;
+};
+function TodoInput(props: TodoInputProps) {
+ const [desc, setDesc] = useState<string>("");
+ const { onEnter } = props;
+ const addItem = () => {
+ if (desc.length) {
+ onEnter(desc);
+ setDesc(() => "");
+ }
+ };
+ const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => {
+ switch (evt.key) {
+ case "Enter": {
+ evt.preventDefault();
+ addItem();
+ break;
+ }
+ }
+ };
+ return (
+ <div className="text-2xl p-2 mx-4">
+ <input
+ value={desc}
+ onChange={(e) => setDesc(e.target.value)}
+ maxLength={MAX_TASK_LENGTH}
+ type="text"
+ onKeyDown={handleKeyPress}
+ placeholder="new todo goes here"
+ ></input>
+ <button onClick={addItem}>add</button>
+ </div>
+ );
+}
+
+type TodoItemProps = {
+ onDelete: () => void;
+ onToggleDone: () => void;
+ updateDescription: (description: string) => void;
+ todo: TodoItem;
+};
+
+function TodoItem(props: TodoItemProps) {
+ const { onDelete, onToggleDone, todo, updateDescription } = props;
+ return (
+ <div className="group">
+ <div className="text-2xl flex place-content-between p-2 mx-4 relative border-t-1 border-gray-600">
+ <button
+ onClick={onDelete}
+ className="absolute left-[-10px] items-center top-0 cursor-pointer bottom-0 text-red-700 hidden group-hover:flex"
+ >
+ x
+ </button>
+ <input
+ value={todo.description}
+ onChange={(e) => updateDescription(e.target.value)}
+ maxLength={MAX_TASK_LENGTH}
+ ></input>
+ <Checkbox isChecked={todo.isDone} onToggle={onToggleDone} />
+ </div>
+ </div>
+ );
+}
+
+export default function TodoList() {
+ const [items, setItems] = useLocalStorage<TodoItem[]>("todos", []);
+ return (
+ <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-16 font-[family-name:var(--font-geist-sans)]">
+ <header>
+ <h1 className="text-4xl">TODO APP</h1>
+ </header>
+ <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full">
+ <div className="w-[100%]">
+ <TodoInput
+ onEnter={(description: string) =>
+ setItems((items) => [mkTodo(description), ...items])
+ }
+ />
+ {!items.length
+ ? "all done :)"
+ : items.map((todo, idx) => {
+ return (
+ <TodoItem
+ todo={todo}
+ onToggleDone={() =>
+ setItems((items) => {
+ const newItems = [...items];
+ newItems[idx] = {
+ ...items[idx],
+ isDone: !items[idx].isDone,
+ };
+ return newItems;
+ })
+ }
+ onDelete={() =>
+ setItems((items) =>
+ items.filter((item, iidx) => idx !== iidx),
+ )
+ }
+ updateDescription={(description: string) =>
+ setItems((items) =>
+ items.map((item, iidx) =>
+ idx === iidx ? { ...item, description } : item,
+ ),
+ )
+ }
+ key={idx}
+ />
+ );
+ })}
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/src/lib/useLocalStorage.ts b/src/lib/useLocalStorage.ts
@@ -0,0 +1,26 @@
+"use client";
+
+import { Dispatch, SetStateAction, useEffect, useState } from "react";
+
+function getStorageValue<T>(key: string, defaultValue: T) {
+ const saved = localStorage.getItem(key);
+ if (!saved) return defaultValue;
+ const initial = JSON.parse(saved) as T;
+ return initial || defaultValue;
+}
+
+export const useLocalStorage = <T>(
+ key: string,
+ defaultValue: T,
+): [T, Dispatch<SetStateAction<T>>] => {
+ const [value, setValue] = useState<T>(() => {
+ return getStorageValue(key, defaultValue);
+ });
+
+ useEffect(() => {
+ // TODO: may want to delete stored data at the old key if key changes
+ localStorage.setItem(key, JSON.stringify(value));
+ }, [key, value]);
+
+ return [value, setValue];
+};