1192 lines
39 KiB
HTML
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="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/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>
|