Browse Source

Reading list prep

imwald
Nuša Pukšič 3 months ago
parent
commit
8134d1f4ef
  1. 2
      assets/app.js
  2. 211
      assets/controllers/reading_list_dropdown_controller.js
  3. 198
      assets/controllers/workflow_progress_controller.js
  4. 228
      assets/styles/03-components/dropdown.css
  5. 119
      assets/styles/reading-lists.css
  6. 1
      composer.json
  7. 66
      composer.lock
  8. 76
      config/packages/workflow.yaml
  9. 112
      src/Controller/ReadingListController.php
  10. 48
      src/Controller/ReadingListWizardController.php
  11. 14
      src/Dto/CategoryDraft.php
  12. 303
      src/Service/ReadingListManager.php
  13. 208
      src/Service/ReadingListWorkflowService.php
  14. 46
      src/Twig/Components/ReadingListDraftComponent.php
  15. 40
      src/Twig/Components/ReadingListDropdown.php
  16. 176
      src/Twig/Components/ReadingListQuickAddComponent.php
  17. 172
      src/Twig/Components/ReadingListQuickInputComponent.php
  18. 50
      src/Twig/Components/ReadingListSelectorComponent.php
  19. 79
      src/Twig/Components/ReadingListWorkflowStatus.php
  20. 94
      templates/components/ReadingListDraftComponent.html.twig
  21. 66
      templates/components/ReadingListDropdown.html.twig
  22. 56
      templates/components/ReadingListQuickAddComponent.html.twig
  23. 27
      templates/components/ReadingListQuickInputComponent.html.twig
  24. 40
      templates/components/ReadingListSelectorComponent.html.twig
  25. 62
      templates/components/ReadingListWorkflowStatus.html.twig
  26. 2
      templates/components/UserMenu.html.twig
  27. 10
      templates/layout.html.twig
  28. 4
      templates/pages/article.html.twig
  29. 3
      templates/pages/list.html.twig
  30. 77
      templates/reading_list/add_article_confirm.html.twig
  31. 66
      templates/reading_list/compose.html.twig
  32. 2
      templates/reading_list/index.html.twig

2
assets/app.js

@ -21,6 +21,7 @@ import './styles/02-layout/header.css'; @@ -21,6 +21,7 @@ import './styles/02-layout/header.css';
import './styles/03-components/button.css';
import './styles/03-components/cards-shared.css';
import './styles/03-components/card.css';
import './styles/03-components/dropdown.css';
import './styles/03-components/form.css';
import './styles/03-components/article.css';
import './styles/03-components/modal.css';
@ -29,6 +30,7 @@ import './styles/03-components/spinner.css'; @@ -29,6 +30,7 @@ import './styles/03-components/spinner.css';
import './styles/03-components/a2hs.css';
import './styles/03-components/og.css';
import './styles/03-components/nostr-previews.css';
import './styles/reading-lists.css';
import './styles/03-components/nip05-badge.css';
import './styles/03-components/picture-event.css';
import './styles/03-components/video-event.css';

211
assets/controllers/reading_list_dropdown_controller.js

@ -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';
}
}
}

198
assets/controllers/workflow_progress_controller.js

@ -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);
}
}
}

228
assets/styles/03-components/dropdown.css

@ -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);
}
}

119
assets/styles/reading-lists.css

@ -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);
}

1
composer.json

@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
"symfony/asset-mapper": "7.1.*",
"symfony/console": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/expression-language": "7.1.*",
"symfony/flex": "^2",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",

