2732 lines
122 KiB
HTML
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="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/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>
|
|
(<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-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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
} 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> |