local cache
This commit is contained in:
@@ -1019,6 +1019,11 @@ data:
|
|||||||
text: str
|
text: str
|
||||||
done: bool = False
|
done: bool = False
|
||||||
|
|
||||||
|
class TodoSyncRequest(BaseModel):
|
||||||
|
"""Request body for syncing todos."""
|
||||||
|
todos: list
|
||||||
|
modified: str # Client's last modified timestamp
|
||||||
|
|
||||||
class MotivationItem(BaseModel):
|
class MotivationItem(BaseModel):
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
@@ -1038,6 +1043,7 @@ data:
|
|||||||
default = {
|
default = {
|
||||||
"notes": "",
|
"notes": "",
|
||||||
"todos": [],
|
"todos": [],
|
||||||
|
"todos_modified": None, # ISO timestamp of last todos modification
|
||||||
"motivation": [
|
"motivation": [
|
||||||
"Believe in yourself!",
|
"Believe in yourself!",
|
||||||
"Every day is a new opportunity.",
|
"Every day is a new opportunity.",
|
||||||
@@ -1100,18 +1106,73 @@ data:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ========== TODOS ==========
|
# ========== TODOS ==========
|
||||||
|
def _update_todos_modified(data: dict) -> str:
|
||||||
|
"""Update the todos_modified timestamp and return it."""
|
||||||
|
ts = datetime.now(timezone.utc).isoformat()
|
||||||
|
data["todos_modified"] = ts
|
||||||
|
return ts
|
||||||
|
|
||||||
@APP.get("/userdata/{user}/todos")
|
@APP.get("/userdata/{user}/todos")
|
||||||
def get_todos(user: str):
|
def get_todos(user: str):
|
||||||
"""Get user todos."""
|
"""Get user todos with modification timestamp for sync."""
|
||||||
data = _load_userdata(user)
|
data = _load_userdata(user)
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({
|
content=json.dumps({
|
||||||
"todos": data.get("todos", []),
|
"todos": data.get("todos", []),
|
||||||
"count": len(data.get("todos", []))
|
"count": len(data.get("todos", [])),
|
||||||
|
"modified": data.get("todos_modified")
|
||||||
}, ensure_ascii=False),
|
}, ensure_ascii=False),
|
||||||
media_type="application/json; charset=utf-8"
|
media_type="application/json; charset=utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@APP.post("/userdata/{user}/todos/sync")
|
||||||
|
def sync_todos(user: str, body: TodoSyncRequest, key: str = Query(default="")):
|
||||||
|
"""
|
||||||
|
Sync todos between client and server.
|
||||||
|
- If client's modified > server's modified: update server with client data
|
||||||
|
- If server's modified > client's modified: return server data (client should update)
|
||||||
|
- Returns current server state either way
|
||||||
|
"""
|
||||||
|
_verify_key(key)
|
||||||
|
data = _load_userdata(user)
|
||||||
|
server_modified = data.get("todos_modified")
|
||||||
|
client_modified = body.modified
|
||||||
|
|
||||||
|
# Determine who has newer data
|
||||||
|
# If server has no timestamp, client data is newer
|
||||||
|
# If client timestamp > server timestamp, client is newer
|
||||||
|
client_is_newer = False
|
||||||
|
if not server_modified:
|
||||||
|
client_is_newer = True
|
||||||
|
elif client_modified and client_modified > server_modified:
|
||||||
|
client_is_newer = True
|
||||||
|
|
||||||
|
if client_is_newer:
|
||||||
|
# Update server with client data
|
||||||
|
data["todos"] = body.todos
|
||||||
|
new_ts = _update_todos_modified(data)
|
||||||
|
_save_userdata(user, data)
|
||||||
|
return Response(
|
||||||
|
content=json.dumps({
|
||||||
|
"action": "uploaded",
|
||||||
|
"todos": data["todos"],
|
||||||
|
"modified": new_ts,
|
||||||
|
"count": len(data["todos"])
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
media_type="application/json; charset=utf-8"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Server has newer or same data, client should download
|
||||||
|
return Response(
|
||||||
|
content=json.dumps({
|
||||||
|
"action": "downloaded",
|
||||||
|
"todos": data.get("todos", []),
|
||||||
|
"modified": server_modified,
|
||||||
|
"count": len(data.get("todos", []))
|
||||||
|
}, ensure_ascii=False),
|
||||||
|
media_type="application/json; charset=utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
@APP.post("/userdata/{user}/todos")
|
@APP.post("/userdata/{user}/todos")
|
||||||
def add_todo(user: str, body: TodoItem, key: str = Query(default="")):
|
def add_todo(user: str, body: TodoItem, key: str = Query(default="")):
|
||||||
"""Add a new todo item."""
|
"""Add a new todo item."""
|
||||||
@@ -1128,10 +1189,11 @@ data:
|
|||||||
if not data.get("todos"):
|
if not data.get("todos"):
|
||||||
data["todos"] = []
|
data["todos"] = []
|
||||||
data["todos"].append(new_todo)
|
data["todos"].append(new_todo)
|
||||||
|
new_ts = _update_todos_modified(data)
|
||||||
_save_userdata(user, data)
|
_save_userdata(user, data)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({"success": True, "todo": new_todo}, ensure_ascii=False),
|
content=json.dumps({"success": True, "todo": new_todo, "modified": new_ts}, ensure_ascii=False),
|
||||||
media_type="application/json; charset=utf-8"
|
media_type="application/json; charset=utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1145,9 +1207,10 @@ data:
|
|||||||
if todo.get("id") == todo_id:
|
if todo.get("id") == todo_id:
|
||||||
todo["text"] = body.text.strip()
|
todo["text"] = body.text.strip()
|
||||||
todo["done"] = body.done
|
todo["done"] = body.done
|
||||||
|
new_ts = _update_todos_modified(data)
|
||||||
_save_userdata(user, data)
|
_save_userdata(user, data)
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({"success": True, "todo": todo}, ensure_ascii=False),
|
content=json.dumps({"success": True, "todo": todo, "modified": new_ts}, ensure_ascii=False),
|
||||||
media_type="application/json; charset=utf-8"
|
media_type="application/json; charset=utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1165,9 +1228,10 @@ data:
|
|||||||
if len(data["todos"]) == original_count:
|
if len(data["todos"]) == original_count:
|
||||||
raise HTTPException(status_code=404, detail="Todo not found")
|
raise HTTPException(status_code=404, detail="Todo not found")
|
||||||
|
|
||||||
|
new_ts = _update_todos_modified(data)
|
||||||
_save_userdata(user, data)
|
_save_userdata(user, data)
|
||||||
return Response(
|
return Response(
|
||||||
content=json.dumps({"success": True, "deleted": todo_id}, ensure_ascii=False),
|
content=json.dumps({"success": True, "deleted": todo_id, "modified": new_ts}, ensure_ascii=False),
|
||||||
media_type="application/json; charset=utf-8"
|
media_type="application/json; charset=utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -306,9 +306,12 @@ data:
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Todo Widget Controller
|
// Todo Widget Controller (Local-first with background sync)
|
||||||
// ================================
|
// ================================
|
||||||
(function() {
|
(function() {
|
||||||
|
const SYNC_INTERVAL = 60000; // 60 seconds
|
||||||
|
const STORAGE_KEY_PREFIX = 'glance-todos-';
|
||||||
|
|
||||||
function initTodoWidgets() {
|
function initTodoWidgets() {
|
||||||
document.querySelectorAll('.todo-widget').forEach(function(widget) {
|
document.querySelectorAll('.todo-widget').forEach(function(widget) {
|
||||||
if (widget.dataset.initialized) return;
|
if (widget.dataset.initialized) return;
|
||||||
@@ -317,70 +320,214 @@ data:
|
|||||||
const API = widget.dataset.api;
|
const API = widget.dataset.api;
|
||||||
const USER = widget.dataset.user;
|
const USER = widget.dataset.user;
|
||||||
const KEY = widget.dataset.key;
|
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) {
|
function apiUrl(path) {
|
||||||
return API + path + (KEY ? '?key=' + KEY : '');
|
return API + path + (KEY ? '?key=' + KEY : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = widget.querySelector('.todo-add-input');
|
function escHtml(s) {
|
||||||
const addBtn = widget.querySelector('.todo-add-btn');
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
async function addTodo() {
|
return d.innerHTML;
|
||||||
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 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) {
|
if (input) {
|
||||||
input.addEventListener('keydown', function(e) {
|
input.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Enter') { e.preventDefault(); addTodo(); }
|
if (e.key === 'Enter') {
|
||||||
});
|
e.preventDefault();
|
||||||
}
|
const text = input.value.trim();
|
||||||
if (addBtn) {
|
if (text) {
|
||||||
addBtn.addEventListener('click', function() {
|
addTodo(text);
|
||||||
if (input.value.trim()) addTodo();
|
input.value = '';
|
||||||
else input.focus();
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
const item = e.target.closest('.todo-item');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
if (e.target.closest('.todo-checkbox')) {
|
if (e.target.closest('.todo-checkbox')) {
|
||||||
const id = item.dataset.id;
|
toggleTodo(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); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.target.closest('.todo-delete')) {
|
if (e.target.closest('.todo-delete')) {
|
||||||
const id = item.dataset.id;
|
deleteTodo(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); }
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ===== 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 {
|
} else {
|
||||||
initTodoWidgets();
|
initTodoWidgets();
|
||||||
}
|
}
|
||||||
// Also run after a delay for dynamically loaded content
|
|
||||||
setTimeout(initTodoWidgets, 500);
|
setTimeout(initTodoWidgets, 500);
|
||||||
setTimeout(initTodoWidgets, 1500);
|
setTimeout(initTodoWidgets, 1500);
|
||||||
})();
|
})();
|
||||||
@@ -1008,21 +1154,25 @@ data:
|
|||||||
{{ $todos := .JSON.Array "todos" }}
|
{{ $todos := .JSON.Array "todos" }}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.todo-widget { display: flex; flex-direction: column; gap: 8px; }
|
.todo-widget { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.todo-add { display: flex; gap: 8px; align-items: center; }
|
.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 {
|
.todo-add-btn {
|
||||||
background: none; border: none; color: inherit; opacity: 0.6;
|
background: none; border: none; color: inherit; opacity: 0.7;
|
||||||
cursor: pointer; font-size: 18px; padding: 4px 8px;
|
cursor: pointer; font-size: 18px; padding: 4px;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.todo-add-btn:hover { opacity: 1; }
|
.todo-add-btn:hover { opacity: 1; }
|
||||||
.todo-add-input {
|
.todo-add-input {
|
||||||
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.1);
|
flex: 1; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15);
|
||||||
border-radius: 6px; padding: 8px 10px; color: inherit; font-size: 13px;
|
border-radius: 6px; padding: 10px 12px; color: inherit; font-size: 13px;
|
||||||
outline: none; transition: border-color 0.15s, background 0.15s;
|
outline: none; transition: border-color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.todo-add-input:focus {
|
.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-add-input::placeholder { opacity: 0.5; }
|
||||||
.todo-list { display: flex; flex-direction: column; gap: 2px; }
|
.todo-list { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user