local cache
This commit is contained in:
@@ -306,9 +306,12 @@ data:
|
||||
})();
|
||||
|
||||
// ================================
|
||||
// Todo Widget Controller
|
||||
// Todo Widget Controller (Local-first with background sync)
|
||||
// ================================
|
||||
(function() {
|
||||
const SYNC_INTERVAL = 60000; // 60 seconds
|
||||
const STORAGE_KEY_PREFIX = 'glance-todos-';
|
||||
|
||||
function initTodoWidgets() {
|
||||
document.querySelectorAll('.todo-widget').forEach(function(widget) {
|
||||
if (widget.dataset.initialized) return;
|
||||
@@ -317,70 +320,214 @@ data:
|
||||
const API = widget.dataset.api;
|
||||
const USER = widget.dataset.user;
|
||||
const KEY = widget.dataset.key;
|
||||
const STORAGE_KEY = STORAGE_KEY_PREFIX + USER;
|
||||
const listEl = widget.querySelector('.todo-list');
|
||||
const input = widget.querySelector('.todo-add-input');
|
||||
const addBtn = widget.querySelector('.todo-add-btn');
|
||||
|
||||
// Local state
|
||||
let localData = { todos: [], modified: null, serverModified: null };
|
||||
let syncInProgress = false;
|
||||
|
||||
function apiUrl(path) {
|
||||
return API + path + (KEY ? '?key=' + KEY : '');
|
||||
}
|
||||
|
||||
const input = widget.querySelector('.todo-add-input');
|
||||
const addBtn = widget.querySelector('.todo-add-btn');
|
||||
|
||||
async function addTodo() {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.disabled = true;
|
||||
try {
|
||||
const r = await fetch(apiUrl('/userdata/' + USER + '/todos'), {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text: text, done: false})
|
||||
});
|
||||
if (r.ok) location.reload();
|
||||
else console.error('Todo add failed:', await r.text());
|
||||
} catch(e) { console.error('Todo add error:', e); }
|
||||
input.disabled = false;
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substr(2, 8);
|
||||
}
|
||||
|
||||
function nowISO() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
// ===== LocalStorage Operations =====
|
||||
function loadLocal() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
localData = JSON.parse(stored);
|
||||
}
|
||||
} catch(e) { console.error('Load local error:', e); }
|
||||
}
|
||||
|
||||
function saveLocal() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(localData));
|
||||
} catch(e) { console.error('Save local error:', e); }
|
||||
}
|
||||
|
||||
// ===== DOM Rendering =====
|
||||
function render() {
|
||||
if (!localData.todos || localData.todos.length === 0) {
|
||||
listEl.innerHTML = '<div class="todo-empty">No tasks yet</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = localData.todos.map(function(t) {
|
||||
return '<div class="todo-item" data-id="' + t.id + '" data-text="' + escHtml(t.text).replace(/"/g, '"') + '" data-done="' + t.done + '">' +
|
||||
'<div class="todo-checkbox ' + (t.done ? 'done' : '') + '"></div>' +
|
||||
'<span class="todo-text ' + (t.done ? 'done' : '') + '">' + escHtml(t.text) + '</span>' +
|
||||
'<button class="todo-delete" type="button" title="Delete">🗑</button>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ===== Sync with Server =====
|
||||
async function syncWithServer() {
|
||||
if (syncInProgress) return;
|
||||
syncInProgress = true;
|
||||
|
||||
try {
|
||||
// First, fetch current server state
|
||||
const getResp = await fetch(API + '/userdata/' + USER + '/todos');
|
||||
if (!getResp.ok) {
|
||||
console.error('Fetch todos failed');
|
||||
syncInProgress = false;
|
||||
return;
|
||||
}
|
||||
const serverData = await getResp.json();
|
||||
const serverMod = serverData.modified;
|
||||
|
||||
// Determine sync action
|
||||
const localMod = localData.modified;
|
||||
const lastKnownServerMod = localData.serverModified;
|
||||
|
||||
// If server has newer data than our last known server state
|
||||
if (serverMod && (!lastKnownServerMod || serverMod > lastKnownServerMod)) {
|
||||
// Server has updates (possibly from another device)
|
||||
// Check if we also have local changes
|
||||
if (localMod && localMod > lastKnownServerMod) {
|
||||
// Conflict: both have changes. Server wins (source of truth)
|
||||
console.log('Sync: Server has newer data, downloading');
|
||||
}
|
||||
// Download server data
|
||||
localData.todos = serverData.todos || [];
|
||||
localData.modified = serverMod;
|
||||
localData.serverModified = serverMod;
|
||||
saveLocal();
|
||||
render();
|
||||
} else if (localMod && (!lastKnownServerMod || localMod > lastKnownServerMod)) {
|
||||
// We have local changes that server doesn't know about
|
||||
console.log('Sync: Uploading local changes');
|
||||
const syncResp = await fetch(apiUrl('/userdata/' + USER + '/todos/sync'), {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
todos: localData.todos,
|
||||
modified: localMod
|
||||
})
|
||||
});
|
||||
if (syncResp.ok) {
|
||||
const result = await syncResp.json();
|
||||
localData.serverModified = result.modified;
|
||||
localData.modified = result.modified;
|
||||
if (result.action === 'downloaded') {
|
||||
// Server had newer data after all
|
||||
localData.todos = result.todos;
|
||||
render();
|
||||
}
|
||||
saveLocal();
|
||||
}
|
||||
}
|
||||
// else: no changes needed
|
||||
} catch(e) {
|
||||
console.error('Sync error:', e);
|
||||
}
|
||||
|
||||
syncInProgress = false;
|
||||
}
|
||||
|
||||
// ===== Local Operations (immediate) =====
|
||||
function addTodo(text) {
|
||||
const newTodo = {
|
||||
id: generateId(),
|
||||
text: text,
|
||||
done: false,
|
||||
created: nowISO()
|
||||
};
|
||||
if (!localData.todos) localData.todos = [];
|
||||
localData.todos.push(newTodo);
|
||||
localData.modified = nowISO();
|
||||
saveLocal();
|
||||
render();
|
||||
// Sync in background (don't await)
|
||||
syncWithServer();
|
||||
}
|
||||
|
||||
function toggleTodo(id) {
|
||||
const todo = localData.todos.find(function(t) { return t.id === id; });
|
||||
if (todo) {
|
||||
todo.done = !todo.done;
|
||||
localData.modified = nowISO();
|
||||
saveLocal();
|
||||
render();
|
||||
syncWithServer();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTodo(id) {
|
||||
localData.todos = localData.todos.filter(function(t) { return t.id !== id; });
|
||||
localData.modified = nowISO();
|
||||
saveLocal();
|
||||
render();
|
||||
syncWithServer();
|
||||
}
|
||||
|
||||
// ===== Event Handlers =====
|
||||
if (input) {
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addTodo(); }
|
||||
});
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', function() {
|
||||
if (input.value.trim()) addTodo();
|
||||
else input.focus();
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (text) {
|
||||
addTodo(text);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
widget.addEventListener('click', async function(e) {
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', function() {
|
||||
const text = input.value.trim();
|
||||
if (text) {
|
||||
addTodo(text);
|
||||
input.value = '';
|
||||
} else {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
widget.addEventListener('click', function(e) {
|
||||
const item = e.target.closest('.todo-item');
|
||||
if (!item) return;
|
||||
|
||||
if (e.target.closest('.todo-checkbox')) {
|
||||
const id = item.dataset.id;
|
||||
const text = item.dataset.text;
|
||||
const newDone = item.dataset.done !== 'true';
|
||||
try {
|
||||
const r = await fetch(apiUrl('/userdata/' + USER + '/todos/' + id), {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({text: text, done: newDone})
|
||||
});
|
||||
if (r.ok) location.reload();
|
||||
} catch(e) { console.error('Todo toggle error:', e); }
|
||||
toggleTodo(item.dataset.id);
|
||||
}
|
||||
|
||||
if (e.target.closest('.todo-delete')) {
|
||||
const id = item.dataset.id;
|
||||
try {
|
||||
const r = await fetch(apiUrl('/userdata/' + USER + '/todos/' + id), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (r.ok) location.reload();
|
||||
} catch(e) { console.error('Todo delete error:', e); }
|
||||
deleteTodo(item.dataset.id);
|
||||
}
|
||||
});
|
||||
|
||||
// ===== Initialize =====
|
||||
// 1. Load from localStorage immediately
|
||||
loadLocal();
|
||||
render();
|
||||
|
||||
// 2. Sync with server
|
||||
syncWithServer();
|
||||
|
||||
// 3. Set up periodic sync
|
||||
setInterval(syncWithServer, SYNC_INTERVAL);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,7 +536,6 @@ data:
|
||||
} else {
|
||||
initTodoWidgets();
|
||||
}
|
||||
// Also run after a delay for dynamically loaded content
|
||||
setTimeout(initTodoWidgets, 500);
|
||||
setTimeout(initTodoWidgets, 1500);
|
||||
})();
|
||||
@@ -1008,21 +1154,25 @@ data:
|
||||
{{ $todos := .JSON.Array "todos" }}
|
||||
|
||||
<style>
|
||||
.todo-widget { display: flex; flex-direction: column; gap: 8px; }
|
||||
.todo-add { display: flex; gap: 8px; align-items: center; }
|
||||
.todo-widget { display: flex; flex-direction: column; gap: 10px; }
|
||||
.todo-add {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
background: rgba(255,255,255,0.06); border-radius: 8px;
|
||||
padding: 4px 4px 4px 8px;
|
||||
}
|
||||
.todo-add-btn {
|
||||
background: none; border: none; color: inherit; opacity: 0.6;
|
||||
cursor: pointer; font-size: 18px; padding: 4px 8px;
|
||||
background: none; border: none; color: inherit; opacity: 0.7;
|
||||
cursor: pointer; font-size: 18px; padding: 4px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.todo-add-btn:hover { opacity: 1; }
|
||||
.todo-add-input {
|
||||
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 6px; padding: 8px 10px; color: inherit; font-size: 13px;
|
||||
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px; padding: 10px 12px; color: inherit; font-size: 13px;
|
||||
outline: none; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.todo-add-input:focus {
|
||||
background: rgba(255,255,255,0.12); border-color: rgba(255,255,255,0.25);
|
||||
background: rgba(255,255,255,0.12); border-color: rgba(96, 165, 250, 0.5);
|
||||
}
|
||||
.todo-add-input::placeholder { opacity: 0.5; }
|
||||
.todo-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
|
||||
Reference in New Issue
Block a user