66
composer.lock generated

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "8172177fac811888db46cc398b805356",
"content-hash": "e98b6121678c3f42e240eca6499ed054",
"packages": [
{
"name": "bacon/bacon-qr-code",
@ -6090,6 +6090,70 @@ @@ -6090,6 +6090,70 @@
],
"time": "2024-09-25T14:21:43+00:00"
},
{
"name": "symfony/expression-language",
"version": "v7.1.6",
"source": {
"type": "git",
"url": "https://github.com/symfony/expression-language.git",
"reference": "c3a1224bc144b36cd79149b42c1aecd5f81395a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/expression-language/zipball/c3a1224bc144b36cd79149b42c1aecd5f81395a5",
"reference": "c3a1224bc144b36cd79149b42c1aecd5f81395a5",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/cache": "^6.4|^7.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/service-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\ExpressionLanguage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an engine that can compile and evaluate expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/expression-language/tree/v7.1.6"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-10-09T08:46:59+00:00"
},
{
"name": "symfony/filesystem",
"version": "v7.1.6",

76
config/packages/workflow.yaml

@ -53,3 +53,79 @@ framework: @@ -53,3 +53,79 @@ framework:
publish:
from: nested_indices_created
to: published
reading_list_workflow:
type: state_machine
audit_trail:
enabled: true
marking_store:
type: method
property: workflowState
supports:
- App\Dto\CategoryDraft
initial_marking: empty
places:
- empty
- draft
- has_metadata
- has_articles
- ready_for_review
- publishing
- published
- editing
transitions:
start_draft:
from: empty
to: draft
add_metadata:
from:
- draft
- editing
to: has_metadata
metadata:
title: Metadata Added
description: Title and summary have been set
add_articles:
from:
- has_metadata
- draft
- editing
to: has_articles
metadata:
title: Articles Added
description: At least one article has been added
ready_for_review:
from: has_articles
to: ready_for_review
guard: "subject.title != '' and subject.articles|length > 0"
metadata:
title: Ready for Review
description: Reading list is ready to be published
start_publishing:
from: ready_for_review
to: publishing
metadata:
title: Publishing
description: Generating Nostr event for publication
complete_publishing:
from: publishing
to: published
metadata:
title: Published
description: Reading list has been published to Nostr
edit_published:
from: published
to: editing
metadata:
title: Editing
description: Modifying a published reading list
cancel:
from:
- draft
- has_metadata
- has_articles
- ready_for_review
- editing
to: empty
metadata:
title: Cancelled
description: Reading list draft has been cancelled

112
src/Controller/ReadingListController.php

@ -4,20 +4,16 @@ declare(strict_types=1); @@ -4,20 +4,16 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Article;
use App\Entity\Event;
use App\Enum\KindsEnum;
use Doctrine\ORM\EntityManagerInterface;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\Term;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;
class ReadingListController extends AbstractController
{
@ -79,36 +75,64 @@ class ReadingListController extends AbstractController @@ -79,36 +75,64 @@ class ReadingListController extends AbstractController
}
#[Route('/reading-list/compose', name: 'reading_list_compose')]
public function compose(): Response
public function compose(Request $request, EntityManagerInterface $em): Response
{
return $this->render('reading_list/compose.html.twig');
// Check if a coordinate was passed via URL parameter
$coordinate = $request->query->get('add');
$addedArticle = null;
if ($coordinate) {
// Auto-add the coordinate to the current draft
$session = $request->getSession();
$draft = $session->get('read_wizard');
if (!$draft instanceof \App\Dto\CategoryDraft) {
$draft = new \App\Dto\CategoryDraft();
$draft->title = 'My Reading List';
$draft->slug = substr(bin2hex(random_bytes(6)), 0, 8);
}
if (!in_array($coordinate, $draft->articles, true)) {
$draft->articles[] = $coordinate;
$session->set('read_wizard', $draft);
$addedArticle = $coordinate;
}
}
return $this->render('reading_list/compose.html.twig', [
'addedArticle' => $addedArticle,
]);
}
/**
*
* @throws InvalidArgumentException
*/
#[Route('/p/{pubkey}/list/{slug}', name: 'reading-list')]
public function readingList($pubkey, $slug, CacheInterface $redisCache,
public function readingList($pubkey, $slug,
EntityManagerInterface $em,
FinderInterface $finder,
LoggerInterface $logger): Response
{
$key = 'single-reading-list-' . $pubkey . '-' . $slug;
$logger->info(sprintf('Reading list: %s', $key));
$list = $redisCache->get($key, function() use ($em, $pubkey, $slug) {
// find reading list by pubkey+slug, kind 30040
$lists = $em->getRepository(Event::class)->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX]);
// filter by tag d = $slug
$lists = array_filter($lists, function($ev) use ($slug) {
return $ev->getSlug() === $slug;
});
// sort revisions and keep latest
usort($lists, function($a, $b) {
return $b->getCreatedAt() <=> $a->getCreatedAt();
});
return array_pop($lists);
});
$logger->info(sprintf('Reading list: pubkey=%s, slug=%s', $pubkey, $slug));
// Find reading list by pubkey+slug, kind 30040 directly from database
$repo = $em->getRepository(Event::class);
$lists = $repo->findBy(['pubkey' => $pubkey, 'kind' => KindsEnum::PUBLICATION_INDEX], ['created_at' => 'DESC']);
// Filter by slug
$list = null;
foreach ($lists as $ev) {
if (!$ev instanceof Event) continue;
$eventSlug = $ev->getSlug();
if ($eventSlug === $slug) {
$list = $ev;
break; // Found the latest one
}
}
if (!$list) {
throw $this->createNotFoundException('Reading list not found');
}
// fetch articles listed in the list's a tags
$coordinates = []; // Store full coordinates (kind:author:slug)
@ -118,26 +142,30 @@ class ReadingListController extends AbstractController @@ -118,26 +142,30 @@ class ReadingListController extends AbstractController
$coordinates[] = $tag[1]; // Store the full coordinate
}
}
$articles = [];
if (count($coordinates) > 0) {
$boolQuery = new BoolQuery();
$articleRepo = $em->getRepository(Article::class);
// Query database directly for each coordinate
foreach ($coordinates as $coord) {
$parts = explode(':', $coord, 3);
[$kind, $author, $slug] = $parts;
$termQuery = new BoolQuery();
$termQuery->addMust(new Term(['kind' => (int)$kind]));
$termQuery->addMust(new Term(['pubkey' => strtolower($author)]));
$termQuery->addMust(new Term(['slug' => $slug]));
$boolQuery->addShould($termQuery);
}
$finalQuery = new Query($boolQuery);
$finalQuery->setSize(100); // Limit to 100 results
$results = $finder->find($finalQuery);
// Index results by their full coordinate for easy lookup
foreach ($results as $result) {
if ($result instanceof Event) {
$coordKey = sprintf('%d:%s:%s', $result->getKind(), strtolower($result->getPubkey()), $result->getSlug());
$articles[$coordKey] = $result;
if (count($parts) === 3) {
[$kind, $author, $articleSlug] = $parts;
// Find the most recent event matching this coordinate
$events = $articleRepo->findBy([
'slug' => $articleSlug,
'pubkey' => $author
], ['createdAt' => 'DESC']);
// Filter by slug and get the latest
foreach ($events as $event) {
if ($event->getSlug() === $articleSlug) {
$articles[] = $event;
break; // Take the first match (most recent if ordered)
}
}
}
}
}

48
src/Controller/ReadingListWizardController.php

@ -7,6 +7,7 @@ namespace App\Controller; @@ -7,6 +7,7 @@ namespace App\Controller;
use App\Dto\CategoryDraft;
use App\Form\CategoryArticlesType;
use App\Form\CategoryType;
use App\Service\ReadingListManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -69,6 +70,53 @@ class ReadingListWizardController extends AbstractController @@ -69,6 +70,53 @@ class ReadingListWizardController extends AbstractController
]);
}
#[Route('/reading-list/add-article', name: 'read_wizard_add_article')]
public function addArticle(Request $request, ReadingListManager $readingListManager): Response
{
// Get the coordinate from the query parameter
$coordinate = $request->query->get('coordinate');
if (!$coordinate) {
$this->addFlash('error', 'No article coordinate provided.');
return $this->redirectToRoute('reading_list_compose');
}
// Get available reading lists
$availableLists = $readingListManager->getUserReadingLists();
$currentDraft = $readingListManager->getCurrentDraft();
// Handle form submission
if ($request->isMethod('POST')) {
$selectedSlug = $request->request->get('selected_list');
// Load or create the selected list
if ($selectedSlug === '__new__' || !$selectedSlug) {
$draft = $readingListManager->createNewDraft();
} else {
$draft = $readingListManager->loadPublishedListIntoDraft($selectedSlug);
}
// Add the article to the draft
if (!in_array($coordinate, $draft->articles, true)) {
$draft->articles[] = $coordinate;
$session = $request->getSession();
$session->set('read_wizard', $draft);
}
// Redirect to compose page with success message
return $this->redirectToRoute('reading_list_compose', [
'add' => $coordinate,
'list' => $selectedSlug ?? '__new__'
]);
}
return $this->render('reading_list/add_article_confirm.html.twig', [
'coordinate' => $coordinate,
'availableLists' => $availableLists,
'currentDraft' => $currentDraft,
]);
}
#[Route('/reading-list/wizard/review', name: 'read_wizard_review')]
public function review(Request $request): Response
{

14
src/Dto/CategoryDraft.php

@ -10,8 +10,20 @@ class CategoryDraft @@ -10,8 +10,20 @@ class CategoryDraft
public string $summary = '';
/** @var string[] */
public array $tags = [];
/** @var string[] article coordinates like kind:pubkey|npub:slug */
/** @var string[] article coordinates like kind:pubkey:slug */
public array $articles = [];
public string $slug = '';
/** Workflow state tracking */
private string $workflowState = 'empty';
public function getWorkflowState(): string
{
return $this->workflowState;
}
public function setWorkflowState(string $state): void
{
$this->workflowState = $state;
}
}

