diff --git a/.env b/.env new file mode 100644 index 0000000..a366373 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://localhost:7027/api diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..64c0b37 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:5000/api diff --git a/index.html b/index.html index bdb7567..ee3b1eb 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Crunchloop Todo Lists
diff --git a/package-lock.json b/package-lock.json index 5bb8683..274fdd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "react-interview", "version": "0.0.0", "dependencies": { + "@microsoft/signalr": "^9.0.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -667,6 +668,19 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@microsoft/signalr": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz", + "integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1464,6 +1478,18 @@ "vite": "^4 || ^5 || ^6" } }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -1891,6 +1917,24 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1952,6 +1996,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2302,6 +2356,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2444,16 +2518,33 @@ "node": ">= 0.8.0" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2496,6 +2587,12 @@ "react": "^19.0.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2607,6 +2704,12 @@ "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2679,6 +2782,27 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -2742,6 +2866,15 @@ "typescript": ">=4.8.4 <5.9.0" } }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2752,6 +2885,16 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", @@ -2824,6 +2967,22 @@ } } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2850,6 +3009,27 @@ "node": ">=0.10.0" } }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 99a0afe..005476a 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@microsoft/signalr": "^9.0.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..ae5c3bf --- /dev/null +++ b/src/App.css @@ -0,0 +1,207 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: #f5f5f5; +} + +.app { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.app-header { + background-color: #282c34; + padding: 20px; + color: white; + margin-bottom: 30px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.app-header h1 { + font-size: 2rem; + margin: 0; +} + +.container { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 20px; + min-height: 500px; +} + +.panel { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.panel h2 { + margin-bottom: 20px; + color: #282c34; + font-size: 1.5rem; +} + +.lists-panel { + border-right: 1px solid #e0e0e0; +} + +.items-panel { + display: flex; + flex-direction: column; +} + +.list-item { + padding: 12px; + margin-bottom: 8px; + background: #f9f9f9; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.2s; + display: flex; + justify-content: space-between; + align-items: center; +} + +.list-item .item-count { + font-size: 0.9rem; + color: #666; +} + +.list-item:hover { + background: #e8e8e8; +} + +.list-item.active { + background: #007bff; + color: white; +} + +.todo-item { + padding: 12px; + margin-bottom: 8px; + background: #f9f9f9; + border-radius: 4px; + display: flex; + align-items: center; + gap: 12px; + transition: background-color 0.2s; +} + +.todo-item:hover { + background: #e8e8e8; +} + +.todo-item.completed { + opacity: 0.6; +} + +.todo-item.completed .todo-text { + text-decoration: line-through; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group input, +.form-group textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #007bff; +} + +.btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s; +} + +.btn-primary { + background-color: #007bff; + color: white; +} + +.btn-primary:hover { + background-color: #0056b3; +} + +.btn-danger { + background-color: #dc3545; + color: white; +} + +.btn-danger:hover { + background-color: #c82333; +} + +.btn-small { + padding: 5px 10px; + font-size: 12px; +} + +.checkbox { + width: 20px; + height: 20px; + cursor: pointer; +} + +.empty-state { + text-align: center; + padding: 40px; + color: #666; +} + +.error { + color: #dc3545; + padding: 10px; + background: #f8d7da; + border-radius: 4px; + margin-bottom: 15px; +} + +.success { + color: #155724; + padding: 10px; + background: #d4edda; + border-radius: 4px; + margin-bottom: 15px; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (max-width: 768px) { + .container { + grid-template-columns: 1fr; + } + + .lists-panel { + border-right: none; + border-bottom: 1px solid #e0e0e0; + } +} diff --git a/src/App.tsx b/src/App.tsx index 0b0df93..345585f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,102 @@ -import logo from 'assets/logo.png' +import { useEffect, useState } from 'react' +import './App.css' +import type { TodoList } from './types' +import { getTodoLists, createTodoList, deleteTodoList, updateTodoList } from './services/api' +import TodoListsPanel from './components/TodoListsPanel' +import TodoItemsPanel from './components/TodoItemsPanel' function App() { + const [lists, setLists] = useState([]) + const [selectedListId, setSelectedListId] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + loadLists() + }, []) + + const loadLists = async () => { + try { + setLoading(true) + setError(null) + const data = await getTodoLists() + setLists(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load lists') + } finally { + setLoading(false) + } + } + + const handleSelectList = (id: number) => { + setSelectedListId(id) + } + + const handleCreateList = async (name: string) => { + try { + setError(null) + const result = await createTodoList({ name }) + // Reload lists to get the newly created list with all its data + await loadLists() + // Auto-select the newly created list + setSelectedListId(result.id) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create list') + throw err + } + } + + const handleDeleteList = async (id: number) => { + try { + setError(null) + await deleteTodoList(id) + // If we deleted the selected list, clear the selection + if (selectedListId === id) { + setSelectedListId(null) + } + // Reload lists to reflect the deletion + await loadLists() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete list') + } + } + + const handleUpdateList = async (id: number, name: string) => { + try { + setError(null) + await updateTodoList(id, { name }) + // Reload lists to reflect the update + await loadLists() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update list') + } + } + + const selectedList = lists.find((list) => list.id === selectedListId) + return ( - <> - - +
+
+

