Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions todo-app/src/app/components/AddTodoForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import { useState } from 'react';

interface AddTodoFormProps {
onAdd: (title: string) => void;
isLoading?: boolean;
}

export default function AddTodoForm({ onAdd, isLoading = false }: AddTodoFormProps) {
const [title, setTitle] = useState('');

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
onAdd(title.trim());
setTitle('');
}
};

return (
<form onSubmit={handleSubmit} className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
<div className="flex gap-3">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Add a new todo..."
disabled={isLoading}
className="flex-1 px-4 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={isLoading || !title.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Add
</button>
</div>
</form>
);
}
51 changes: 51 additions & 0 deletions todo-app/src/app/components/DeleteConfirmationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use client';

interface DeleteConfirmationModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
todoTitle: string;
}

export default function DeleteConfirmationModal({
isOpen,
onConfirm,
onCancel,
todoTitle
}: DeleteConfirmationModalProps) {
if (!isOpen) return null;

return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onCancel}
/>

{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100 mb-4">
Delete Todo
</h2>
<p className="text-gray-600 dark:text-gray-300 mb-6">
Are you sure you want to delete "{todoTitle}"? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
);
}
55 changes: 52 additions & 3 deletions todo-app/src/app/components/TodoApp.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
'use client';

import { useState } from 'react';
import { modelenceMutation, modelenceQuery } from '@modelence/react-query';
import { useMutation, useQuery } from '@tanstack/react-query';
import AddTodoForm from './AddTodoForm';
import TodoList from './TodoList';
import DeleteConfirmationModal from './DeleteConfirmationModal';

interface Todo {
_id: string;
Expand All @@ -11,27 +14,73 @@ interface Todo {
}

export default function TodoApp() {
const [todoToDelete, setTodoToDelete] = useState<Todo | null>(null);

const { data: todos, refetch: refetchTodos, isFetching, error } = useQuery(modelenceQuery<Todo[]>('todos.getAll'));
const { mutateAsync: setCompleted } = useMutation(modelenceMutation('todos.setCompleted'));
const { mutateAsync: createTodo } = useMutation(modelenceMutation('todos.create'));
const { mutateAsync: updateTodo } = useMutation(modelenceMutation('todos.update'));
const { mutateAsync: deleteTodo } = useMutation(modelenceMutation('todos.delete'));

if (error) return <div>Error: {error.message}</div>;
if (!todos && !isFetching) return <div>No todos found</div>;

const toggleTodo = async (todo: Todo) => {
const handleAddTodo = async (title: string) => {
await createTodo({ title });
refetchTodos();
};

const handleToggleTodo = async (todo: Todo) => {
await setCompleted({ id: todo._id, completed: !todo.completed });
refetchTodos();
};

const handleEditTodo = async (todo: Todo, newTitle: string) => {
await updateTodo({ id: todo._id, title: newTitle });
refetchTodos();
};

const handleDeleteTodo = (todo: Todo) => {
setTodoToDelete(todo);
};

const confirmDelete = async () => {
if (todoToDelete) {
await deleteTodo({ id: todoToDelete._id });
setTodoToDelete(null);
refetchTodos();
}
};

const cancelDelete = () => {
setTodoToDelete(null);
};

return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-8 text-foreground">My Todo List</h1>

<AddTodoForm onAdd={handleAddTodo} isLoading={isFetching} />

{isFetching && !todos ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 text-center">
<div>Loading...</div>
</div>
) : (
<TodoList todos={todos || []} onToggleTodo={toggleTodo} isLoading={isFetching} />
<TodoList
todos={todos || []}
onToggleTodo={handleToggleTodo}
onEditTodo={handleEditTodo}
onDeleteTodo={handleDeleteTodo}
isLoading={isFetching}
/>
)}

<DeleteConfirmationModal
isOpen={!!todoToDelete}
onConfirm={confirmDelete}
onCancel={cancelDelete}
todoTitle={todoToDelete?.title || ''}
/>
</div>
);
}
107 changes: 92 additions & 15 deletions todo-app/src/app/components/TodoItem.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';

