Last active
May 7, 2025 17:38
-
-
Save sibbng/08b41512c55e22a8f88647bee4e06d70 to your computer and use it in GitHub Desktop.
voby todo app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "imports": { | |
| "voby": "https://esm.sh/voby", | |
| "oby": "https://esm.sh/oby" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @theme { | |
| --font-sans: 'Inter', sans-serif; | |
| } | |
| @custom-variant dark (&:where(.dark, .dark *)); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { $, For, render, Observable, useMemo, ObservableReadonly, useEffect, If, Ternary } from 'voby'; | |
| interface Todo { | |
| id: number; | |
| text: string; | |
| description: Observable<string>; | |
| completed: boolean; | |
| createdAt: Date; | |
| isFavorite: Observable<boolean>; | |
| tags: Observable<string[]>; | |
| } | |
| type FilterType = 'all' | 'active' | 'completed' | 'favorites'; | |
| type Theme = 'light' | 'dark'; | |
| const CheckIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" /> | |
| </svg> | |
| ); | |
| const PlusIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /> | |
| </svg> | |
| ); | |
| const TrashIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> | |
| <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /> | |
| </svg> | |
| ); | |
| const CircleIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-neutral-400 dark:text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <circle cx="12" cy="12" r="9" /> | |
| </svg> | |
| ); | |
| const StarIcon = ({ filled }: { filled: boolean }) => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill={filled ? "currentColor" : "none"} stroke={filled ? "none" : "currentColor"} stroke-width="1.5"> | |
| <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> | |
| </svg> | |
| ); | |
| const SunIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /> | |
| </svg> | |
| ); | |
| const MoonIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /> | |
| </svg> | |
| ); | |
| const MenuIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" /> | |
| </svg> | |
| ); | |
| const XIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> | |
| </svg> | |
| ); | |
| const ChevronLeftIcon = () => ( | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /> | |
| </svg> | |
| ); | |
| interface DetailsHeaderMobileProps { | |
| onBack: () => void; | |
| onMenuOpen: () => void; | |
| } | |
| const DetailsHeaderMobile = ({ onBack, onMenuOpen }: DetailsHeaderMobileProps) => ( | |
| <div class="md:hidden sticky top-0 z-10 flex items-center justify-between p-4 bg-white dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700"> | |
| <button onClick={onBack} class="p-2 text-neutral-600 dark:text-neutral-400 flex items-center"> | |
| <ChevronLeftIcon /> <span class="ml-1">Back</span> | |
| </button> | |
| <span class="font-semibold text-neutral-700 dark:text-neutral-200 truncate">Task Details</span> | |
| <button onClick={onMenuOpen} class="p-2 text-neutral-600 dark:text-neutral-400"> | |
| <MenuIcon /> | |
| </button> | |
| </div> | |
| ); | |
| const TodoApp = (): JSX.Element => { | |
| const currentTheme: Observable<Theme> = $('dark'); | |
| const isSidebarOpen: Observable<boolean> = $(false); | |
| const isAddingTag: Observable<boolean> = $(false); | |
| const newTagInput: Observable<string> = $(''); | |
| useEffect(() => { | |
| const applyTheme = (theme: Theme) => { | |
| if (theme === 'dark') { | |
| document.documentElement.classList.add('dark'); | |
| } else { | |
| document.documentElement.classList.remove('dark'); | |
| } | |
| }; | |
| applyTheme(currentTheme()); | |
| }); | |
| const toggleTheme = () => { | |
| currentTheme(prev => prev === 'light' ? 'dark' : 'light'); | |
| }; | |
| const todos: Observable<Todo[]> = $([ | |
| { id: 1, text: "Setup Voby project", description: $("Figure out Tailwind integration."), completed: true, createdAt: new Date(Date.now() - 86400000 * 2), isFavorite: $(false), tags: $(['voby', 'setup']) }, | |
| { id: 2, text: "Build UI components", description: $("Sidebar, Todo List, Details Panel."), completed: false, createdAt: new Date(Date.now() - 86400000), isFavorite: $(true), tags: $(['ui', 'tailwind']) }, | |
| { id: 3, text: "Implement core logic", description: $("Add, toggle, delete, filter, select."), completed: false, createdAt: new Date(), isFavorite: $(false), tags: $(['logic']) }, | |
| ]); | |
| const newTodoText: Observable<string> = $(''); | |
| const selectedTodoId: Observable<number | null> = $(null); | |
| const currentFilter: Observable<FilterType> = $('all'); | |
| const filteredTodos: ObservableReadonly<Todo[]> = useMemo(() => { | |
| const allTodos = todos(); | |
| const filter = currentFilter(); | |
| if (filter === 'active') return allTodos.filter(todo => !todo.completed); | |
| if (filter === 'completed') return allTodos.filter(todo => todo.completed); | |
| if (filter === 'favorites') return allTodos.filter(todo => todo.isFavorite()); | |
| return allTodos; | |
| }); | |
| const selectedTodo: ObservableReadonly<Todo | undefined> = useMemo(() => { | |
| const id = selectedTodoId(); | |
| if (id === null) return undefined; | |
| return todos().find(todo => todo.id === id); | |
| }); | |
| const addTodo = (event?: Event) => { | |
| event?.preventDefault(); | |
| const text = newTodoText().trim(); | |
| if (text) { | |
| const newId = Date.now(); | |
| todos(prev => [ | |
| ...prev, | |
| { id: newId, text, description: $(""), completed: false, createdAt: new Date(), isFavorite: $(false), tags: $([]) } | |
| ]); | |
| newTodoText(''); | |
| selectTodo(newId); | |
| if(isSidebarOpen()) isSidebarOpen(false); | |
| } | |
| }; | |
| const toggleComplete = (id: number) => { | |
| todos(prev => | |
| prev.map(todo => | |
| todo.id === id ? { ...todo, completed: !todo.completed } : todo | |
| ) | |
| ); | |
| }; | |
| const toggleFavorite = (id: number) => { | |
| const todo = todos().find(t => t.id === id); | |
| if (todo) { | |
| todo.isFavorite(!todo.isFavorite()); | |
| if (currentFilter() === 'favorites') todos([...todos()]); | |
| } | |
| }; | |
| const addTagToCurrentTodo = () => { | |
| const todo = selectedTodo(); | |
| const tagValue = newTagInput().trim(); | |
| if (todo && tagValue && !todo.tags().includes(tagValue)) { | |
| todo.tags([...todo.tags(), tagValue]); | |
| } | |
| newTagInput(''); | |
| isAddingTag(false); | |
| }; | |
| const removeTagFromCurrentTodo = (tagToRemove: string) => { | |
| const todo = selectedTodo(); | |
| if (todo) { | |
| todo.tags(todo.tags().filter(tag => tag !== tagToRemove)); | |
| } | |
| }; | |
| const deleteTodo = (id: number) => { | |
| if (selectedTodoId() === id) { | |
| selectedTodoId(null); | |
| } | |
| todos(prev => prev.filter(todo => todo.id !== id)); | |
| }; | |
| const selectTodo = (id: number) => { | |
| selectedTodoId(id); | |
| }; | |
| const handleBackFromDetails = () => { | |
| selectedTodoId(null); | |
| }; | |
| const Sidebar = () => ( | |
| <aside class={[ | |
| "fixed inset-y-0 left-0 z-30 w-64 bg-neutral-100 dark:bg-neutral-800 p-4 border-r border-neutral-200 dark:border-neutral-700", | |
| "flex flex-col space-y-1 transform transition-transform duration-300 ease-in-out", | |
| "md:relative md:translate-x-0 md:flex md:w-60 lg:w-64", | |
| () => isSidebarOpen() ? 'translate-x-0 shadow-xl' : '-translate-x-full' | |
| ]}> | |
| <div class="flex justify-between items-center mb-4 md:mb-0"> | |
| <h1 class="text-2xl font-semibold text-neutral-800 dark:text-white px-2 pb-4 pt-1">Voby Todos</h1> | |
| <button onClick={() => isSidebarOpen(false)} class="md:hidden p-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-white"> | |
| <XIcon/> | |
| </button> | |
| </div> | |
| <NavItem filterType="all" label="All Tasks" /> | |
| <NavItem filterType="active" label="Active" /> | |
| <NavItem filterType="completed" label="Completed" /> | |
| <NavItem filterType="favorites" label="Favorites" /> | |
| <div class="mt-auto pt-4"> | |
| <button | |
| onClick={toggleTheme} | |
| class="w-full flex items-center justify-center px-4 py-2.5 rounded-md text-sm font-medium transition-colors duration-150 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-200 dark:hover:bg-neutral-700" | |
| > | |
| {() => currentTheme() === 'light' ? <MoonIcon /> : <SunIcon />} | |
| <span class="ml-2">Switch to {() => currentTheme() === 'light' ? 'Dark' : 'Light'} Mode</span> | |
| </button> | |
| </div> | |
| </aside> | |
| ); | |
| const NavItem = ({ filterType, label }: { filterType: FilterType, label: string }) => ( | |
| <button | |
| onClick={() => { currentFilter(filterType); if (window.innerWidth < 768) isSidebarOpen(false); }} | |
| class={[ | |
| "w-full text-left px-4 py-2.5 rounded-md text-sm font-medium transition-colors duration-150", | |
| () => currentFilter() === filterType | |
| ? 'bg-indigo-500 text-white' | |
| : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-BOB dark:hover:text-white hover:bg-neutral-200 dark:hover:bg-neutral-700' | |
| ]} | |
| > | |
| {label} | |
| </button> | |
| ); | |
| const TodoListItem = ({ todo }: { todo: Todo }) => ( | |
| <li | |
| onClick={() => selectTodo(todo.id)} | |
| class={[ | |
| "flex items-center p-3 border-b border-neutral-200 dark:border-neutral-700 cursor-pointer group transition-colors duration-150", | |
| () => selectedTodoId() === todo.id | |
| ? 'bg-neutral-200 dark:bg-neutral-700' | |
| : 'hover:bg-neutral-100 dark:hover:bg-neutral-800' | |
| ]} | |
| > | |
| <button | |
| onClick={(e) => { e.stopPropagation(); toggleComplete(todo.id); }} | |
| class={[ | |
| "mr-2 p-1 rounded-full transition-colors hover:bg-neutral-300 dark:hover:bg-neutral-600", | |
| () => todo.completed ? 'text-green-500 dark:text-green-400 bg-green-500/10' : 'text-neutral-400 dark:text-neutral-500' | |
| ]} | |
| aria-label={() => todo.completed ? "Mark as incomplete" : "Mark as complete"} | |
| > | |
| {() => todo.completed ? <CheckIcon /> : <CircleIcon />} | |
| </button> | |
| <div class="flex-grow overflow-hidden"> | |
| <span class={[ | |
| "block truncate", | |
| () => todo.completed ? 'line-through text-neutral-400 dark:text-neutral-500' : 'text-neutral-800 dark:text-neutral-100' | |
| ]}> | |
| {todo.text} | |
| </span> | |
| <If when={() => todo.tags().length > 0}> | |
| <div class="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 truncate"> | |
| <For values={todo.tags}> | |
| {(tag) => ( | |
| <span class="inline-block bg-neutral-200 dark:bg-neutral-600 px-1.5 py-0.5 rounded text-xs mr-1 mb-0.5"> | |
| {tag} | |
| </span> | |
| )} | |
| </For> | |
| </div> | |
| </If> | |
| </div> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); toggleFavorite(todo.id); }} | |
| class={[ | |
| "ml-2 p-1 rounded-full transition-colors", | |
| () => todo.isFavorite() | |
| ? 'text-yellow-500 dark:text-yellow-400' | |
| : 'text-neutral-400 dark:text-neutral-500 opacity-20 group-hover:opacity-100', | |
| "hover:text-yellow-500 dark:hover:text-yellow-400 hover:bg-yellow-500/10" | |
| ]} | |
| aria-label={() => todo.isFavorite() ? "Unmark as favorite" : "Mark as favorite"} | |
| > | |
| {() => <StarIcon filled={todo.isFavorite()} /> } | |
| </button> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); deleteTodo(todo.id); }} | |
| class="ml-1 p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity rounded-full hover:bg-red-500/10" | |
| aria-label="Delete todo" | |
| > | |
| <TrashIcon /> | |
| </button> | |
| </li> | |
| ) | |
| return ( | |
| <div class="flex h-screen bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-sans overflow-hidden"> | |
| <Sidebar /> | |
| <If when={isSidebarOpen}> | |
| <div | |
| onClick={() => isSidebarOpen(false)} | |
| class="fixed inset-0 bg-black/50 z-20 md:hidden" | |
| aria-hidden="true" | |
| /> | |
| </If> | |
| <main class="flex-1 flex flex-col md:flex-row overflow-hidden"> | |
| <section class={[ | |
| "w-full md:w-2/5 lg:w-1/3 border-r border-neutral-200 dark:border-neutral-700 flex-col overflow-y-auto", | |
| () => selectedTodoId() !== null ? 'hidden md:flex' : 'flex' | |
| ]}> | |
| <div class="p-4 border-b border-neutral-200 dark:border-neutral-700 sticky top-0 bg-white dark:bg-neutral-800 z-10 flex items-center"> | |
| <button onClick={() => isSidebarOpen(true)} class="md:hidden mr-2 p-2 text-neutral-600 dark:text-neutral-400"> | |
| <MenuIcon/> | |
| </button> | |
| <form onSubmit={addTodo} class="flex-grow"> | |
| <div class="flex items-center bg-neutral-100 dark:bg-neutral-700 rounded-md"> | |
| <input | |
| type="text" | |
| value={newTodoText} | |
| onInput={e => newTodoText(e.currentTarget.value)} | |
| placeholder="Add a new task..." | |
| class="w-full bg-transparent p-3 text-neutral-800 dark:text-neutral-100 placeholder-neutral-500 dark:placeholder-neutral-400 focus:outline-none" | |
| /> | |
| <button | |
| type="submit" | |
| class="p-2 m-1 text-neutral-500 dark:text-neutral-400 hover:text-green-600 dark:hover:text-green-400 hover:bg-green-500/10 rounded-full transition-colors" | |
| disabled={() => !newTodoText().trim()} | |
| aria-label="Add todo" | |
| > | |
| <PlusIcon /> | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| <ul class="flex-grow"> | |
| <For values={filteredTodos}> | |
| {(todo: Todo) => <TodoListItem todo={todo} />} | |
| </For> | |
| <If when={() => filteredTodos().length === 0}> | |
| <p class="text-center text-neutral-500 dark:text-neutral-400 p-10"> | |
| {() => currentFilter() === 'all' ? "No tasks yet. Add one!" : | |
| currentFilter() === 'active' ? "No active tasks." : | |
| currentFilter() === 'completed' ? "No completed tasks." : | |
| "No favorite tasks."} | |
| </p> | |
| </If> | |
| </ul> | |
| </section> | |
| <section class={[ | |
| "flex-1 p-0 md:p-6 overflow-y-auto bg-white dark:bg-neutral-800", | |
| () => selectedTodoId() === null ? 'hidden md:flex flex-col' : 'flex flex-col' | |
| ]}> | |
| <Ternary when={selectedTodo}> | |
| <div class="flex-grow flex flex-col"> | |
| <DetailsHeaderMobile onBack={handleBackFromDetails} onMenuOpen={() => isSidebarOpen(true)} /> | |
| <div class="p-6 flex-grow flex flex-col"> | |
| <div class="flex justify-between items-start mb-6"> | |
| <div> | |
| <div class="flex items-center mb-1"> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); toggleComplete(selectedTodo().id); }} | |
| class={[ | |
| "mr-3 p-1 rounded-full transition-colors hover:bg-neutral-300 dark:hover:bg-neutral-600", | |
| () => selectedTodo().completed ? 'text-green-500 dark:text-green-400 bg-green-500/10' : 'text-neutral-400 dark:text-neutral-500' | |
| ]} | |
| aria-label={() => selectedTodo().completed ? "Mark as incomplete" : "Mark as complete"} | |
| > | |
| {() => selectedTodo().completed ? <CheckIcon /> : <CircleIcon />} | |
| </button> | |
| <h2 class="text-3xl font-semibold text-neutral-800 dark:text-white break-all">{() => selectedTodo().text}</h2> | |
| </div> | |
| <p class="text-sm text-neutral-500 dark:text-neutral-400 ml-10"> | |
| Created: {() => selectedTodo().createdAt.toLocaleDateString()} | |
| </p> | |
| </div> | |
| <button | |
| onClick={(e) => { e.stopPropagation(); toggleFavorite(selectedTodo().id); }} | |
| class={[ | |
| "p-2 rounded-full transition-colors", | |
| () => selectedTodo().isFavorite() ? 'text-yellow-500 dark:text-yellow-400' : 'text-neutral-400 dark:text-neutral-500', | |
| "hover:text-yellow-500 dark:hover:text-yellow-400 hover:bg-yellow-500/10" | |
| ]} | |
| aria-label={() => selectedTodo().isFavorite() ? "Unmark as favorite" : "Mark as favorite"} | |
| > | |
| {() => <StarIcon filled={selectedTodo().isFavorite()} />} | |
| </button> | |
| </div> | |
| <div class="mb-6"> | |
| <label for="todoDescription" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Description</label> | |
| <textarea | |
| id="todoDescription" | |
| rows={5} | |
| class="w-full p-3 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 rounded-md text-neutral-800 dark:text-neutral-100 placeholder-neutral-500 dark:placeholder-neutral-400 focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none" | |
| placeholder="Add more details..." | |
| value={() => selectedTodo().description()} | |
| onInput={e => selectedTodo().description(e.currentTarget.value)} | |
| /> | |
| </div> | |
| <div class="mb-6"> | |
| <label class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Tags</label> | |
| <div class="flex flex-wrap gap-2 mb-2"> | |
| <For values={() => selectedTodo().tags()}> | |
| {(tag) => ( | |
| <span class="inline-flex items-center bg-indigo-100 dark:bg-indigo-700 text-indigo-700 dark:text-indigo-200 px-2.5 py-1 rounded-full text-xs font-medium"> | |
| {tag} | |
| <button | |
| onClick={() => removeTagFromCurrentTodo(tag)} | |
| class="ml-1.5 text-indigo-400 dark:text-indigo-300 hover:text-indigo-600 dark:hover:text-indigo-100 focus:outline-none" | |
| > | |
| × | |
| </button> | |
| </span> | |
| )} | |
| </For> | |
| </div> | |
| <If when={() => !isAddingTag()}> | |
| <button | |
| onClick={() => isAddingTag(true)} | |
| class="text-sm text-indigo-600 dark:text-indigo-400 hover:underline focus:outline-none flex items-center" | |
| > | |
| <PlusIcon /> <span class="ml-1">Add Tag</span> | |
| </button> | |
| </If> | |
| <If when={isAddingTag}> | |
| <form onSubmit={(e) => { e.preventDefault(); addTagToCurrentTodo();}} class="flex items-center gap-2 mt-1"> | |
| <input | |
| type="text" | |
| value={newTagInput} | |
| onInput={e => newTagInput(e.currentTarget.value)} | |
| onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addTagToCurrentTodo(); }}} | |
| placeholder="New tag..." | |
| class="flex-grow p-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 rounded-md text-sm focus:ring-indigo-500 focus:border-indigo-500 focus:outline-none" | |
| ref={el => setTimeout(() => el?.focus(), 0)} | |
| /> | |
| <button | |
| type="submit" | |
| class="px-3 py-2 bg-green-500 hover:bg-green-600 text-white rounded-md text-sm" | |
| > | |
| Add | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => { isAddingTag(false); newTagInput(''); }} | |
| class="px-3 py-2 bg-neutral-200 dark:bg-neutral-600 hover:bg-neutral-300 dark:hover:bg-neutral-500 text-neutral-700 dark:text-neutral-200 rounded-md text-sm" | |
| > | |
| Cancel | |
| </button> | |
| </form> | |
| </If> | |
| </div> | |
| <div class="mt-auto border-t border-neutral-200 dark:border-neutral-700 pt-6 flex justify-between items-center"> | |
| <button | |
| onClick={() => toggleComplete(selectedTodo().id)} | |
| class={() => `px-4 py-2 rounded-md text-sm font-medium transition-colors | |
| ${selectedTodo().completed | |
| ? 'bg-yellow-500 hover:bg-yellow-600 text-white' | |
| : 'bg-green-500 hover:bg-green-600 text-white' | |
| }`} | |
| > | |
| {() => selectedTodo().completed ? 'Mark as Active' : 'Mark as Completed'} | |
| </button> | |
| <button | |
| onClick={() => deleteTodo(selectedTodo().id)} | |
| class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm font-medium transition-colors flex items-center space-x-2" | |
| > | |
| <TrashIcon /> | |
| <span>Delete Task</span> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex-grow flex flex-col items-center justify-center text-neutral-500 dark:text-neutral-400 p-6"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1"> | |
| <path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /> | |
| </svg> | |
| <p class="text-lg">Select a task to see its details</p> | |
| </div> | |
| </Ternary> | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| }; | |
| render(<TodoApp />, document.getElementById('app')); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment