32 changed files with 2618 additions and 124 deletions
@ -0,0 +1,211 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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