Crunchloop Todo Lists Manager

+
+
+ + +
+
) } diff --git a/src/components/CreateItemForm.tsx b/src/components/CreateItemForm.tsx new file mode 100644 index 0000000..7895b8b --- /dev/null +++ b/src/components/CreateItemForm.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' + +interface CreateItemFormProps { + onCreateItem: (description: string, completed: boolean) => Promise +} + +export default function CreateItemForm({ onCreateItem }: CreateItemFormProps) { + const [description, setDescription] = useState('') + const [completed, setCompleted] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!description.trim()) return + + try { + setIsSubmitting(true) + await onCreateItem(description.trim(), completed) + setDescription('') + setCompleted(false) + } catch (error) { + // Error handled by parent + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ setDescription(e.target.value)} + disabled={isSubmitting} + /> +
+
+ + +
+
+ ) +} diff --git a/src/components/CreateListForm.tsx b/src/components/CreateListForm.tsx new file mode 100644 index 0000000..8e28145 --- /dev/null +++ b/src/components/CreateListForm.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react' + +interface CreateListFormProps { + onCreateList: (name: string) => Promise +} + +export default function CreateListForm({ onCreateList }: CreateListFormProps) { + const [name, setName] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + + try { + setIsSubmitting(true) + await onCreateList(name) + setName('') + } catch (error) { + // Error handled by parent + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+ setName(e.target.value)} + disabled={isSubmitting} + /> +
+ +
+ ) +} diff --git a/src/components/TodoItemComponent.tsx b/src/components/TodoItemComponent.tsx new file mode 100644 index 0000000..54e3ce8 --- /dev/null +++ b/src/components/TodoItemComponent.tsx @@ -0,0 +1,38 @@ +import type { TodoItem } from '../types' + +interface TodoItemProps { + item: TodoItem + onToggleComplete: (id: number, completed: boolean) => void + onDelete: (id: number) => void +} + +export default function TodoItemComponent({ item, onToggleComplete, onDelete }: TodoItemProps) { + const handleToggle = () => { + onToggleComplete(item.id, !item.completed) + } + + const handleDelete = () => { + if (confirm(`Are you sure you want to delete "${item.description}"?`)) { + onDelete(item.id) + } + } + + return ( +
+ + {item.description} + +
+ ) +} diff --git a/src/components/TodoItemsPanel.tsx b/src/components/TodoItemsPanel.tsx new file mode 100644 index 0000000..2c89990 --- /dev/null +++ b/src/components/TodoItemsPanel.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from 'react' +import type { TodoItem } from '../types' +import { getTodoItems, createTodoItem, updateTodoItem, deleteTodoItem, completeAllTodoItems } from '../services/api' +import TodoItemComponent from './TodoItemComponent' +import CreateItemForm from './CreateItemForm' +import { useSignalR } from '../hooks/useSignalR' +import { JobState } from '../types' + +interface TodoItemsPanelProps { + todoListId: number | null + todoListName: string | null + onItemsChange: () => Promise +} + +export default function TodoItemsPanel({ todoListId, todoListName, onItemsChange }: TodoItemsPanelProps) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [currentJobId, setCurrentJobId] = useState(null) + const [completionMessage, setCompletionMessage] = useState(null) + + const { jobStatus, error: signalRError } = useSignalR(currentJobId) + + useEffect(() => { + if (todoListId) { + loadItems() + setCurrentJobId(null) + setCompletionMessage(null) + } else { + setItems([]) + } + }, [todoListId]) + + // Monitor job status changes + useEffect(() => { + if (!jobStatus) return + + if (jobStatus.state === JobState.Completed) { + setCompletionMessage('All items marked as done successfully!') + // Refresh items and list counter + loadItems() + onItemsChange() + // Clear job ID after a short delay to show completion state + setTimeout(() => setCurrentJobId(null), 1000) + // Clear completion message after 5 seconds + setTimeout(() => setCompletionMessage(null), 5000) + } else if (jobStatus.state === JobState.Failed) { + setError(jobStatus.errorMessage || 'Job failed') + setTimeout(() => setCurrentJobId(null), 1000) + } + }, [jobStatus]) + + const loadItems = async () => { + if (!todoListId) return + + try { + setLoading(true) + setError(null) + const data = await getTodoItems(todoListId) + setItems(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load items') + } finally { + setLoading(false) + } + } + + const handleCreateItem = async (description: string, completed: boolean) => { + if (!todoListId) return + + try { + setError(null) + await createTodoItem(todoListId, { description, completed }) + // Reload items to reflect the new item + await loadItems() + // Refresh parent lists to update item count + await onItemsChange() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create item') + throw err + } + } + + const handleToggleComplete = async (itemId: number, completed: boolean) => { + if (!todoListId) return + + // Find the item to get its description + const item = items.find((i) => i.id === itemId) + if (!item) return + + try { + setError(null) + await updateTodoItem(todoListId, itemId, { description: item.description, completed }) + // Reload items to reflect the update + await loadItems() + // Refresh parent lists to update incomplete item count + await onItemsChange() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update item') + } + } + + const handleDeleteItem = async (itemId: number) => { + if (!todoListId) return + + try { + setError(null) + await deleteTodoItem(todoListId, itemId) + // Reload items to reflect the deletion + await loadItems() + // Refresh parent lists to update item count + await onItemsChange() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete item') + } + } + + const handleCompleteAll = async () => { + if (!todoListId) return + + try { + setError(null) + setCompletionMessage(null) + const result = await completeAllTodoItems(todoListId) + setCurrentJobId(result.jobId) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start complete all job') + } + } + + const isProcessingJob = currentJobId !== null && + (!jobStatus || (jobStatus.state !== JobState.Completed && jobStatus.state !== JobState.Failed)) + + const hasIncompleteItems = items.some(item => !item.completed) + + if (!todoListId) { + return ( +
+