303
src/Service/ReadingListManager.php

@ -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 [];
}
}

208
src/Service/ReadingListWorkflowService.php

@ -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,
};
}
}

46
src/Twig/Components/ReadingListDraftComponent.php

@ -5,6 +5,7 @@ namespace App\Twig\Components; @@ -5,6 +5,7 @@ namespace App\Twig\Components;
use App\Dto\CategoryDraft;
use App\Enum\KindsEnum;
use App\Service\NostrClient;
use App\Service\ReadingListWorkflowService;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use Psr\Log\LoggerInterface;
@ -31,10 +32,14 @@ final class ReadingListDraftComponent @@ -31,10 +32,14 @@ final class ReadingListDraftComponent
#[LiveProp]
public string $naddrSuccess = '';
#[LiveProp(writable: true)]
public bool $editingMeta = false;
public function __construct(
private readonly RequestStack $requestStack,
private readonly NostrClient $nostrClient,
private readonly LoggerInterface $logger,
private readonly ReadingListWorkflowService $workflowService,
) {}
public function mount(): void
@ -48,6 +53,31 @@ final class ReadingListDraftComponent @@ -48,6 +53,31 @@ final class ReadingListDraftComponent
$this->reloadFromSession();
}
#[LiveAction]
public function toggleEditMeta(): void
{
$this->editingMeta = !$this->editingMeta;
}
#[LiveAction]
public function updateMeta(string $title = '', string $summary = ''): void
{
$session = $this->requestStack->getSession();
$draft = $session->get('read_wizard');
if (!$draft instanceof CategoryDraft) {
$draft = new CategoryDraft();
}
$draft->title = $title ?: 'My Reading List';
$draft->summary = $summary;
// Update workflow state
$this->workflowService->updateMetadata($draft);
$session->set('read_wizard', $draft);
$this->draft = $draft;
$this->editingMeta = false;
}
#[LiveAction]
public function remove(string $coordinate): void
{
@ -60,6 +90,15 @@ final class ReadingListDraftComponent @@ -60,6 +90,15 @@ final class ReadingListDraftComponent
}
}
#[LiveAction]
public function clearAll(): void
{
$session = $this->requestStack->getSession();
$session->remove('read_wizard');
$this->draft = new CategoryDraft();
$this->draft->slug = substr(bin2hex(random_bytes(6)), 0, 8);
}
#[LiveAction]
public function addNaddr(): void
{
@ -119,9 +158,14 @@ final class ReadingListDraftComponent @@ -119,9 +158,14 @@ final class ReadingListDraftComponent
]);
}
$draft->articles[] = $coordinate;
// Update workflow state
$this->workflowService->addArticles($draft);
$session->set('read_wizard', $draft);
$this->draft = $draft;
$this->naddrSuccess = 'Added article: ' . $coordinate;
$this->dispatchBrowserEvent('readingListUpdated');
} else {
$this->naddrSuccess = 'Article already in list.';
}
@ -149,8 +193,6 @@ final class ReadingListDraftComponent @@ -149,8 +193,6 @@ final class ReadingListDraftComponent
}
$this->draft = new CategoryDraft();
$this->draft->title = 'Reading List';
$this->draft->slug = substr(bin2hex(random_bytes(6)), 0, 8);
$session->set('read_wizard', $this->draft);
}
}

