1148 lines
35 KiB
HTML
1148 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<html lang="en" dir="ltr">
|
|
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>DB</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 media="screen">
|
|
#divBody {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
#tblDB {
|
|
font-family: var(--font-family);
|
|
border: 2px solid var(--primary-color);
|
|
border-collapse: collapse;
|
|
white-space: nowrap;
|
|
width: 100%;
|
|
}
|
|
|
|
#tblDB th {
|
|
border: 1px solid var(--primary-color);
|
|
padding: 10px;
|
|
font-weight: bold;
|
|
background: var(--primary-color);
|
|
color: var(--secondary-color);
|
|
text-align: left;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
padding-right: 30px;
|
|
}
|
|
|
|
#tblDB th:hover {
|
|
background: var(--accent-color);
|
|
}
|
|
|
|
#tblDB th .sort-icon {
|
|
position: absolute;
|
|
right: 5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
width: 20px;
|
|
height: 20px;
|
|
}
|
|
|
|
#tblDB th.sorted-asc .sort-icon,
|
|
#tblDB th.sorted-desc .sort-icon {
|
|
display: block;
|
|
}
|
|
|
|
#tblDB th:not(.sorted-asc):not(.sorted-desc) .sort-icon {
|
|
opacity: 0.3;
|
|
}
|
|
|
|
#tblDB td {
|
|
border: 1px dashed var(--muted-color);
|
|
padding: 2px;
|
|
max-width: 400px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
#tblDB tr:hover td {
|
|
background-color: var(--muted-color);
|
|
}
|
|
|
|
.divJsonPreview {
|
|
max-height: none;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
font-size: 70%;
|
|
font-family: monospace;
|
|
background: var(--secondary-color);
|
|
border: 1px solid var(--muted-color);
|
|
padding: 5px;
|
|
}
|
|
|
|
.btnRefresh {
|
|
padding: 10px 20px;
|
|
border: 2px solid var(--primary-color);
|
|
background: transparent;
|
|
color: var(--primary-color);
|
|
font-family: var(--font-family);
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
width: 100%;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.btnRefresh:hover {
|
|
background: var(--accent-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.filter-row th {
|
|
background: var(--secondary-color) !important;
|
|
cursor: default !important;
|
|
padding: 6px;
|
|
}
|
|
|
|
.filter-row th:hover {
|
|
background: var(--secondary-color) !important;
|
|
}
|
|
|
|
.filter-input {
|
|
width: 100%;
|
|
box-sizing: border-box;
|
|
padding: 6px;
|
|
border: 1px solid var(--muted-color);
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
font-family: var(--font-family);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.divCommands {
|
|
display: flex;
|
|
gap: 6px;
|
|
}
|
|
|
|
.btnCmd {
|
|
padding: 4px 8px;
|
|
border: 1px solid var(--primary-color);
|
|
background: transparent;
|
|
color: var(--primary-color);
|
|
font-family: var(--font-family);
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.btnCmd:hover {
|
|
background: var(--accent-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.kindSummaryTable {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
border: 2px solid var(--primary-color);
|
|
}
|
|
|
|
.kindSummaryTable th,
|
|
.kindSummaryTable td {
|
|
border: 1px solid var(--muted-color);
|
|
padding: 8px;
|
|
text-align: left;
|
|
}
|
|
|
|
.kindSummaryTable th {
|
|
background: var(--primary-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.kindSummaryRow {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.kindSummaryRow:hover td {
|
|
background: var(--muted-color);
|
|
}
|
|
|
|
.kindExpandedCell {
|
|
padding: 0 !important;
|
|
background: var(--secondary-color);
|
|
}
|
|
|
|
.kindExpandedSection {
|
|
padding: 10px;
|
|
border-top: 1px dashed var(--muted-color);
|
|
}
|
|
|
|
.paginationBar {
|
|
margin-top: 8px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: var(--muted-color);
|
|
}
|
|
|
|
.btnPage {
|
|
padding: 4px 8px;
|
|
border: 1px solid var(--primary-color);
|
|
background: transparent;
|
|
color: var(--primary-color);
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-family: var(--font-family);
|
|
}
|
|
|
|
.btnPage[disabled] {
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
}
|
|
|
|
.btnPage.active {
|
|
background: var(--accent-color);
|
|
color: var(--secondary-color);
|
|
}
|
|
|
|
.rawJsonModal {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
z-index: 1000;
|
|
padding: 20px;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.rawJsonModal.open {
|
|
display: flex;
|
|
}
|
|
|
|
.rawJsonModalContent {
|
|
background: var(--secondary-color);
|
|
color: var(--primary-color);
|
|
border: 2px solid var(--primary-color);
|
|
width: min(1000px, 100%);
|
|
max-height: 85vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.rawJsonModalToolbar {
|
|
display: flex;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
border-bottom: 1px solid var(--muted-color);
|
|
}
|
|
|
|
.rawJsonModalPre {
|
|
margin: 0;
|
|
padding: 12px;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
font-family: monospace;
|
|
font-size: 12px;
|
|
}
|
|
</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">DB</div>
|
|
</div>
|
|
|
|
<div id="divHeaderFlexRight">
|
|
<!-- No button in header right - logout is in sidenav footer -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ================================================================
|
|
BODY
|
|
================================================================ -->
|
|
<div id="divBody">
|
|
<div id="divTable"></div>
|
|
</div>
|
|
|
|
<div id="rawJsonModal" class="rawJsonModal">
|
|
<div class="rawJsonModalContent">
|
|
<div class="rawJsonModalToolbar">
|
|
<button id="btnRawJsonCopy" class="btnCmd">Copy</button>
|
|
<button id="btnRawJsonClose" class="btnCmd">Close</button>
|
|
</div>
|
|
<pre id="preRawJson" class="rawJsonModalPre"></pre>
|
|
</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>
|
|
|
|
<!-- ================================================================
|
|
SIDENAV
|
|
================================================================ -->
|
|
<div id="divSideNav">
|
|
<div id="divSideNavHeader">
|
|
<!-- No close button - use main hamburger to close -->
|
|
</div>
|
|
|
|
<div id="divSideNavBody">
|
|
<div id="divSideNavInfo">Database browser - view IndexedDB contents</div>
|
|
<button id="btnRefresh" class="btn btnRefresh">Refresh</button>
|
|
<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">loading...</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
|
|
================================================================ -->
|
|
<script src="./nostr.bundle.js"></script>
|
|
<script src="/nostr-login-lite/nostr-lite.js"></script>
|
|
|
|
<script type="module">
|
|
/* ================================================================
|
|
IMPORTS
|
|
================================================================ */
|
|
import { initNDKPage, getPubkey, injectHeaderAvatar, disconnect } 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';
|
|
// Fetch version from JSON
|
|
const versionResponse = await fetch('./js/version.json');
|
|
const versionData = await versionResponse.json();
|
|
const VERSION = versionData.VERSION;
|
|
|
|
console.log(`[db.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;
|
|
|
|
// IndexedDB
|
|
let db = null;
|
|
const DB_NAME = 'ndk-shared';
|
|
|
|
// Grouped table state
|
|
let currentEvents = [];
|
|
let kindGroups = new Map();
|
|
let expandedKinds = new Set();
|
|
let kindViewState = new Map();
|
|
let rowLookupByKey = new Map();
|
|
|
|
const DEFAULT_PAGE_SIZE = 50;
|
|
const PAGE_SIZE_OPTIONS = [25, 50, 100];
|
|
|
|
|
|
/* ================================================================
|
|
DOM VARIABLES
|
|
================================================================ */
|
|
const divBody = document.getElementById("divBody");
|
|
const divSideNav = document.getElementById("divSideNav");
|
|
const divSideNavBody = document.getElementById("divSideNavBody");
|
|
const divSideNavInfo = document.getElementById("divSideNavInfo");
|
|
const divFooterCenter = document.getElementById("divFooterCenter");
|
|
const divFooterRight = document.getElementById("divFooterRight");
|
|
const divTable = document.getElementById("divTable");
|
|
const btnRefresh = document.getElementById("btnRefresh");
|
|
const rawJsonModal = document.getElementById("rawJsonModal");
|
|
const preRawJson = document.getElementById("preRawJson");
|
|
const btnRawJsonCopy = document.getElementById("btnRawJsonCopy");
|
|
const btnRawJsonClose = document.getElementById("btnRawJsonClose");
|
|
|
|
/* ================================================================
|
|
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');
|
|
}
|
|
if (divSideNavInfo) {
|
|
divSideNavInfo.textContent = "Database browser - view IndexedDB contents";
|
|
}
|
|
|
|
// 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)'
|
|
});
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
INDEXEDDB FUNCTIONS
|
|
================================================================ */
|
|
async function openDatabase() {
|
|
return new Promise((resolve, reject) => {
|
|
// Open without version to get current version
|
|
const request = indexedDB.open(DB_NAME);
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => resolve(request.result);
|
|
});
|
|
}
|
|
|
|
async function getAllEvents() {
|
|
if (!db) {
|
|
db = await openDatabase();
|
|
}
|
|
|
|
// Debug: log available stores
|
|
const availableStores = Array.from(db.objectStoreNames);
|
|
console.log('[db.html] Available object stores:', availableStores);
|
|
|
|
// Try different possible store names
|
|
const possibleStores = ['events', 'Event', 'ndk_events', 'cache', 'nostr_events'];
|
|
const storeName = possibleStores.find(s => availableStores.includes(s)) || availableStores[0];
|
|
|
|
if (!storeName) {
|
|
throw new Error('No object stores found in database');
|
|
}
|
|
|
|
console.log('[db.html] Using store:', storeName);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([storeName], 'readonly');
|
|
const store = transaction.objectStore(storeName);
|
|
const request = store.getAll();
|
|
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const results = request.result;
|
|
console.log('[db.html] Raw results from store:', results);
|
|
if (results.length > 0) {
|
|
console.log('[db.html] First event structure:', JSON.stringify(results[0], null, 2));
|
|
}
|
|
resolve(results);
|
|
};
|
|
});
|
|
}
|
|
|
|
function formatValue(value) {
|
|
if (value === null) return 'null';
|
|
if (value === undefined) return 'undefined';
|
|
if (typeof value === 'object') {
|
|
const json = JSON.stringify(value);
|
|
return json.length > 200 ? `${json.substring(0, 200)}...` : json;
|
|
}
|
|
const str = String(value);
|
|
return str.length > 200 ? `${str.substring(0, 200)}...` : str;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|
|
|
|
function normalizeForFilter(value) {
|
|
if (value === null || value === undefined) return '';
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
return String(value);
|
|
}
|
|
|
|
function wildcardToRegex(pattern) {
|
|
const escaped = pattern
|
|
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
.replaceAll('*', '.*');
|
|
return new RegExp(`^${escaped}$`, 'i');
|
|
}
|
|
|
|
function applyFilters(events, filters) {
|
|
const activeFilters = Object.entries(filters || {})
|
|
.filter(([, filterValue]) => String(filterValue || '').trim() !== '');
|
|
|
|
if (activeFilters.length === 0) {
|
|
return events;
|
|
}
|
|
|
|
return events.filter(evt => {
|
|
return activeFilters.every(([column, filterPattern]) => {
|
|
const value = normalizeForFilter(evt[column]);
|
|
const regex = wildcardToRegex(String(filterPattern).trim());
|
|
return regex.test(value);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getPrettyRawJson(rawValue) {
|
|
if (rawValue === null || rawValue === undefined) {
|
|
return '';
|
|
}
|
|
|
|
if (typeof rawValue === 'string') {
|
|
try {
|
|
return JSON.stringify(JSON.parse(rawValue), null, 2);
|
|
} catch {
|
|
return rawValue;
|
|
}
|
|
}
|
|
|
|
return JSON.stringify(rawValue, null, 2);
|
|
}
|
|
|
|
function sortEvents(events, column, direction) {
|
|
return [...events].sort((a, b) => {
|
|
let valA = a[column];
|
|
let valB = b[column];
|
|
|
|
if (column === 'kind' || column === 'created_at') {
|
|
valA = Number(valA) || 0;
|
|
valB = Number(valB) || 0;
|
|
} else {
|
|
valA = String(valA || '').toLowerCase();
|
|
valB = String(valB || '').toLowerCase();
|
|
}
|
|
|
|
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
|
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
function getPreferredColumns(events) {
|
|
const preferredColumns = ['kind', 'created_at', 'id', 'pubkey', 'content', 'tags', 'relay'];
|
|
const fields = new Set(preferredColumns);
|
|
events.forEach(evt => {
|
|
Object.keys(evt).forEach(key => {
|
|
if (key !== 'raw_event') {
|
|
fields.add(key);
|
|
}
|
|
});
|
|
});
|
|
return [
|
|
...preferredColumns,
|
|
...Array.from(fields).filter(col => !preferredColumns.includes(col))
|
|
];
|
|
}
|
|
|
|
function groupEventsByKind(events) {
|
|
const groups = new Map();
|
|
for (const evt of events) {
|
|
const kind = evt.kind ?? 'unknown';
|
|
if (!groups.has(kind)) {
|
|
groups.set(kind, []);
|
|
}
|
|
groups.get(kind).push(evt);
|
|
}
|
|
return groups;
|
|
}
|
|
|
|
function getKindState(kind) {
|
|
if (!kindViewState.has(kind)) {
|
|
kindViewState.set(kind, {
|
|
page: 1,
|
|
pageSize: DEFAULT_PAGE_SIZE,
|
|
sortColumn: 'created_at',
|
|
sortDirection: 'desc',
|
|
filters: {}
|
|
});
|
|
}
|
|
return kindViewState.get(kind);
|
|
}
|
|
|
|
function kindToKey(kind) {
|
|
return encodeURIComponent(String(kind));
|
|
}
|
|
|
|
function keyToKind(kindKey) {
|
|
const decoded = decodeURIComponent(String(kindKey));
|
|
const asNum = Number(decoded);
|
|
return Number.isFinite(asNum) && String(asNum) === decoded ? asNum : decoded;
|
|
}
|
|
|
|
function formatTimestamp(ts) {
|
|
const n = Number(ts);
|
|
if (!Number.isFinite(n) || n <= 0) return '-';
|
|
return new Date(n * 1000).toLocaleString();
|
|
}
|
|
|
|
function getKindTimeRange(events) {
|
|
let minTs = Number.POSITIVE_INFINITY;
|
|
let maxTs = Number.NEGATIVE_INFINITY;
|
|
events.forEach(evt => {
|
|
const n = Number(evt.created_at);
|
|
if (Number.isFinite(n) && n > 0) {
|
|
if (n < minTs) minTs = n;
|
|
if (n > maxTs) maxTs = n;
|
|
}
|
|
});
|
|
return {
|
|
oldest: Number.isFinite(minTs) ? minTs : null,
|
|
newest: Number.isFinite(maxTs) ? maxTs : null
|
|
};
|
|
}
|
|
|
|
window.viewRawEvent = function(rowKey) {
|
|
const evt = rowLookupByKey.get(String(rowKey));
|
|
if (!evt) return;
|
|
preRawJson.textContent = getPrettyRawJson(evt.raw_event);
|
|
rawJsonModal.classList.add('open');
|
|
}
|
|
|
|
window.copyRawEvent = async function(rowKey) {
|
|
const evt = rowLookupByKey.get(String(rowKey));
|
|
if (!evt) return;
|
|
const raw = getPrettyRawJson(evt.raw_event);
|
|
try {
|
|
await navigator.clipboard.writeText(raw);
|
|
} catch (error) {
|
|
console.error('[db.html] Failed to copy raw JSON:', error);
|
|
}
|
|
}
|
|
|
|
window.toggleKind = function(kindKey) {
|
|
const kind = keyToKind(kindKey);
|
|
if (expandedKinds.has(kind)) {
|
|
expandedKinds.delete(kind);
|
|
} else {
|
|
expandedKinds.add(kind);
|
|
getKindState(kind);
|
|
}
|
|
renderKindSummary();
|
|
}
|
|
|
|
window.handleKindSort = function(kindKey, column) {
|
|
const kind = keyToKind(kindKey);
|
|
const state = getKindState(kind);
|
|
if (state.sortColumn === column) {
|
|
state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
state.sortColumn = column;
|
|
state.sortDirection = 'asc';
|
|
}
|
|
state.page = 1;
|
|
renderKindSummary();
|
|
}
|
|
|
|
window.handleKindFilterInput = function(kindKey, column, value) {
|
|
const kind = keyToKind(kindKey);
|
|
const state = getKindState(kind);
|
|
state.filters[column] = value;
|
|
state.page = 1;
|
|
renderKindSummary();
|
|
}
|
|
|
|
window.handleKindPage = function(kindKey, delta) {
|
|
const kind = keyToKind(kindKey);
|
|
const state = getKindState(kind);
|
|
state.page = Math.max(1, state.page + Number(delta || 0));
|
|
renderKindSummary();
|
|
}
|
|
|
|
window.handleKindPageSize = function(kindKey, pageSize) {
|
|
const kind = keyToKind(kindKey);
|
|
const state = getKindState(kind);
|
|
state.pageSize = Number(pageSize) || DEFAULT_PAGE_SIZE;
|
|
state.page = 1;
|
|
renderKindSummary();
|
|
}
|
|
|
|
function renderExpandedKind(kind, events) {
|
|
const kindKey = kindToKey(kind);
|
|
const state = getKindState(kind);
|
|
const dataColumns = getPreferredColumns(events);
|
|
|
|
const filteredEvents = applyFilters(events, state.filters);
|
|
const sortedEvents = state.sortColumn
|
|
? sortEvents(filteredEvents, state.sortColumn, state.sortDirection)
|
|
: filteredEvents;
|
|
|
|
const total = sortedEvents.length;
|
|
const totalPages = Math.max(1, Math.ceil(total / state.pageSize));
|
|
if (state.page > totalPages) {
|
|
state.page = totalPages;
|
|
}
|
|
|
|
const startIndex = (state.page - 1) * state.pageSize;
|
|
const pageEvents = sortedEvents.slice(startIndex, startIndex + state.pageSize);
|
|
|
|
let html = '<div class="kindExpandedSection">';
|
|
html += '<table id="tblDB"><thead><tr>';
|
|
html += '<th>commands</th>';
|
|
dataColumns.forEach(col => {
|
|
const isSorted = state.sortColumn === col;
|
|
const sortArrow = isSorted ? (state.sortDirection === 'asc' ? ' ▲' : ' ▼') : '';
|
|
html += `<th onclick="handleKindSort('${kindKey}', '${col}')">${col}${sortArrow}</th>`;
|
|
});
|
|
html += '</tr>';
|
|
|
|
html += '<tr class="filter-row">';
|
|
html += '<th></th>';
|
|
dataColumns.forEach(col => {
|
|
const filterValue = state.filters[col] || '';
|
|
html += `<th><input class="filter-input" value="${escapeHtml(filterValue)}" oninput="handleKindFilterInput('${kindKey}', '${col}', this.value)" placeholder="* wildcard" /></th>`;
|
|
});
|
|
html += '</tr></thead><tbody>';
|
|
|
|
pageEvents.forEach((evt, idx) => {
|
|
const rowKey = `${kindKey}:${startIndex + idx}:${String(evt.id || '').slice(0, 32)}`;
|
|
rowLookupByKey.set(rowKey, evt);
|
|
html += '<tr>';
|
|
html += `<td><div class="divCommands"><button class="btnCmd" onclick="viewRawEvent('${rowKey}')">view</button><button class="btnCmd" onclick="copyRawEvent('${rowKey}')">copy</button></div></td>`;
|
|
dataColumns.forEach(col => {
|
|
html += `<td>${formatValue(evt[col])}</td>`;
|
|
});
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
|
|
const shownStart = total === 0 ? 0 : startIndex + 1;
|
|
const shownEnd = Math.min(startIndex + pageEvents.length, total);
|
|
const canPrev = state.page > 1;
|
|
const canNext = state.page < totalPages;
|
|
|
|
html += '<div class="paginationBar">';
|
|
html += `<button class="btnPage" ${canPrev ? '' : 'disabled'} onclick="handleKindPage('${kindKey}', -1)">Prev</button>`;
|
|
html += `<span>Page ${state.page} / ${totalPages}</span>`;
|
|
html += `<button class="btnPage" ${canNext ? '' : 'disabled'} onclick="handleKindPage('${kindKey}', 1)">Next</button>`;
|
|
html += '<span>Show:</span>';
|
|
PAGE_SIZE_OPTIONS.forEach(size => {
|
|
const activeClass = state.pageSize === size ? 'active' : '';
|
|
html += `<button class="btnPage ${activeClass}" onclick="handleKindPageSize('${kindKey}', ${size})">${size}</button>`;
|
|
});
|
|
html += `<span>Showing ${shownStart}-${shownEnd} of ${total}</span>`;
|
|
html += '</div>';
|
|
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderKindSummary() {
|
|
rowLookupByKey = new Map();
|
|
|
|
const sortedKinds = Array.from(kindGroups.keys()).sort((a, b) => {
|
|
const na = Number(a);
|
|
const nb = Number(b);
|
|
const aNum = Number.isFinite(na);
|
|
const bNum = Number.isFinite(nb);
|
|
if (aNum && bNum) return na - nb;
|
|
return String(a).localeCompare(String(b));
|
|
});
|
|
|
|
const totalEvents = currentEvents.length;
|
|
divFooterCenter.textContent = `${totalEvents.toLocaleString()} events across ${sortedKinds.length.toLocaleString()} kinds`;
|
|
|
|
let html = '<table class="kindSummaryTable">';
|
|
html += '<thead><tr><th></th><th>kind</th><th>count</th><th>newest</th><th>oldest</th></tr></thead><tbody>';
|
|
|
|
sortedKinds.forEach(kind => {
|
|
const events = kindGroups.get(kind) || [];
|
|
const kindKey = kindToKey(kind);
|
|
const expanded = expandedKinds.has(kind);
|
|
const range = getKindTimeRange(events);
|
|
|
|
html += `<tr class="kindSummaryRow" onclick="toggleKind('${kindKey}')">`;
|
|
html += `<td>${expanded ? '▼' : '▶'}</td>`;
|
|
html += `<td>${escapeHtml(kind)}</td>`;
|
|
html += `<td>${events.length.toLocaleString()}</td>`;
|
|
html += `<td>${formatTimestamp(range.newest)}</td>`;
|
|
html += `<td>${formatTimestamp(range.oldest)}</td>`;
|
|
html += '</tr>';
|
|
|
|
if (expanded) {
|
|
html += '<tr><td class="kindExpandedCell" colspan="5">';
|
|
html += renderExpandedKind(kind, events);
|
|
html += '</td></tr>';
|
|
}
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
divTable.innerHTML = html;
|
|
}
|
|
|
|
function normalizeDisplayId(id, kind) {
|
|
const rawId = String(id ?? '');
|
|
const prefix = `${kind}:`;
|
|
if (rawId.startsWith(prefix)) return rawId.slice(prefix.length);
|
|
return rawId;
|
|
}
|
|
|
|
async function loadEvents() {
|
|
try {
|
|
const events = await getAllEvents();
|
|
console.log(`[db.html] Loaded ${events.length} events`);
|
|
|
|
if (events.length === 0) {
|
|
divFooterCenter.textContent = '0 events across 0 kinds';
|
|
divTable.innerHTML = '<div style="text-align: center; padding: 50px;">No events in IndexedDB</div>';
|
|
return;
|
|
}
|
|
|
|
// Parse events - the 'event' field contains the full serialized event
|
|
currentEvents = events.map(evt => {
|
|
try {
|
|
const parsed = JSON.parse(evt.event);
|
|
const parsedKind = Array.isArray(parsed) ? parsed[0] : parsed.kind;
|
|
const parsedId = Array.isArray(parsed) ? parsed[6] : parsed.id;
|
|
const kind = evt.kind ?? parsedKind;
|
|
const content = Array.isArray(parsed) ? (parsed[5] || '') : (parsed.content || '');
|
|
const tags = Array.isArray(parsed) ? (parsed[4] || []) : (parsed.tags || []);
|
|
return {
|
|
id: normalizeDisplayId(parsedId ?? evt.id, kind),
|
|
kind,
|
|
pubkey: evt.pubkey,
|
|
created_at: evt.createdAt ?? evt.created_at,
|
|
content,
|
|
tags,
|
|
relay: evt.relay,
|
|
raw_event: evt.event
|
|
};
|
|
} catch (e) {
|
|
console.error('[db.html] Failed to parse event:', evt.id, e);
|
|
return {
|
|
id: normalizeDisplayId(evt.id, evt.kind),
|
|
kind: evt.kind,
|
|
pubkey: evt.pubkey,
|
|
created_at: evt.createdAt ?? evt.created_at,
|
|
content: 'PARSE ERROR',
|
|
tags: [],
|
|
relay: evt.relay,
|
|
raw_event: evt.event
|
|
};
|
|
}
|
|
});
|
|
|
|
kindGroups = groupEventsByKind(currentEvents);
|
|
renderKindSummary();
|
|
} catch (error) {
|
|
console.error('[db.html] Error loading events:', error);
|
|
divTable.innerHTML = `<div style="text-align: center; padding: 50px; color: red;">Error: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function initDatabase() {
|
|
try {
|
|
db = await openDatabase();
|
|
console.log('[db.html] Database opened, version:', db.version);
|
|
await loadEvents();
|
|
} catch (error) {
|
|
console.error('[db.html] Error opening database:', error);
|
|
divTable.innerHTML = `<div style="text-align: center; padding: 50px; color: red;">Error opening database: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
UPDATE FOOTER
|
|
================================================================ */
|
|
const UpdateFooter = async () => {
|
|
try {
|
|
await updateFooterRelayStatus();
|
|
await updateSidenavRelaySection();
|
|
|
|
await updateBlossomSection();
|
|
divFooterRight.innerHTML = '';
|
|
} catch (error) {
|
|
console.error('[db.html] Error updating footer:', error);
|
|
}
|
|
};
|
|
|
|
/* ================================================================
|
|
LOGOUT
|
|
================================================================ */
|
|
const Logout = async () => {
|
|
console.log("[db.html] Starting logout process...");
|
|
|
|
if (updateIntervalId) {
|
|
clearInterval(updateIntervalId);
|
|
updateIntervalId = null;
|
|
}
|
|
|
|
disconnect();
|
|
|
|
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
|
|
await window.NOSTR_LOGIN_LITE.logout();
|
|
}
|
|
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
|
|
if (window.indexedDB) {
|
|
const databases = await window.indexedDB.databases();
|
|
for (const dbInfo of databases) {
|
|
if (dbInfo.name) {
|
|
window.indexedDB.deleteDatabase(dbInfo.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log("[db.html] Logged out, reloading page");
|
|
location.reload(true);
|
|
};
|
|
|
|
/* ================================================================
|
|
EVENT LISTENERS
|
|
================================================================ */
|
|
function setupEventListeners() {
|
|
// Hamburger
|
|
const divSvgHam = document.getElementById('divSvgHam');
|
|
if (divSvgHam) {
|
|
divSvgHam.addEventListener('click', toggleNav);
|
|
}
|
|
|
|
// Refresh button
|
|
if (btnRefresh) {
|
|
btnRefresh.addEventListener('click', () => {
|
|
loadEvents();
|
|
});
|
|
}
|
|
|
|
// Raw JSON modal
|
|
if (btnRawJsonClose) {
|
|
btnRawJsonClose.addEventListener('click', () => {
|
|
rawJsonModal.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
if (btnRawJsonCopy) {
|
|
btnRawJsonCopy.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(preRawJson.textContent || '');
|
|
} catch (error) {
|
|
console.error('[db.html] Failed to copy modal raw JSON:', error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
INITIALIZATION
|
|
================================================================ */
|
|
(async function main() {
|
|
console.log("[db.html] Starting initialization...");
|
|
|
|
try {
|
|
// Initialize hamburger menu
|
|
initHamburgerMenu();
|
|
|
|
// Setup event listeners
|
|
setupEventListeners();
|
|
|
|
// Initialize NDK
|
|
await initNDKPage();
|
|
|
|
// Get authenticated pubkey
|
|
currentPubkey = await getPubkey();
|
|
await injectHeaderAvatar(currentPubkey);
|
|
console.log("[db.html] Authenticated as:", currentPubkey);
|
|
|
|
// Initialize relay UI
|
|
initFooterRelayStatus();
|
|
initSidenavRelaySection();
|
|
|
|
await initBlossomSection();
|
|
initAiSectionWithLocalConfig();
|
|
|
|
// Listen for relay activity
|
|
window.addEventListener('ndkRelayActivity', (event) => {
|
|
const { relayUrl, activity, stats } = event.detail;
|
|
setRelayActivityState(relayUrl, activity);
|
|
});
|
|
|
|
// Initialize database and load events automatically
|
|
await initDatabase();
|
|
|
|
// Restore sidenav state
|
|
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
|
|
if (sidenavWasOpen === 'true') {
|
|
localStorage.removeItem('sidenavWasOpen');
|
|
openNav();
|
|
}
|
|
|
|
// Start update loop
|
|
updateIntervalId = setInterval(UpdateFooter, 1000);
|
|
|
|
// Update version display
|
|
document.getElementById('versionDisplay').textContent = VERSION;
|
|
|
|
console.log('[db.html] Initialization complete');
|
|
} catch (error) {
|
|
console.error('[db.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>
|