You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
345 lines
9.4 KiB
345 lines
9.4 KiB
import { Controller } from '@hotwired/stimulus'; |
|
|
|
// Inline utility functions |
|
function calculateShares(splits) { |
|
if (splits.length === 0) { |
|
return []; |
|
} |
|
|
|
// Check if any weights are specified |
|
const hasWeights = splits.some(s => s.weight !== undefined && s.weight !== null && s.weight > 0); |
|
|
|
if (!hasWeights) { |
|
// Equal distribution |
|
const equalShare = 100 / splits.length; |
|
return splits.map(() => equalShare); |
|
} |
|
|
|
// Calculate total weight |
|
const totalWeight = splits.reduce((sum, s) => sum + (s.weight || 0), 0); |
|
|
|
if (totalWeight === 0) { |
|
return splits.map(() => 0); |
|
} |
|
|
|
// Calculate weighted shares |
|
return splits.map(s => { |
|
const weight = s.weight || 0; |
|
return (weight / totalWeight) * 100; |
|
}); |
|
} |
|
|
|
function isValidPubkey(pubkey) { |
|
if (!pubkey) return false; |
|
|
|
// Check if hex (64 chars) |
|
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
|
return true; |
|
} |
|
|
|
// Check if npub (basic check) |
|
if (pubkey.startsWith('npub1') && pubkey.length > 60) { |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
function isValidRelay(relay) { |
|
if (!relay) return true; // Empty is valid (optional) |
|
|
|
try { |
|
const url = new URL(relay); |
|
return url.protocol === 'wss:'; |
|
} catch { |
|
return false; |
|
} |
|
} |
|
|
|
export default class extends Controller { |
|
static targets = [ |
|
'zapSplitsContainer', |
|
'addZapButton', |
|
'distributeEquallyButton', |
|
'licenseSelect', |
|
'customLicenseInput', |
|
'protectedCheckbox', |
|
'protectedWarning', |
|
'expirationInput' |
|
]; |
|
|
|
connect() { |
|
console.log('Advanced metadata controller connected'); |
|
this.updateLicenseVisibility(); |
|
this.updateProtectedWarning(); |
|
this.updateZapShares(); |
|
} |
|
|
|
/** |
|
* Add a new zap split row |
|
*/ |
|
addZapSplit(event) { |
|
event.preventDefault(); |
|
|
|
const container = this.zapSplitsContainerTarget; |
|
const prototype = container.dataset.prototype; |
|
|
|
if (!prototype) { |
|
console.error('No prototype found for zap splits'); |
|
return; |
|
} |
|
|
|
// Get the current index |
|
const index = parseInt(container.dataset.index || '0', 10); |
|
|
|
// Replace __name__ with the index |
|
const newForm = prototype.replace(/__name__/g, index.toString()); |
|
|
|
// Create wrapper div |
|
const wrapper = document.createElement('div'); |
|
wrapper.classList.add('zap-split-item', 'mb-3', 'p-3', 'border', 'rounded'); |
|
wrapper.dataset.index = index.toString(); |
|
wrapper.innerHTML = newForm; |
|
|
|
// Add delete button |
|
const deleteBtn = document.createElement('button'); |
|
deleteBtn.type = 'button'; |
|
deleteBtn.classList.add('btn', 'btn-sm', 'btn-danger', 'mt-2'); |
|
deleteBtn.textContent = 'Remove'; |
|
deleteBtn.addEventListener('click', () => this.removeZapSplit(wrapper)); |
|
wrapper.appendChild(deleteBtn); |
|
|
|
// Add share percentage display |
|
const shareDisplay = document.createElement('div'); |
|
shareDisplay.classList.add('zap-share-display', 'mt-2', 'text-muted'); |
|
shareDisplay.innerHTML = '<small>Share: <span class="share-percent">0</span>%</small>'; |
|
wrapper.appendChild(shareDisplay); |
|
|
|
container.appendChild(wrapper); |
|
|
|
// Update index |
|
container.dataset.index = (index + 1).toString(); |
|
|
|
// Add event listeners for live validation and share calculation |
|
this.attachZapSplitListeners(wrapper); |
|
|
|
this.updateZapShares(); |
|
} |
|
|
|
/** |
|
* Remove a zap split row |
|
*/ |
|
removeZapSplit(wrapper) { |
|
wrapper.remove(); |
|
this.updateZapShares(); |
|
} |
|
|
|
/** |
|
* Distribute weights equally among all splits |
|
*/ |
|
distributeEqually(event) { |
|
event.preventDefault(); |
|
|
|
const splits = this.zapSplitsContainerTarget.querySelectorAll('.zap-split-item'); |
|
|
|
splits.forEach((split) => { |
|
const weightInput = split.querySelector('.zap-weight'); |
|
if (weightInput) { |
|
weightInput.value = '1'; |
|
} |
|
}); |
|
|
|
this.updateZapShares(); |
|
} |
|
|
|
/** |
|
* Update share percentages for all zap splits |
|
*/ |
|
updateZapShares() { |
|
const splits = this.zapSplitsContainerTarget.querySelectorAll('.zap-split-item'); |
|
const zapSplits = []; |
|
|
|
splits.forEach((split) => { |
|
const weightInput = split.querySelector('.zap-weight'); |
|
const weight = weightInput?.value ? parseInt(weightInput.value, 10) : undefined; |
|
|
|
zapSplits.push({ |
|
recipient: '', |
|
weight: weight |
|
}); |
|
}); |
|
|
|
const shares = calculateShares(zapSplits); |
|
|
|
splits.forEach((split, index) => { |
|
const shareDisplay = split.querySelector('.share-percent'); |
|
if (shareDisplay) { |
|
shareDisplay.textContent = shares[index].toFixed(1); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Attach event listeners to a zap split row |
|
*/ |
|
attachZapSplitListeners(wrapper) { |
|
const recipientInput = wrapper.querySelector('.zap-recipient'); |
|
const relayInput = wrapper.querySelector('.zap-relay'); |
|
const weightInput = wrapper.querySelector('.zap-weight'); |
|
|
|
if (recipientInput) { |
|
recipientInput.addEventListener('blur', (e) => this.validateRecipient(e.target)); |
|
} |
|
|
|
if (relayInput) { |
|
relayInput.addEventListener('blur', (e) => this.validateRelay(e.target)); |
|
} |
|
|
|
if (weightInput) { |
|
weightInput.addEventListener('input', () => this.updateZapShares()); |
|
} |
|
} |
|
|
|
/** |
|
* Validate recipient pubkey (npub or hex) |
|
*/ |
|
validateRecipient(input) { |
|
const value = input.value.trim(); |
|
|
|
if (!value) { |
|
this.setInputValid(input, true); |
|
return; |
|
} |
|
|
|
const isValid = isValidPubkey(value); |
|
this.setInputValid(input, isValid, isValid ? '' : 'Invalid pubkey. Must be npub or 64-character hex.'); |
|
} |
|
|
|
/** |
|
* Validate relay URL |
|
*/ |
|
validateRelay(input) { |
|
const value = input.value.trim(); |
|
|
|
if (!value) { |
|
this.setInputValid(input, true); |
|
return; |
|
} |
|
|
|
const isValid = isValidRelay(value); |
|
this.setInputValid(input, isValid, isValid ? '' : 'Invalid relay URL. Must start with wss://'); |
|
} |
|
|
|
/** |
|
* Set input validation state |
|
*/ |
|
setInputValid(input, isValid, message = '') { |
|
if (isValid) { |
|
input.classList.remove('is-invalid'); |
|
input.classList.add('is-valid'); |
|
|
|
// Remove error message |
|
const feedback = input.parentElement?.querySelector('.invalid-feedback'); |
|
if (feedback) { |
|
feedback.remove(); |
|
} |
|
} else { |
|
input.classList.remove('is-valid'); |
|
input.classList.add('is-invalid'); |
|
|
|
// Add/update error message |
|
let feedback = input.parentElement?.querySelector('.invalid-feedback'); |
|
if (!feedback) { |
|
feedback = document.createElement('div'); |
|
feedback.classList.add('invalid-feedback'); |
|
input.parentElement?.appendChild(feedback); |
|
} |
|
feedback.textContent = message; |
|
} |
|
} |
|
|
|
/** |
|
* Update license field visibility based on selection |
|
*/ |
|
updateLicenseVisibility() { |
|
if (!this.hasLicenseSelectTarget || !this.hasCustomLicenseInputTarget) { |
|
return; |
|
} |
|
|
|
const isCustom = this.licenseSelectTarget.value === 'custom'; |
|
const customWrapper = this.customLicenseInputTarget.closest('.mb-3'); |
|
|
|
if (customWrapper) { |
|
if (isCustom) { |
|
customWrapper.style.display = 'block'; |
|
this.customLicenseInputTarget.required = true; |
|
} else { |
|
customWrapper.style.display = 'none'; |
|
this.customLicenseInputTarget.required = false; |
|
this.customLicenseInputTarget.value = ''; |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* Show/hide protected event warning |
|
*/ |
|
updateProtectedWarning() { |
|
if (!this.hasProtectedCheckboxTarget || !this.hasProtectedWarningTarget) { |
|
return; |
|
} |
|
|
|
if (this.protectedCheckboxTarget.checked) { |
|
this.protectedWarningTarget.style.display = 'block'; |
|
} else { |
|
this.protectedWarningTarget.style.display = 'none'; |
|
} |
|
} |
|
|
|
/** |
|
* Validate expiration is in the future |
|
*/ |
|
validateExpiration() { |
|
if (!this.hasExpirationInputTarget) { |
|
return; |
|
} |
|
|
|
const value = this.expirationInputTarget.value; |
|
if (!value) { |
|
this.setInputValid(this.expirationInputTarget, true); |
|
return; |
|
} |
|
|
|
const expirationDate = new Date(value); |
|
const now = new Date(); |
|
|
|
const isValid = expirationDate > now; |
|
this.setInputValid( |
|
this.expirationInputTarget, |
|
isValid, |
|
isValid ? '' : 'Expiration date must be in the future' |
|
); |
|
} |
|
|
|
/** |
|
* Event handler for license select change |
|
*/ |
|
licenseChanged() { |
|
this.updateLicenseVisibility(); |
|
} |
|
|
|
/** |
|
* Event handler for protected checkbox change |
|
*/ |
|
protectedChanged() { |
|
this.updateProtectedWarning(); |
|
} |
|
|
|
/** |
|
* Event handler for expiration input change |
|
*/ |
|
expirationChanged() { |
|
this.validateExpiration(); |
|
} |
|
} |
|
|
|
|