22 changed files with 2162 additions and 243 deletions
@ -0,0 +1,345 @@
@@ -0,0 +1,345 @@
|
||||
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(); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,285 @@
@@ -0,0 +1,285 @@
|
||||
/** |
||||
* Nostr utilities for handling pubkeys, relays, and tag building |
||||
*/ |
||||
|
||||
// Bech32 character set
|
||||
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; |
||||
|
||||
/** |
||||
* Decode bech32 string to hex |
||||
* Simplified implementation for npub decoding |
||||
*/ |
||||
function bech32Decode(str: string): { prefix: string; data: Uint8Array } | null { |
||||
const lowered = str.toLowerCase(); |
||||
|
||||
// Find the separator
|
||||
let sepIndex = lowered.lastIndexOf('1'); |
||||
if (sepIndex < 1) return null; |
||||
|
||||
const prefix = lowered.substring(0, sepIndex); |
||||
const dataStr = lowered.substring(sepIndex + 1); |
||||
|
||||
if (dataStr.length < 6) return null; |
||||
|
||||
// Decode the data
|
||||
const values: number[] = []; |
||||
for (let i = 0; i < dataStr.length; i++) { |
||||
const c = dataStr[i]; |
||||
const v = BECH32_CHARSET.indexOf(c); |
||||
if (v === -1) return null; |
||||
values.push(v); |
||||
} |
||||
|
||||
// Remove checksum (last 6 chars)
|
||||
const data = values.slice(0, -6); |
||||
|
||||
// Convert from 5-bit to 8-bit
|
||||
const bytes: number[] = []; |
||||
let acc = 0; |
||||
let bits = 0; |
||||
|
||||
for (const value of data) { |
||||
acc = (acc << 5) | value; |
||||
bits += 5; |
||||
|
||||
if (bits >= 8) { |
||||
bits -= 8; |
||||
bytes.push((acc >> bits) & 0xff); |
||||
} |
||||
} |
||||
|
||||
return { |
||||
prefix, |
||||
data: new Uint8Array(bytes) |
||||
}; |
||||
} |
||||
|
||||
/** |
||||
* Convert Uint8Array to hex string |
||||
*/ |
||||
function bytesToHex(bytes: Uint8Array): string { |
||||
return Array.from(bytes) |
||||
.map(b => b.toString(16).padStart(2, '0')) |
||||
.join(''); |
||||
} |
||||
|
||||
/** |
||||
* Convert npub to hex pubkey |
||||
*/ |
||||
export function npubToHex(npub: string): string | null { |
||||
if (!npub.startsWith('npub1')) { |
||||
// Check if it's already hex
|
||||
if (/^[0-9a-f]{64}$/i.test(npub)) { |
||||
return npub.toLowerCase(); |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
const decoded = bech32Decode(npub); |
||||
if (!decoded || decoded.prefix !== 'npub') { |
||||
return null; |
||||
} |
||||
|
||||
return bytesToHex(decoded.data); |
||||
} catch (e) { |
||||
console.error('Error decoding npub:', e); |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Validate if a string is a valid npub or hex pubkey |
||||
*/ |
||||
export function isValidPubkey(pubkey: string): boolean { |
||||
if (!pubkey) return false; |
||||
|
||||
// Check if hex (64 chars)
|
||||
if (/^[0-9a-f]{64}$/i.test(pubkey)) { |
||||
return true; |
||||
} |
||||
|
||||
// Check if npub
|
||||
if (pubkey.startsWith('npub1')) { |
||||
return npubToHex(pubkey) !== null; |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
/** |
||||
* Validate relay URL |
||||
*/ |
||||
export function isValidRelay(relay: string): boolean { |
||||
if (!relay) return true; // Empty is valid (optional)
|
||||
|
||||
try { |
||||
const url = new URL(relay); |
||||
return url.protocol === 'wss:'; |
||||
} catch { |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* ZapSplit interface |
||||
*/ |
||||
export interface ZapSplit { |
||||
recipient: string; |
||||
relay?: string; |
||||
weight?: number; |
||||
sharePercent?: number; |
||||
} |
||||
|
||||
/** |
||||
* Calculate share percentages for zap splits |
||||
*/ |
||||
export function calculateShares(splits: ZapSplit[]): number[] { |
||||
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; |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Build a zap tag for Nostr event |
||||
*/ |
||||
export function buildZapTag(split: ZapSplit): (string | number)[] { |
||||
const hexPubkey = npubToHex(split.recipient); |
||||
if (!hexPubkey) { |
||||
throw new Error(`Invalid recipient pubkey: ${split.recipient}`); |
||||
} |
||||
|
||||
const tag: (string | number)[] = ['zap', hexPubkey]; |
||||
|
||||
// Add relay (even if empty, to maintain position)
|
||||
tag.push(split.relay || ''); |
||||
|
||||
// Add weight if specified
|
||||
if (split.weight !== undefined && split.weight !== null) { |
||||
tag.push(split.weight); |
||||
} |
||||
|
||||
return tag; |
||||
} |
||||
|
||||
/** |
||||
* Advanced metadata interface |
||||
*/ |
||||
export interface AdvancedMetadata { |
||||
doNotRepublish: boolean; |
||||
license: string; |
||||
customLicense?: string; |
||||
zapSplits: ZapSplit[]; |
||||
contentWarning?: string; |
||||
expirationTimestamp?: number; |
||||
isProtected: boolean; |
||||
} |
||||
|
||||
/** |
||||
* Build advanced metadata tags for Nostr event |
||||
*/ |
||||
export function buildAdvancedTags(metadata: AdvancedMetadata): any[][] { |
||||
const tags: any[][] = []; |
||||
|
||||
// Policy: Do not republish
|
||||
if (metadata.doNotRepublish) { |
||||
tags.push(['L', 'rights.decent.newsroom']); |
||||
tags.push(['l', 'no-republish', 'rights.decent.newsroom']); |
||||
} |
||||
|
||||
// License
|
||||
const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license; |
||||
if (license && license !== 'All rights reserved') { |
||||
tags.push(['L', 'spdx.org/licenses']); |
||||
tags.push(['l', license, 'spdx.org/licenses']); |
||||
} else if (license === 'All rights reserved') { |
||||
tags.push(['L', 'rights.decent.newsroom']); |
||||
tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']); |
||||
} |
||||
|
||||
// Zap splits
|
||||
for (const split of metadata.zapSplits) { |
||||
try { |
||||
tags.push(buildZapTag(split)); |
||||
} catch (e) { |
||||
console.error('Error building zap tag:', e); |
||||
} |
||||
} |
||||
|
||||
// Content warning
|
||||
if (metadata.contentWarning) { |
||||
tags.push(['content-warning', metadata.contentWarning]); |
||||
} |
||||
|
||||
// Expiration
|
||||
if (metadata.expirationTimestamp) { |
||||
tags.push(['expiration', metadata.expirationTimestamp.toString()]); |
||||
} |
||||
|
||||
// Protected event
|
||||
if (metadata.isProtected) { |
||||
tags.push(['-']); |
||||
} |
||||
|
||||
return tags; |
||||
} |
||||
|
||||
/** |
||||
* Validate advanced metadata before publishing |
||||
*/ |
||||
export function validateAdvancedMetadata(metadata: AdvancedMetadata): { valid: boolean; errors: string[] } { |
||||
const errors: string[] = []; |
||||
|
||||
// Validate zap splits
|
||||
for (let i = 0; i < metadata.zapSplits.length; i++) { |
||||
const split = metadata.zapSplits[i]; |
||||
|
||||
if (!isValidPubkey(split.recipient)) { |
||||
errors.push(`Zap split ${i + 1}: Invalid recipient pubkey`); |
||||
} |
||||
|
||||
if (split.relay && !isValidRelay(split.relay)) { |
||||
errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`); |
||||
} |
||||
|
||||
if (split.weight !== undefined && split.weight !== null && split.weight < 0) { |
||||
errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`); |
||||
} |
||||
} |
||||
|
||||
// Validate expiration is in the future
|
||||
if (metadata.expirationTimestamp) { |
||||
const now = Math.floor(Date.now() / 1000); |
||||
if (metadata.expirationTimestamp <= now) { |
||||
errors.push('Expiration date must be in the future'); |
||||
} |
||||
} |
||||
|
||||
return { |
||||
valid: errors.length === 0, |
||||
errors |
||||
}; |
||||
} |
||||
|
||||
@ -1,273 +1,418 @@
@@ -1,273 +1,418 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
// Inline utility functions (simplified versions)
|
||||
function buildAdvancedTags(metadata) { |
||||
const tags = []; |
||||
|
||||
// Policy: Do not republish
|
||||
if (metadata.doNotRepublish) { |
||||
tags.push(['L', 'rights.decent.newsroom']); |
||||
tags.push(['l', 'no-republish', 'rights.decent.newsroom']); |
||||
} |
||||
|
||||
// License
|
||||
const license = metadata.license === 'custom' ? metadata.customLicense : metadata.license; |
||||
if (license && license !== 'All rights reserved') { |
||||
tags.push(['L', 'spdx.org/licenses']); |
||||
tags.push(['l', license, 'spdx.org/licenses']); |
||||
} else if (license === 'All rights reserved') { |
||||
tags.push(['L', 'rights.decent.newsroom']); |
||||
tags.push(['l', 'all-rights-reserved', 'rights.decent.newsroom']); |
||||
} |
||||
|
||||
// Zap splits
|
||||
for (const split of metadata.zapSplits) { |
||||
const zapTag = ['zap', split.recipient, split.relay || '']; |
||||
if (split.weight !== undefined && split.weight !== null) { |
||||
zapTag.push(split.weight.toString()); |
||||
} |
||||
tags.push(zapTag); |
||||
} |
||||
|
||||
// Content warning
|
||||
if (metadata.contentWarning) { |
||||
tags.push(['content-warning', metadata.contentWarning]); |
||||
} |
||||
|
||||
// Expiration
|
||||
if (metadata.expirationTimestamp) { |
||||
tags.push(['expiration', metadata.expirationTimestamp.toString()]); |
||||
} |
||||
|
||||
// Protected event
|
||||
if (metadata.isProtected) { |
||||
tags.push(['-']); |
||||
} |
||||
|
||||
return tags; |
||||
} |
||||
|
||||
function validateAdvancedMetadata(metadata) { |
||||
const errors = []; |
||||
|
||||
// Basic validation
|
||||
for (let i = 0; i < metadata.zapSplits.length; i++) { |
||||
const split = metadata.zapSplits[i]; |
||||
|
||||
if (!split.recipient) { |
||||
errors.push(`Zap split ${i + 1}: Recipient is required`); |
||||
} |
||||
|
||||
if (split.relay && !split.relay.startsWith('wss://')) { |
||||
errors.push(`Zap split ${i + 1}: Invalid relay URL (must start with wss://)`); |
||||
} |
||||
|
||||
if (split.weight !== undefined && split.weight !== null && split.weight < 0) { |
||||
errors.push(`Zap split ${i + 1}: Weight must be 0 or greater`); |
||||
} |
||||
} |
||||
|
||||
// Validate expiration is in the future
|
||||
if (metadata.expirationTimestamp) { |
||||
const now = Math.floor(Date.now() / 1000); |
||||
if (metadata.expirationTimestamp <= now) { |
||||
errors.push('Expiration date must be in the future'); |
||||
} |
||||
} |
||||
|
||||
return { |
||||
valid: errors.length === 0, |
||||
errors |
||||
}; |
||||
} |
||||
|
||||
export default class extends Controller { |
||||
static targets = ['form', 'publishButton', 'status']; |
||||
static values = { |
||||
publishUrl: String, |
||||
csrfToken: String |
||||
}; |
||||
static targets = ['form', 'publishButton', 'status']; |
||||
static values = { |
||||
publishUrl: String, |
||||
csrfToken: String |
||||
}; |
||||
|
||||
connect() { |
||||
console.log('Nostr publish controller connected'); |
||||
try { |
||||
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); |
||||
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); |
||||
console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); |
||||
} catch (_) {} |
||||
} |
||||
|
||||
async publish(event) { |
||||
event.preventDefault(); |
||||
|
||||
if (!this.publishUrlValue) { |
||||
this.showError('Publish URL is not configured'); |
||||
return; |
||||
} |
||||
if (!this.csrfTokenValue) { |
||||
this.showError('Missing CSRF token'); |
||||
return; |
||||
} |
||||
|
||||
connect() { |
||||
console.log('Nostr publish controller connected'); |
||||
try { |
||||
console.debug('[nostr-publish] publishUrl:', this.publishUrlValue || '(none)'); |
||||
console.debug('[nostr-publish] has csrfToken:', Boolean(this.csrfTokenValue)); |
||||
console.debug('[nostr-publish] existing slug:', (this.element.dataset.slug || '(none)')); |
||||
} catch (_) {} |
||||
if (!window.nostr) { |
||||
this.showError('Nostr extension not found'); |
||||
return; |
||||
} |
||||
|
||||
async publish(event) { |
||||
event.preventDefault(); |
||||
this.publishButtonTarget.disabled = true; |
||||
this.showStatus('Preparing article for signing...'); |
||||
|
||||
try { |
||||
// Collect form data
|
||||
const formData = this.collectFormData(); |
||||
|
||||
if (!this.publishUrlValue) { |
||||
this.showError('Publish URL is not configured'); |
||||
return; |
||||
} |
||||
if (!this.csrfTokenValue) { |
||||
this.showError('Missing CSRF token'); |
||||
return; |
||||
} |
||||
// Validate required fields
|
||||
if (!formData.title || !formData.content) { |
||||
throw new Error('Title and content are required'); |
||||
} |
||||
|
||||
if (!window.nostr) { |
||||
this.showError('Nostr extension not found'); |
||||
return; |
||||
} |
||||
// Create Nostr event
|
||||
const nostrEvent = await this.createNostrEvent(formData); |
||||
|
||||
this.publishButtonTarget.disabled = true; |
||||
this.showStatus('Preparing article for signing...'); |
||||
this.showStatus('Requesting signature from Nostr extension...'); |
||||
|
||||
try { |
||||
// Collect form data
|
||||
const formData = this.collectFormData(); |
||||
// Sign the event with Nostr extension
|
||||
const signedEvent = await window.nostr.signEvent(nostrEvent); |
||||
|
||||
// Validate required fields
|
||||
if (!formData.title || !formData.content) { |
||||
throw new Error('Title and content are required'); |
||||
} |
||||
this.showStatus('Publishing article...'); |
||||
|
||||
// Create Nostr event
|
||||
const nostrEvent = await this.createNostrEvent(formData); |
||||
// Send to backend
|
||||
await this.sendToBackend(signedEvent, formData); |
||||
|
||||
this.showStatus('Requesting signature from Nostr extension...'); |
||||
this.showSuccess('Article published successfully!'); |
||||
|
||||
// Sign the event with Nostr extension
|
||||
const signedEvent = await window.nostr.signEvent(nostrEvent); |
||||
// Optionally redirect after successful publish
|
||||
setTimeout(() => { |
||||
window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; |
||||
}, 2000); |
||||
|
||||
} catch (error) { |
||||
console.error('Publishing error:', error); |
||||
this.showError(`Publishing failed: ${error.message}`); |
||||
} finally { |
||||
this.publishButtonTarget.disabled = false; |
||||
} |
||||
} |
||||
|
||||
collectFormData() { |
||||
// Find the actual form element within our target
|
||||
const form = this.formTarget.querySelector('form'); |
||||
if (!form) throw new Error('Form element not found'); |
||||
|
||||
const fd = new FormData(form); |
||||
|
||||
// Prefer the Markdown field populated by the Quill controller
|
||||
const md = fd.get('editor[content_md]'); |
||||
let html = fd.get('editor[content]') || fd.get('content') || ''; |
||||
|
||||
// Final content: use MD if present, otherwise convert HTML -> MD
|
||||
const content = (typeof md === 'string' && md.length > 0) |
||||
? md |
||||
: this.htmlToMarkdown(String(html)); |
||||
|
||||
const title = fd.get('editor[title]') || ''; |
||||
const summary = fd.get('editor[summary]') || ''; |
||||
const image = fd.get('editor[image]') || ''; |
||||
const topicsString = fd.get('editor[topics]') || ''; |
||||
const isDraft = fd.get('editor[isDraft]') === '1'; |
||||
const addClientTag = fd.get('editor[clientTag]') === '1'; |
||||
|
||||
// Collect advanced metadata
|
||||
const advancedMetadata = this.collectAdvancedMetadata(fd); |
||||
|
||||
// Parse topics
|
||||
const topics = String(topicsString).split(',') |
||||
.map(s => s.trim()) |
||||
.filter(Boolean) |
||||
.map(t => t.startsWith('#') ? t : `#${t}`); |
||||
|
||||
// Reuse existing slug if provided on the container (editing), else generate from title
|
||||
const existingSlug = (this.element.dataset.slug || '').trim(); |
||||
const slug = existingSlug || this.generateSlug(String(title)); |
||||
|
||||
return { |
||||
title: String(title), |
||||
summary: String(summary), |
||||
content, |
||||
image: String(image), |
||||
topics, |
||||
slug, |
||||
isDraft, |
||||
addClientTag, |
||||
advancedMetadata, |
||||
}; |
||||
} |
||||
|
||||
collectAdvancedMetadata(fd) { |
||||
const metadata = { |
||||
doNotRepublish: fd.get('editor[advancedMetadata][doNotRepublish]') === '1', |
||||
license: fd.get('editor[advancedMetadata][license]') || '', |
||||
customLicense: fd.get('editor[advancedMetadata][customLicense]') || '', |
||||
contentWarning: fd.get('editor[advancedMetadata][contentWarning]') || '', |
||||
isProtected: fd.get('editor[advancedMetadata][isProtected]') === '1', |
||||
zapSplits: [], |
||||
}; |
||||
|
||||
this.showStatus('Publishing article...'); |
||||
// Parse expiration timestamp
|
||||
const expirationDate = fd.get('editor[advancedMetadata][expirationTimestamp]'); |
||||
if (expirationDate) { |
||||
try { |
||||
metadata.expirationTimestamp = Math.floor(new Date(expirationDate).getTime() / 1000); |
||||
} catch (e) { |
||||
console.warn('Invalid expiration date:', e); |
||||
} |
||||
} |
||||
|
||||
// Send to backend
|
||||
await this.sendToBackend(signedEvent, formData); |
||||
// Collect zap splits
|
||||
let index = 0; |
||||
while (true) { |
||||
const recipient = fd.get(`editor[advancedMetadata][zapSplits][${index}][recipient]`); |
||||
if (!recipient) break; |
||||
|
||||
this.showSuccess('Article published successfully!'); |
||||
const relay = fd.get(`editor[advancedMetadata][zapSplits][${index}][relay]`) || ''; |
||||
const weightStr = fd.get(`editor[advancedMetadata][zapSplits][${index}][weight]`); |
||||
const weight = weightStr ? parseInt(weightStr, 10) : undefined; |
||||
|
||||
// Optionally redirect after successful publish
|
||||
setTimeout(() => { |
||||
window.location.href = `/article/d/${encodeURIComponent(formData.slug)}`; |
||||
}, 2000); |
||||
metadata.zapSplits.push({ |
||||
recipient: String(recipient), |
||||
relay: relay ? String(relay) : undefined, |
||||
weight: weight !== undefined && !isNaN(weight) ? weight : undefined, |
||||
}); |
||||
|
||||
} catch (error) { |
||||
console.error('Publishing error:', error); |
||||
this.showError(`Publishing failed: ${error.message}`); |
||||
} finally { |
||||
this.publishButtonTarget.disabled = false; |
||||
} |
||||
index++; |
||||
} |
||||
|
||||
collectFormData() { |
||||
// Find the actual form element within our target
|
||||
const form = this.formTarget.querySelector('form'); |
||||
if (!form) { |
||||
throw new Error('Form element not found'); |
||||
} |
||||
|
||||
const formData = new FormData(form); |
||||
|
||||
let content = formData.get('editor[content]') || ''; |
||||
content = this.htmlToMarkdown(content); |
||||
|
||||
const title = formData.get('editor[title]') || ''; |
||||
const summary = formData.get('editor[summary]') || ''; |
||||
const image = formData.get('editor[image]') || ''; |
||||
const topicsString = formData.get('editor[topics]') || ''; |
||||
const isDraft = formData.get('editor[isDraft]') === '1'; |
||||
const addClientTag = formData.get('editor[clientTag]') === '1'; |
||||
|
||||
// Parse topics
|
||||
const topics = topicsString.split(',') |
||||
.map(topic => topic.trim()) |
||||
.filter(topic => topic.length > 0) |
||||
.map(topic => topic.startsWith('#') ? topic : `#${topic}`); |
||||
|
||||
// Reuse existing slug if provided on the container (editing), else generate from title
|
||||
const existingSlug = (this.element.dataset.slug || '').trim(); |
||||
const slug = existingSlug || this.generateSlug(title); |
||||
|
||||
return { |
||||
title, |
||||
summary, |
||||
content, |
||||
image, |
||||
topics, |
||||
slug, |
||||
isDraft, |
||||
addClientTag |
||||
}; |
||||
return metadata; |
||||
} |
||||
|
||||
async createNostrEvent(formData) { |
||||
// Get user's public key
|
||||
const pubkey = await window.nostr.getPublicKey(); |
||||
|
||||
// Validate advanced metadata if present
|
||||
if (formData.advancedMetadata && formData.advancedMetadata.zapSplits.length > 0) { |
||||
const validation = validateAdvancedMetadata(formData.advancedMetadata); |
||||
if (!validation.valid) { |
||||
throw new Error('Invalid advanced metadata: ' + validation.errors.join(', ')); |
||||
} |
||||
} |
||||
|
||||
async createNostrEvent(formData) { |
||||
// Get user's public key
|
||||
const pubkey = await window.nostr.getPublicKey(); |
||||
|
||||
// Create tags array
|
||||
const tags = [ |
||||
['d', formData.slug], |
||||
['title', formData.title], |
||||
['published_at', Math.floor(Date.now() / 1000).toString()], |
||||
]; |
||||
|
||||
let kind = 30023; // Default kind for long-form content
|
||||
if (formData.isDraft) { |
||||
kind = 30024; // Draft kind
|
||||
} |
||||
|
||||
if (formData.summary) { |
||||
tags.push(['summary', formData.summary]); |
||||
} |
||||
|
||||
if (formData.image) { |
||||
tags.push(['image', formData.image]); |
||||
} |
||||
|
||||
// Add topic tags
|
||||
formData.topics.forEach(topic => { |
||||
tags.push(['t', topic.replace('#', '')]); |
||||
}); |
||||
|
||||
if (formData.addClientTag) { |
||||
tags.push(['client', 'Decent Newsroom']); |
||||
} |
||||
|
||||
// Create the Nostr event (NIP-23 long-form content)
|
||||
const event = { |
||||
kind: kind, // Long-form content kind
|
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags, |
||||
content: formData.content, |
||||
pubkey: pubkey |
||||
}; |
||||
|
||||
return event; |
||||
// Create tags array
|
||||
const tags = [ |
||||
['d', formData.slug], |
||||
['title', formData.title], |
||||
['published_at', Math.floor(Date.now() / 1000).toString()], |
||||
]; |
||||
|
||||
let kind = 30023; // Default kind for long-form content
|
||||
if (formData.isDraft) { |
||||
kind = 30024; // Draft kind
|
||||
} |
||||
|
||||
async sendToBackend(signedEvent, formData) { |
||||
const response = await fetch(this.publishUrlValue, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
'X-Requested-With': 'XMLHttpRequest', |
||||
'X-CSRF-TOKEN': this.csrfTokenValue |
||||
}, |
||||
body: JSON.stringify({ |
||||
event: signedEvent, |
||||
formData: formData |
||||
}) |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
const errorData = await response.json().catch(() => ({})); |
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); |
||||
} |
||||
|
||||
return await response.json(); |
||||
if (formData.summary) { |
||||
tags.push(['summary', formData.summary]); |
||||
} |
||||
|
||||
htmlToMarkdown(html) { |
||||
// Basic HTML to Markdown conversion
|
||||
// This is a simplified version - you might want to use a proper library
|
||||
let markdown = html; |
||||
|
||||
// Convert headers
|
||||
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); |
||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); |
||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); |
||||
|
||||
// Convert formatting
|
||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**'); |
||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**'); |
||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*'); |
||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*'); |
||||
|
||||
// Convert links
|
||||
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); |
||||
|
||||
// Convert images (handle src/alt in any order)
|
||||
markdown = markdown.replace(/<img\b[^>]*>/gi, (imgTag) => { |
||||
const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); |
||||
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); |
||||
const src = srcMatch ? srcMatch[1] : ''; |
||||
const alt = altMatch ? altMatch[1] : ''; |
||||
return src ? `` : ''; |
||||
}); |
||||
|
||||
// Convert lists
|
||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n'); |
||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n'); |
||||
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n'); |
||||
|
||||
// Convert paragraphs
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); |
||||
|
||||
// Convert line breaks
|
||||
markdown = markdown.replace(/<br[^>]*>/gi, '\n'); |
||||
|
||||
// Convert blockquotes
|
||||
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); |
||||
|
||||
// Convert code blocks
|
||||
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); |
||||
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); |
||||
|
||||
// Clean up HTML entities and remaining tags
|
||||
markdown = markdown.replace(/ /g, ' '); |
||||
markdown = markdown.replace(/&/g, '&'); |
||||
markdown = markdown.replace(/</g, '<'); |
||||
markdown = markdown.replace(/>/g, '>'); |
||||
markdown = markdown.replace(/"/g, '"'); |
||||
markdown = markdown.replace(/<[^>]*>/g, ''); // Remove any remaining HTML tags
|
||||
|
||||
// Clean up extra whitespace
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); |
||||
|
||||
return markdown; |
||||
if (formData.image) { |
||||
tags.push(['image', formData.image]); |
||||
} |
||||
|
||||
generateSlug(title) { |
||||
// add a random seed at the end of the title to avoid collisions
|
||||
const randomSeed = Math.random().toString(36).substring(2, 8); |
||||
title = `${title} ${randomSeed}`; |
||||
return title |
||||
.toLowerCase() |
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
// Add topic tags
|
||||
formData.topics.forEach(topic => { |
||||
tags.push(['t', topic.replace('#', '')]); |
||||
}); |
||||
|
||||
if (formData.addClientTag) { |
||||
tags.push(['client', 'Decent Newsroom']); |
||||
} |
||||
|
||||
showStatus(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
||||
} |
||||
// Add advanced metadata tags
|
||||
if (formData.advancedMetadata) { |
||||
const advancedTags = buildAdvancedTags(formData.advancedMetadata); |
||||
tags.push(...advancedTags); |
||||
} |
||||
|
||||
showSuccess(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
||||
} |
||||
// Create the Nostr event (NIP-23 long-form content)
|
||||
const event = { |
||||
kind: kind, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags, |
||||
content: formData.content, |
||||
pubkey: pubkey |
||||
}; |
||||
|
||||
return event; |
||||
} |
||||
|
||||
async sendToBackend(signedEvent, formData) { |
||||
const response = await fetch(this.publishUrlValue, { |
||||
method: 'POST', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
'X-Requested-With': 'XMLHttpRequest', |
||||
'X-CSRF-TOKEN': this.csrfTokenValue |
||||
}, |
||||
body: JSON.stringify({ |
||||
event: signedEvent, |
||||
formData: formData |
||||
}) |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
const errorData = await response.json().catch(() => ({})); |
||||
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); |
||||
} |
||||
|
||||
showError(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
||||
} |
||||
return await response.json(); |
||||
} |
||||
|
||||
htmlToMarkdown(html) { |
||||
// Basic HTML to Markdown conversion
|
||||
let markdown = html; |
||||
|
||||
// Convert headers
|
||||
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); |
||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); |
||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); |
||||
|
||||
// Convert formatting
|
||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**'); |
||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**'); |
||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*'); |
||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*'); |
||||
|
||||
// Convert links
|
||||
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); |
||||
|
||||
// Convert images
|
||||
markdown = markdown.replace(/<img\b[^>]*>/gi, (imgTag) => { |
||||
const srcMatch = imgTag.match(/src=["']([^"']+)["']/i); |
||||
const altMatch = imgTag.match(/alt=["']([^"']*)["']/i); |
||||
const src = srcMatch ? srcMatch[1] : ''; |
||||
const alt = altMatch ? altMatch[1] : ''; |
||||
return src ? `` : ''; |
||||
}); |
||||
|
||||
// Convert lists
|
||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n'); |
||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n'); |
||||
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n'); |
||||
|
||||
// Convert paragraphs
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); |
||||
|
||||
// Convert line breaks
|
||||
markdown = markdown.replace(/<br[^>]*>/gi, '\n'); |
||||
|
||||
// Convert blockquotes
|
||||
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); |
||||
|
||||
// Convert code blocks
|
||||
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); |
||||
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); |
||||
|
||||
// Clean up HTML entities and remaining tags
|
||||
markdown = markdown.replace(/ /g, ' '); |
||||
markdown = markdown.replace(/&/g, '&'); |
||||
markdown = markdown.replace(/</g, '<'); |
||||
markdown = markdown.replace(/>/g, '>'); |
||||
markdown = markdown.replace(/"/g, '"'); |
||||
markdown = markdown.replace(/<[^>]*>/g, ''); |
||||
|
||||
// Clean up extra whitespace
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); |
||||
|
||||
return markdown; |
||||
} |
||||
|
||||
generateSlug(title) { |
||||
// add a random seed at the end of the title to avoid collisions
|
||||
const randomSeed = Math.random().toString(36).substring(2, 8); |
||||
title = `${title} ${randomSeed}`; |
||||
return title |
||||
.toLowerCase() |
||||
.replace(/[^a-z0-9\s-]/g, '') |
||||
.replace(/\s+/g, '-') |
||||
.replace(/-+/g, '-') |
||||
.replace(/^-|-$/g, ''); |
||||
} |
||||
|
||||
showStatus(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
||||
} |
||||
} |
||||
|
||||
showSuccess(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
||||
} |
||||
} |
||||
|
||||
showError(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
|
||||
/* Advanced Metadata Styles */ |
||||
|
||||
.advanced-metadata-section details { |
||||
border: 1px solid #dee2e6; |
||||
border-radius: 0.375rem; |
||||
padding: 1rem; |
||||
background-color: #f8f9fa; |
||||
} |
||||
|
||||
.advanced-metadata-section summary { |
||||
cursor: pointer; |
||||
user-select: none; |
||||
font-weight: 600; |
||||
margin: -1rem -1rem 0 -1rem; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.advanced-metadata-section summary:hover { |
||||
background-color: #e9ecef; |
||||
} |
||||
|
||||
.advanced-metadata-section details[open] summary { |
||||
border-bottom: 1px solid #dee2e6; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.zap-split-item { |
||||
background-color: #ffffff; |
||||
transition: box-shadow 0.2s; |
||||
} |
||||
|
||||
.zap-split-item:hover { |
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.zap-share-display { |
||||
font-size: 0.875rem; |
||||
font-weight: 600; |
||||
color: #6c757d; |
||||
} |
||||
|
||||
.upload-area { |
||||
border: 2px dashed #dee2e6; |
||||
border-radius: 0.375rem; |
||||
padding: 2rem; |
||||
text-align: center; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
margin: 1rem 0; |
||||
} |
||||
|
||||
.upload-area:hover { |
||||
border-color: #0d6efd; |
||||
background-color: #f8f9fa; |
||||
} |
||||
|
||||
.upload-area input[type="file"] { |
||||
display: none; |
||||
} |
||||
|
||||
.iu-dialog { |
||||
display: none; |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
z-index: 1050; |
||||
} |
||||
|
||||
.iu-dialog.active { |
||||
display: block; |
||||
} |
||||
|
||||
.iu-backdrop { |
||||
position: absolute; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background-color: rgba(0, 0, 0, 0.5); |
||||
} |
||||
|
||||
.iu-modal { |
||||
position: relative; |
||||
max-width: 500px; |
||||
margin: 3rem auto; |
||||
background-color: white; |
||||
border-radius: 0.375rem; |
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); |
||||
z-index: 1051; |
||||
} |
||||
|
||||
.modal-header { |
||||
padding: 1rem; |
||||
border-bottom: 1px solid #dee2e6; |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
} |
||||
|
||||
.modal-header .close { |
||||
background: none; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
cursor: pointer; |
||||
color: #6c757d; |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.upload-progress, |
||||
.upload-error { |
||||
margin-top: 1rem; |
||||
padding: 0.75rem; |
||||
border-radius: 0.375rem; |
||||
} |
||||
|
||||
.upload-progress { |
||||
background-color: #d1ecf1; |
||||
color: #0c5460; |
||||
} |
||||
|
||||
.upload-error { |
||||
background-color: #f8d7da; |
||||
color: #721c24; |
||||
} |
||||
|
||||
/* Form validation styles */ |
||||
.form-control.is-invalid { |
||||
border-color: #dc3545; |
||||
} |
||||
|
||||
.form-control.is-valid { |
||||
border-color: #28a745; |
||||
} |
||||
|
||||
.invalid-feedback { |
||||
display: block; |
||||
margin-top: 0.25rem; |
||||
font-size: 0.875em; |
||||
color: #dc3545; |
||||
} |
||||
|
||||
.cursor-pointer { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace DoctrineMigrations; |
||||
|
||||
use Doctrine\DBAL\Schema\Schema; |
||||
use Doctrine\Migrations\AbstractMigration; |
||||
|
||||
final class Version20251105000000 extends AbstractMigration |
||||
{ |
||||
public function getDescription(): string |
||||
{ |
||||
return 'Add advanced_metadata field to article table for storing Nostr advanced tags'; |
||||
} |
||||
|
||||
public function up(Schema $schema): void |
||||
{ |
||||
$this->addSql('ALTER TABLE article ADD advanced_metadata JSON DEFAULT NULL'); |
||||
} |
||||
|
||||
public function down(Schema $schema): void |
||||
{ |
||||
$this->addSql('ALTER TABLE article DROP advanced_metadata'); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Dto; |
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert; |
||||
|
||||
class AdvancedMetadata |
||||
{ |
||||
public bool $doNotRepublish = false; |
||||
|
||||
#[Assert\Choice(choices: [ |
||||
'', |
||||
'CC0-1.0', |
||||
'CC-BY-4.0', |
||||
'CC-BY-SA-4.0', |
||||
'CC-BY-NC-4.0', |
||||
'CC-BY-NC-SA-4.0', |
||||
'CC-BY-ND-4.0', |
||||
'CC-BY-NC-ND-4.0', |
||||
'MIT', |
||||
'Apache-2.0', |
||||
'GPL-3.0', |
||||
'AGPL-3.0', |
||||
'All rights reserved', |
||||
'custom' |
||||
])] |
||||
public string $license = ''; |
||||
|
||||
public ?string $customLicense = null; |
||||
|
||||
/** @var ZapSplit[] */ |
||||
#[Assert\Valid] |
||||
public array $zapSplits = []; |
||||
|
||||
public ?string $contentWarning = null; |
||||
|
||||
public ?int $expirationTimestamp = null; |
||||
|
||||
public bool $isProtected = false; |
||||
|
||||
/** @var array<string, mixed> Additional tags to preserve on re-publish */ |
||||
public array $extraTags = []; |
||||
|
||||
public function addZapSplit(ZapSplit $split): void |
||||
{ |
||||
$this->zapSplits[] = $split; |
||||
} |
||||
|
||||
public function removeZapSplit(int $index): void |
||||
{ |
||||
if (isset($this->zapSplits[$index])) { |
||||
unset($this->zapSplits[$index]); |
||||
$this->zapSplits = array_values($this->zapSplits); |
||||
} |
||||
} |
||||
|
||||
public function getLicenseValue(): ?string |
||||
{ |
||||
if ($this->license === 'custom') { |
||||
return $this->customLicense; |
||||
} |
||||
return $this->license !== '' ? $this->license : null; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Dto; |
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert; |
||||
|
||||
class ZapSplit |
||||
{ |
||||
#[Assert\NotBlank(message: 'Recipient is required')] |
||||
public string $recipient = ''; |
||||
|
||||
#[Assert\Regex( |
||||
pattern: '/^wss:\/\/.+/', |
||||
message: 'Relay must be a valid WebSocket URL starting with wss://' |
||||
)] |
||||
public ?string $relay = null; |
||||
|
||||
#[Assert\PositiveOrZero(message: 'Weight must be 0 or greater')] |
||||
public ?int $weight = null; |
||||
|
||||
/** Computed share percentage (0-100) */ |
||||
public ?float $sharePercent = null; |
||||
|
||||
public function __construct( |
||||
string $recipient = '', |
||||
?string $relay = null, |
||||
?int $weight = null |
||||
) { |
||||
$this->recipient = $recipient; |
||||
$this->relay = $relay; |
||||
$this->weight = $weight; |
||||
} |
||||
|
||||
/** |
||||
* Returns the recipient as hex pubkey. |
||||
* If it's an npub, it gets converted to hex. |
||||
*/ |
||||
public function getRecipientHex(): string |
||||
{ |
||||
// This will be handled by the service/utility |
||||
return $this->recipient; |
||||
} |
||||
|
||||
public function isNpub(): bool |
||||
{ |
||||
return str_starts_with($this->recipient, 'npub1'); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Form; |
||||
|
||||
use App\Dto\AdvancedMetadata; |
||||
use Symfony\Component\Form\AbstractType; |
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; |
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; |
||||
use Symfony\Component\Form\Extension\Core\Type\CollectionType; |
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType; |
||||
use Symfony\Component\Form\Extension\Core\Type\TextType; |
||||
use Symfony\Component\Form\FormBuilderInterface; |
||||
use Symfony\Component\OptionsResolver\OptionsResolver; |
||||
|
||||
class AdvancedMetadataType extends AbstractType |
||||
{ |
||||
public function buildForm(FormBuilderInterface $builder, array $options): void |
||||
{ |
||||
$builder |
||||
->add('doNotRepublish', CheckboxType::class, [ |
||||
'label' => 'Do not republish', |
||||
'required' => false, |
||||
'help' => 'Mark this article with a policy label indicating it should not be republished', |
||||
'row_attr' => ['class' => 'form-check'], |
||||
'label_attr' => ['class' => 'form-check-label'], |
||||
'attr' => ['class' => 'form-check-input'], |
||||
]) |
||||
->add('license', ChoiceType::class, [ |
||||
'label' => 'License', |
||||
'required' => false, |
||||
'choices' => [ |
||||
'No license' => '', |
||||
'Public Domain (CC0)' => 'CC0-1.0', |
||||
'Attribution (CC-BY)' => 'CC-BY-4.0', |
||||
'Attribution-ShareAlike (CC-BY-SA)' => 'CC-BY-SA-4.0', |
||||
'Attribution-NonCommercial (CC-BY-NC)' => 'CC-BY-NC-4.0', |
||||
'Attribution-NonCommercial-ShareAlike (CC-BY-NC-SA)' => 'CC-BY-NC-SA-4.0', |
||||
'Attribution-NoDerivs (CC-BY-ND)' => 'CC-BY-ND-4.0', |
||||
'Attribution-NonCommercial-NoDerivs (CC-BY-NC-ND)' => 'CC-BY-NC-ND-4.0', |
||||
'MIT License' => 'MIT', |
||||
'Apache License 2.0' => 'Apache-2.0', |
||||
'GNU GPL v3' => 'GPL-3.0', |
||||
'GNU AGPL v3' => 'AGPL-3.0', |
||||
'All rights reserved' => 'All rights reserved', |
||||
'Custom license' => 'custom', |
||||
], |
||||
'attr' => ['class' => 'form-select'], |
||||
]) |
||||
->add('customLicense', TextType::class, [ |
||||
'label' => 'Custom license identifier', |
||||
'required' => false, |
||||
'attr' => [ |
||||
'class' => 'form-control', |
||||
'placeholder' => 'e.g., My-Custom-License-1.0' |
||||
], |
||||
'help' => 'Specify a custom SPDX identifier or license name', |
||||
]) |
||||
->add('zapSplits', CollectionType::class, [ |
||||
'entry_type' => ZapSplitType::class, |
||||
'allow_add' => true, |
||||
'allow_delete' => true, |
||||
'by_reference' => false, |
||||
'label' => 'Zap splits', |
||||
'required' => false, |
||||
'attr' => [ |
||||
'class' => 'zap-splits-collection', |
||||
], |
||||
'help' => 'Configure multiple recipients for zaps (tips). Leave weights empty for equal distribution.', |
||||
]) |
||||
->add('contentWarning', TextType::class, [ |
||||
'label' => 'Content warning', |
||||
'required' => false, |
||||
'attr' => [ |
||||
'class' => 'form-control', |
||||
'placeholder' => 'e.g., graphic content, spoilers, etc.' |
||||
], |
||||
'help' => 'Optional warning about sensitive content', |
||||
]) |
||||
->add('expirationTimestamp', DateTimeType::class, [ |
||||
'label' => 'Expiration date', |
||||
'required' => false, |
||||
'widget' => 'single_text', |
||||
'input' => 'timestamp', |
||||
'attr' => ['class' => 'form-control'], |
||||
'help' => 'When this article should expire (optional)', |
||||
]) |
||||
->add('isProtected', CheckboxType::class, [ |
||||
'label' => 'Protected event', |
||||
'required' => false, |
||||
'help' => 'Mark this event as protected. Warning: Some relays may reject protected events.', |
||||
'row_attr' => ['class' => 'form-check'], |
||||
'label_attr' => ['class' => 'form-check-label'], |
||||
'attr' => ['class' => 'form-check-input'], |
||||
]); |
||||
} |
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void |
||||
{ |
||||
$resolver->setDefaults([ |
||||
'data_class' => AdvancedMetadata::class, |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Form; |
||||
|
||||
use App\Dto\ZapSplit; |
||||
use Symfony\Component\Form\AbstractType; |
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType; |
||||
use Symfony\Component\Form\Extension\Core\Type\TextType; |
||||
use Symfony\Component\Form\FormBuilderInterface; |
||||
use Symfony\Component\OptionsResolver\OptionsResolver; |
||||
use Symfony\Component\Validator\Constraints as Assert; |
||||
|
||||
class ZapSplitType extends AbstractType |
||||
{ |
||||
public function buildForm(FormBuilderInterface $builder, array $options): void |
||||
{ |
||||
$builder |
||||
->add('recipient', TextType::class, [ |
||||
'label' => 'Recipient', |
||||
'required' => true, |
||||
'attr' => [ |
||||
'class' => 'form-control zap-recipient', |
||||
'placeholder' => 'npub1... or hex pubkey' |
||||
], |
||||
'constraints' => [ |
||||
new Assert\NotBlank(['message' => 'Recipient is required']), |
||||
], |
||||
]) |
||||
->add('relay', TextType::class, [ |
||||
'label' => 'Relay hint', |
||||
'required' => false, |
||||
'attr' => [ |
||||
'class' => 'form-control zap-relay', |
||||
'placeholder' => 'wss://relay.example.com' |
||||
], |
||||
'constraints' => [ |
||||
new Assert\Regex([ |
||||
'pattern' => '/^wss:\/\/.+/', |
||||
'message' => 'Relay must be a valid WebSocket URL starting with wss://' |
||||
]), |
||||
], |
||||
]) |
||||
->add('weight', IntegerType::class, [ |
||||
'label' => 'Weight', |
||||
'required' => false, |
||||
'attr' => [ |
||||
'class' => 'form-control zap-weight', |
||||
'placeholder' => '1', |
||||
'min' => 0 |
||||
], |
||||
'constraints' => [ |
||||
new Assert\PositiveOrZero(['message' => 'Weight must be 0 or greater']), |
||||
], |
||||
]); |
||||
} |
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void |
||||
{ |
||||
$resolver->setDefaults([ |
||||
'data_class' => ZapSplit::class, |
||||
]); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service\Nostr; |
||||
|
||||
use App\Dto\AdvancedMetadata; |
||||
use App\Dto\ZapSplit; |
||||
use App\Entity\Article; |
||||
use nostriphant\NIP19\Bech32; |
||||
use swentel\nostr\Key\Key; |
||||
|
||||
class NostrEventBuilder |
||||
{ |
||||
private Key $key; |
||||
|
||||
public function __construct() |
||||
{ |
||||
$this->key = new Key(); |
||||
} |
||||
|
||||
/** |
||||
* Build tags array from Article and AdvancedMetadata |
||||
* |
||||
* @param Article $article |
||||
* @param AdvancedMetadata|null $metadata |
||||
* @param array $formData Additional form data (isDraft, addClientTag, etc.) |
||||
* @return array |
||||
*/ |
||||
public function buildTags(Article $article, ?AdvancedMetadata $metadata, array $formData = []): array |
||||
{ |
||||
$tags = []; |
||||
|
||||
// Base NIP-23 tags |
||||
$tags[] = ['d', $article->getSlug() ?? '']; |
||||
$tags[] = ['title', $article->getTitle() ?? '']; |
||||
$tags[] = ['published_at', (string)($article->getPublishedAt()?->getTimestamp() ?? time())]; |
||||
|
||||
if ($article->getSummary()) { |
||||
$tags[] = ['summary', $article->getSummary()]; |
||||
} |
||||
|
||||
if ($article->getImage()) { |
||||
$tags[] = ['image', $article->getImage()]; |
||||
} |
||||
|
||||
// Topic tags |
||||
if ($article->getTopics()) { |
||||
foreach ($article->getTopics() as $topic) { |
||||
$cleanTopic = str_replace('#', '', $topic); |
||||
$tags[] = ['t', $cleanTopic]; |
||||
} |
||||
} |
||||
|
||||
// Client tag |
||||
if ($formData['addClientTag'] ?? false) { |
||||
$tags[] = ['client', 'Decent Newsroom']; |
||||
} |
||||
|
||||
// Advanced metadata tags |
||||
if ($metadata) { |
||||
$tags = array_merge($tags, $this->buildAdvancedTags($metadata)); |
||||
} |
||||
|
||||
return $tags; |
||||
} |
||||
|
||||
/** |
||||
* Build advanced metadata tags |
||||
*/ |
||||
private function buildAdvancedTags(AdvancedMetadata $metadata): array |
||||
{ |
||||
$tags = []; |
||||
|
||||
// Policy: Do not republish |
||||
if ($metadata->doNotRepublish) { |
||||
$tags[] = ['L', 'rights.decent.newsroom']; |
||||
$tags[] = ['l', 'no-republish', 'rights.decent.newsroom']; |
||||
} |
||||
|
||||
// License |
||||
$license = $metadata->getLicenseValue(); |
||||
if ($license && $license !== 'All rights reserved') { |
||||
$tags[] = ['L', 'spdx.org/licenses']; |
||||
$tags[] = ['l', $license, 'spdx.org/licenses']; |
||||
} elseif ($license === 'All rights reserved') { |
||||
$tags[] = ['L', 'rights.decent.newsroom']; |
||||
$tags[] = ['l', 'all-rights-reserved', 'rights.decent.newsroom']; |
||||
} |
||||
|
||||
// Zap splits |
||||
foreach ($metadata->zapSplits as $split) { |
||||
$zapTag = ['zap', $this->convertToHex($split->recipient)]; |
||||
|
||||
if ($split->relay) { |
||||
$zapTag[] = $split->relay; |
||||
} else { |
||||
$zapTag[] = ''; |
||||
} |
||||
|
||||
if ($split->weight !== null) { |
||||
$zapTag[] = (string)$split->weight; |
||||
} |
||||
|
||||
$tags[] = $zapTag; |
||||
} |
||||
|
||||
// Content warning |
||||
if ($metadata->contentWarning) { |
||||
$tags[] = ['content-warning', $metadata->contentWarning]; |
||||
} |
||||
|
||||
// Expiration |
||||
if ($metadata->expirationTimestamp) { |
||||
$tags[] = ['expiration', (string)$metadata->expirationTimestamp]; |
||||
} |
||||
|
||||
// Protected event |
||||
if ($metadata->isProtected) { |
||||
$tags[] = ['-']; |
||||
} |
||||
|
||||
// Extra tags (passthrough) |
||||
foreach ($metadata->extraTags as $tag) { |
||||
if (is_array($tag)) { |
||||
$tags[] = $tag; |
||||
} |
||||
} |
||||
|
||||
return $tags; |
||||
} |
||||
|
||||
/** |
||||
* Convert npub or hex to hex pubkey |
||||
*/ |
||||
public function convertToHex(string $pubkey): string |
||||
{ |
||||
if (str_starts_with($pubkey, 'npub1')) { |
||||
try { |
||||
return $this->key->convertToHex($pubkey); |
||||
} catch (\Exception $e) { |
||||
throw new \InvalidArgumentException('Invalid npub format: ' . $e->getMessage()); |
||||
} |
||||
} |
||||
|
||||
// Validate hex format |
||||
if (!preg_match('/^[0-9a-f]{64}$/i', $pubkey)) { |
||||
throw new \InvalidArgumentException('Invalid pubkey format. Must be hex (64 chars) or npub'); |
||||
} |
||||
|
||||
return strtolower($pubkey); |
||||
} |
||||
|
||||
/** |
||||
* Calculate share percentages for zap splits |
||||
* |
||||
* @param ZapSplit[] $splits |
||||
* @return array Array of percentages indexed by split position |
||||
*/ |
||||
public function calculateShares(array $splits): array |
||||
{ |
||||
if (empty($splits)) { |
||||
return []; |
||||
} |
||||
|
||||
$hasWeights = false; |
||||
$totalWeight = 0; |
||||
|
||||
foreach ($splits as $split) { |
||||
if ($split->weight !== null && $split->weight > 0) { |
||||
$hasWeights = true; |
||||
$totalWeight += $split->weight; |
||||
} |
||||
} |
||||
|
||||
// If no weights specified, equal distribution |
||||
if (!$hasWeights) { |
||||
$equalShare = 100.0 / count($splits); |
||||
return array_fill(0, count($splits), $equalShare); |
||||
} |
||||
|
||||
// Calculate weighted shares |
||||
$shares = []; |
||||
foreach ($splits as $split) { |
||||
$weight = $split->weight ?? 0; |
||||
$shares[] = $totalWeight > 0 ? ($weight / $totalWeight) * 100 : 0; |
||||
} |
||||
|
||||
return $shares; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service\Nostr; |
||||
|
||||
use App\Dto\AdvancedMetadata; |
||||
use App\Dto\ZapSplit; |
||||
use swentel\nostr\Key\Key; |
||||
|
||||
class NostrEventParser |
||||
{ |
||||
private Key $key; |
||||
|
||||
public function __construct() |
||||
{ |
||||
$this->key = new Key(); |
||||
} |
||||
|
||||
/** |
||||
* Parse tags from a Nostr event into AdvancedMetadata DTO |
||||
* |
||||
* @param array $tags The tags array from the Nostr event |
||||
* @return AdvancedMetadata |
||||
*/ |
||||
public function parseAdvancedMetadata(array $tags): AdvancedMetadata |
||||
{ |
||||
$metadata = new AdvancedMetadata(); |
||||
$knownTags = ['d', 'title', 'summary', 'image', 'published_at', 't', 'client']; |
||||
$processedAdvancedTags = []; |
||||
|
||||
foreach ($tags as $tag) { |
||||
if (!is_array($tag) || empty($tag)) { |
||||
continue; |
||||
} |
||||
|
||||
$tagName = $tag[0] ?? ''; |
||||
|
||||
switch ($tagName) { |
||||
case 'L': |
||||
// Label namespace - just track it, process with 'l' tags |
||||
break; |
||||
|
||||
case 'l': |
||||
$this->parseLabel($tag, $metadata); |
||||
$processedAdvancedTags[] = $tag; |
||||
break; |
||||
|
||||
case 'zap': |
||||
$split = $this->parseZapTag($tag); |
||||
if ($split) { |
||||
$metadata->addZapSplit($split); |
||||
$processedAdvancedTags[] = $tag; |
||||
} |
||||
break; |
||||
|
||||
case 'content-warning': |
||||
$metadata->contentWarning = $tag[1] ?? ''; |
||||
$processedAdvancedTags[] = $tag; |
||||
break; |
||||
|
||||
case 'expiration': |
||||
$timestamp = (int)($tag[1] ?? 0); |
||||
if ($timestamp > 0) { |
||||
$metadata->expirationTimestamp = $timestamp; |
||||
} |
||||
$processedAdvancedTags[] = $tag; |
||||
break; |
||||
|
||||
case '-': |
||||
$metadata->isProtected = true; |
||||
$processedAdvancedTags[] = $tag; |
||||
break; |
||||
|
||||
default: |
||||
// Preserve unknown tags for passthrough |
||||
if (!in_array($tagName, $knownTags, true)) { |
||||
$metadata->extraTags[] = $tag; |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
|
||||
return $metadata; |
||||
} |
||||
|
||||
/** |
||||
* Parse a label tag (NIP-32) |
||||
*/ |
||||
private function parseLabel(array $tag, AdvancedMetadata $metadata): void |
||||
{ |
||||
$label = $tag[1] ?? ''; |
||||
$namespace = $tag[2] ?? ''; |
||||
|
||||
if ($namespace === 'rights.decent.newsroom') { |
||||
if ($label === 'no-republish') { |
||||
$metadata->doNotRepublish = true; |
||||
} elseif ($label === 'all-rights-reserved') { |
||||
$metadata->license = 'All rights reserved'; |
||||
} |
||||
} elseif ($namespace === 'spdx.org/licenses') { |
||||
$metadata->license = $label; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parse a zap split tag |
||||
* |
||||
* Format: ["zap", <hex-pubkey>, <relay>, <weight>] |
||||
* Relay and weight are optional |
||||
*/ |
||||
private function parseZapTag(array $tag): ?ZapSplit |
||||
{ |
||||
if (count($tag) < 2) { |
||||
return null; |
||||
} |
||||
|
||||
$pubkeyHex = $tag[1] ?? ''; |
||||
if (empty($pubkeyHex)) { |
||||
return null; |
||||
} |
||||
|
||||
// Convert hex to npub for display (more user-friendly) |
||||
try { |
||||
$npub = $this->key->convertPublicKeyToBech32($pubkeyHex); |
||||
} catch (\Exception $e) { |
||||
// If conversion fails, use hex |
||||
$npub = $pubkeyHex; |
||||
} |
||||
|
||||
$relay = $tag[2] ?? null; |
||||
if ($relay === '') { |
||||
$relay = null; |
||||
} |
||||
|
||||
$weight = isset($tag[3]) && $tag[3] !== '' ? (int)$tag[3] : null; |
||||
|
||||
return new ZapSplit($npub, $relay, $weight); |
||||
} |
||||
|
||||
/** |
||||
* Extract basic article data from tags |
||||
*/ |
||||
public function extractArticleData(array $tags): array |
||||
{ |
||||
$data = [ |
||||
'slug' => '', |
||||
'title' => '', |
||||
'summary' => '', |
||||
'image' => '', |
||||
'topics' => [], |
||||
'publishedAt' => null, |
||||
]; |
||||
|
||||
foreach ($tags as $tag) { |
||||
if (!is_array($tag) || count($tag) < 2) { |
||||
continue; |
||||
} |
||||
|
||||
switch ($tag[0]) { |
||||
case 'd': |
||||
$data['slug'] = $tag[1]; |
||||
break; |
||||
case 'title': |
||||
$data['title'] = $tag[1]; |
||||
break; |
||||
case 'summary': |
||||
$data['summary'] = $tag[1]; |
||||
break; |
||||
case 'image': |
||||
$data['image'] = $tag[1]; |
||||
break; |
||||
case 't': |
||||
$data['topics'][] = $tag[1]; |
||||
break; |
||||
case 'published_at': |
||||
$timestamp = (int)$tag[1]; |
||||
if ($timestamp > 0) { |
||||
$data['publishedAt'] = new \DateTimeImmutable('@' . $timestamp); |
||||
} |
||||
break; |
||||
} |
||||
} |
||||
|
||||
return $data; |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
{% block _editor_advancedMetadata_widget %} |
||||
<div class="advanced-metadata-section" {{ stimulus_controller('advanced-metadata') }}> |
||||
<details class="mb-4"> |
||||
<summary class="h5 cursor-pointer">Zap splits and content warning</summary> |
||||
<div class="mt-2 p-3 border rounded"> |
||||
<div class="row hidden"> |
||||
<div class="col-md-6"> |
||||
{{ form_row(form.doNotRepublish) }} |
||||
</div> |
||||
<div class="col-md-6"> |
||||
{{ form_row(form.license, { |
||||
'attr': { |
||||
'data-advanced-metadata-target': 'licenseSelect', |
||||
'data-action': 'change->advanced-metadata#licenseChanged' |
||||
} |
||||
}) }} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="row hidden"> |
||||
<div class="col-md-12"> |
||||
{{ form_row(form.customLicense, { |
||||
'attr': { |
||||
'data-advanced-metadata-target': 'customLicenseInput' |
||||
} |
||||
}) }} |
||||
</div> |
||||
</div> |
||||
|
||||
|
||||
<h6>Zap Splits</h6> |
||||
<p class="text-muted small">Configure multiple recipients for zaps (tips). Weights determine the split ratio.</p> |
||||
|
||||
<div data-advanced-metadata-target="zapSplitsContainer" |
||||
data-prototype="{{ form_widget(form.zapSplits.vars.prototype)|e('html_attr') }}" |
||||
data-index="{{ form.zapSplits|length }}"> |
||||
{% for zapSplit in form.zapSplits %} |
||||
<div class="zap-split-item mb-3 p-3 border rounded" data-index="{{ loop.index0 }}"> |
||||
<div class="row"> |
||||
<div class="col-md-5"> |
||||
{{ form_row(zapSplit.recipient) }} |
||||
</div> |
||||
<div class="col-md-4"> |
||||
{{ form_row(zapSplit.relay) }} |
||||
</div> |
||||
<div class="col-md-3"> |
||||
{{ form_row(zapSplit.weight) }} |
||||
</div> |
||||
</div> |
||||
<button type="button" |
||||
class="btn btn-sm btn-danger mt-2" |
||||
data-action="click->advanced-metadata#removeZapSplit" |
||||
data-index="{{ loop.index0 }}"> |
||||
Remove |
||||
</button> |
||||
<div class="zap-share-display mt-2 text-muted"> |
||||
<small>Share: <span class="share-percent">0</span>%</small> |
||||
</div> |
||||
</div> |
||||
{% endfor %} |
||||
</div> |
||||
|
||||
<div class="mb-3"> |
||||
<button type="button" |
||||
class="btn btn-sm btn-secondary" |
||||
data-advanced-metadata-target="addZapButton" |
||||
data-action="click->advanced-metadata#addZapSplit"> |
||||
Add Zap Recipient |
||||
</button> |
||||
<button type="button" |
||||
class="btn btn-sm btn-outline-secondary" |
||||
data-advanced-metadata-target="distributeEquallyButton" |
||||
data-action="click->advanced-metadata#distributeEqually"> |
||||
Distribute Equally |
||||
</button> |
||||
</div> |
||||
|
||||
<hr class="my-4"> |
||||
|
||||
<div class="row"> |
||||
<div class="col-md-6"> |
||||
{{ form_row(form.contentWarning) }} |
||||
</div> |
||||
</div> |
||||
<div class="row hidden"> |
||||
<div class="col-md-6"> |
||||
{{ form_row(form.expirationTimestamp, { |
||||
'attr': { |
||||
'data-advanced-metadata-target': 'expirationInput', |
||||
'data-action': 'change->advanced-metadata#expirationChanged' |
||||
} |
||||
}) }} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="row hidden"> |
||||
<div class="col-md-12"> |
||||
{{ form_row(form.isProtected, { |
||||
'attr': { |
||||
'data-advanced-metadata-target': 'protectedCheckbox', |
||||
'data-action': 'change->advanced-metadata#protectedChanged' |
||||
} |
||||
}) }} |
||||
<div class="alert alert-warning mt-2" |
||||
style="display: none;" |
||||
data-advanced-metadata-target="protectedWarning"> |
||||
<small><strong>Warning:</strong> Some relays may reject protected events. Use with caution.</small> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</details> |
||||
</div> |
||||
{% endblock %} |
||||
|
||||
Loading…
Reference in new issue