1400 lines
48 KiB
HTML
1400 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Projects</title>
|
|
|
|
<link rel="stylesheet" href="./css/client.css" />
|
|
|
|
<!-- Initialize theme BEFORE any components load -->
|
|
<script>
|
|
(function () {
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme === 'dark') {
|
|
document.documentElement.classList.add('dark-mode');
|
|
if (document.body) {
|
|
document.body.classList.add('dark-mode');
|
|
}
|
|
}
|
|
})();
|
|
</script>
|
|
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
|
|
|
|
<!-- SVG.js library (required by HamburgerMorphing) -->
|
|
<script src="./js/vendor/svg.min.js"></script>
|
|
<style>
|
|
#divBody {
|
|
flex-direction: column !important;
|
|
flex-wrap: nowrap !important;
|
|
align-items: stretch !important;
|
|
justify-content: flex-start !important;
|
|
align-content: flex-start !important;
|
|
gap: 12px !important;
|
|
padding: 12px;
|
|
overflow: auto;
|
|
}
|
|
|
|
.projectsPage {
|
|
width: 100%;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.projectsTitle {
|
|
margin: 0 0 8px 0;
|
|
font-size: 1.4rem;
|
|
}
|
|
|
|
.projectsStatus {
|
|
margin: 0 0 8px 0;
|
|
opacity: 0.85;
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.projectsStatus.error {
|
|
color: #d33;
|
|
}
|
|
|
|
.projectsTableWrap {
|
|
overflow-x: auto;
|
|
border: var(--border);
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.projectsTable {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
min-width: 1000px;
|
|
font-family: var(--font-family);
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.projectsTable th,
|
|
.projectsTable td {
|
|
border: var(--border);
|
|
padding: 0.45rem 0.55rem;
|
|
text-align: left;
|
|
vertical-align: top;
|
|
}
|
|
|
|
.projectsTable th {
|
|
background: var(--button-background-color);
|
|
cursor: pointer;
|
|
user-select: none;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.projectsTable th.sort-asc::after {
|
|
content: " ▲";
|
|
}
|
|
|
|
.projectsTable th.sort-desc::after {
|
|
content: " ▼";
|
|
}
|
|
|
|
.projectsTable td.num,
|
|
.projectsTable th.num {
|
|
text-align: right;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.projectsHeatmapTitle {
|
|
margin: 14px 0 8px 0;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.projectsHeatmap {
|
|
margin: 0;
|
|
border: var(--border);
|
|
background: var(--secondary-color);
|
|
padding: 0.8rem;
|
|
overflow-x: auto;
|
|
white-space: pre;
|
|
color: var(--primary-color);
|
|
font-family: var(--font-family);
|
|
}
|
|
|
|
.projectsLink {
|
|
color: inherit;
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|
|
</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">Projects</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 class="projectsPage">
|
|
<h1 class="projectsTitle">Projects of Laan Tungir</h1>
|
|
<p id="status" class="projectsStatus">Loading realtime data from Gitea API…</p>
|
|
|
|
<div class="projectsTableWrap">
|
|
<table id="projects-table" class="projectsTable">
|
|
<thead>
|
|
<tr>
|
|
<th data-type="text">Project</th>
|
|
<th data-type="date">Last Activity</th>
|
|
<th data-type="text">Description</th>
|
|
<th class="num" data-type="number">Commits (30d)</th>
|
|
<th class="num" data-type="number">Commits (year)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rows"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2 id="heatmap-title" class="projectsHeatmapTitle">Contribution Heatmap</h2>
|
|
<pre id="heatmap" class="projectsHeatmap">Loading heatmap…</pre>
|
|
</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>
|
|
|
|
<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;
|
|
|
|
/*
|
|
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 = 'required';
|
|
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");
|
|
|
|
const statusEl = document.getElementById('status');
|
|
const rowsEl = document.getElementById('rows');
|
|
const heatmapEl = document.getElementById('heatmap');
|
|
const heatmapTitleEl = document.getElementById('heatmap-title');
|
|
const table = document.getElementById('projects-table');
|
|
|
|
/* ================================================================
|
|
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;
|
|
}
|
|
|
|
// Projects page default: public readable without login.
|
|
return 'none';
|
|
}
|
|
|
|
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);
|
|
};
|
|
|
|
let projectsPageInitialized = false;
|
|
let sortProjectsBy30DayDesc = null;
|
|
|
|
function setProjectsStatus(msg, isError = false) {
|
|
if (!statusEl) return;
|
|
statusEl.textContent = msg;
|
|
statusEl.classList.toggle('error', Boolean(isError));
|
|
}
|
|
|
|
function cleanDescriptionText(text) {
|
|
let out = String(text || '');
|
|
out = out.replace(/<[^>]+>/g, '');
|
|
out = out.replace(/\*\*/g, '');
|
|
out = out.replace(/__/g, '');
|
|
out = out.replace(/\*/g, '');
|
|
out = out.replace(/_/g, '');
|
|
out = out.replace(/`/g, '');
|
|
out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
|
|
out = out.replace(/\s+/g, ' ').trim();
|
|
return out;
|
|
}
|
|
|
|
function formatDate(dateLike) {
|
|
if (!dateLike) return 'N/A';
|
|
const d = new Date(dateLike);
|
|
if (Number.isNaN(d.getTime())) return 'N/A';
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function heatLevelChar(count, maxCount) {
|
|
if (count <= 0) return '.';
|
|
if (maxCount <= 1) return '#';
|
|
|
|
const t1 = 1;
|
|
const t2 = Math.max(2, Math.ceil(maxCount * 0.33));
|
|
const t3 = Math.max(t2 + 1, Math.ceil(maxCount * 0.66));
|
|
|
|
if (count <= t1) return '-';
|
|
if (count <= t2) return '+';
|
|
if (count <= t3) return '*';
|
|
return '#';
|
|
}
|
|
|
|
function renderAsciiHeatmap(dayCounts, year) {
|
|
const jan1 = new Date(Date.UTC(year, 0, 1));
|
|
const dec31 = new Date(Date.UTC(year, 11, 31));
|
|
|
|
const start = new Date(jan1);
|
|
start.setUTCDate(start.getUTCDate() - start.getUTCDay());
|
|
|
|
const end = new Date(dec31);
|
|
end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
|
|
|
|
const weeks = [];
|
|
for (let cur = new Date(start); cur <= end; cur.setUTCDate(cur.getUTCDate() + 7)) {
|
|
const week = [];
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = new Date(cur);
|
|
d.setUTCDate(cur.getUTCDate() + i);
|
|
week.push(d);
|
|
}
|
|
weeks.push(week);
|
|
}
|
|
|
|
const counts = Object.values(dayCounts);
|
|
const maxCount = counts.length ? Math.max(...counts) : 0;
|
|
|
|
const width = weeks.length * 2;
|
|
const monthLine = new Array(width).fill(' ');
|
|
const monthPositions = {};
|
|
|
|
weeks.forEach((week, idx) => {
|
|
week.forEach(d => {
|
|
if (d.getUTCFullYear() === year && d.getUTCDate() === 1) {
|
|
const m = d.getUTCMonth() + 1;
|
|
if (!(m in monthPositions)) {
|
|
monthPositions[m] = idx * 2;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
Object.entries(monthPositions).forEach(([monthStr, pos]) => {
|
|
const month = Number(monthStr);
|
|
const label = new Date(Date.UTC(year, month - 1, 1)).toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
|
|
for (let i = 0; i < label.length; i++) {
|
|
if (pos + i < width) {
|
|
monthLine[pos + i] = label[i];
|
|
}
|
|
}
|
|
});
|
|
|
|
const lines = [];
|
|
lines.push(' ' + monthLine.join('').replace(/\s+$/, ''));
|
|
|
|
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
for (let dayIdx = 0; dayIdx < dayLabels.length; dayIdx++) {
|
|
const rowCells = [];
|
|
weeks.forEach(week => {
|
|
const d = week[dayIdx];
|
|
const key = d.toISOString().slice(0, 10);
|
|
const count = d.getUTCFullYear() === year ? (dayCounts[key] || 0) : 0;
|
|
rowCells.push(heatLevelChar(count, maxCount));
|
|
});
|
|
lines.push(`${dayLabels[dayIdx]} ${rowCells.join(' ')}`);
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('Legend: . = 0, - = low, + = medium, * = high, # = very high');
|
|
lines.push(`Max commits/day in ${year}: ${maxCount}`);
|
|
return lines.join('\n');
|
|
}
|
|
|
|
const PROJECTS_BASE = 'https://git.laantungir.net';
|
|
const PROJECTS_USER = 'laantungir';
|
|
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
|
|
function getCache(key) {
|
|
try {
|
|
const raw = sessionStorage.getItem(key);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object') return null;
|
|
if (Date.now() - parsed.ts > CACHE_TTL_MS) {
|
|
sessionStorage.removeItem(key);
|
|
return null;
|
|
}
|
|
return parsed.data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setCache(key, data) {
|
|
try {
|
|
sessionStorage.setItem(key, JSON.stringify({ ts: Date.now(), data }));
|
|
} catch {
|
|
// ignore storage failures
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, cacheKey, { allow404AsEmpty = false, allow409AsEmpty = false } = {}) {
|
|
const cached = getCache(cacheKey);
|
|
if (cached) return cached;
|
|
|
|
const resp = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/json' },
|
|
cache: 'no-store',
|
|
mode: 'cors',
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
if ((allow404AsEmpty && resp.status === 404) || (allow409AsEmpty && resp.status === 409)) {
|
|
return [];
|
|
}
|
|
throw new Error(`HTTP ${resp.status} for ${url}`);
|
|
}
|
|
|
|
const data = await resp.json();
|
|
setCache(cacheKey, data);
|
|
return data;
|
|
}
|
|
|
|
async function fetchAllRepos() {
|
|
const all = [];
|
|
let page = 1;
|
|
|
|
while (true) {
|
|
const url = `${PROJECTS_BASE}/api/v1/users/${PROJECTS_USER}/repos?limit=50&page=${page}`;
|
|
const pageData = await fetchJson(url, `repos:${PROJECTS_USER}:${page}`);
|
|
|
|
if (!Array.isArray(pageData) || pageData.length === 0) break;
|
|
all.push(...pageData);
|
|
|
|
if (pageData.length < 50) break;
|
|
page += 1;
|
|
}
|
|
|
|
return all
|
|
.filter(r => !r.private && !r.mirror && !r.fork)
|
|
.map(r => ({
|
|
name: String(r.name || '').trim(),
|
|
description: cleanDescriptionText(r.description || ''),
|
|
updated_at: r.updated_at || '',
|
|
html_url: r.html_url || `${PROJECTS_BASE}/${PROJECTS_USER}/${r.name}`,
|
|
}))
|
|
.filter(r => r.name)
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
async function fetchHeatmap() {
|
|
const url = `${PROJECTS_BASE}/api/v1/users/${PROJECTS_USER}/heatmap`;
|
|
const data = await fetchJson(url, `heatmap:${PROJECTS_USER}`);
|
|
if (!Array.isArray(data)) return [];
|
|
return data;
|
|
}
|
|
|
|
function renderTable(rows) {
|
|
if (!rowsEl) return;
|
|
rowsEl.innerHTML = '';
|
|
|
|
for (const row of rows) {
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.repo = row.name;
|
|
|
|
const tdName = document.createElement('td');
|
|
const link = document.createElement('a');
|
|
link.href = row.html_url;
|
|
link.textContent = row.name;
|
|
link.rel = 'noopener noreferrer';
|
|
link.target = '_blank';
|
|
link.className = 'projectsLink';
|
|
tdName.appendChild(link);
|
|
|
|
const tdLast = document.createElement('td');
|
|
tdLast.textContent = formatDate(row.updated_at);
|
|
|
|
const tdDesc = document.createElement('td');
|
|
tdDesc.textContent = row.description || 'No description available.';
|
|
|
|
const td30 = document.createElement('td');
|
|
td30.className = 'num commit-30';
|
|
td30.dataset.pending = '1';
|
|
td30.textContent = '⠋';
|
|
|
|
const tdYear = document.createElement('td');
|
|
tdYear.className = 'num commit-year';
|
|
tdYear.dataset.pending = '1';
|
|
tdYear.textContent = '⠋';
|
|
|
|
tr.appendChild(tdName);
|
|
tr.appendChild(tdLast);
|
|
tr.appendChild(tdDesc);
|
|
tr.appendChild(td30);
|
|
tr.appendChild(tdYear);
|
|
|
|
rowsEl.appendChild(tr);
|
|
}
|
|
}
|
|
|
|
function setRepoCounts(repoName, c30, cYear) {
|
|
if (!rowsEl) return;
|
|
const tr = rowsEl.querySelector(`tr[data-repo="${CSS.escape(repoName)}"]`);
|
|
if (!tr) return;
|
|
const td30 = tr.querySelector('.commit-30');
|
|
const tdYear = tr.querySelector('.commit-year');
|
|
if (td30) td30.textContent = Number.isFinite(c30) ? c30.toLocaleString('en-US') : 'ERR';
|
|
if (tdYear) tdYear.textContent = Number.isFinite(cYear) ? cYear.toLocaleString('en-US') : 'ERR';
|
|
}
|
|
|
|
function getCommitDate(commitObj) {
|
|
const candidate =
|
|
commitObj?.commit?.committer?.date ||
|
|
commitObj?.commit?.author?.date ||
|
|
commitObj?.created ||
|
|
null;
|
|
if (!candidate) return null;
|
|
const d = new Date(candidate);
|
|
return Number.isNaN(d.getTime()) ? null : d;
|
|
}
|
|
|
|
async function fetchRepoCommitCounts(repoName, since30, sinceYear) {
|
|
const perPage = 50;
|
|
let page = 1;
|
|
let yearCount = 0;
|
|
let day30Count = 0;
|
|
|
|
while (true) {
|
|
const url = `${PROJECTS_BASE}/api/v1/repos/${PROJECTS_USER}/${encodeURIComponent(repoName)}/commits?limit=${perPage}&page=${page}`;
|
|
const commits = await fetchJson(
|
|
url,
|
|
`commits:${PROJECTS_USER}:${repoName}:all:${page}`,
|
|
{ allow404AsEmpty: true, allow409AsEmpty: true }
|
|
);
|
|
|
|
if (!Array.isArray(commits) || commits.length === 0) break;
|
|
|
|
let sawOlderThanYear = false;
|
|
for (const c of commits) {
|
|
const d = getCommitDate(c);
|
|
if (!d) continue;
|
|
if (d >= sinceYear) {
|
|
yearCount += 1;
|
|
if (d >= since30) {
|
|
day30Count += 1;
|
|
}
|
|
} else {
|
|
sawOlderThanYear = true;
|
|
}
|
|
}
|
|
|
|
if (sawOlderThanYear) break;
|
|
if (commits.length < perPage) break;
|
|
|
|
page += 1;
|
|
if (page > 500) break;
|
|
}
|
|
|
|
return { day30Count, yearCount };
|
|
}
|
|
|
|
function startBrailleSpinner() {
|
|
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
let idx = 0;
|
|
const timer = setInterval(() => {
|
|
const frame = frames[idx % frames.length];
|
|
idx += 1;
|
|
|
|
rowsEl?.querySelectorAll('td.commit-30, td.commit-year').forEach(td => {
|
|
if (td.dataset.pending === '1') {
|
|
td.textContent = frame;
|
|
}
|
|
});
|
|
}, 90);
|
|
|
|
return () => clearInterval(timer);
|
|
}
|
|
|
|
async function loadCommitCountsProgressively(repos, year) {
|
|
const since30 = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
const sinceYear = new Date(Date.UTC(year, 0, 1, 0, 0, 0));
|
|
|
|
const queue = repos.map(r => r.name);
|
|
const total = queue.length;
|
|
let done = 0;
|
|
const concurrency = 4;
|
|
const stopSpinner = startBrailleSpinner();
|
|
|
|
const markPending = (repoName, pending) => {
|
|
const tr = rowsEl?.querySelector(`tr[data-repo="${CSS.escape(repoName)}"]`);
|
|
if (!tr) return;
|
|
const td30 = tr.querySelector('.commit-30');
|
|
const tdYear = tr.querySelector('.commit-year');
|
|
if (td30) td30.dataset.pending = pending ? '1' : '0';
|
|
if (tdYear) tdYear.dataset.pending = pending ? '1' : '0';
|
|
};
|
|
|
|
const worker = async () => {
|
|
while (queue.length > 0) {
|
|
const repoName = queue.shift();
|
|
if (!repoName) return;
|
|
|
|
markPending(repoName, true);
|
|
try {
|
|
const counts = await fetchRepoCommitCounts(repoName, since30, sinceYear);
|
|
setRepoCounts(repoName, counts.day30Count, counts.yearCount);
|
|
} catch {
|
|
setRepoCounts(repoName, NaN, NaN);
|
|
}
|
|
markPending(repoName, false);
|
|
|
|
done += 1;
|
|
setProjectsStatus(
|
|
`Updated ${new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC • ` +
|
|
`${repos.length} public owned repositories loaded via Gitea API • ` +
|
|
`Commit counts: ${done}/${total}`
|
|
);
|
|
}
|
|
};
|
|
|
|
try {
|
|
await Promise.all(Array.from({ length: Math.min(concurrency, total || 1) }, () => worker()));
|
|
if (typeof sortProjectsBy30DayDesc === 'function') {
|
|
sortProjectsBy30DayDesc();
|
|
}
|
|
} finally {
|
|
stopSpinner();
|
|
}
|
|
}
|
|
|
|
function aggregateHeatmap(heatmapData, year) {
|
|
const dayCounts = {};
|
|
for (const item of heatmapData) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const ts = Number(item.timestamp);
|
|
const contributions = Number(item.contributions || 0);
|
|
if (!Number.isFinite(ts) || !Number.isFinite(contributions)) continue;
|
|
|
|
const d = new Date(ts * 1000);
|
|
const key = d.toISOString().slice(0, 10);
|
|
if (d.getUTCFullYear() !== year) continue;
|
|
|
|
dayCounts[key] = (dayCounts[key] || 0) + contributions;
|
|
}
|
|
return dayCounts;
|
|
}
|
|
|
|
function attachSorting() {
|
|
if (!table || !rowsEl) return;
|
|
const headers = Array.from(table.querySelectorAll('thead th'));
|
|
|
|
const getCellValue = (row, idx, type) => {
|
|
const text = row.children[idx].textContent.trim();
|
|
if (type === 'number') {
|
|
const n = Number(text.replace(/,/g, ''));
|
|
return Number.isFinite(n) ? n : -1;
|
|
}
|
|
if (type === 'date') return text === 'N/A' ? -Infinity : (Date.parse(text) || -Infinity);
|
|
return text.toLowerCase();
|
|
};
|
|
|
|
const sortRowsByColumn = (idx, asc) => {
|
|
const th = headers[idx];
|
|
if (!th) return;
|
|
const type = th.dataset.type || 'text';
|
|
const rows = Array.from(rowsEl.querySelectorAll('tr'));
|
|
|
|
rows.sort((a, b) => {
|
|
const va = getCellValue(a, idx, type);
|
|
const vb = getCellValue(b, idx, type);
|
|
if (va < vb) return asc ? -1 : 1;
|
|
if (va > vb) return asc ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
|
th.classList.add(asc ? 'sort-asc' : 'sort-desc');
|
|
rows.forEach(row => rowsEl.appendChild(row));
|
|
};
|
|
|
|
headers.forEach((th, idx) => {
|
|
let asc = true;
|
|
th.addEventListener('click', () => {
|
|
sortRowsByColumn(idx, asc);
|
|
asc = !asc;
|
|
});
|
|
});
|
|
|
|
sortProjectsBy30DayDesc = () => sortRowsByColumn(3, false);
|
|
sortProjectsBy30DayDesc();
|
|
}
|
|
|
|
async function initializeProjectsPage() {
|
|
if (projectsPageInitialized) return;
|
|
if (!statusEl || !rowsEl || !heatmapEl || !heatmapTitleEl || !table) return;
|
|
|
|
const year = new Date().getUTCFullYear();
|
|
heatmapTitleEl.textContent = `Contribution Heatmap (${year}, all projects)`;
|
|
|
|
try {
|
|
const [repos, heatmapData] = await Promise.all([fetchAllRepos(), fetchHeatmap()]);
|
|
|
|
renderTable(repos);
|
|
|
|
const dayCounts = aggregateHeatmap(heatmapData, year);
|
|
const ascii = renderAsciiHeatmap(dayCounts, year);
|
|
heatmapEl.textContent = ascii;
|
|
|
|
attachSorting();
|
|
|
|
setProjectsStatus(
|
|
`Updated ${new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC • ` +
|
|
`${repos.length} public owned repositories loaded via Gitea API • Commit counts: 0/${repos.length}`
|
|
);
|
|
|
|
await loadCommitCountsProgressively(repos, year);
|
|
} catch (err) {
|
|
heatmapEl.textContent = 'Failed to load heatmap data.';
|
|
setProjectsStatus(
|
|
`Failed to load realtime data: ${err && err.message ? err.message : String(err)}. ` +
|
|
'If this page is served from a different origin, enable CORS on Gitea or serve from the same origin.',
|
|
true
|
|
);
|
|
}
|
|
|
|
projectsPageInitialized = 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).
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
USER SETTINGS EXAMPLE (NIP-78)
|
|
================================================================
|
|
Read/subscribe/write helper pattern for all pages:
|
|
|
|
// Read latest merged settings (cache + relay hydrated by worker)
|
|
const settings = await getUserSettings();
|
|
|
|
// Subscribe to cross-tab updates
|
|
const unsubscribe = onUserSettings((nextSettings) => {
|
|
// Re-render page from nextSettings
|
|
});
|
|
|
|
// Patch only your feature namespace
|
|
await patchUserSettings({
|
|
myFeature: {
|
|
someFlag: true
|
|
}
|
|
});
|
|
|
|
// On page teardown (if applicable)
|
|
// unsubscribe();
|
|
================================================================ */
|
|
|
|
/* ================================================================
|
|
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();
|
|
await initializeProjectsPage();
|
|
|
|
/* ============================================================
|
|
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.)
|
|
});
|
|
============================================================ */
|
|
|
|
|
|
// Optional UX note for public mode pages.
|
|
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
|
|
divFooterCenter.textContent = 'Public mode';
|
|
divFooterRight.textContent = 'Sign in from side menu for private features';
|
|
}
|
|
|
|
// Update version display
|
|
await updateVersionDisplay();
|
|
|
|
console.log('[template.html] 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. 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>
|