18 KiB
Sidenav Collapsible Sections, Drag-to-Upload & Blossom API Extraction
Overview
Four enhancements to the sidenav on www/blobs.html (prototyped here before rolling out to all pages):
- Collapsible sections — clicking a section title collapses/expands its content
- Generalized sidenav section pattern — shared CSS and optional JS helper so relay and blossom sections follow the same collapsible structure
- Drag-and-drop upload onto blossom server names — drag a blob file over a server name in the blossom section to upload directly to that specific server
- Extract
blossom-api.mjs— shared upload/delete/auth/hash module so any page can interact with blossom servers
Current Architecture
Sidenav Section Layout in blobs.html
#divSideNav
├── #divSideNavHeader
├── #divSideNavBody (flex: 1, scrollable)
├── #divRelaySection (fixed, rendered by relay-ui.mjs)
│ ├── #divRelaySectionTitle "リレー"
│ └── #divRelayList
├── #divBlossomSection (fixed, rendered by blossom-ui.mjs)
│ ├── #divBlossomSectionTitle "ブロッサム"
│ └── #divBlossomList
└── #divVersionBar
Key Files
www/css/client.css— Global styles for#divRelaySection,#divBlossomSection, titles, listswww/js/relay-ui.mjs— Renders relay rows into#divRelayListwww/js/blossom-ui.mjs— Renders blossom server rows into#divBlossomListwww/blobs.html— The page we are prototyping on
Feature 1: Collapsible Sections
Behavior
- Clicking the section title toggles visibility of the section content (the list div)
- Collapsed state persists via
localStorageper section ID - A small visual indicator shows collapsed/expanded state (CSS rotation of a caret or similar)
- Default state: expanded
Implementation
CSS Changes in client.css
Add styles for the collapsible behavior on the shared section title selectors:
#divRelaySectionTitle,
#divBlossomSectionTitle {
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Collapse indicator arrow appended via ::after */
#divRelaySectionTitle::after,
#divBlossomSectionTitle::after {
content: '▾';
font-size: 80%;
transition: transform 0.3s ease;
color: var(--muted-color);
}
/* When parent section has .collapsed class, rotate the arrow */
#divRelaySection.collapsed #divRelaySectionTitle::after,
#divBlossomSection.collapsed #divBlossomSectionTitle::after {
transform: rotate(-90deg);
}
/* Hide the list when collapsed */
#divRelaySection.collapsed #divRelayList,
#divBlossomSection.collapsed #divBlossomList {
display: none;
}
JS — Generalized Toggle Helper
Rather than duplicating toggle logic in each UI module, add a small shared function. This can live in a new utility or be inlined in each module. The simplest approach: add it directly to relay-ui.mjs and blossom-ui.mjs since they already own their respective sections.
In relay-ui.mjs — add to initSidenavRelaySection():
export function initSidenavRelaySection() {
// ... existing code ...
// Collapsible section toggle
const title = document.getElementById('divRelaySectionTitle');
const section = document.getElementById('divRelaySection');
if (title && section) {
// Restore collapsed state
if (localStorage.getItem('sidenav-relay-collapsed') === 'true') {
section.classList.add('collapsed');
}
title.addEventListener('click', () => {
section.classList.toggle('collapsed');
localStorage.setItem('sidenav-relay-collapsed', section.classList.contains('collapsed'));
});
}
}
In blossom-ui.mjs — add to initBlossomSection():
export async function initBlossomSection() {
// ... existing code ...
// Collapsible section toggle
const title = document.getElementById('divBlossomSectionTitle');
const section = document.getElementById('divBlossomSection');
if (title && section) {
if (localStorage.getItem('sidenav-blossom-collapsed') === 'true') {
section.classList.add('collapsed');
}
title.addEventListener('click', () => {
section.classList.toggle('collapsed');
localStorage.setItem('sidenav-blossom-collapsed', section.classList.contains('collapsed'));
});
}
}
Feature 2: Generalized Sidenav Section Pattern
Analysis
The relay and blossom sections already share a very similar structure:
- A container div with
border-top,padding,flex-shrink: 0 - A title div with
pageKanjifont - A list div with
font-size: 70%
The CSS in client.css already groups them:
#divRelaySection,
#divBlossomSection { ... }
#divRelaySectionTitle,
#divBlossomSectionTitle { ... }
#divRelayList,
#divBlossomList { ... }
Approach: CSS Class-Based Generalization
Convert the ID-based styles to also support class-based selectors. This makes it trivial to add future sections without touching client.css again.
Add class-based equivalents alongside the existing ID selectors:
/* Generalized sidenav section classes */
.sidenavSection {
flex-shrink: 0;
background-color: var(--secondary-color);
border-top: 3px solid var(--primary-color);
padding: 10px;
}
.sidenavSectionTitle {
font-family: pageKanji;
font-size: 100%;
font-weight: bold;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid var(--muted-color);
cursor: pointer;
user-select: none;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidenavSectionTitle::after {
content: '▾';
font-size: 80%;
transition: transform 0.3s ease;
color: var(--muted-color);
}
.sidenavSection.collapsed .sidenavSectionTitle::after {
transform: rotate(-90deg);
}
.sidenavSectionList {
font-size: 70%;
font-family: var(--font-family);
}
.sidenavSection.collapsed .sidenavSectionList {
display: none;
}
Then update the existing HTML to add these classes alongside the IDs:
<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>
The existing ID-based selectors remain for backward compatibility. The class-based selectors provide the collapsible behavior and make future sections easy.
Why Not a Full JS Component?
The relay and blossom sections have fundamentally different internal rendering logic (relay-ui.mjs creates HamburgerMorphing indicators with relay-specific shapes; blossom-ui.mjs creates health indicators with blob counts). Abstracting the content rendering into a shared component would add complexity without real benefit. The shared parts are:
- CSS structure — handled by the
.sidenavSectionclasses above - Collapse toggle — a 5-line pattern repeated in each module's init function
- HTML skeleton — the same 3-div pattern in each page
This is the right level of abstraction: shared CSS classes + a simple collapse pattern, with each module owning its own content rendering.
Feature 3: Drag-and-Drop Upload onto Blossom Server Names
Behavior
- User drags a file from their desktop (or from the blob grid) over a server name in the
#divBlossomList - The server name highlights to indicate it is a valid drop target
- On drop, the file is uploaded to that specific server only (not all upload servers)
- Visual feedback during upload (server name shows uploading state)
Implementation
Changes to blossom-ui.mjs
The renderBlossomSection() function creates server rows. We need to:
- Add drag-and-drop event listeners to each server row's name element
- Emit a custom event when a file is dropped, carrying the server URL and file data
- Add CSS classes for drag-over visual feedback
In renderBlossomSection() — modify the row creation to add drag-drop handlers:
// Make the server name a drop target
name.classList.add('blossomServerDropTarget');
name.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
name.classList.add('dragover');
});
name.addEventListener('dragleave', (e) => {
e.stopPropagation();
name.classList.remove('dragover');
});
name.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
name.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
// Dispatch custom event with server URL and files
window.dispatchEvent(new CustomEvent('blossomServerDrop', {
detail: { serverUrl: server.url, files }
}));
}
});
CSS for Drop Target Feedback
Add to client.css:
.blossomServerDropTarget {
transition: color 0.2s, border-color 0.2s;
padding: 2px 4px;
border-radius: var(--border-radius);
}
.blossomServerDropTarget.dragover {
color: var(--accent-color) !important;
font-weight: bold !important;
outline: 1px dashed var(--accent-color);
outline-offset: 2px;
}
Changes to blobs.html
In blobs.html, listen for the custom event and perform the upload to the specific server:
// Listen for drag-drop uploads to specific blossom servers
window.addEventListener('blossomServerDrop', async (event) => {
const { serverUrl, files } = event.detail;
console.log(`[blobs.html] Uploading ${files.length} file(s) to ${serverUrl}`);
for (const file of files) {
try {
await uploadBlobToServer(file, serverUrl);
} catch (error) {
console.error(`Failed to upload ${file.name} to ${serverUrl}:`, error);
}
}
});
Add a new uploadBlobToServer() function that is a simplified version of uploadBlob() targeting a single server:
const uploadBlobToServer = async (file, serverUrl) => {
const sha256 = await calculateSHA256(file);
const event = {
kind: 24242,
created_at: Math.floor(Date.now() / 1000),
tags: [
['t', 'upload'],
['x', sha256],
['size', String(file.size)],
['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
],
content: `Upload ${file.name}`,
pubkey: currentPubkey
};
const signedEvent = await window.nostr.signEvent(event);
event.id = signedEvent.id;
event.sig = signedEvent.sig;
const authToken = btoa(JSON.stringify(event));
const response = await fetch(`${serverUrl}/upload`, {
method: 'PUT',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Authorization': `Nostr ${authToken}`
},
body: file
});
if (response.ok) {
const blobDescriptor = await response.json();
USER_BLOBS.unshift(blobDescriptor);
// Update server blob maps
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);
}
// Refresh UI
if (BLOB_VIEW_MODE === 'list') updateBlobTable();
else updateBlobGrid();
showSuccess(`Uploaded ${file.name} to ${serverUrl}`);
return blobDescriptor;
} else {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
};
Feature 4: Extract blossom-api.mjs — Shared Upload/Delete Module
Problem
Currently all blossom upload/delete/auth/hash logic lives directly in blobs.html as page-level functions. Other pages (post.html for image uploads, msg.html for attachments, music.html for album art, etc.) will need the same capabilities. Duplicating this code would be unmaintainable.
Solution
Create www/js/blossom-api.mjs — a shared module containing the operational side of blossom interactions. This complements blossom-ui.mjs which handles the display side.
graph LR
subgraph Shared Modules
A[blossom-ui.mjs] -->|getBlossomServers| B[Server list + health state]
C[blossom-api.mjs] -->|upload delete mirror| D[Blossom operations]
C -->|imports getBlossomServers| A
end
subgraph Pages
E[blobs.html] -->|imports| A
E -->|imports| C
F[post.html - future] -->|imports| C
G[msg.html - future] -->|imports| C
H[music.html - future] -->|imports| C
end
Exports
| Function | Signature | Purpose |
|---|---|---|
uploadToServer |
file, serverUrl => Promise of BlobDescriptor |
Upload a single file to a specific server |
uploadToAllServers |
file => Promise of BlobDescriptor |
Upload to all configured upload servers + mirror to mirror servers |
deleteFromAllServers |
sha256, filename? => Promise of boolean |
Delete from all healthy servers |
mirrorBlobToServers |
sha256, filename? => Promise of void |
Mirror an existing blob to configured mirror servers |
createBlossomAuth |
action, sha256?, content? => Promise of string |
Create and sign a BUD-02 auth event, return base64 token |
calculateSHA256 |
file => Promise of string |
Hash a file and return hex string |
listUserBlobs |
serverUrl, pubkey, since? => Promise of Array |
List blobs for a user on a specific server |
Internal Dependencies
- Imports
getBlossomServers()fromblossom-ui.mjsfor server list - Imports
getPubkey()frominit-ndk.mjsfor current user pubkey - Uses
window.nostr.signEvent()for Nostr auth signing (same as current code)
Functions Extracted from blobs.html
These functions move from blobs.html into blossom-api.mjs:
| Current function in blobs.html | New export in blossom-api.mjs |
|---|---|
calculateSHA256() line 1174 |
calculateSHA256() |
createAuthEvent() line 1187 |
createBlossomAuth() |
listUserBlobs() line 1232 |
listUserBlobs() |
uploadBlob() line 1280 |
uploadToAllServers() |
mirrorBlob() line 1558 |
mirrorBlobToServers() |
deleteBlob() line 1640 |
deleteFromAllServers() |
| new | uploadToServer() — simplified single-server upload |
What Stays in blobs.html
USER_BLOBSarray and blob pagination staterefreshBlobs()— orchestrates listing from all servers, deduplication, UI updatesyncBlobs()— orchestrates cross-server synchronizationupdateBlobGrid()/updateBlobTable()— UI renderingwindow.SERVER_BLOB_MAPS— tracking which blobs are on which servers- Event listeners for buttons, drag-drop handler for
blossomServerDrop
Callback Pattern for UI Updates
uploadToAllServers() and deleteFromAllServers() currently update USER_BLOBS and call UI update functions directly. In the extracted module, they return results and let the calling page handle UI updates:
// In blossom-api.mjs — returns the blob descriptor, does not touch UI
export async function uploadToAllServers(file) {
// ... upload logic ...
return { blobDescriptor, serverResults };
}
// In blobs.html — handles UI
const result = await uploadToAllServers(file);
if (result.blobDescriptor) {
USER_BLOBS.unshift(result.blobDescriptor);
updateBlobGrid();
}
Architecture Diagram
graph TD
subgraph CSS - client.css
A[.sidenavSection] --> B[.sidenavSectionTitle]
A --> C[.sidenavSectionList]
A --> D[.sidenavSection.collapsed]
E[.blossomServerDropTarget]
E --> F[.blossomServerDropTarget.dragover]
end
subgraph relay-ui.mjs
G[initSidenavRelaySection] --> H[Add collapse toggle listener]
G --> I[Restore collapsed state from localStorage]
end
subgraph blossom-ui.mjs
J[initBlossomSection] --> K[Add collapse toggle listener]
J --> L[Restore collapsed state from localStorage]
M[renderBlossomSection] --> N[Add dragover/drop listeners to server names]
N --> O[Dispatch blossomServerDrop custom event]
end
subgraph blossom-api.mjs - NEW
S[uploadToServer] --> T[createBlossomAuth + fetch PUT]
U[uploadToAllServers] --> V[Local/remote server partitioning + mirror]
W[deleteFromAllServers] --> X[Auth + DELETE to all healthy servers]
Y[calculateSHA256] --> Z[crypto.subtle.digest]
end
subgraph blobs.html
P[Listen for blossomServerDrop event]
P --> S
Q[refreshBlobs] --> R[listUserBlobs from blossom-api.mjs]
AA[uploadBlob button] --> U
BB[deleteBlob button] --> W
end
File Change Summary
| File | Changes |
|---|---|
www/css/client.css |
Add .sidenavSection, .sidenavSectionTitle, .sidenavSectionList classes with collapse behavior; add .blossomServerDropTarget styles |
www/js/relay-ui.mjs |
Add collapse toggle logic in initSidenavRelaySection() |
www/js/blossom-ui.mjs |
Add collapse toggle logic in initBlossomSection(); add drag-drop event listeners in renderBlossomSection() |
www/js/blossom-api.mjs (new) |
New shared module: uploadToServer, uploadToAllServers, deleteFromAllServers, mirrorBlobToServers, createBlossomAuth, calculateSHA256, listUserBlobs |
www/blobs.html |
Add section classes to HTML; import from blossom-api.mjs; remove extracted functions; add blossomServerDrop event listener; refactor upload/delete/mirror calls to use shared module |
Notes
- This is prototyped on
blobs.htmlonly. Once validated, the collapsible section classes and HTML class additions can be rolled out to all 20+ pages. - The drag-drop upload feature only makes sense on
blobs.html(the event listener). The drag-drop target setup inblossom-ui.mjsis harmless on other pages — the custom event simply won't have a listener. - The
blossomServerDropcustom event pattern keepsblossom-ui.mjsdecoupled from upload logic. Any page can listen for this event and handle it differently. blossom-api.mjsis designed so future pages canimport { uploadToAllServers } from './js/blossom-api.mjs'and immediately have full blossom upload capability without duplicating any code.- The
isLocalServer()helper and local/remote server partitioning logic moves intoblossom-api.mjssince it is part of the upload strategy.