todo-next

yet another task list webapp, made with Next.js
Log | Files | Refs | README

commit 41fd139f11e941af298cdaa11516818419292557
parent 1d6e5b29cb7610ea03d85121e220b7e789080ee2
Author: massi <git@massi.world>
Date:   Thu, 10 Apr 2025 19:21:28 -0700

basic todo app

Diffstat:
Msrc/app/page.tsx | 9+++++++++
Asrc/app/todo-list/page.tsx | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib/useLocalStorage.ts | 26++++++++++++++++++++++++++
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]; +};