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>
|
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage</button>
|
||||||
</div>
|
</div>
|
||||||
</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="grid grid-cols-3 gap-2 text-xs">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="block text-muted">Provider</span>
|
<span class="block text-muted">Provider</span>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
ensureStorageUnlockedForUpload,
|
ensureStorageUnlockedForUpload,
|
||||||
resetThumbnailPicker,
|
resetThumbnailPicker,
|
||||||
promptStoredNsecUnlock,
|
promptStoredNsecUnlock,
|
||||||
|
pickTargetConnection,
|
||||||
|
renderConnectionPicker,
|
||||||
} from "./uploadModalStorageUnlock.js";
|
} from "./uploadModalStorageUnlock.js";
|
||||||
|
|
||||||
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
const INFO_HASH_PATTERN = /^[a-f0-9]{40}$/;
|
||||||
@@ -116,6 +118,8 @@ export class UploadModal {
|
|||||||
this.activeProvider = null;
|
this.activeProvider = null;
|
||||||
this.isStorageUnlocked = false;
|
this.isStorageUnlocked = false;
|
||||||
this.storageConfigured = false;
|
this.storageConfigured = false;
|
||||||
|
// #44: per-modal upload-destination override (null = account default).
|
||||||
|
this.selectedConnectionId = null;
|
||||||
|
|
||||||
// Upload State
|
// Upload State
|
||||||
this.videoUploadState = {
|
this.videoUploadState = {
|
||||||
@@ -247,6 +251,7 @@ export class UploadModal {
|
|||||||
progress: $("#upload-progress-container"),
|
progress: $("#upload-progress-container"),
|
||||||
thumbnailProgress: $("#thumbnail-progress-container"),
|
thumbnailProgress: $("#thumbnail-progress-container"),
|
||||||
results: $("#upload-results-container"),
|
results: $("#upload-results-container"),
|
||||||
|
connectionPicker: $("#storage-connection-picker"),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.storageViews = {
|
this.storageViews = {
|
||||||
@@ -274,6 +279,9 @@ export class UploadModal {
|
|||||||
// Progress
|
// Progress
|
||||||
progress: $("#input-progress"),
|
progress: $("#input-progress"),
|
||||||
thumbnailProgress: $("#thumbnail-progress"),
|
thumbnailProgress: $("#thumbnail-progress"),
|
||||||
|
|
||||||
|
// #44: upload-destination picker
|
||||||
|
storageConnection: $("#select-storage-connection"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Results (Generated Links)
|
// Results (Generated Links)
|
||||||
@@ -334,6 +342,14 @@ export class UploadModal {
|
|||||||
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
|
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
|
// Manage Storage
|
||||||
if (this.toggles.manageStorage) {
|
if (this.toggles.manageStorage) {
|
||||||
this.toggles.manageStorage.addEventListener("click", () => {
|
this.toggles.manageStorage.addEventListener("click", () => {
|
||||||
@@ -910,9 +926,9 @@ export class UploadModal {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const connections = await this.storageService.listConnections(pubkey);
|
const connections = await this.storageService.listConnections(pubkey);
|
||||||
// Prefer default connection
|
// #44: a per-modal selection wins over the account default.
|
||||||
const defaultConn = connections.find(c => c.meta?.defaultForUploads);
|
const { targetConn, defaultConn } = pickTargetConnection(this, connections);
|
||||||
const targetConn = defaultConn || connections[0];
|
renderConnectionPicker(this, connections, targetConn, defaultConn);
|
||||||
|
|
||||||
if (targetConn) {
|
if (targetConn) {
|
||||||
this.storageConfigured = true;
|
this.storageConfigured = true;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// js/ui/components/uploadModalStorageUnlock.js
|
// js/ui/components/uploadModalStorageUnlock.js
|
||||||
//
|
//
|
||||||
// Storage-unlock glue for the Upload modal (#36 / #56), kept out of
|
// Storage glue for the Upload modal (#36 / #56 unlock + #44 connection
|
||||||
// UploadModal.js to stay within its file-size budget. Every function operates
|
// picker), kept out of UploadModal.js to stay within its file-size budget.
|
||||||
// on the UploadModal instance (`modal`); UploadModal exposes them as thin
|
// Every function operates on the UploadModal instance (`modal`); UploadModal
|
||||||
// prototype delegates so callers and tests keep using the instance methods.
|
// exposes them as thin prototype delegates so callers and tests keep using
|
||||||
|
// the instance methods.
|
||||||
|
|
||||||
import { devLogger, userLogger } from "../../utils/logger.js";
|
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
|
// 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
|
// account) and the re-unlock (passphrase) flow was opened, so the caller can
|
||||||
// stop instead of dead-ending with "No signer available".
|
// 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.
|
the active signer if ever reused.
|
||||||
|
|
||||||
### 43. Logged-in profile card at the top of the profile modal (UX)
|
### 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
|
- [x] **DONE 2026-07-03.** An "Acting as <name>" identity pill now renders
|
||||||
profile modal, styled as a "profile card" that sits **half-on / half-off** the
|
top-center on EVERY pane, straddling the header divider (negative
|
||||||
modal edge (overlapping the top border) for a polished look. Goal: users can see
|
block-start margin — the modal is full-screen, so the header edge is the
|
||||||
WHICH profile they're acting as (e.g. uploading as) at a glance. Pull avatar/name
|
overlap surface). Avatar + name come from the same resolveMeta path as
|
||||||
from the profile cache for the active pubkey; update on profile switch.
|
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)
|
### 44. In-modal storage selector for uploads (UX)
|
||||||
- [ ] Make storage unlock + selection easier from the **video upload modal**: let the
|
- [x] **DONE 2026-07-03.** The upload modal's storage summary now has an
|
||||||
user **pick any configured storage connection** (R2 / B2 / Custom S3) and unlock it
|
"Upload destination" <select> (hidden with <2 connections) listing every
|
||||||
**without leaving the upload modal** — today they must close it, open Profile →
|
configured connection ("Provider — bucket", default labelled). Selecting
|
||||||
Storage, change the default/selected connection, then reopen upload. Add a provider
|
one becomes the upload target for THIS modal (per-modal override; the
|
||||||
picker (driven by `storageService.listConnections`) + inline unlock in the upload
|
account default is untouched — set that in Profile → Storage) and
|
||||||
modal's storage section; selecting one sets it as the active upload target for this
|
loadFromStorage re-resolves summary + credentials immediately. A stale
|
||||||
upload (and optionally the default). Reuse `loadFromStorage` / the per-provider
|
selection (connection deleted) falls back to the default. Inline unlock
|
||||||
connection model.
|
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)
|
### 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
|
- [x] **DONE 2026-07-01.** The Hashtags section of the upload modal now shows one-tap
|
||||||
|
|||||||
Reference in new issue
Block a user