40
src/Twig/Components/ReadingListDropdown.php

@ -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;
}
}

176
src/Twig/Components/ReadingListQuickAddComponent.php

@ -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;
}
}
}

172
src/Twig/Components/ReadingListQuickInputComponent.php

@ -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' => ''];
}
}

50
src/Twig/Components/ReadingListSelectorComponent.php

@ -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');
}
}

79
src/Twig/Components/ReadingListWorkflowStatus.php

@ -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 => [],
};
}
}

94
templates/components/ReadingListDraftComponent.html.twig

@ -1,43 +1,79 @@ @@ -1,43 +1,79 @@
<div {{ attributes }}>
<h2>Title: <b>{{ draft.title ?: 'Reading List' }}</b></h2>
<p><small>Slug: {{ draft.slug }}</small></p>
<div {{ attributes.defaults({class: 'card reading-list-draft'}) }}>
<div class="card-body">
{# Workflow Status #}
{% if draft %}
<twig:ReadingListWorkflowStatus :draft="draft" class="mb-3" />
{% endif %}
{% if draft.summary %}<p>{{ draft.summary }}</p>{% endif %}
{% if editingMeta %}
<form data-action="live#action:prevent" data-live-action-param="updateMeta">
<div class="mb-2">
<label class="form-label small">Title</label>
<input
type="text"
name="title"
class="form-control form-control-sm"
value="{{ draft.title ?: 'My Reading List' }}"
/>
</div>
<div class="mb-2">
<label class="form-label small">Summary (optional)</label>
<textarea
name="summary"
class="form-control form-control-sm"
rows="2"
>{{ draft.summary }}</textarea>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-sm btn-primary">Save</button>
<button type="button" class="btn btn-sm btn-secondary" data-action="live#action" data-live-action-param="toggleEditMeta">Cancel</button>
</div>
</form>
{% else %}
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h2 class="h5 mb-1">{{ draft.title ?: 'My Reading List' }}</h2>
{% if draft.summary %}
<p class="small text-muted mb-1">{{ draft.summary }}</p>
{% endif %}
<p class="small text-muted mb-0">Slug: <code>{{ draft.slug }}</code></p>
</div>
<button class="btn btn-sm btn-outline-secondary" data-action="live#action" data-live-action-param="toggleEditMeta">
Edit
</button>
</div>
{% endif %}
<h3 class="mt-4">Articles</h3>
<hr />
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="h6 mb-0">Articles ({{ draft.articles|length }})</h3>
{% if draft.articles is not empty %}
<ul class="small">
<button class="btn btn-sm btn-outline-danger" data-action="live#action" data-live-action-param="clearAll" onclick="return confirm('Clear all articles?')">
Clear All
</button>
{% endif %}
</div>
{% if draft.articles is not empty %}
<ul class="list-unstyled small">
{% for coord in draft.articles %}
<li class="d-flex justify-content-between align-items-center gap-2">
<code class="flex-fill">{{ coord }}</code>
<button class="btn btn-sm btn-outline-danger" data-action="live#action" data-live-action-param="remove" data-live-coordinate-param="{{ coord }}">Remove</button>
<li class="d-flex justify-content-between align-items-center gap-2 mb-2 p-2 bg-light rounded">
<code class="flex-fill text-truncate" title="{{ coord }}">{{ coord }}</code>
<button class="btn btn-sm btn-outline-danger" data-action="live#action" data-live-action-param="remove" data-live-coordinate-param="{{ coord }}">×</button>
</li>
{% endfor %}
</ul>
{% else %}
<p><small>No articles yet. Use search or paste an naddr to add some.</small></p>
<p class="small text-muted"><em>No articles yet. Use the quick add or search below to add articles.</em></p>
{% endif %}
<div class="mt-3">
<form class="d-flex gap-3" data-action="live#action:prevent" data-live-action-param="addNaddr">
<label>
<input
type="text"
placeholder="Paste article naddr (nostr:naddr1...)"
class="form-control form-control-sm"
data-model="norender|naddrInput"
value="{{ naddrInput }}"
/>
</label>
<div class="actions">
<button class="btn btn-sm btn-secondary">Add</button>
</div>
</form>
{% if naddrError %}<div class="small text-danger mt-1">{{ naddrError }}</div>{% endif %}
{% if naddrSuccess %}<div class="small text-success mt-1">{{ naddrSuccess }}</div>{% endif %}
</div>
<hr />
<div class="mt-3">
<a class="btn btn-primary" href="{{ path('read_wizard_review') }}">Review & Sign</a>
<a class="btn btn-success w-100" href="{{ path('read_wizard_review') }}">
📝 Review & Publish Reading List
</a>
</div>
</div>
</div>

66
templates/components/ReadingListDropdown.html.twig

@ -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>

56
templates/components/ReadingListQuickAddComponent.html.twig

@ -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>

27
templates/components/ReadingListQuickInputComponent.html.twig

@ -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)&#10;Example:&#10;nostr:naddr1qqs8w4r3v3jhxapfdehhxarjv4jzumn9wdshgct5d4kz7cte8ycrjcpzfmhxue69uhk7m3vd46x7un4dejxemn80e3k7aewwp3k7tnzd9nkjmn8v3jhyetdw4jx7at59ehxetn2d9hqjqqqqqxjt8kyx...&#10;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>

