1970 lines
72 KiB
HTML
1970 lines
72 KiB
HTML
<!DOCTYPE html>
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<html lang="en" dir="ltr">
|
||
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<title>Strudel</title>
|
||
|
||
<link rel="stylesheet" href="./css/client.css" />
|
||
<link rel="stylesheet" href="./css/strudel.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>
|
||
|
||
<!-- Strudel REPL Library (locally built from source) -->
|
||
<script type="module" src="./strudel-repl/index.js"></script>
|
||
</head>
|
||
|
||
<body>
|
||
<!-- ================================================================
|
||
HAMBURGER BUTTON (Fixed, separate from header)
|
||
================================================================
|
||
The hamburger button is a fixed element outside the header
|
||
to ensure it stays visible above the sidenav (z-index: 10 > 3).
|
||
================================================================ -->
|
||
<div id="divSvgHam" class="divHeaderButtons">
|
||
<!-- HamburgerMorphing will be injected here -->
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
HEADER
|
||
================================================================
|
||
Standard header with title (center).
|
||
================================================================ -->
|
||
<div id="divHeader">
|
||
<div id="divHeaderFlexLeft">
|
||
<!-- Hamburger is now separate fixed element -->
|
||
</div>
|
||
|
||
<div id="divHeaderFlexCenter">
|
||
<div id="divHeaderText" class="divHeaderText">Strudel</div>
|
||
</div>
|
||
|
||
<div id="divHeaderFlexRight">
|
||
<!-- No button in header right - logout is in sidenav footer -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
BODY
|
||
================================================================
|
||
Main content area. Add your page-specific content here.
|
||
================================================================ -->
|
||
<div id="divBody">
|
||
<!-- Strudel Interface -->
|
||
<div class="strudel-container">
|
||
<!-- Control Panel -->
|
||
<div class="strudel-controls">
|
||
<button id="strudelToggleButton" class="strudel-button">Play</button>
|
||
<button id="strudelReloadButton" class="strudel-button">Reload</button>
|
||
</div>
|
||
|
||
<!-- Strudel Editor Component -->
|
||
<div class="strudel-editor-section">
|
||
<div class="strudel-editor-wrapper">
|
||
<strudel-editor id="strudelEditor">
|
||
<!--
|
||
// Welcome to Strudel!
|
||
// Press Play or Ctrl+Enter to start
|
||
|
||
note("<c a f e>(3,8)")
|
||
.sound("sawtooth")
|
||
.room(0.5)
|
||
.lpf(2000)
|
||
.scope()
|
||
.punchcard()
|
||
-->
|
||
</strudel-editor>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- AI Chat Panel -->
|
||
<div id="strudelAIPanel" class="strudel-ai-panel">
|
||
<div id="strudelAIHistory" class="strudel-ai-history"></div>
|
||
<div id="strudelAIInput" class="strudel-ai-input">
|
||
<textarea id="strudelAIPrompt" placeholder="Tell the AI what sounds you want... (Enter to send, Ctrl+Enter for new line)" rows="3"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error Display -->
|
||
<div id="strudelError" class="strudel-error">
|
||
<div class="strudel-error-header">
|
||
<span class="strudel-error-title">⚠️ Error</span>
|
||
<button class="strudel-error-close" onclick="document.getElementById('strudelError').classList.remove('show')">×</button>
|
||
</div>
|
||
<div id="strudelErrorMessage" class="strudel-error-message"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
FOOTER
|
||
================================================================
|
||
Three-section footer layout:
|
||
- Left: Relay status animations (HamburgerMorphing instances)
|
||
- Center: General status information
|
||
- Right: Additional information
|
||
================================================================ -->
|
||
<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
|
||
================================================================
|
||
Slide-out navigation panel. Opens from left when hamburger clicked.
|
||
Uses flexbox layout to pin version bar to bottom.
|
||
Includes a version bar footer with theme toggle and logout buttons.
|
||
================================================================ -->
|
||
<div id="divSideNav">
|
||
<div id="divSideNavHeader">
|
||
<!-- No close button - use main hamburger to close -->
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
SONGS SECTION
|
||
================================================================ -->
|
||
<div id="divSongsSection">
|
||
<div id="divSongsSectionTitle">Songs</div>
|
||
<div id="divSongsSaveRow">
|
||
<input type="text" id="songsSaveTitle" placeholder="Title..." />
|
||
<button id="songsSaveButton" type="button">Save New</button>
|
||
<button id="songsUpdateButton" type="button" style="display:none;">Update</button>
|
||
</div>
|
||
<div id="divSongsCurrent" style="display:none; font-size:11px; margin-bottom:6px; color:var(--accent-color);"></div>
|
||
<div id="divSongsList">
|
||
<span style="opacity:0.5;font-size:11px;">Login to see your songs</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ================================================================
|
||
INSTRUMENTS SECTION
|
||
================================================================ -->
|
||
<div id="divInstrumentsSection">
|
||
<div id="divInstrumentsSectionTitle">Instruments</div>
|
||
<div id="divInstrumentsSaveRow">
|
||
<input type="text" id="instrumentsSaveTitle" placeholder="Title..." />
|
||
<button id="instrumentsSaveButton" type="button" disabled>Save New</button>
|
||
<button id="instrumentsUpdateButton" type="button" style="display:none;">Update</button>
|
||
</div>
|
||
<div id="divInstrumentsCurrent" style="display:none; font-size:11px; margin-bottom:6px; color:var(--accent-color);"></div>
|
||
<div id="divInstrumentsHint">
|
||
<span style="opacity:0.5;font-size:10px;">Select text in editor to save as instrument</span>
|
||
</div>
|
||
<div id="divInstrumentsList">
|
||
<span style="opacity:0.5;font-size:11px;">Login to see your instruments</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divMIDISection">
|
||
<div id="divMIDISectionTitle">MIDI</div>
|
||
<div id="divMIDISettings">
|
||
<div id="divMIDIDeviceList">
|
||
<span style="opacity:0.5;font-size:11px;">No MIDI devices detected</span>
|
||
</div>
|
||
<div id="divMIDILearnLog"></div>
|
||
<div id="divMIDIControlMap"></div>
|
||
<button id="midiScanButton" type="button">Scan Devices</button>
|
||
<button id="midiLearnButton" type="button" disabled>Learn Controls</button>
|
||
<button id="midiClearMapButton" type="button" disabled>Clear Map</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="divAISection">
|
||
<div id="divAISectionTitle">
|
||
AI
|
||
</div>
|
||
<div id="divAISettings">
|
||
<input type="text" id="aiApiUrl" placeholder="API URL (e.g. https://api.ppq.ai)" />
|
||
<input type="password" id="aiApiKey" placeholder="API Key" />
|
||
<select id="aiModel">
|
||
<option value="">Select a model...</option>
|
||
</select>
|
||
<button id="aiRefreshModels" type="button">Refresh Models</button>
|
||
<button id="aiSaveSettings">Save</button>
|
||
<button id="aiClearChat">Clear Chat</button>
|
||
</div>
|
||
</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
|
||
================================================================
|
||
These scripts must be loaded in this order:
|
||
1. nostr.bundle.js - Nostr tools library
|
||
2. nostr-lite.js - Authentication modal (nostr-login-lite)
|
||
================================================================ -->
|
||
<script src="./nostr.bundle.js"></script>
|
||
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
||
|
||
<script type="module">
|
||
/* ================================================================
|
||
IMPORTS
|
||
================================================================
|
||
Import shared NDK functionality from init-ndk.mjs:
|
||
- initNDKPage() - Initialize authentication and worker
|
||
- getPubkey() - Get current user's pubkey
|
||
- subscribe() - Create NDK subscriptions
|
||
- publishEvent() - Publish events via NDK
|
||
- disconnect() - Disconnect from worker
|
||
- getRelayData() - Get relay connection data
|
||
- getRelayStats() - Get relay activity statistics
|
||
|
||
Import HamburgerMorphing for animated icons
|
||
================================================================ */
|
||
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';
|
||
import { getAISettings, saveAISettings, clearConversationHistory, sendMessage, fetchAvailableModels } from './js/strudel-ai.mjs';
|
||
import { initMidi, getConnectedDevices, startLearnMode, stopLearnMode, loadMidiMaps, saveMidiMaps, clearMidiMaps, buildMidiContext, getAllMaps, isMidiAvailable } from './js/strudel-midi.mjs';
|
||
import {
|
||
KINDS,
|
||
saveSong,
|
||
saveInstrument,
|
||
deleteItem,
|
||
subscribeToMyItems,
|
||
parseStrudelEvent,
|
||
getEditorSelection,
|
||
hasEditorSelection,
|
||
insertAtCursor,
|
||
replaceSelection,
|
||
replaceEditorContent,
|
||
getEditorContent,
|
||
formatTimestamp
|
||
} from './js/strudel-nostr.mjs';
|
||
|
||
// Version will be loaded asynchronously
|
||
const versionInfo = await getVersion();
|
||
const VERSION = versionInfo.VERSION;
|
||
console.log(`[template.html ${VERSION}] Loading...`);
|
||
|
||
/* ================================================================
|
||
GLOBAL VARIABLES
|
||
================================================================
|
||
Track state for hamburger menu, relay status, and theme.
|
||
================================================================ */
|
||
let updateIntervalId = null;
|
||
let currentPubkey = null;
|
||
let lastErrorMessage = null;
|
||
|
||
// Hamburger menu
|
||
let hamburgerInstance = null;
|
||
let isNavOpen = false;
|
||
|
||
// Version bar buttons
|
||
let logoutHamburger = null;
|
||
let themeToggleHamburger = null;
|
||
let isDarkMode = false;
|
||
|
||
/* ================================================================
|
||
DOM VARIABLES
|
||
================================================================
|
||
Cache DOM element references for better performance.
|
||
================================================================ */
|
||
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");
|
||
|
||
/* ================================================================
|
||
HAMBURGER MENU
|
||
================================================================
|
||
Initialize and control the animated 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
|
||
================================================================
|
||
Open/close sidenav with hamburger morphing animation.
|
||
================================================================ */
|
||
function openNav() {
|
||
divSideNav.style.zIndex = 3;
|
||
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
|
||
isNavOpen = true;
|
||
if (hamburgerInstance) {
|
||
hamburgerInstance.animateTo('arrow_left');
|
||
}
|
||
divSideNavBody.innerHTML = "";
|
||
|
||
// 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
|
||
================================================================
|
||
Update footer sections with relay status, pubkey, and other info.
|
||
Called periodically by update loop.
|
||
================================================================ */
|
||
const UpdateFooter = async () => {
|
||
|
||
try {
|
||
// Update relay status visuals in footer and sidenav
|
||
await updateFooterRelayStatus();
|
||
await updateSidenavRelaySection();
|
||
|
||
await updateBlossomSection();
|
||
// Display last error in center section if there is one
|
||
if (lastErrorMessage && divFooterCenter) {
|
||
divFooterCenter.innerHTML = `<span style="color: #ff4444; font-size: 12px; font-family: monospace;">${escapeHtml(lastErrorMessage)}</span>`;
|
||
} else {
|
||
divFooterCenter.innerHTML = '';
|
||
}
|
||
|
||
// Clear right section
|
||
divFooterRight.innerHTML = '';
|
||
} catch (error) {
|
||
console.error('[template.html] Error updating footer:', error);
|
||
}
|
||
};
|
||
|
||
/* ================================================================
|
||
LOGOUT
|
||
================================================================
|
||
Complete logout process:
|
||
1. Stop update loop
|
||
2. Disconnect from NDK worker
|
||
3. Logout from nostr-login-lite
|
||
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
|
||
5. Reload page
|
||
================================================================ */
|
||
const Logout = async () => {
|
||
console.log("[template.html] Starting logout process...");
|
||
|
||
// Stop the update loop
|
||
if (updateIntervalId) {
|
||
clearInterval(updateIntervalId);
|
||
updateIntervalId = null;
|
||
}
|
||
|
||
// Disconnect from worker
|
||
disconnect();
|
||
|
||
// Logout from nostr-login-lite
|
||
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
||
await window.NOSTR_LOGIN_LITE.logout();
|
||
}
|
||
|
||
// Clear all storage
|
||
localStorage.clear();
|
||
sessionStorage.clear();
|
||
|
||
// Clear IndexedDB
|
||
if (window.indexedDB) {
|
||
const databases = await window.indexedDB.databases();
|
||
for (const db of databases) {
|
||
if (db.name) {
|
||
window.indexedDB.deleteDatabase(db.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("[template.html] Logged out, reloading page");
|
||
location.reload(true);
|
||
};
|
||
|
||
/* ================================================================
|
||
EVENT LISTENERS
|
||
================================================================
|
||
Wire up UI interactions.
|
||
Main hamburger button click handler is set up in main() after initialization.
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
SUBSCRIPTION EXAMPLE
|
||
================================================================
|
||
Example of how to subscribe to Nostr events:
|
||
|
||
const sub = subscribe(
|
||
{ kinds: [1], authors: [pubkey], limit: 10 },
|
||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||
);
|
||
|
||
Cache usage options:
|
||
- 'CACHE_FIRST' - Check cache first, then relays
|
||
- 'ONLY_RELAY' - Only query relays
|
||
- 'ONLY_CACHE' - Only query cache
|
||
- 'PARALLEL' - Query cache and relays simultaneously
|
||
|
||
Listen for events via window events:
|
||
window.addEventListener('ndkEvent', (event) => {
|
||
const evt = event.detail;
|
||
console.log('Received event:', evt);
|
||
});
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
PUBLISH EXAMPLE
|
||
================================================================
|
||
Example of how to publish a Nostr event:
|
||
|
||
const event = {
|
||
created_at: Math.floor(Date.now() / 1000),
|
||
kind: 1,
|
||
tags: [],
|
||
content: "Hello, Nostr!"
|
||
};
|
||
|
||
try {
|
||
const result = await publishEvent(event);
|
||
console.log("✅ Published to:", result.relayResults.successful);
|
||
console.log("❌ Failed:", result.relayResults.failed);
|
||
console.log("Total relays:", result.totalRelays);
|
||
} catch (error) {
|
||
console.error("Publish error:", error);
|
||
}
|
||
|
||
Note: Events are automatically signed by the NDK worker using
|
||
the message-based signer (which calls window.nostr.signEvent).
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
STRUDEL WITH <strudel-editor> COMPONENT
|
||
================================================================
|
||
Using the @strudel/repl package's web component for full
|
||
visualization support (scope, punchcard, pianoroll, etc.)
|
||
================================================================ */
|
||
|
||
// Strudel state
|
||
let strudelEditorElement = null;
|
||
let isPlaying = false;
|
||
|
||
/* ================================================================
|
||
STRUDEL FUNCTIONS
|
||
================================================================ */
|
||
|
||
function showError(message) {
|
||
const errorDiv = document.getElementById('strudelError');
|
||
const errorMessage = document.getElementById('strudelErrorMessage');
|
||
errorMessage.textContent = message;
|
||
errorDiv.classList.add('show');
|
||
|
||
// Auto-hide after 10 seconds
|
||
setTimeout(() => {
|
||
errorDiv.classList.remove('show');
|
||
}, 10000);
|
||
}
|
||
|
||
// Track error line decorations to clear them later
|
||
let errorLineDecorations = [];
|
||
|
||
function highlightErrorLine(lineNumber) {
|
||
console.log('[Strudel] Attempting to highlight error on line:', lineNumber);
|
||
|
||
if (!strudelEditorElement?.editor?.editor) {
|
||
console.log('[Strudel] No editor available');
|
||
return;
|
||
}
|
||
|
||
const cmView = strudelEditorElement.editor.editor;
|
||
if (!cmView?.dispatch) {
|
||
console.log('[Strudel] No dispatch available');
|
||
return;
|
||
}
|
||
|
||
// Clear any existing error highlights first
|
||
clearErrorHighlights();
|
||
|
||
// Find the line element - CodeMirror uses .cm-line but nth-child doesn't map to line numbers
|
||
// Instead, we need to find all cm-line elements and get the one at the index
|
||
const allLines = cmView.dom?.querySelectorAll?.('.cm-line');
|
||
console.log('[Strudel] Total lines found:', allLines?.length);
|
||
|
||
if (allLines && allLines.length >= lineNumber && lineNumber > 0) {
|
||
const lineEl = allLines[lineNumber - 1]; // 0-indexed
|
||
console.log('[Strudel] Highlighting line element:', lineEl);
|
||
|
||
// Add class and inline style for maximum visibility
|
||
lineEl.classList.add('strudel-error-line');
|
||
lineEl.style.backgroundColor = 'var(--accent-color)';
|
||
lineEl.style.opacity = '0.3';
|
||
|
||
errorLineDecorations.push(lineEl);
|
||
} else {
|
||
console.log('[Strudel] Line not found or out of range');
|
||
}
|
||
}
|
||
|
||
function clearErrorHighlights() {
|
||
// Remove error class and inline styles from all previously highlighted lines
|
||
errorLineDecorations.forEach(el => {
|
||
el.classList.remove('strudel-error-line');
|
||
el.style.backgroundColor = '';
|
||
el.style.opacity = '';
|
||
});
|
||
errorLineDecorations = [];
|
||
}
|
||
|
||
function updateStatus(status) {
|
||
const statusEl = document.getElementById('strudelStatus');
|
||
if (statusEl) {
|
||
statusEl.className = 'strudel-status-inline ' + status;
|
||
statusEl.textContent = status.charAt(0).toUpperCase() + status.slice(1);
|
||
}
|
||
}
|
||
|
||
function updateButtonStates(playing) {
|
||
const toggleBtn = document.getElementById('strudelToggleButton');
|
||
const reloadBtn = document.getElementById('strudelReloadButton');
|
||
|
||
if (toggleBtn) {
|
||
toggleBtn.textContent = playing ? 'Stop' : 'Play';
|
||
}
|
||
reloadBtn.disabled = false;
|
||
}
|
||
|
||
function handleToggle() {
|
||
if (isPlaying) {
|
||
handleStop();
|
||
} else {
|
||
handlePlay();
|
||
}
|
||
}
|
||
|
||
/* ================================================================
|
||
STRUDEL THEME SYNC
|
||
================================================================
|
||
Syncs Strudel's theme system with app's day/night mode.
|
||
This updates both CSS variables (editor colors) and the JS
|
||
theme object (visualization colors like .scope() waveforms).
|
||
================================================================ */
|
||
function syncStrudelTheme(isDark) {
|
||
const editor = strudelEditorElement?.editor;
|
||
if (!editor) {
|
||
console.log('[Strudel] Editor not ready, skipping theme sync');
|
||
return;
|
||
}
|
||
|
||
// Always use clientNdk theme which uses our CSS variables
|
||
const themeName = 'clientNdk';
|
||
console.log(`[Strudel] Syncing theme to: ${themeName} (isDark: ${isDark})`);
|
||
|
||
try {
|
||
// Use changeSetting() instead of updateSettings() to avoid resetting other settings
|
||
// changeSetting() only changes the theme and calls activateTheme() which sets
|
||
// both CSS variables and the JS theme object for visualization colors
|
||
editor.changeSetting('theme', themeName);
|
||
console.log('[Strudel] Theme synced successfully');
|
||
} catch (error) {
|
||
console.error('[Strudel] Error syncing theme:', error);
|
||
}
|
||
}
|
||
|
||
/* ================================================================
|
||
AI ASSISTANT FUNCTIONS
|
||
================================================================ */
|
||
|
||
// AI DOM elements
|
||
let aiPromptInput = null;
|
||
let aiHistoryDiv = null;
|
||
let aiApiUrlInput = null;
|
||
let aiApiKeyInput = null;
|
||
let aiModelInput = null;
|
||
let aiRefreshModelsButton = null;
|
||
let aiSaveButton = null;
|
||
let aiClearChatButton = null;
|
||
|
||
function initializeAISettings() {
|
||
// Get DOM elements
|
||
aiPromptInput = document.getElementById('strudelAIPrompt');
|
||
aiHistoryDiv = document.getElementById('strudelAIHistory');
|
||
aiApiUrlInput = document.getElementById('aiApiUrl');
|
||
aiApiKeyInput = document.getElementById('aiApiKey');
|
||
aiModelInput = document.getElementById('aiModel');
|
||
aiRefreshModelsButton = document.getElementById('aiRefreshModels');
|
||
aiSaveButton = document.getElementById('aiSaveSettings');
|
||
aiClearChatButton = document.getElementById('aiClearChat');
|
||
|
||
// Load saved settings
|
||
const settings = getAISettings();
|
||
if (aiApiUrlInput) aiApiUrlInput.value = settings.url;
|
||
if (aiApiKeyInput) aiApiKeyInput.value = settings.key;
|
||
if (aiModelInput) aiModelInput.value = settings.model;
|
||
|
||
// Wire up event listeners - Enter sends, Ctrl+Enter adds newline
|
||
if (aiPromptInput) {
|
||
aiPromptInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.ctrlKey && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleAISend();
|
||
}
|
||
// Ctrl+Enter or Shift+Enter = newline (default textarea behavior)
|
||
});
|
||
}
|
||
if (aiRefreshModelsButton) {
|
||
aiRefreshModelsButton.addEventListener('click', handleAIRefreshModels);
|
||
}
|
||
if (aiSaveButton) {
|
||
aiSaveButton.addEventListener('click', handleAISaveSettings);
|
||
}
|
||
if (aiClearChatButton) {
|
||
aiClearChatButton.addEventListener('click', handleAIClearChat);
|
||
}
|
||
|
||
// Auto-fetch models on startup (only needs URL, no API key required)
|
||
handleAIRefreshModels();
|
||
|
||
// Fetch models when URL changes
|
||
if (aiApiUrlInput) {
|
||
aiApiUrlInput.addEventListener('change', handleAIRefreshModels);
|
||
}
|
||
|
||
console.log('[Strudel] AI settings initialized');
|
||
}
|
||
|
||
async function handleAIRefreshModels() {
|
||
if (!aiApiUrlInput || !aiModelInput) return;
|
||
|
||
const url = aiApiUrlInput.value;
|
||
|
||
if (!url) {
|
||
console.log('[Strudel] Cannot fetch models: missing URL');
|
||
return;
|
||
}
|
||
|
||
if (aiRefreshModelsButton) {
|
||
aiRefreshModelsButton.disabled = true;
|
||
aiRefreshModelsButton.textContent = 'Loading...';
|
||
}
|
||
|
||
try {
|
||
const models = await fetchAvailableModels(url);
|
||
|
||
// Clear current options except the first placeholder
|
||
while (aiModelInput.options.length > 1) {
|
||
aiModelInput.remove(1);
|
||
}
|
||
|
||
// Add models
|
||
models.forEach(model => {
|
||
const option = document.createElement('option');
|
||
option.value = model.id;
|
||
option.textContent = model.id;
|
||
aiModelInput.appendChild(option);
|
||
});
|
||
|
||
// Restore selected value if it exists in the list
|
||
const settings = getAISettings();
|
||
if (settings.model && aiModelInput.querySelector(`option[value="${settings.model}"]`)) {
|
||
aiModelInput.value = settings.model;
|
||
}
|
||
|
||
console.log(`[Strudel] Loaded ${models.length} models`);
|
||
} catch (error) {
|
||
console.error('[Strudel] Error loading models:', error);
|
||
} finally {
|
||
aiRefreshModelsButton.disabled = false;
|
||
aiRefreshModelsButton.textContent = 'Refresh Models';
|
||
}
|
||
}
|
||
|
||
function handleAISaveSettings() {
|
||
const url = aiApiUrlInput?.value || '';
|
||
const key = aiApiKeyInput?.value || '';
|
||
const model = aiModelInput?.value || '';
|
||
saveAISettings(url, key, model);
|
||
console.log('[Strudel] AI settings saved');
|
||
alert('AI settings saved!');
|
||
}
|
||
|
||
function handleAIClearChat() {
|
||
clearConversationHistory();
|
||
if (aiHistoryDiv) {
|
||
aiHistoryDiv.innerHTML = '';
|
||
}
|
||
console.log('[Strudel] Chat history cleared');
|
||
}
|
||
|
||
/* ================================================================
|
||
MIDI SECTION
|
||
================================================================ */
|
||
let midiScanButton = null;
|
||
let midiLearnButton = null;
|
||
let midiClearMapButton = null;
|
||
let midiDeviceListDiv = null;
|
||
let midiControlMapDiv = null;
|
||
let midiLearnLogDiv = null;
|
||
let midiLearnActive = false;
|
||
|
||
/* ================================================================
|
||
SONGS/INSTRUMENTS SECTION VARIABLES
|
||
================================================================ */
|
||
let songsSaveTitle = null;
|
||
let songsSaveButton = null;
|
||
let songsListDiv = null;
|
||
let instrumentsSaveTitle = null;
|
||
let instrumentsSaveButton = null;
|
||
let instrumentsHintDiv = null;
|
||
let instrumentsListDiv = null;
|
||
let songsUpdateButton = null;
|
||
let instrumentsUpdateButton = null;
|
||
let editorInstance = null;
|
||
let mySongs = new Map();
|
||
let myInstruments = new Map();
|
||
let songsSubscription = null;
|
||
let instrumentsSubscription = null;
|
||
let currentSongDTag = null;
|
||
let currentInstrumentDTag = null;
|
||
let currentSongOriginalContent = null;
|
||
let currentInstrumentOriginalContent = null;
|
||
|
||
function initializeMIDISection() {
|
||
midiScanButton = document.getElementById('midiScanButton');
|
||
midiLearnButton = document.getElementById('midiLearnButton');
|
||
midiClearMapButton = document.getElementById('midiClearMapButton');
|
||
midiDeviceListDiv = document.getElementById('divMIDIDeviceList');
|
||
midiControlMapDiv = document.getElementById('divMIDIControlMap');
|
||
midiLearnLogDiv = document.getElementById('divMIDILearnLog');
|
||
|
||
loadMidiMaps();
|
||
|
||
if (midiScanButton) midiScanButton.addEventListener('click', handleMidiScan);
|
||
if (midiLearnButton) midiLearnButton.addEventListener('click', handleMidiLearnToggle);
|
||
if (midiClearMapButton) midiClearMapButton.addEventListener('click', handleMidiClearMap);
|
||
|
||
// Auto-scan on load
|
||
handleMidiScan();
|
||
}
|
||
|
||
async function handleMidiScan() {
|
||
if (midiScanButton) midiScanButton.disabled = true;
|
||
if (midiDeviceListDiv) midiDeviceListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">Scanning...</span>';
|
||
|
||
try {
|
||
const devices = await initMidi();
|
||
renderMidiDevices(devices);
|
||
renderMidiControlMap();
|
||
if (midiLearnButton) midiLearnButton.disabled = devices.inputs.length === 0;
|
||
if (midiClearMapButton) midiClearMapButton.disabled = false;
|
||
} catch (err) {
|
||
if (midiDeviceListDiv) {
|
||
midiDeviceListDiv.innerHTML = `<span style="color:var(--accent-color);font-size:11px;">⚠ ${err.message}</span>`;
|
||
}
|
||
console.warn('[Strudel MIDI]', err.message);
|
||
} finally {
|
||
if (midiScanButton) midiScanButton.disabled = false;
|
||
}
|
||
}
|
||
|
||
function renderMidiDevices(devices) {
|
||
if (!midiDeviceListDiv) return;
|
||
if (devices.inputs.length === 0 && devices.outputs.length === 0) {
|
||
midiDeviceListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">No MIDI devices found</span>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
if (devices.inputs.length > 0) {
|
||
html += '<div class="midi-device-group"><div class="midi-device-label">Inputs:</div>';
|
||
for (const d of devices.inputs) {
|
||
html += `<div class="midi-device-name"><span class="midi-device-dot"></span>${d.name}</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
if (devices.outputs.length > 0) {
|
||
html += '<div class="midi-device-group"><div class="midi-device-label">Outputs:</div>';
|
||
for (const d of devices.outputs) {
|
||
html += `<div class="midi-device-name"><span class="midi-device-dot" style="background:var(--muted-color)"></span>${d.name}</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
midiDeviceListDiv.innerHTML = html;
|
||
}
|
||
|
||
function renderMidiControlMap() {
|
||
if (!midiControlMapDiv) return;
|
||
const maps = getAllMaps();
|
||
const entries = Object.entries(maps);
|
||
if (entries.length === 0) {
|
||
midiControlMapDiv.classList.remove('has-controls');
|
||
midiControlMapDiv.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
for (const [deviceName, controls] of entries) {
|
||
if (controls.length === 0) continue;
|
||
html += `<div style="font-weight:bold;font-size:10px;margin-bottom:2px;">${deviceName}</div>`;
|
||
for (const ctrl of controls) {
|
||
const val = ctrl.type === 'cc'
|
||
? `CC#${ctrl.cc} ch${ctrl.channel}`
|
||
: `Note ${ctrl.note} ch${ctrl.channel}`;
|
||
html += `<div class="midi-control-entry">
|
||
<span class="midi-control-label">${ctrl.label}</span>
|
||
<span class="midi-control-value">${val}</span>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
if (html) {
|
||
midiControlMapDiv.innerHTML = html;
|
||
midiControlMapDiv.classList.add('has-controls');
|
||
} else {
|
||
midiControlMapDiv.classList.remove('has-controls');
|
||
midiControlMapDiv.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function handleMidiLearnToggle() {
|
||
if (!midiLearnActive) {
|
||
// Start learn mode
|
||
midiLearnActive = true;
|
||
if (midiLearnButton) {
|
||
midiLearnButton.textContent = 'Stop Learning';
|
||
midiLearnButton.classList.add('midi-learn-active');
|
||
}
|
||
if (midiLearnLogDiv) midiLearnLogDiv.textContent = 'Move a control...';
|
||
|
||
startLearnMode((ctrl) => {
|
||
if (midiLearnLogDiv) {
|
||
const val = ctrl.type === 'cc' ? `CC#${ctrl.cc}` : `Note ${ctrl.note}`;
|
||
midiLearnLogDiv.textContent = `Learned: ${val} ch${ctrl.channel} (${ctrl.deviceName})`;
|
||
}
|
||
renderMidiControlMap();
|
||
});
|
||
} else {
|
||
// Stop learn mode
|
||
midiLearnActive = false;
|
||
stopLearnMode();
|
||
if (midiLearnButton) {
|
||
midiLearnButton.textContent = 'Learn Controls';
|
||
midiLearnButton.classList.remove('midi-learn-active');
|
||
}
|
||
if (midiLearnLogDiv) midiLearnLogDiv.textContent = '';
|
||
renderMidiControlMap();
|
||
}
|
||
}
|
||
|
||
function handleMidiClearMap() {
|
||
clearMidiMaps();
|
||
renderMidiControlMap();
|
||
if (midiLearnLogDiv) midiLearnLogDiv.textContent = 'Map cleared.';
|
||
setTimeout(() => { if (midiLearnLogDiv) midiLearnLogDiv.textContent = ''; }, 2000);
|
||
}
|
||
|
||
/* ================================================================
|
||
SONGS/INSTRUMENTS SECTION FUNCTIONS
|
||
================================================================ */
|
||
function initializeSongsInstrumentsSection() {
|
||
// Get DOM elements
|
||
songsSaveTitle = document.getElementById('songsSaveTitle');
|
||
songsSaveButton = document.getElementById('songsSaveButton');
|
||
songsUpdateButton = document.getElementById('songsUpdateButton');
|
||
songsListDiv = document.getElementById('divSongsList');
|
||
instrumentsSaveTitle = document.getElementById('instrumentsSaveTitle');
|
||
instrumentsSaveButton = document.getElementById('instrumentsSaveButton');
|
||
instrumentsUpdateButton = document.getElementById('instrumentsUpdateButton');
|
||
instrumentsHintDiv = document.getElementById('divInstrumentsHint');
|
||
instrumentsListDiv = document.getElementById('divInstrumentsList');
|
||
|
||
// Add event listeners
|
||
if (songsSaveButton) {
|
||
songsSaveButton.addEventListener('click', handleSaveSong);
|
||
}
|
||
if (songsUpdateButton) {
|
||
songsUpdateButton.addEventListener('click', handleUpdateSong);
|
||
}
|
||
if (instrumentsSaveButton) {
|
||
instrumentsSaveButton.addEventListener('click', handleSaveInstrument);
|
||
}
|
||
if (instrumentsUpdateButton) {
|
||
instrumentsUpdateButton.addEventListener('click', handleUpdateInstrument);
|
||
}
|
||
if (songsSaveTitle) {
|
||
songsSaveTitle.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') handleSaveSong();
|
||
});
|
||
}
|
||
if (instrumentsSaveTitle) {
|
||
instrumentsSaveTitle.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') handleSaveInstrument();
|
||
});
|
||
}
|
||
|
||
// Set up editor selection tracking
|
||
setupEditorSelectionTracking();
|
||
|
||
// Load library if authenticated
|
||
if (currentPubkey) {
|
||
loadMyLibrary();
|
||
}
|
||
}
|
||
|
||
function setupEditorSelectionTracking() {
|
||
// The strudel-editor uses CodeMirror 6
|
||
// We need to poll for selection changes since CodeMirror doesn't have
|
||
// a simple 'selectionchange' event at the DOM level
|
||
setInterval(() => {
|
||
updateInstrumentSaveButtonState();
|
||
updateSaveButtonVisibility();
|
||
}, 200);
|
||
}
|
||
|
||
function updateSaveButtonVisibility() {
|
||
// Check if we have a current song loaded and if content has changed
|
||
if (currentSongDTag && currentSongOriginalContent && strudelEditorElement?.editor) {
|
||
const currentContent = strudelEditorElement.editor.code || '';
|
||
const hasChanged = currentContent !== currentSongOriginalContent;
|
||
|
||
if (hasChanged && songsSaveButton && songsUpdateButton) {
|
||
// Content changed - show Update, hide Save New
|
||
songsSaveButton.style.display = 'none';
|
||
songsUpdateButton.style.display = 'inline-block';
|
||
}
|
||
}
|
||
|
||
// Check if we have a current instrument loaded and if content has changed
|
||
if (currentInstrumentDTag && currentInstrumentOriginalContent && strudelEditorElement?.editor) {
|
||
const currentContent = strudelEditorElement.editor.code || '';
|
||
const hasChanged = currentContent !== currentInstrumentOriginalContent;
|
||
|
||
if (hasChanged && instrumentsSaveButton && instrumentsUpdateButton) {
|
||
// Content changed - show Update, hide Save New
|
||
instrumentsSaveButton.style.display = 'none';
|
||
instrumentsUpdateButton.style.display = 'inline-block';
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateInstrumentSaveButtonState() {
|
||
if (!strudelEditorElement || !instrumentsSaveButton) return;
|
||
|
||
// Try to access the editor's CodeMirror view
|
||
const editor = strudelEditorElement.editor;
|
||
if (!editor) {
|
||
console.log('[Strudel] updateInstrumentSaveButtonState: no editor');
|
||
return;
|
||
}
|
||
|
||
// The CodeMirror view is at editor.editor (StrudelMirror.editor = CodeMirror EditorView)
|
||
const cmView = editor.editor;
|
||
if (!cmView) {
|
||
console.log('[Strudel] updateInstrumentSaveButtonState: no cmView');
|
||
return;
|
||
}
|
||
|
||
const hasSelection = hasEditorSelection(cmView);
|
||
console.log('[Strudel] updateInstrumentSaveButtonState: hasSelection =', hasSelection);
|
||
|
||
instrumentsSaveButton.disabled = !hasSelection;
|
||
|
||
if (instrumentsHintDiv) {
|
||
instrumentsHintDiv.innerHTML = hasSelection
|
||
? '<span style="opacity:0.7;font-size:10px;color:var(--accent-color);">Ready to save selection</span>'
|
||
: '<span style="opacity:0.5;font-size:10px;">Select text in editor to save as instrument</span>';
|
||
}
|
||
}
|
||
|
||
async function handleSaveSong() {
|
||
if (!songsSaveTitle || !currentPubkey) return;
|
||
|
||
const title = songsSaveTitle.value.trim();
|
||
if (!title) {
|
||
alert('Please enter a title for your song');
|
||
return;
|
||
}
|
||
|
||
// Get current code from editor
|
||
const code = strudelEditorElement?.editor?.code || '';
|
||
if (!code.trim()) {
|
||
alert('Editor is empty');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
songsSaveButton.disabled = true;
|
||
songsSaveButton.textContent = 'Saving...';
|
||
|
||
await saveSong(title, code);
|
||
|
||
songsSaveTitle.value = '';
|
||
console.log('[Strudel] Song saved:', title);
|
||
} catch (err) {
|
||
console.error('[Strudel] Failed to save song:', err);
|
||
alert('Failed to save song: ' + err.message);
|
||
} finally {
|
||
songsSaveButton.disabled = false;
|
||
songsSaveButton.textContent = 'Save New';
|
||
}
|
||
}
|
||
|
||
async function handleUpdateSong() {
|
||
if (!songsSaveTitle || !currentPubkey || !currentSongDTag) return;
|
||
|
||
const title = songsSaveTitle.value.trim();
|
||
if (!title) {
|
||
alert('Please enter a title for your song');
|
||
return;
|
||
}
|
||
|
||
// Get current code from editor
|
||
const code = strudelEditorElement?.editor?.code || '';
|
||
if (!code.trim()) {
|
||
alert('Editor is empty');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
songsUpdateButton.disabled = true;
|
||
songsUpdateButton.textContent = 'Updating...';
|
||
|
||
// Pass the dTag to update the existing song
|
||
await saveSong(title, code, [], '', currentSongDTag);
|
||
|
||
console.log('[Strudel] Song updated:', title);
|
||
} catch (err) {
|
||
console.error('[Strudel] Failed to update song:', err);
|
||
alert('Failed to update song: ' + err.message);
|
||
} finally {
|
||
songsUpdateButton.disabled = false;
|
||
songsUpdateButton.textContent = 'Update';
|
||
}
|
||
}
|
||
|
||
async function handleSaveInstrument() {
|
||
if (!instrumentsSaveTitle || !currentPubkey) return;
|
||
|
||
const title = instrumentsSaveTitle.value.trim();
|
||
if (!title) {
|
||
alert('Please enter a title for your instrument');
|
||
return;
|
||
}
|
||
|
||
// Get selected text from editor
|
||
const editor = strudelEditorElement?.editor;
|
||
if (!editor || !editor.editor) {
|
||
alert('Editor not available');
|
||
return;
|
||
}
|
||
|
||
const selection = getEditorSelection(editor.editor);
|
||
if (!selection) {
|
||
alert('Please select some code to save as an instrument');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
instrumentsSaveButton.disabled = true;
|
||
instrumentsSaveButton.textContent = 'Saving...';
|
||
|
||
await saveInstrument(title, selection);
|
||
|
||
instrumentsSaveTitle.value = '';
|
||
console.log('[Strudel] Instrument saved:', title);
|
||
} catch (err) {
|
||
console.error('[Strudel] Failed to save instrument:', err);
|
||
alert('Failed to save instrument: ' + err.message);
|
||
} finally {
|
||
instrumentsSaveButton.disabled = true;
|
||
instrumentsSaveButton.textContent = 'Save New';
|
||
updateInstrumentSaveButtonState();
|
||
}
|
||
}
|
||
|
||
async function handleUpdateInstrument() {
|
||
if (!instrumentsSaveTitle || !currentPubkey || !currentInstrumentDTag) return;
|
||
|
||
const title = instrumentsSaveTitle.value.trim();
|
||
if (!title) {
|
||
alert('Please enter a title for your instrument');
|
||
return;
|
||
}
|
||
|
||
// Get selected text from editor
|
||
const editor = strudelEditorElement?.editor;
|
||
if (!editor || !editor.editor) {
|
||
alert('Editor not available');
|
||
return;
|
||
}
|
||
|
||
const selection = getEditorSelection(editor.editor);
|
||
if (!selection) {
|
||
alert('Please select some code to save as an instrument');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
instrumentsUpdateButton.disabled = true;
|
||
instrumentsUpdateButton.textContent = 'Updating...';
|
||
|
||
// Pass the dTag to update the existing instrument
|
||
await saveInstrument(title, selection, [], '', currentInstrumentDTag);
|
||
|
||
console.log('[Strudel] Instrument updated:', title);
|
||
} catch (err) {
|
||
console.error('[Strudel] Failed to update instrument:', err);
|
||
alert('Failed to update instrument: ' + err.message);
|
||
} finally {
|
||
instrumentsUpdateButton.disabled = false;
|
||
instrumentsUpdateButton.textContent = 'Update';
|
||
}
|
||
}
|
||
|
||
function loadMyLibrary() {
|
||
if (!currentPubkey) return;
|
||
|
||
// Close any existing subscriptions
|
||
if (songsSubscription) {
|
||
songsSubscription.close();
|
||
}
|
||
if (instrumentsSubscription) {
|
||
instrumentsSubscription.close();
|
||
}
|
||
|
||
// Subscribe to user's songs
|
||
songsSubscription = subscribeToMyItems(currentPubkey, KINDS.SONG, (event) => {
|
||
const item = parseStrudelEvent(event);
|
||
mySongs.set(item.dTag, item);
|
||
renderSongsList();
|
||
});
|
||
|
||
// Subscribe to user's instruments
|
||
instrumentsSubscription = subscribeToMyItems(currentPubkey, KINDS.INSTRUMENT, (event) => {
|
||
const item = parseStrudelEvent(event);
|
||
myInstruments.set(item.dTag, item);
|
||
renderInstrumentsList();
|
||
});
|
||
}
|
||
|
||
function renderSongsList() {
|
||
if (!songsListDiv) return;
|
||
|
||
if (mySongs.size === 0) {
|
||
songsListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">No songs saved yet</span>';
|
||
return;
|
||
}
|
||
|
||
// Sort by creation date (newest first)
|
||
const sortedSongs = Array.from(mySongs.values()).sort((a, b) => b.createdAt - a.createdAt);
|
||
|
||
let html = '';
|
||
for (const song of sortedSongs) {
|
||
html += `
|
||
<div class="library-item" data-dtag="${song.dTag}" data-kind="${KINDS.SONG}">
|
||
<div class="library-item-content" onclick="loadSong('${song.dTag}')">
|
||
<span class="library-item-icon">🎵</span>
|
||
<span class="library-item-title">${escapeHtml(song.title)}</span>
|
||
<span class="library-item-date">${formatTimestamp(song.createdAt)}</span>
|
||
</div>
|
||
<button class="library-item-delete" onclick="deleteLibraryItem(${KINDS.SONG}, '${song.dTag}')" title="Delete">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
songsListDiv.innerHTML = html;
|
||
}
|
||
|
||
function renderInstrumentsList() {
|
||
if (!instrumentsListDiv) return;
|
||
|
||
if (myInstruments.size === 0) {
|
||
instrumentsListDiv.innerHTML = '<span style="opacity:0.5;font-size:11px;">No instruments saved yet</span>';
|
||
return;
|
||
}
|
||
|
||
// Sort by creation date (newest first)
|
||
const sortedInstruments = Array.from(myInstruments.values()).sort((a, b) => b.createdAt - a.createdAt);
|
||
|
||
let html = '';
|
||
for (const inst of sortedInstruments) {
|
||
html += `
|
||
<div class="library-item" data-dtag="${inst.dTag}" data-kind="${KINDS.INSTRUMENT}">
|
||
<div class="library-item-content" onclick="loadInstrument('${inst.dTag}')">
|
||
<span class="library-item-icon">🎹</span>
|
||
<span class="library-item-title">${escapeHtml(inst.title)}</span>
|
||
<span class="library-item-date">${formatTimestamp(inst.createdAt)}</span>
|
||
</div>
|
||
<button class="library-item-delete" onclick="deleteLibraryItem(${KINDS.INSTRUMENT}, '${inst.dTag}')" title="Delete">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
instrumentsListDiv.innerHTML = html;
|
||
}
|
||
|
||
function loadSong(dTag) {
|
||
console.log('[Strudel] loadSong called with dTag:', dTag);
|
||
const song = mySongs.get(dTag);
|
||
if (!song) {
|
||
console.warn('[Strudel] Song not found for dTag:', dTag);
|
||
return;
|
||
}
|
||
|
||
// Replace entire editor content
|
||
if (strudelEditorElement?.editor) {
|
||
const cmView = strudelEditorElement.editor.editor;
|
||
if (cmView && cmView.dispatch) {
|
||
cmView.dispatch({
|
||
changes: {
|
||
from: 0,
|
||
to: cmView.state.doc.length,
|
||
insert: song.content
|
||
}
|
||
});
|
||
} else {
|
||
strudelEditorElement.editor.code = song.content;
|
||
}
|
||
|
||
// Track this as the current song and update UI
|
||
setCurrentSong(song);
|
||
|
||
// Close sidebar after loading
|
||
closeNav();
|
||
}
|
||
}
|
||
|
||
function setCurrentSong(song) {
|
||
currentSongDTag = song?.dTag || null;
|
||
|
||
// Update header with song title or reset to "Strudel"
|
||
const headerText = document.getElementById('divHeaderText');
|
||
if (headerText) {
|
||
headerText.textContent = song?.title || 'Strudel';
|
||
}
|
||
|
||
if (song && songsSaveTitle && songsSaveButton && songsUpdateButton) {
|
||
// Set the title input
|
||
songsSaveTitle.value = song.title;
|
||
|
||
// Initially show Save New, hide Update until content changes
|
||
songsSaveButton.style.display = 'inline-block';
|
||
songsUpdateButton.style.display = 'none';
|
||
|
||
// Store original content for comparison
|
||
currentSongOriginalContent = song.content;
|
||
|
||
// Show current song indicator
|
||
const currentDiv = document.getElementById('divSongsCurrent');
|
||
if (currentDiv) {
|
||
currentDiv.textContent = `Editing: ${song.title}`;
|
||
currentDiv.style.display = 'block';
|
||
}
|
||
} else {
|
||
// No song loaded - reset to Save New mode
|
||
if (songsSaveButton) songsSaveButton.style.display = 'inline-block';
|
||
if (songsUpdateButton) songsUpdateButton.style.display = 'none';
|
||
const currentDiv = document.getElementById('divSongsCurrent');
|
||
if (currentDiv) currentDiv.style.display = 'none';
|
||
currentSongOriginalContent = null;
|
||
}
|
||
}
|
||
|
||
function clearCurrentSong() {
|
||
setCurrentSong(null);
|
||
if (songsSaveTitle) songsSaveTitle.value = '';
|
||
}
|
||
|
||
function loadInstrument(dTag) {
|
||
const inst = myInstruments.get(dTag);
|
||
if (!inst) return;
|
||
|
||
const editor = strudelEditorElement?.editor;
|
||
if (!editor || !editor.editor) return;
|
||
|
||
// Check if there's a selection
|
||
if (hasEditorSelection(editor.editor)) {
|
||
// Replace selection
|
||
replaceSelection(editor.editor, inst.content);
|
||
} else {
|
||
// Insert at cursor
|
||
insertAtCursor(editor.editor, inst.content);
|
||
}
|
||
console.log('[Strudel] Loaded instrument:', inst.title);
|
||
|
||
// Track this as the current instrument and update UI
|
||
setCurrentInstrument(inst);
|
||
|
||
// Close sidebar after loading
|
||
closeNav();
|
||
}
|
||
|
||
function setCurrentInstrument(inst) {
|
||
currentInstrumentDTag = inst?.dTag || null;
|
||
|
||
if (inst && instrumentsSaveTitle && instrumentsSaveButton && instrumentsUpdateButton) {
|
||
// Set the title input
|
||
instrumentsSaveTitle.value = inst.title;
|
||
|
||
// Initially show Save New, hide Update until content changes
|
||
instrumentsSaveButton.style.display = 'inline-block';
|
||
instrumentsUpdateButton.style.display = 'none';
|
||
|
||
// Store original content for comparison
|
||
currentInstrumentOriginalContent = inst.content;
|
||
|
||
// Show current instrument indicator
|
||
const currentDiv = document.getElementById('divInstrumentsCurrent');
|
||
if (currentDiv) {
|
||
currentDiv.textContent = `Editing: ${inst.title}`;
|
||
currentDiv.style.display = 'block';
|
||
}
|
||
} else {
|
||
// No instrument loaded - reset to Save New mode
|
||
if (instrumentsSaveButton) instrumentsSaveButton.style.display = 'inline-block';
|
||
if (instrumentsUpdateButton) instrumentsUpdateButton.style.display = 'none';
|
||
const currentDiv = document.getElementById('divInstrumentsCurrent');
|
||
if (currentDiv) currentDiv.style.display = 'none';
|
||
currentInstrumentOriginalContent = null;
|
||
}
|
||
}
|
||
|
||
function clearCurrentInstrument() {
|
||
setCurrentInstrument(null);
|
||
if (instrumentsSaveTitle) instrumentsSaveTitle.value = '';
|
||
}
|
||
|
||
async function deleteLibraryItem(kind, dTag) {
|
||
if (!confirm('Delete this item?')) return;
|
||
|
||
try {
|
||
await deleteItem(kind, dTag);
|
||
|
||
// Remove from local cache
|
||
if (kind === KINDS.SONG) {
|
||
mySongs.delete(dTag);
|
||
renderSongsList();
|
||
} else if (kind === KINDS.INSTRUMENT) {
|
||
myInstruments.delete(dTag);
|
||
renderInstrumentsList();
|
||
}
|
||
|
||
console.log('[Strudel] Deleted item:', dTag);
|
||
} catch (err) {
|
||
console.error('[Strudel] Failed to delete item:', err);
|
||
alert('Failed to delete: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// Make functions available globally for onclick handlers
|
||
window.loadSong = loadSong;
|
||
window.loadInstrument = loadInstrument;
|
||
window.deleteLibraryItem = deleteLibraryItem;
|
||
|
||
async function handleAISend() {
|
||
if (!aiPromptInput || !aiHistoryDiv) return;
|
||
|
||
const prompt = aiPromptInput.value.trim();
|
||
if (!prompt) return;
|
||
|
||
// Add user message to chat
|
||
addMessageToChat('user', prompt);
|
||
aiPromptInput.value = '';
|
||
|
||
// Disable input during request
|
||
aiPromptInput.disabled = true;
|
||
|
||
// Get current code from editor
|
||
let currentCode = '';
|
||
if (strudelEditorElement?.editor) {
|
||
currentCode = strudelEditorElement.editor.code || '';
|
||
}
|
||
|
||
// Send to AI (include MIDI context)
|
||
const result = await sendMessage(
|
||
prompt,
|
||
currentCode,
|
||
strudelEditorElement?.editor,
|
||
(responseText) => addMessageToChat('assistant', responseText),
|
||
(errorText) => addMessageToChat('error', errorText),
|
||
buildMidiContext()
|
||
);
|
||
|
||
// Re-enable input
|
||
aiPromptInput.disabled = false;
|
||
aiPromptInput.focus();
|
||
}
|
||
|
||
function addMessageToChat(role, content) {
|
||
if (!aiHistoryDiv) return;
|
||
|
||
const messageDiv = document.createElement('div');
|
||
messageDiv.className = `ai-message ai-message-${role}`;
|
||
messageDiv.textContent = content;
|
||
aiHistoryDiv.appendChild(messageDiv);
|
||
|
||
// Scroll to bottom
|
||
aiHistoryDiv.scrollTop = aiHistoryDiv.scrollHeight;
|
||
}
|
||
|
||
function handlePlay() {
|
||
try {
|
||
console.log('[Strudel] Play button clicked');
|
||
|
||
if (strudelEditorElement) {
|
||
// Access the editor instance from the strudel-editor element
|
||
const editor = strudelEditorElement.editor;
|
||
|
||
if (editor) {
|
||
console.log('[Strudel] Found editor instance, calling evaluate()');
|
||
// Call the evaluate method directly
|
||
editor.evaluate();
|
||
} else {
|
||
// Fallback: dispatch repl-evaluate custom event
|
||
const editorContainer = strudelEditorElement.nextElementSibling;
|
||
const cmEditor = editorContainer?.querySelector('.cm-editor');
|
||
|
||
if (cmEditor) {
|
||
console.log('[Strudel] Dispatching repl-evaluate event');
|
||
const event = new CustomEvent('repl-evaluate', {
|
||
detail: { view: cmEditor },
|
||
bubbles: true,
|
||
cancelable: true
|
||
});
|
||
document.dispatchEvent(event);
|
||
}
|
||
}
|
||
|
||
isPlaying = true;
|
||
updateStatus('playing');
|
||
updateButtonStates(true);
|
||
}
|
||
} catch (error) {
|
||
console.error('[Strudel] Error playing:', error);
|
||
showError('Error: ' + error.message);
|
||
}
|
||
}
|
||
|
||
function handleStop() {
|
||
try {
|
||
console.log('[Strudel] Stop button clicked');
|
||
|
||
if (strudelEditorElement) {
|
||
// Access the editor instance from the strudel-editor element
|
||
const editor = strudelEditorElement.editor;
|
||
|
||
if (editor && editor.stop) {
|
||
console.log('[Strudel] Found editor instance, calling stop()');
|
||
editor.stop();
|
||
} else if (typeof hush === 'function') {
|
||
// Fallback to global hush()
|
||
hush();
|
||
console.log('[Strudel] Called global hush()');
|
||
}
|
||
}
|
||
|
||
isPlaying = false;
|
||
updateStatus('stopped');
|
||
updateButtonStates(false);
|
||
} catch (error) {
|
||
console.error('[Strudel] Error stopping:', error);
|
||
showError('Error stopping: ' + error.message);
|
||
}
|
||
}
|
||
|
||
function handleReload() {
|
||
// Clear any previous error messages
|
||
lastErrorMessage = null;
|
||
clearErrorHighlights();
|
||
|
||
// Stop current playback
|
||
handleStop();
|
||
|
||
// Re-trigger play after a short delay
|
||
setTimeout(() => {
|
||
handlePlay();
|
||
// Explicitly update button to ensure it shows "Stop" when playing
|
||
isPlaying = true;
|
||
updateStatus('playing');
|
||
updateButtonStates(true);
|
||
}, 200);
|
||
|
||
console.log('[Strudel] Reloading current code');
|
||
}
|
||
|
||
/* ================================================================
|
||
INITIALIZATION
|
||
================================================================
|
||
Main initialization sequence:
|
||
1. Initialize hamburger menu
|
||
2. Set up hamburger click handler
|
||
3. Initialize NDK (handles authentication automatically)
|
||
4. Get authenticated pubkey
|
||
5. Set up relay activity listeners
|
||
6. Set up version bar button listeners
|
||
7. Initialize Strudel components
|
||
8. Start update loop
|
||
|
||
The initNDKPage() function:
|
||
- Checks if already authenticated (via nostr-login-lite)
|
||
- If not authenticated, shows login modal
|
||
- Connects to NDK SharedWorker
|
||
- Returns when authentication is complete
|
||
|
||
Authentication persists across pages via nostr-login-lite's
|
||
localStorage, so users only need to login once.
|
||
================================================================ */
|
||
(async function main() {
|
||
console.log("[template.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);
|
||
syncStrudelTheme(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("[template.html] Authenticated as:", currentPubkey);
|
||
|
||
// Initialize relay UI components
|
||
initFooterRelayStatus();
|
||
initSidenavRelaySection();
|
||
await initBlossomSection();
|
||
initAiSectionWithLocalConfig();
|
||
await UpdateFooter(); // Initial update before interval starts
|
||
|
||
// Listen for relay activity broadcasts from worker
|
||
window.addEventListener('ndkRelayActivity', (event) => {
|
||
const { relayUrl, activity, stats } = event.detail;
|
||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
|
||
setRelayActivityState(relayUrl, activity);
|
||
});
|
||
|
||
// Listen for relay activity broadcasts from worker (alternative message format)
|
||
window.addEventListener('message', (event) => {
|
||
if (event.data && event.data.type === 'relayActivity') {
|
||
const { relayUrl, activity } = event.data;
|
||
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
|
||
setRelayActivityState(relayUrl, activity);
|
||
}
|
||
});
|
||
|
||
/* ============================================================
|
||
EXAMPLE: Subscribe to events
|
||
============================================================
|
||
Uncomment to subscribe to user's notes:
|
||
|
||
const notesSub = subscribe(
|
||
{ kinds: [1], authors: [currentPubkey], limit: 10 },
|
||
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
|
||
);
|
||
console.log("[template.html] Subscribed to kind 1");
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
EXAMPLE: Listen for events from worker
|
||
============================================================
|
||
Uncomment to handle incoming events:
|
||
|
||
window.addEventListener('ndkEvent', (event) => {
|
||
const evt = event.detail;
|
||
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
|
||
|
||
if (evt.pubkey === currentPubkey) {
|
||
if (evt.kind === 1) {
|
||
// Handle note event
|
||
console.log("Note:", evt.content);
|
||
}
|
||
}
|
||
});
|
||
============================================================ */
|
||
|
||
/* ============================================================
|
||
EXAMPLE: Listen for cached profile
|
||
============================================================
|
||
Uncomment to handle cached profile data:
|
||
|
||
window.addEventListener('ndkProfile', (event) => {
|
||
console.log("[template.html] Cached profile:", event.detail);
|
||
// event.detail contains profile object (name, about, etc.)
|
||
});
|
||
============================================================ */
|
||
|
||
|
||
// Start update loop (updates footer every second)
|
||
updateIntervalId = setInterval(UpdateFooter, 1000);
|
||
|
||
// Update version display
|
||
await updateVersionDisplay();
|
||
|
||
// Initialize Strudel components
|
||
console.log('[Strudel] Initializing Strudel components...');
|
||
|
||
// Get reference to strudel-editor element
|
||
strudelEditorElement = document.getElementById('strudelEditor');
|
||
console.log('[Strudel] strudelEditorElement:', strudelEditorElement);
|
||
|
||
// Wait for component to fully render
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// The strudel-editor component creates a sibling div for the actual editor
|
||
const editorContainer = strudelEditorElement.nextElementSibling;
|
||
console.log('[Strudel] Editor container (sibling):', editorContainer);
|
||
|
||
// Check if editor has rendered in the sibling container
|
||
const editorContent = editorContainer?.querySelector('.cm-editor') ||
|
||
editorContainer?.querySelector('.cm-content') ||
|
||
editorContainer?.querySelector('textarea');
|
||
console.log('[Strudel] Editor content element:', editorContent);
|
||
console.log('[Strudel] strudelEditorElement.innerHTML length:', strudelEditorElement.innerHTML.length);
|
||
console.log('[Strudel] strudelEditorElement children:', strudelEditorElement.children.length);
|
||
if (editorContainer) {
|
||
console.log('[Strudel] editorContainer children:', editorContainer.children.length);
|
||
}
|
||
|
||
// Setup button handlers
|
||
document.getElementById('strudelToggleButton').addEventListener('click', handleToggle);
|
||
document.getElementById('strudelReloadButton').addEventListener('click', handleReload);
|
||
|
||
// Add keyboard shortcuts for play/stop (Ctrl+Enter to reload/play, Ctrl+. to stop)
|
||
document.addEventListener('keydown', (e) => {
|
||
// Ctrl+Enter to reload and play (always re-evaluates code)
|
||
if (e.ctrlKey && e.key === 'Enter') {
|
||
e.preventDefault();
|
||
handleReload();
|
||
}
|
||
// Ctrl+. to stop
|
||
if (e.ctrlKey && e.key === '.') {
|
||
e.preventDefault();
|
||
handleStop();
|
||
}
|
||
});
|
||
|
||
// Listen for play/stop events from the strudel-editor
|
||
window.addEventListener('strudel:start', () => {
|
||
isPlaying = true;
|
||
updateStatus('playing');
|
||
updateButtonStates(true);
|
||
// Clear error message when code starts playing successfully
|
||
lastErrorMessage = null;
|
||
clearErrorHighlights();
|
||
});
|
||
|
||
window.addEventListener('strudel:stop', () => {
|
||
isPlaying = false;
|
||
updateStatus('stopped');
|
||
updateButtonStates(false);
|
||
});
|
||
|
||
// Listen for Strudel log events (errors, warnings, etc.)
|
||
document.addEventListener('strudel.log', (e) => {
|
||
const { message, type } = e.detail;
|
||
console.log('[Strudel Log]', message, type);
|
||
|
||
// Check if this is an error message
|
||
if (message && message.includes('error:')) {
|
||
console.log('[Strudel Log] Found error message, extracting line number...');
|
||
|
||
// Store the error message for display in footer
|
||
lastErrorMessage = message;
|
||
|
||
// Try multiple patterns to find line numbers:
|
||
// 1. (line:col) - syntax errors like "Unexpected token (9:14)"
|
||
// 2. line X - explicit line mentions
|
||
// 3. <anonymous>:line:col - runtime errors in eval
|
||
|
||
const patterns = [
|
||
/\((\d+):\d+\)/, // (9:14)
|
||
/line\s*(\d+)/i, // line 9
|
||
/<anonymous>:(\d+):\d+/ // <anonymous>:9:14
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
const lineMatch = message.match(pattern);
|
||
if (lineMatch) {
|
||
const lineNum = parseInt(lineMatch[1], 10);
|
||
console.log(`[Strudel Log] Pattern ${pattern} matched, highlighting line ${lineNum}`);
|
||
highlightErrorLine(lineNum);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Also listen for successful eval to clear error highlights and footer message
|
||
window.addEventListener('strudel:eval', () => {
|
||
clearErrorHighlights();
|
||
lastErrorMessage = null;
|
||
});
|
||
|
||
// Hook into console.error to catch runtime errors with stack traces
|
||
const originalConsoleError = console.error;
|
||
console.error = function(...args) {
|
||
originalConsoleError.apply(console, args);
|
||
|
||
console.log('[Strudel Error Hook] Caught console.error:', args);
|
||
|
||
// Check if any argument contains a stack trace with line numbers
|
||
args.forEach((arg, i) => {
|
||
const str = arg?.toString?.() || '';
|
||
console.log(`[Strudel Error Hook] Arg ${i}:`, str.substring(0, 200));
|
||
|
||
// Try multiple patterns to find line numbers:
|
||
// 1. <anonymous>:line:col - runtime errors in eval
|
||
// 2. (line:col) - syntax errors like "Unexpected token (9:14)"
|
||
// 3. line X - explicit line mentions
|
||
|
||
const patterns = [
|
||
/<anonymous>:(\d+):\d+/g,
|
||
/\((\d+):\d+\)/g,
|
||
/line\s*(\d+)/gi
|
||
];
|
||
|
||
for (const pattern of patterns) {
|
||
const matches = str.matchAll(pattern);
|
||
const matchesArray = Array.from(matches);
|
||
if (matchesArray.length > 0) {
|
||
console.log(`[Strudel Error Hook] Pattern ${pattern} found ${matchesArray.length} matches`);
|
||
|
||
for (const match of matchesArray) {
|
||
const lineNum = parseInt(match[1], 10);
|
||
console.log(`[Strudel Error Hook] Highlighting line ${lineNum}`);
|
||
if (lineNum > 0) {
|
||
highlightErrorLine(lineNum);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
};
|
||
|
||
// Sync Strudel theme with current day/night mode
|
||
syncStrudelTheme(isDarkMode);
|
||
|
||
// Initialize AI settings
|
||
initializeAISettings();
|
||
|
||
// Initialize MIDI section
|
||
initializeMIDISection();
|
||
|
||
// Initialize Songs/Instruments section
|
||
initializeSongsInstrumentsSection();
|
||
|
||
console.log('[Strudel] Initialization complete');
|
||
} catch (error) {
|
||
console.error('[template.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>`;
|
||
}
|
||
})();
|
||
|
||
/* ================================================================
|
||
WORKER MESSAGE TYPES
|
||
================================================================
|
||
The NDK worker can send these message types:
|
||
|
||
1. 'response' - Response to init/subscribe/publish requests
|
||
- data.profile - User profile (from init)
|
||
- data.relays - User relays (from init)
|
||
- data.success - Publish success status
|
||
- data.relayResults - Relay publish results
|
||
|
||
2. 'event' - Nostr event from subscription
|
||
- Dispatched as 'ndkEvent' window event
|
||
- event.detail contains the Nostr event
|
||
|
||
3. 'eose' - End of stored events for subscription
|
||
- Dispatched as 'ndkEose' window event
|
||
- event.detail.subId contains subscription ID
|
||
|
||
4. 'signRequest' - Request to sign event/encrypt/decrypt
|
||
- Handled automatically by init-ndk.mjs
|
||
- Calls window.nostr methods and sends response
|
||
|
||
5. 'error' - Error from worker
|
||
- Logged to console automatically
|
||
|
||
6. 'relayActivity' - Relay read/write activity notification
|
||
- Dispatched as 'ndkRelayActivity' window event
|
||
- Used to animate relay status icons in footer
|
||
================================================================ */
|
||
|
||
/* ================================================================
|
||
DISTRIBUTED ARCHITECTURE NOTES
|
||
================================================================
|
||
Each page is independently accessible and self-contained:
|
||
|
||
1. Authentication persists via nostr-login-lite localStorage
|
||
- Login once on any page
|
||
- All other pages automatically authenticated
|
||
|
||
2. NDK SharedWorker is shared across all tabs/pages
|
||
- Single NDK instance manages all connections
|
||
- Subscriptions from all pages handled by one worker
|
||
- Events broadcast to all connected pages
|
||
|
||
3. Dexie cache is shared across all pages
|
||
- IndexedDB persists across sessions
|
||
- Cache-first queries are fast
|
||
- Reduces relay load
|
||
|
||
4. Each page can be distributed independently
|
||
- Copy template.html and customize
|
||
- No dependencies on other pages
|
||
- Works standalone or as part of suite
|
||
|
||
5. Message-based signer bridges worker and page
|
||
- Worker's NDK uses MessageBasedSigner
|
||
- Signer sends sign requests to page
|
||
- Page calls window.nostr.signEvent()
|
||
- Response sent back to worker
|
||
- NDK completes signing and publishing
|
||
|
||
6. Relay status visualization
|
||
- Footer left section shows connected relays
|
||
- Each relay has animated icon (HamburgerMorphing)
|
||
- Icons morph based on activity (read/write)
|
||
- Temporary animations show real-time activity
|
||
================================================================ */
|
||
</script>
|
||
</body>
|
||
|
||
</html>
|