Files
client/www/app-stacks.html

1132 lines
47 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>APP STACKS</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>
</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 class="divHeaderText"></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">
</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>
<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, injectHeaderLoginButton,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings
} 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';
// 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;
// App definitions state
let apps = [];
let appDefSub = null;
let appDefsLoaded = false;
// App stacks state
let stacks = [];
let stackSub = null;
let stacksLoaded = false;
// App versions state (kind 3063 Software Asset events)
let appVersions = {}; // identifier -> { version, size, url, created_at }
let assetSub = null;
let assetsLoaded = false;
let assetEventCount = 0; // total kind 3063 events received
let assetOldestTimestamp = Infinity; // oldest raw event timestamp seen (for pagination)
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'optional';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
/* ================================================================
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');
}
// 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();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// Convention: explicit target profiles are public-readable by default.
if (hasTargetPubkeyInUrl()) {
return 'optional';
}
return 'required';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[template.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated) {
await injectHeaderLoginButton();
return;
}
if (authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[template.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
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);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
return true;
}
/* ================================================================
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();
// Clear center and right sections
divFooterCenter.innerHTML = '';
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.
================================================================ */
/* ================================================================
APP DEFINITION FUNCTIONS
================================================================ */
function parseAppDefinition(evt) {
const tags = evt.tags || [];
const dTag = getTagValue(tags, 'd') || '';
const name = getTagValue(tags, 'name') || dTag.replace('app-', '');
const identifier = dTag.replace('app-', '');
const repository = getTagValue(tags, 'repository') || '';
const categories = tags.filter(t => t[0] === 't' && t[1] !== 'app-definition').map(t => t[1]);
let description = '';
try { const c = JSON.parse(evt.content || '{}'); description = c.description || ''; } catch (e) {}
return { identifier, name, pubkey: evt.pubkey || '', repository, description, category: categories[0] || 'uncategorized', eventId: evt.id };
}
function getTagValue(tags, name) {
const tag = tags.find(t => t[0] === name);
return tag && tag.length > 1 ? tag[1] : '';
}
function renderApps() {
window.renderApps = renderApps;
// === CREATE STACK FORM ===
var createStackHtml =
'<div style="max-width:500px;margin-bottom:15px;border:1px solid var(--border-color);border-radius:var(--border-radius);padding:12px;">' +
'<div style="font-weight:bold;margin-bottom:10px;">Create App Stack</div>' +
'<div style="margin-bottom:8px;">' +
'<input id="stackName" type="text" placeholder="Stack name (e.g. Nostr Clients)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
'</div>' +
'<div style="margin-bottom:8px;">' +
'<input id="stackDesc" type="text" placeholder="Description (optional)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
'</div>' +
'<div><button class="btn" onclick="doCreateStack()">Create Stack</button></div>' +
'</div>';
// === PUBLISH APP DEFINITION FORM ===
// Collect unique stack d-tags from loaded stacks for the dropdown
var stackOptions = stacks.map(function(s) {
return '<option value="' + esc(s.dTag) + '">' + esc(s.name) + '</option>';
}).join('');
var formHtml =
'<div style="max-width:500px;margin-bottom:15px;border:1px solid var(--border-color);border-radius:var(--border-radius);padding:12px;">' +
'<div style="font-weight:bold;margin-bottom:10px;">Publish App Definition</div>' +
'<div style="margin-bottom:8px;">' +
'<input id="pubRepoUrl" type="text" placeholder="Gitea Repo URL (e.g. https://git.laantungir.net/owner/repo)" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
'</div>' +
'<div style="margin-bottom:8px;">' +
'<select id="pubStack" style="width:100%;padding:8px;font-family:var(--font-family);font-size:13px;border:1px solid var(--border-color);border-radius:var(--border-radius);background:var(--secondary-color);color:var(--color);">' +
'<option value="">Select an app stack...</option>' + stackOptions +
'</select>' +
'</div>' +
'<div><button class="btn" onclick="doPublish()">Publish</button></div>' +
'</div>';
// === STACKS WITH APPS ===
// Build a map of stack d-tag -> apps
var appsByStack = {};
apps.forEach(function(a) {
var cat = a.category || 'uncategorized';
if (!appsByStack[cat]) appsByStack[cat] = [];
appsByStack[cat].push(a);
});
var contentHtml = '';
if (stacks.length === 0 && apps.length === 0) {
contentHtml = '<div style="text-align:center;padding:20px;color:var(--muted-color);">No stacks or apps yet. Create a stack above, then publish apps into it.</div>';
} else {
// Show stacks that have apps, then stacks without apps, then apps without stacks
var stacksWithApps = [];
var stacksWithoutApps = [];
stacks.forEach(function(s) {
if (appsByStack[s.dTag] && appsByStack[s.dTag].length > 0) {
stacksWithApps.push(s);
} else {
stacksWithoutApps.push(s);
}
});
// Render stacks with apps
stacksWithApps.forEach(function(s) {
var stackApps = appsByStack[s.dTag] || [];
contentHtml +=
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);margin-bottom:10px;">' +
'<div style="font-weight:bold;font-size:14px;padding:10px 12px;border-bottom:1px solid var(--border-color);cursor:pointer;" onclick="toggleStackApps(this)">' +
esc(s.name) + ' <span style="font-size:12px;color:var(--muted-color);">(' + stackApps.length + ')</span>' +
' <span style="font-size:11px;color:var(--muted-color);">▼</span>' +
'</div>' +
'<div class="stackAppsBody">';
stackApps.forEach(function(a) {
contentHtml +=
'<div style="border-bottom:1px solid var(--border-color);font-size:12px;">' +
'<div style="padding:8px 12px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick="toggleAppDetail(this)">' +
'<span><span style="font-weight:bold;">' + esc(a.name) + '</span> <span style="color:var(--muted-color);">' + esc(appVersions[a.identifier] ? 'v' + appVersions[a.identifier].version : a.identifier) + '</span></span>' +
'<span style="font-size:11px;color:var(--muted-color);">▶</span>' +
'</div>' +
'<div class="appDetailBody" style="display:none;padding:0 12px 8px 12px;font-size:12px;color:var(--muted-color);">' +
'<div><span style="color:var(--color);">identifier:</span> ' + esc(a.identifier) + '</div>' +
'<div><span style="color:var(--color);">version:</span> ' + esc(appVersions[a.identifier] ? appVersions[a.identifier].version : '—') + '</div>' +
'<div><span style="color:var(--color);">pubkey:</span> ' + esc(a.pubkey) + '</div>' +
'<div><span style="color:var(--color);">repository:</span> ' + (a.repository ? '<a href="' + esc(a.repository) + '" target="_blank" rel="noopener">' + esc(a.repository) + '</a>' : '—') + '</div>' +
'<div><span style="color:var(--color);">description:</span> ' + esc(a.description || '—') + '</div>' +
'<div><span style="color:var(--color);">eventId:</span> ' + esc(a.eventId) + '</div>' +
'</div>' +
'</div>';
});
contentHtml += '</div></div>';
});
// Render stacks without apps
stacksWithoutApps.forEach(function(s) {
contentHtml +=
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);padding:10px 12px;margin-bottom:6px;font-size:12px;">' +
'<div style="font-weight:bold;">' + esc(s.name) + '</div>' +
'<div style="color:var(--muted-color);font-size:11px;">' + esc(s.dTag) + ' — no apps yet</div>' +
'</div>';
});
// Apps without any matching stack
var orphanApps = [];
apps.forEach(function(a) {
if (!stacks.some(function(s) { return s.dTag === a.category; })) {
orphanApps.push(a);
}
});
if (orphanApps.length > 0) {
contentHtml +=
'<div style="border:1px solid var(--border-color);border-radius:var(--border-radius);margin-bottom:10px;">' +
'<div style="font-weight:bold;font-size:14px;padding:10px 12px;border-bottom:1px solid var(--border-color);">Uncategorized <span style="font-size:12px;color:var(--muted-color);">(' + orphanApps.length + ')</span></div>';
orphanApps.forEach(function(a) {
contentHtml +=
'<div style="border-bottom:1px solid var(--border-color);font-size:12px;">' +
'<div style="padding:8px 12px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;" onclick="toggleAppDetail(this)">' +
'<span><span style="font-weight:bold;">' + esc(a.name) + '</span> <span style="color:var(--muted-color);">' + esc(appVersions[a.identifier] ? 'v' + appVersions[a.identifier].version : a.identifier) + '</span></span>' +
'<span style="font-size:11px;color:var(--muted-color);">▶</span>' +
'</div>' +
'<div class="appDetailBody" style="display:none;padding:0 12px 8px 12px;font-size:12px;color:var(--muted-color);">' +
'<div><span style="color:var(--color);">identifier:</span> ' + esc(a.identifier) + '</div>' +
'<div><span style="color:var(--color);">version:</span> ' + esc(appVersions[a.identifier] ? appVersions[a.identifier].version : '—') + '</div>' +
'<div><span style="color:var(--color);">pubkey:</span> ' + esc(a.pubkey) + '</div>' +
'<div><span style="color:var(--color);">repository:</span> ' + (a.repository ? '<a href="' + esc(a.repository) + '" target="_blank" rel="noopener">' + esc(a.repository) + '</a>' : '—') + '</div>' +
'<div><span style="color:var(--color);">description:</span> ' + esc(a.description || '—') + '</div>' +
'<div><span style="color:var(--color);">eventId:</span> ' + esc(a.eventId) + '</div>' +
'</div>' +
'</div>';
});
contentHtml += '</div>';
}
}
// Single wrapper div so #divBody flexbox sees only one child
divBody.innerHTML = '<div>' + createStackHtml + formHtml + contentHtml + '</div>';
}
// Toggle stack body collapse
window.toggleStackApps = function(header) {
var body = header.nextElementSibling;
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? '' : 'none';
header.querySelector('span:last-child').textContent = isHidden ? '▼' : '▶';
};
// Toggle app detail expand
window.toggleAppDetail = function(row) {
var body = row.nextElementSibling;
var isHidden = body.style.display === 'none' || body.style.display === '';
body.style.display = isHidden ? 'block' : 'none';
row.querySelector('span:last-child').textContent = isHidden ? '▼' : '▶';
};
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function parseGiteaUrl(url) {
// Handle URLs like https://git.laantungir.net/owner/repo or https://github.com/owner/repo
try {
const u = new URL(url);
const parts = u.pathname.replace(/^\//, '').replace(/\/$/, '').split('/');
if (parts.length < 2) return null;
const owner = parts[0];
const repo = parts[1].replace(/\.git$/, '');
return { owner, repo, giteaPath: owner + '/' + repo };
} catch (e) { return null; }
}
function setStatus(msg) {
divFooterCenter.textContent = msg;
}
async function publishAppDefinition(name, identifier, repository, category, giteaPath) {
if (!isAuthenticated) { const ok = await promptLoginIfNeeded(); if (!ok) { setStatus('Sign in to publish.'); return; } }
try {
const tags = [
['d', 'app-' + identifier],
['t', 'app-definition'],
['t', category],
['name', name],
['repository', repository],
];
if (giteaPath) tags.push(['gitea', giteaPath]);
const result = await publishEvent({
kind: 30078,
content: JSON.stringify({ name, identifier, repository, description: '' }),
tags,
created_at: Math.floor(Date.now() / 1000),
});
const n = (result?.relayResults?.successful || []).length;
setStatus('App definition published to ' + n + ' relay(s)');
renderApps();
} catch (e) { setStatus('Publish failed: ' + e.message); }
}
// Expose doPublish globally for onclick handler
window.doPublish = async function() {
const repoUrl = document.getElementById('pubRepoUrl').value.trim();
if (!repoUrl) { setStatus('Enter a Gitea repo URL.'); return; }
const parsed = parseGiteaUrl(repoUrl);
if (!parsed) { setStatus('Invalid repo URL. Use format: https://git.example.com/owner/repo'); return; }
const stackSelect = document.getElementById('pubStack');
const stackDTag = stackSelect ? stackSelect.value : '';
if (!stackDTag) { setStatus('Select an app stack.'); return; }
// Derive name from repo name: my-cool-app -> My Cool App
const name = parsed.repo.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
// Derive identifier from repo name: my-cool-app -> com.gitea.my_cool_app
const identifier = 'com.gitea.' + parsed.repo.replace(/-/g, '_');
await publishAppDefinition(name, identifier, repoUrl, stackDTag, parsed.giteaPath);
};
function subscribeAppDefinitions() {
console.log('[app-stacks] Subscribing to app definitions...');
appDefSub = subscribe({ kinds: [30078], '#t': ['app-definition'], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
}
function initAppDefinitionListener() {
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
if (evt.kind !== 30078) return;
const tags = evt.tags || [];
if (!tags.some(t => t[0] === 't' && t[1] === 'app-definition')) return;
const dTag = getTagValue(tags, 'd') || '';
if (apps.some(a => a.eventId === evt.id || a.identifier === dTag.replace('app-', ''))) return;
apps.push(parseAppDefinition(evt));
console.log('[app-stacks] Added app def:', apps[apps.length - 1].name);
if (appDefsLoaded) renderApps();
});
window.addEventListener('ndkEose', (event) => {
if (appDefSub && event.detail && event.detail.subId !== appDefSub.subId) return;
if (!appDefsLoaded) { appDefsLoaded = true; renderApps(); }
});
}
/* ================================================================
APP STACK FUNCTIONS (kind 30267)
================================================================ */
function parseAppStack(evt) {
const tags = evt.tags || [];
const dTag = getTagValue(tags, 'd') || '';
const name = getTagValue(tags, 'name') || dTag;
const description = getTagValue(tags, 'description') || '';
const appRefs = tags.filter(function(t) { return t[0] === 'a'; }).map(function(t) { return t[1]; });
return { dTag: dTag, name: name, description: description, appRefs: appRefs, eventId: evt.id, pubkey: evt.pubkey || '' };
}
function subscribeAppStacks() {
console.log('[app-stacks] Subscribing to app stacks...');
stackSub = subscribe({ kinds: [30267], '#t': ['app-stack'], limit: 200 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
}
function initAppStackListener() {
window.addEventListener('ndkEvent', function(event) {
var evt = event.detail;
if (evt.kind !== 30267) return;
var tags = evt.tags || [];
if (!tags.some(function(t) { return t[0] === 't' && t[1] === 'app-stack'; })) return;
var dTag = getTagValue(tags, 'd') || '';
if (stacks.some(function(s) { return s.eventId === evt.id || s.dTag === dTag; })) return;
stacks.push(parseAppStack(evt));
console.log('[app-stacks] Added stack:', stacks[stacks.length - 1].name);
if (stacksLoaded) renderApps();
});
window.addEventListener('ndkEose', function(event) {
if (stackSub && event.detail && event.detail.subId !== stackSub.subId) return;
if (!stacksLoaded) { stacksLoaded = true; renderApps(); }
});
}
/* ================================================================
APP VERSION FUNCTIONS (kind 3063 Software Asset)
================================================================ */
function parseAppAsset(evt) {
const tags = evt.tags || [];
const identifier = getTagValue(tags, 'i') || '';
const version = getTagValue(tags, 'version') || '';
const size = getTagValue(tags, 'size') || '';
const urls = tags.filter(function(t) { return t[0] === 'url'; }).map(function(t) { return t[1]; });
return { identifier, version, size, url: urls[0] || '', created_at: evt.created_at || 0 };
}
// Direct WebSocket fetch of kind 3063 events from relay.zapstore.dev.
// Uses #i tag filtering to request only events for our specific apps.
// Bypasses NDK to avoid grouping/cache issues.
var ZAPSTORE_RELAY = 'wss://relay.zapstore.dev';
function subscribeAppAssets() {
// Wait for app definitions to load so we know the identifiers
if (!appDefsLoaded || apps.length === 0) {
setTimeout(subscribeAppAssets, 1000);
return;
}
console.log('[app-stacks] Fetching kind 3063 events for ' + apps.length + ' apps from ' + ZAPSTORE_RELAY);
fetchAppAssetsByIdentifier();
}
function fetchAppAssetsByIdentifier() {
var identifiers = apps.map(function(a) { return a.identifier; });
var batchSize = 50;
var batches = [];
for (var i = 0; i < identifiers.length; i += batchSize) {
batches.push(identifiers.slice(i, i + batchSize));
}
console.log('[app-stacks] Fetching in ' + batches.length + ' batches of up to ' + batchSize + ' apps each');
var batchIndex = 0;
function fetchNextBatch() {
if (batchIndex >= batches.length) {
if (!assetsLoaded) {
assetsLoaded = true;
renderApps();
}
console.log('[app-stacks] Kind 3063 fetch complete. Total events: ' + assetEventCount + ', unique apps with versions: ' + Object.keys(appVersions).length);
return;
}
var batch = batches[batchIndex];
batchIndex++;
var reqId = 'assets-' + batchIndex + '-' + Date.now();
console.log('[app-stacks] Fetching batch ' + batchIndex + '/' + batches.length + ' (' + batch.length + ' apps)...');
var ws = new WebSocket(ZAPSTORE_RELAY);
ws.onopen = function() {
ws.send(JSON.stringify(['REQ', reqId, { kinds: [3063], '#i': batch, limit: 500 }]));
};
ws.onmessage = function(ev) {
var msg = JSON.parse(ev.data);
if (msg[0] === 'EVENT' && msg[1] === reqId) {
var evt = msg[2];
assetEventCount++;
var parsed = parseAppAsset(evt);
if (parsed.identifier) {
var existing = appVersions[parsed.identifier];
if (!existing || parsed.created_at > existing.created_at) {
appVersions[parsed.identifier] = parsed;
if (assetsLoaded) renderApps();
}
}
} else if (msg[0] === 'EOSE' && msg[1] === reqId) {
ws.send(JSON.stringify(['CLOSE', reqId]));
ws.close();
console.log('[app-stacks] Batch ' + batchIndex + ' complete (total events: ' + assetEventCount + ', unique apps: ' + Object.keys(appVersions).length + ')');
if (!assetsLoaded) {
assetsLoaded = true;
renderApps();
}
setTimeout(fetchNextBatch, 100);
}
};
ws.onerror = function(err) {
console.error('[app-stacks] WebSocket error in batch ' + batchIndex + ':', err);
if (!assetsLoaded) {
assetsLoaded = true;
renderApps();
}
setTimeout(fetchNextBatch, 100);
};
}
fetchNextBatch();
}
async function publishAppStack(name, description) {
if (!isAuthenticated) { var ok = await promptLoginIfNeeded(); if (!ok) { setStatus('Sign in to publish.'); return; } }
var dTag = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
try {
var result = await publishEvent({
kind: 30267,
content: JSON.stringify({ name: name, description: description }),
tags: [
['d', dTag],
['t', 'app-stack'],
['name', name],
['description', description],
],
created_at: Math.floor(Date.now() / 1000),
});
var n = (result?.relayResults?.successful || []).length;
setStatus('Stack "' + name + '" published to ' + n + ' relay(s)');
renderApps();
} catch (e) { setStatus('Publish failed: ' + e.message); }
}
window.doCreateStack = async function() {
var name = document.getElementById('stackName').value.trim();
if (!name) { alert('Enter a stack name.'); return; }
var desc = document.getElementById('stackDesc').value.trim();
await publishAppStack(name, desc);
};
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(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);
if (themeToggleHamburger) {
themeToggleHamburger.animateTo(isDarkMode ? 'moon' : 'circle');
}
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[template.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
// Set up app-definition event listener and subscription
initAppDefinitionListener();
subscribeAppDefinitions();
// Set up app-stack event listener and subscription
initAppStackListener();
subscribeAppStacks();
// Fetch kind 3063 (Software Asset) events directly from relay.zapstore.dev
// for version info. Bypasses NDK to avoid grouping/cache issues.
subscribeAppAssets();
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu to publish apps';
}
// Update version display
await updateVersionDisplay();
console.log('[app-stacks] Initialization complete');
} catch (error) {
console.error('[app-stacks] Initialization failed:', error);
divBody.innerHTML = '<div style="text-align:center;padding:40px;"><div style="font-size:18px;margin-bottom:10px;">Error</div><div>' + error.message + '</div><button class="btn" style="margin-top:10px;" onclick="location.reload()">Retry</button></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. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. 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
7. 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>