40
templates/components/ReadingListSelectorComponent.html.twig

@ -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>

62
templates/components/ReadingListWorkflowStatus.html.twig

@ -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>

2
templates/components/UserMenu.html.twig

@ -29,7 +29,7 @@ @@ -29,7 +29,7 @@
<span>
{{ 'heading.logIn'|trans }}
</span>
<ul class="dropdown-menu">
<ul>
<li>
<twig:Atoms:Button {{ ...stimulus_action('login', 'loginAct') }} tag="a" variant="accent">Extension</twig:Atoms:Button>
</li>

10
templates/layout.html.twig

@ -12,7 +12,7 @@ @@ -12,7 +12,7 @@
<a href="{{ path('newsstand') }}">Newsstand</a>
</li>
<li>
<a href="{{ path('editor-create') }}">Write Article</a>
<a href="{{ path('reading_list_index') }}">Reading Lists</a>
</li>
{# <li>#}
{# <a href="{{ path('lists') }}">Lists</a>#}
@ -20,6 +20,9 @@ @@ -20,6 +20,9 @@
<li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>
</li>
<li>
<a href="{{ path('editor-create') }}">Write Article</a>
</li>
<li>
<a href="{{ path('home') }}">Overview</a>
</li>
@ -34,6 +37,11 @@ @@ -34,6 +37,11 @@
{# <button class="toggle" aria-controls="rightNav" aria-expanded="false" data-action="click->sidebar-toggle#toggle">Right ☰</button>#}
</div>
{% block body %}{% endblock %}
{# Floating reading list quick add widget #}
{% if app.user %}
<twig:ReadingListQuickAddComponent />
{% endif %}
</main>
<div>
<aside id="rightNav">

4
templates/pages/article.html.twig

@ -21,6 +21,10 @@ @@ -21,6 +21,10 @@
<a class="btn btn-primary" href="{{ path('editor-edit-slug', {'slug': article.slug}) }}">Edit article</a>
{% endif %}
{% if app.user %}
<twig:ReadingListDropdown coordinate="30023:{{ article.pubkey }}:{{ article.slug }}" />
{% endif %}
{% if is_granted('ROLE_ADMIN') %}
<button class="btn btn-primary" onclick="navigator.clipboard.writeText('30023:{{ article.pubkey }}:{{ article.slug }}')">
Copy coordinates

3
templates/pages/list.html.twig

@ -16,6 +16,7 @@ @@ -16,6 +16,7 @@
</div>
</section>
<div class="w-container">
<twig:Organisms:CardList :list="articles" class="article-list" />
</div>
{% endblock %}

77
templates/reading_list/add_article_confirm.html.twig

@ -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 %}

66
templates/reading_list/compose.html.twig

@ -7,11 +7,69 @@ @@ -7,11 +7,69 @@
</div>
</section>
<section class="d-flex flex-row gap-3">
<div class="container mt-4">
{% if addedArticle %}
<div class="alert alert-success" role="alert">
<strong>Article added!</strong> The article has been added to your reading list.
</div>
{% endif %}
<div class="row g-4">
{# Left sidebar - Reading List Preview #}
<div class="col-lg-4">
<div class="sticky-top" style="top: 20px;">
<twig:ReadingListDraftComponent />
<div class="mt-3">
<p>Search articles and click “Add to list”.</p>
</div>
</div>
{# Main content - Simple tabbed interface #}
<div class="col-lg-8">
{# List Selector #}
<div class="card mb-3">
<div class="card-body">
<twig:ReadingListSelectorComponent />
</div>
</div>
{# Tabbed content #}
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="paste-tab" data-bs-toggle="tab" data-bs-target="#paste" type="button" role="tab">
📋 Paste Links
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="search-tab" data-bs-toggle="tab" data-bs-target="#search" type="button" role="tab">
🔍 Search
</button>
</li>
</ul>
<div class="tab-content">
{# Paste tab #}
<div class="tab-pane fade show active" id="paste" role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title">Quick Add Articles</h5>
<p class="text-muted small">Paste article links below (one per line)</p>
<twig:ReadingListQuickInputComponent />
</div>
</div>
</div>
{# Search tab #}
<div class="tab-pane fade" id="search" role="tabpanel">
<div class="card">
<div class="card-body">
<h5 class="card-title">Search & Add Articles</h5>
<p class="text-muted small">Find articles and add them to your list</p>
<twig:SearchComponent :selectMode="true" currentRoute="compose" />
</div>
</section>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

2
templates/reading_list/index.html.twig

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
</div>
</section>
<div class="w-container mb-5 mt-5">
{% if lists is defined and lists|length %}
<ul class="list-unstyled d-grid gap-2 mb-4">
{% for item in lists %}
@ -40,5 +41,6 @@ @@ -40,5 +41,6 @@
{% else %}
<p><small>No reading lists found.</small></p>
{% endif %}
</div>
{% endblock %}

Loading…
Cancel
Save