Refactor upload modal for generic storage and read-only config

- Updates Upload Modal UI labels to be provider-agnostic (e.g., 'Storage Credentials', 'Endpoint').
- Implements a read-only 'Summary View' for configured storage connections, collapsing the editable form by default.
- Adds 'Manage Storage' button that navigates to the Profile Modal's storage pane.
- Updates `initUploadModal.js` to inject the navigation callback.
- Updates `r2Service.js` status messages to be generic.
- Fixes unlock flow to ensure UI updates after storage unlock.
This commit is contained in:
google-labs-jules[bot] committed 2026-01-16 15:44:22 +00:00
1 parent 0f4775819e
commit 2b446e8ddd
5 files changed
+273 -129

No files matched your search

+99 -74
View File
@@ -115,93 +115,118 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Configure R2 Storage</span>
<span>Storage Configuration</span>
</button>
<!-- Storage Settings Form -->
<!-- Storage Settings Container -->
<div id="section-storage-settings" class="hidden mt-3 space-y-4 bg-surface-alt/50 p-4 rounded-lg border border-border/50">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Cloudflare R2 Credentials</h4>
<div class="flex items-center gap-2">
<span id="storage-lock-status" class="text-xs text-muted">Locked 🔒</span>
<button type="button" id="btn-storage-unlock" class="text-xs text-accent hover:underline hidden">Unlock</button>
<span id="storage-status" class="text-xs text-muted hidden">Stored locally</span>
<!-- Read-Only Summary (for configured storage) -->
<div id="storage-summary-view" class="hidden space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Active Storage</h4>
<div class="flex items-center gap-2">
<span id="storage-lock-status" class="text-xs text-muted">Locked 🔒</span>
<button type="button" id="btn-storage-unlock" class="text-xs text-accent hover:underline hidden">Unlock</button>
<button type="button" id="btn-manage-storage" class="text-xs text-muted hover:text-text hover:underline">Manage Storage</button>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<span class="block text-muted">Provider</span>
<span id="summary-provider" class="font-medium text-text">--</span>
</div>
<div>
<span class="block text-muted">Bucket</span>
<span id="summary-bucket" class="font-medium text-text">--</span>
</div>
</div>
</div>
<div class="mb-2">
<p class="text-xs text-muted">
Required for direct file uploads.
<a href="#view=upload-content" id="link-r2-help" class="text-accent hover:underline font-medium ml-1">
Read the setup guide.
</a>
Your videos will be uploaded to this bucket.
</p>
</div>
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
>
Copy
</button>
<!-- Editable Form (Legacy/Fallback) -->
<div id="storage-form-view" class="space-y-4">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold text-text">Storage Credentials</h4>
<span id="storage-status" class="text-xs text-muted hidden">Stored locally</span>
</div>
<p class="text-4xs text-muted">Create a bucket with this exact name in Cloudflare R2.</p>
</div>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx..." />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
</div>
</div>
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public Bucket URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-4xs text-muted leading-tight">
Enable <strong>Public Access</strong> in the Cloudflare dashboard to get your r2.dev URL.
<div class="mb-2">
<p class="text-xs text-muted">
Required for direct file uploads.
<a href="#view=upload-content" id="link-r2-help" class="text-accent hover:underline font-medium ml-1">
Read the setup guide.
</a>
</p>
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Open Cloudflare Dashboard &rarr;
</a>
<!-- Target Bucket Display -->
<div class="space-y-1">
<label for="input-r2-bucket-name" class="text-xs font-medium text-text">Target Bucket Name (Required)</label>
<div class="flex gap-2">
<input
id="input-r2-bucket-name"
type="text"
class="input text-xs bg-surface-alt text-muted cursor-text select-all"
readonly
placeholder="Log in to see your bucket name"
/>
<button
type="button"
id="btn-copy-bucket"
class="btn-ghost px-2 text-xs border border-border/30"
aria-label="Copy bucket name"
>
Copy
</button>
</div>
<p class="text-4xs text-muted">Create a bucket with this exact name in your storage provider.</p>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
<!-- Step 1: Credentials -->
<div id="step-credentials" class="space-y-3">
<div class="space-y-1">
<label for="input-r2-account" class="text-xs font-medium text-text">Account ID / Endpoint</label>
<input id="input-r2-account" type="text" class="input text-xs" placeholder="xxxxxxxx... or https://..." />
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-1">
<label for="input-r2-key" class="text-xs font-medium text-text">Access Key ID</label>
<input id="input-r2-key" type="text" class="input text-xs" placeholder="AKIA..." />
</div>
<div class="space-y-1">
<label for="input-r2-secret" class="text-xs font-medium text-text">Secret Access Key</label>
<input id="input-r2-secret" type="password" class="input text-xs" />
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-next-step" class="btn btn-sm">Next</button>
</div>
</div>
<!-- Step 2: Verification -->
<div id="step-verification" class="hidden space-y-3 pt-2 border-t border-border/30">
<div class="space-y-1">
<label for="input-r2-domain" class="text-xs font-medium text-text">Public URL</label>
<input id="input-r2-domain" type="text" class="input text-xs" placeholder="https://pub-xxx.r2.dev" />
<p class="text-4xs text-muted leading-tight">
The public URL where your files will be accessible.
</p>
</div>
<!-- Error Container for Verification Failures -->
<div id="container-verification-error" class="hidden bg-critical/10 border border-critical/30 rounded p-3 text-xs text-critical space-y-2">
<p id="text-verification-error" class="font-medium">Verification Failed</p>
<a id="link-cloudflare-guide" href="#" target="_blank" class="block text-center bg-critical/10 hover:bg-critical/20 py-1.5 rounded font-medium transition-colors">
Check Provider Settings &rarr;
</a>
</div>
<div class="flex justify-end gap-2 pt-2">
<button type="button" id="btn-back-step" class="btn-ghost px-2 text-xs">Back</button>
<button type="button" id="btn-save-settings" class="btn btn-sm">Verify & Save</button>
</div>
</div>
</div>
</div>
@@ -404,4 +429,4 @@
</form>
</div>
</div>
</div>
</div>
+124 -55
View File
@@ -36,6 +36,7 @@ export class UploadModal {
safeEncodeNpub,
eventTarget,
container,
onRequestStorageSettings,
} = {}) {
this.authService = authService || null;
this.r2Service = r2Service || null;
@@ -60,6 +61,7 @@ export class UploadModal {
this.eventTarget =
eventTarget instanceof EventTarget ? eventTarget : new EventTarget();
this.container = container || null;
this.onRequestStorageSettings = typeof onRequestStorageSettings === "function" ? onRequestStorageSettings : null;
this.root = null;
this.isVisible = false;
@@ -67,6 +69,7 @@ export class UploadModal {
this.cloudflareSettings = this.r2Service?.getSettings?.() || null;
this.isStorageUnlocked = false;
this.activeConnectionId = null;
this.storageConfigured = false;
// UI References
this.form = null;
@@ -76,6 +79,7 @@ export class UploadModal {
this.toggles = {};
this.submitButton = null;
this.submitStatus = null;
this.summaryView = {};
// Logic/State
this.nip71FormManager = new Nip71FormManager();
@@ -125,14 +129,8 @@ export class UploadModal {
if (this.storageService) {
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
if (this.isStorageUnlocked) {
await this.loadFromStorage();
} else {
// Attempt legacy load if not unlocked (or maybe just show locked state)
// For now we keep legacy behavior as fallback if storageService isn't used
// But user requested provider agnostic.
// We will update UI state in setSourceMode or similar
}
// Even if locked, we want to know if there's *potentially* a connection saved to show correct UI state (locked vs empty)
await this.loadFromStorage();
} else {
await this.loadR2Settings();
}
@@ -172,6 +170,11 @@ export class UploadModal {
progress: $("#upload-progress-container"),
};
this.storageViews = {
summary: $("#storage-summary-view"),
form: $("#storage-form-view"),
};
// Inputs (Common)
this.inputs = {
title: $("#input-title"),
@@ -225,6 +228,7 @@ export class UploadModal {
r2HelpLink: $("#link-r2-help"),
copyBucket: $("#btn-copy-bucket"),
storageUnlock: $("#btn-storage-unlock"),
manageStorage: $("#btn-manage-storage"),
};
// Status text
@@ -233,6 +237,8 @@ export class UploadModal {
storageLock: $("#storage-lock-status"),
uploadMain: $("#upload-status-text"),
uploadPercent: $("#upload-percent-text"),
summaryProvider: $("#summary-provider"),
summaryBucket: $("#summary-bucket"),
};
this.nip71FormManager.registerSection("main", this.form);
@@ -294,6 +300,16 @@ export class UploadModal {
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
}
// Manage Storage
if (this.toggles.manageStorage) {
this.toggles.manageStorage.addEventListener("click", () => {
if (this.onRequestStorageSettings) {
this.close();
this.onRequestStorageSettings();
}
});
}
// Automation
this.setupDescriptionMirror();
this.setupMutuallyExclusiveCheckboxes(this.toggles.nsfw, this.toggles.kids);
@@ -333,9 +349,11 @@ export class UploadModal {
this.sourceSections.upload.classList.remove("hidden");
this.sourceSections.external.classList.add("hidden");
// Auto-show settings if missing
if (!this.hasValidR2Settings() && this.sourceSections.settings.classList.contains("hidden")) {
// Auto-show settings if missing or not configured
if (!this.storageConfigured && !this.hasValidR2Settings() && this.sourceSections.settings.classList.contains("hidden")) {
this.toggles.storageSettings.click(); // Expand
} else if (this.storageConfigured) {
// Keep collapsed if configured, per requirements
}
if (this.toggles.browseThumbnail) {
@@ -461,15 +479,7 @@ export class UploadModal {
this.isStorageUnlocked = pubkey ? this.storageService.isUnlocked(pubkey) : false;
this.updateLockUi();
if (this.isStorageUnlocked) {
await this.loadFromStorage();
} else {
// Locked: Clear sensitive data from UI and memory
this.cloudflareSettings = null;
this.fillSettingsForm({});
this.updateStorageStatus(false);
this.activeConnectionId = null;
}
await this.loadFromStorage();
} else if (this.r2Service && typeof this.r2Service.loadSettings === "function") {
await this.loadR2Settings();
}
@@ -483,13 +493,24 @@ export class UploadModal {
this.cloudflareSettings = settings || {};
this.fillSettingsForm(this.cloudflareSettings);
this.updateStorageStatus(this.hasValidR2Settings());
// Legacy mode implies no storage service or not used
this.toggleStorageView("form");
return settings;
}
async loadFromStorage() {
if (!this.storageService) return;
const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
if (!pubkey) return;
this.cloudflareSettings = null;
this.activeConnectionId = null;
this.storageConfigured = false;
if (!pubkey) {
this.toggleStorageView("form");
return;
}
try {
const connections = await this.storageService.listConnections(pubkey);
@@ -498,32 +519,56 @@ export class UploadModal {
const targetConn = defaultConn || connections.find(c => c.provider === PROVIDERS.R2 || c.provider === "cloudflare_r2");
if (targetConn) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
// Map generic S3 or R2 details to settings
// R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain
this.cloudflareSettings = {
accountId: details.accountId, // Might be undefined for generic S3
accessKeyId: details.accessKeyId,
secretAccessKey: details.secretAccessKey,
baseDomain: details.meta?.baseDomain || "",
// Add generic endpoint for internal use if needed
endpoint: details.endpoint,
region: details.region,
bucket: details.bucket
};
this.fillSettingsForm(this.cloudflareSettings);
this.storageConfigured = true;
this.toggleStorageView("summary");
const providerLabel = targetConn.provider === PROVIDERS.R2 || targetConn.provider === "cloudflare_r2"
? "R2"
: (targetConn.meta?.label || "S3");
this.updateStorageStatus(true, providerLabel);
// Populate Summary
const providerName = targetConn.meta?.provider === "cloudflare_r2" ? "Cloudflare R2" : (targetConn.meta?.label || "S3 Storage");
const bucketName = targetConn.meta?.bucket || "Unknown Bucket";
this.activeConnectionId = targetConn.id;
if (this.statusText.summaryProvider) this.statusText.summaryProvider.textContent = providerName;
if (this.statusText.summaryBucket) this.statusText.summaryBucket.textContent = bucketName;
if (this.isStorageUnlocked) {
const details = await this.storageService.getConnection(pubkey, targetConn.id);
if (details) {
// Map generic S3 or R2 details to settings
// R2 settings struct: accountId, accessKeyId, secretAccessKey, baseDomain
this.cloudflareSettings = {
accountId: details.accountId, // Might be undefined for generic S3
accessKeyId: details.accessKeyId,
secretAccessKey: details.secretAccessKey,
baseDomain: details.meta?.baseDomain || "",
// Add generic endpoint for internal use if needed
endpoint: details.endpoint,
region: details.region,
bucket: details.bucket
};
this.fillSettingsForm(this.cloudflareSettings);
this.updateStorageStatus(true, providerName);
this.activeConnectionId = targetConn.id;
}
} else {
// Locked state, can't get details yet
this.updateStorageStatus(false);
}
} else {
// No connections configured
this.toggleStorageView("form");
}
} catch (err) {
console.error("Failed to load connection", err);
this.toggleStorageView("form");
}
}
toggleStorageView(viewName) {
if (viewName === "summary") {
this.storageViews.summary.classList.remove("hidden");
this.storageViews.form.classList.add("hidden");
} else {
this.storageViews.summary.classList.add("hidden");
this.storageViews.form.classList.remove("hidden");
}
}
@@ -544,8 +589,10 @@ export class UploadModal {
}
try {
this.toggles.storageUnlock.textContent = "Unlocking...";
this.toggles.storageUnlock.disabled = true;
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlocking...";
this.toggles.storageUnlock.disabled = true;
}
await this.storageService.unlock(pubkey, { signer });
this.isStorageUnlocked = true;
this.updateLockUi();
@@ -554,8 +601,10 @@ export class UploadModal {
console.error("Unlock failed", err);
alert("Failed to unlock storage: " + err.message);
} finally {
this.toggles.storageUnlock.textContent = "Unlock";
this.toggles.storageUnlock.disabled = false;
if (this.toggles.storageUnlock) {
this.toggles.storageUnlock.textContent = "Unlock";
this.toggles.storageUnlock.disabled = false;
}
}
}
@@ -566,14 +615,17 @@ export class UploadModal {
this.statusText.storageLock.className = locked ? "text-xs text-critical" : "text-xs text-success";
}
if (this.toggles.storageUnlock) {
if (locked) {
// Show unlock button only if locked AND we have a configuration to unlock
if (locked && this.storageConfigured) {
this.toggles.storageUnlock.classList.remove("hidden");
} else {
this.toggles.storageUnlock.classList.add("hidden");
}
}
if (this.statusText.storage) {
if (locked) {
if (locked && this.storageConfigured) {
this.statusText.storage.classList.add("hidden"); // Summary view has its own status area
} else if (locked) {
this.statusText.storage.classList.add("hidden");
this.statusText.storage.textContent = "";
} else {
@@ -610,7 +662,7 @@ export class UploadModal {
const secret = this.inputs.r2Secret?.value?.trim();
if (!accountId || !keyId || !secret) {
alert("Please fill in Account ID, Access Key ID, and Secret Access Key.");
alert("Please fill in Account ID/Endpoint, Access Key ID, and Secret Access Key.");
return;
}
@@ -642,7 +694,7 @@ export class UploadModal {
const npub = pubkey ? this.safeEncodeNpub(pubkey) : null;
if (!settings.baseDomain) {
this.wizard.errorText.textContent = "Public Bucket URL is required.";
this.wizard.errorText.textContent = "Public URL is required.";
this.wizard.errorContainer.classList.remove("hidden");
return;
}
@@ -663,7 +715,7 @@ export class UploadModal {
// Dynamic Cloudflare Link
// https://dash.cloudflare.com/?to=/:account/r2/default/buckets/:bucket/settings
if (settings.accountId && npub) {
if (settings.accountId && npub && !settings.accountId.includes("://")) {
const bucketName = this.inputs.r2BucketName?.value || "";
const cfLink = `https://dash.cloudflare.com/?to=/${settings.accountId}/r2/default/buckets/${bucketName}/settings`;
this.wizard.guideLink.href = cfLink;
@@ -681,16 +733,31 @@ export class UploadModal {
if (pubkey) {
// Save to secure storage
const connectionId = this.activeConnectionId || `r2-${Date.now()}`;
await this.storageService.saveConnection(pubkey, connectionId, {
provider: PROVIDERS.R2,
accountId: settings.accountId,
// Heuristic for provider type
let provider = PROVIDERS.GENERIC;
let payload = {
accessKeyId: settings.accessKeyId,
secretAccessKey: settings.secretAccessKey,
};
// If account ID doesn't look like a URL, assume R2/S3-account-based
if (settings.accountId && !settings.accountId.includes("://")) {
provider = PROVIDERS.R2;
payload.accountId = settings.accountId;
} else {
payload.endpoint = settings.accountId;
}
await this.storageService.saveConnection(pubkey, connectionId, {
provider,
...payload,
}, {
provider: PROVIDERS.R2,
label: "Default R2",
provider,
label: provider === PROVIDERS.R2 ? "Default R2" : "Default S3",
baseDomain: settings.baseDomain,
defaultForUploads: true
defaultForUploads: true,
bucket: this.inputs.r2BucketName?.value || sanitizeBucketName(npub)
});
this.activeConnectionId = connectionId;
}
@@ -712,6 +779,8 @@ export class UploadModal {
this.toggles.storageSettings.setAttribute("aria-expanded", "false");
// Reset wizard to start for next edit
this.goToCredentialsStep();
// Reload state to potentially switch to summary view
this.loadFromStorage();
}, 1500);
} catch (err) {
userLogger.error("Verification crashed:", err);
+5
View File
@@ -48,6 +48,11 @@ export function initUploadModal({
safeEncodeNpub,
eventTarget: events,
container,
onRequestStorageSettings: () => {
if (app && app.profileController && typeof app.profileController.show === "function") {
app.profileController.show("storage");
}
},
});
const boundSubmitHandler =
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

+45
View File
@@ -0,0 +1,45 @@
from playwright.sync_api import sync_playwright
def run():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Load the HTML template file directly by fetching it and injecting it
# effectively unit-testing the template structure in a browser
page.goto("http://localhost:8080")
# Inject the modal HTML manually to verify the static changes
page.evaluate("""async () => {
const response = await fetch("/components/upload-modal.html");
const html = await response.text();
document.body.innerHTML = html;
document.getElementById("uploadModal").classList.remove("hidden");
document.getElementById("section-storage-settings").classList.remove("hidden");
}""")
# Wait for rendering
page.wait_for_selector("#uploadModal", state="visible")
# Check text
button_text = page.inner_text("#btn-storage-settings")
print(f"Button Text: {button_text}")
content = page.content()
if "Storage Configuration" in button_text:
print("Verified: Button text updated.")
if "Account ID / Endpoint" in content:
print("Verified: Account label updated.")
if "Public URL" in content:
print("Verified: Public URL label updated.")
page.screenshot(path="verification/upload_modal_static.png")
print("Screenshot saved to verification/upload_modal_static.png")
browser.close()
if __name__ == "__main__":
run()