Files
client/www/profile.html
T

725 lines
24 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>PROFILE</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');
document.body.classList.add('dark-mode');
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="./js/vendor/svg.min.js"></script>
<style media="screen">
#divBody {
display: flex;
flex-direction: column;
align-items: center;
height: 90%;
width: 100%;
font-family: var(--font-family);
padding: 20px;
overflow-y: auto;
}
.divSection {
width: 95%;
max-width: 1200px;
margin-bottom: 30px;
border: var(--border-width) solid var(--primary-color);
border-radius: 10px;
padding: 20px;
background-color: var(--secondary-color);
display: flex;
flex-direction: column;
align-items: center;
}
.divSectionTitle {
font-family: pageKanji;
font-size: 600%;
font-weight: bold;
margin-bottom: 15px;
border-bottom: var(--border-width) solid var(--muted-color);
padding-bottom: 5px;
text-align: center;
}
#tblProfile {
min-width: 400px;
width: 60%;
padding: 10px;
}
.inpDiv {
border: 1px solid var(--muted-color);
padding: 10px;
font-size: 60%;
font-family: var(--font-family);
}
#tdBanner {
width: 100%;
}
#tdAvatar {
width: 400px;
height: 200px;
}
#imgBanner {
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(var(--image-grayscale));
}
#imgAvatar {
width: 100%;
height: 100%;
object-fit: contain;
filter: grayscale(var(--image-grayscale));
}
.divButtonBox {
display: flex;
flex-wrap: wrap;
flex-direction: row;
justify-content: space-around;
}
.divButton {
border: 2px solid var(--primary-color);
border-radius: 10px;
text-align: center;
padding: 10px;
margin: 10px;
}
.divButton:hover {
cursor: pointer;
background-color: var(--accent-color);
}
#inpFollows {
height: 200px;
}
.divTitle {
min-width: 10em;
}
</style>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">PROFILE</div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<div id="divBody" class="clsBodyFull">
<div class="divSection">
<div class="divSectionTitle">プロフィール</div>
<table id="tblProfile">
<tr>
<td></td>
<td id="tdBanner"></td>
</tr>
<tr>
<td class="divTitle">Banner Pic</td>
<td id="inpBanner" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td></td>
<td id="tdAvatar"></td>
</tr>
<tr>
<td class="divTitle">Avatar</td>
<td id="inpAvatar" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">Name</td>
<td id="inpName" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">Display Name</td>
<td id="inpDisplayName" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">Website</td>
<td id="inpWebsite" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">About</td>
<td id="inpAbout" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">NIP-05</td>
<td id="inpNip5" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">LN Address</td>
<td id="inpLnAddress" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">Follows</td>
<td id="inpFollows" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td class="divTitle">Public Relays</td>
<td id="inpPublicRelays" class="inpDiv" contenteditable="true"></td>
</tr>
<tr>
<td></td>
<td id="UpdateButton" class="btn divButton">Update</td>
</tr>
</table>
</div>
</div>
<!-- ================================================================
FOOTER
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<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>
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getVersion, updateVersionDisplay } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[profile.html ${VERSION}] Loading...`);
//////////////////////////////////////////////////////////////////////
// GLOBAL VARIABLES
//////////////////////////////////////////////////////////////////////
let updateIntervalId = null;
let currentPubkey = null;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
//////////////////////////////////////////////////////////////////////
// DOM VARIABLES
//////////////////////////////////////////////////////////////////////
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterLeft = document.getElementById("divFooterLeft");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
const inpName = document.getElementById(`inpName`);
const inpDisplayName = document.getElementById(`inpDisplayName`);
const inpAvatar = document.getElementById(`inpAvatar`);
const inpBanner = document.getElementById(`inpBanner`);
const inpWebsite = document.getElementById(`inpWebsite`);
const inpAbout = document.getElementById(`inpAbout`);
const inpNip5 = document.getElementById(`inpNip5`);
const inpLnAddress = document.getElementById(`inpLnAddress`);
const inpFollows = document.getElementById(`inpFollows`);
const inpPublicRelays = document.getElementById(`inpPublicRelays`);
const tdBanner = document.getElementById(`tdBanner`);
const tdAvatar = document.getElementById(`tdAvatar`);
const UpdateButton = document.getElementById(`UpdateButton`);
//////////////////////////////////////////////////////////////////////
// HAMBURGER MENU
//////////////////////////////////////////////////////////////////////
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
//////////////////////////////////////////////////////////////////////
// SIDENAV FUNCTIONS
//////////////////////////////////////////////////////////////////////
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
divSideNavBody.innerHTML = "Settings and options coming soon...";
// 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();
}
}
//////////////////////////////////////////////////////////////////////
// DISPLAY PROFILE (KIND 0)
//////////////////////////////////////////////////////////////////////
const DisplayEvt0 = (E) => {
console.log("[profile.html] Displaying kind 0 event");
if (!E || typeof E.content !== 'string') {
console.warn('[profile.html] Missing or invalid event content', E);
return;
}
let objProfile;
try {
objProfile = JSON.parse(E.content);
} catch (err) {
console.error('[profile.html] Failed to parse content', err, E.content);
return;
}
if (objProfile.name !== undefined) {
inpName.innerText = objProfile.name;
}
if (objProfile.about !== undefined) {
inpAbout.innerText = objProfile.about;
}
if (objProfile.banner !== undefined) {
inpBanner.innerText = objProfile.banner;
tdBanner.innerHTML = `<img id="imgBanner" src="${objProfile.banner}" alt="">`;
}
if (objProfile.website !== undefined) {
inpWebsite.innerText = objProfile.website;
}
if (objProfile.picture !== undefined) {
inpAvatar.innerText = objProfile.picture;
tdAvatar.innerHTML = `<img id="imgAvatar" src="${objProfile.picture}" alt="">`;
}
if (objProfile.display_name !== undefined) {
inpDisplayName.innerText = objProfile.display_name;
}
if (objProfile.lud16 !== undefined) {
inpLnAddress.innerText = objProfile.lud16;
}
if (objProfile.nip05 !== undefined) {
inpNip5.innerText = objProfile.nip05;
}
};
//////////////////////////////////////////////////////////////////////
// DISPLAY CONTACTS (KIND 3)
//////////////////////////////////////////////////////////////////////
const DisplayEvt3 = (E) => {
console.log("[profile.html] Displaying kind 3 event");
if (!E) {
console.error("[profile.html] Event is null/undefined");
return;
}
const tags = Array.isArray(E.tags) ? E.tags : [];
inpFollows.innerText = JSON.stringify(tags, null, " ");
let relaysObj = [];
if (E && typeof E.content === 'string') {
try {
relaysObj = JSON.parse(E.content);
} catch (err) {
console.error('[profile.html] Failed to parse content', err, E.content);
relaysObj = [];
}
}
inpPublicRelays.innerText = JSON.stringify(relaysObj, null, " ");
};
//////////////////////////////////////////////////////////////////////
// UPDATE PROFILE
//////////////////////////////////////////////////////////////////////
const UpdateProfile = async () => {
console.log("[profile.html] Updating profile...");
// Publish kind 0 (profile metadata)
let objProfile = {};
objProfile.name = inpName.innerText;
objProfile.display_name = inpDisplayName.innerText;
objProfile.about = inpAbout.innerText;
objProfile.banner = inpBanner.innerText;
objProfile.website = inpWebsite.innerText;
objProfile.picture = inpAvatar.innerText;
objProfile.lud16 = inpLnAddress.innerText;
objProfile.nip05 = inpNip5.innerText;
let objEvent = {};
objEvent.created_at = Math.floor(Date.now() / 1000);
objEvent.kind = 0;
objEvent.tags = [];
objEvent.content = JSON.stringify(objProfile);
try {
// Send unsigned event to worker - NDK will sign via message-based signer
const result = await publishEvent(objEvent);
console.log("[profile.html] ========================================");
console.log("[profile.html] Profile (kind 0) Update Results:");
console.log("[profile.html] ✅ Successfully published to:", result.relayResults.successful);
console.log("[profile.html] ❌ Failed to publish to:", result.relayResults.failed);
console.log("[profile.html] Total relays updated:", result.totalRelays);
console.log("[profile.html] ========================================");
} catch (error) {
console.error("[profile.html] ========================================");
console.error("[profile.html] ❌ Error publishing profile (kind 0):", error.message);
console.error("[profile.html] ========================================");
return;
}
// Publish kind 3 (contacts)
try {
objEvent = {};
objEvent.created_at = Math.floor(Date.now() / 1000);
objEvent.kind = 3;
objEvent.tags = JSON.parse(inpFollows.innerText);
let relaysContent = JSON.parse(inpPublicRelays.innerText);
objEvent.content = JSON.stringify(relaysContent);
// Send unsigned event to worker - NDK will sign via message-based signer
const result = await publishEvent(objEvent);
console.log("[profile.html] ========================================");
console.log("[profile.html] Contacts (kind 3) Update Results:");
console.log("[profile.html] ✅ Successfully published to:", result.relayResults.successful);
console.log("[profile.html] ❌ Failed to publish to:", result.relayResults.failed);
console.log("[profile.html] Total relays updated:", result.totalRelays);
console.log("[profile.html] ========================================");
} catch (error) {
console.error("[profile.html] ========================================");
console.error("[profile.html] ❌ Error publishing contacts (kind 3):", error.message);
console.error("[profile.html] ========================================");
}
};
//////////////////////////////////////////////////////////////////////
// UPDATE FOOTER
//////////////////////////////////////////////////////////////////////
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('[profile.html] Error updating footer:', error);
}
};
//////////////////////////////////////////////////////////////////////
// LOGOUT
//////////////////////////////////////////////////////////////////////
const Logout = async () => {
console.log("[profile.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("[profile.html] Logged out, reloading page");
location.reload(true);
};
//////////////////////////////////////////////////////////////////////
// EVENTS
//////////////////////////////////////////////////////////////////////
UpdateButton.addEventListener("click", UpdateProfile);
//////////////////////////////////////////////////////////////////////
// INITIALIZATION
//////////////////////////////////////////////////////////////////////
(async function main() {
console.log("[profile.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;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
await Logout();
} catch (error) {
console.error('Logout failed:', error);
}
});
}
// Initialize NDK (handles authentication automatically)
await initNDKPage();
// Get authenticated pubkey
currentPubkey = await getPubkey();
await injectHeaderAvatar(currentPubkey);
console.log("[profile.html] Authenticated as:", currentPubkey);
// Initialize relay UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[profile.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
// Subscribe to profile (kind 0)
const profileSub = subscribe(
{ kinds: [0], authors: [currentPubkey], limit: 1 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[profile.html] Subscribed to kind 0");
// Subscribe to contacts (kind 3)
const contactsSub = subscribe(
{ kinds: [3], authors: [currentPubkey], limit: 1 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[profile.html] Subscribed to kind 3");
// Listen for events from worker
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[profile.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 0) {
DisplayEvt0(evt);
} else if (evt.kind === 3) {
DisplayEvt3(evt);
}
}
});
// Listen for profile from worker (cached)
window.addEventListener('ndkProfile', (event) => {
console.log("[profile.html] Received cached profile:", event.detail);
// Profile comes as parsed object, need to convert to event format
const profileEvent = {
kind: 0,
content: JSON.stringify(event.detail),
pubkey: currentPubkey
};
DisplayEvt0(profileEvent);
});
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Start update loop
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[profile.html] Initialization complete');
} catch (error) {
console.error('[profile.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
</script>
</body>
</html>