Files
client/www/note.html

1350 lines
43 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>Note</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 media="screen">
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
/* ADD THIS - prevents wrapping */
align-items: stretch !important;
justify-content: flex-start !important;
align-content: flex-start !important;
}
.divNoteTitle {
margin: 5px;
padding: 5px;
font-family: var(--font-mono);
}
.divNoteTitle:hover {
background-color: var(--button-hover-color);
cursor: pointer;
}
#NewNoteTitle {
border: 1px solid black;
margin: 5px;
padding: 5px;
font-family: var(--font-mono);
}
#NewNoteTitle:hover {
border: 2px solid black;
cursor: text;
}
.MainBox {
margin: 20px;
padding: 15px;
}
#taNote {
box-sizing: border-box;
font-family: var(--font-mono);
resize: none;
font-size: 150%;
height: 70vh;
width: calc(100% - 40px);
margin-left: 20px;
overflow: visible;
background-color: var(--background-color);
color: var(--primary-color);
border: 1px solid var(--secondary-color);
}
#divHTMLOutBox {
margin: 20px;
border: 2px solid black;
overflow-y: scroll;
min-height: 100%;
width: calc(100% - 40px);
}
#iframeHTMLOut {
box-sizing: border-box;
padding: 20px;
width: calc(100% - 40px);
/* Match taNote */
margin-left: 20px;
/* Match taNote */
height: 70vh;
border: none;
background-color: white;
display: block;
overflow-y: auto;
}
#iframeHTMLOut.clsVisible {
display: block;
}
#iframeHTMLOut.clsHidden {
display: none;
}
#divHTMLCode {
font-family: var(--font-mono);
}
.clsVisible {
display: block;
}
.clsHidden {
display: none;
}
#tblTags {
width: 100%;
padding: 20px;
padding-top: 60px;
color: var(--primary-color);
}
.tdEditable {
border: 0.1px solid var(--secondary-color);
padding: 5px;
background-color: var(--background-color);
color: var(--primary-color);
}
.tdTitle {
width: 1%;
padding-left: 5px;
padding-right: 15px;
color: var(--primary-color);
font-family: var(--font-mono);
font-weight: bold;
}
#imgMain {
width: 60%;
padding: 20px;
display: block;
/* Add this */
margin: 0 auto;
/* Add this to center the image */
}
#divTitle {
text-align: center;
margin-top: 40px;
padding: 20px;
font-size: 150%;
color: var(--primary-color);
min-height: 1.5em;
display: block;
/* Add this */
clear: both;
/* Add this to ensure it's on its own line */
}
/* SIDENAV NOTES TABLE */
#divSideNavBody {
display: flex;
flex-direction: column;
overflow-y: auto;
}
.divNewNote {
border: 1px solid var(--secondary-color);
margin: 3px;
height: 1.7em;
margin-left: 30px;
margin-right: 20px;
padding-left: 15px;
padding: 2px;
cursor: pointer;
}
.divNewNote:hover {
background-color: var(--button-hover-color);
}
#tblNotes {
font-family: var(--font-mono);
font-size: 60%;
width: 100%;
color: var(--primary-color);
}
#tblNotes tr:hover {
background: var(--button-hover-color);
cursor: pointer;
}
.col0 {
font-weight: 900;
padding-left: 20px;
font-family: var(--font-mono);
font-size: 140%;
}
.col1 {
padding-left: 20px;
font-family: var(--font-mono);
}
.col2 {
padding-left: 20px;
text-align: right;
font-family: var(--font-mono);
}
.col3 {
padding-left: 20px;
font-family: var(--font-mono);
}
.divTableButtons {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 20px;
z-index: 3;
}
/* Sidenav structure for notes + relay section */
#divNotesSection {
flex: 1;
overflow-y: auto;
padding: 10px;
}
/* Header buttons */
#divSvgCheck {
cursor: pointer;
}
#divSvgEdit {
cursor: pointer;
}
#divFullScreen {
cursor: pointer;
}
#divHeaderText {
outline: none;
}
#txtDecryptTrace {
font-family: var(--font-mono);
white-space: pre-wrap;
word-break: break-word;
}
#divDiagnosticBadge {
display: inline-block;
margin-left: 10px;
padding: 4px 8px;
border: 1px solid #b45309;
border-radius: 4px;
color: #b45309;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.02em;
}
</style>
<script src="./js/marked.min.js"></script>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Check button for save -->
<div id="divSvgCheck" class="divHeaderButtons" title="Save Note">
<svg id="svgCheck" class="svgHeaderButtons" viewBox="0 0 10 10">
<path id="svgCheckPath" d="M2,6 L4,8 L8,2" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
</div>
</div>
<div id="divHeaderFlexCenter">
<div id="divHeaderText" class="divHeaderText" contenteditable="true">TITLE</div>
</div>
<div id="divHeaderFlexRight">
<!-- Fullscreen button -->
<div id="divFullScreen" class="divHeaderButtons" title="Toggle Fullscreen">
<svg class="svgHeaderButtons" viewBox="0 0 10 10">
<line x1="5" y1="2" x2="5" y2="8" stroke="currentColor" stroke-width="1" />
<line x1="5" y1="2" x2="3" y2="4" stroke="currentColor" stroke-width="1" />
<line x1="5" y1="2" x2="7" y2="4" stroke="currentColor" stroke-width="1" />
</svg>
</div>
<!-- Edit/Preview toggle -->
<div id="divSvgEdit" class="divHeaderButtons" title="Toggle Edit/Preview">
<svg id="svgEdit" class="svgHeaderButtons" viewBox="0 0 10 10">
<path id="svgEditP1" d="M8,2 L2,5 L8,8" stroke="currentColor" stroke-width="1" fill="none" />
<path id="svgEditP2" d="M6,3 L7,5 L6,7" stroke="currentColor" stroke-width="1" fill="none" />
</svg>
</div>
</div>
</div>
<!-- ================================================================
BODY - Note Editor
================================================================ -->
<div id="divBody">
<table id="tblTags">
<tr>
<td class="tdTitle">Note ID</td>
<td id="txtID"></td>
</tr>
<tr>
<td class="tdTitle">Title</td>
<td id="txtTitle" class="tdEditable" contenteditable="true"></td>
</tr>
<tr>
<td class="tdTitle">Image</td>
<td id="txtImg" class="tdEditable" contenteditable="true"></td>
</tr>
<tr>
<td class="tdTitle">Summary</td>
<td id="txtSummary" class="tdEditable" contenteditable="true"></td>
</tr>
<tr>
<td class="tdTitle">Tags</td>
<td id="txtTags" class="tdEditable" contenteditable="true"></td>
</tr>
<tr>
<td class="tdTitle">Autosave</td>
<td id="txtAutosave" class="tdEditable" contenteditable="true">10</td>
</tr>
<tr>
<td class="tdTitle">Decrypt Trace</td>
<td id="txtDecryptTrace">None</td>
</tr>
<tr>
<td class="tdTitle"></td>
<td>
<button id="btnSaveNote" class="btn">Save Note</button>
<span id="divDiagnosticBadge" class="clsHidden">DIAGNOSTIC VIEW — do not save</span>
</td>
</tr>
</table>
<div id="divTitle"></div>
<img id="imgMain" src="" class="clsHidden" />
<textarea id="taNote" class="MainBox clsVisible clsThreeThird"></textarea>
<iframe id="iframeHTMLOut" scrolling="auto" class="clsHidden"></iframe>
</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 - Notes list + Relay section at bottom
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<!-- Notes section - scrollable -->
<div id="divNotesSection">
<div class="divNewNote" id="divNewNote">+ New Note</div>
<div id="divFiles"></div>
</div>
<!-- Relay section - at bottom -->
<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-login-lite/nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================ */
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay } 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(`[note.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// Note-specific variables
let OBJ_NOTES = {};
let CURRENT_NOTE = "";
let ENCRYPTED_TAG_TYPES = ["title", "image", "summary", "t"];
const MAX_DECRYPT_PASSES = 8;
let EDIT_MODE = false;
let LastText = "";
let NUM_LAST_EDIT_TIME = Math.floor(Date.now() / 1000);
let numAutoSaveSec = 30;
let CURRENT_DECRYPT_TRACE = null;
let DIAGNOSTIC_ACTIVE = false;
/* ================================================================
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 divFooterLeft = document.getElementById("divFooterLeft");
// Note-specific DOM elements
const taNote = document.getElementById('taNote');
const iframeHTMLOut = document.getElementById('iframeHTMLOut');
const imgMain = document.getElementById('imgMain');
const txtID = document.getElementById('txtID');
const txtTitle = document.getElementById('txtTitle');
const divTitle = document.getElementById('divTitle');
const txtImg = document.getElementById('txtImg');
const txtSummary = document.getElementById('txtSummary');
const txtTags = document.getElementById('txtTags');
const txtAutosave = document.getElementById('txtAutosave');
const divHeaderText = document.getElementById('divHeaderText');
const tblTags = document.getElementById('tblTags');
const divFiles = document.getElementById('divFiles');
const txtDecryptTrace = document.getElementById('txtDecryptTrace');
const divDiagnosticBadge = document.getElementById('divDiagnosticBadge');
/* ================================================================
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');
}
LoadSidenav();
// 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)'
});
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();
}
}
/* ================================================================
NOTE FUNCTIONS
================================================================ */
async function nip44DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-44 decryption');
if (!window.nostr?.nip44?.decrypt) throw new Error('NIP-44 decrypt unavailable');
return await window.nostr.nip44.decrypt(selfPubkey, String(ciphertext || ''));
}
async function nip04DecryptFromSelf(ciphertext) {
const selfPubkey = String(currentPubkey || await getPubkey() || '').trim();
if (!selfPubkey) throw new Error('Missing pubkey for NIP-04 decryption');
if (!window.nostr?.nip04?.decrypt) throw new Error('NIP-04 decrypt unavailable');
return await window.nostr.nip04.decrypt(selfPubkey, String(ciphertext || ''));
}
function looksLikeNip04Ciphertext(input) {
const text = String(input || '').trim();
return /^[A-Za-z0-9+/_-]+={0,2}\?iv=[A-Za-z0-9+/_-]+={0,2}$/.test(text);
}
function looksLikeBase64(input) {
const text = String(input || '').trim();
if (!text || text.length < 24 || text.length % 4 !== 0) return false;
return /^[A-Za-z0-9+/]+={0,2}$/.test(text);
}
function looksLikeNip44Ciphertext(input) {
const text = String(input || '').trim();
if (!looksLikeBase64(text)) return false;
try {
const decoded = atob(text);
if (!decoded || decoded.length < 2) return false;
const versionByte = decoded.charCodeAt(0);
return versionByte === 1 || versionByte === 2;
} catch (_error) {
return false;
}
}
function classifyCiphertext(input) {
const text = String(input || '').trim();
if (!text) return 'plaintext';
if (looksLikeNip04Ciphertext(text)) return 'nip04';
if (looksLikeNip44Ciphertext(text)) return 'nip44';
if (/[\n\r\t]/.test(text) || /[#*_`\[\]{}()]/.test(text)) {
return 'plaintext';
}
if (looksLikeBase64(text)) return 'base64-unknown';
return 'plaintext';
}
async function decryptOnePass(input) {
const text = String(input || '').trim();
const kind = classifyCiphertext(text);
const methods = kind === 'nip04'
? ['nip04', 'nip44']
: ['nip44', 'nip04'];
for (const method of methods) {
try {
const output = method === 'nip44'
? await nip44DecryptFromSelf(text)
: await nip04DecryptFromSelf(text);
return {
ok: true,
method,
text: String(output || '')
};
} catch (_error) {
// try next method
}
}
return {
ok: false,
method: null,
text
};
}
async function peelDecrypt(input, maxPasses = MAX_DECRYPT_PASSES) {
let current = String(input || '').trim();
const trace = [];
let stoppedReason = 'max-passes';
for (let pass = 1; pass <= maxPasses; pass++) {
const beforeClass = classifyCiphertext(current);
if (beforeClass === 'plaintext') {
stoppedReason = 'plaintext';
break;
}
const one = await decryptOnePass(current);
if (!one.ok) {
stoppedReason = 'decrypt-failed';
break;
}
trace.push({
pass,
method: one.method,
inLength: current.length,
outLength: one.text.length
});
current = String(one.text || '');
}
return {
finalText: current,
trace,
stoppedReason,
finalClass: classifyCiphertext(current)
};
}
function formatDecryptTrace(diag) {
if (!diag) return 'None';
const passes = Number(diag.passes || 0);
const methods = Array.isArray(diag.methods) && diag.methods.length > 0
? diag.methods.join(' → ')
: 'none';
const reason = String(diag.stoppedReason || 'unknown');
const finalClass = String(diag.finalClass || 'unknown');
const finalLength = Number(diag.finalLength || 0);
return `passes=${passes} | methods=${methods} | stop=${reason} | final=${finalClass} | len=${finalLength}`;
}
async function peelTagsMaybeEncrypted(objTags) {
const safe = objTags && typeof objTags === 'object' ? { ...objTags } : {};
for (const key of ['title', 'image', 'summary']) {
const value = String(safe[key] || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
safe[key] = result.trace.length > 0 ? result.finalText : value;
}
if (Array.isArray(safe.t)) {
const decryptedTags = [];
for (const tagValue of safe.t) {
const value = String(tagValue || '').trim();
if (!value) continue;
const result = await peelDecrypt(value, 3);
decryptedTags.push(result.trace.length > 0 ? result.finalText : value);
}
safe.t = decryptedTags;
}
return safe;
}
async function ensureNoteDiagnostics(note) {
if (!note || Number(note.kind) !== 30024) return note;
if (note.decryptDiagnostics) return note;
const peel = await peelDecrypt(String(note.content || ''));
const decryptedTags = await peelTagsMaybeEncrypted(note.objTags || {});
note.decryptedContent = peel.finalText;
note.decryptedObjTags = decryptedTags;
note.decryptDiagnostics = {
passes: peel.trace.length,
methods: peel.trace.map((step) => String(step.method || '').toUpperCase()),
stoppedReason: peel.stoppedReason,
finalClass: peel.finalClass,
finalLength: String(peel.finalText || '').length
};
return note;
}
function setDiagnosticUI(note) {
CURRENT_DECRYPT_TRACE = note?.decryptDiagnostics || null;
txtDecryptTrace.textContent = formatDecryptTrace(CURRENT_DECRYPT_TRACE);
DIAGNOSTIC_ACTIVE = Boolean(
Number(note?.kind) === 30024 &&
(Number(CURRENT_DECRYPT_TRACE?.passes || 0) > 0 || CURRENT_DECRYPT_TRACE?.stoppedReason === 'decrypt-failed')
);
if (DIAGNOSTIC_ACTIVE) {
divDiagnosticBadge.className = '';
} else {
divDiagnosticBadge.className = 'clsHidden';
}
}
const LoadSidenav = async () => {
// Subscribe to long-form notes (kind 30023 and 30024)
const notesSub = subscribe(
{ kinds: [30023, 30024], authors: [currentPubkey] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
// Wait a moment for events to come in
await new Promise(resolve => setTimeout(resolve, 500));
// Get notes from cache via events
let htmlOut = `<table id="tblNotes">`;
htmlOut += `<tr><th colspan="10"> NOTES </th></tr>`;
htmlOut += `<tr><th colspan="10"> &nbsp </th></tr>`;
htmlOut += `<tr>
<th>Evt</th>
<th>Del</th>
<th>Pub</th>
<th>Title</th>
<th>d</th>
<th>Created</th>
<th>Edited</th>
<th>Type</th>
<th>Encrypted</th>
<th>Layers</th>
</tr>`;
// Build table from OBJ_NOTES
for (let Each of Object.keys(OBJ_NOTES).sort((a, b) => b - a)) {
const note = OBJ_NOTES[Each];
if (Number(note?.kind) === 30024) {
await ensureNoteDiagnostics(note);
}
let d = new Date(Number(Each) * 1000);
let dCreated = d.toLocaleString();
d = new Date(Number(note?.created_at) * 1000);
let dEdited = d.toLocaleString();
const dTagValue = note?.objTags?.d ?? `${note?.created_at} (from created_at)`;
const rowTitle = note?.decryptedObjTags?.title || note?.objTags?.title || 'Untitled';
let svgPub = `<div class="divTableButtons"></div>`;
if (note?.kind == 30024) {
svgPub = `<div id="divSVGPublish-${Each}" class="divTableButtons">➜</div>`;
}
htmlOut += `<tr>
<td class="col5"> <div id="divSVG-${Each}" class="divTableButtons">📋</div> </td>
<td class="col5"> <div id="divSVGDelete-${Each}" class="divTableButtons">✕</div> </td>
<td class="col5"> ${svgPub} </td>
<td class="col0" id="divFileID-${Each}">${rowTitle}</td>
<td class="col1">${dTagValue}</td>
<td class="col1">${dCreated}</td>
<td class="col1">${dEdited}</td>
<td class="col3">${note?.kind} </td>
<td class="col3">${note?.encrypted ? 'Yes' : 'No'} </td>
<td class="col3">${note?.kind == 30024 ? Number(note?.decryptDiagnostics?.passes || 0) : '-'}</td>
</tr>`;
}
divFiles.innerHTML = htmlOut;
// SET EVENT LISTENERS
for (let Each of Object.keys(OBJ_NOTES)) {
document.getElementById(`divSVGDelete-${Each}`)?.addEventListener("click", function () {
DeleteNote(`${Each}`);
});
document.getElementById(`divSVGDelete-${Each}`)?.addEventListener("click", closeNav);
document.getElementById(`divSVG-${Each}`)?.addEventListener("click", closeNav);
document.getElementById(`divFileID-${Each}`)?.addEventListener("click", function () {
LoadNote(Each);
});
const pubBtn = document.getElementById(`divSVGPublish-${Each}`);
if (pubBtn) {
pubBtn.addEventListener("click", function () {
Publish30023Note(Each);
});
}
}
};
const SetEditMode = async (EditMode) => {
EDIT_MODE = EditMode;
console.log("Edit Mode ", EDIT_MODE);
if (EditMode) {
taNote.className = "MainBox clsVisible";
iframeHTMLOut.className = "clsHidden";
tblTags.className = "clsVisible";
imgMain.className = "clsHidden";
document.getElementById("svgEditP1").setAttribute("d", "M8,2 L2,5 L8,8");
document.getElementById("svgEditP2").setAttribute("d", "M6,3 L7,5 L6,7");
imgMain.src = "";
} else {
taNote.className = "MainBox clsHidden";
iframeHTMLOut.className = "clsVisible";
tblTags.className = "clsHidden";
document.getElementById("svgEditP1").setAttribute("d", "M2,8 L2,6 L6,2 L8,4 L4,8 L2,8");
document.getElementById("svgEditP2").setAttribute("d", "");
if (txtImg.textContent.trim() === "") {
imgMain.className = "clsHidden";
} else {
imgMain.className = "clsVisible";
imgMain.src = txtImg.textContent.trim();
}
// Save when switching from edit to view mode if there are unsaved changes
if (taNote.value.trim() !== LastText) {
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Diagnostic view active - skipping implicit save');
LastText = taNote.value.trim();
} else {
console.log('[autosave] Switching to view mode with unsaved changes - triggering save');
await Publish30024Note();
LastText = taNote.value.trim();
}
}
}
};
const LoadNote = async (id) => {
console.log("Load Note", id);
closeNav();
if (!OBJ_NOTES[id]) {
console.error('Note not found:', id);
return;
}
const note = OBJ_NOTES[id];
await ensureNoteDiagnostics(note);
const displayTags = note.decryptedObjTags || note.objTags || {};
const displayContent = Number(note.kind) === 30024
? String(note.decryptedContent || note.content || '')
: String(note.content || '');
taNote.value = displayContent;
LastText = taNote.value.trim();
txtTitle.textContent = displayTags?.title || '';
divTitle.textContent = displayTags?.title || '';
txtSummary.textContent = displayTags?.summary || '';
txtTags.textContent = displayTags?.t?.join(' ') || '';
CURRENT_NOTE = id;
txtID.textContent = CURRENT_NOTE;
setDiagnosticUI(note);
// Parse markdown for preview
console.log('[note.html] LoadNote - window.marked exists:', !!window.marked);
console.log('[note.html] LoadNote - taNote.value length:', taNote.value?.length || 0);
console.log('[note.html] LoadNote - content preview:', taNote.value?.substring(0, 50));
if (window.marked && taNote.value && taNote.value.trim()) {
const parsed = marked.parse(taNote.value.trim());
iframeHTMLOut.srcdoc = parsed;
console.log('[note.html] Loaded note into iframe, parsed length:', parsed.length);
} else {
const reason = !window.marked ? 'marked library not loaded' :
(!taNote.value ? 'taNote.value is null/undefined' :
(taNote.value.trim() === '' ? 'content is empty after trim' : 'unknown'));
iframeHTMLOut.srcdoc = '<p><em>No content</em></p>';
console.log('[note.html] No content to display - reason:', reason);
}
if (displayTags?.image === "" || !displayTags?.image) {
txtImg.textContent = "";
imgMain.className = "clsHidden";
} else {
txtImg.textContent = displayTags.image;
imgMain.className = "clsVisible";
}
SetEditMode(false);
};
const DeleteNote = async (NoteD) => {
console.log("Delete Note", NoteD);
// Create a deletion event (kind 5)
const deleteEvent = {
kind: 5,
content: "",
tags: [["e", OBJ_NOTES[NoteD].id]],
created_at: Math.floor(Date.now() / 1000)
};
try {
await publishEvent(deleteEvent);
console.log("Delete event published");
delete OBJ_NOTES[NoteD];
closeNav();
} catch (error) {
console.error("Failed to delete note:", error);
}
};
const Publish30023Note = async (NoteD) => {
console.log("Publish Note", NoteD);
if (window.confirm("Are you sure you want to publish this note? You can't take it back.")) {
console.log("Publish");
} else {
return;
}
let Now = Math.floor(Date.now() / 1000);
// Clone and modify for publishing
let objOut = JSON.parse(JSON.stringify(OBJ_NOTES[NoteD]));
objOut.kind = 30023;
objOut.created_at = Now;
for (let Tag of objOut.tags) {
if (Tag[0] == "d" || Tag[0] == "published_at") {
Tag[1] = `${Now}`;
}
}
try {
await publishEvent(objOut);
console.log("Published new note", Now);
CURRENT_NOTE = Now;
DeleteNote(NoteD);
} catch (error) {
console.error("Failed to publish:", error);
}
};
const Publish30024Note = async () => {
console.log("CurrentNote", CURRENT_NOTE);
let Now = Math.floor(Date.now() / 1000);
let kind = 30024;
// For a new note
if (CURRENT_NOTE == "") {
CURRENT_NOTE = Now;
txtID.textContent = CURRENT_NOTE;
}
let arrTags = [];
arrTags.push(["d", `${CURRENT_NOTE}`]);
arrTags.push(["published_at", `${CURRENT_NOTE}`]);
arrTags.push(["title", txtTitle.textContent.trim()]);
arrTags.push(["image", txtImg.textContent.trim()]);
arrTags.push(["summary", txtSummary.textContent.trim()]);
// Process hashtags
let tagText = txtTags.textContent.replaceAll(",", " ").replaceAll("#", "");
let arrHashtags = tagText.split(" ").filter(t => t.trim());
for (let Each of arrHashtags) {
arrTags.push(["t", Each]);
}
let objEvent = {
created_at: Now,
kind: kind,
tags: arrTags,
content: taNote.value.trim()
};
try {
await publishEvent(objEvent);
console.log("Note published");
LastText = taNote.value.trim();
} catch (error) {
console.error("Failed to publish note:", error);
}
};
const UpdateFooter = async () => {
try {
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Note-specific footer info
if (taNote.value.trim() != LastText) {
divFooterCenter.textContent = "UNSAVED";
} else {
divFooterCenter.textContent = "Saved";
}
let letters = taNote.value.length;
let words = taNote.value.split(/\s+/).filter(w => w).length;
let paragraphs = taNote.value.split("\n").filter(p => p.trim()).length;
let row = taNote.value.substr(0, taNote.selectionStart).split("\n").length;
divFooterRight.textContent = `C:${letters} W:${words} P:${paragraphs} R:${row}`;
// divFooterLeft is populated by updateFooterRelayStatus() with relay visualization
} catch (error) {
console.error('[note.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================ */
const Logout = async () => {
console.log("[note.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("[note.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
INITIALIZATION
================================================================ */
(async function main() {
console.log("[note.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
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[note.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter(); // Initial update before interval starts
// Listen for relay activity
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
setRelayActivityState(relayUrl, activity);
});
// Subscribe to notes
const notesSub = subscribe(
{ kinds: [30023, 30024], authors: [currentPubkey] },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
// Listen for incoming note events
window.addEventListener('ndkEvent', async (event) => {
const evt = event.detail;
if (evt.kind === 30023 || evt.kind === 30024) {
console.log("[note.html] Received note:", evt);
// Parse tags into objTags
let objTags = {};
for (let T of evt.tags) {
if (T[0] == "t") {
if (objTags["t"]) {
objTags["t"].push(T[1]);
} else {
objTags[T[0]] = [T[1]];
}
} else {
objTags[T[0]] = T[1];
}
}
const dTag = objTags.d || evt.created_at;
OBJ_NOTES[dTag] = {
...evt,
objTags: objTags,
encrypted: evt.kind === 30024
};
if (evt.kind === 30024) {
await ensureNoteDiagnostics(OBJ_NOTES[dTag]);
}
if (isNavOpen) {
LoadSidenav();
}
}
});
// Event listeners for note editing
document.getElementById('divSvgCheck').addEventListener('click', Publish30024Note);
document.getElementById('divSvgEdit').addEventListener('click', () => SetEditMode(!EDIT_MODE));
document.getElementById('divFullScreen').addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
});
document.getElementById('divNewNote').addEventListener('click', () => {
CURRENT_NOTE = "";
txtID.textContent = "";
taNote.value = "";
LastText = "";
txtTitle.textContent = "";
divTitle.textContent = "";
txtImg.textContent = "";
txtSummary.textContent = "";
txtTags.textContent = "";
divHeaderText.textContent = "TITLE";
setDiagnosticUI(null);
SetEditMode(true);
closeNav();
});
txtImg.addEventListener("input", function () {
imgMain.src = txtImg.textContent.trim();
});
// Save button
document.getElementById('btnSaveNote').addEventListener('click', async () => {
console.log("[note.html] Save button clicked");
await Publish30024Note();
LastText = taNote.value.trim();
// Update markdown preview
if (window.marked && taNote.value.trim()) {
iframeHTMLOut.srcdoc = marked.parse(taNote.value.trim());
}
});
// Track edits on all editable fields for autosave timing
const editableFields = [taNote, txtTitle, txtImg, txtSummary, txtTags];
editableFields.forEach(field => {
if (field) {
field.addEventListener("input", function () {
NUM_LAST_EDIT_TIME = Math.floor(Date.now() / 1000);
console.log('[autosave] Edit detected, timer reset');
});
}
});
// Start update loop
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
// Autosave interval
setInterval(async () => {
iframeHTMLOut.height = iframeHTMLOut.contentWindow?.document?.body?.scrollHeight + 100 || 500;
// Get current autosave setting
const autoSaveSetting = parseInt(txtAutosave?.textContent?.trim() || "10", 10);
// console.log('[autosave] Setting:', autoSaveSetting, 'seconds');
// Skip if autosave disabled (0 or negative)
if (autoSaveSetting <= 0) {
// console.log('[autosave] Skipping: autosave disabled (<=0)');
return;
}
// Skip if no changes
const currentText = taNote.value.trim();
const hasChanges = currentText !== LastText;
// console.log('[autosave] Has changes:', hasChanges, '| current length:', currentText.length, '| last length:', LastText.length);
if (!hasChanges) {
console.log('[autosave] Skipping: no changes');
return;
}
if (DIAGNOSTIC_ACTIVE) {
console.log('[autosave] Skipping: diagnostic mode active');
return;
}
// Skip if not enough time passed since last edit
const secondsSinceEdit = Math.floor(Date.now() / 1000) - NUM_LAST_EDIT_TIME;
// console.log('[autosave] Seconds since edit:', secondsSinceEdit, '| required:', autoSaveSetting);
if (secondsSinceEdit < autoSaveSetting) {
// console.log('[autosave] Skipping: not enough time passed');
return;
}
// Skip if default title still set
const titleText = txtTitle.textContent.trim();
// console.log('[autosave] Title text:', titleText);
if (titleText === "" || titleText === "TITLE") {
// console.log('[autosave] Skipping: no title set');
return;
}
// Skip if title is being edited
const isEditingTitle = document.activeElement === divHeaderText;
// console.log('[autosave] Is editing title:', isEditingTitle);
if (isEditingTitle) {
// console.log('[autosave] Skipping: title is being edited');
return;
}
// console.log("[autosave] ✓ All checks passed - Autosaving after", secondsSinceEdit, "seconds");
// Parse markdown
if (window.marked && taNote.value.trim()) {
iframeHTMLOut.srcdoc = marked.parse(taNote.value.trim());
}
await Publish30024Note();
LastText = taNote.value.trim();
// console.log('[autosave] ✓ Autosave complete, LastText updated to length:', LastText.length);
}, 1000);
// Initial state
SetEditMode(true);
console.log('[note.html] Initialization complete');
} catch (error) {
console.error('[note.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>