1071 lines
44 KiB
HTML
1071 lines
44 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;
|
|
|
|
/*
|
|
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', () => {
|
|
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() {
|
|
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 };
|
|
}
|
|
|
|
function subscribeAppAssets() {
|
|
console.log('[app-stacks] Subscribing to app assets (kind 3063)...');
|
|
assetSub = subscribe({ kinds: [3063], limit: 500 }, { closeOnEose: false, cacheUsage: 'CACHE_FIRST' });
|
|
}
|
|
|
|
function initAppAssetListener() {
|
|
window.addEventListener('ndkEvent', function(event) {
|
|
var evt = event.detail;
|
|
if (evt.kind !== 3063) return;
|
|
var parsed = parseAppAsset(evt);
|
|
if (!parsed.identifier) return;
|
|
// Keep the latest version (highest created_at)
|
|
var existing = appVersions[parsed.identifier];
|
|
if (!existing || parsed.created_at > existing.created_at) {
|
|
appVersions[parsed.identifier] = parsed;
|
|
if (assetsLoaded) renderApps();
|
|
}
|
|
});
|
|
window.addEventListener('ndkEose', function() {
|
|
if (!assetsLoaded) { assetsLoaded = true; renderApps(); }
|
|
});
|
|
}
|
|
|
|
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();
|
|
|
|
// Set up app-asset (kind 3063) listener and subscription for version info
|
|
initAppAssetListener();
|
|
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>
|