mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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:
1 parent
d1a578f3a2
commit
35a50611fe
5 files changed
+217
-20
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user