32 changed files with 2618 additions and 124 deletions
@ -0,0 +1,211 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
export default class extends Controller { |
||||||
|
static targets = ['dropdown', 'status', 'menu']; |
||||||
|
static values = { |
||||||
|
coordinate: String, |
||||||
|
lists: String, |
||||||
|
publishUrl: String, |
||||||
|
csrfToken: String |
||||||
|
}; |
||||||
|
|
||||||
|
connect() { |
||||||
|
// Close dropdown when clicking outside
|
||||||
|
this.boundCloseOnClickOutside = this.closeOnClickOutside.bind(this); |
||||||
|
document.addEventListener('click', this.boundCloseOnClickOutside); |
||||||
|
} |
||||||
|
|
||||||
|
disconnect() { |
||||||
|
document.removeEventListener('click', this.boundCloseOnClickOutside); |
||||||
|
} |
||||||
|
|
||||||
|
toggleDropdown(event) { |
||||||
|
event.preventDefault(); |
||||||
|
event.stopPropagation(); |
||||||
|
|
||||||
|
if (this.hasMenuTarget) { |
||||||
|
const isOpen = this.menuTarget.classList.contains('show'); |
||||||
|
if (isOpen) { |
||||||
|
this.closeDropdown(); |
||||||
|
} else { |
||||||
|
this.openDropdown(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
openDropdown() { |
||||||
|
if (this.hasMenuTarget) { |
||||||
|
this.menuTarget.classList.add('show'); |
||||||
|
if (this.hasDropdownTarget) { |
||||||
|
this.dropdownTarget.setAttribute('aria-expanded', 'true'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
closeDropdown() { |
||||||
|
if (this.hasMenuTarget) { |
||||||
|
this.menuTarget.classList.remove('show'); |
||||||
|
if (this.hasDropdownTarget) { |
||||||
|
this.dropdownTarget.setAttribute('aria-expanded', 'false'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
closeOnClickOutside(event) { |
||||||
|
if (!this.element.contains(event.target)) { |
||||||
|
this.closeDropdown(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async addToList(event) { |
||||||
|
event.preventDefault(); |
||||||
|
event.stopPropagation(); |
||||||
|
|
||||||
|
const slug = event.currentTarget.dataset.slug; |
||||||
|
const title = event.currentTarget.dataset.title; |
||||||
|
|
||||||
|
if (!window.nostr) { |
||||||
|
this.showError('Nostr extension not found. Please install a Nostr signer extension.'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
this.showStatus(`Adding to "${title}"...`); |
||||||
|
|
||||||
|
// Parse the existing lists data
|
||||||
|
const lists = JSON.parse(this.listsValue || '[]'); |
||||||
|
const selectedList = lists.find(l => l.slug === slug); |
||||||
|
|
||||||
|
if (!selectedList) { |
||||||
|
this.showError('Reading list not found'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if article is already in the list
|
||||||
|
if (selectedList.articles && selectedList.articles.includes(this.coordinateValue)) { |
||||||
|
this.showSuccess(`Already in "${title}"`); |
||||||
|
setTimeout(() => { |
||||||
|
this.hideStatus(); |
||||||
|
this.closeDropdown(); |
||||||
|
}, 2000); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Build the event skeleton for the updated reading list
|
||||||
|
const eventSkeleton = await this.buildReadingListEvent(selectedList); |
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
this.showStatus(`Signing update to "${title}"...`); |
||||||
|
const signedEvent = await window.nostr.signEvent(eventSkeleton); |
||||||
|
|
||||||
|
// Publish the event
|
||||||
|
this.showStatus(`Publishing update...`); |
||||||
|
await this.publishEvent(signedEvent); |
||||||
|
|
||||||
|
this.showSuccess(`✓ Added to "${title}"`); |
||||||
|
|
||||||
|
// Close dropdown after success and reload to update the UI
|
||||||
|
setTimeout(() => { |
||||||
|
this.hideStatus(); |
||||||
|
this.closeDropdown(); |
||||||
|
// Reload the page to show updated state
|
||||||
|
window.location.reload(); |
||||||
|
}, 1500); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Error adding to reading list:', error); |
||||||
|
this.showError(error.message || 'Failed to add article'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async buildReadingListEvent(listData) { |
||||||
|
const pubkey = await window.nostr.getPublicKey(); |
||||||
|
|
||||||
|
// Build tags array
|
||||||
|
const tags = []; |
||||||
|
tags.push(['d', listData.slug]); |
||||||
|
tags.push(['type', 'reading-list']); |
||||||
|
tags.push(['title', listData.title]); |
||||||
|
|
||||||
|
if (listData.summary) { |
||||||
|
tags.push(['summary', listData.summary]); |
||||||
|
} |
||||||
|
|
||||||
|
// Add existing articles (avoid duplicates)
|
||||||
|
const articleSet = new Set(); |
||||||
|
if (listData.articles && Array.isArray(listData.articles)) { |
||||||
|
listData.articles.forEach(coord => { |
||||||
|
if (coord && typeof coord === 'string') { |
||||||
|
articleSet.add(coord); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Add the new article
|
||||||
|
if (this.coordinateValue) { |
||||||
|
articleSet.add(this.coordinateValue); |
||||||
|
} |
||||||
|
|
||||||
|
// Convert set to tags
|
||||||
|
articleSet.forEach(coord => { |
||||||
|
tags.push(['a', coord]); |
||||||
|
}); |
||||||
|
|
||||||
|
return { |
||||||
|
kind: 30040, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags: tags, |
||||||
|
content: '', |
||||||
|
pubkey: pubkey |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
async publishEvent(signedEvent) { |
||||||
|
const response = await fetch(this.publishUrlValue, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
'X-CSRF-TOKEN': this.csrfTokenValue, |
||||||
|
'X-Requested-With': 'XMLHttpRequest' |
||||||
|
}, |
||||||
|
body: JSON.stringify({ event: signedEvent }) |
||||||
|
}); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const data = await response.json().catch(() => ({})); |
||||||
|
throw new Error(data.error || `HTTP ${response.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return response.json(); |
||||||
|
} |
||||||
|
|
||||||
|
showStatus(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.className = 'alert alert-info small mt-2 mb-0'; |
||||||
|
this.statusTarget.textContent = message; |
||||||
|
this.statusTarget.style.display = 'block'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showSuccess(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.className = 'alert alert-success small mt-2 mb-0'; |
||||||
|
this.statusTarget.textContent = message; |
||||||
|
this.statusTarget.style.display = 'block'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
showError(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.className = 'alert alert-danger small mt-2 mb-0'; |
||||||
|
this.statusTarget.textContent = message; |
||||||
|
this.statusTarget.style.display = 'block'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
hideStatus() { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.style.display = 'none'; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,198 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Workflow Progress Bar Controller |
||||||
|
* |
||||||
|
* Handles animated progress bar with color transitions and status updates. |
||||||
|
* |
||||||
|
* Usage: |
||||||
|
* <div data-controller="workflow-progress" |
||||||
|
* data-workflow-progress-percentage-value="80" |
||||||
|
* data-workflow-progress-status-value="ready_for_review" |
||||||
|
* data-workflow-progress-color-value="success"> |
||||||
|
* </div> |
||||||
|
*/ |
||||||
|
export default class extends Controller { |
||||||
|
static values = { |
||||||
|
percentage: { type: Number, default: 0 }, |
||||||
|
status: { type: String, default: 'empty' }, |
||||||
|
color: { type: String, default: 'secondary' }, |
||||||
|
animated: { type: Boolean, default: true } |
||||||
|
} |
||||||
|
|
||||||
|
static targets = ['bar', 'badge', 'statusText', 'nextSteps'] |
||||||
|
|
||||||
|
connect() { |
||||||
|
this.updateProgress(); |
||||||
|
} |
||||||
|
|
||||||
|
percentageValueChanged() { |
||||||
|
this.updateProgress(); |
||||||
|
} |
||||||
|
|
||||||
|
statusValueChanged() { |
||||||
|
this.updateStatusDisplay(); |
||||||
|
} |
||||||
|
|
||||||
|
colorValueChanged() { |
||||||
|
this.updateBarColor(); |
||||||
|
} |
||||||
|
|
||||||
|
updateProgress() { |
||||||
|
if (!this.hasBarTarget) return; |
||||||
|
|
||||||
|
const percentage = this.percentageValue; |
||||||
|
|
||||||
|
if (this.animatedValue) { |
||||||
|
// Smooth animation
|
||||||
|
this.animateProgressBar(percentage); |
||||||
|
} else { |
||||||
|
// Instant update
|
||||||
|
this.barTarget.style.width = `${percentage}%`; |
||||||
|
this.barTarget.setAttribute('aria-valuenow', percentage); |
||||||
|
} |
||||||
|
|
||||||
|
// Update accessibility
|
||||||
|
this.updateAriaLabel(); |
||||||
|
} |
||||||
|
|
||||||
|
animateProgressBar(targetPercentage) { |
||||||
|
const currentPercentage = parseInt(this.barTarget.style.width) || 0; |
||||||
|
const duration = 600; // ms
|
||||||
|
const steps = 30; |
||||||
|
const increment = (targetPercentage - currentPercentage) / steps; |
||||||
|
const stepDuration = duration / steps; |
||||||
|
|
||||||
|
let currentStep = 0; |
||||||
|
|
||||||
|
const animate = () => { |
||||||
|
if (currentStep >= steps) { |
||||||
|
this.barTarget.style.width = `${targetPercentage}%`; |
||||||
|
this.barTarget.setAttribute('aria-valuenow', targetPercentage); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const newPercentage = currentPercentage + (increment * currentStep); |
||||||
|
this.barTarget.style.width = `${newPercentage}%`; |
||||||
|
this.barTarget.setAttribute('aria-valuenow', Math.round(newPercentage)); |
||||||
|
|
||||||
|
currentStep++; |
||||||
|
requestAnimationFrame(() => { |
||||||
|
setTimeout(animate, stepDuration); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
animate(); |
||||||
|
} |
||||||
|
|
||||||
|
updateBarColor() { |
||||||
|
if (!this.hasBarTarget) return; |
||||||
|
|
||||||
|
const colorClasses = [ |
||||||
|
'bg-secondary', 'bg-info', 'bg-primary', |
||||||
|
'bg-success', 'bg-warning', 'bg-danger' |
||||||
|
]; |
||||||
|
|
||||||
|
// Remove all color classes
|
||||||
|
colorClasses.forEach(cls => this.barTarget.classList.remove(cls)); |
||||||
|
|
||||||
|
// Add new color class
|
||||||
|
this.barTarget.classList.add(`bg-${this.colorValue}`); |
||||||
|
} |
||||||
|
|
||||||
|
updateStatusDisplay() { |
||||||
|
if (this.hasBadgeTarget) { |
||||||
|
const statusMessages = this.getStatusMessage(this.statusValue); |
||||||
|
this.badgeTarget.textContent = statusMessages.short; |
||||||
|
} |
||||||
|
|
||||||
|
if (this.hasStatusTextTarget) { |
||||||
|
const statusMessages = this.getStatusMessage(this.statusValue); |
||||||
|
this.statusTextTarget.textContent = statusMessages.long; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
updateAriaLabel() { |
||||||
|
if (!this.hasBarTarget) return; |
||||||
|
|
||||||
|
const percentage = this.percentageValue; |
||||||
|
const statusMessages = this.getStatusMessage(this.statusValue); |
||||||
|
const label = `${statusMessages.short}: ${percentage}% complete`; |
||||||
|
|
||||||
|
this.barTarget.setAttribute('aria-label', label); |
||||||
|
} |
||||||
|
|
||||||
|
getStatusMessage(status) { |
||||||
|
const messages = { |
||||||
|
'empty': { |
||||||
|
short: 'Not started', |
||||||
|
long: 'Reading list not started yet' |
||||||
|
}, |
||||||
|
'draft': { |
||||||
|
short: 'Draft created', |
||||||
|
long: 'Draft created, add content to continue' |
||||||
|
}, |
||||||
|
'has_metadata': { |
||||||
|
short: 'Title and summary added', |
||||||
|
long: 'Metadata complete, add articles next' |
||||||
|
}, |
||||||
|
'has_articles': { |
||||||
|
short: 'Articles added', |
||||||
|
long: 'Articles added, checking requirements' |
||||||
|
}, |
||||||
|
'ready_for_review': { |
||||||
|
short: 'Ready to publish', |
||||||
|
long: 'Your reading list is ready to publish' |
||||||
|
}, |
||||||
|
'publishing': { |
||||||
|
short: 'Publishing...', |
||||||
|
long: 'Publishing to Nostr, please wait' |
||||||
|
}, |
||||||
|
'published': { |
||||||
|
short: 'Published', |
||||||
|
long: 'Successfully published to Nostr' |
||||||
|
}, |
||||||
|
'editing': { |
||||||
|
short: 'Editing', |
||||||
|
long: 'Editing published reading list' |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
return messages[status] || messages['empty']; |
||||||
|
} |
||||||
|
|
||||||
|
// Public methods that can be called from other controllers
|
||||||
|
setPercentage(percentage) { |
||||||
|
this.percentageValue = percentage; |
||||||
|
} |
||||||
|
|
||||||
|
setStatus(status) { |
||||||
|
this.statusValue = status; |
||||||
|
} |
||||||
|
|
||||||
|
setColor(color) { |
||||||
|
this.colorValue = color; |
||||||
|
} |
||||||
|
|
||||||
|
pulse() { |
||||||
|
if (!this.hasBarTarget) return; |
||||||
|
|
||||||
|
this.barTarget.classList.add('workflow-progress-pulse'); |
||||||
|
setTimeout(() => { |
||||||
|
this.barTarget.classList.remove('workflow-progress-pulse'); |
||||||
|
}, 1000); |
||||||
|
} |
||||||
|
|
||||||
|
celebrate() { |
||||||
|
if (!this.hasBarTarget) return; |
||||||
|
|
||||||
|
// Add celebration animation when reaching 100%
|
||||||
|
if (this.percentageValue === 100) { |
||||||
|
this.barTarget.classList.add('workflow-progress-celebrate'); |
||||||
|
setTimeout(() => { |
||||||
|
this.barTarget.classList.remove('workflow-progress-celebrate'); |
||||||
|
}, 2000); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,228 @@ |
|||||||
|
/* Dropdown Component Styles */ |
||||||
|
|
||||||
|
.dropdown { |
||||||
|
position: relative; |
||||||
|
display: inline-block; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle { |
||||||
|
cursor: pointer; |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle::after { |
||||||
|
display: inline-block; |
||||||
|
margin-left: 0.5em; |
||||||
|
vertical-align: 0.125em; |
||||||
|
content: ""; |
||||||
|
border-top: 0.3em solid; |
||||||
|
border-right: 0.3em solid transparent; |
||||||
|
border-bottom: 0; |
||||||
|
border-left: 0.3em solid transparent; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-toggle:hover { |
||||||
|
opacity: 0.9; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu { |
||||||
|
position: absolute; |
||||||
|
top: 100%; |
||||||
|
left: 0; |
||||||
|
z-index: 1000; |
||||||
|
display: none; |
||||||
|
min-width: 280px; |
||||||
|
padding: 0.5rem 0; |
||||||
|
margin: 0.125rem 0 0; |
||||||
|
font-size: 1rem; |
||||||
|
color: var(--text-primary, #212529); |
||||||
|
text-align: left; |
||||||
|
list-style: none; |
||||||
|
background-color: var(--surface, #fff); |
||||||
|
background-clip: padding-box; |
||||||
|
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.15)); |
||||||
|
border-radius: 0.375rem; |
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.175); |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu.show { |
||||||
|
display: block; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item { |
||||||
|
display: block; |
||||||
|
width: 100%; |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
clear: both; |
||||||
|
font-weight: 400; |
||||||
|
color: var(--text-primary, #212529); |
||||||
|
text-align: inherit; |
||||||
|
text-decoration: none; |
||||||
|
white-space: nowrap; |
||||||
|
background-color: transparent; |
||||||
|
border: 0; |
||||||
|
cursor: pointer; |
||||||
|
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item:hover, |
||||||
|
.dropdown-item:focus { |
||||||
|
color: var(--text-primary, #1e2125); |
||||||
|
background-color: var(--surface-hover, #e9ecef); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item:active { |
||||||
|
color: var(--text-on-primary, #fff); |
||||||
|
background-color: var(--primary, #0d6efd); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item.disabled, |
||||||
|
.dropdown-item:disabled { |
||||||
|
color: var(--text-muted, #6c757d); |
||||||
|
pointer-events: none; |
||||||
|
background-color: transparent; |
||||||
|
cursor: not-allowed; |
||||||
|
opacity: 0.65; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-header { |
||||||
|
display: block; |
||||||
|
padding: 0.5rem 1rem; |
||||||
|
margin-bottom: 0; |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--text-muted, #6c757d); |
||||||
|
white-space: nowrap; |
||||||
|
font-weight: 600; |
||||||
|
text-transform: uppercase; |
||||||
|
letter-spacing: 0.05em; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-divider { |
||||||
|
height: 0; |
||||||
|
margin: 0.5rem 0; |
||||||
|
overflow: hidden; |
||||||
|
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.15)); |
||||||
|
} |
||||||
|
|
||||||
|
/* Dropdown menu positioning variants */ |
||||||
|
.dropdown-menu-end { |
||||||
|
right: 0; |
||||||
|
left: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu-start { |
||||||
|
right: auto; |
||||||
|
left: 0; |
||||||
|
} |
||||||
|
|
||||||
|
/* Reading List Dropdown Specific Styles */ |
||||||
|
.dropdown-item .d-flex { |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item strong { |
||||||
|
font-size: 0.95rem; |
||||||
|
color: var(--text-primary, #212529); |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item small { |
||||||
|
font-size: 0.8rem; |
||||||
|
line-height: 1.3; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item .badge { |
||||||
|
font-size: 0.75rem; |
||||||
|
padding: 0.25em 0.5em; |
||||||
|
} |
||||||
|
|
||||||
|
/* Status alerts inside dropdown */ |
||||||
|
.dropdown + [data-reading-list-dropdown-target="status"] { |
||||||
|
margin-top: 0.5rem; |
||||||
|
padding: 0.5rem 0.75rem; |
||||||
|
border-radius: 0.25rem; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Responsive adjustments */ |
||||||
|
@media (max-width: 768px) { |
||||||
|
.dropdown-menu { |
||||||
|
min-width: 240px; |
||||||
|
max-width: 90vw; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item { |
||||||
|
padding: 0.75rem 1rem; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* Dark mode support */ |
||||||
|
@media (prefers-color-scheme: dark) { |
||||||
|
.dropdown-menu { |
||||||
|
background-color: var(--surface-dark, #2b2b2b); |
||||||
|
border-color: var(--border-color-dark, rgba(255, 255, 255, 0.15)); |
||||||
|
color: var(--text-primary-dark, #e9ecef); |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item { |
||||||
|
color: var(--text-primary-dark, #e9ecef); |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item:hover, |
||||||
|
.dropdown-item:focus { |
||||||
|
background-color: var(--surface-hover-dark, #3b3b3b); |
||||||
|
color: var(--text-primary-dark, #fff); |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item strong { |
||||||
|
color: var(--text-primary-dark, #fff); |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-header { |
||||||
|
color: var(--text-muted-dark, #adb5bd); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* Animation for dropdown appearance */ |
||||||
|
@keyframes dropdown-fade-in { |
||||||
|
from { |
||||||
|
opacity: 0; |
||||||
|
transform: translateY(-10px); |
||||||
|
} |
||||||
|
to { |
||||||
|
opacity: 1; |
||||||
|
transform: translateY(0); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-menu.show { |
||||||
|
animation: dropdown-fade-in 0.15s ease-out; |
||||||
|
} |
||||||
|
|
||||||
|
/* Loading state */ |
||||||
|
.dropdown-item.loading { |
||||||
|
pointer-events: none; |
||||||
|
opacity: 0.6; |
||||||
|
position: relative; |
||||||
|
} |
||||||
|
|
||||||
|
.dropdown-item.loading::after { |
||||||
|
content: ""; |
||||||
|
position: absolute; |
||||||
|
right: 1rem; |
||||||
|
top: 50%; |
||||||
|
transform: translateY(-50%); |
||||||
|
width: 1rem; |
||||||
|
height: 1rem; |
||||||
|
border: 2px solid var(--text-muted, #6c757d); |
||||||
|
border-top-color: transparent; |
||||||
|
border-radius: 50%; |
||||||
|
animation: spinner-rotate 0.6s linear infinite; |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes spinner-rotate { |
||||||
|
to { |
||||||
|
transform: translateY(-50%) rotate(360deg); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,119 @@ |
|||||||
|
/* Reading List Workflow Styles */ |
||||||
|
|
||||||
|
/* Workflow Status Component */ |
||||||
|
.workflow-status-card { |
||||||
|
padding: 1rem; |
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); |
||||||
|
border-radius: 8px; |
||||||
|
border: 1px solid #dee2e6; |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-status-card .progress { |
||||||
|
border-radius: 4px; |
||||||
|
background-color: #e9ecef; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-status-card .progress-bar { |
||||||
|
transition: width 0.6s ease-in-out, background-color 0.3s ease; |
||||||
|
} |
||||||
|
|
||||||
|
/* Pulse animation for progress bar updates */ |
||||||
|
@keyframes workflow-pulse { |
||||||
|
0%, 100% { |
||||||
|
opacity: 1; |
||||||
|
transform: scaleY(1); |
||||||
|
} |
||||||
|
50% { |
||||||
|
opacity: 0.8; |
||||||
|
transform: scaleY(1.1); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-progress-pulse { |
||||||
|
animation: workflow-pulse 0.5s ease-in-out; |
||||||
|
} |
||||||
|
|
||||||
|
/* Celebration animation when reaching 100% */ |
||||||
|
@keyframes workflow-celebrate { |
||||||
|
0%, 100% { |
||||||
|
transform: scaleX(1); |
||||||
|
} |
||||||
|
25% { |
||||||
|
transform: scaleX(1.02); |
||||||
|
} |
||||||
|
50% { |
||||||
|
transform: scaleX(0.98); |
||||||
|
} |
||||||
|
75% { |
||||||
|
transform: scaleX(1.01); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-progress-celebrate { |
||||||
|
animation: workflow-celebrate 0.6s ease-in-out; |
||||||
|
} |
||||||
|
|
||||||
|
/* Shimmer effect for publishing state */ |
||||||
|
@keyframes workflow-shimmer { |
||||||
|
0% { |
||||||
|
background-position: -100% 0; |
||||||
|
} |
||||||
|
100% { |
||||||
|
background-position: 100% 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-status-card .progress-bar.bg-warning { |
||||||
|
background: linear-gradient( |
||||||
|
90deg, |
||||||
|
#ffc107 0%, |
||||||
|
#ffeb3b 50%, |
||||||
|
#ffc107 100% |
||||||
|
); |
||||||
|
background-size: 200% 100%; |
||||||
|
animation: workflow-shimmer 2s ease-in-out infinite; |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-status-card .next-steps ul { |
||||||
|
padding-left: 1.25rem; |
||||||
|
margin-bottom: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-status-card .next-steps li { |
||||||
|
color: #495057; |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-state-info { |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Reading List Selector */ |
||||||
|
.reading-list-selector { |
||||||
|
max-width: 500px; |
||||||
|
} |
||||||
|
|
||||||
|
.reading-list-selector .form-select { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.reading-list-selector .alert-info { |
||||||
|
border-left: 3px solid #0dcaf0; |
||||||
|
} |
||||||
|
|
||||||
|
/* Floating Quick Add Widget */ |
||||||
|
.reading-list-quick-add { |
||||||
|
position: fixed; |
||||||
|
bottom: 20px; |
||||||
|
right: 20px; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
/* Badge animations */ |
||||||
|
.workflow-status-card .badge { |
||||||
|
transition: all 0.3s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.workflow-status-card .badge:hover { |
||||||
|
transform: scale(1.05); |
||||||
|
} |
||||||
@ -0,0 +1,303 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use App\Entity\Event; |
||||||
|
use Doctrine\ORM\EntityManagerInterface; |
||||||
|
use swentel\nostr\Key\Key; |
||||||
|
use Symfony\Component\HttpFoundation\RequestStack; |
||||||
|
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for managing reading list drafts and published lists |
||||||
|
*/ |
||||||
|
class ReadingListManager |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly EntityManagerInterface $em, |
||||||
|
private readonly TokenStorageInterface $tokenStorage, |
||||||
|
private readonly RequestStack $requestStack, |
||||||
|
private readonly ReadingListWorkflowService $workflowService, |
||||||
|
) {} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all published reading lists for the current user |
||||||
|
* @return array<array{id: int, title: string, summary: ?string, slug: string, createdAt: \DateTimeInterface, pubkey: string, articleCount: int}> |
||||||
|
*/ |
||||||
|
public function getUserReadingLists(): array |
||||||
|
{ |
||||||
|
$lists = []; |
||||||
|
$user = $this->tokenStorage->getToken()?->getUser(); |
||||||
|
|
||||||
|
if (!$user) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
$key = new Key(); |
||||||
|
$pubkeyHex = $key->convertToHex($user->getUserIdentifier()); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$repo = $this->em->getRepository(Event::class); |
||||||
|
$events = $repo->findBy(['kind' => 30040, 'pubkey' => $pubkeyHex], ['created_at' => 'DESC']); |
||||||
|
$seenSlugs = []; |
||||||
|
|
||||||
|
foreach ($events as $ev) { |
||||||
|
if (!$ev instanceof Event) continue; |
||||||
|
$tags = $ev->getTags(); |
||||||
|
$isReadingList = false; |
||||||
|
$title = null; |
||||||
|
$slug = null; |
||||||
|
$summary = null; |
||||||
|
$articleCount = 0; |
||||||
|
|
||||||
|
foreach ($tags as $t) { |
||||||
|
if (is_array($t)) { |
||||||
|
if (($t[0] ?? null) === 'type' && ($t[1] ?? null) === 'reading-list') { |
||||||
|
$isReadingList = true; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'title') { |
||||||
|
$title = (string)$t[1]; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'summary') { |
||||||
|
$summary = (string)$t[1]; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'd') { |
||||||
|
$slug = (string)$t[1]; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'a') { |
||||||
|
$articleCount++; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($isReadingList) { |
||||||
|
// Collapse by slug: keep only newest per slug |
||||||
|
$keySlug = $slug ?: ('__no_slug__:' . $ev->getId()); |
||||||
|
if (isset($seenSlugs[$slug ?? $keySlug])) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$seenSlugs[$slug ?? $keySlug] = true; |
||||||
|
|
||||||
|
$lists[] = [ |
||||||
|
'id' => $ev->getId(), |
||||||
|
'title' => $title ?: '(untitled)', |
||||||
|
'summary' => $summary, |
||||||
|
'slug' => $slug, |
||||||
|
'createdAt' => $ev->getCreatedAt(), |
||||||
|
'pubkey' => $ev->getPubkey(), |
||||||
|
'articleCount' => $articleCount, |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $lists; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the current draft reading list from session |
||||||
|
*/ |
||||||
|
public function getCurrentDraft(): ?CategoryDraft |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$data = $session->get('read_wizard'); |
||||||
|
return $data instanceof CategoryDraft ? $data : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get the currently selected reading list slug (or null for new draft) |
||||||
|
*/ |
||||||
|
public function getSelectedListSlug(): ?string |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
return $session->get('selected_reading_list_slug'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set which reading list is currently selected |
||||||
|
*/ |
||||||
|
public function setSelectedListSlug(?string $slug): void |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
if ($slug === null) { |
||||||
|
$session->remove('selected_reading_list_slug'); |
||||||
|
} else { |
||||||
|
$session->set('selected_reading_list_slug', $slug); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Load an existing published reading list into the draft |
||||||
|
*/ |
||||||
|
public function loadPublishedListIntoDraft(string $slug): ?CategoryDraft |
||||||
|
{ |
||||||
|
$user = $this->tokenStorage->getToken()?->getUser(); |
||||||
|
if (!$user) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
$key = new Key(); |
||||||
|
$pubkeyHex = $key->convertToHex($user->getUserIdentifier()); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
$repo = $this->em->getRepository(Event::class); |
||||||
|
$events = $repo->findBy(['kind' => 30040, 'pubkey' => $pubkeyHex], ['created_at' => 'DESC']); |
||||||
|
|
||||||
|
foreach ($events as $ev) { |
||||||
|
if (!$ev instanceof Event) continue; |
||||||
|
$tags = $ev->getTags(); |
||||||
|
$isReadingList = false; |
||||||
|
$eventSlug = null; |
||||||
|
|
||||||
|
// First pass: check if this is the right event |
||||||
|
foreach ($tags as $t) { |
||||||
|
if (is_array($t)) { |
||||||
|
if (($t[0] ?? null) === 'd') { |
||||||
|
$eventSlug = (string)$t[1]; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'type' && ($t[1] ?? null) === 'reading-list') { |
||||||
|
$isReadingList = true; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($isReadingList && $eventSlug === $slug) { |
||||||
|
// Found it! Parse into CategoryDraft |
||||||
|
$draft = new CategoryDraft(); |
||||||
|
$draft->slug = $slug; |
||||||
|
|
||||||
|
foreach ($tags as $t) { |
||||||
|
if (!is_array($t)) continue; |
||||||
|
$tagName = $t[0] ?? null; |
||||||
|
$tagValue = $t[1] ?? null; |
||||||
|
|
||||||
|
match ($tagName) { |
||||||
|
'title' => $draft->title = (string)$tagValue, |
||||||
|
'summary' => $draft->summary = (string)$tagValue, |
||||||
|
't' => $draft->tags[] = (string)$tagValue, |
||||||
|
'a' => $draft->articles[] = (string)$tagValue, |
||||||
|
default => null, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// Save to session |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
$this->setSelectedListSlug($slug); |
||||||
|
|
||||||
|
return $draft; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a new draft reading list |
||||||
|
*/ |
||||||
|
public function createNewDraft(): CategoryDraft |
||||||
|
{ |
||||||
|
$draft = new CategoryDraft(); |
||||||
|
$draft->title = 'My Reading List'; |
||||||
|
$draft->slug = substr(bin2hex(random_bytes(6)), 0, 8); |
||||||
|
|
||||||
|
// Initialize workflow |
||||||
|
$this->workflowService->initializeDraft($draft); |
||||||
|
|
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
$this->setSelectedListSlug(null); // null = new draft |
||||||
|
|
||||||
|
return $draft; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update draft metadata and advance workflow |
||||||
|
*/ |
||||||
|
public function updateDraftMetadata(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
$this->workflowService->updateMetadata($draft); |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add articles to draft and advance workflow |
||||||
|
*/ |
||||||
|
public function addArticlesToDraft(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
$this->workflowService->addArticles($draft); |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Mark draft as ready for review |
||||||
|
*/ |
||||||
|
public function markReadyForReview(CategoryDraft $draft): bool |
||||||
|
{ |
||||||
|
$result = $this->workflowService->markReadyForReview($draft); |
||||||
|
if ($result) { |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
} |
||||||
|
return $result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get article coordinates for a specific reading list by slug |
||||||
|
*/ |
||||||
|
public function getArticleCoordinatesForList(string $slug): array |
||||||
|
{ |
||||||
|
$user = $this->tokenStorage->getToken()?->getUser(); |
||||||
|
if (!$user) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
$key = new Key(); |
||||||
|
$pubkeyHex = $key->convertToHex($user->getUserIdentifier()); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
$repo = $this->em->getRepository(Event::class); |
||||||
|
$events = $repo->findBy(['kind' => 30040, 'pubkey' => $pubkeyHex], ['created_at' => 'DESC']); |
||||||
|
|
||||||
|
foreach ($events as $ev) { |
||||||
|
if (!$ev instanceof Event) continue; |
||||||
|
|
||||||
|
$eventSlug = null; |
||||||
|
$isReadingList = false; |
||||||
|
$articles = []; |
||||||
|
|
||||||
|
foreach ($ev->getTags() as $t) { |
||||||
|
if (!is_array($t)) continue; |
||||||
|
|
||||||
|
if (($t[0] ?? null) === 'd') { |
||||||
|
$eventSlug = (string)$t[1]; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'type' && ($t[1] ?? null) === 'reading-list') { |
||||||
|
$isReadingList = true; |
||||||
|
} |
||||||
|
if (($t[0] ?? null) === 'a') { |
||||||
|
$articles[] = (string)$t[1]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($isReadingList && $eventSlug === $slug) { |
||||||
|
return $articles; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return []; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,208 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Service; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Component\Workflow\WorkflowInterface; |
||||||
|
|
||||||
|
/** |
||||||
|
* Service for managing reading list workflow transitions |
||||||
|
*/ |
||||||
|
class ReadingListWorkflowService |
||||||
|
{ |
||||||
|
public function __construct( |
||||||
|
private readonly WorkflowInterface $readingListWorkflow, |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
) {} |
||||||
|
|
||||||
|
/** |
||||||
|
* Initialize a new reading list draft |
||||||
|
*/ |
||||||
|
public function initializeDraft(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if ($this->readingListWorkflow->can($draft, 'start_draft')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'start_draft'); |
||||||
|
$this->logger->info('Reading list workflow: started draft', [ |
||||||
|
'slug' => $draft->slug |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Update metadata (title/summary) and transition if needed |
||||||
|
*/ |
||||||
|
public function updateMetadata(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if ($draft->title !== '' && $this->readingListWorkflow->can($draft, 'add_metadata')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'add_metadata'); |
||||||
|
$this->logger->info('Reading list workflow: metadata added', [ |
||||||
|
'slug' => $draft->slug, |
||||||
|
'title' => $draft->title |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Add articles and transition if needed |
||||||
|
*/ |
||||||
|
public function addArticles(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if (!empty($draft->articles) && $this->readingListWorkflow->can($draft, 'add_articles')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'add_articles'); |
||||||
|
$this->logger->info('Reading list workflow: articles added', [ |
||||||
|
'slug' => $draft->slug, |
||||||
|
'count' => count($draft->articles) |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Mark as ready for review |
||||||
|
*/ |
||||||
|
public function markReadyForReview(CategoryDraft $draft): bool |
||||||
|
{ |
||||||
|
if ($this->readingListWorkflow->can($draft, 'ready_for_review')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'ready_for_review'); |
||||||
|
$this->logger->info('Reading list workflow: ready for review', [ |
||||||
|
'slug' => $draft->slug |
||||||
|
]); |
||||||
|
return true; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Start the publishing process |
||||||
|
*/ |
||||||
|
public function startPublishing(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if ($this->readingListWorkflow->can($draft, 'start_publishing')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'start_publishing'); |
||||||
|
$this->logger->info('Reading list workflow: publishing started', [ |
||||||
|
'slug' => $draft->slug |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Complete the publishing process |
||||||
|
*/ |
||||||
|
public function completePublishing(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if ($this->readingListWorkflow->can($draft, 'complete_publishing')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'complete_publishing'); |
||||||
|
$this->logger->info('Reading list workflow: published', [ |
||||||
|
'slug' => $draft->slug |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Edit a published reading list |
||||||
|
*/ |
||||||
|
public function editPublished(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if ($this->readingListWorkflow->can($draft, 'edit_published')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'edit_published'); |
||||||
|
$this->logger->info('Reading list workflow: editing published list', [ |
||||||
|
'slug' => $draft->slug |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Cancel the draft |
||||||
|
*/ |
||||||
|
public function cancel(CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
if ($this->readingListWorkflow->can($draft, 'cancel')) { |
||||||
|
$this->readingListWorkflow->apply($draft, 'cancel'); |
||||||
|
$this->logger->info('Reading list workflow: cancelled', [ |
||||||
|
'slug' => $draft->slug |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get current state of the reading list |
||||||
|
*/ |
||||||
|
public function getCurrentState(CategoryDraft $draft): string |
||||||
|
{ |
||||||
|
return $draft->getWorkflowState(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get available transitions |
||||||
|
* @return array<string> |
||||||
|
*/ |
||||||
|
public function getAvailableTransitions(CategoryDraft $draft): array |
||||||
|
{ |
||||||
|
return $this->readingListWorkflow->getEnabledTransitions($draft); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if draft is ready to publish |
||||||
|
*/ |
||||||
|
public function isReadyToPublish(CategoryDraft $draft): bool |
||||||
|
{ |
||||||
|
return $this->readingListWorkflow->can($draft, 'start_publishing'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get a human-readable status message |
||||||
|
*/ |
||||||
|
public function getStatusMessage(CategoryDraft $draft): string |
||||||
|
{ |
||||||
|
return match ($draft->getWorkflowState()) { |
||||||
|
'empty' => 'Not started', |
||||||
|
'draft' => 'Draft created', |
||||||
|
'has_metadata' => 'Title and summary added', |
||||||
|
'has_articles' => 'Articles added', |
||||||
|
'ready_for_review' => 'Ready to publish', |
||||||
|
'publishing' => 'Publishing...', |
||||||
|
'published' => 'Published', |
||||||
|
'editing' => 'Editing published list', |
||||||
|
default => 'Unknown state', |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get a badge color for the current state |
||||||
|
*/ |
||||||
|
public function getStateBadgeColor(CategoryDraft $draft): string |
||||||
|
{ |
||||||
|
return match ($draft->getWorkflowState()) { |
||||||
|
'empty' => 'secondary', |
||||||
|
'draft' => 'info', |
||||||
|
'has_metadata' => 'info', |
||||||
|
'has_articles' => 'primary', |
||||||
|
'ready_for_review' => 'success', |
||||||
|
'publishing' => 'warning', |
||||||
|
'published' => 'success', |
||||||
|
'editing' => 'warning', |
||||||
|
default => 'secondary', |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get completion percentage (for progress bar) |
||||||
|
*/ |
||||||
|
public function getCompletionPercentage(CategoryDraft $draft): int |
||||||
|
{ |
||||||
|
return match ($draft->getWorkflowState()) { |
||||||
|
'empty' => 0, |
||||||
|
'draft' => 20, |
||||||
|
'has_metadata' => 40, |
||||||
|
'has_articles' => 60, |
||||||
|
'ready_for_review' => 80, |
||||||
|
'publishing' => 90, |
||||||
|
'published' => 100, |
||||||
|
'editing' => 50, |
||||||
|
default => 0, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,40 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Service\ReadingListManager; |
||||||
|
use Symfony\Bundle\SecurityBundle\Security; |
||||||
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||||
|
|
||||||
|
#[AsTwigComponent] |
||||||
|
final class ReadingListDropdown |
||||||
|
{ |
||||||
|
public string $coordinate = ''; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly ReadingListManager $readingListManager, |
||||||
|
private readonly Security $security, |
||||||
|
) {} |
||||||
|
|
||||||
|
public function getUserLists(): array |
||||||
|
{ |
||||||
|
if (!$this->security->getUser()) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
return $this->readingListManager->getUserReadingLists(); |
||||||
|
} |
||||||
|
|
||||||
|
public function getListsWithArticles(): array |
||||||
|
{ |
||||||
|
$lists = $this->getUserLists(); |
||||||
|
|
||||||
|
// Fetch full article data for each list |
||||||
|
foreach ($lists as &$list) { |
||||||
|
$list['articles'] = $this->readingListManager->getArticleCoordinatesForList($list['slug']); |
||||||
|
} |
||||||
|
|
||||||
|
return $lists; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,176 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use App\Service\NostrClient; |
||||||
|
use nostriphant\NIP19\Bech32; |
||||||
|
use nostriphant\NIP19\Data\NAddr; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Component\HttpFoundation\RequestStack; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveListener; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
||||||
|
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||||
|
|
||||||
|
/** |
||||||
|
* A floating widget to quickly add articles to the reading list from anywhere |
||||||
|
*/ |
||||||
|
#[AsLiveComponent] |
||||||
|
final class ReadingListQuickAddComponent |
||||||
|
{ |
||||||
|
use DefaultActionTrait; |
||||||
|
|
||||||
|
#[LiveProp(writable: true)] |
||||||
|
public string $input = ''; |
||||||
|
|
||||||
|
#[LiveProp] |
||||||
|
public string $error = ''; |
||||||
|
|
||||||
|
#[LiveProp] |
||||||
|
public string $success = ''; |
||||||
|
|
||||||
|
#[LiveProp] |
||||||
|
public int $itemCount = 0; |
||||||
|
|
||||||
|
#[LiveProp(writable: true)] |
||||||
|
public bool $isExpanded = false; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly RequestStack $requestStack, |
||||||
|
private readonly NostrClient $nostrClient, |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
) {} |
||||||
|
|
||||||
|
public function mount(): void |
||||||
|
{ |
||||||
|
$this->updateItemCount(); |
||||||
|
} |
||||||
|
|
||||||
|
#[LiveListener('readingListUpdated')] |
||||||
|
public function refresh(): void |
||||||
|
{ |
||||||
|
$this->updateItemCount(); |
||||||
|
$this->success = 'Added to reading list!'; |
||||||
|
} |
||||||
|
|
||||||
|
#[LiveAction] |
||||||
|
public function toggleExpanded(): void |
||||||
|
{ |
||||||
|
$this->isExpanded = !$this->isExpanded; |
||||||
|
} |
||||||
|
|
||||||
|
#[LiveAction] |
||||||
|
public function addItem(): void |
||||||
|
{ |
||||||
|
$this->error = ''; |
||||||
|
$this->success = ''; |
||||||
|
$raw = trim($this->input); |
||||||
|
|
||||||
|
if ($raw === '') { |
||||||
|
$this->error = 'Please enter an naddr or coordinate.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to parse as naddr first |
||||||
|
if (preg_match('/(naddr1[0-9a-zA-Z]+)/', $raw, $m)) { |
||||||
|
$this->addFromNaddr($m[1]); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to parse as coordinate (kind:pubkey:slug) |
||||||
|
if (preg_match('/^(\d+):([0-9a-f]{64}):(.+)$/i', $raw, $m)) { |
||||||
|
$kind = (int)$m[1]; |
||||||
|
$pubkey = $m[2]; |
||||||
|
$slug = $m[3]; |
||||||
|
$coordinate = "$kind:$pubkey:$slug"; |
||||||
|
$this->addCoordinate($coordinate); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$this->error = 'Invalid format. Use naddr or coordinate (kind:pubkey:slug).'; |
||||||
|
} |
||||||
|
|
||||||
|
private function addFromNaddr(string $naddr): void |
||||||
|
{ |
||||||
|
try { |
||||||
|
$decoded = new Bech32($naddr); |
||||||
|
if ($decoded->type !== 'naddr') { |
||||||
|
$this->error = 'Invalid naddr type.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
/** @var NAddr $data */ |
||||||
|
$data = $decoded->data; |
||||||
|
$slug = $data->identifier; |
||||||
|
$pubkey = $data->pubkey; |
||||||
|
$kind = $data->kind; |
||||||
|
$relays = $data->relays; |
||||||
|
|
||||||
|
if ($kind !== KindsEnum::LONGFORM->value) { |
||||||
|
$this->error = 'Not a long-form article (kind '.$kind.').'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$coordinate = $kind . ':' . $pubkey . ':' . $slug; |
||||||
|
|
||||||
|
// Attempt to fetch article so it exists locally |
||||||
|
try { |
||||||
|
$this->nostrClient->getLongFormFromNaddr($slug, $relays, $pubkey, $kind); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->logger->warning('Failed fetching article from naddr', [ |
||||||
|
'error' => $e->getMessage(), |
||||||
|
'naddr' => $naddr |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
$this->addCoordinate($coordinate); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->error = 'Failed to decode naddr.'; |
||||||
|
$this->logger->error('naddr decode failed', [ |
||||||
|
'input' => $naddr, |
||||||
|
'error' => $e->getMessage() |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function addCoordinate(string $coordinate): void |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$draft = $session->get('read_wizard'); |
||||||
|
|
||||||
|
if (!$draft instanceof CategoryDraft) { |
||||||
|
$draft = new CategoryDraft(); |
||||||
|
$draft->title = 'My Reading List'; |
||||||
|
$draft->slug = substr(bin2hex(random_bytes(6)), 0, 8); |
||||||
|
} |
||||||
|
|
||||||
|
if (in_array($coordinate, $draft->articles, true)) { |
||||||
|
$this->success = 'Already in reading list.'; |
||||||
|
$this->input = ''; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$draft->articles[] = $coordinate; |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
|
||||||
|
$this->success = 'Added to reading list!'; |
||||||
|
$this->input = ''; |
||||||
|
$this->updateItemCount(); |
||||||
|
} |
||||||
|
|
||||||
|
private function updateItemCount(): void |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$draft = $session->get('read_wizard'); |
||||||
|
|
||||||
|
if ($draft instanceof CategoryDraft) { |
||||||
|
$this->itemCount = count($draft->articles); |
||||||
|
} else { |
||||||
|
$this->itemCount = 0; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,172 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use App\Service\NostrClient; |
||||||
|
use nostriphant\NIP19\Bech32; |
||||||
|
use nostriphant\NIP19\Data\NAddr; |
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Component\HttpFoundation\RequestStack; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveListener; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
||||||
|
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||||
|
|
||||||
|
#[AsLiveComponent] |
||||||
|
final class ReadingListQuickInputComponent |
||||||
|
{ |
||||||
|
use DefaultActionTrait; |
||||||
|
|
||||||
|
#[LiveProp(writable: true)] |
||||||
|
public string $input = ''; |
||||||
|
|
||||||
|
#[LiveProp] |
||||||
|
public string $error = ''; |
||||||
|
|
||||||
|
#[LiveProp] |
||||||
|
public string $success = ''; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly RequestStack $requestStack, |
||||||
|
private readonly NostrClient $nostrClient, |
||||||
|
private readonly LoggerInterface $logger, |
||||||
|
) {} |
||||||
|
|
||||||
|
#[LiveAction] |
||||||
|
public function addMultiple(): void |
||||||
|
{ |
||||||
|
$this->error = ''; |
||||||
|
$this->success = ''; |
||||||
|
$raw = trim($this->input); |
||||||
|
|
||||||
|
if ($raw === '') { |
||||||
|
$this->error = 'Please enter at least one naddr or coordinate.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Split by newlines and process each line |
||||||
|
$lines = array_filter(array_map('trim', explode("\n", $raw))); |
||||||
|
$added = 0; |
||||||
|
$skipped = 0; |
||||||
|
$errors = []; |
||||||
|
|
||||||
|
foreach ($lines as $line) { |
||||||
|
$result = $this->processLine($line); |
||||||
|
if ($result['success']) { |
||||||
|
$added++; |
||||||
|
} elseif ($result['skipped']) { |
||||||
|
$skipped++; |
||||||
|
} else { |
||||||
|
$errors[] = $result['error']; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($added > 0) { |
||||||
|
$this->success = "Added $added article" . ($added > 1 ? 's' : '') . " to reading list."; |
||||||
|
if ($skipped > 0) { |
||||||
|
$this->success .= " ($skipped already in list)"; |
||||||
|
} |
||||||
|
$this->input = ''; |
||||||
|
} |
||||||
|
|
||||||
|
if (!empty($errors)) { |
||||||
|
$this->error = implode('; ', array_slice($errors, 0, 3)); |
||||||
|
if (count($errors) > 3) { |
||||||
|
$this->error .= ' (and ' . (count($errors) - 3) . ' more errors)'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($added > 0 || $skipped > 0) { |
||||||
|
// Trigger update for other components |
||||||
|
$this->dispatchBrowserEvent('readingListUpdated'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function processLine(string $line): array |
||||||
|
{ |
||||||
|
// Try to parse as naddr first |
||||||
|
if (preg_match('/(naddr1[0-9a-zA-Z]+)/', $line, $m)) { |
||||||
|
return $this->addFromNaddr($m[1]); |
||||||
|
} |
||||||
|
|
||||||
|
// Try to parse as coordinate (kind:pubkey:slug) |
||||||
|
if (preg_match('/^(\d+):([0-9a-f]{64}):(.+)$/i', $line, $m)) { |
||||||
|
$kind = (int)$m[1]; |
||||||
|
$pubkey = $m[2]; |
||||||
|
$slug = $m[3]; |
||||||
|
$coordinate = "$kind:$pubkey:$slug"; |
||||||
|
return $this->addCoordinate($coordinate); |
||||||
|
} |
||||||
|
|
||||||
|
return ['success' => false, 'skipped' => false, 'error' => "Invalid format: $line"]; |
||||||
|
} |
||||||
|
|
||||||
|
private function addFromNaddr(string $naddr): array |
||||||
|
{ |
||||||
|
try { |
||||||
|
$decoded = new Bech32($naddr); |
||||||
|
if ($decoded->type !== 'naddr') { |
||||||
|
return ['success' => false, 'skipped' => false, 'error' => 'Invalid naddr type']; |
||||||
|
} |
||||||
|
|
||||||
|
/** @var NAddr $data */ |
||||||
|
$data = $decoded->data; |
||||||
|
$slug = $data->identifier; |
||||||
|
$pubkey = $data->pubkey; |
||||||
|
$kind = $data->kind; |
||||||
|
$relays = $data->relays; |
||||||
|
|
||||||
|
if ($kind !== KindsEnum::LONGFORM->value) { |
||||||
|
return ['success' => false, 'skipped' => false, 'error' => "Not a long-form article (kind $kind)"]; |
||||||
|
} |
||||||
|
|
||||||
|
if (!$slug) { |
||||||
|
return ['success' => false, 'skipped' => false, 'error' => 'Missing identifier']; |
||||||
|
} |
||||||
|
|
||||||
|
$coordinate = $kind . ':' . $pubkey . ':' . $slug; |
||||||
|
|
||||||
|
// Attempt to fetch article so it exists locally (best-effort) |
||||||
|
try { |
||||||
|
$this->nostrClient->getLongFormFromNaddr($slug, $relays, $pubkey, $kind); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->logger->warning('Failed fetching article from naddr', [ |
||||||
|
'error' => $e->getMessage(), |
||||||
|
'naddr' => $naddr |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
return $this->addCoordinate($coordinate); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->logger->error('naddr decode failed', [ |
||||||
|
'input' => $naddr, |
||||||
|
'error' => $e->getMessage() |
||||||
|
]); |
||||||
|
return ['success' => false, 'skipped' => false, 'error' => 'Failed to decode naddr']; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function addCoordinate(string $coordinate): array |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$draft = $session->get('read_wizard'); |
||||||
|
|
||||||
|
if (!$draft instanceof CategoryDraft) { |
||||||
|
$draft = new CategoryDraft(); |
||||||
|
$draft->title = 'My Reading List'; |
||||||
|
$draft->slug = substr(bin2hex(random_bytes(6)), 0, 8); |
||||||
|
} |
||||||
|
|
||||||
|
if (in_array($coordinate, $draft->articles, true)) { |
||||||
|
return ['success' => false, 'skipped' => true, 'error' => '']; |
||||||
|
} |
||||||
|
|
||||||
|
$draft->articles[] = $coordinate; |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
|
||||||
|
return ['success' => true, 'skipped' => false, 'error' => '']; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,50 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use App\Service\ReadingListManager; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveAction; |
||||||
|
use Symfony\UX\LiveComponent\Attribute\LiveProp; |
||||||
|
use Symfony\UX\LiveComponent\DefaultActionTrait; |
||||||
|
|
||||||
|
#[AsLiveComponent] |
||||||
|
final class ReadingListSelectorComponent |
||||||
|
{ |
||||||
|
use DefaultActionTrait; |
||||||
|
|
||||||
|
#[LiveProp(writable: true)] |
||||||
|
public string $selectedSlug = ''; |
||||||
|
|
||||||
|
public array $availableLists = []; |
||||||
|
public ?CategoryDraft $currentDraft = null; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly ReadingListManager $readingListManager, |
||||||
|
) {} |
||||||
|
|
||||||
|
public function mount(): void |
||||||
|
{ |
||||||
|
$this->availableLists = $this->readingListManager->getUserReadingLists(); |
||||||
|
$selectedSlug = $this->readingListManager->getSelectedListSlug(); |
||||||
|
$this->selectedSlug = $selectedSlug ?? ''; |
||||||
|
$this->currentDraft = $this->readingListManager->getCurrentDraft(); |
||||||
|
} |
||||||
|
|
||||||
|
#[LiveAction] |
||||||
|
public function selectList(string $slug): void |
||||||
|
{ |
||||||
|
if ($slug === '__new__') { |
||||||
|
// Create new draft |
||||||
|
$this->currentDraft = $this->readingListManager->createNewDraft(); |
||||||
|
$this->selectedSlug = ''; |
||||||
|
} else { |
||||||
|
// Load existing list |
||||||
|
$this->currentDraft = $this->readingListManager->loadPublishedListIntoDraft($slug); |
||||||
|
$this->selectedSlug = $slug; |
||||||
|
} |
||||||
|
|
||||||
|
$this->dispatchBrowserEvent('readingListUpdated'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,79 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use App\Service\ReadingListWorkflowService; |
||||||
|
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; |
||||||
|
|
||||||
|
#[AsTwigComponent] |
||||||
|
final class ReadingListWorkflowStatus |
||||||
|
{ |
||||||
|
public CategoryDraft $draft; |
||||||
|
|
||||||
|
public function __construct( |
||||||
|
private readonly ReadingListWorkflowService $workflowService, |
||||||
|
) {} |
||||||
|
|
||||||
|
public function getStatusMessage(): string |
||||||
|
{ |
||||||
|
return $this->workflowService->getStatusMessage($this->draft); |
||||||
|
} |
||||||
|
|
||||||
|
public function getBadgeColor(): string |
||||||
|
{ |
||||||
|
return $this->workflowService->getStateBadgeColor($this->draft); |
||||||
|
} |
||||||
|
|
||||||
|
public function getCompletionPercentage(): int |
||||||
|
{ |
||||||
|
return $this->workflowService->getCompletionPercentage($this->draft); |
||||||
|
} |
||||||
|
|
||||||
|
public function isReadyToPublish(): bool |
||||||
|
{ |
||||||
|
return $this->workflowService->isReadyToPublish($this->draft); |
||||||
|
} |
||||||
|
|
||||||
|
public function getCurrentState(): string |
||||||
|
{ |
||||||
|
return $this->workflowService->getCurrentState($this->draft); |
||||||
|
} |
||||||
|
|
||||||
|
public function getNextSteps(): array |
||||||
|
{ |
||||||
|
$state = $this->getCurrentState(); |
||||||
|
|
||||||
|
return match ($state) { |
||||||
|
'empty', 'draft' => [ |
||||||
|
'Add a title and summary', |
||||||
|
'Add articles to your list', |
||||||
|
], |
||||||
|
'has_metadata' => [ |
||||||
|
'Add articles to your list', |
||||||
|
], |
||||||
|
'has_articles' => [ |
||||||
|
'Review your list', |
||||||
|
'Click "Review & Publish" when ready', |
||||||
|
], |
||||||
|
'ready_for_review' => [ |
||||||
|
'Review the event JSON', |
||||||
|
'Sign and publish with your Nostr extension', |
||||||
|
], |
||||||
|
'publishing' => [ |
||||||
|
'Please wait...', |
||||||
|
], |
||||||
|
'published' => [ |
||||||
|
'Your reading list is live!', |
||||||
|
'Share the link with others', |
||||||
|
], |
||||||
|
'editing' => [ |
||||||
|
'Add or remove articles', |
||||||
|
'Update title or summary', |
||||||
|
'Republish when done', |
||||||
|
], |
||||||
|
default => [], |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,66 @@ |
|||||||
|
{% set lists = this.getListsWithArticles() %} |
||||||
|
{% set publishUrl = path('api-index-publish') %} |
||||||
|
{% set csrfToken = csrf_token('nostr_publish') %} |
||||||
|
|
||||||
|
<div {{ attributes }} |
||||||
|
data-controller="reading-list-dropdown" |
||||||
|
data-reading-list-dropdown-coordinate-value="{{ coordinate }}" |
||||||
|
data-reading-list-dropdown-lists-value="{{ lists|json_encode|e('html_attr') }}" |
||||||
|
data-reading-list-dropdown-publish-url-value="{{ publishUrl }}" |
||||||
|
data-reading-list-dropdown-csrf-token-value="{{ csrfToken }}"> |
||||||
|
|
||||||
|
<div class="dropdown"> |
||||||
|
<button class="btn btn-outline-primary dropdown-toggle" |
||||||
|
type="button" |
||||||
|
id="readingListDropdown" |
||||||
|
data-reading-list-dropdown-target="dropdown" |
||||||
|
data-action="click->reading-list-dropdown#toggleDropdown" |
||||||
|
aria-expanded="false"> |
||||||
|
📚 Add to Reading List |
||||||
|
</button> |
||||||
|
<ul class="dropdown-menu" |
||||||
|
aria-labelledby="readingListDropdown" |
||||||
|
data-reading-list-dropdown-target="menu"> |
||||||
|
{% if lists is empty %} |
||||||
|
<li> |
||||||
|
<span class="dropdown-item disabled"> |
||||||
|
<em>No reading lists yet</em> |
||||||
|
</span> |
||||||
|
</li> |
||||||
|
<li><hr class="dropdown-divider"></li> |
||||||
|
{% else %} |
||||||
|
<li class="dropdown-header">Select a list:</li> |
||||||
|
{% for list in lists %} |
||||||
|
<li> |
||||||
|
<a class="dropdown-item" |
||||||
|
href="#" |
||||||
|
data-action="click->reading-list-dropdown#addToList" |
||||||
|
data-slug="{{ list.slug }}" |
||||||
|
data-title="{{ list.title }}"> |
||||||
|
<div class="d-flex flex-row"> |
||||||
|
<div> |
||||||
|
<strong>{{ list.title }}</strong> |
||||||
|
<br> |
||||||
|
<small class="text-muted"> |
||||||
|
{{ list.articleCount }} article{{ list.articleCount != 1 ? 's' : '' }} |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
{% if list.articles and coordinate in list.articles %} |
||||||
|
<span class="badge bg-success ms-2">✓</span> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
{% endfor %} |
||||||
|
<li><hr class="dropdown-divider"></li> |
||||||
|
{% endif %} |
||||||
|
<li> |
||||||
|
<a class="dropdown-item" href="{{ path('read_wizard_setup') }}"> |
||||||
|
<strong>➕ Create New List</strong> |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div data-reading-list-dropdown-target="status" style="display: none;"></div> |
||||||
|
</div> |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
<div {{ attributes.defaults({class: 'reading-list-quick-add'}) }}> |
||||||
|
<div class="quick-add-toggle" data-action="live#action" data-live-action-param="toggleExpanded"> |
||||||
|
<span class="badge bg-primary"> |
||||||
|
📚 Reading List |
||||||
|
{% if itemCount > 0 %} |
||||||
|
<span class="badge bg-secondary ms-1">{{ itemCount }}</span> |
||||||
|
{% endif %} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{% if isExpanded %} |
||||||
|
<div class="quick-add-panel card shadow"> |
||||||
|
<div class="card-body"> |
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2"> |
||||||
|
<h6 class="mb-0">Add to Reading List</h6> |
||||||
|
<button type="button" class="btn-close btn-sm" |
||||||
|
data-action="live#action" |
||||||
|
data-live-action-param="toggleExpanded"></button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<twig:ReadingListSelectorComponent class="mb-3" /> |
||||||
|
|
||||||
|
<form data-action="live#action:prevent" data-live-action-param="addItem"> |
||||||
|
<div class="mb-2"> |
||||||
|
<textarea |
||||||
|
class="form-control form-control-sm" |
||||||
|
placeholder="Paste naddr (nostr:naddr1...) or coordinate (30023:pubkey:slug)" |
||||||
|
rows="3" |
||||||
|
data-model="norender|input" |
||||||
|
>{{ input }}</textarea> |
||||||
|
</div> |
||||||
|
<button type="submit" class="btn btn-sm btn-primary w-100">Add Article</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="alert alert-danger alert-sm mt-2 mb-0">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if success %} |
||||||
|
<div class="alert alert-success alert-sm mt-2 mb-0">{{ success }}</div> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="mt-3 pt-2 border-top"> |
||||||
|
<small class="text-muted d-block mb-2">{{ itemCount }} article{{ itemCount != 1 ? 's' : '' }} in list</small> |
||||||
|
<div class="d-flex gap-2"> |
||||||
|
<a href="{{ path('reading_list_compose') }}" class="btn btn-sm btn-outline-primary flex-fill"> |
||||||
|
View List |
||||||
|
</a> |
||||||
|
<a href="{{ path('read_wizard_review') }}" class="btn btn-sm btn-success flex-fill"> |
||||||
|
Publish |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<div {{ attributes }}> |
||||||
|
<form data-action="live#action:prevent" data-live-action-param="addMultiple"> |
||||||
|
<div class="mb-3"> |
||||||
|
<label class="form-label"> |
||||||
|
<strong>Paste article naddresses or coordinates</strong> |
||||||
|
<small class="d-block text-muted">One per line. Supports both formats:</small> |
||||||
|
<small class="d-block text-muted">• naddr1... (or nostr:naddr1...)</small> |
||||||
|
<small class="d-block text-muted">• 30023:pubkey:slug</small> |
||||||
|
</label> |
||||||
|
<textarea |
||||||
|
class="form-control font-monospace" |
||||||
|
placeholder="Paste one or more articles here (one per line) Example: nostr:naddr1qqs8w4r3v3jhxapfdehhxarjv4jzumn9wdshgct5d4kz7cte8ycrjcpzfmhxue69uhk7m3vd46x7un4dejxemn80e3k7aewwp3k7tnzd9nkjmn8v3jhyetdw4jx7at59ehxetn2d9hqjqqqqqxjt8kyx... 30023:a1b2c3d4e5f6...abcd:my-article-slug" |
||||||
|
rows="6" |
||||||
|
data-model="norender|input" |
||||||
|
>{{ input }}</textarea> |
||||||
|
</div> |
||||||
|
<button type="submit" class="btn btn-primary">Add to Reading List</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
{% if error %} |
||||||
|
<div class="alert alert-danger mt-3">{{ error }}</div> |
||||||
|
{% endif %} |
||||||
|
{% if success %} |
||||||
|
<div class="alert alert-success mt-3">{{ success }}</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
@ -0,0 +1,40 @@ |
|||||||
|
<div {{ attributes }}> |
||||||
|
<div class="reading-list-selector"> |
||||||
|
<label class="form-label small mb-2"> |
||||||
|
<strong>Add to Reading List:</strong> |
||||||
|
</label> |
||||||
|
|
||||||
|
<select |
||||||
|
class="form-select form-select-sm mb-2" |
||||||
|
data-model="live|selectedSlug" |
||||||
|
data-action="live#action" |
||||||
|
data-live-action-param="selectList" |
||||||
|
> |
||||||
|
<option value="__new__" {% if selectedSlug == '' %}selected{% endif %}> |
||||||
|
➕ Create New Reading List |
||||||
|
</option> |
||||||
|
|
||||||
|
{% if availableLists is not empty %} |
||||||
|
<optgroup label="Your Existing Lists"> |
||||||
|
{% for list in availableLists %} |
||||||
|
<option value="{{ list.slug }}" {% if selectedSlug == list.slug %}selected{% endif %}> |
||||||
|
{{ list.title }} ({{ list.articleCount }} articles) |
||||||
|
</option> |
||||||
|
{% endfor %} |
||||||
|
</optgroup> |
||||||
|
{% endif %} |
||||||
|
</select> |
||||||
|
|
||||||
|
{% if currentDraft %} |
||||||
|
<div class="alert alert-info alert-sm"> |
||||||
|
<small> |
||||||
|
<strong>Current:</strong> {{ currentDraft.title ?: 'New Reading List' }} |
||||||
|
{% if currentDraft.articles|length > 0 %} |
||||||
|
<br><strong>Articles:</strong> {{ currentDraft.articles|length }} |
||||||
|
{% endif %} |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
@ -0,0 +1,62 @@ |
|||||||
|
<div {{ attributes }}> |
||||||
|
<div class="workflow-status-card" |
||||||
|
data-controller="workflow-progress" |
||||||
|
data-workflow-progress-percentage-value="{{ this.completionPercentage }}" |
||||||
|
data-workflow-progress-status-value="{{ this.currentState }}" |
||||||
|
data-workflow-progress-color-value="{{ this.badgeColor }}" |
||||||
|
data-workflow-progress-animated-value="true"> |
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3"> |
||||||
|
<h6 class="mb-0">Workflow Status</h6> |
||||||
|
<span class="badge bg-{{ this.badgeColor }}" data-workflow-progress-target="badge"> |
||||||
|
{{ this.statusMessage }} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{# Progress bar with Stimulus controller #} |
||||||
|
<div class="progress mb-3" style="height: 8px;"> |
||||||
|
<div |
||||||
|
class="progress-bar bg-{{ this.badgeColor }}" |
||||||
|
role="progressbar" |
||||||
|
style="width: 0%" |
||||||
|
aria-valuenow="0" |
||||||
|
aria-valuemin="0" |
||||||
|
aria-valuemax="100" |
||||||
|
aria-label="{{ this.statusMessage }}: {{ this.completionPercentage }}% complete" |
||||||
|
data-workflow-progress-target="bar" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{# Current state info #} |
||||||
|
<div class="workflow-state-info"> |
||||||
|
<p class="small text-muted mb-2"> |
||||||
|
<strong>Current State:</strong> |
||||||
|
<span data-workflow-progress-target="statusText"> |
||||||
|
{{ this.currentState|replace({'_': ' '})|title }} |
||||||
|
</span> |
||||||
|
</p> |
||||||
|
|
||||||
|
{% if this.nextSteps is not empty %} |
||||||
|
<div class="next-steps" data-workflow-progress-target="nextSteps"> |
||||||
|
<p class="small mb-1"><strong>Next Steps:</strong></p> |
||||||
|
<ul class="small mb-0"> |
||||||
|
{% for step in this.nextSteps %} |
||||||
|
<li>{{ step }}</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
|
||||||
|
{# Publish button state #} |
||||||
|
{% if this.readyToPublish %} |
||||||
|
<div class="alert alert-success alert-sm mt-3 mb-0"> |
||||||
|
<small>✓ Your reading list is ready to publish!</small> |
||||||
|
</div> |
||||||
|
{% elseif this.currentState == 'published' %} |
||||||
|
<div class="alert alert-info alert-sm mt-3 mb-0"> |
||||||
|
<small>✓ Published successfully!</small> |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,77 @@ |
|||||||
|
{% extends 'layout.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<div class="container mt-5"> |
||||||
|
<div class="row justify-content-center"> |
||||||
|
<div class="col-lg-6"> |
||||||
|
<div class="card shadow-sm"> |
||||||
|
<div class="card-body p-4"> |
||||||
|
<h2 class="h4 mb-3">📚 Add Article to Reading List</h2> |
||||||
|
|
||||||
|
<div class="alert alert-info mb-4"> |
||||||
|
<small> |
||||||
|
<strong>Article coordinate:</strong><br> |
||||||
|
<code class="small">{{ coordinate }}</code> |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
|
||||||
|
<form method="post"> |
||||||
|
<div class="mb-4"> |
||||||
|
<label class="form-label fw-bold">Select a reading list:</label> |
||||||
|
|
||||||
|
<div class="list-group"> |
||||||
|
{# Create new list option #} |
||||||
|
<label class="list-group-item list-group-item-action"> |
||||||
|
<input class="form-check-input me-2" type="radio" name="selected_list" value="__new__" |
||||||
|
{% if not currentDraft or availableLists is empty %}checked{% endif %}> |
||||||
|
<div class="d-flex w-100 justify-content-between align-items-center"> |
||||||
|
<div> |
||||||
|
<strong>➕ Create New Reading List</strong> |
||||||
|
<small class="d-block text-muted">Start a fresh collection</small> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</label> |
||||||
|
|
||||||
|
{# Existing lists #} |
||||||
|
{% if availableLists is not empty %} |
||||||
|
{% for list in availableLists %} |
||||||
|
<label class="list-group-item list-group-item-action"> |
||||||
|
<input class="form-check-input me-2" type="radio" name="selected_list" value="{{ list.slug }}" |
||||||
|
{% if currentDraft and currentDraft.slug == list.slug %}checked{% endif %}> |
||||||
|
<div class="d-flex w-100 justify-content-between align-items-center"> |
||||||
|
<div> |
||||||
|
<strong>{{ list.title }}</strong> |
||||||
|
<small class="d-block text-muted"> |
||||||
|
{{ list.articleCount }} article{{ list.articleCount != 1 ? 's' : '' }} |
||||||
|
{% if list.summary %} • {{ list.summary|u.truncate(50, '...') }}{% endif %} |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</label> |
||||||
|
{% endfor %} |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="d-flex gap-2"> |
||||||
|
<button type="submit" class="btn btn-primary flex-fill"> |
||||||
|
Add to Selected List → |
||||||
|
</button> |
||||||
|
<a href="{{ app.request.headers.get('referer') ?: path('home') }}" class="btn btn-outline-secondary"> |
||||||
|
Cancel |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="text-center mt-3"> |
||||||
|
<small class="text-muted"> |
||||||
|
After adding, you'll be taken to the compose page where you can add more articles or publish your list. |
||||||
|
</small> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
Loading…
Reference in new issue