7 changed files with 202 additions and 109 deletions
@ -1,118 +1,157 @@ |
|||||||
import { Controller } from '@hotwired/stimulus'; |
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
export default class extends Controller { |
export default class extends Controller { |
||||||
static targets = ["dialog", "dropArea", "fileInput", "progress", "error", "provider"]; |
static targets = ["dialog", "dropArea", "fileInput", "progress", "error", "provider"]; |
||||||
|
|
||||||
// Unicode-safe base64 encoder
|
// Unicode-safe base64 encoder
|
||||||
base64Encode(str) { |
base64Encode(str) { |
||||||
try { |
try { |
||||||
return btoa(unescape(encodeURIComponent(str))); |
return btoa(unescape(encodeURIComponent(str))); |
||||||
} catch (_) { |
} catch (_) { |
||||||
return btoa(str); |
return btoa(str); |
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
openDialog() { |
openDialog() { |
||||||
this.dialogTarget.classList.add('active'); |
this.dialogTarget.classList.add('active'); |
||||||
this.errorTarget.textContent = ''; |
this.clearError(); |
||||||
this.progressTarget.style.display = 'none'; |
this.hideProgress(); |
||||||
} |
} |
||||||
|
|
||||||
closeDialog() { |
closeDialog() { |
||||||
this.dialogTarget.classList.remove('active'); |
this.dialogTarget.classList.remove('active'); |
||||||
this.errorTarget.textContent = ''; |
this.clearError(); |
||||||
this.progressTarget.style.display = 'none'; |
this.hideProgress(); |
||||||
} |
} |
||||||
|
|
||||||
connect() { |
connect() { |
||||||
this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click()); |
this.dropAreaTarget.addEventListener('click', () => this.fileInputTarget.click()); |
||||||
this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0])); |
this.fileInputTarget.addEventListener('change', (e) => this.handleFile(e.target.files[0])); |
||||||
this.dropAreaTarget.addEventListener('dragover', (e) => { |
this.dropAreaTarget.addEventListener('dragover', (e) => { |
||||||
e.preventDefault(); |
e.preventDefault(); |
||||||
this.dropAreaTarget.classList.add('dragover'); |
this.dropAreaTarget.classList.add('dragover'); |
||||||
}); |
}); |
||||||
this.dropAreaTarget.addEventListener('dragleave', (e) => { |
this.dropAreaTarget.addEventListener('dragleave', (e) => { |
||||||
e.preventDefault(); |
e.preventDefault(); |
||||||
this.dropAreaTarget.classList.remove('dragover'); |
this.dropAreaTarget.classList.remove('dragover'); |
||||||
}); |
}); |
||||||
this.dropAreaTarget.addEventListener('drop', (e) => { |
this.dropAreaTarget.addEventListener('drop', (e) => { |
||||||
e.preventDefault(); |
e.preventDefault(); |
||||||
this.dropAreaTarget.classList.remove('dragover'); |
this.dropAreaTarget.classList.remove('dragover'); |
||||||
if (e.dataTransfer.files.length > 0) { |
if (e.dataTransfer.files.length > 0) { |
||||||
this.handleFile(e.dataTransfer.files[0]); |
this.handleFile(e.dataTransfer.files[0]); |
||||||
} |
} |
||||||
}); |
}); |
||||||
} |
// Ensure initial visibility states
|
||||||
|
this.hideProgress(); |
||||||
|
this.clearError(); |
||||||
|
} |
||||||
|
|
||||||
|
async handleFile(file) { |
||||||
|
if (!file) return; |
||||||
|
this.clearError(); |
||||||
|
this.showProgress('Preparing upload...'); |
||||||
|
try { |
||||||
|
// NIP98: get signed HTTP Auth event from window.nostr
|
||||||
|
if (!window.nostr || !window.nostr.signEvent) { |
||||||
|
this.showError('Nostr extension not found.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
// Determine provider
|
||||||
|
const provider = this.providerTarget.value; |
||||||
|
|
||||||
async handleFile(file) { |
// Map provider -> upstream endpoint used for signing the NIP-98 event
|
||||||
if (!file) return; |
const upstreamMap = { |
||||||
this.errorTarget.textContent = ''; |
nostrbuild: 'https://nostr.build/nip96/upload', |
||||||
this.progressTarget.style.display = ''; |
nostrcheck: 'https://nostrcheck.me/api/v2/media', |
||||||
this.progressTarget.textContent = 'Preparing upload...'; |
sovbit: 'https://files.sovbit.host/api/v2/media', |
||||||
try { |
}; |
||||||
// NIP98: get signed HTTP Auth event from window.nostr
|
const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck']; |
||||||
if (!window.nostr || !window.nostr.signEvent) { |
|
||||||
this.errorTarget.textContent = 'Nostr extension not found.'; |
|
||||||
return; |
|
||||||
} |
|
||||||
// Determine provider
|
|
||||||
const provider = this.providerTarget.value; |
|
||||||
|
|
||||||
// Map provider -> upstream endpoint used for signing the NIP-98 event
|
// Backend proxy endpoint to avoid third-party CORS
|
||||||
const upstreamMap = { |
const proxyEndpoint = `/api/image-upload/${provider}`; |
||||||
nostrbuild: 'https://nostr.build/nip96/upload', |
|
||||||
nostrcheck: 'https://nostrcheck.me/api/v2/media', |
|
||||||
sovbit: 'https://files.sovbit.host/api/v2/media', |
|
||||||
}; |
|
||||||
const upstreamEndpoint = upstreamMap[provider] || upstreamMap['nostrcheck']; |
|
||||||
|
|
||||||
// Backend proxy endpoint to avoid third-party CORS
|
const event = { |
||||||
const proxyEndpoint = `/api/image-upload/${provider}`; |
kind: 27235, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags: [ |
||||||
|
["u", upstreamEndpoint], |
||||||
|
["method", "POST"] |
||||||
|
], |
||||||
|
content: "" |
||||||
|
}; |
||||||
|
const signed = await window.nostr.signEvent(event); |
||||||
|
const signedJson = JSON.stringify(signed); |
||||||
|
const authHeader = 'Nostr ' + this.base64Encode(signedJson); |
||||||
|
// Prepare form data
|
||||||
|
const formData = new FormData(); |
||||||
|
formData.append('uploadtype', 'media'); |
||||||
|
formData.append('file', file); |
||||||
|
this.showProgress('Uploading...'); |
||||||
|
// Upload to backend proxy
|
||||||
|
const response = await fetch(proxyEndpoint, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Authorization': authHeader |
||||||
|
}, |
||||||
|
body: formData |
||||||
|
}); |
||||||
|
const result = await response.json().catch(() => ({})); |
||||||
|
if (!response.ok || result.status !== 'success' || !result.url) { |
||||||
|
this.showError(result.message || `Upload failed (HTTP ${response.status})`); |
||||||
|
return; |
||||||
|
} |
||||||
|
this.setImageField(result.url); |
||||||
|
this.showProgress('Upload successful!'); |
||||||
|
// clear file input so subsequent identical uploads work
|
||||||
|
if (this.hasFileInputTarget) this.fileInputTarget.value = ''; |
||||||
|
setTimeout(() => this.closeDialog(), 1000); |
||||||
|
} catch (e) { |
||||||
|
this.showError('Upload error: ' + (e.message || e)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setImageField(url) { |
||||||
|
// Find the image input in the form and set its value
|
||||||
|
const imageInput = document.querySelector('input[name$="[image]"]'); |
||||||
|
if (imageInput) { |
||||||
|
imageInput.value = url; |
||||||
|
imageInput.dispatchEvent(new Event('input', { bubbles: true })); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Helpers to manage UI visibility and content
|
||||||
|
showProgress(text = '') { |
||||||
|
if (this.hasProgressTarget) { |
||||||
|
this.progressTarget.style.display = 'block'; |
||||||
|
this.progressTarget.textContent = text; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
hideProgress() { |
||||||
|
if (this.hasProgressTarget) { |
||||||
|
this.progressTarget.style.display = 'none'; |
||||||
|
this.progressTarget.textContent = ''; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
const event = { |
showError(message) { |
||||||
kind: 27235, |
if (this.hasErrorTarget) { |
||||||
created_at: Math.floor(Date.now() / 1000), |
this.errorTarget.textContent = message; |
||||||
tags: [ |
this.errorTarget.style.display = 'block'; |
||||||
["u", upstreamEndpoint], |
// make assistive tech aware
|
||||||
["method", "POST"] |
this.errorTarget.setAttribute('role', 'alert'); |
||||||
], |
this.hideProgress(); |
||||||
content: "" |
// clear file input so user can re-select the same file
|
||||||
}; |
if (this.hasFileInputTarget) this.fileInputTarget.value = ''; |
||||||
const signed = await window.nostr.signEvent(event); |
|
||||||
const signedJson = JSON.stringify(signed); |
|
||||||
const authHeader = 'Nostr ' + this.base64Encode(signedJson); |
|
||||||
// Prepare form data
|
|
||||||
const formData = new FormData(); |
|
||||||
formData.append('uploadtype', 'media'); |
|
||||||
formData.append('file', file); |
|
||||||
this.progressTarget.textContent = 'Uploading...'; |
|
||||||
// Upload to backend proxy
|
|
||||||
const response = await fetch(proxyEndpoint, { |
|
||||||
method: 'POST', |
|
||||||
headers: { |
|
||||||
'Authorization': authHeader |
|
||||||
}, |
|
||||||
body: formData |
|
||||||
}); |
|
||||||
const result = await response.json().catch(() => ({})); |
|
||||||
if (!response.ok || result.status !== 'success' || !result.url) { |
|
||||||
this.errorTarget.textContent = result.message || `Upload failed (HTTP ${response.status})`; |
|
||||||
return; |
|
||||||
} |
|
||||||
this.setImageField(result.url); |
|
||||||
this.progressTarget.textContent = 'Upload successful!'; |
|
||||||
setTimeout(() => this.closeDialog(), 1000); |
|
||||||
} catch (e) { |
|
||||||
this.errorTarget.textContent = 'Upload error: ' + (e.message || e); |
|
||||||
} |
|
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
setImageField(url) { |
clearError() { |
||||||
// Find the image input in the form and set its value
|
if (this.hasErrorTarget) { |
||||||
const imageInput = document.querySelector('input[name$="[image]"]'); |
this.errorTarget.textContent = ''; |
||||||
if (imageInput) { |
this.errorTarget.style.display = 'none'; |
||||||
imageInput.value = url; |
this.errorTarget.removeAttribute('role'); |
||||||
imageInput.dispatchEvent(new Event('input', { bubbles: true })); |
|
||||||
} |
|
||||||
} |
} |
||||||
|
} |
||||||
} |
} |
||||||
|
|||||||
Loading…
Reference in new issue