Files

2732 lines
122 KiB
HTML

<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>BLOBS</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="stylesheet" href="./css/post-composer.css" />
<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>
<!-- 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>
<style media="screen">
#divBody {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
align-content: flex-start;
height: 90%;
width: 100%;
font-family: var(--font-family);
padding: 7px;
overflow-y: auto;
}
.divSection {
width: 98%;
margin-bottom: 30px;
border: var(--border-width) solid var(--primary-color);
border-radius: 10px;
padding: 20px;
background-color: var(--secondary-color);
}
.divSectionTitle {
/* color: var(--accent-color); */
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;
}
.divServerConfig {
display: flex;
flex-direction: column;
gap: 15px;
}
.divConfigRow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.inpServer {
flex: 1;
min-width: 300px;
padding: 8px;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
}
.btnServer {
color: var(--primary-color);
padding: 6px;
border: var(--border-width) solid var(--primary-color);
border-radius: var(--border-radius);
background-color: var(--secondary-color);
cursor: pointer;
font-family: var(--font-family) !important;
font-size: 0.65em !important;
}
/* Muted variant for inactive toggle buttons */
.btnServer.btnMuted {
border: var(--border-width) solid var(--muted-color);
color: var(--muted-color);
}
/* Active/selected filter button — inverted colors */
.btnServer.btnFilter.active {
background-color: var(--primary-color);
color: var(--secondary-color);
border-color: var(--primary-color);
}
/* Inverted active state for rows/col toggle buttons */
.btnServer.active {
background-color: var(--primary-color);
color: var(--secondary-color);
border-color: var(--primary-color);
}
/* Post composer popup — make wrapper fill the inner panel */
#divPostPopupInner .post-composer-wrapper {
width: 100%;
box-sizing: border-box;
}
#divPostPopupInner .post-composer-editor-shell {
width: 100%;
box-sizing: border-box;
}
/* Wide pagination arrow buttons */
.btnPageNav {
min-width: 8em;
}
/* Text blob preview */
.divBlobTextPreview {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 12% 14% 10%;
overflow: hidden;
gap: 6px;
}
.divBlobTextLabel {
font-family: var(--font-family);
font-size: 70%;
font-weight: bold;
color: var(--muted-color);
letter-spacing: 0.1em;
opacity: 0.6;
flex-shrink: 0;
}
.divBlobTextContent {
font-family: var(--font-family);
font-size: 68%;
color: var(--muted-color);
line-height: 1.4;
word-break: break-word;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 8;
text-align: left;
width: 100%;
}
/* Invisible gutter zones for pagination */
#divGutterLeft,
#divGutterRight {
position: fixed;
top: 0;
bottom: 0;
/* Keep gutters narrow so they don't overlap top toolbar buttons */
width: max(12px, calc((100vw - 98vw) / 2));
z-index: 1;
cursor: pointer;
}
#divGutterLeft { left: 0; }
#divGutterRight { right: 0; }
.btnServer:hover {
background-color: var(--button-hover-color);
}
.tblBlobs {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.tblBlobs th,
.tblBlobs td {
border: var(--border-width) solid var(--muted-color);
padding: 8px;
text-align: left;
font-family: var(--font-family);
font-size: 85%;
}
.tblBlobs th {
background-color: var(--muted-color);
font-weight: bold;
}
.tblBlobs tr:hover {
background-color: var(--muted-color);
}
.btnAction {
padding: 4px 8px;
margin: 2px;
border: var(--border-width) solid var(--muted-color);
border-radius: 3px;
background-color: var(--secondary-color);
cursor: pointer;
font-size: 80%;
font-family: var(--font-family);
}
.btnAction:hover {
background-color: var(--accent-color);
color: var(--secondary-color);
}
.btnDelete {
background-color: var(--muted-color);
border-color: var(--accent-color);
color: var(--accent-color);
}
.btnDelete:hover {
background-color: var(--accent-color);
color: var(--secondary-color);
}
.btnDelete.confirm {
background-color: var(--accent-color);
color: var(--secondary-color);
}
.divBlobPreview {
max-width: 100px;
max-height: 60px;
overflow: hidden;
border-radius: var(--border-radius);
}
.imgBlobPreview {
width: 100%;
height: auto;
border-radius: 3px;
filter: grayscale(var(--image-grayscale));
}
.imgBlobPreview:hover {
filter: grayscale(var(--image-grayscale-hover));
}
.divServerStatus {
display: flex;
align-items: center;
gap: 10px;
margin-top: 10px;
}
.statusIndicator {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: var(--muted-color);
}
.statusIndicator.connected {
background-color: var(--accent-color);
}
.statusIndicator.error {
background-color: var(--accent-color);
}
.statusIndicator.warning {
background-color: var(--accent-color);
}
.divUploadRequirements {
background-color: var(--muted-color);
padding: 15px;
border-radius: var(--border-radius);
margin-top: 15px;
font-size: 90%;
}
.requirementItem {
display: flex;
justify-content: space-between;
margin: 5px 0;
}
.divBlobInfo {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 85%;
}
.hashText {
font-family: var(--font-family);
word-break: break-all;
font-size: 80%;
color: var(--muted-color);
}
.sizeText {
color: var(--muted-color);
}
.typeText {
color: var(--muted-color);
font-style: italic;
}
/* Grid view styles */
.divBlobGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 15px;
margin-top: 15px;
}
.divBlobGridItem {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
padding: 0;
background-color: var(--secondary-color);
aspect-ratio: 1 / 1;
/* transition: all 0.2s ease; */
}
/* Removed hover effect from grid item */
.imgBlobGridPreview {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
object-fit: cover;
border-radius: var(--border-radius);
filter: grayscale(var(--image-grayscale));
}
.imgBlobGridPreview:hover {
filter: grayscale(var(--image-grayscale-hover));
}
.divServerIndicators {
position: absolute;
bottom: 5px;
left: 5px;
display: flex;
gap: 5px;
flex-wrap: wrap;
justify-content: flex-start;
z-index: 1;
opacity: 0;
transition: opacity 0.2s ease;
}
.divBlobGridItem:hover .divServerIndicators {
opacity: 1;
}
/* Selection button — upper left of grid item */
.btnSelectBlob {
position: absolute;
top: 5px;
left: 5px;
cursor: pointer;
padding: 3px;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color);
background-color: var(--secondary-color);
transition: border-color 0.2s ease, opacity 0.2s ease;
z-index: 2;
width: 24px;
height: 24px;
font-family: var(--font-family);
font-size: 0.65em;
font-weight: bold;
opacity: 0;
}
.divBlobGridItem:hover .btnSelectBlob {
opacity: 1;
}
/* Keep visible when selected */
.btnSelectBlob.selected {
opacity: 1;
border-color: var(--accent-color);
color: var(--accent-color);
}
/* Selected item border */
.divBlobGridItem.selected {
border-color: var(--accent-color);
}
.btnDeleteBlob {
position: absolute;
top: 5px;
right: 5px;
cursor: pointer;
padding: 3px;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary-color);
background-color: var(--secondary-color);
transition: border-color 0.2s ease, opacity 0.2s ease;
z-index: 2;
width: 24px;
height: 24px;
opacity: 0;
}
.divBlobGridItem:hover .btnDeleteBlob {
opacity: 1;
}
.btnDeleteBlob:hover {
border-color: var(--accent-color);
}
.deleteIconContainer {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.spanServerKanji {
cursor: pointer;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
background-color: var(--primary-color);
transition: border-color 0.2s ease;
width: 24px;
height: 24px;
font-family: pageKanji;
font-size: 85%;
}
.spanServerKanji.available:hover {
border-color: var(--accent-color);
}
.spanServerKanji.available {
font-weight: bold;
color: var(--accent-color);
/* color: var(--secondary-color); */
}
.spanServerKanji.unavailable {
/* color: var(--muted-color); */
color: grey;
}
/* Upload box in grid view */
.divBlobGridUpload {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed var(--accent-color);
border-radius: var(--border-radius);
padding: 20px;
background-color: var(--secondary-color);
cursor: pointer;
transition: all 0.3s ease;
aspect-ratio: 1 / 1;
text-align: center;
}
.divBlobGridUpload svg {
transition: stroke 0.3s ease;
stroke: var(--muted-color);
}
.divBlobGridUpload:hover svg {
stroke: var(--accent-color);
}
.divBlobGridUpload.dragover {
border-color: var(--accent-color);
}
.divBlobGridUpload.dragover svg {
stroke: var(--accent-color);
}
.uploadGridText {
font-size: 120%;
margin-bottom: 8px;
color: var(--primary-color);
}
.uploadGridSubtext {
color: var(--muted-color);
font-size: 85%;
}
@media (max-width: 768px) {
#divBody {
padding: 4px;
}
.divSection {
width: 99%;
padding: 15px;
}
.divConfigRow {
flex-direction: column;
align-items: stretch;
}
.inpServer {
min-width: unset;
}
.divBlobGrid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px;
}
}
</style>
</head>
<body>
<!-- Hamburger button as separate fixed element -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- HEADER -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">ブロブ</div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- Invisible gutter click zones for pagination -->
<div id="divGutterLeft"></div>
<div id="divGutterRight"></div>
<!-- BODY -->
<div id="divBody" class="clsBodyFull">
<!-- Blob Management Section -->
<div class="divSection">
<div style="display: flex; gap: 10px; margin-bottom: 15px; flex-wrap: wrap; align-items: center;">
<button id="btnRefreshBlobs" class="btn btnServer">Refresh</button>
<button id="btnToggleView" class="btn btnServer">List View</button>
<button id="btnSyncBlobs" class="btn btnServer">Mirror</button>
<button id="btnPostSelected" class="btn btnServer btnMuted" disabled>Post Selected</button>
<button id="btnDeleteSelected" class="btn btnServer btnMuted" disabled>Delete Selected</button>
<div style="width: 1px; background: var(--muted-color); align-self: stretch; margin: 0 4px;"></div>
<button id="btnFilterImages" class="btn btnServer btnFilter active">Images</button>
<button id="btnFilterText" class="btn btnServer btnFilter active">Text</button>
<button id="btnFilterBinary" class="btn btnServer btnFilter active">Binary</button>
<span style="margin-left: auto; display: flex; align-items: center; gap: 6px; font-size: 90%; color: #666;">
<button id="btnPagePrev" class="btn btnServer btnMuted btnPageNav"></button>
<span id="pageIndicator"></span>
&nbsp;(<span id="totalBlobCount">0</span>)
<button id="btnPageNext" class="btn btnServer btnMuted btnPageNav"></button>
</span>
</div>
<div id="divBlobList" style="overflow-x: auto; display: none;">
<table id="tblBlobs" class="tblBlobs">
<thead>
<tr>
<th>Preview</th>
<th>Blob Details</th>
</tr>
</thead>
<tbody id="tblBlobsBody">
<!-- Blob rows will be inserted here -->
</tbody>
</table>
</div>
<div id="divBlobGrid" class="divBlobGrid">
<!-- Grid items will be inserted here -->
</div>
</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">
</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" class="sidenavSection">
<div id="divRelaySectionTitle" class="sidenavSectionTitle">
リレー
</div>
<div id="divRelayList" class="sidenavSectionList">
Loading relays...
</div>
</div>
<div id="divBlossomSection" class="sidenavSection">
<div id="divBlossomSectionTitle" class="sidenavSectionTitle">
ブロッサム
</div>
<div id="divBlossomList" class="sidenavSectionList">
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>
<!-- Post composer popup -->
<div id="divPostPopup" style="display:none; position:fixed; inset:0; z-index:100; background:rgba(0,0,0,0.55); align-items:center; justify-content:center;">
<div id="divPostPopupInner" style="width:min(680px,92vw); height:80vh; background:var(--secondary-color); border:var(--border-width) solid var(--primary-color); border-radius:10px; padding:24px; display:flex; flex-direction:column; gap:12px; position:relative; box-sizing:border-box;">
<button id="btnClosePostPopup" style="position:absolute; top:10px; right:12px; background:none; border:none; cursor:pointer; font-size:130%; color:var(--muted-color); line-height:1;"></button>
<div id="divPostComposerHost" style="width:100%; min-height:8em; border:var(--border-width) solid var(--primary-color); border-radius:var(--border-radius); padding:8px; font-family:var(--font-family); box-sizing:border-box;"></div>
</div>
</div>
<script src="./nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
//////////////////////////////////////////////////////////////////////
// IMPORTS
//////////////////////////////////////////////////////////////////////
import { initNDKPage, getPubkey, injectHeaderAvatar, subscribe, publishEvent, disconnect, getRelayData, getRelayStats, getVersion, updateVersionDisplay, getUserSettings, patchUserSettings, onUserSettings } from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { mountComposer } from './js/post-composer.mjs';
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState as setRelayActivityStateUI } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection, getBlossomServers } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
import { calculateSHA256 as calculateSHA256Api, createBlossomAuth, listUserBlobs as listUserBlobsApi, uploadToAllServers, uploadToServer, deleteFromAllServers, mirrorBlobToServers } from './js/blossom-api.mjs';
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[blobs.html ${VERSION}] Loading...`);
//////////////////////////////////////////////////////////////////////
// GLOBAL VARIABLES
//////////////////////////////////////////////////////////////////////
let currentPubkey = null;
let USER_BLOBS = [];
let USER_SERVER_LIST = []; // Array of server objects: {url, upload, mirror, healthy, healthStatus, healthMessage, redirected, redirectUrl, healthEndpoint, warning}
let BLOB_VIEW_MODE = 'grid'; // 'list' or 'grid'
let rowsPerPage = 0; // 0 = auto; loaded from user settings on init
let currentPage = 0; // zero-indexed
let initialBlobRefreshTriggered = false;
// hiddenFilters: categories the user has clicked off. Empty = show all.
let hiddenFilters = new Set();
// Ordered selection list — each entry is a blob sha256, in selection order
let selectedBlobs = [];
let updateIntervalId = null;
let serverListSubscription = null;
// Relay status visualization
let relayStatusInstances = new Map(); // relay URL -> HamburgerMorphing instance
let relayActivityStates = new Map(); // relay URL -> { activity: 'read'|'write', timeout: id }
let lastRelayShapes = new Map(); // relay URL -> last rendered shape
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
//////////////////////////////////////////////////////////////////////
// DOM ELEMENTS
//////////////////////////////////////////////////////////////////////
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");
// Blob management elements
const btnRefreshBlobs = document.getElementById("btnRefreshBlobs");
const btnPagePrev = document.getElementById("btnPagePrev");
const btnPageNext = document.getElementById("btnPageNext");
const btnToggleView = document.getElementById("btnToggleView");
const btnSyncBlobs = document.getElementById("btnSyncBlobs");
const btnFilterImages = document.getElementById("btnFilterImages");
const btnFilterText = document.getElementById("btnFilterText");
const btnFilterBinary = document.getElementById("btnFilterBinary");
const btnPostSelected = document.getElementById("btnPostSelected");
const btnDeleteSelected = document.getElementById("btnDeleteSelected");
const divPostPopup = document.getElementById("divPostPopup");
const divPostComposerHost = document.getElementById("divPostComposerHost");
const btnClosePostPopup = document.getElementById("btnClosePostPopup");
const totalBlobCount = document.getElementById("totalBlobCount");
const pageIndicator = document.getElementById("pageIndicator");
const tblBlobsBody = document.getElementById("tblBlobsBody");
const divBlobList = document.getElementById("divBlobList");
const divBlobGrid = document.getElementById("divBlobGrid");
//////////////////////////////////////////////////////////////////////
// HAMBURGER MENU
//////////////////////////////////////////////////////////////////////
let hamburgerInstance = null;
let isNavOpen = false;
// Initialize hamburger menu with morphing animation
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Constructor's init() already creates the hamburger, no need to call render
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');
}
loadSideNav();
// 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();
}
}
//////////////////////////////////////////////////////////////////////
// BUD-03 SERVER LIST FUNCTIONS
//////////////////////////////////////////////////////////////////////
const PublishServerList = async (serverList) => {
if (!currentPubkey) {
throw new Error('Please login with your Nostr keys first');
}
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 10063,
tags: [],
content: ''
};
// Add server tags with upload/mirror preferences
for (const server of serverList) {
const tag = ['server', server.url];
if (server.upload) tag.push('upload');
if (server.mirror) tag.push('mirror');
event.tags.push(tag);
}
console.log('[blobs.html] Publishing server list event:', event);
try {
const result = await publishEvent(event);
console.log('[blobs.html] Server list published to:', result.relayResults.successful);
showSuccess(`Published server list with ${serverList.length} servers to ${result.relayResults.successful.length} relays`);
} catch (error) {
console.error('[blobs.html] Failed to publish server list:', error);
showError(`Failed to publish server list: ${error.message}`);
}
};
const UpdateServerList = async (serverListEvent) => {
if (!serverListEvent) return;
// Extract server data from tags
USER_SERVER_LIST = [];
for (const tag of serverListEvent.tags) {
if (tag[0] === 'server' && tag[1]) {
const serverObj = {
url: tag[1],
upload: tag.includes('upload'),
mirror: tag.includes('mirror'),
healthy: false, // Will be checked
healthStatus: 0,
healthMessage: 'Not checked',
redirected: false,
redirectUrl: null,
healthEndpoint: false,
warning: false
};
USER_SERVER_LIST.push(serverObj);
}
}
console.log('Updated server list:', USER_SERVER_LIST);
// Check health of all servers
await checkAllServersHealth();
updateServerListUI();
};
const checkAllServersHealth = async () => {
console.log('Checking health of all servers...');
for (const server of USER_SERVER_LIST) {
try {
const healthResult = await checkServerHealth(server.url);
if (healthResult) {
server.healthy = healthResult.healthy;
server.healthStatus = healthResult.status;
server.healthMessage = healthResult.statusText;
server.redirected = healthResult.redirected || false;
server.redirectUrl = healthResult.redirectUrl || null;
server.healthEndpoint = healthResult.healthEndpoint || false;
server.warning = healthResult.warning || false;
} else {
// Handle undefined result
server.healthy = false;
server.healthStatus = 0;
server.healthMessage = 'Health check returned undefined';
server.redirected = false;
server.redirectUrl = null;
server.healthEndpoint = false;
server.warning = false;
}
} catch (error) {
console.warn(`Health check failed for ${server.url}:`, error);
server.healthy = false;
server.healthStatus = 0;
server.healthMessage = error.message;
server.redirected = false;
server.redirectUrl = null;
server.healthEndpoint = false;
server.warning = false;
}
}
};
const checkServerHealth = async (serverUrl) => {
const cleanUrl = serverUrl.replace(/\/$/, '');
try {
// First try the standard /health endpoint
let response = await fetch(`${cleanUrl}/health`, {
method: 'GET',
mode: 'cors',
cache: 'no-cache',
redirect: 'manual' // Don't follow redirects automatically
});
// Check if it's a redirect (3xx status)
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
if (location) {
console.log(`Server ${serverUrl} redirected to: ${location}`);
// Try the redirected URL
try {
const redirectResponse = await fetch(location, {
method: 'GET',
mode: 'cors',
cache: 'no-cache'
});
return {
healthy: redirectResponse.ok,
status: redirectResponse.status,
statusText: redirectResponse.statusText,
redirected: true,
redirectUrl: location,
healthEndpoint: redirectResponse.ok
};
} catch (redirectError) {
console.warn(`Redirect health check failed for ${location}:`, redirectError);
return {
healthy: false,
status: response.status,
statusText: `Redirect failed: ${redirectError.message}`,
redirected: true,
redirectUrl: location,
healthEndpoint: false
};
}
}
}
// If /health endpoint works (200 OK), return success
if (response.ok) {
return {
healthy: true,
status: response.status,
statusText: response.statusText,
redirected: false,
healthEndpoint: true
};
}
// If /health returns 401, the server is working but requires auth
if (response.status === 401) {
console.log(`Health endpoint requires auth for ${serverUrl} - server is healthy`);
return {
healthy: true,
status: response.status,
statusText: 'Server online (requires auth)',
redirected: false,
healthEndpoint: true,
warning: true
};
}
// If /health returns any other non-200 status (404, 400, CORS error, etc.), try checking if server root is accessible
console.log(`Health endpoint returned ${response.status} for ${serverUrl}, checking server root...`);
} catch (healthError) {
// /health endpoint failed (CORS, network error, etc.)
console.log(`Health endpoint check failed for ${serverUrl}: ${healthError.message}, checking server root...`);
}
// Check if server root is accessible (fallback for servers without proper /health)
try {
const rootResponse = await fetch(cleanUrl, {
method: 'HEAD',
mode: 'cors',
cache: 'no-cache'
});
// If root returns 401, server is working but requires auth
if (rootResponse.status === 401) {
console.log(`Server root requires auth for ${serverUrl} - server is healthy`);
return {
healthy: true,
status: rootResponse.status,
statusText: 'Server online (requires auth)',
redirected: false,
healthEndpoint: false,
warning: true
};
}
// If root is accessible, consider server "online" but with non-standard health endpoint
if (rootResponse.ok) {
return {
healthy: true, // Allow using the server
status: rootResponse.status,
statusText: 'Server online (no /health endpoint)',
redirected: false,
healthEndpoint: false,
warning: true // Mark as warning status
};
} else {
return {
healthy: false,
status: rootResponse.status,
statusText: rootResponse.statusText,
redirected: false,
healthEndpoint: false
};
}
} catch (rootError) {
console.warn(`Both health and root checks failed for ${serverUrl}:`, rootError);
return {
healthy: false,
status: 0,
statusText: `Server unreachable: ${rootError.message}`,
redirected: false,
healthEndpoint: false
};
}
};
// Convert number to Japanese Kanji
const numberToKanji = (num) => {
const kanjiNumbers = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const kanjiTens = ['', '十', '二十', '三十', '四十', '五十', '六十', '七十', '八十', '九十'];
if (num === 0) return kanjiNumbers[0];
if (num < 10) return kanjiNumbers[num];
if (num < 100) {
const tens = Math.floor(num / 10);
const ones = num % 10;
return kanjiTens[tens] + (ones > 0 ? kanjiNumbers[ones] : '');
}
return num.toString(); // Fallback for numbers >= 100
};
const updateServerListUI = () => {
const serverListContainer = document.getElementById('serverListContainer');
if (!serverListContainer) return;
serverListContainer.innerHTML = '';
const serverCountElement = document.getElementById('serverCount');
if (serverCountElement) {
serverCountElement.textContent = USER_SERVER_LIST.length;
}
if (USER_SERVER_LIST.length === 0) {
serverListContainer.innerHTML = '<div style="color: var(--muted-color); font-style: italic;">No servers configured</div>';
return;
}
USER_SERVER_LIST.forEach((server, index) => {
const serverItem = document.createElement('div');
serverItem.className = 'server-item';
serverItem.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
margin: 8px 0;
border: var(--border-width) solid var(--muted-color);
border-radius: var(--border-radius);
background-color: var(--secondary-color);
`;
const fontWeight = server.healthy ? 'bold' : 'normal';
const disabled = server.healthy ? '' : 'disabled';
const kanjiNumber = numberToKanji(index);
const kanjiColor = server.healthy ? 'var(--accent-color)' : 'var(--muted-color)';
const urlColor = server.healthy ? 'var(--primary-color)' : 'var(--muted-color)';
serverItem.innerHTML = `
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 120%; font-weight: bold; color: ${kanjiColor};">${kanjiNumber}</span>
<span style="font-family: var(--font-family); font-size: 85%; font-weight: ${fontWeight}; color: ${urlColor};">
${server.url}
</span>
</div>
<div style="display: flex; align-items: center; gap: 10px;">
<button type="button" class="btn btnServer ${server.upload ? '' : 'btnMuted'}" ${disabled}
onclick="toggleServerUploadUI(${index}, this)">Upload</button>
<button type="button" class="btn btnServer ${server.mirror ? '' : 'btnMuted'}" ${disabled}
onclick="toggleServerMirrorUI(${index}, this)">Mirror</button>
<button class="btn btnServer" onclick="testServerFromList('${server.url}')">Test</button>
<button class="btn btnServer" onclick="removeServerFromList(${index})">Remove</button>
</div>
`;
serverListContainer.appendChild(serverItem);
});
};
const addServerToList = async () => {
const inpNewServer = document.getElementById('inpNewServer');
if (!inpNewServer) {
showError('Server input field not found');
return;
}
const serverUrl = inpNewServer.value.trim();
if (!serverUrl) {
showError('Please enter a server URL');
return;
}
// Clean URL
const cleanUrl = serverUrl.replace(/\/$/, '');
if (USER_SERVER_LIST.some(server => server.url === cleanUrl)) {
showError('Server already in list');
return;
}
// Test server health before adding
try {
const btnAddServer = document.getElementById('btnAddServer');
const inpNewServer = document.getElementById('inpNewServer');
if (btnAddServer) {
btnAddServer.textContent = 'Testing...';
btnAddServer.disabled = true;
}
const healthResult = await checkServerHealth(cleanUrl);
const newServer = {
url: cleanUrl,
upload: healthResult ? healthResult.healthy : false, // Default to upload if healthy
mirror: false,
healthy: healthResult ? healthResult.healthy : false,
healthStatus: healthResult ? healthResult.status : 0,
healthMessage: healthResult ? healthResult.statusText : 'Health check failed',
redirected: healthResult ? (healthResult.redirected || false) : false,
redirectUrl: healthResult ? (healthResult.redirectUrl || null) : null,
healthEndpoint: healthResult ? (healthResult.healthEndpoint || false) : false,
warning: healthResult ? (healthResult.warning || false) : false
};
USER_SERVER_LIST.push(newServer);
updateServerListUI();
if (inpNewServer) {
inpNewServer.value = '';
}
if (healthResult.healthy) {
showSuccess(`Added healthy server: ${cleanUrl}`);
} else {
showSuccess(`Added server: ${cleanUrl} (${healthResult.status} ${healthResult.statusText})`);
}
} catch (error) {
showError(`Failed to test server: ${error.message}`);
} finally {
const btnAddServer = document.getElementById('btnAddServer');
if (btnAddServer) {
btnAddServer.textContent = 'Add Server';
btnAddServer.disabled = false;
}
}
};
const removeServerFromList = (index) => {
if (index >= 0 && index < USER_SERVER_LIST.length) {
const removedServer = USER_SERVER_LIST.splice(index, 1)[0];
updateServerListUI();
showSuccess(`Removed server: ${removedServer.url}`);
}
};
const testServerFromList = async (serverUrl) => {
try {
const healthResult = await checkServerHealth(serverUrl);
// Update the server's health status
const server = USER_SERVER_LIST.find(s => s.url === serverUrl);
if (server) {
if (healthResult) {
server.healthy = healthResult.healthy;
server.healthStatus = healthResult.status;
server.healthMessage = healthResult.statusText;
server.redirected = healthResult.redirected || false;
server.redirectUrl = healthResult.redirectUrl || null;
server.healthEndpoint = healthResult.healthEndpoint || false;
server.warning = healthResult.warning || false;
} else {
server.healthy = false;
server.healthStatus = 0;
server.healthMessage = 'Health check returned undefined';
server.redirected = false;
server.redirectUrl = null;
server.healthEndpoint = false;
server.warning = false;
}
updateServerListUI();
}
if (healthResult.healthy) {
showSuccess(`Server test successful: ${serverUrl}`);
} else {
showError(`Server test failed: ${serverUrl} (${healthResult.status} ${healthResult.statusText})`);
}
} catch (error) {
showError(`Server test error: ${error.message}`);
}
};
const toggleServerUpload = (index, checked) => {
if (index >= 0 && index < USER_SERVER_LIST.length) {
USER_SERVER_LIST[index].upload = checked;
console.log(`Server ${USER_SERVER_LIST[index].url} upload: ${checked}`);
}
};
const toggleServerMirror = (index, checked) => {
if (index >= 0 && index < USER_SERVER_LIST.length) {
USER_SERVER_LIST[index].mirror = checked;
console.log(`Server ${USER_SERVER_LIST[index].url} mirror: ${checked}`);
}
};
// Toggle UI handlers that mirror checkbox behavior using button styling
const toggleServerUploadUI = (index, btn) => {
if (index < 0 || index >= USER_SERVER_LIST.length) return;
const now = !USER_SERVER_LIST[index].upload;
USER_SERVER_LIST[index].upload = now;
btn.classList.toggle('btnMuted', !now);
toggleServerUpload(index, now);
};
const toggleServerMirrorUI = (index, btn) => {
if (index < 0 || index >= USER_SERVER_LIST.length) return;
const now = !USER_SERVER_LIST[index].mirror;
USER_SERVER_LIST[index].mirror = now;
btn.classList.toggle('btnMuted', !now);
toggleServerMirror(index, now);
};
// Expose for inline onclick
window.toggleServerUploadUI = toggleServerUploadUI;
window.toggleServerMirrorUI = toggleServerMirrorUI;
const refreshServerListFromNostr = async () => {
if (!currentPubkey) {
console.error('[blobs.html] Please login with your Nostr keys first');
return;
}
try {
const btnRefreshServerList = document.getElementById('btnRefreshServerList');
if (btnRefreshServerList) {
btnRefreshServerList.textContent = 'Loading...';
btnRefreshServerList.disabled = true;
}
console.log('[blobs.html] Refreshing server list from Nostr...');
// The subscription will automatically update when new events arrive
showSuccess('Checking for server list updates...');
} catch (error) {
showError(`Failed to refresh server list: ${error.message}`);
} finally {
const btnRefreshServerList = document.getElementById('btnRefreshServerList');
if (btnRefreshServerList) {
btnRefreshServerList.textContent = 'Refresh from Nostr';
btnRefreshServerList.disabled = false;
}
}
};
// Make functions global for onclick handlers
window.testServerFromList = testServerFromList;
window.removeServerFromList = removeServerFromList;
window.toggleServerUpload = toggleServerUpload;
window.toggleServerMirror = toggleServerMirror;
//////////////////////////////////////////////////////////////////////
// UTILITY FUNCTIONS
//////////////////////////////////////////////////////////////////////
const showSuccess = (message) => {
console.log('✓ SUCCESS:', message);
// You can add a toast notification here if desired
};
const showError = (message, isHtml = false) => {
console.error('✗ ERROR:', message);
// You can add a toast notification here if desired
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDate = (timestamp) => {
return new Date(timestamp * 1000).toLocaleDateString() + ' ' +
new Date(timestamp * 1000).toLocaleTimeString();
};
const calculateSHA256 = async (file) => {
return calculateSHA256Api(file);
};
//////////////////////////////////////////////////////////////////////
// NOSTR AUTHENTICATION
//////////////////////////////////////////////////////////////////////
const createAuthEvent = async (action, sha256Hash = null, content = null) => {
return createBlossomAuth(action, sha256Hash, content);
};
//////////////////////////////////////////////////////////////////////
// SERVER FUNCTIONS
//////////////////////////////////////////////////////////////////////
// Old testServer and checkUploadRequirements functions removed - using enhanced health checking
const listUserBlobs = async (serverUrl, pubkey, since = 0) => {
return listUserBlobsApi(serverUrl, pubkey, since);
};
//////////////////////////////////////////////////////////////////////
// UPLOAD FUNCTIONS
//////////////////////////////////////////////////////////////////////
// Helper function to check if server is local
const isLocalServer = (serverUrl) => {
return serverUrl.includes('127.0.0.1') || serverUrl.includes('localhost');
};
const uploadBlob = async (file) => {
try {
const result = await uploadToAllServers(file);
const { blobDescriptor, sha256, uploadedServers } = result;
USER_BLOBS.unshift(blobDescriptor);
window.SERVER_BLOB_MAPS = window.SERVER_BLOB_MAPS || {};
for (const serverUrl of uploadedServers || []) {
if (!window.SERVER_BLOB_MAPS[serverUrl]) {
window.SERVER_BLOB_MAPS[serverUrl] = [];
}
if (!window.SERVER_BLOB_MAPS[serverUrl].includes(sha256)) {
window.SERVER_BLOB_MAPS[serverUrl].push(sha256);
}
}
if (BLOB_VIEW_MODE === 'list') {
updateBlobTable();
} else {
updateBlobGrid();
}
console.log(`Upload complete for ${file.name}`);
return blobDescriptor;
} catch (error) {
console.error('Upload error:', error);
showError(`Failed to upload ${file.name}: ${error.message}`);
throw error;
}
};
const uploadBlobToSpecificServer = async (file, serverUrl) => {
try {
const result = await uploadToServer(file, serverUrl);
const { blobDescriptor, sha256, serverUrl: uploadedTo } = result;
USER_BLOBS.unshift(blobDescriptor);
window.SERVER_BLOB_MAPS = window.SERVER_BLOB_MAPS || {};
if (!window.SERVER_BLOB_MAPS[uploadedTo]) {
window.SERVER_BLOB_MAPS[uploadedTo] = [];
}
if (!window.SERVER_BLOB_MAPS[uploadedTo].includes(sha256)) {
window.SERVER_BLOB_MAPS[uploadedTo].push(sha256);
}
if (BLOB_VIEW_MODE === 'list') {
updateBlobTable();
} else {
updateBlobGrid();
}
showSuccess(`Uploaded ${file.name} to ${uploadedTo}`);
return blobDescriptor;
} catch (error) {
showError(`Failed to upload ${file.name} to ${serverUrl}: ${error.message}`);
throw error;
}
};
// Mirror blob to selected mirror servers (excluding the upload server)
const mirrorBlob = async (sha256, filename) => {
try {
await mirrorBlobToServers(sha256, filename);
} catch (error) {
console.error(`Mirror error for ${sha256}:`, error);
}
};
const deleteBlob = async (sha256, filename) => {
try {
const deleteSuccess = await deleteFromAllServers(sha256, filename || 'blob');
if (deleteSuccess) {
showSuccess(`Successfully deleted ${filename || 'blob'}`);
// Remove from local list
USER_BLOBS = USER_BLOBS.filter(blob => blob.sha256 !== sha256);
if (BLOB_VIEW_MODE === 'list') {
updateBlobTable();
} else {
updateBlobGrid();
}
} else {
throw new Error('Failed to delete from any server');
}
} catch (error) {
showError(`Failed to delete blob: ${error.message}`);
}
};
// handleDeleteClick removed - using single-click delete in grid view
//////////////////////////////////////////////////////////////////////
// UI FUNCTIONS
//////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////
// SELECTION FUNCTIONS
//////////////////////////////////////////////////////////////////////
const toggleBlobSelection = (sha256) => {
const idx = selectedBlobs.indexOf(sha256);
if (idx === -1) {
selectedBlobs.push(sha256);
} else {
selectedBlobs.splice(idx, 1);
}
updateSelectionUI();
};
const updateSelectionUI = () => {
const count = selectedBlobs.length;
// Update toolbar buttons
btnPostSelected.disabled = count === 0;
btnPostSelected.classList.toggle('btnMuted', count === 0);
btnDeleteSelected.disabled = count === 0;
btnDeleteSelected.classList.toggle('btnMuted', count === 0);
// Update each grid item's selection button and border
document.querySelectorAll('.divBlobGridItem').forEach(item => {
const sha256 = item.dataset.sha256;
const idx = selectedBlobs.indexOf(sha256);
const isSelected = idx !== -1;
const btn = item.querySelector('.btnSelectBlob');
if (btn) {
btn.textContent = isSelected ? String(idx + 1) : '';
btn.classList.toggle('selected', isSelected);
}
item.classList.toggle('selected', isSelected);
});
};
const deleteSelectedBlobs = async () => {
if (selectedBlobs.length === 0) return;
const toDelete = [...selectedBlobs];
selectedBlobs = [];
updateSelectionUI();
for (const sha256 of toDelete) {
const blob = USER_BLOBS.find(b => b.sha256 === sha256);
await deleteBlob(sha256, blob?.url?.split('/').pop() || 'blob');
}
};
//////////////////////////////////////////////////////////////////////
// POST COMPOSER POPUP
//////////////////////////////////////////////////////////////////////
let composerInstance = null;
const openPostPopup = () => {
// Build ordered list of URLs from selectedBlobs
const urls = selectedBlobs.map(sha256 => {
const blob = USER_BLOBS.find(b => b.sha256 === sha256);
if (!blob) return null;
let url = blob.url || '';
if (url.includes('localhost')) url = url.replace('localhost', '127.0.0.1');
if (url.includes(':9001')) url = url.replace(':9001', ':9443');
return urlWithExt(url, blob.type);
}).filter(Boolean);
// Show popup
divPostPopup.style.display = 'flex';
// Mount or re-use composer
if (!composerInstance) {
composerInstance = mountComposer(divPostComposerHost, {
currentPubkey,
showUploadIcon: false,
showPreview: true,
alwaysShowSendButton: true,
clearBeforeSubmit: false,
popupMode: true,
onSubmit: async (content) => {
closePostPopup();
selectedBlobs = [];
updateSelectionUI();
publishEvent({
kind: 1,
content,
tags: [],
created_at: Math.floor(Date.now() / 1000)
}).catch(e => console.error('[blobs.html] Failed to publish post:', e));
}
});
}
// Pre-fill: cursor on first line, URLs below
const text = '\n' + urls.join('\n');
divPostComposerHost.innerText = text;
// Place cursor at the very beginning (before the newline)
const range = document.createRange();
const sel = window.getSelection();
range.setStart(divPostComposerHost.firstChild || divPostComposerHost, 0);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
divPostComposerHost.focus();
// Trigger composer to update preview + send button
document.dispatchEvent(new Event('selectionchange'));
divPostComposerHost.dispatchEvent(new Event('input', { bubbles: true }));
};
const closePostPopup = () => {
divPostPopup.style.display = 'none';
};
//////////////////////////////////////////////////////////////////////
// FILTER FUNCTIONS
//////////////////////////////////////////////////////////////////////
const getBlobCategory = (mimeType) => {
if (!mimeType) return 'binary';
if (mimeType.startsWith('image/')) return 'images';
if (
mimeType.startsWith('text/') ||
mimeType === 'application/json' ||
mimeType === 'application/xml' ||
mimeType === 'application/javascript' ||
mimeType === 'application/typescript' ||
mimeType === 'application/x-yaml' ||
mimeType === 'application/toml' ||
mimeType === 'application/pdf' ||
mimeType.includes('markdown') ||
mimeType.includes('yaml') ||
mimeType.includes('csv')
) return 'text';
return 'binary';
};
const getFilteredBlobs = () => {
if (hiddenFilters.size === 0) return USER_BLOBS;
return USER_BLOBS.filter(blob => !hiddenFilters.has(getBlobCategory(blob.type)));
};
const toggleFilter = (filter) => {
if (hiddenFilters.has(filter)) {
hiddenFilters.delete(filter);
} else {
hiddenFilters.add(filter);
}
currentPage = 0;
updateFilterButtons();
BLOB_VIEW_MODE === 'list' ? updateBlobTable() : updateBlobGrid();
};
const updateFilterButtons = () => {
[
[btnFilterImages, 'images'],
[btnFilterText, 'text'],
[btnFilterBinary, 'binary'],
].forEach(([btn, filter]) => {
const hidden = hiddenFilters.has(filter);
btn.classList.toggle('active', !hidden);
btn.classList.toggle('btnMuted', hidden);
});
};
// Calculate how many full rows fit in the visible grid area
const calcAutoRows = () => {
const gridEl = divBlobGrid;
const gridRect = gridEl.getBoundingClientRect();
// Available height from where the grid starts down to the bottom of the viewport
const availableHeight = window.innerHeight - gridRect.top;
const cols = gridColumns > 0 ? gridColumns : Math.max(1, Math.floor(gridEl.clientWidth / 120));
const gap = 15;
// Each item is square: width = (gridWidth - (cols-1)*gap) / cols
const itemSize = (gridEl.clientWidth - (cols - 1) * gap) / cols;
// Each row takes itemSize height + gap (last row has no trailing gap)
const rows = Math.max(1, Math.floor((availableHeight + gap) / (itemSize + gap)));
return rows;
};
const getPageSize = () => {
const cols = gridColumns > 0 ? gridColumns : Math.max(1, Math.floor(divBlobGrid.clientWidth / 120));
const rows = rowsPerPage === 0 ? calcAutoRows() : rowsPerPage;
// Subtract 1 for the upload box which occupies one slot in the first row
return Math.max(1, cols * rows - 1);
};
const getTotalPages = () => Math.max(1, Math.ceil(getFilteredBlobs().length / getPageSize()));
const getVisibleBlobs = () => {
const size = getPageSize();
return getFilteredBlobs().slice(currentPage * size, (currentPage + 1) * size);
};
const updatePaginationControls = () => {
const total = getTotalPages();
if (pageIndicator) {
pageIndicator.textContent = total > 1 ? `${currentPage + 1} / ${total}` : '';
}
if (btnPagePrev) {
btnPagePrev.classList.toggle('btnMuted', currentPage === 0);
btnPagePrev.disabled = currentPage === 0;
}
if (btnPageNext) {
btnPageNext.classList.toggle('btnMuted', currentPage >= total - 1);
btnPageNext.disabled = currentPage >= total - 1;
}
};
const goToPrevPage = () => {
if (currentPage > 0) {
currentPage--;
BLOB_VIEW_MODE === 'list' ? updateBlobTable() : updateBlobGrid();
}
};
const goToNextPage = () => {
if (currentPage < getTotalPages() - 1) {
currentPage++;
BLOB_VIEW_MODE === 'list' ? updateBlobTable() : updateBlobGrid();
}
};
const goToFirstPage = () => {
if (currentPage !== 0) {
currentPage = 0;
BLOB_VIEW_MODE === 'list' ? updateBlobTable() : updateBlobGrid();
}
};
const goToLastPage = () => {
const last = getTotalPages() - 1;
if (currentPage !== last) {
currentPage = last;
BLOB_VIEW_MODE === 'list' ? updateBlobTable() : updateBlobGrid();
}
};
const updateBlobTable = () => {
tblBlobsBody.innerHTML = '';
totalBlobCount.textContent = hiddenFilters.size > 0
? `${getFilteredBlobs().length} / ${USER_BLOBS.length}`
: USER_BLOBS.length;
getVisibleBlobs().forEach(blob => {
const row = document.createElement('tr');
// Fix blob URL to match current server configuration
let safeUrl = blob.url;
// Replace localhost with 127.0.0.1 to match our server configuration
if (safeUrl.includes('localhost')) {
safeUrl = safeUrl.replace('localhost', '127.0.0.1');
}
// Replace port 9001 with 9443 to match current server
if (safeUrl.includes(':9001')) {
safeUrl = safeUrl.replace(':9001', ':9443');
}
// Update blob URLs to use current healthy servers and preserve HTTPS
const healthyServers = USER_SERVER_LIST.filter(s => s.healthy);
if (healthyServers.length > 0 && safeUrl) {
// Extract the blob hash from the URL
const urlParts = safeUrl.split('/');
const blobHash = urlParts[urlParts.length - 1];
if (blobHash && blobHash.length === 64) { // SHA256 hash length
// Extract the original server base URL from the blob URL
const originalServerUrl = safeUrl.replace(`/${blobHash}`, '');
// Check if the original server is still healthy and in our list
// Normalize URLs for comparison (remove trailing slashes)
const normalizedOriginalUrl = originalServerUrl.replace(/\/$/, '');
const originalServerStillHealthy = healthyServers.some(server => {
const normalizedServerUrl = server.url.replace(/\/$/, '');
return normalizedServerUrl === normalizedOriginalUrl;
});
if (originalServerStillHealthy) {
// Keep the original server URL
safeUrl = `${originalServerUrl}/${blobHash}`;
} else {
// Original server not available, use the first healthy server
safeUrl = `${healthyServers[0].url}/${blobHash}`;
}
} else {
// Fix common protocol issues for existing URLs
if (safeUrl.includes('127.0.0.1:9443') || safeUrl.includes('localhost:9443')) {
// Ensure HTTPS for local development server
safeUrl = safeUrl.replace('http://', 'https://');
}
}
}
// Append file extension to safeUrl if missing
safeUrl = urlWithExt(safeUrl, blob.type);
// Generate URLs for all healthy servers that might have this blob
const allServerUrls = healthyServers.map(server => urlWithExt(`${server.url}/${blob.sha256}`, blob.type));
// Preview cell - clickable to open blob
const previewCell = document.createElement('td');
if (blob.type && blob.type.startsWith('image/')) {
previewCell.innerHTML = `<div class="divBlobPreview">
<a href="${safeUrl}" target="_blank" style="display: block;">
<img src="${safeUrl}" class="imgBlobPreview" alt="Preview"
onerror="this.style.display='none'" style="cursor: pointer;" />
</a>
</div>`;
} else {
previewCell.innerHTML = `<div style="text-align: center; color: #ccc;">
<a href="${safeUrl}" target="_blank" style="color: inherit; text-decoration: none;">
${getFileIcon(blob.type)}
</a>
</div>`;
}
row.appendChild(previewCell);
// Combined details cell with name at top and metadata below
const detailsCell = document.createElement('td');
const serverLinks = allServerUrls.map(url => {
const serverName = url.split('://')[1].split('/')[0];
const isPrimary = url === safeUrl;
return `<a href="${url}" target="_blank" style="color: ${isPrimary ? 'var(--accent-color)' : 'var(--muted-color)'}; text-decoration: none; font-size: 80%; margin-right: 8px;" title="${url}">${serverName}${isPrimary ? ' (primary)' : ''}</a>`;
}).join('');
detailsCell.innerHTML = `
<div style="margin-bottom: 8px;">
<a href="${safeUrl}" target="_blank"
style="color: var(--primary-color); text-decoration: none; font-weight: bold; font-size: 85%; word-break: break-all;"
onmouseover="this.style.color='var(--accent-color)'; this.style.textDecoration='underline'"
onmouseout="this.style.color='var(--primary-color)'; this.style.textDecoration='none'">${safeUrl}</a>
</div>
<div style="margin-bottom: 4px; font-size: 80%; color: var(--muted-color);">
Also available on: ${serverLinks}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 15px; align-items: center; font-size: 85%; color: var(--muted-color);">
<span><strong>Type:</strong> ${blob.type || 'unknown'}</span>
<span><strong>Size:</strong> ${formatBytes(blob.size || 0)}</span>
<span><strong>Uploaded:</strong> ${formatDate(blob.uploaded || 0)}</span>
</div>
<div style="margin-top: 8px;">
<button class="btn btnAction" onclick="navigator.clipboard.writeText('${safeUrl}')" style="margin-right: 5px;">Copy URL</button>
<button class="btn btnAction btnDelete" id="delete-${blob.sha256}" onclick="handleDeleteClick('${blob.sha256}', '${safeUrl.split('/').pop()}')">Delete</button>
</div>
`;
row.appendChild(detailsCell);
tblBlobsBody.appendChild(row);
});
updatePaginationControls();
};
const getFileIcon = (mimeType) => {
if (!mimeType) return 'FILE';
if (mimeType.startsWith('image/')) return 'IMG';
if (mimeType.startsWith('video/')) return 'VID';
if (mimeType.startsWith('audio/')) return 'AUD';
if (mimeType.includes('pdf')) return 'PDF';
if (mimeType.includes('text')) return 'TXT';
return 'FILE';
};
// Return a file extension (with dot) for a given mime type, or '' if unknown
const extFromMime = (mimeType) => {
if (!mimeType) return '';
const map = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/avif': '.avif',
'image/bmp': '.bmp',
'image/tiff': '.tiff',
'video/mp4': '.mp4',
'video/webm': '.webm',
'video/ogg': '.ogv',
'video/quicktime': '.mov',
'audio/mpeg': '.mp3',
'audio/ogg': '.ogg',
'audio/wav': '.wav',
'audio/flac': '.flac',
'audio/aac': '.aac',
'application/pdf': '.pdf',
'text/plain': '.txt',
'text/html': '.html',
'application/json': '.json',
'application/zip': '.zip',
'application/gzip': '.gz',
};
return map[mimeType] || '';
};
// Append a file extension to a URL if it doesn't already have one
const urlWithExt = (url, mimeType) => {
if (!url) return url;
const ext = extFromMime(mimeType);
if (!ext) return url;
// Already has an extension (last path segment contains a dot after the hash)
const lastSegment = url.split('/').pop();
if (lastSegment.includes('.')) return url;
return url + ext;
};
// Toggle between list and grid view
const toggleBlobView = () => {
if (BLOB_VIEW_MODE === 'list') {
BLOB_VIEW_MODE = 'grid';
divBlobList.style.display = 'none';
divBlobGrid.style.display = 'grid';
btnToggleView.textContent = 'List View';
updateBlobGrid();
} else {
BLOB_VIEW_MODE = 'list';
divBlobList.style.display = 'block';
divBlobGrid.style.display = 'none';
btnToggleView.textContent = 'Grid View';
updateBlobTable();
}
};
// Check if blob is available on a specific server
// Use server configuration to determine availability instead of HEAD requests
const checkBlobAvailability = (server, blob) => {
// If this server was used for upload, the blob should be there
if (server.upload) {
return true;
}
// If this server has mirror enabled, check if blob was mirrored
// For now, assume mirrored blobs are available (we could track this more precisely)
if (server.mirror) {
return true;
}
// Otherwise, blob is not expected to be on this server
return false;
};
// Update grid view
const updateBlobGrid = async () => {
divBlobGrid.innerHTML = '';
const healthyServers = USER_SERVER_LIST.filter(s => s.healthy);
// Add upload box as first item
const uploadGridItem = document.createElement('div');
uploadGridItem.className = 'divBlobGridUpload';
uploadGridItem.innerHTML = `
<svg viewBox="0 0 100 100" width="48px" height="48px">
<line x1="20" x2="80" y1="50" y2="50" stroke-width="29" stroke-linecap="round" stroke-linejoin="round" fill="#fff" opacity="1"></line>
<line x1="50" x2="50" y1="20" y2="80" stroke-width="29" stroke-linecap="round" stroke-linejoin="round" fill="#fff" opacity="1"></line>
<line x1="50" x2="50" y1="50" y2="50" stroke-width="29" stroke-linecap="round" stroke-linejoin="round" fill="#fff" opacity="1"></line>
</svg>
`;
// Add drag and drop functionality
uploadGridItem.addEventListener('click', () => {
// Create a temporary file input for this upload
const tempFileInput = document.createElement('input');
tempFileInput.type = 'file';
tempFileInput.multiple = true;
tempFileInput.style.display = 'none';
tempFileInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
for (const file of files) {
try {
await uploadBlob(file);
} catch (error) {
console.error(`Failed to upload ${file.name}: ${error.message}`);
break; // Stop on first error
}
}
document.body.removeChild(tempFileInput);
});
document.body.appendChild(tempFileInput);
tempFileInput.click();
});
uploadGridItem.addEventListener('dragover', (e) => {
e.preventDefault();
uploadGridItem.classList.add('dragover');
});
uploadGridItem.addEventListener('dragleave', () => {
uploadGridItem.classList.remove('dragover');
});
uploadGridItem.addEventListener('drop', async (e) => {
e.preventDefault();
uploadGridItem.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
try {
await uploadBlob(file);
} catch (error) {
console.error(`Failed to upload ${file.name}: ${error.message}`);
break; // Stop on first error
}
}
});
divBlobGrid.appendChild(uploadGridItem);
for (const blob of getVisibleBlobs()) {
const gridItem = document.createElement('div');
gridItem.className = 'divBlobGridItem';
// Fix blob URL
let safeUrl = blob.url;
if (safeUrl.includes('localhost')) {
safeUrl = safeUrl.replace('localhost', '127.0.0.1');
}
if (safeUrl.includes(':9001')) {
safeUrl = safeUrl.replace(':9001', ':9443');
}
safeUrl = urlWithExt(safeUrl, blob.type);
// Preview
const blobCategory = getBlobCategory(blob.type);
let previewHtml = '';
if (blob.type && blob.type.startsWith('image/')) {
previewHtml = `
<div class="divBlobGridPreview">
<img src="${safeUrl}" class="imgBlobGridPreview" alt="Preview"
onerror="this.style.display='none'" />
</div>
`;
} else if (blobCategory === 'text') {
// Fetch a small chunk of text to preview
let snippet = '';
try {
const res = await fetch(safeUrl, { headers: { Range: 'bytes=0-400' } });
if (res.ok || res.status === 206) {
const raw = await res.text();
// Escape HTML special chars so content renders as plain text
snippet = raw.trim().slice(0, 300)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
} catch (e) { /* silent */ }
previewHtml = `
<div class="divBlobTextPreview">
<div class="divBlobTextLabel">${getFileIcon(blob.type)}</div>
<div class="divBlobTextContent">${snippet}</div>
</div>
`;
} else {
previewHtml = `
<div class="divBlobGridPreview" style="font-size: 200%; color: var(--muted-color);">
${getFileIcon(blob.type)}
</div>
`;
}
// Server availability indicators with Kanji - start as unavailable
const serverIndicators = healthyServers.map((server, index) => {
const kanjiNumber = numberToKanji(index);
const serverUrl = urlWithExt(`${server.url}/${blob.sha256}`, blob.type);
// Start as unavailable, will be updated after checking
return `<span class="spanServerKanji unavailable"
data-server-url="${serverUrl}"
data-blob-sha="${blob.sha256}"
data-server-index="${index}"
title="${server.url}">${kanjiNumber}</span>`;
}).join('');
// Create unique ID for this delete button's icon container
const deleteIconId = `delete-icon-${blob.sha256}`;
const selectionIdx = selectedBlobs.indexOf(blob.sha256);
const isSelected = selectionIdx !== -1;
gridItem.dataset.sha256 = blob.sha256;
if (isSelected) gridItem.classList.add('selected');
gridItem.innerHTML = `
${previewHtml}
<button class="btnSelectBlob${isSelected ? ' selected' : ''}" title="Select">${isSelected ? String(selectionIdx + 1) : ''}</button>
<button class="btnDeleteBlob" data-sha256="${blob.sha256}" title="Delete from all servers">
<div id="${deleteIconId}" class="deleteIconContainer"></div>
</button>
<div class="divServerIndicators">
${serverIndicators}
</div>
`;
divBlobGrid.appendChild(gridItem);
// Wire up selection button
const selectBtn = gridItem.querySelector('.btnSelectBlob');
if (selectBtn) {
selectBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
toggleBlobSelection(blob.sha256);
});
}
// Initialize HamburgerMorphing X icon for delete button
const deleteBtn = gridItem.querySelector('.btnDeleteBlob');
if (deleteBtn) {
// Create X icon using HamburgerMorphing with CSS selector
const deleteIcon = new HamburgerMorphing(`#${deleteIconId}`, {
size: 18,
foreground: 'var(--primary-color)',
background: 'transparent'
});
deleteIcon.animateTo('x'); // Show X immediately
// Single-click delete handler
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
console.log('Delete button clicked for blob:', blob.sha256);
try {
await deleteBlob(blob.sha256, safeUrl.split('/').pop());
} catch (error) {
console.error('Delete failed:', error);
}
});
}
// Check availability for each server based on actual server listings
healthyServers.forEach((server, index) => {
const indicator = gridItem.querySelector(`[data-server-index="${index}"]`);
if (indicator) {
// Check if this blob's SHA256 is in the server's blob list
const serverBlobHashes = window.SERVER_BLOB_MAPS?.[server.url] || [];
const isAvailable = serverBlobHashes.includes(blob.sha256);
if (isAvailable) {
indicator.classList.remove('unavailable');
indicator.classList.add('available');
// Add click handler for available servers
indicator.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent event bubbling
const serverUrl = urlWithExt(`${server.url}/${blob.sha256}`, blob.type);
window.open(serverUrl, '_blank');
});
}
}
});
}
updatePaginationControls();
};
const refreshBlobs = async () => {
if (!currentPubkey) {
console.error('[blobs.html] Please login with your Nostr keys first');
return;
}
try {
btnRefreshBlobs.textContent = 'Loading...';
btnRefreshBlobs.disabled = true;
// List blobs from all healthy servers with upload capability
const uploadServers = USER_SERVER_LIST.filter(server => server.healthy && server.upload);
if (uploadServers.length === 0) {
throw new Error('No healthy upload servers available to list blobs from');
}
console.log('[blobs.html] Listing blobs from all upload servers:', uploadServers.map(s => s.url));
// Collect blobs from all upload servers
const allBlobs = [];
const serverBlobLists = {}; // Track which blobs are on which servers
for (const server of uploadServers) {
try {
const serverBlobs = await listUserBlobs(server.url, currentPubkey);
// Track which server this blob came from
serverBlobLists[server.url] = serverBlobs.map(blob => blob.sha256);
// Add server info to each blob for tracking
serverBlobs.forEach(blob => {
blob.sourceServer = server.url;
});
allBlobs.push(...serverBlobs);
} catch (error) {
console.warn(`[blobs.html] Failed to list blobs from ${server.url}:`, error.message);
serverBlobLists[server.url] = []; // Empty list for failed server
// Continue with other servers even if one fails
}
}
// Store server blob mappings globally for grid view
window.SERVER_BLOB_MAPS = serverBlobLists;
// Remove duplicates based on sha256 hash (keep the most recent upload)
const uniqueBlobs = allBlobs.reduce((acc, blob) => {
const existing = acc.find(b => b.sha256 === blob.sha256);
if (!existing) {
acc.push(blob);
} else if (blob.uploaded > existing.uploaded) {
// Replace with more recent upload
const index = acc.indexOf(existing);
acc[index] = blob;
}
return acc;
}, []);
USER_BLOBS = uniqueBlobs.sort((a, b) => b.uploaded - a.uploaded);
currentPage = 0;
console.log(`Total unique blobs across all servers: ${USER_BLOBS.length}`);
if (BLOB_VIEW_MODE === 'list') {
updateBlobTable();
} else {
updateBlobGrid();
}
console.log(`Loaded ${USER_BLOBS.length} blobs from ${uploadServers.length} server(s)`);
} catch (error) {
console.error(`Failed to load blobs: ${error.message}`);
} finally {
btnRefreshBlobs.textContent = 'Refresh';
btnRefreshBlobs.disabled = false;
}
};
// Sync blobs across all configured servers
const syncBlobs = async () => {
if (!currentPubkey) {
console.error('[blobs.html] Please login with your Nostr keys first');
return;
}
try {
console.log('Starting blob synchronization...');
// Get all healthy servers (both upload and mirror)
const allServers = USER_SERVER_LIST.filter(server => server.healthy && (server.upload || server.mirror));
if (allServers.length === 0) {
console.warn('No healthy servers available for synchronization');
return;
}
// IMPORTANT (future agents): local and remote Blossom deployments are often on separate networks.
// We intentionally sync in two partitions only (local->local and remote->remote) and skip cross-partition mirroring.
const localServers = allServers.filter(server => isLocalServer(server.url));
const remoteServers = allServers.filter(server => !isLocalServer(server.url));
const partitions = [
{ name: 'local', servers: localServers },
{ name: 'remote', servers: remoteServers }
];
console.log(`Synchronizing blobs across ${allServers.length} servers:`, allServers.map(s => s.url));
console.log('Sync partitions:', {
local: localServers.map(s => s.url),
remote: remoteServers.map(s => s.url)
});
let syncCount = 0;
let unsyncedBlobCount = 0;
let crossPartitionSkipped = 0;
// For each blob, mirror only within the same partition.
for (const blob of USER_BLOBS) {
const hasLocalSource = localServers.some(server => (window.SERVER_BLOB_MAPS?.[server.url] || []).includes(blob.sha256));
const hasRemoteSource = remoteServers.some(server => (window.SERVER_BLOB_MAPS?.[server.url] || []).includes(blob.sha256));
for (const partition of partitions) {
if (partition.servers.length === 0) {
continue;
}
const missingServers = [];
// Check which servers in this partition are missing the blob.
for (const server of partition.servers) {
const serverBlobHashes = window.SERVER_BLOB_MAPS?.[server.url] || [];
if (!serverBlobHashes.includes(blob.sha256)) {
missingServers.push(server);
}
}
if (missingServers.length === 0) {
continue;
}
console.log(`Syncing blob ${blob.sha256} to ${missingServers.length} ${partition.name} server(s)`);
// Source servers are limited to the same partition by design.
const sourceServers = partition.servers.filter(s => !missingServers.includes(s));
if (sourceServers.length === 0) {
const crossPartitionOnly = (partition.name === 'local' && hasRemoteSource) || (partition.name === 'remote' && hasLocalSource);
if (crossPartitionOnly) {
crossPartitionSkipped += missingServers.length;
console.warn(`Skipping cross-partition sync for blob ${blob.sha256} to ${partition.name} server(s): local<->remote mirroring is intentionally disabled`);
} else {
unsyncedBlobCount += missingServers.length;
console.warn(`No ${partition.name} source available for blob ${blob.sha256}`);
}
continue;
}
for (const targetServer of missingServers) {
let mirrored = false;
for (const sourceServer of sourceServers) {
const sourceBlobUrl = `${sourceServer.url}/${blob.sha256}`;
try {
console.log(`Mirroring (${partition.name}) to ${targetServer.url} from ${sourceBlobUrl}`);
const mirrorResponse = await fetch(`${targetServer.url}/mirror`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Nostr ${await createAuthEvent('mirror', blob.sha256, `Mirror ${blob.sha256}`)}`
},
body: JSON.stringify({ url: sourceBlobUrl })
});
if (mirrorResponse.ok) {
console.log(`Successfully mirrored to: ${targetServer.url}`);
// Update server blob maps
if (!window.SERVER_BLOB_MAPS[targetServer.url]) {
window.SERVER_BLOB_MAPS[targetServer.url] = [];
}
if (!window.SERVER_BLOB_MAPS[targetServer.url].includes(blob.sha256)) {
window.SERVER_BLOB_MAPS[targetServer.url].push(blob.sha256);
}
syncCount++;
mirrored = true;
break;
} else {
const reason = await mirrorResponse.text().catch(() => 'no response body');
console.warn(`Mirror failed ${targetServer.url} <= ${sourceServer.url}: ${mirrorResponse.status} ${reason}`);
}
} catch (error) {
const msg = String(error?.message || error || 'Unknown mirror error');
console.warn(`Mirror request failed ${targetServer.url} <= ${sourceServer.url}: ${msg}`);
}
}
if (!mirrored) {
unsyncedBlobCount++;
console.error(`Could not mirror blob ${blob.sha256} to ${targetServer.url} from any ${partition.name} source server`);
}
}
}
}
// Refresh the UI to show updated server availability
if (BLOB_VIEW_MODE === 'list') {
updateBlobTable();
} else {
updateBlobGrid();
}
console.log(`Blob synchronization complete. Synced ${syncCount} blob(s) across servers.`);
if (unsyncedBlobCount > 0) {
console.warn(`Could not sync ${unsyncedBlobCount} blob target(s) within local/remote partitions.`);
}
if (crossPartitionSkipped > 0) {
console.warn(`Skipped ${crossPartitionSkipped} cross-partition sync target(s) because local<->remote mirroring is intentionally disabled.`);
} else if (syncCount > 0) {
console.log(`Successfully synchronized ${syncCount} blob(s) across all servers`);
} else {
console.log('All blobs are already synchronized across servers');
}
} catch (error) {
console.error('Blob synchronization failed:', error);
console.error(`Synchronization failed: ${error.message}`);
}
};
//////////////////////////////////////////////////////////////////////
// UPDATE FOOTER
//////////////////////////////////////////////////////////////////////
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav using relay-ui.mjs
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Sync local server cache from shared blossom section state
USER_SERVER_LIST = getBlossomServers();
// Update center section with server count
const healthyServers = USER_SERVER_LIST.filter(s => s.healthy);
divFooterCenter.innerHTML = `Servers: ${healthyServers.length}/${USER_SERVER_LIST.length}`;
// Update blossom sidenav section
await updateBlossomSection();
// First-load auto refresh once servers become available
if (!initialBlobRefreshTriggered && USER_SERVER_LIST.some(s => s.healthy && s.upload)) {
initialBlobRefreshTriggered = true;
await refreshBlobs();
}
// Update right section with blob count
divFooterRight.innerHTML = `Blobs: ${USER_BLOBS.length}`;
} catch (error) {
console.error('[blobs.html] Error updating footer:', error);
}
};
//////////////////////////////////////////////////////////////////////
// LOGOUT
//////////////////////////////////////////////////////////////////////
const Logout = async () => {
console.log("[blobs.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Unsubscribe from server list
if (serverListSubscription) {
serverListSubscription.unsubscribe();
serverListSubscription = 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("[blobs.html] Logged out, reloading page");
location.reload(true);
};
//////////////////////////////////////////////////////////////////////
// GRID SETTINGS
//////////////////////////////////////////////////////////////////////
let gridColumns = 0; // 0 = auto; loaded from user settings on init
const setRowsPerPage = (val) => {
rowsPerPage = val;
currentPage = 0;
BLOB_VIEW_MODE === 'list' ? updateBlobTable() : updateBlobGrid();
updateRowsButtons();
patchUserSettings({ blobs: { gridColumns, rowsPerPage: val } }).catch(() => {});
};
const updateRowsButtons = () => {
document.querySelectorAll('.btnGridRows').forEach(btn => {
const val = parseInt(btn.dataset.rows);
btn.classList.toggle('btnMuted', val !== rowsPerPage);
btn.classList.toggle('active', val === rowsPerPage);
});
};
window.setRowsPerPage = setRowsPerPage;
const applyGridColumns = () => {
if (gridColumns > 0) {
divBlobGrid.style.gridTemplateColumns = `repeat(${gridColumns}, 1fr)`;
} else {
divBlobGrid.style.gridTemplateColumns = '';
}
};
const setGridColumns = (val) => {
gridColumns = val;
applyGridColumns();
updateGridColumnButtons();
patchUserSettings({ blobs: { gridColumns: val, rowsPerPage } }).catch(() => {});
};
const updateGridColumnButtons = () => {
const buttons = document.querySelectorAll('.btnGridCol');
buttons.forEach(btn => {
const val = parseInt(btn.dataset.cols);
btn.classList.toggle('btnMuted', val !== gridColumns);
btn.classList.toggle('active', val === gridColumns);
});
};
const applyBlobSettings = (settings) => {
const s = settings?.blobs || {};
if (s.gridColumns !== undefined && s.gridColumns !== gridColumns) {
gridColumns = s.gridColumns;
applyGridColumns();
updateGridColumnButtons();
}
if (s.rowsPerPage !== undefined && s.rowsPerPage !== rowsPerPage) {
rowsPerPage = s.rowsPerPage;
updateRowsButtons();
}
};
//////////////////////////////////////////////////////////////////////
// SIDENAV
//////////////////////////////////////////////////////////////////////
const loadSideNav = () => {
const divSideNavBody = document.getElementById("divSideNavBody");
if (!divSideNavBody) {
console.error("[blobs.html] divSideNavBody not found");
return;
}
const colOptions = [0, 2, 3, 4, 5, 6, 8, 10, 12];
const rowOptions = [0, 1, 2, 3, 4, 5, 6, 8, 10];
divSideNavBody.innerHTML = `
<div style="padding: 20px; font-family: var(--font-family); display: flex; flex-direction: column; gap: 16px;">
<div>
<div style="margin-bottom: 8px; font-size: 85%; color: var(--muted-color);">Columns</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
${colOptions.map(n => `
<button class="btn btnServer btnGridCol ${n !== gridColumns ? 'btnMuted' : ''}"
data-cols="${n}"
onclick="setGridColumns(${n})">
${n === 0 ? 'Auto' : n}
</button>
`).join('')}
</div>
</div>
<div>
<div style="margin-bottom: 8px; font-size: 85%; color: var(--muted-color);">Rows per page</div>
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
${rowOptions.map(n => `
<button class="btn btnServer btnGridRows ${n !== rowsPerPage ? 'btnMuted' : ''} ${n === rowsPerPage ? 'active' : ''}"
data-rows="${n}"
onclick="setRowsPerPage(${n})">
${n === 0 ? 'Auto' : n}
</button>
`).join('')}
</div>
</div>
</div>
`;
};
window.setGridColumns = setGridColumns;
//////////////////////////////////////////////////////////////////////
// EVENT LISTENERS
//////////////////////////////////////////////////////////////////////
// Hamburger click will be handled after initialization
// Server management event listeners are now attached dynamically in loadSideNav()
btnRefreshBlobs.addEventListener('click', refreshBlobs);
btnPagePrev.addEventListener('click', goToPrevPage);
btnPageNext.addEventListener('click', goToNextPage);
btnPagePrev.addEventListener('dblclick', (e) => { e.stopPropagation(); goToFirstPage(); });
btnPageNext.addEventListener('dblclick', (e) => { e.stopPropagation(); goToLastPage(); });
btnToggleView.addEventListener('click', toggleBlobView);
btnSyncBlobs.addEventListener('click', syncBlobs);
btnFilterImages.addEventListener('click', () => toggleFilter('images'));
btnFilterText.addEventListener('click', () => toggleFilter('text'));
btnFilterBinary.addEventListener('click', () => toggleFilter('binary'));
btnPostSelected.addEventListener('click', openPostPopup);
btnDeleteSelected.addEventListener('click', deleteSelectedBlobs);
// Close popup on backdrop click or close button
btnClosePostPopup.addEventListener('click', closePostPopup);
divPostPopup.addEventListener('click', (e) => {
if (e.target === divPostPopup) closePostPopup();
});
document.getElementById('divGutterLeft').addEventListener('click', goToPrevPage);
document.getElementById('divGutterRight').addEventListener('click', goToNextPage);
document.getElementById('divGutterLeft').addEventListener('dblclick', goToFirstPage);
document.getElementById('divGutterRight').addEventListener('dblclick', goToLastPage);
window.addEventListener('blossomServerDrop', async (event) => {
const { serverUrl, files } = event.detail || {};
if (!serverUrl || !Array.isArray(files) || files.length === 0) return;
for (const file of files) {
try {
await uploadBlobToSpecificServer(file, serverUrl);
} catch (error) {
console.error(`Failed to upload ${file.name} to ${serverUrl}: ${error.message}`);
break;
}
}
});
window.addEventListener('blossomAllServersDrop', async (event) => {
const { files } = event.detail || {};
if (!Array.isArray(files) || files.length === 0) return;
for (const file of files) {
try {
await uploadBlob(file);
} catch (error) {
console.error(`Failed to upload ${file.name} to all blossom servers: ${error.message}`);
break;
}
}
});
// Make delete function global for button onclick (list view)
window.deleteBlob = deleteBlob;
window.handleDeleteClick = (sha256, filename) => {
deleteBlob(sha256, filename);
};
//////////////////////////////////////////////////////////////////////////////
// MAIN INITIALIZATION
//////////////////////////////////////////////////////////////////////////////
(async function main() {
console.log("[blobs.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 {
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("[blobs.html] Authenticated as:", currentPubkey);
// Initialize relay + blossom UI components
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
USER_SERVER_LIST = getBlossomServers();
// Listen for relay activity broadcasts from worker
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[blobs.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityStateUI(relayUrl, activity);
});
// Listen for relay activity broadcasts from worker (alternative message format)
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[blobs.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityStateUI(relayUrl, activity);
}
});
// Load grid settings from user config
try {
const settings = await getUserSettings();
applyBlobSettings(settings || {});
} catch (e) {
console.warn('[blobs.html] Could not load user settings:', e.message);
}
applyGridColumns();
// Subscribe to live settings updates (e.g. changed on another device)
onUserSettings((settings) => {
applyBlobSettings(settings || {});
});
// Load blobs from healthy upload servers (UpdateFooter also retries once servers become available)
setTimeout(async () => {
try {
await refreshBlobs();
} catch (error) {
console.log('[blobs.html] Could not load blobs:', error.message);
}
}, 1000);
// Start update loop (updates footer every second)
updateIntervalId = setInterval(UpdateFooter, 1000);
// Update version display
await updateVersionDisplay();
console.log('[blobs.html] Initialization complete');
} catch (error) {
console.error('[blobs.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>