todo-next

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

commit 7d60567044b92e1ba13f2d27b5f6e786aba36a55
parent 0392642cf6fc3012aa8e434035cc41974c227851
Author: massi <git@massi.world>
Date:   Tue, 15 Apr 2025 15:26:12 -0700

title and url ops

Diffstat:
Asrc/app/[[...title]]/page.tsx | 436+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/app/page.tsx | 112-------------------------------------------------------------------------------
Dsrc/app/todo-list/[title]/page.tsx | 433-------------------------------------------------------------------------------
Dsrc/app/todo-list/page.tsx | 3---
Msrc/lib/useLocalStorage.ts | 5++---
5 files changed, 438 insertions(+), 551 deletions(-)

diff --git a/src/app/[[...title]]/page.tsx b/src/app/[[...title]]/page.tsx @@ -0,0 +1,436 @@ +"use client"; +import { + KeyboardEvent, + KeyboardEventHandler, + Ref, + RefObject, + useEffect, + useRef, + useState, +} from "react"; + +import { useLocalStorage } from "@/lib/useLocalStorage"; +import { useParams, useRouter } from "next/navigation"; + +const MAX_TASK_LENGTH = 128; + +const genId = () => { + try { + return crypto.randomUUID(); + } catch (e) { + return String(Math.random()); // good enough for being unique on a single local list + } +}; + +type TodoItem = { + description: string; + isDone: boolean; + id: string; +}; + +function mkTodo(description: string = ""): TodoItem { + return { + description, + isDone: false, + id: genId(), + }; +} + +// helper hooks + +function useListFocuser<T extends HTMLElement>(): [ + RefObject<(T | null)[]>, + (idx: number, evt: KeyboardEvent<T>) => void, +] { + const listRef = useRef<(T | null)[]>([]); + + const onKeyDown = (idx: number, evt: KeyboardEvent) => { + switch (evt.key) { + case "ArrowUp": { + if (idx > 0) { + evt.preventDefault(); + listRef.current[idx - 1]?.focus(); + } + break; + } + case "ArrowDown": { + if (idx < listRef.current.length - 1) { + evt.preventDefault(); + listRef.current[idx + 1]?.focus(); + } + break; + } + } + }; + + return [listRef, onKeyDown]; +} + +// components + +type CheckboxProps = { + isChecked: boolean; + onToggle: () => void; + idx: number; + ref: Ref<HTMLButtonElement>; + onKeyDown: KeyboardEventHandler<HTMLButtonElement>; +}; + +function Checkbox(props: CheckboxProps) { + const { isChecked, onToggle, idx, ref, onKeyDown } = props; + return ( + <button + ref={ref} + className={` rounded-sm cursor-pointer ${!isChecked ? "bg-gray-300 hover:bg-gray-400 dark:hover:bg-gray-700 dark:bg-gray-800 " : ""} select-none text-xl aspect-square w-[24px] h-[24px] leading-0`} + onClick={onToggle} + onKeyDown={onKeyDown} + > + {isChecked ? "✅" : ""} + </button> + ); +} + +type TodoItemProps = { + onDelete: () => void; + onToggleDone: () => void; + updateDescription: (description: string) => void; + todo: TodoItem; + inputRef?: (ref: HTMLInputElement | null) => void; + idx: number; + onInputKeyPress: KeyboardEventHandler<HTMLInputElement>; + xRef: (ref: HTMLButtonElement | null) => void; + xOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; + checkRef: (ref: HTMLButtonElement | null) => void; + checkOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; +}; + +function TodoItem(props: TodoItemProps) { + const { + onDelete, + onToggleDone, + todo, + updateDescription, + inputRef, + idx, + onInputKeyPress, + xRef, + xOnKeyDown, + checkRef, + checkOnKeyDown, + } = props; + + const initialDescription = useRef(todo.description); + const commitChange = () => { + initialDescription.current = todo.description; + }; + useEffect(() => { + initialDescription.current = todo.description; + }, []); + + const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { + switch (evt.key) { + case "Enter": { + if (todo.description.length) commitChange(); + break; + } + case "Escape": { + updateDescription(initialDescription.current); + break; + } + } + onInputKeyPress(evt); + }; + return ( + <div className="group [&:not(:last-child)>*]:border-b-1 flex flex-col"> + <div className="text-md gap-2 flex place-content-between p-1 mx-4 relative border-gray-300 dark:border-gray-700"> + <Checkbox + ref={checkRef} + onKeyDown={checkOnKeyDown} + isChecked={todo.isDone} + onToggle={onToggleDone} + idx={idx} + /> + <input + className="grow" + placeholder={"new task"} + value={todo.description} + onChange={(e) => updateDescription(e.target.value)} + maxLength={MAX_TASK_LENGTH} + onBlur={() => { + commitChange(); + }} + onFocus={commitChange} + onKeyDown={handleKeyPress} + ref={inputRef} + ></input> + <button + ref={xRef} + onKeyDown={xOnKeyDown} + onClick={onDelete} + className="items-center top-0 cursor-pointer bottom-0 px-1" + > + ❌ + </button> + </div> + </div> + ); +} + +type TitleProps = { title: string; rename: (newTitle: string) => boolean }; + +function Title(props: TitleProps) { + const { title, rename } = props; + const [editingTitle, setEditingTitle] = useState<null | string>(null); + const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { + switch (evt.key) { + case "Enter": { + evt.preventDefault(); + if (editingTitle) { + const success = rename(editingTitle); + if (!success) setEditingTitle(null); + } + break; + } + case "Escape": { + setEditingTitle(null); + } + } + }; + return ( + <h1 className="text-3xl p-3"> + <input + className="text-center w-full" + type="text" + value={editingTitle === null ? title : editingTitle} + onChange={(evt) => setEditingTitle(evt.target.value)} + onKeyDown={handleKeyPress} + onBlur={() => { + if (editingTitle) { + const success = rename(editingTitle); + if (!success) setEditingTitle(null); + } + }} + ></input> + </h1> + ); +} + +export default function TodoList() { + const router = useRouter(); + const { title: _title } = useParams<{ title: string }>(); + const title = _title ? decodeURIComponent(_title) : "tasks"; + const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]); + const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]); + const renameTitle = (newTitle: string) => { + if (title === newTitle) return false; + const dataAtNewTitle = !!localStorage.getItem(newTitle); + if (dataAtNewTitle) { + alert( + "A task list with that name already exists! Pick another name. If you want to see that list, navigate to it via the URL.", + ); + return false; + } else { + localStorage.removeItem(title); + localStorage.setItem(newTitle, JSON.stringify(items)); + router.replace(`/${encodeURIComponent(newTitle)}`); + return true; + } + }; + + const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>(); + const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>(); + + const focusInput = (idx: number) => { + const el = todoDescriptionRefs.current[idx]; + if (el) el.focus(); + }; + + const autoFocusInputIdx = useRef<number | null>(null); + + useEffect(() => { + if (autoFocusInputIdx.current !== null) { + focusInput(autoFocusInputIdx.current); + autoFocusInputIdx.current = null; + } + }, [items]); + + const addTodo = (idx: number) => { + setItems((items) => { + const newItems = [...items]; + newItems.splice(idx, 0, mkTodo()); + return newItems; + }); + autoFocusInputIdx.current = idx; + }; + + const deleteTodo = (idx: number) => { + setItems((items) => { + const newItems = items.filter((item, iidx) => idx !== iidx); + if (!newItems.length) { + newItems.push(mkTodo("")); + } + return newItems; + }); + }; + + const gotoTodo = ( + idx: number, + deltaIdx: number, + selectionPosition: "start" | "end" | number, + ) => { + const gotoIdx = idx + deltaIdx; + if (gotoIdx < 0) { + todoDescriptionRefs.current[idx]?.setSelectionRange(0, 0); + } else if (gotoIdx >= items.length) { + const lastDescLength = items.at(-1)?.description.length || 0; + todoDescriptionRefs.current + .at(-1) + ?.setSelectionRange(lastDescLength, lastDescLength); + } else { + focusInput(idx + deltaIdx); + const selection = + selectionPosition === "end" + ? items[idx + deltaIdx].description.length + : selectionPosition === "start" + ? 0 + : selectionPosition; + todoDescriptionRefs.current[idx + deltaIdx]?.setSelectionRange( + selection, + selection, + ); + } + }; + + useEffect(() => { + if (!items.length) { + setItems([mkTodo()]); + } + }, [items.length]); + + return ( + <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-2 font-[monospace]"> + <header> + <Title title={title} rename={renameTitle} /> + </header> + <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full"> + <div className="w-[100%]"> + {items.map((todo, idx) => { + const inputPosition = () => + todoDescriptionRefs.current[idx]?.selectionStart; + return ( + <TodoItem + todo={todo} + onToggleDone={() => + setItems((items) => { + const newItems = [...items]; + newItems[idx] = { + ...items[idx], + isDone: !items[idx].isDone, + }; + return newItems; + }) + } + onDelete={() => { + const xIsFocused = + xRef.current[idx]?.matches(":focus-visible"); + setItems((items) => { + // to keep focus on the delete button, just reset the todo if it's the last one. + if (items.length === 1) { + const newItems = [...items]; + newItems[0].description = ""; + newItems[0].isDone = false; + return newItems; + } else { + return items.filter((item, iidx) => idx !== iidx); + } + }); + if (xIsFocused) { + (xRef.current[idx + 1] || xRef.current[idx - 1])?.focus(); + } + }} + updateDescription={(description: string) => + setItems((items) => + items.map((item, iidx) => + idx === iidx ? { ...item, description } : item, + ), + ) + } + key={todo.id} + idx={idx} + inputRef={(ref) => (todoDescriptionRefs.current[idx] = ref)} + onInputKeyPress={(evt) => { + switch (evt.key) { + case "Enter": { + const inputPos = + todoDescriptionRefs.current[idx]?.selectionStart || 0; + + const multiselect = + todoDescriptionRefs.current[idx]?.selectionEnd || + 0 > inputPos; + const pos = + evt.getModifierState("Shift") || + (inputPos === 0 && + todo.description.length && + !multiselect) + ? 0 + : 1; + addTodo(idx + pos); + break; + } + case "Backspace": { + if (!todo.description.length && !evt.repeat) { + if (items.length === 1) { + break; + } + evt.preventDefault(); + const dir = idx === 0 ? 1 : -1; + // keep the element around while we go to the next todo + // prevents mobile keyboards from closing due to their + // focused element disappearing. + deleteTodo(idx); + gotoTodo(idx, dir, "end"); + } + break; + } + case "ArrowRight": { + if (inputPosition() === todo.description.length) { + evt.preventDefault(); + gotoTodo(idx, 1, "start"); + } + break; + } + case "ArrowLeft": { + if (inputPosition() === 0) { + evt.preventDefault(); + gotoTodo(idx, -1, "end"); + } + break; + } + case "ArrowDown": { + evt.preventDefault(); + gotoTodo(idx, 1, inputPosition() || "start"); + break; + } + case "ArrowUp": { + evt.preventDefault(); + gotoTodo(idx, -1, inputPosition() || "start"); + break; + } + } + }} + xRef={(ref) => { + xRef.current[idx] = ref; + }} + xOnKeyDown={(evt) => xOnKeyDown(idx, evt)} + checkRef={(ref) => { + checkRef.current[idx] = ref; + }} + checkOnKeyDown={(evt) => checkOnKeyDown(idx, evt)} + /> + ); + })} + </div> + </main> + </div> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -1,112 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; - -export default function Home() { - return ( - <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> - <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> - <Image - className="dark:invert" - src="/next.svg" - alt="Next.js logo" - width={180} - height={38} - priority - /> - <ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]"> - <li className="mb-2 tracking-[-.01em]"> - Get started by editing{" "} - <code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold"> - src/app/page.tsx - </code> - . - </li> - <li className="tracking-[-.01em]"> - Save and see your changes instantly. - </li> - </ol> - - <div className="flex gap-4 items-center flex-col sm:flex-row"> - <a - className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto" - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - className="dark:invert" - src="/vercel.svg" - alt="Vercel logomark" - width={20} - height={20} - /> - Deploy now - </a> - <a - className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center 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-full sm:w-auto md:w-[158px]" - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - 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 - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="/file.svg" - alt="File icon" - width={16} - height={16} - /> - Learn - </a> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="/window.svg" - alt="Window icon" - width={16} - height={16} - /> - Examples - </a> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="/globe.svg" - alt="Globe icon" - width={16} - height={16} - /> - Go to nextjs.org → - </a> - </footer> - </div> - ); -} diff --git a/src/app/todo-list/[title]/page.tsx b/src/app/todo-list/[title]/page.tsx @@ -1,433 +0,0 @@ -"use client"; -import { - KeyboardEvent, - KeyboardEventHandler, - Ref, - RefObject, - useEffect, - useRef, - useState, -} from "react"; - -import { useLocalStorage } from "@/lib/useLocalStorage"; -import { useParams, useRouter } from "next/navigation"; - -const MAX_TASK_LENGTH = 128; - -const genId = () => { - try { - return crypto.randomUUID(); - } catch (e) { - return String(Math.random()); // good enough for being unique on a single local list - } -}; - -type TodoItem = { - description: string; - isDone: boolean; - id: string; -}; - -function mkTodo(description: string = ""): TodoItem { - return { - description, - isDone: false, - id: genId(), - }; -} - -// helper hooks - -function useListFocuser<T extends HTMLElement>(): [ - RefObject<(T | null)[]>, - (idx: number, evt: KeyboardEvent<T>) => void, -] { - const listRef = useRef<(T | null)[]>([]); - - const onKeyDown = (idx: number, evt: KeyboardEvent) => { - switch (evt.key) { - case "ArrowUp": { - if (idx > 0) { - evt.preventDefault(); - listRef.current[idx - 1]?.focus(); - } - break; - } - case "ArrowDown": { - if (idx < listRef.current.length - 1) { - evt.preventDefault(); - listRef.current[idx + 1]?.focus(); - } - break; - } - } - }; - - return [listRef, onKeyDown]; -} - -// components - -type CheckboxProps = { - isChecked: boolean; - onToggle: () => void; - idx: number; - ref: Ref<HTMLButtonElement>; - onKeyDown: KeyboardEventHandler<HTMLButtonElement>; -}; - -function Checkbox(props: CheckboxProps) { - const { isChecked, onToggle, idx, ref, onKeyDown } = props; - return ( - <button - ref={ref} - className={`cursor-pointer ${!isChecked ? "bg-gray-800 hover:bg-gray-700 " : ""} select-none text-xl aspect-square w-[24px] h-[24px] leading-0`} - onClick={onToggle} - onKeyDown={onKeyDown} - > - {isChecked ? "✅" : ""} - </button> - ); -} - -type TodoItemProps = { - onDelete: () => void; - onToggleDone: () => void; - updateDescription: (description: string) => void; - todo: TodoItem; - inputRef?: (ref: HTMLInputElement | null) => void; - idx: number; - onInputKeyPress: KeyboardEventHandler<HTMLInputElement>; - xRef: (ref: HTMLButtonElement | null) => void; - xOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; - checkRef: (ref: HTMLButtonElement | null) => void; - checkOnKeyDown: KeyboardEventHandler<HTMLButtonElement>; -}; - -function TodoItem(props: TodoItemProps) { - const { - onDelete, - onToggleDone, - todo, - updateDescription, - inputRef, - idx, - onInputKeyPress, - xRef, - xOnKeyDown, - checkRef, - checkOnKeyDown, - } = props; - - const initialDescription = useRef(todo.description); - const commitChange = () => { - initialDescription.current = todo.description; - }; - useEffect(() => { - initialDescription.current = todo.description; - }, []); - - const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { - switch (evt.key) { - case "Enter": { - if (todo.description.length) commitChange(); - break; - } - case "Escape": { - updateDescription(initialDescription.current); - break; - } - } - onInputKeyPress(evt); - }; - return ( - <div className="group [&:not(:last-child)>*]:border-b-1 flex flex-col"> - <div className="text-md gap-2 flex place-content-between p-1 mx-4 relative border-gray-300 dark:border-gray-700"> - <Checkbox - ref={checkRef} - onKeyDown={checkOnKeyDown} - isChecked={todo.isDone} - onToggle={onToggleDone} - idx={idx} - /> - <input - className="grow" - placeholder={"new task"} - value={todo.description} - onChange={(e) => updateDescription(e.target.value)} - maxLength={MAX_TASK_LENGTH} - onBlur={() => { - commitChange(); - }} - onFocus={commitChange} - onKeyDown={handleKeyPress} - ref={inputRef} - ></input> - <button - ref={xRef} - onKeyDown={xOnKeyDown} - onClick={onDelete} - className="items-center top-0 cursor-pointer bottom-0" - > - ❌ - </button> - </div> - </div> - ); -} - -type TitleProps = { title: string; rename: (newTitle: string) => boolean }; - -function Title(props: TitleProps) { - const { title, rename } = props; - const [editingTitle, setEditingTitle] = useState<null | string>(null); - const handleKeyPress: KeyboardEventHandler<HTMLInputElement> = (evt) => { - switch (evt.key) { - case "Enter": { - evt.preventDefault(); - if (editingTitle) { - const success = rename(editingTitle); - if (!success) setEditingTitle(null); - } - break; - } - case "Escape": { - setEditingTitle(null); - } - } - }; - return ( - <h1 className="text-4xl"> - <input - className="text-center" - type="text" - value={editingTitle || title} - onChange={(evt) => setEditingTitle(evt.target.value)} - onKeyDown={handleKeyPress} - onBlur={() => { - if (editingTitle) { - const success = rename(editingTitle); - if (!success) setEditingTitle(null); - } - }} - ></input> - </h1> - ); -} - -export default function TodoList() { - const router = useRouter(); - const { title: _title } = useParams<{ title: string }>(); - const title = decodeURIComponent(_title); - const [items, setItems] = useLocalStorage<TodoItem[]>(title, [mkTodo()]); - const todoDescriptionRefs = useRef<(HTMLInputElement | null)[]>([]); - const renameTitle = (newTitle: string) => { - const dataAtNewTitle = !!localStorage.getItem(newTitle); - if (dataAtNewTitle) { - alert("A todo list with that name already exists!"); - return false; - } else { - localStorage.removeItem(title); - localStorage.setItem(newTitle, JSON.stringify(items)); - router.replace(`/todo-list/${encodeURIComponent(newTitle)}`); - return true; - } - }; - - const [xRef, xOnKeyDown] = useListFocuser<HTMLButtonElement>(); - const [checkRef, checkOnKeyDown] = useListFocuser<HTMLButtonElement>(); - - const focusInput = (idx: number) => { - const el = todoDescriptionRefs.current[idx]; - if (el) el.focus(); - }; - - const autoFocusInputIdx = useRef<number | null>(null); - - useEffect(() => { - if (autoFocusInputIdx.current !== null) { - focusInput(autoFocusInputIdx.current); - autoFocusInputIdx.current = null; - } - }, [items]); - - const addTodo = (idx: number) => { - setItems((items) => { - const newItems = [...items]; - newItems.splice(idx, 0, mkTodo()); - return newItems; - }); - autoFocusInputIdx.current = idx; - }; - - const deleteTodo = (idx: number) => { - setItems((items) => { - const newItems = items.filter((item, iidx) => idx !== iidx); - if (!newItems.length) { - newItems.push(mkTodo("")); - } - return newItems; - }); - }; - - const gotoTodo = ( - idx: number, - deltaIdx: number, - selectionPosition: "start" | "end" | number, - ) => { - const gotoIdx = idx + deltaIdx; - if (gotoIdx < 0) { - todoDescriptionRefs.current[idx]?.setSelectionRange(0, 0); - } else if (gotoIdx >= items.length) { - const lastDescLength = items.at(-1)?.description.length || 0; - todoDescriptionRefs.current - .at(-1) - ?.setSelectionRange(lastDescLength, lastDescLength); - } else { - focusInput(idx + deltaIdx); - const selection = - selectionPosition === "end" - ? items[idx + deltaIdx].description.length - : selectionPosition === "start" - ? 0 - : selectionPosition; - todoDescriptionRefs.current[idx + deltaIdx]?.setSelectionRange( - selection, - selection, - ); - } - }; - - useEffect(() => { - if (!items.length) { - setItems([mkTodo()]); - } - }, [items.length]); - - return ( - <div className="flex flex-col items-center justify-items-center min-h-screen pb-20 gap-16 font-[monospace]"> - <header> - <Title title={title} rename={renameTitle} /> - </header> - <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start w-full"> - <div className="w-[100%]"> - {items.map((todo, idx) => { - const inputPosition = () => - todoDescriptionRefs.current[idx]?.selectionStart; - return ( - <TodoItem - todo={todo} - onToggleDone={() => - setItems((items) => { - const newItems = [...items]; - newItems[idx] = { - ...items[idx], - isDone: !items[idx].isDone, - }; - return newItems; - }) - } - onDelete={() => { - const xIsFocused = - xRef.current[idx]?.matches(":focus-visible"); - setItems((items) => { - // to keep focus on the delete button, just reset the todo if it's the last one. - if (items.length === 1) { - const newItems = [...items]; - newItems[0].description = ""; - newItems[0].isDone = false; - return newItems; - } else { - return items.filter((item, iidx) => idx !== iidx); - } - }); - if (xIsFocused) { - (xRef.current[idx + 1] || xRef.current[idx - 1])?.focus(); - } - }} - updateDescription={(description: string) => - setItems((items) => - items.map((item, iidx) => - idx === iidx ? { ...item, description } : item, - ), - ) - } - key={todo.id} - idx={idx} - inputRef={(ref) => (todoDescriptionRefs.current[idx] = ref)} - onInputKeyPress={(evt) => { - switch (evt.key) { - case "Enter": { - const inputPos = - todoDescriptionRefs.current[idx]?.selectionStart || 0; - - const multiselect = - todoDescriptionRefs.current[idx]?.selectionEnd || - 0 > inputPos; - const pos = - evt.getModifierState("Shift") || - (inputPos === 0 && - todo.description.length && - !multiselect) - ? 0 - : 1; - addTodo(idx + pos); - break; - } - case "Backspace": { - if (!todo.description.length && !evt.repeat) { - if (items.length === 1) { - break; - } - evt.preventDefault(); - const dir = idx === 0 ? 1 : -1; - // keep the element around while we go to the next todo - // prevents mobile keyboards from closing due to their - // focused element disappearing. - deleteTodo(idx); - gotoTodo(idx, dir, "end"); - } - break; - } - case "ArrowRight": { - if (inputPosition() === todo.description.length) { - evt.preventDefault(); - gotoTodo(idx, 1, "start"); - } - break; - } - case "ArrowLeft": { - if (inputPosition() === 0) { - evt.preventDefault(); - gotoTodo(idx, -1, "end"); - } - break; - } - case "ArrowDown": { - evt.preventDefault(); - gotoTodo(idx, 1, inputPosition() || "start"); - break; - } - case "ArrowUp": { - evt.preventDefault(); - gotoTodo(idx, -1, inputPosition() || "start"); - break; - } - } - }} - xRef={(ref) => { - xRef.current[idx] = ref; - }} - xOnKeyDown={(evt) => xOnKeyDown(idx, evt)} - checkRef={(ref) => { - checkRef.current[idx] = ref; - }} - checkOnKeyDown={(evt) => checkOnKeyDown(idx, evt)} - /> - ); - })} - </div> - </main> - </div> - ); -} diff --git a/src/app/todo-list/page.tsx b/src/app/todo-list/page.tsx @@ -1,3 +0,0 @@ -export default function Page() { - return <h2>howde?</h2>; -} diff --git a/src/lib/useLocalStorage.ts b/src/lib/useLocalStorage.ts @@ -3,7 +3,7 @@ import { Dispatch, SetStateAction, useEffect, useState } from "react"; function getStorageValue<T>(key: string, defaultValue: T) { - const saved = localStorage.getItem(key); + const saved = window.localStorage.getItem(key); if (!saved) return defaultValue; const initial = JSON.parse(saved) as T; return initial || defaultValue; @@ -18,8 +18,7 @@ export const useLocalStorage = <T>( }); useEffect(() => { - // TODO: may want to delete stored data at the old key if key changes - localStorage.setItem(key, JSON.stringify(value)); + window.localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue];