upload: in-modal "Upload destination" storage picker (#44)

The storage summary gains a connection <select> (hidden with fewer than two
connections) listing every configured connection with the default labelled.
Selection is a per-modal override — loadFromStorage re-resolves the summary
and credentials immediately, stale selections fall back to the default, and
the account default stays untouched. Picker logic lives in
uploadModalStorageUnlock.js for UploadModal's file-size budget.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Fable 5 committed 2026-07-03 21:24:13 -04:00
1 parent d1a578f3a2
commit 35a50611fe
5 files changed
+217 -20

No files matched your search

+5
View File
@@ -153,6 +153,11 @@
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage</button>
</div>
</div>
<!-- #44: switch upload destination without leaving the modal -->
<div id="storage-connection-picker" class="hidden space-y-1">
<label for="select-storage-connection" class="text-xs text-muted">Upload destination</label>
<select id="select-storage-connection" class="input input-sm text-xs"></select>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="min-w-0">
<span class="block text-muted">Provider</span>
+19 -3
View File
@@ -31,6 +31,8 @@ import {
ensureStorageUnlockedForUpload,
resetThumbnailPicker,
promptStoredNsecUnlock,
pickTargetConnection,
renderConnectionPicker,
} from "./uploadModalStorageUnlock.js";
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
@@ -116,6 +118,8 @@ export class UploadModal {
this.activeProvider = null;
this.isStorageUnlocked = false;
this.storageConfigured = false;
// #44: per-modal upload-destination override (null = account default).
this.selectedConnectionId = null;
// Upload State
this.videoUploadState = {
@@ -247,6 +251,7 @@ export class UploadModal {
progress: $("#upload-progress-container"),
thumbnailProgress: $("#thumbnail-progress-container"),
results: $("#upload-results-container"),
connectionPicker: $("#storage-connection-picker"),
};
this.storageViews = {
@@ -274,6 +279,9 @@ export class UploadModal {
// Progress
progress: $("#input-progress"),
thumbnailProgress: $("#thumbnail-progress"),
// #44: upload-destination picker
storageConnection: $("#select-storage-connection"),
};
// Results (Generated Links)
@@ -334,6 +342,14 @@ export class UploadModal {
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
}
if (this.inputs.storageConnection) {
// #44: switching the destination re-resolves summary + credentials.
this.inputs.storageConnection.addEventListener("change", () => {
this.selectedConnectionId = this.inputs.storageConnection.value || null;
void this.loadFromStorage();
});
}
// Manage Storage
if (this.toggles.manageStorage) {
this.toggles.manageStorage.addEventListener("click", () => {
@@ -910,9 +926,9 @@ export class UploadModal {
try {
const connections = await this.storageService.listConnections(pubkey);
// Prefer default connection
const defaultConn = connections.find(c => c.meta?.defaultForUploads);
const targetConn = defaultConn || connections[0];
// #44: a per-modal selection wins over the account default.
const { targetConn, defaultConn } = pickTargetConnection(this, connections);
renderConnectionPicker(this, connections, targetConn, defaultConn);
if (targetConn) {
this.storageConfigured = true;
+51 -4
View File
@@ -1,9 +1,10 @@
// js/ui/components/uploadModalStorageUnlock.js
//
// Storage-unlock glue for the Upload modal (#36 / #56), kept out of
// UploadModal.js to stay within its file-size budget. Every function operates
// on the UploadModal instance (`modal`); UploadModal exposes them as thin
// prototype delegates so callers and tests keep using the instance methods.
// Storage glue for the Upload modal (#36 / #56 unlock + #44 connection
// picker), kept out of UploadModal.js to stay within its file-size budget.
// Every function operates on the UploadModal instance (`modal`); UploadModal
// exposes them as thin prototype delegates so callers and tests keep using
// the instance methods.
import { devLogger, userLogger } from "../../utils/logger.js";
@@ -67,6 +68,52 @@ export function resetThumbnailPicker(modal) {
}
}
// #44: resolve which configured connection this upload targets. A per-modal
// selection wins; a stale selection (connection deleted meanwhile) is reset;
// otherwise the account default, then the first configured connection.
export function pickTargetConnection(modal, connections) {
const list = Array.isArray(connections) ? connections : [];
const defaultConn = list.find((conn) => conn?.meta?.defaultForUploads) || null;
if (modal.selectedConnectionId) {
const selected = list.find((conn) => conn?.id === modal.selectedConnectionId);
if (selected) {
return { targetConn: selected, defaultConn };
}
modal.selectedConnectionId = null;
}
return { targetConn: defaultConn || list[0] || null, defaultConn };
}
// #44: render the "Upload destination" <select>. Hidden with fewer than two
// connections (nothing to choose); the default connection is labelled.
export function renderConnectionPicker(modal, connections, targetConn, defaultConn) {
const select = modal.inputs?.storageConnection;
const wrap = modal.sourceSections?.connectionPicker;
if (!select || !(wrap instanceof HTMLElement)) {
return;
}
const list = Array.isArray(connections) ? connections : [];
if (list.length < 2) {
wrap.classList.add("hidden");
return;
}
select.textContent = "";
for (const conn of list) {
if (!conn?.id) {
continue;
}
const option = select.ownerDocument.createElement("option");
option.value = conn.id;
const label = `${modal.getProviderLabel(conn.provider || conn.meta?.provider)} — ${
conn.meta?.bucket || "unnamed bucket"
}`;
option.textContent = conn.id === defaultConn?.id ? `${label} (default)` : label;
option.selected = conn.id === targetConn?.id;
select.appendChild(option);
}
wrap.classList.remove("hidden");
}
// Returns true if a locked persisted-nsec session was detected (for the active
// account) and the re-unlock (passphrase) flow was opened, so the caller can
// stop instead of dead-ending with "No signer available".
+116
View File
@@ -0,0 +1,116 @@
// #44: the upload modal gets an in-modal "Upload destination" picker so users
// can target any configured storage connection (R2 / B2 / custom S3) without
// leaving the modal. Selection is per-modal and wins over the account default;
// a stale selection (connection deleted meanwhile) falls back to the default.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-upload-destination-picker
// given: "0..n configured connections, with/without a default and a per-modal selection"
// when: "pickTargetConnection resolves the target and renderConnectionPicker renders the select"
// then: "selection > default > first; stale selections reset; picker hidden under two connections; default labelled"
// observable_outcomes:
// - "resolved target connection object"
// - "rendered <option> values/labels/selected state and wrapper visibility"
// determinism_controls:
// - "JSDOM document; plain data objects; no storage or network"
// anti_cheat_rationale:
// prevents: ["hard-coded return value", "over-mocking internal logic"]
// relaxation:
// did_relax_any_assertion: false
import test from "node:test";
import { strict as assert } from "node:assert";
import { JSDOM } from "jsdom";
import {
pickTargetConnection,
renderConnectionPicker,
} from "../js/ui/components/uploadModalStorageUnlock.js";
const r2 = { id: "c1", provider: "cloudflare_r2", meta: { bucket: "vids" } };
const b2 = {
id: "c2",
provider: "backblaze_b2",
meta: { bucket: "backups", defaultForUploads: true },
};
const s3 = { id: "c3", provider: "s3", meta: { bucket: "archive" } };
test("target resolution: selection > default > first, stale selection resets", () => {
const modal = { selectedConnectionId: null };
assert.equal(
pickTargetConnection(modal, [r2, b2, s3]).targetConn.id,
"c2",
"account default wins with no selection",
);
assert.equal(
pickTargetConnection(modal, [r2, s3]).targetConn.id,
"c1",
"first connection when nothing is default",
);
modal.selectedConnectionId = "c3";
const picked = pickTargetConnection(modal, [r2, b2, s3]);
assert.equal(picked.targetConn.id, "c3", "per-modal selection wins");
assert.equal(picked.defaultConn.id, "c2", "default still reported for labelling");
modal.selectedConnectionId = "gone";
assert.equal(
pickTargetConnection(modal, [r2, b2, s3]).targetConn.id,
"c2",
"stale selection falls back to the default",
);
assert.equal(modal.selectedConnectionId, null, "stale selection is reset");
assert.equal(pickTargetConnection(modal, []).targetConn, null, "no connections");
});
function makeModal() {
const dom = new JSDOM(
'<!DOCTYPE html><div id="wrap" class="hidden"><select id="sel"></select></div>',
);
const doc = dom.window.document;
global.HTMLElement = dom.window.HTMLElement;
return {
modal: {
selectedConnectionId: null,
inputs: { storageConnection: doc.getElementById("sel") },
sourceSections: { connectionPicker: doc.getElementById("wrap") },
getProviderLabel(provider) {
return provider === "cloudflare_r2" ? "Cloudflare R2" : provider;
},
},
doc,
};
}
test("picker renders options (default labelled, target selected); hidden under two connections", () => {
const { modal } = makeModal();
renderConnectionPicker(modal, [r2, b2, s3], b2, b2);
const wrap = modal.sourceSections.connectionPicker;
const options = Array.from(modal.inputs.storageConnection.options);
assert.equal(wrap.classList.contains("hidden"), false, "picker shown");
assert.deepEqual(
options.map((o) => o.value),
["c1", "c2", "c3"],
);
assert.match(options[0].textContent, /Cloudflare R2 — vids/);
assert.match(options[1].textContent, /\(default\)$/, "default is labelled");
assert.equal(
modal.inputs.storageConnection.value,
"c2",
"target connection pre-selected",
);
renderConnectionPicker(modal, [r2, b2, s3], s3, b2);
assert.equal(modal.inputs.storageConnection.value, "c3", "re-render follows the target");
renderConnectionPicker(modal, [r2], r2, null);
assert.equal(
wrap.classList.contains("hidden"),
true,
"hidden when there is nothing to choose",
);
});
+26 -13
View File
@@ -555,21 +555,34 @@ toward freshness and looked identical. Gave each a structural identity:
the active signer if ever reused.
### 43. Logged-in profile card at the top of the profile modal (UX)
- [ ] Show the **logged-in user's profile photo + name** at the **top-center** of the
profile modal, styled as a "profile card" that sits **half-on / half-off** the
modal edge (overlapping the top border) for a polished look. Goal: users can see
WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name
from the profile cache for the active pubkey; update on profile switch.
- [x] **DONE 2026-07-03.** An "Acting as <name>" identity pill now renders
top-center on EVERY pane, straddling the header divider (negative
block-start margin — the modal is full-screen, so the header edge is the
overlap surface). Avatar + name come from the same resolveMeta path as
the Account pane card (profile cache first), populated in
renderSavedProfiles so it refreshes on every profile switch; hidden when
logged out; clicking it jumps to the Account pane. Markup in
components/profile-modal.html, styles in css/tailwind.source.css
(.profile-modal__identity*), wiring in profileModalController. Test in
tests/profile-modal-controller.test.mjs (26 pass). Lint + build green.
- [ ] **VERIFY on unstable:** open any pane → pill shows the active identity;
switch accounts → it updates; log out → it disappears; click → Account.
### 44. In-modal storage selector for uploads (UX)
- [ ] Make storage unlock + selection easier from the **video upload modal**: let the
user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it
**without leaving the upload modal** — today they must close it, open Profile →
Storage, change the default/selected connection, then reopen upload. Add a provider
picker (driven by `storageService.listConnections`) + inline unlock in the upload
modal's storage section; selecting one sets it as the active upload target for this
upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
connection model.
- [x] **DONE 2026-07-03.** The upload modal's storage summary now has an
"Upload destination" <select> (hidden with <2 connections) listing every
configured connection ("Provider — bucket", default labelled). Selecting
one becomes the upload target for THIS modal (per-modal override; the
account default is untouched — set that in Profile → Storage) and
loadFromStorage re-resolves summary + credentials immediately. A stale
selection (connection deleted) falls back to the default. Inline unlock
was already covered by #56's gate. Logic in
uploadModalStorageUnlock.js (pickTargetConnection/renderConnectionPicker,
UploadModal file-size budget). Tests:
tests/upload-storage-picker.test.mjs (2 scenarios). Lint + build green.
- [ ] **VERIFY on unstable:** with 2+ connections configured, open Upload →
Storage Status → switch destination → summary updates and the upload
lands in the chosen bucket; with 1 connection the picker stays hidden.
### 45. Upload modal: suggest tags from the user's previous videos (UX)
- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap