Skip to content

Instantly share code, notes, and snippets.

@sibbng
Last active May 7, 2025 17:38
Show Gist options
  • Select an option

  • Save sibbng/08b41512c55e22a8f88647bee4e06d70 to your computer and use it in GitHub Desktop.

Select an option

Save sibbng/08b41512c55e22a8f88647bee4e06d70 to your computer and use it in GitHub Desktop.
voby todo app
{
"imports": {
"voby": "https://esm.sh/voby",
"oby": "https://esm.sh/oby"
}
}
@theme {
--font-sans: 'Inter', sans-serif;
}
@custom-variant dark (&:where(.dark, .dark *));
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