import { useState, useRef, useEffect } from 'react';

interface Todo {
_id: string;
title: string;
Expand All @@ -9,35 +11,110 @@ interface Todo {
interface TodoItemProps {
todo: Todo;
onToggle: (todo: Todo) => void;
onEdit: (todo: Todo, newTitle: string) => void;
onDelete: (todo: Todo) => void;
isDisabled?: boolean;
}

export default function TodoItem({ todo, onToggle, isDisabled = false }: TodoItemProps) {
export default function TodoItem({ todo, onToggle, onEdit, onDelete, isDisabled = false }: TodoItemProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(todo.title);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);

const handleEditSubmit = () => {
if (editValue.trim() && editValue !== todo.title) {
onEdit(todo, editValue.trim());
}
setIsEditing(false);
setEditValue(todo.title);
};

const handleEditCancel = () => {
setIsEditing(false);
setEditValue(todo.title);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleEditSubmit();
} else if (e.key === 'Escape') {
handleEditCancel();
}
};

return (
<li className={`flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg transition-colors ${
isDisabled
? 'opacity-60 cursor-not-allowed'
isDisabled
? 'opacity-60 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
}`}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-1 min-w-0">
<input
type="checkbox"
checked={todo.completed}
onChange={() => !isDisabled && onToggle(todo)}
disabled={isDisabled}
className="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isDisabled || isEditing}
className="w-5 h-5 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
/>
<span className={`${todo.completed ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-700 dark:text-gray-200'}`}>
{todo.title}
{isEditing ? (
<div className="flex items-center gap-2 flex-1">
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleEditCancel}
className="flex-1 px-2 py-1 bg-white dark:bg-gray-600 border border-gray-300 dark:border-gray-500 rounded text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onMouseDown={(e) => {
e.preventDefault();
handleEditSubmit();
}}
className="p-1 text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300 flex-shrink-0"
title="Submit"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
) : (
<span
className={`flex-1 cursor-pointer ${todo.completed ? 'line-through text-gray-500 dark:text-gray-400' : 'text-gray-700 dark:text-gray-200'}`}
onClick={() => !isDisabled && setIsEditing(true)}
>
{todo.title}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
<span className={`px-2 py-1 text-xs rounded-full ${
todo.completed
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
<button
onClick={() => !isDisabled && onDelete(todo)}
disabled={isDisabled || isEditing}
className="p-1 text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<span className={`px-2 py-1 text-xs rounded-full ${
todo.completed
? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</li>
);
}
32 changes: 21 additions & 11 deletions todo-app/src/app/components/TodoList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,32 @@ interface Todo {
interface TodoListProps {
todos: Todo[];
onToggleTodo: (todo: Todo) => void;
onEditTodo: (todo: Todo, newTitle: string) => void;
onDeleteTodo: (todo: Todo) => void;
isLoading?: boolean;
}

export default function TodoList({ todos, onToggleTodo, isLoading = false }: TodoListProps) {
export default function TodoList({ todos, onToggleTodo, onEditTodo, onDeleteTodo, isLoading = false }: TodoListProps) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<ul className="space-y-3">
{todos.map((todo, index) => (
<TodoItem
key={todo._id || index}
todo={todo}
onToggle={onToggleTodo}
isDisabled={isLoading}
/>
))}
</ul>
{todos.length === 0 ? (
<p className="text-center text-gray-500 dark:text-gray-400 py-8">
No todos yet. Add one above to get started!
</p>
) : (
<ul className="space-y-3">
{todos.map((todo, index) => (
<TodoItem
key={todo._id || index}
todo={todo}
onToggle={onToggleTodo}
onEdit={onEditTodo}
onDelete={onDeleteTodo}
isDisabled={isLoading}
/>
))}
</ul>
)}
</div>
);
}
1 change: 1 addition & 0 deletions todo-app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export default function RootLayout({
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
suppressHydrationWarning
>
<Providers>
{children}
Expand Down
Loading