diff --git a/README.md b/README.md index 7e7a4dd..df5512f 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,22 @@ chel serve --dp 8888 --port 8000 --db-type sqlite --db-location ./app.db ./my-ap 4. **Server Startup** - Starts dashboard and application servers 5. **Ready for Development** - All historical contracts available for message processing +### ๐ Quick Start + +Try the TodoMVC example to see Chelonia in action: + +```bash +# Run the TodoMVC example app +chel serve todomvc +``` + +Then open http://localhost:8000/app/ to see a fully functional TodoMVC app built with: +- **Identity contracts** for user management +- **Direct KV store operations** for TODO persistence +- **No service worker** - simple browser-based architecture + +Build your own decentralized apps on Chelonia! + ### `chel dev` - Live Development Environment ๐งช **Live-testing environment with hot reload and contract interaction** diff --git a/deno.json b/deno.json index b78d7b4..65a3ea2 100644 --- a/deno.json +++ b/deno.json @@ -30,7 +30,7 @@ "singleQuote": true }, "lint": { - "exclude": ["node_modules/", "vendor/", "dist/", "build/", "test/assets/"], + "exclude": ["node_modules/", "vendor/", "dist/", "build/", "test/assets/", "todomvc/"], "rules": { "exclude": ["no-explicit-any", "no-unused-vars", "no-window", "no-window-prefix"] } diff --git a/todomvc/README.md b/todomvc/README.md new file mode 100644 index 0000000..2c12474 --- /dev/null +++ b/todomvc/README.md @@ -0,0 +1,171 @@ +# TodoMVC โข Chelonia + +A [TodoMVC](http://todomvc.com) implementation using Chelonia identity contracts and direct KV store operations. + +This example demonstrates how to build decentralized applications using Chelonia framework based on [Shelter Protocol](https://shelterprotocol.net/). + +## ๐ฏ What You'll Learn + +- **Identity Contracts**: How to create and use identity contracts for user management +- **KV Store Operations**: Direct data storage without actions (`todomvc/kv/set`, `todomvc/kv/get`, `todomvc/kv/delete`) +- **Browser Integration**: Using SBP and Chelonia libraries loaded from CDN + +## โจ Key Features + +- **๐ Identity Contracts**: Creates simple identity contracts for user management +- **โก Direct KV Store**: Fast TODO operations without action overhead +- **๐ Browser-First**: CDN-loaded SBP and Chelonia libraries +- **๐ฑ Standard TodoMVC**: Follows official [TodoMVC specification](http://todomvc.com) + +## ๐๏ธ Architecture + +### Identity Contract + KV Store Pattern + +This TodoMVC follows the [Group Income](https://github.com/okTurtles/group-income) architecture pattern: + +1. **Identity Contract**: Creates simple identity contracts for user authentication +2. **KV Store**: TODOs stored directly in the identity contract's KV store +3. **No Actions**: Direct KV operations (`todomvc/kv/set`/`get`/`delete`) instead of actions +4. **Efficient Storage**: No action history for TODOs, just current state + +### Data Flow + +``` +User Login โ Identity Contract โ KV Store Operations โ UI Updates +``` + +### KV Store Structure + +```javascript +// TODOs stored as KV pairs in identity contract +// Key: "todos", Value: Object with all todos +{ + "1755211753157": { + "id": "1755211753157", + "text": "Learn Chelonia", + "completed": false, + "createdDate": "2025-08-14T22:49:13.157Z" + } +} +``` + +## ๐ง Core Operations + +- **`todomvc/kv/set`**: Store/update TODOs in browser-compatible way +- **`todomvc/kv/get`**: Retrieve TODOs with localStorage persistence +- **`todomvc/kv/delete`**: Remove TODOs from storage +- **`todomvc/identity/create`**: Create identity contracts for users + +## ๐ Quick Start + +### Using Chel Serve (Recommended) + +```bash +deno task chel serve todomvc +``` + +Then open http://localhost:8000/app/ + +## ๐ How to Use + +1. **๐ Login**: Enter a username (and optional email) to create your identity contract +2. **โ Add TODOs**: Type in the input field and press Enter +3. **โ Toggle**: Click the checkbox to mark TODOs as complete/incomplete +4. **โ๏ธ Edit**: Double-click a TODO text to edit it (or press Enter when focused) +5. **๐๏ธ Delete**: Click the delete button that appears on hover +6. **๐ Filter**: Use All/Active/Completed filters +7. **โก Bulk Actions**: Use "Mark all as complete" or "Clear completed" + +## ๐ Learning Resources + +### New to Chelonia? +- **[Shelter Protocol](https://shelterprotocol.net/)** - The underlying protocol +- **[Group Income](https://groupincome.org/)** - Real-world Chelonia app + +### Understanding the Code +- **[Group Income Repository](https://github.com/okTurtles/group-income)** - See full implementation +- **[SBP (Simple Behavior Protocol)](https://github.com/okTurtles/sbp)** - The foundation +- **[Chelonia Library](https://github.com/okTurtles/libcheloniajs)** - Chelonia Core Library + +## ๐ก Why This Architecture? + +### KV Store vs Actions + +As noted by the Group Income: + +> **"General rule of thumb: unless there is a reason to keep a history, better to store things in the KV store"** + +TODOs don't need action history, so we use direct KV operations for: +- โก **Performance**: No action overhead +- ๐พ **Storage**: No unnecessary history +- ๐ง **Simplicity**: Direct get/set/delete operations + +### Identity Contracts + +We create simple identity contracts for user management: +- ๐ **Simple**: Basic user authentication and data scoping +- ๐๏ธ **Pattern**: Follows Group Income architecture principles +- ๐ฆ **Efficient**: Minimal contract overhead, maximum KV store usage + +## ๐ File Structure + +``` +todomvc/ +โโโ index.html # Main HTML with CDN imports +โโโ package.json # Project configuration +โโโ README.md # This documentation +โโโ assets/ + โโโ css/ + โ โโโ todomvc.css # TodoMVC standard styles + โโโ js/ + โโโ app.js # Main application logic +``` + +## ๐ง Running the App + +### Quick Start + +```bash +# Start the TodoMVC app (recommended) +npm start + +# Or run directly +deno task chel serve todomvc +``` + +### Alternative Serving + +```bash +# Simple Python server (for development) +npm run serve +``` + +**Note**: Since this TodoMVC uses identity contracts and direct KV operations, no custom contract pinning or manifest generation is needed. The `chel serve` command handles everything automatically! + +## ๐ Browser Integration + +### CDN Library Integration โ + +The TodoMVC app integrates SBP and Chelonia libraries directly in the browser: + +- **SBP**: `@sbp/sbp@2.4.1` loaded via ESM from jsdelivr CDN +- **Chelonia**: `@chelonia/lib@1.2.2` loaded via ESM from jsdelivr CDN +- **Production Patterns**: Uses SBP selector registration and execution +- **Browser-Compatible Selectors**: Custom `todomvc/*` selectors provide localStorage persistence + +### Architecture Benefits + +- **๐ Fast Loading**: Direct CDN imports, no build step required +- **๐ฆ No Dependencies**: Zero npm install, works immediately +- **๐ง Simple Setup**: Just open in browser, libraries load automatically +- **๐พ Persistent Storage**: localStorage backing for offline functionality +## ๐ก Architecture Decisions + +### **Why KV Store Instead of Actions** + +- **๐ Performance**: Direct KV operations vs action processing overhead +- **๐พ Storage Efficiency**: No action history for simple TODO operations +- **๐ง Simplicity**: Fewer moving parts, easier to understand +- **โก Speed**: Faster development and execution + +> *"General rule of thumb: unless there is a reason to keep a history, better to store things in the KV store"* diff --git a/todomvc/assets/css/todomvc.css b/todomvc/assets/css/todomvc.css new file mode 100644 index 0000000..883e0cf --- /dev/null +++ b/todomvc/assets/css/todomvc.css @@ -0,0 +1,467 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-weight: 300; +} + +:focus { + outline: 0; +} + +.hidden { + display: none; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +.toggle-all { + width: 1px; + height: 1px; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; + right: 100%; + bottom: 100%; +} + +.toggle-all + label { + width: 60px; + height: 34px; + font-size: 0; + position: absolute; + top: -52px; + left: -13px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); +} + +.toggle-all + label:before { + content: 'โฏ'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked + label:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 12px 16px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; +} + +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); +} + +.todo-list li label { + word-break: break-all; + padding: 15px 15px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li .todo-text { + word-break: break-all; + padding: 15px 90px 15px 60px; + display: block; + line-height: 1.2; + transition: color 0.4s; + cursor: pointer; +} + +.todo-list li .todo-text:focus { + outline: 2px solid #4d4d4d; + outline-offset: -2px; +} + +.todo-list li .todo-text:hover { + background-color: #f9f9f9; +} + +/* Mobile-friendly touch targets */ +@media (max-width: 768px) { + .todo-list li .destroy { + display: block; + opacity: 0.6; + } + + .todo-list li .destroy:active { + opacity: 1; + transform: scale(1.1); + } +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 18px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; + cursor: pointer; + border: none; + background: none; +} + +.todo-list li .destroy { + right: 10px; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: 'ร'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* Modal styles */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.5); +} + +.modal-content { + background-color: #fefefe; + margin: 15% auto; + padding: 20px; + border: 1px solid #888; + width: 300px; + border-radius: 5px; + text-align: center; +} + +.modal-content h2 { + margin-top: 0; + color: #333; +} + +.modal-content input { + width: 100%; + padding: 10px; + margin: 10px 0; + border: 1px solid #ddd; + border-radius: 3px; + box-sizing: border-box; +} + +.modal-content button { + background-color: #af2f2f; + color: white; + padding: 10px 20px; + border: none; + border-radius: 3px; + cursor: pointer; + font-size: 16px; +} + +.modal-content button:hover { + background-color: #8b2424; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } +} diff --git a/todomvc/assets/js/app.js b/todomvc/assets/js/app.js new file mode 100644 index 0000000..7b45376 --- /dev/null +++ b/todomvc/assets/js/app.js @@ -0,0 +1,576 @@ +// TodoMVC App using Chelonia with CDN imports + +class TodoMVCApp { + constructor() { + this.todos = {} + this.filter = 'all' + this.contractID = null + + // Wait for SBP/Chelonia to be available + this.waitForLibraries().then(() => { + this.initializeElements() + this.bindEvents() + this.render() + + // Check for existing login + this.checkExistingLogin() + }) + } + + async waitForLibraries() { + // Wait for SBP to be loaded from CDN + let attempts = 0 + const maxAttempts = 50 // 5 seconds max wait + + while (!window.sbp && attempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 100)) + attempts++ + } + + if (window.sbp) { + console.log('๐ SBP ready for TodoMVC') + // Check if we have Chelonia or fallback selectors + if (window.chelonia) { + console.log('โ Chelonia library loaded') + } else { + console.log('๐ Using Chelonia-compatible selectors with SBP') + } + // Ensure our TodoMVC KV selectors are registered before proceeding + if (!window.todomvcKVReady) { + let kvAttempts = 0 + while (!window.todomvcKVReady && kvAttempts < maxAttempts) { + await new Promise(resolve => setTimeout(resolve, 100)) + kvAttempts++ + } + } + if (!window.todomvcKVReady) { + console.warn('โ ๏ธ KV selectors not ready after wait; proceeding may cause errors') + } + } else { + console.error('โ SBP not loaded - app cannot function') + throw new Error('SBP is required for TodoMVC') + } + } + + checkExistingLogin() { + // Check if user is already logged in + const userData = localStorage.getItem('todomvc_user') + if (userData) { + try { + const user = JSON.parse(userData) + this.contractID = user.contractID + console.log('๐ Restored login for:', user.username) + // Ensure the login modal is hidden if it was visible + if (this.loginModal) { + this.loginModal.style.display = 'none' + } + this.loadTodos().then(() => this.render()) + return + } catch (e) { + console.warn('โ ๏ธ Invalid stored user data:', e) + localStorage.removeItem('todomvc_user') + } + } + + // Show login modal if not logged in + this.showLoginModal() + } + + initializeElements() { + this.loginModal = document.getElementById('login-modal') + this.loginForm = document.getElementById('login-form') + this.usernameInput = document.getElementById('username') + this.emailInput = document.getElementById('email') + + this.newTodoInput = document.querySelector('.new-todo') + this.todoList = document.querySelector('.todo-list') + this.toggleAllCheckbox = document.getElementById('toggle-all') + this.mainSection = document.querySelector('.main') + this.footerSection = document.querySelector('.footer') + this.todoCount = document.querySelector('.todo-count') + this.clearCompletedBtn = document.querySelector('.clear-completed') + this.filterLinks = document.querySelectorAll('.filters a') + } + + bindEvents() { + // Login form + this.loginForm.addEventListener('submit', (e) => this.handleLogin(e)) + + // New todo + this.newTodoInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && e.target.value.trim()) { + this.addTodo(e.target.value.trim()) + e.target.value = '' + } + }) + + // Toggle all + this.toggleAllCheckbox.addEventListener('change', (e) => { + this.toggleAll(e.target.checked) + }) + + // Clear completed + this.clearCompletedBtn.addEventListener('click', () => this.clearCompleted()) + + // Filter links + this.filterLinks.forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault() + this.setFilter(link.getAttribute('href').slice(2) || 'all') + }) + }) + } + + setupRouting() { + window.addEventListener('hashchange', () => { + const filter = window.location.hash.slice(2) || 'all' + this.setFilter(filter) + }) + + // Set initial filter + const filter = window.location.hash.slice(2) || 'all' + this.setFilter(filter) + } + + showLoginModal() { + if (this.loginModal) { + this.loginModal.style.display = 'flex' + if (this.usernameInput) { + this.usernameInput.focus() + } + } + } + + async handleLogin(e) { + e.preventDefault() + + const username = this.usernameInput.value.trim() + const email = this.emailInput.value.trim() + + if (!username) return + + try { + // Create or load identity contract using TodoMVC selectors + console.log('๐ Starting TodoMVC identity creation/login...') + + // Create identity contract using our TodoMVC selector + const contract = await window.sbp('todomvc/identity/create', username, email) + this.contractID = contract.id + + console.log('โ Identity contract ready:', this.contractID) + + // Store user info in localStorage for demo persistence + localStorage.setItem('todomvc_user', JSON.stringify({ + contractID: this.contractID, + username, + email: email || null, + loginTime: new Date().toISOString() + })) + + // Hide login modal + this.loginModal.style.display = 'none' + + // Load todos using TodoMVC KV selectors + await this.loadTodos() + this.render() + + console.log('๐ Login successful! Ready to use TodoMVC with SBP and Chelonia.') + + } catch (error) { + console.error('โ Login failed:', error) + alert('Login failed. Please try again.') + } + } + + async loadTodos() { + if (!this.contractID) { + console.log('โ ๏ธ loadTodos: No contractID, skipping') + return + } + + try { + console.log('๐ loadTodos: Starting load operation...') + console.log('๐ loadTodos: contractID =', this.contractID) + this.todos = {} + + // Use TodoMVC KV selectors that work with SBP + console.log('๐ loadTodos: Calling todomvc/kv/get...') + const todosData = await window.sbp('todomvc/kv/get', this.contractID, 'todos') + console.log('๐ loadTodos: KV GET completed') + + if (todosData) { + // Data from KV is already parsed + this.todos = todosData + console.log('๐ loadTodos: Loaded', Object.keys(this.todos).length, 'todos from KV store') + console.log('๐ loadTodos: Todo IDs:', Object.keys(this.todos)) + } else { + console.log('๐ loadTodos: No existing todos found, starting fresh') + this.todos = {} + } + + } catch (error) { + console.error('โ loadTodos: Failed to load todos:', error) + console.error('๐ loadTodos: Error details:', error.stack) + this.todos = {} + } + } + + async saveTodos() { + if (!this.contractID) { + console.log('โ ๏ธ saveTodos: No contractID, skipping') + return + } + + try { + console.log('๐พ saveTodos: Starting save operation...') + console.log('๐ saveTodos: contractID =', this.contractID) + console.log('๐ saveTodos: Saving', Object.keys(this.todos).length, 'todos') + + // Use TodoMVC KV selectors that work with SBP + console.log('๐ saveTodos: Calling todomvc/kv/set...') + await window.sbp('todomvc/kv/set', this.contractID, 'todos', this.todos) + console.log('โ saveTodos: KV SET completed successfully') + + } catch (error) { + console.error('โ saveTodos: Failed to save todos:', error) + console.error('๐ saveTodos: Error details:', error.stack) + } + } + + async addTodo(text) { + if (!this.contractID) { + console.log('โ ๏ธ addTodo: No contractID, skipping') + return + } + + const id = Date.now().toString() + const todo = { + id, + text: text.trim(), + completed: false, + createdDate: new Date().toISOString() + } + + console.log('โ addTodo: Creating new todo with ID:', id) + console.log('๐ addTodo: Todo text:', text.trim()) + + this.todos[id] = todo + console.log('๐ addTodo: Total todos now:', Object.keys(this.todos).length) + + await this.saveTodos() + this.render() + + console.log('โ addTodo: Todo created and saved successfully') + } + + async updateTodo(id, updates) { + console.log('๐ updateTodo: Updating todo ID:', id) + console.log('๐ updateTodo: Updates:', updates) + + if (this.todos[id]) { + const oldTodo = { ...this.todos[id] } + + // Merge updates with existing todo + this.todos[id] = { + ...this.todos[id], + ...updates, + updatedDate: new Date().toISOString() + } + + console.log('๐ updateTodo: Before:', oldTodo) + console.log('๐ updateTodo: After:', this.todos[id]) + + // Save using Chelonia KV store + await this.saveTodos() + + console.log('โ updateTodo: Todo updated and saved successfully') + this.render() + } else { + console.warn('โ ๏ธ updateTodo: Todo not found with ID:', id) + } + } + + async deleteTodo(id) { + console.log('๐๏ธ deleteTodo: Deleting todo ID:', id) + + if (this.todos[id]) { + const deletedTodo = { ...this.todos[id] } + console.log('๐ deleteTodo: Deleting todo:', deletedTodo.text) + + delete this.todos[id] + console.log('๐ deleteTodo: Remaining todos:', Object.keys(this.todos).length) + + await this.saveTodos() + this.render() + + console.log('โ deleteTodo: Todo deleted and saved successfully') + } else { + console.warn('โ ๏ธ deleteTodo: Todo not found with ID:', id) + } + } + + async toggleAll(completed) { + if (!this.contractID) return + + try { + // Update all todos + for (const [id, todo] of Object.entries(this.todos)) { + this.todos[id] = { ...todo, completed, updatedDate: new Date().toISOString() } + } + + // Save all updates at once using TodoMVC KV store + await this.saveTodos() + + this.render() + } catch (error) { + console.error('Failed to toggle all todos:', error) + } + } + + async clearCompleted() { + if (!this.contractID) return + + try { + // Delete completed todos + const completedIds = Object.keys(this.todos).filter(id => this.todos[id].completed) + + for (const id of completedIds) { + delete this.todos[id] + } + + // Save updated todos list using TodoMVC KV store + await this.saveTodos() + + this.render() + } catch (error) { + console.error('Failed to clear completed todos:', error) + } + } + + setFilter(filter) { + this.currentFilter = filter + + // Update filter links + this.filterLinks.forEach(link => { + link.classList.remove('selected') + if (link.getAttribute('href') === `#/${filter}` || (filter === 'all' && link.getAttribute('href') === '#/')) { + link.classList.add('selected') + } + }) + + this.render() + } + + getFilteredTodos() { + const todos = Object.values(this.todos) + + switch (this.currentFilter) { + case 'active': + return todos.filter(todo => !todo.completed) + case 'completed': + return todos.filter(todo => todo.completed) + default: + return todos + } + } + + render() { + const todos = Object.values(this.todos) + const filteredTodos = this.getFilteredTodos() + const activeTodos = todos.filter(todo => !todo.completed) + const completedTodos = todos.filter(todo => todo.completed) + + // Show/hide main and footer sections + const hasTodos = todos.length > 0 + this.mainSection.style.display = hasTodos ? 'block' : 'none' + this.footerSection.style.display = hasTodos ? 'block' : 'none' + + // Update toggle all checkbox + this.toggleAllCheckbox.checked = activeTodos.length === 0 && todos.length > 0 + + // Render todo list using template-based DOM manipulation + this.renderTodoList(filteredTodos) + + // Update todo count using proper DOM manipulation + this.renderTodoCount(activeTodos.length) + + // Show/hide clear completed button + this.clearCompletedBtn.style.display = completedTodos.length > 0 ? 'block' : 'none' + + // Bind todo item events + this.bindTodoEvents() + } + + renderTodoList(filteredTodos) { + // Clear existing todo items + this.todoList.replaceChildren() + + // Get template + const template = document.getElementById('todo-template') + + // Create and append each todo item using template + filteredTodos.forEach(todo => { + const todoElement = this.createTodoElement(todo, template) + this.todoList.appendChild(todoElement) + }) + } + + createTodoElement(todo, template) { + // Clone template content + const todoElement = template.content.cloneNode(true).firstElementChild + + // Set data attributes and classes + todoElement.dataset.id = todo.id + if (todo.completed) { + todoElement.classList.add('completed') + } + + // Populate content using proper DOM manipulation (official TodoMVC structure) + const checkbox = todoElement.querySelector('.toggle') + const label = todoElement.querySelector('label') + const editInput = todoElement.querySelector('.edit') + const destroyButton = todoElement.querySelector('.destroy') + + checkbox.checked = todo.completed + checkbox.setAttribute('aria-label', `Mark "${todo.text}" as ${todo.completed ? 'incomplete' : 'complete'}`) + + label.textContent = todo.text + label.setAttribute('tabindex', '0') + label.setAttribute('role', 'button') + label.setAttribute('aria-label', `Edit todo: ${todo.text}`) + + editInput.value = todo.text + editInput.setAttribute('aria-label', `Edit todo: ${todo.text}`) + + destroyButton.setAttribute('aria-label', `Delete todo: ${todo.text}`) + + return todoElement + } + + renderTodoCount(activeCount) { + // Clear existing content + this.todoCount.replaceChildren() + + // Create elements using proper DOM manipulation + const strongElement = document.createElement('strong') + strongElement.textContent = activeCount.toString() + + const itemText = activeCount === 1 ? 'item' : 'items' + const textNode = document.createTextNode(` ${itemText} left`) + + // Append elements + this.todoCount.appendChild(strongElement) + this.todoCount.appendChild(textNode) + } + + bindTodoEvents() { + // Prevent form submission for todo forms + this.todoList.querySelectorAll('.view').forEach(form => { + form.addEventListener('submit', (e) => { + e.preventDefault() + }) + }) + + // Toggle todo + this.todoList.querySelectorAll('.toggle').forEach(checkbox => { + checkbox.addEventListener('change', (e) => { + const li = e.target.closest('li') + const id = li.dataset.id + this.updateTodo(id, { completed: e.target.checked }) + }) + }) + + // Delete todo + this.todoList.querySelectorAll('.destroy').forEach(button => { + button.addEventListener('click', (e) => { + const li = e.target.closest('li') + const id = li.dataset.id + this.deleteTodo(id) + }) + }) + + // Edit todo - multiple accessible ways to enter edit mode + this.todoList.querySelectorAll('label').forEach(label => { + const enterEditMode = (e) => { + const li = e.target.closest('li') + li.classList.add('editing') + const editInput = li.querySelector('.edit') + editInput.focus() + editInput.select() + } + + // Double-click for desktop users (official TodoMVC behavior) + label.addEventListener('dblclick', enterEditMode) + + // Keyboard accessibility - Enter or Space key to edit + label.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + enterEditMode(e) + } + }) + + // Touch/mobile support - single tap after focus + let tapCount = 0 + label.addEventListener('click', (e) => { + tapCount++ + setTimeout(() => { + if (tapCount === 1 && document.activeElement === label) { + // Single tap on focused element = edit (mobile friendly) + enterEditMode(e) + } + tapCount = 0 + }, 300) + }) + }) + + // Save edit + this.todoList.querySelectorAll('.edit').forEach(input => { + const saveEdit = () => { + const li = input.closest('li') + const id = li.dataset.id + const text = input.value.trim() + + if (text) { + this.updateTodo(id, { text }) + } else { + this.deleteTodo(id) + } + + li.classList.remove('editing') + } + + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + saveEdit() + } + }) + + input.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + input.value = this.todos[input.closest('li').dataset.id].text + input.closest('li').classList.remove('editing') + } + }) + + input.addEventListener('blur', saveEdit) + }) + } +} + +// Initialize app when DOM is loaded and libraries are ready +document.addEventListener('DOMContentLoaded', () => { + console.log('๐ DOM loaded, starting TodoMVC with Chelonia...') + + // Start the app only after our KV selectors are ready to avoid race conditions + const startApp = () => new TodoMVCApp() + if (window.todomvcKVReady) { + startApp() + } else { + const onReady = () => { + window.removeEventListener('todomvc-kv-ready', onReady) + startApp() + } + window.addEventListener('todomvc-kv-ready', onReady) + } +}) diff --git a/todomvc/index.html b/todomvc/index.html new file mode 100644 index 0000000..5f30d69 --- /dev/null +++ b/todomvc/index.html @@ -0,0 +1,153 @@ + + +
+ + +