Todo Items

+
+

Select a list to view items

+
+
+ ) + } + + return ( +
+

Todo Items {todoListName && `- ${todoListName}`}

+ + {error &&
{error}
} + {signalRError &&
SignalR error: {signalRError}
} + {completionMessage &&
{completionMessage}
} + {loading ? ( +
+

Loading items...

+
+ ) : items.length === 0 ? ( +
+

No items in this list yet

+
+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} + + {items.length > 0 && ( +
+ + + {jobStatus && ( +
+ {jobStatus.state === JobState.Queued &&

Job queued, waiting to start...

} + {jobStatus.state === JobState.Processing && ( +

Processing: {jobStatus.processedCount}/{jobStatus.totalCount} items completed...

+ )} +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/TodoListItem.tsx b/src/components/TodoListItem.tsx new file mode 100644 index 0000000..4500501 --- /dev/null +++ b/src/components/TodoListItem.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import type { TodoList } from '../types' +import * as React from 'react'; + +interface TodoListItemProps { + list: TodoList + isActive: boolean + onSelect: (id: number) => void + onDelete: (id: number) => void + onUpdate: (id: number, name: string) => void +} + +export default function TodoListItem({ list, isActive, onSelect, onDelete, onUpdate }: TodoListItemProps) { + const [isEditing, setIsEditing] = useState(false) + const [editName, setEditName] = useState(list.name) + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation() + if (confirm(`Are you sure you want to delete "${list.name}"?`)) { + onDelete(list.id) + } + } + + const handleEdit = (e: React.MouseEvent) => { + e.stopPropagation() + setIsEditing(true) + setEditName(list.name) + } + + const handleSave = async (e: React.MouseEvent) => { + e.stopPropagation() + if (editName.trim() && editName !== list.name) { + onUpdate(list.id, editName.trim()) + } + setIsEditing(false) + } + + const handleCancel = (e: React.MouseEvent) => { + e.stopPropagation() + setIsEditing(false) + setEditName(list.name) + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (editName.trim() && editName !== list.name) { + onUpdate(list.id, editName.trim()) + } + setIsEditing(false) + } else if (e.key === 'Escape') { + setIsEditing(false) + setEditName(list.name) + } + } + + return ( +
!isEditing && onSelect(list.id)} + > + {isEditing ? ( + setEditName(e.target.value)} + onKeyDown={handleKeyDown} + onClick={(e) => e.stopPropagation()} + autoFocus + style={{ flex: 1, marginRight: '10px' }} + /> + ) : ( + {list.name} + )} +
+ {!isEditing && ({list.incompleteItemCount})} + {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ ) +} diff --git a/src/components/TodoListsPanel.tsx b/src/components/TodoListsPanel.tsx new file mode 100644 index 0000000..bf7c7ea --- /dev/null +++ b/src/components/TodoListsPanel.tsx @@ -0,0 +1,55 @@ +import type { TodoList } from '../types' +import TodoListItem from './TodoListItem' +import CreateListForm from './CreateListForm' + +interface TodoListsPanelProps { + lists: TodoList[] + selectedListId: number | null + loading: boolean + error: string | null + onCreateList: (name: string) => Promise + onSelectList: (id: number) => void + onDeleteList: (id: number) => Promise + onUpdateList: (id: number, name: string) => Promise +} + +export default function TodoListsPanel({ + lists, + selectedListId, + loading, + error, + onCreateList, + onSelectList, + onDeleteList, + onUpdateList, +}: TodoListsPanelProps) { + return ( +
+

Lists

+ + {error &&
{error}
} + {loading ? ( +
+

Loading...

+
+ ) : lists.length === 0 ? ( +
+

No todo lists yet

+
+ ) : ( +
+ {lists.map((list) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/hooks/useSignalR.ts b/src/hooks/useSignalR.ts new file mode 100644 index 0000000..b1ff9ef --- /dev/null +++ b/src/hooks/useSignalR.ts @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import * as signalR from '@microsoft/signalr'; +import type { JobStatus } from '../types'; + +const getHubUrl = () => { + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'; + + const baseUrl = apiBaseUrl.replace(/\/api$/, ''); + return `${baseUrl}/hubs/todo-progress`; +}; + +interface UseSignalRResult { + connection: signalR.HubConnection | null; + jobStatus: JobStatus | null; + error: string | null; +} + +export function useSignalR(jobId: string | null): UseSignalRResult { + const [connection, setConnection] = useState(null); + const [jobStatus, setJobStatus] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!jobId) { + setConnection(null); + setJobStatus(null); + setError(null); + return; + } + + const hubUrl = getHubUrl(); + const newConnection = new signalR.HubConnectionBuilder() + .withUrl(hubUrl) + .withAutomaticReconnect() + .build(); + + setConnection(newConnection); + + const startConnection = async () => { + try { + await newConnection.start(); + + // Join the job group + await newConnection.invoke('JoinJobGroup', jobId); + + // Listen for job status updates + newConnection.on('JobStatusUpdate', (status: JobStatus) => { + setJobStatus(status); + }); + } catch (err) { + setError(err instanceof Error ? err.message : 'SignalR connection failed'); + } + }; + + startConnection(); + + return () => { + if (newConnection) { + newConnection.stop(); + } + }; + }, [jobId]); + + return { connection, jobStatus, error }; +} diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..f46d626 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,102 @@ +import type { + TodoList, + TodoItem, + CreateTodoListPayload, + UpdateTodoListPayload, + CreateTodoItemPayload, + UpdateTodoItemPayload, + CompleteAllResponse, + JobStatus, +} from '../types'; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api'; + +// Todo Lists +export async function getTodoLists(): Promise { + const response = await fetch(`${API_BASE_URL}/todolists`); + if (!response.ok) throw new Error('Failed to fetch todo lists'); + return response.json(); +} + +export async function createTodoList(payload: CreateTodoListPayload): Promise<{ id: number }> { + const response = await fetch(`${API_BASE_URL}/todolists`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to create todo list'); + return response.json(); +} + +export async function updateTodoList(id: number, payload: UpdateTodoListPayload): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to update todo list'); + return response.json(); +} + +export async function deleteTodoList(id: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${id}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete todo list'); +} + +// Todo Items +export async function getTodoItems(todoListId: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos`); + if (!response.ok) throw new Error('Failed to fetch todo items'); + return response.json(); +} + +export async function createTodoItem( + todoListId: number, + payload: CreateTodoItemPayload +): Promise<{ id: number; description: string }> { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to create todo item'); + return response.json(); +} + +export async function updateTodoItem( + todoListId: number, + id: number, + payload: UpdateTodoItemPayload +): Promise<{ description: string; completed: boolean }> { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error('Failed to update todo item'); + return response.json(); +} + +export async function deleteTodoItem(todoListId: number, id: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/todos/${id}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete todo item'); +} + +// Complete All +export async function completeAllTodoItems(todoListId: number): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/complete-all`, { + method: 'POST', + }); + if (!response.ok) throw new Error('Failed to start complete all job'); + return response.json(); +} + +export async function getJobStatus(todoListId: number, jobId: string): Promise { + const response = await fetch(`${API_BASE_URL}/todolists/${todoListId}/jobs/${jobId}/status`); + if (!response.ok) throw new Error('Failed to fetch job status'); + return response.json(); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..de283c2 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,51 @@ +export interface TodoList { + id: number; + name: string; + incompleteItemCount: number; +} + +export interface TodoItem { + id: number; + description: string; + completed: boolean; + todoListId?: number; +} + +export interface CreateTodoListPayload { + name: string; +} + +export interface UpdateTodoListPayload { + name: string; +} + +export interface CreateTodoItemPayload { + description: string; + completed: boolean; +} + +export interface UpdateTodoItemPayload { + description: string; + completed: boolean; +} + +export enum JobState { + Queued = 0, + Processing = 1, + Completed = 2, + Failed = 3, +} + +export interface JobStatus { + jobId: string; + state: JobState; + processedCount: number; + totalCount: number; + errorMessage?: string; + createdAt: string; + completedAt?: string; +} + +export interface CompleteAllResponse { + jobId: string; +}