clawfire 0.6.19 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/starter/app/pages/todos/index.html +158 -40
- package/templates/starter/functions/package.json +1 -0
- package/templates/starter/functions/routes/todos/create.ts +4 -2
- package/templates/starter/functions/routes/todos/delete.ts +4 -2
- package/templates/starter/functions/routes/todos/list.ts +7 -4
- package/templates/starter/functions/routes/todos/update.ts +4 -2
- package/templates/starter/functions/store.ts +161 -80
- package/templates/starter/functions/tsconfig.json +2 -2
package/package.json
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
<!-- @title: Todos — Clawfire -->
|
|
2
2
|
|
|
3
3
|
<div class="max-w-xl mx-auto">
|
|
4
|
-
<div class="text-center mb-
|
|
4
|
+
<div class="text-center mb-6">
|
|
5
5
|
<h1 class="text-3xl font-bold">
|
|
6
6
|
<span class="bg-gradient-to-r from-brand-400 to-brand-600 bg-clip-text text-transparent">Todos</span>
|
|
7
7
|
</h1>
|
|
8
|
-
<p class="text-zinc-500 text-sm mt-1">
|
|
8
|
+
<p class="text-zinc-500 text-sm mt-1">Dual-mode store — compare In-Memory vs Firestore</p>
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<!-- Tabs -->
|
|
12
|
+
<div class="flex border-b border-zinc-800 mb-6">
|
|
13
|
+
<button id="tab-memory" onclick="switchTab('memory')"
|
|
14
|
+
class="flex-1 py-3 text-sm font-semibold text-center border-b-2 transition-colors cursor-pointer">
|
|
15
|
+
<span class="inline-flex items-center gap-1.5">
|
|
16
|
+
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2zM9 9h6v6H9V9z"/></svg>
|
|
17
|
+
In-Memory
|
|
18
|
+
</span>
|
|
19
|
+
</button>
|
|
20
|
+
<button id="tab-firestore" onclick="switchTab('firestore')"
|
|
21
|
+
class="flex-1 py-3 text-sm font-semibold text-center border-b-2 transition-colors cursor-pointer">
|
|
22
|
+
<span class="inline-flex items-center gap-1.5">
|
|
23
|
+
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></svg>
|
|
24
|
+
Firestore DB
|
|
25
|
+
</span>
|
|
26
|
+
</button>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<!-- Mode Badge -->
|
|
30
|
+
<div class="flex justify-center mb-4">
|
|
31
|
+
<span id="mode-badge" class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium"></span>
|
|
9
32
|
</div>
|
|
10
33
|
|
|
11
34
|
<!-- Input -->
|
|
@@ -44,10 +67,28 @@
|
|
|
44
67
|
<script>
|
|
45
68
|
(function() {
|
|
46
69
|
const API = '/api/todos';
|
|
47
|
-
let
|
|
70
|
+
let currentMode = 'memory';
|
|
71
|
+
let memoryTodos = [];
|
|
72
|
+
let firestoreTodos = [];
|
|
73
|
+
let memoryLoaded = false;
|
|
74
|
+
let firestoreLoaded = false;
|
|
75
|
+
let firestoreError = null;
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
|
|
77
|
+
function getTodos() {
|
|
78
|
+
return currentMode === 'memory' ? memoryTodos : firestoreTodos;
|
|
79
|
+
}
|
|
80
|
+
function setTodos(list) {
|
|
81
|
+
if (currentMode === 'memory') { memoryTodos = list; memoryLoaded = true; }
|
|
82
|
+
else { firestoreTodos = list; firestoreLoaded = true; }
|
|
83
|
+
}
|
|
84
|
+
function isLoaded() {
|
|
85
|
+
return currentMode === 'memory' ? memoryLoaded : firestoreLoaded;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function api(path, body) {
|
|
89
|
+
if (!body) body = {};
|
|
90
|
+
body.mode = currentMode;
|
|
91
|
+
const res = await fetch(API + '/' + path, {
|
|
51
92
|
method: 'POST',
|
|
52
93
|
headers: { 'Content-Type': 'application/json' },
|
|
53
94
|
body: JSON.stringify(body),
|
|
@@ -62,29 +103,91 @@
|
|
|
62
103
|
return json.data;
|
|
63
104
|
}
|
|
64
105
|
|
|
106
|
+
function updateTabs() {
|
|
107
|
+
var memTab = document.getElementById('tab-memory');
|
|
108
|
+
var fsTab = document.getElementById('tab-firestore');
|
|
109
|
+
var badge = document.getElementById('mode-badge');
|
|
110
|
+
if (!memTab || !fsTab) return;
|
|
111
|
+
|
|
112
|
+
if (currentMode === 'memory') {
|
|
113
|
+
memTab.className = 'flex-1 py-3 text-sm font-semibold text-center border-b-2 border-brand-500 text-brand-400 transition-colors cursor-pointer';
|
|
114
|
+
fsTab.className = 'flex-1 py-3 text-sm font-semibold text-center border-b-2 border-transparent text-zinc-500 hover:text-zinc-300 transition-colors cursor-pointer';
|
|
115
|
+
badge.className = 'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-brand-500/10 text-brand-400 border border-brand-500/20';
|
|
116
|
+
badge.innerHTML = '<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2H7a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2zM9 9h6v6H9V9z"/></svg> In-Memory — resets on cold start';
|
|
117
|
+
} else {
|
|
118
|
+
fsTab.className = 'flex-1 py-3 text-sm font-semibold text-center border-b-2 border-amber-500 text-amber-400 transition-colors cursor-pointer';
|
|
119
|
+
memTab.className = 'flex-1 py-3 text-sm font-semibold text-center border-b-2 border-transparent text-zinc-500 hover:text-zinc-300 transition-colors cursor-pointer';
|
|
120
|
+
badge.className = 'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20';
|
|
121
|
+
badge.innerHTML = '<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></svg> Firestore DB — persists across deploys';
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
window.switchTab = function(mode) {
|
|
126
|
+
if (mode === currentMode) return;
|
|
127
|
+
currentMode = mode;
|
|
128
|
+
firestoreError = null;
|
|
129
|
+
updateTabs();
|
|
130
|
+
if (isLoaded()) {
|
|
131
|
+
render();
|
|
132
|
+
} else {
|
|
133
|
+
var list = document.getElementById('todo-list');
|
|
134
|
+
if (list) list.innerHTML = '<div class="text-center py-12 text-zinc-500 text-sm">Loading todos...</div>';
|
|
135
|
+
loadTodos();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
65
139
|
async function loadTodos() {
|
|
66
140
|
try {
|
|
67
141
|
const data = await api('list');
|
|
68
|
-
|
|
142
|
+
setTodos(data.todos);
|
|
143
|
+
if (currentMode === 'firestore') firestoreError = null;
|
|
69
144
|
render();
|
|
70
145
|
} catch (e) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
146
|
+
if (currentMode === 'firestore' && (e.message.includes('Firestore') || e.message.includes('not configured') || e.message.includes('not available'))) {
|
|
147
|
+
firestoreError = e.message;
|
|
148
|
+
renderFirestoreError();
|
|
149
|
+
} else {
|
|
150
|
+
var list = document.getElementById('todo-list');
|
|
151
|
+
if (list) {
|
|
152
|
+
var isApiUnavailable = e.message.includes('not available') || e.message.includes('non-JSON');
|
|
153
|
+
if (isApiUnavailable) {
|
|
154
|
+
list.innerHTML = '<div class="text-center py-12">'
|
|
155
|
+
+ '<svg class="mx-auto mb-3 text-zinc-600" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"/></svg>'
|
|
156
|
+
+ '<p class="text-zinc-400 text-sm font-medium mb-1">API Not Available</p>'
|
|
157
|
+
+ '<p class="text-zinc-600 text-xs">Cloud Functions need to be deployed for the API to work.</p>'
|
|
158
|
+
+ '<p class="text-zinc-600 text-xs mt-1">Run <code class="text-brand-400 bg-zinc-800 px-1.5 py-0.5 rounded">/clawfire-deploy</code> in Claude Code.</p>'
|
|
159
|
+
+ '</div>';
|
|
160
|
+
} else {
|
|
161
|
+
showError('Failed to load todos: ' + e.message);
|
|
162
|
+
}
|
|
83
163
|
}
|
|
84
164
|
}
|
|
85
165
|
}
|
|
86
166
|
}
|
|
87
167
|
|
|
168
|
+
function renderFirestoreError() {
|
|
169
|
+
var list = document.getElementById('todo-list');
|
|
170
|
+
var stats = document.getElementById('stats');
|
|
171
|
+
if (stats) stats.classList.add('hidden');
|
|
172
|
+
if (!list) return;
|
|
173
|
+
list.innerHTML = '<div class="text-center py-12">'
|
|
174
|
+
+ '<div class="mx-auto mb-4 w-12 h-12 rounded-full bg-amber-500/10 flex items-center justify-center">'
|
|
175
|
+
+ '<svg width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" class="text-amber-400"><path d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"/></svg>'
|
|
176
|
+
+ '</div>'
|
|
177
|
+
+ '<p class="text-amber-400 text-sm font-medium mb-2">Firestore Not Available</p>'
|
|
178
|
+
+ '<p class="text-zinc-500 text-xs max-w-xs mx-auto mb-3">Firebase config is not set or Firestore is not enabled for this project.</p>'
|
|
179
|
+
+ '<div class="bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-left max-w-xs mx-auto">'
|
|
180
|
+
+ '<p class="text-zinc-400 text-xs font-medium mb-1">To enable Firestore:</p>'
|
|
181
|
+
+ '<ol class="text-zinc-500 text-xs space-y-1 list-decimal list-inside">'
|
|
182
|
+
+ '<li>Run <code class="text-brand-400 bg-zinc-800 px-1 py-0.5 rounded">/clawfire-init</code></li>'
|
|
183
|
+
+ '<li>Select or create a Firebase project</li>'
|
|
184
|
+
+ '<li>Deploy with <code class="text-brand-400 bg-zinc-800 px-1 py-0.5 rounded">/clawfire-deploy</code></li>'
|
|
185
|
+
+ '</ol>'
|
|
186
|
+
+ '</div>'
|
|
187
|
+
+ '<p class="text-zinc-600 text-xs mt-3">Meanwhile, use the <button onclick="switchTab(\'memory\')" class="text-brand-400 hover:underline cursor-pointer">In-Memory</button> tab!</p>'
|
|
188
|
+
+ '</div>';
|
|
189
|
+
}
|
|
190
|
+
|
|
88
191
|
window.addTodo = async function() {
|
|
89
192
|
const input = document.getElementById('todo-input');
|
|
90
193
|
const title = input.value.trim();
|
|
@@ -92,8 +195,10 @@
|
|
|
92
195
|
const btn = document.getElementById('add-btn');
|
|
93
196
|
btn.disabled = true;
|
|
94
197
|
try {
|
|
95
|
-
const data = await api('create', { title });
|
|
198
|
+
const data = await api('create', { title: title });
|
|
199
|
+
var todos = getTodos();
|
|
96
200
|
todos.unshift(data.todo);
|
|
201
|
+
setTodos(todos);
|
|
97
202
|
input.value = '';
|
|
98
203
|
render();
|
|
99
204
|
} catch (e) {
|
|
@@ -105,13 +210,15 @@
|
|
|
105
210
|
};
|
|
106
211
|
|
|
107
212
|
window.toggleTodo = async function(id) {
|
|
108
|
-
|
|
213
|
+
var todos = getTodos();
|
|
214
|
+
const todo = todos.find(function(t) { return t.id === id; });
|
|
109
215
|
if (!todo) return;
|
|
110
216
|
try {
|
|
111
|
-
const data = await api('update', { id, completed: !todo.completed });
|
|
217
|
+
const data = await api('update', { id: id, completed: !todo.completed });
|
|
112
218
|
if (data.todo) {
|
|
113
|
-
const idx = todos.findIndex(t
|
|
219
|
+
const idx = todos.findIndex(function(t) { return t.id === id; });
|
|
114
220
|
if (idx !== -1) todos[idx] = data.todo;
|
|
221
|
+
setTodos(todos);
|
|
115
222
|
render();
|
|
116
223
|
}
|
|
117
224
|
} catch (e) {
|
|
@@ -121,8 +228,9 @@
|
|
|
121
228
|
|
|
122
229
|
window.deleteTodo = async function(id) {
|
|
123
230
|
try {
|
|
124
|
-
await api('delete', { id });
|
|
125
|
-
todos =
|
|
231
|
+
await api('delete', { id: id });
|
|
232
|
+
var todos = getTodos().filter(function(t) { return t.id !== id; });
|
|
233
|
+
setTodos(todos);
|
|
126
234
|
render();
|
|
127
235
|
} catch (e) {
|
|
128
236
|
showError('Failed to delete todo: ' + e.message);
|
|
@@ -130,34 +238,44 @@
|
|
|
130
238
|
};
|
|
131
239
|
|
|
132
240
|
function render() {
|
|
241
|
+
var todos = getTodos();
|
|
133
242
|
const list = document.getElementById('todo-list');
|
|
134
243
|
const stats = document.getElementById('stats');
|
|
135
244
|
if (!list) return;
|
|
136
245
|
|
|
246
|
+
if (currentMode === 'firestore' && firestoreError) {
|
|
247
|
+
renderFirestoreError();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
137
251
|
if (todos.length === 0) {
|
|
138
252
|
if (stats) stats.classList.add('hidden');
|
|
139
|
-
|
|
253
|
+
var modeLabel = currentMode === 'memory' ? 'in-memory' : 'Firestore';
|
|
254
|
+
list.innerHTML = '<div class="text-center py-12 text-zinc-600">'
|
|
255
|
+
+ '<svg class="mx-auto mb-3" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2M9 5h6"/></svg>'
|
|
256
|
+
+ '<p class="text-sm">No ' + modeLabel + ' todos yet. Add one above!</p>'
|
|
257
|
+
+ '</div>';
|
|
140
258
|
return;
|
|
141
259
|
}
|
|
142
260
|
|
|
143
|
-
const done = todos.filter(t
|
|
261
|
+
const done = todos.filter(function(t) { return t.completed; }).length;
|
|
144
262
|
if (stats) {
|
|
145
263
|
stats.classList.remove('hidden');
|
|
146
264
|
document.getElementById('total-count').textContent = todos.length;
|
|
147
265
|
document.getElementById('done-count').textContent = done;
|
|
148
266
|
}
|
|
149
267
|
|
|
150
|
-
list.innerHTML = todos.map(todo
|
|
151
|
-
<div class="group flex items-center gap-3 px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl hover:border-zinc-700 transition-colors
|
|
152
|
-
<div onclick="toggleTodo('
|
|
153
|
-
|
|
154
|
-
</div>
|
|
155
|
-
<span class="flex-1 text-sm
|
|
156
|
-
<button onclick="deleteTodo('
|
|
157
|
-
|
|
158
|
-
</button>
|
|
159
|
-
|
|
160
|
-
|
|
268
|
+
list.innerHTML = todos.map(function(todo) {
|
|
269
|
+
return '<div class="group flex items-center gap-3 px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl hover:border-zinc-700 transition-colors ' + (todo.completed ? 'opacity-50' : '') + '">'
|
|
270
|
+
+ '<div onclick="toggleTodo(\'' + todo.id + '\')" class="w-5.5 h-5.5 border-2 ' + (todo.completed ? 'bg-brand-500 border-brand-500' : 'border-zinc-700 hover:border-brand-500') + ' rounded-md cursor-pointer flex items-center justify-center flex-shrink-0 transition-colors">'
|
|
271
|
+
+ (todo.completed ? '<svg width="14" height="14" fill="none" stroke="#fff" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 13l4 4L19 7"/></svg>' : '')
|
|
272
|
+
+ '</div>'
|
|
273
|
+
+ '<span class="flex-1 text-sm ' + (todo.completed ? 'line-through text-zinc-500' : '') + '">' + escapeHtml(todo.title) + '</span>'
|
|
274
|
+
+ '<button onclick="deleteTodo(\'' + todo.id + '\')" class="opacity-0 group-hover:opacity-100 p-1 rounded-md text-zinc-600 hover:text-red-400 hover:bg-red-400/10 transition-all" title="Delete">'
|
|
275
|
+
+ '<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>'
|
|
276
|
+
+ '</button>'
|
|
277
|
+
+ '</div>';
|
|
278
|
+
}).join('');
|
|
161
279
|
}
|
|
162
280
|
|
|
163
281
|
function escapeHtml(text) {
|
|
@@ -171,20 +289,20 @@
|
|
|
171
289
|
if (!toast) return;
|
|
172
290
|
toast.textContent = msg;
|
|
173
291
|
toast.classList.remove('hidden');
|
|
174
|
-
setTimeout(()
|
|
292
|
+
setTimeout(function() { toast.classList.add('hidden'); }, 3000);
|
|
175
293
|
}
|
|
176
294
|
|
|
177
295
|
function init() {
|
|
178
296
|
const input = document.getElementById('todo-input');
|
|
179
297
|
if (input) {
|
|
180
|
-
input.addEventListener('keydown', (e)
|
|
298
|
+
input.addEventListener('keydown', function(e) {
|
|
181
299
|
if (e.key === 'Enter') window.addTodo();
|
|
182
300
|
});
|
|
183
301
|
}
|
|
302
|
+
updateTabs();
|
|
184
303
|
loadTodos();
|
|
185
304
|
}
|
|
186
305
|
|
|
187
|
-
// Initialize — SPA router re-executes scripts on navigation, so no need for clawfire:navigate listener
|
|
188
306
|
init();
|
|
189
307
|
})();
|
|
190
308
|
</script>
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineAPI, z } from "clawfire";
|
|
2
|
-
import {
|
|
2
|
+
import { getStore } from "../../store.js";
|
|
3
3
|
|
|
4
4
|
export default defineAPI({
|
|
5
5
|
input: z.object({
|
|
6
|
+
mode: z.enum(["memory", "firestore"]).optional().default("memory"),
|
|
6
7
|
title: z.string().min(1).max(200),
|
|
7
8
|
}),
|
|
8
9
|
output: z.object({
|
|
@@ -19,7 +20,8 @@ export default defineAPI({
|
|
|
19
20
|
tags: ["todos"],
|
|
20
21
|
},
|
|
21
22
|
handler: async (input) => {
|
|
22
|
-
const
|
|
23
|
+
const store = getStore(input.mode);
|
|
24
|
+
const todo = await store.create(input.title);
|
|
23
25
|
return { todo };
|
|
24
26
|
},
|
|
25
27
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineAPI, z } from "clawfire";
|
|
2
|
-
import {
|
|
2
|
+
import { getStore } from "../../store.js";
|
|
3
3
|
|
|
4
4
|
export default defineAPI({
|
|
5
5
|
input: z.object({
|
|
6
|
+
mode: z.enum(["memory", "firestore"]).optional().default("memory"),
|
|
6
7
|
id: z.string(),
|
|
7
8
|
}),
|
|
8
9
|
output: z.object({
|
|
@@ -14,7 +15,8 @@ export default defineAPI({
|
|
|
14
15
|
tags: ["todos"],
|
|
15
16
|
},
|
|
16
17
|
handler: async (input) => {
|
|
17
|
-
const
|
|
18
|
+
const store = getStore(input.mode);
|
|
19
|
+
const success = await store.delete(input.id);
|
|
18
20
|
return { success };
|
|
19
21
|
},
|
|
20
22
|
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { defineAPI, z } from "clawfire";
|
|
2
|
-
import {
|
|
2
|
+
import { getStore } from "../../store.js";
|
|
3
3
|
|
|
4
4
|
export default defineAPI({
|
|
5
|
-
input: z.object({
|
|
5
|
+
input: z.object({
|
|
6
|
+
mode: z.enum(["memory", "firestore"]).optional().default("memory"),
|
|
7
|
+
}),
|
|
6
8
|
output: z.object({
|
|
7
9
|
todos: z.array(
|
|
8
10
|
z.object({
|
|
@@ -19,8 +21,9 @@ export default defineAPI({
|
|
|
19
21
|
auth: "public",
|
|
20
22
|
tags: ["todos"],
|
|
21
23
|
},
|
|
22
|
-
handler: async () => {
|
|
23
|
-
const
|
|
24
|
+
handler: async (input) => {
|
|
25
|
+
const store = getStore(input.mode);
|
|
26
|
+
const todos = await store.list();
|
|
24
27
|
return { todos, count: todos.length };
|
|
25
28
|
},
|
|
26
29
|
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineAPI, z } from "clawfire";
|
|
2
|
-
import {
|
|
2
|
+
import { getStore } from "../../store.js";
|
|
3
3
|
|
|
4
4
|
export default defineAPI({
|
|
5
5
|
input: z.object({
|
|
6
|
+
mode: z.enum(["memory", "firestore"]).optional().default("memory"),
|
|
6
7
|
id: z.string(),
|
|
7
8
|
title: z.string().min(1).max(200).optional(),
|
|
8
9
|
completed: z.boolean().optional(),
|
|
@@ -23,7 +24,8 @@ export default defineAPI({
|
|
|
23
24
|
tags: ["todos"],
|
|
24
25
|
},
|
|
25
26
|
handler: async (input) => {
|
|
26
|
-
const
|
|
27
|
+
const store = getStore(input.mode);
|
|
28
|
+
const todo = await store.update(input.id, {
|
|
27
29
|
title: input.title,
|
|
28
30
|
completed: input.completed,
|
|
29
31
|
});
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Todo Store — Dual Mode (In-Memory + Firestore)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Two explicit stores are available:
|
|
5
|
+
* - memoryTodoStore: Pure in-memory Map, always works
|
|
6
|
+
* - firestoreTodoStore: Client Firebase SDK via CLAWFIRE_FIREBASE_CONFIG
|
|
7
|
+
*
|
|
8
|
+
* Use getStore("memory" | "firestore") to select at runtime.
|
|
9
|
+
* Legacy todoStore is kept for backward compatibility (auto-detect).
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
export interface Todo {
|
|
@@ -13,26 +16,76 @@ export interface Todo {
|
|
|
13
16
|
createdAt: string;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
export interface TodoStore {
|
|
20
|
+
list(): Promise<Todo[]>;
|
|
21
|
+
create(title: string): Promise<Todo>;
|
|
22
|
+
update(
|
|
23
|
+
id: string,
|
|
24
|
+
data: Partial<Pick<Todo, "title" | "completed">>,
|
|
25
|
+
): Promise<Todo | null>;
|
|
26
|
+
delete(id: string): Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
16
29
|
// ─── In-Memory Store ──────────────────────────────────────────
|
|
17
|
-
const
|
|
30
|
+
const memoryMap = new Map<string, Todo>();
|
|
18
31
|
let idCounter = 0;
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
export const memoryTodoStore: TodoStore = {
|
|
34
|
+
async list() {
|
|
35
|
+
return Array.from(memoryMap.values()).sort(
|
|
36
|
+
(a, b) =>
|
|
37
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
38
|
+
);
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async create(title) {
|
|
42
|
+
const todo: Todo = {
|
|
43
|
+
id: `todo_${++idCounter}_${Date.now()}`,
|
|
44
|
+
title,
|
|
45
|
+
completed: false,
|
|
46
|
+
createdAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
memoryMap.set(todo.id, todo);
|
|
49
|
+
return todo;
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async update(id, data) {
|
|
53
|
+
const todo = memoryMap.get(id);
|
|
54
|
+
if (!todo) return null;
|
|
55
|
+
if (data.title !== undefined) todo.title = data.title;
|
|
56
|
+
if (data.completed !== undefined) todo.completed = data.completed;
|
|
57
|
+
memoryMap.set(id, todo);
|
|
58
|
+
return todo;
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async delete(id) {
|
|
62
|
+
return memoryMap.delete(id);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// ─── Firestore Store (lazy-initialized) ──────────────────────
|
|
67
|
+
let fsInitialized = false;
|
|
68
|
+
let fsAvailable = false;
|
|
69
|
+
let fsDb: any = null;
|
|
70
|
+
let fsMethods: any = null;
|
|
71
|
+
let fsError: string | null = null;
|
|
25
72
|
|
|
26
|
-
async function
|
|
27
|
-
if (
|
|
28
|
-
|
|
73
|
+
async function ensureFirestore(): Promise<void> {
|
|
74
|
+
if (fsInitialized) return;
|
|
75
|
+
fsInitialized = true;
|
|
29
76
|
|
|
30
77
|
const configJson = process.env.CLAWFIRE_FIREBASE_CONFIG;
|
|
31
|
-
if (!configJson)
|
|
78
|
+
if (!configJson) {
|
|
79
|
+
fsError = "Firestore not configured: CLAWFIRE_FIREBASE_CONFIG env var is not set.";
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
32
82
|
|
|
33
83
|
try {
|
|
34
84
|
const config = JSON.parse(configJson);
|
|
35
|
-
if (!config.apiKey || config.apiKey.startsWith("YOUR_"))
|
|
85
|
+
if (!config.apiKey || config.apiKey.startsWith("YOUR_")) {
|
|
86
|
+
fsError = "Firestore not configured: Firebase API key is missing or placeholder.";
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
36
89
|
|
|
37
90
|
const firebaseApp = await import("firebase/app");
|
|
38
91
|
const firebaseFirestore = await import("firebase/firestore");
|
|
@@ -42,89 +95,117 @@ async function ensureInit(): Promise<void> {
|
|
|
42
95
|
? firebaseApp.initializeApp(config)
|
|
43
96
|
: firebaseApp.getApps()[0];
|
|
44
97
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
console.log(" [store] Firestore mode
|
|
98
|
+
fsDb = firebaseFirestore.getFirestore(app);
|
|
99
|
+
fsMethods = firebaseFirestore;
|
|
100
|
+
fsAvailable = true;
|
|
101
|
+
console.log(" [store] Firestore mode available");
|
|
49
102
|
} catch (err) {
|
|
50
|
-
|
|
103
|
+
fsError = `Firestore init failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
104
|
+
console.warn(" [store]", fsError);
|
|
51
105
|
}
|
|
52
106
|
}
|
|
53
107
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
(a, b) =>
|
|
69
|
-
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
|
108
|
+
function requireFirestore() {
|
|
109
|
+
if (!fsAvailable) {
|
|
110
|
+
throw new Error(fsError || "Firestore is not available.");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const firestoreTodoStore: TodoStore = {
|
|
115
|
+
async list() {
|
|
116
|
+
await ensureFirestore();
|
|
117
|
+
requireFirestore();
|
|
118
|
+
const { collection, getDocs, query, orderBy } = fsMethods;
|
|
119
|
+
const q = query(
|
|
120
|
+
collection(fsDb, "todos"),
|
|
121
|
+
orderBy("createdAt", "desc"),
|
|
70
122
|
);
|
|
123
|
+
const snap = await getDocs(q);
|
|
124
|
+
return snap.docs.map((d: any) => ({ id: d.id, ...d.data() }) as Todo);
|
|
71
125
|
},
|
|
72
126
|
|
|
73
|
-
async create(title
|
|
74
|
-
await
|
|
127
|
+
async create(title) {
|
|
128
|
+
await ensureFirestore();
|
|
129
|
+
requireFirestore();
|
|
130
|
+
const { collection, addDoc } = fsMethods;
|
|
75
131
|
const todo: Todo = {
|
|
76
132
|
id: "",
|
|
77
133
|
title,
|
|
78
134
|
completed: false,
|
|
79
135
|
createdAt: new Date().toISOString(),
|
|
80
136
|
};
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
88
|
-
todo.id = ref.id;
|
|
89
|
-
} else {
|
|
90
|
-
todo.id = `todo_${++idCounter}_${Date.now()}`;
|
|
91
|
-
memoryStore.set(todo.id, todo);
|
|
92
|
-
}
|
|
137
|
+
const ref = await addDoc(collection(fsDb, "todos"), {
|
|
138
|
+
title: todo.title,
|
|
139
|
+
completed: todo.completed,
|
|
140
|
+
createdAt: todo.createdAt,
|
|
141
|
+
});
|
|
142
|
+
todo.id = ref.id;
|
|
93
143
|
return todo;
|
|
94
144
|
},
|
|
95
145
|
|
|
96
|
-
async update(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return snap.exists()
|
|
110
|
-
? ({ id: snap.id, ...snap.data() } as Todo)
|
|
111
|
-
: null;
|
|
112
|
-
}
|
|
113
|
-
const todo = memoryStore.get(id);
|
|
114
|
-
if (!todo) return null;
|
|
115
|
-
if (data.title !== undefined) todo.title = data.title;
|
|
116
|
-
if (data.completed !== undefined) todo.completed = data.completed;
|
|
117
|
-
memoryStore.set(id, todo);
|
|
118
|
-
return todo;
|
|
146
|
+
async update(id, data) {
|
|
147
|
+
await ensureFirestore();
|
|
148
|
+
requireFirestore();
|
|
149
|
+
const { doc, updateDoc, getDoc } = fsMethods;
|
|
150
|
+
const ref = doc(fsDb, "todos", id);
|
|
151
|
+
const updateData: Record<string, any> = {};
|
|
152
|
+
if (data.title !== undefined) updateData.title = data.title;
|
|
153
|
+
if (data.completed !== undefined) updateData.completed = data.completed;
|
|
154
|
+
await updateDoc(ref, updateData);
|
|
155
|
+
const snap = await getDoc(ref);
|
|
156
|
+
return snap.exists()
|
|
157
|
+
? ({ id: snap.id, ...snap.data() } as Todo)
|
|
158
|
+
: null;
|
|
119
159
|
},
|
|
120
160
|
|
|
121
|
-
async delete(id
|
|
122
|
-
await
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
161
|
+
async delete(id) {
|
|
162
|
+
await ensureFirestore();
|
|
163
|
+
requireFirestore();
|
|
164
|
+
const { doc, deleteDoc } = fsMethods;
|
|
165
|
+
await deleteDoc(doc(fsDb, "todos", id));
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// ─── Store Selector ──────────────────────────────────────────
|
|
171
|
+
export function getStore(mode?: string): TodoStore {
|
|
172
|
+
return mode === "firestore" ? firestoreTodoStore : memoryTodoStore;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Legacy Export (auto-detect, backward compatible) ────────
|
|
176
|
+
let legacyInitialized = false;
|
|
177
|
+
let legacyUseFirestore = false;
|
|
178
|
+
|
|
179
|
+
async function ensureLegacyInit(): Promise<void> {
|
|
180
|
+
if (legacyInitialized) return;
|
|
181
|
+
legacyInitialized = true;
|
|
182
|
+
await ensureFirestore();
|
|
183
|
+
legacyUseFirestore = fsAvailable;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const todoStore: TodoStore = {
|
|
187
|
+
async list() {
|
|
188
|
+
await ensureLegacyInit();
|
|
189
|
+
return legacyUseFirestore
|
|
190
|
+
? firestoreTodoStore.list()
|
|
191
|
+
: memoryTodoStore.list();
|
|
192
|
+
},
|
|
193
|
+
async create(title) {
|
|
194
|
+
await ensureLegacyInit();
|
|
195
|
+
return legacyUseFirestore
|
|
196
|
+
? firestoreTodoStore.create(title)
|
|
197
|
+
: memoryTodoStore.create(title);
|
|
198
|
+
},
|
|
199
|
+
async update(id, data) {
|
|
200
|
+
await ensureLegacyInit();
|
|
201
|
+
return legacyUseFirestore
|
|
202
|
+
? firestoreTodoStore.update(id, data)
|
|
203
|
+
: memoryTodoStore.update(id, data);
|
|
204
|
+
},
|
|
205
|
+
async delete(id) {
|
|
206
|
+
await ensureLegacyInit();
|
|
207
|
+
return legacyUseFirestore
|
|
208
|
+
? firestoreTodoStore.delete(id)
|
|
209
|
+
: memoryTodoStore.delete(id);
|
|
129
210
|
},
|
|
130
211
|
};
|