Files
client/www/todo.html
T

1192 lines
39 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>TODO</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="./js/vendor/svg.min.js"></script>
<style>
/* ================================================================
TODO-SPECIFIC STYLES
================================================================ */
/* Header with input */
#divHeader {
display: flex;
align-items: center;
padding: 0.5rem 1rem;
padding-left: 4rem; /* Space for hamburger button */
border-bottom: 1px solid var(--muted-color);
background-color: var(--secondary-color);
}
/* Input box styling */
#inpTodo {
flex: 1;
padding: 0.75rem;
font-size: 1.2rem;
font-family: var(--font-family);
color: var(--primary-color);
background-color: var(--secondary-color);
border: 2px solid var(--primary-color);
border-radius: var(--border-radius);
outline: none;
box-sizing: border-box;
}
#inpTodo::placeholder {
color: var(--muted-color);
}
#inpTodo:focus {
border-color: var(--accent-color);
}
/* Body alignment */
#divBody {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
padding: 1rem;
padding-left: 0.5rem; /* Align with hamburger button */
}
/* Todo container */
#divTodoContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
box-sizing: border-box;
}
/* Individual todo item */
.divTodo {
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
width: 100%;
box-sizing: border-box;
padding: 0.75rem;
border: 1px solid var(--muted-color);
border-radius: var(--border-radius);
background-color: var(--secondary-color);
cursor: grab;
transition: border-color 0.2s ease;
position: relative;
font-size: 1rem;
line-height: 1.4;
}
.divTodo:hover {
border-color: var(--accent-color);
}
.divTodoGrabbed {
cursor: grabbing;
border-color: var(--accent-color);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.divTodo.clsComplete {
text-decoration: line-through;
color: var(--muted-color);
opacity: 0.6;
}
.divTodo.swipe-left {
transform: translateX(-100%);
transition: transform 0.3s ease;
opacity: 0;
}
.divTodo.swipe-right {
transform: translateX(100%);
transition: transform 0.3s ease;
}
/* Reorder mode - show controls */
.divTodo.reorderMode {
display: flex;
align-items: center;
gap: 0.5rem;
}
.todoTitle {
flex: 1;
}
.reorderControls {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
}
.btnReorder {
width: 32px;
height: 32px;
padding: 0;
border: 1px solid var(--muted-color);
border-radius: var(--border-radius);
background-color: var(--secondary-color);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.btnReorder:hover:not(:disabled) {
border-color: var(--accent-color);
}
.btnReorder:active:not(:disabled) {
background-color: var(--button-click-color);
color: var(--button-click-text-color);
border-color: var(--button-border-color);
}
.btnReorder:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* Empty state */
.emptyState {
text-align: center;
padding: 3rem 1rem;
color: var(--muted-color);
font-size: 1.1rem;
}
.emptyState svg {
width: 64px;
height: 64px;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Loading state */
.loadingState {
text-align: center;
padding: 2rem;
color: var(--muted-color);
}
@media (max-width: 768px) {
#inpTodo {
font-size: 1rem;
}
.divTodo {
padding: 0.6rem;
font-size: 0.95rem;
}
}
</style>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER WITH INPUT
================================================================ -->
<div id="divHeader">
<input
id="inpTodo"
type="text"
placeholder="Add new todo..."
autocomplete="off"
spellcheck="false"
/>
</div>
<!-- ================================================================
BODY - TODO LIST
================================================================ -->
<div id="divBody">
<div id="divTodoContainer">
<div class="loadingState">Loading todos...</div>
</div>
</div>
<!-- ================================================================
FOOTER
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- ================================================================
SIDENAV
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay, encryptContent, decryptContent } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[todo.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
let USER_TODOS = [];
let todoSubscription = null;
let isReorderMode = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// Pointer tracking for gestures
let objPointer = {
X: 0,
Y: 0,
initX: 0,
initY: 0,
grabbed: false,
target: null,
downTime: 0,
longPressTimer: null
};
/* ================================================================
DOM VARIABLES
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const divTodoContainer = document.getElementById("divTodoContainer");
const inpTodo = document.getElementById("inpTodo");
/* ================================================================
TODO DATA STRUCTURE
================================================================ */
function createTodo(title) {
return {
id: Date.now(),
Title: title,
Created: Math.floor(Date.now() / 1000),
Completed: "",
Hidden: "",
Order: Date.now(),
Attachments: [],
Hashtags: [],
SubTodos: [],
Collapse: false
};
}
/* ================================================================
TODO RENDERING
================================================================ */
function renderTodos() {
divTodoContainer.innerHTML = '';
if (USER_TODOS.length === 0) {
divTodoContainer.innerHTML = `
<div class="emptyState">
<svg viewBox="0 0 100 100" width="64" height="64">
<line x1="20" x2="80" y1="30" y2="30" stroke="currentColor" stroke-width="4" stroke-linecap="round"></line>
<line x1="20" x2="80" y1="50" y2="50" stroke="currentColor" stroke-width="4" stroke-linecap="round"></line>
<line x1="20" x2="80" y1="70" y2="70" stroke="currentColor" stroke-width="4" stroke-linecap="round"></line>
</svg>
<div>No todos yet</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem;">Add one above to get started!</div>
</div>
`;
return;
}
// Sort by Order (most recent first)
const sortedTodos = [...USER_TODOS].sort((a, b) => (b.Order || 0) - (a.Order || 0));
sortedTodos.forEach((todo, index) => {
if (todo.Hidden) return; // Skip hidden todos
// Get the ID (use id field only)
const todoId = todo.id;
if (!todoId) {
console.warn('[todo.html] Todo missing id field:', todo);
return;
}
const todoDiv = document.createElement('div');
todoDiv.className = 'divTodo' + (todo.Completed ? ' clsComplete' : '') + (isReorderMode ? ' reorderMode' : '');
todoDiv.dataset.id = String(todoId);
todoDiv.dataset.index = index;
if (isReorderMode) {
// Create structure with title and reorder controls
const titleSpan = document.createElement('span');
titleSpan.className = 'todoTitle';
titleSpan.textContent = todo.Title;
const controlsDiv = document.createElement('div');
controlsDiv.className = 'reorderControls';
// Arrow Up button (move to top)
const btnArrowUp = document.createElement('button');
btnArrowUp.className = 'btnReorder';
const arrowUpIconId = `arrow-up-icon-${todoId}`;
btnArrowUp.innerHTML = `<div id="${arrowUpIconId}"></div>`;
btnArrowUp.onclick = (e) => {
e.stopPropagation();
moveToTop(index);
};
if (index === 0) btnArrowUp.disabled = true;
// Carrot Up button (move up one)
const btnCarrotUp = document.createElement('button');
btnCarrotUp.className = 'btnReorder';
const carrotUpIconId = `carrot-up-icon-${todoId}`;
btnCarrotUp.innerHTML = `<div id="${carrotUpIconId}"></div>`;
btnCarrotUp.onclick = (e) => {
e.stopPropagation();
moveUp(index);
};
if (index === 0) btnCarrotUp.disabled = true;
// Carrot Down button (move down one)
const btnCarrotDown = document.createElement('button');
btnCarrotDown.className = 'btnReorder';
const carrotDownIconId = `carrot-down-icon-${todoId}`;
btnCarrotDown.innerHTML = `<div id="${carrotDownIconId}"></div>`;
btnCarrotDown.onclick = (e) => {
e.stopPropagation();
moveDown(index);
};
if (index === sortedTodos.length - 1) btnCarrotDown.disabled = true;
// Arrow Down button (move to bottom)
const btnArrowDown = document.createElement('button');
btnArrowDown.className = 'btnReorder';
const arrowDownIconId = `arrow-down-icon-${todoId}`;
btnArrowDown.innerHTML = `<div id="${arrowDownIconId}"></div>`;
btnArrowDown.onclick = (e) => {
e.stopPropagation();
moveToBottom(index);
};
if (index === sortedTodos.length - 1) btnArrowDown.disabled = true;
controlsDiv.appendChild(btnArrowUp);
controlsDiv.appendChild(btnCarrotUp);
controlsDiv.appendChild(btnCarrotDown);
controlsDiv.appendChild(btnArrowDown);
todoDiv.appendChild(titleSpan);
todoDiv.appendChild(controlsDiv);
divTodoContainer.appendChild(todoDiv);
// Initialize HamburgerMorphing icons after DOM insertion
setTimeout(() => {
const arrowUpIcon = new HamburgerMorphing(`#${arrowUpIconId}`, {
size: 24,
foreground: 'var(--primary-color)',
background: 'transparent',
hover: 'var(--accent-color)'
});
arrowUpIcon.animateTo('arrow_up');
const carrotUpIcon = new HamburgerMorphing(`#${carrotUpIconId}`, {
size: 24,
foreground: 'var(--primary-color)',
background: 'transparent',
hover: 'var(--accent-color)'
});
carrotUpIcon.animateTo('carrot_up');
const carrotDownIcon = new HamburgerMorphing(`#${carrotDownIconId}`, {
size: 24,
foreground: 'var(--primary-color)',
background: 'transparent',
hover: 'var(--accent-color)'
});
carrotDownIcon.animateTo('carrot_down');
const arrowDownIcon = new HamburgerMorphing(`#${arrowDownIconId}`, {
size: 24,
foreground: 'var(--primary-color)',
background: 'transparent',
hover: 'var(--accent-color)'
});
arrowDownIcon.animateTo('arrow_down');
}, 0);
} else {
// Normal mode - just show title with swipe gestures
todoDiv.textContent = todo.Title;
// Add gesture handlers only in normal mode
todoDiv.addEventListener('pointerdown', handlePointerDown);
todoDiv.addEventListener('pointerup', handlePointerUp);
todoDiv.addEventListener('pointermove', handlePointerMove);
todoDiv.addEventListener('pointerleave', handlePointerLeave);
divTodoContainer.appendChild(todoDiv);
}
});
}
/* ================================================================
GESTURE HANDLERS
================================================================ */
function handlePointerDown(e) {
e.preventDefault();
e.stopPropagation();
objPointer.downTime = Date.now();
objPointer.initX = e.clientX;
objPointer.initY = e.clientY;
objPointer.grabbed = true;
objPointer.target = e.currentTarget;
objPointer.target.classList.add('divTodoGrabbed');
if (isReorderMode) {
objPointer.target.classList.add('dragging');
}
// Capture pointer to ensure we get all events
objPointer.target.setPointerCapture(e.pointerId);
// Start long press timer for reorder mode
objPointer.longPressTimer = setTimeout(() => {
if (objPointer.grabbed) {
toggleReorderMode();
}
}, 800); // 800ms for long press
}
function handlePointerUp(e) {
e.preventDefault();
e.stopPropagation();
clearTimeout(objPointer.longPressTimer);
if (!objPointer.target) return;
const deltaX = e.clientX - objPointer.initX;
const deltaY = e.clientY - objPointer.initY;
const duration = Date.now() - objPointer.downTime;
console.log('[todo.html] Pointer up:', { deltaX, deltaY, duration });
// Reset transform
objPointer.target.style.transform = 'translate(0px, 0px)';
objPointer.target.classList.remove('divTodoGrabbed');
// Process swipes (only in normal mode, not reorder mode)
if (!isReorderMode && duration < 1000) {
const todoId = objPointer.target.dataset.id;
// Left swipe (complete/delete)
if (deltaX < -30 && Math.abs(deltaY) < 50) {
console.log('[todo.html] Left swipe detected for todo:', todoId);
handleLeftSwipe(todoId, objPointer.target);
}
// Right swipe (uncomplete)
else if (deltaX > 30 && Math.abs(deltaY) < 50) {
console.log('[todo.html] Right swipe detected for todo:', todoId);
handleRightSwipe(todoId, objPointer.target);
}
}
objPointer.grabbed = false;
objPointer.target = null;
}
function handlePointerMove(e) {
if (!objPointer.grabbed || !objPointer.target) return;
e.preventDefault();
const deltaX = e.clientX - objPointer.initX;
const deltaY = e.clientY - objPointer.initY;
// Cancel long press if moved too much
if (Math.abs(deltaX) > 10 || Math.abs(deltaY) > 10) {
clearTimeout(objPointer.longPressTimer);
}
// Only allow horizontal swipes in normal mode
if (!isReorderMode) {
objPointer.target.style.transform = `translate(${deltaX}px, 0px)`;
}
}
function handlePointerLeave(e) {
if (!objPointer.target) return;
clearTimeout(objPointer.longPressTimer);
objPointer.target.style.transform = 'translate(0px, 0px)';
objPointer.target.classList.remove('divTodoGrabbed');
objPointer.grabbed = false;
objPointer.target = null;
}
/* ================================================================
TODO ACTIONS
================================================================ */
async function handleLeftSwipe(todoId, element) {
console.log('[todo.html] handleLeftSwipe called for:', todoId, typeof todoId);
console.log('[todo.html] Available todos:', USER_TODOS.map(t => ({ id: t.id, type: typeof t.id })));
const todoIndex = USER_TODOS.findIndex(t => String(t.id) === String(todoId));
if (todoIndex === -1) {
console.error('[todo.html] Todo not found:', todoId);
console.error('[todo.html] USER_TODOS:', JSON.stringify(USER_TODOS, null, 2));
return;
}
const todo = USER_TODOS[todoIndex];
console.log('[todo.html] Todo found:', { id: todo.id, title: todo.Title, completed: todo.Completed });
if (!todo.Completed) {
// First swipe: mark as complete
console.log('[todo.html] Marking todo as complete');
todo.Completed = Math.floor(Date.now() / 1000);
await saveTodos();
} else {
// Second swipe: delete
console.log('[todo.html] Deleting todo');
element.classList.add('swipe-left');
setTimeout(() => {
USER_TODOS.splice(todoIndex, 1);
saveTodos();
}, 300);
}
}
async function handleRightSwipe(todoId, element) {
console.log('[todo.html] handleRightSwipe called for:', todoId, typeof todoId);
const todoIndex = USER_TODOS.findIndex(t => String(t.id) === String(todoId));
if (todoIndex === -1) {
console.error('[todo.html] Todo not found:', todoId);
return;
}
const todo = USER_TODOS[todoIndex];
console.log('[todo.html] Todo found:', { id: todo.id, title: todo.Title, completed: todo.Completed });
if (todo.Completed) {
// Uncomplete the todo
console.log('[todo.html] Unmarking todo as complete');
todo.Completed = "";
await saveTodos();
} else {
console.log('[todo.html] Todo is not completed, ignoring right swipe');
}
}
function toggleReorderMode() {
isReorderMode = !isReorderMode;
console.log('[todo.html] Reorder mode:', isReorderMode ? 'ON' : 'OFF');
renderTodos();
}
async function clearCompleted() {
const completedCount = USER_TODOS.filter(t => t.Completed).length;
if (completedCount === 0) {
console.log('[todo.html] No completed todos to clear');
return;
}
console.log('[todo.html] Clearing', completedCount, 'completed todos');
USER_TODOS = USER_TODOS.filter(t => !t.Completed);
await saveTodos();
closeNav();
}
async function moveToTop(index) {
if (index <= 0) return;
console.log('[todo.html] Moving todo to top from index', index);
// Sort by Order to get current display order
const sortedTodos = [...USER_TODOS].sort((a, b) => (b.Order || 0) - (a.Order || 0));
// Remove item and insert at beginning
const [movedTodo] = sortedTodos.splice(index, 1);
sortedTodos.unshift(movedTodo);
// Update Order values based on new positions
sortedTodos.forEach((todo, idx) => {
todo.Order = Date.now() - idx * 1000;
});
USER_TODOS = sortedTodos;
await saveTodos();
}
async function moveUp(index) {
if (index <= 0) return;
console.log('[todo.html] Moving todo up one position from index', index);
// Sort by Order to get current display order
const sortedTodos = [...USER_TODOS].sort((a, b) => (b.Order || 0) - (a.Order || 0));
// Swap with previous item
const temp = sortedTodos[index - 1];
sortedTodos[index - 1] = sortedTodos[index];
sortedTodos[index] = temp;
// Update Order values based on new positions
sortedTodos.forEach((todo, idx) => {
todo.Order = Date.now() - idx * 1000;
});
USER_TODOS = sortedTodos;
await saveTodos();
}
async function moveDown(index) {
// Sort by Order to get current display order
const sortedTodos = [...USER_TODOS].sort((a, b) => (b.Order || 0) - (a.Order || 0));
if (index >= sortedTodos.length - 1) return;
console.log('[todo.html] Moving todo down one position from index', index);
// Swap with next item
const temp = sortedTodos[index + 1];
sortedTodos[index + 1] = sortedTodos[index];
sortedTodos[index] = temp;
// Update Order values based on new positions
sortedTodos.forEach((todo, idx) => {
todo.Order = Date.now() - idx * 1000;
});
USER_TODOS = sortedTodos;
await saveTodos();
}
async function moveToBottom(index) {
// Sort by Order to get current display order
const sortedTodos = [...USER_TODOS].sort((a, b) => (b.Order || 0) - (a.Order || 0));
if (index >= sortedTodos.length - 1) return;
console.log('[todo.html] Moving todo to bottom from index', index);
// Remove item and insert at end
const [movedTodo] = sortedTodos.splice(index, 1);
sortedTodos.push(movedTodo);
// Update Order values based on new positions
sortedTodos.forEach((todo, idx) => {
todo.Order = Date.now() - idx * 1000;
});
USER_TODOS = sortedTodos;
await saveTodos();
}
/* ================================================================
TODO INPUT HANDLER
================================================================ */
inpTodo.addEventListener('keydown', async (e) => {
if (e.key === 'Enter' && inpTodo.value.trim()) {
const newTodo = createTodo(inpTodo.value.trim());
USER_TODOS.push(newTodo);
inpTodo.value = '';
await saveTodos();
}
});
/* ================================================================
NOSTR INTEGRATION
================================================================ */
async function saveTodos() {
if (!currentPubkey) {
console.error('[todo.html] Cannot save: not authenticated');
return;
}
try {
// Create the event content
const content = JSON.stringify({ rows: USER_TODOS });
// Encrypt via worker signer (NIP-44)
let encryptedContent;
try {
const encryptedResp = await encryptContent(currentPubkey, content, 'nip44');
encryptedContent = String(encryptedResp?.ciphertext || '');
if (!encryptedContent) throw new Error('Empty ciphertext');
} catch (error) {
console.error('[todo.html] Encryption failed:', error);
throw new Error('Failed to encrypt todo data');
}
// Create the event
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 30078,
tags: [['d', 'todo']],
content: encryptedContent
};
console.log('[todo.html] Publishing todos...');
const result = await publishEvent(event);
console.log('[todo.html] Todos published to:', result.relayResults.successful);
// Re-render after save
renderTodos();
} catch (error) {
console.error('[todo.html] Failed to save todos:', error);
alert('Failed to save todos: ' + error.message);
}
}
async function loadTodos() {
if (!currentPubkey) {
console.error('[todo.html] Cannot load: not authenticated');
return;
}
console.log('[todo.html] Loading todos for:', currentPubkey);
// Subscribe to kind 30078 events with d tag "todo"
todoSubscription = subscribe(
{
kinds: [30078],
authors: [currentPubkey],
'#d': ['todo']
},
{
closeOnEose: false,
cacheUsage: 'CACHE_FIRST'
}
);
console.log('[todo.html] Subscribed to todos');
}
// Listen for todo events
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (evt.kind === 30078 && evt.pubkey === currentPubkey) {
const dTag = evt.tags.find(t => t[0] === 'd');
if (dTag && dTag[1] === 'todo') {
console.log('[todo.html] Received todo event');
try {
// Decrypt the content (prefer NIP-44, fallback NIP-04)
const decryptedResp = await decryptContent(evt.pubkey, evt.content, 'auto');
const decryptedContent = String(decryptedResp?.plaintext || '');
const decryptedScheme = String(decryptedResp?.scheme || '').toLowerCase();
const data = JSON.parse(decryptedContent);
if (data.rows && Array.isArray(data.rows)) {
// Migrate old format: convert _id to id and ensure all required fields
USER_TODOS = data.rows.map(todo => {
// If using old _id format, convert to new format
if (todo._id !== undefined && !todo.id) {
return {
id: Date.now() + Math.random(), // Generate new unique ID
Title: todo.Title || '',
Created: todo.Created ? (typeof todo.Created === 'string' ? Math.floor(new Date(todo.Created).getTime() / 1000) : todo.Created) : Math.floor(Date.now() / 1000),
Completed: todo.Completed || "",
Hidden: todo.Hidden || "",
Order: todo.Order || Date.now(),
Attachments: todo.Attachments || [],
Hashtags: todo.Hashtags || [],
SubTodos: todo.SubTodos || [],
Collapse: todo.Collapse || false
};
}
// Return as-is if already in correct format
return todo;
});
console.log('[todo.html] Loaded', USER_TODOS.length, 'todos');
renderTodos();
// If we migrated any todos, save the new format
const hasMigrated = data.rows.some(t => t._id !== undefined && !t.id);
if (hasMigrated) {
console.log('[todo.html] Migrated old format, saving...');
await saveTodos();
} else if (decryptedScheme === 'nip04') {
console.log('[todo.html] Migrating legacy NIP-04 payload to NIP-44...');
await saveTodos();
}
}
} catch (error) {
console.error('[todo.html] Failed to decrypt/parse todos:', error);
}
}
}
});
// Listen for EOSE (end of stored events)
window.addEventListener('ndkEose', (event) => {
console.log('[todo.html] EOSE received');
if (USER_TODOS.length === 0) {
renderTodos(); // Show empty state
}
});
/* ================================================================
HAMBURGER MENU
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Add todo management buttons
const completedCount = USER_TODOS.filter(t => t.Completed).length;
divSideNavBody.innerHTML = `
<div style="padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem;">
<button id="btnClearCompleted" class="btnSideNav" style="
padding: 0.75rem;
border: 1px solid var(--primary-color);
border-radius: var(--border-radius);
background-color: var(--secondary-color);
color: var(--primary-color);
font-family: var(--font-family);
font-size: 1rem;
cursor: pointer;
">
Clear Completed (${completedCount})
</button>
<button id="btnToggleReorder" class="btnSideNav" style="
padding: 0.75rem;
border: 1px solid var(--primary-color);
border-radius: var(--border-radius);
background-color: ${isReorderMode ? 'var(--accent-color)' : 'var(--secondary-color)'};
color: ${isReorderMode ? 'var(--secondary-color)' : 'var(--primary-color)'};
font-family: var(--font-family);
font-size: 1rem;
cursor: pointer;
">
${isReorderMode ? '✓ Reorder Mode' : 'Reorder'}
</button>
</div>
`;
// Add event listeners
const btnClearCompleted = document.getElementById('btnClearCompleted');
const btnToggleReorder = document.getElementById('btnToggleReorder');
if (btnClearCompleted) {
btnClearCompleted.addEventListener('click', clearCompleted);
}
if (btnToggleReorder) {
btnToggleReorder.addEventListener('click', () => {
toggleReorderMode();
closeNav(); // Close nav after toggling
});
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
UPDATE FOOTER
================================================================ */
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = `${USER_TODOS.length} todos`;
} catch (error) {
console.error('[todo.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================ */
const Logout = async () => {
console.log("[todo.html] Starting logout process...");
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
disconnect();
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
localStorage.clear();
sessionStorage.clear();
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[todo.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log("[todo.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
localStorage.setItem('theme', isDarkMode ? 'dark' : 'light');
document.documentElement.classList.toggle('dark-mode', isDarkMode);
document.body.classList.toggle('dark-mode', isDarkMode);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[todo.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
// Listen for relay activity
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
setRelayActivityState(relayUrl, activity);
});
// Load todos from Nostr
await loadTodos();
// Start update loop
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[todo.html] Initialization complete');
} catch (error) {
console.error('[todo.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
</script>
</body>
</html>