Files
client/plans/sidenav-collapsible-sections-and-drag-upload.md
2026-04-17 16:52:51 -04:00

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):

  1. Collapsible sections — clicking a section title collapses/expands its content
  2. Generalized sidenav section pattern — shared CSS and optional JS helper so relay and blossom sections follow the same collapsible structure
  3. 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
  4. 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


Feature 1: Collapsible Sections

Behavior

  • Clicking the section title toggles visibility of the section content (the list div)
  • Collapsed state persists via localStorage per 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 pageKanji font
  • 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:

  1. CSS structure — handled by the .sidenavSection classes above
  2. Collapse toggle — a 5-line pattern repeated in each module's init function
  3. 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:

  1. Add drag-and-drop event listeners to each server row's name element
  2. Emit a custom event when a file is dropped, carrying the server URL and file data
  3. 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() from blossom-ui.mjs for server list
  • Imports getPubkey() from init-ndk.mjs for 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_BLOBS array and blob pagination state
  • refreshBlobs() — orchestrates listing from all servers, deduplication, UI update
  • syncBlobs() — orchestrates cross-server synchronization
  • updateBlobGrid() / updateBlobTable() — UI rendering
  • window.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.html only. 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 in blossom-ui.mjs is harmless on other pages — the custom event simply won't have a listener.
  • The blossomServerDrop custom event pattern keeps blossom-ui.mjs decoupled from upload logic. Any page can listen for this event and handle it differently.
  • blossom-api.mjs is designed so future pages can import { 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 into blossom-api.mjs since it is part of the upload strategy.