diff --git a/assets/app.js b/assets/app.js index bd6291b..b1ad6bd 100644 --- a/assets/app.js +++ b/assets/app.js @@ -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'; 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'; diff --git a/assets/controllers/reading_list_dropdown_controller.js b/assets/controllers/reading_list_dropdown_controller.js new file mode 100644 index 0000000..0585901 --- /dev/null +++ b/assets/controllers/reading_list_dropdown_controller.js @@ -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'; + } + } +} diff --git a/assets/controllers/workflow_progress_controller.js b/assets/controllers/workflow_progress_controller.js new file mode 100644 index 0000000..c715034 --- /dev/null +++ b/assets/controllers/workflow_progress_controller.js @@ -0,0 +1,198 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Workflow Progress Bar Controller + * + * Handles animated progress bar with color transitions and status updates. + * + * Usage: + *
+ *
+ */ +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); + } + } +} + diff --git a/assets/styles/03-components/dropdown.css b/assets/styles/03-components/dropdown.css new file mode 100644 index 0000000..b49f7f0 --- /dev/null +++ b/assets/styles/03-components/dropdown.css @@ -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); + } +} diff --git a/assets/styles/reading-lists.css b/assets/styles/reading-lists.css new file mode 100644 index 0000000..eeebb5e --- /dev/null +++ b/assets/styles/reading-lists.css @@ -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); +} diff --git a/composer.json b/composer.json index e9aa294..f7f67f5 100644 --- a/composer.json +++ b/composer.json @@ -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.*", diff --git a/composer.lock b/composer.lock index 3b0f124..7925240 100644 --- a/composer.lock +++ b/composer.lock @@ -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 @@ ], "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", diff --git a/config/packages/workflow.yaml b/config/packages/workflow.yaml index 0840c7d..8e48179 100644 --- a/config/packages/workflow.yaml +++ b/config/packages/workflow.yaml @@ -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 diff --git a/src/Controller/ReadingListController.php b/src/Controller/ReadingListController.php index bead369..7404158 100644 --- a/src/Controller/ReadingListController.php +++ b/src/Controller/ReadingListController.php @@ -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 } #[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 $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) + } + } } } } diff --git a/src/Controller/ReadingListWizardController.php b/src/Controller/ReadingListWizardController.php index 5f08240..b3e59f8 100644 --- a/src/Controller/ReadingListWizardController.php +++ b/src/Controller/ReadingListWizardController.php @@ -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 ]); } + #[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 { diff --git a/src/Dto/CategoryDraft.php b/src/Dto/CategoryDraft.php index 1f451fe..225c93b 100644 --- a/src/Dto/CategoryDraft.php +++ b/src/Dto/CategoryDraft.php @@ -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; + } +} diff --git a/src/Service/ReadingListManager.php b/src/Service/ReadingListManager.php new file mode 100644 index 0000000..c5bd67e --- /dev/null +++ b/src/Service/ReadingListManager.php @@ -0,0 +1,303 @@ + + */ + 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 []; + } +} diff --git a/src/Service/ReadingListWorkflowService.php b/src/Service/ReadingListWorkflowService.php new file mode 100644 index 0000000..1b33cb3 --- /dev/null +++ b/src/Service/ReadingListWorkflowService.php @@ -0,0 +1,208 @@ +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 + */ + 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, + }; + } +} + diff --git a/src/Twig/Components/ReadingListDraftComponent.php b/src/Twig/Components/ReadingListDraftComponent.php index bd4267b..dd0b466 100644 --- a/src/Twig/Components/ReadingListDraftComponent.php +++ b/src/Twig/Components/ReadingListDraftComponent.php @@ -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 #[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 $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 } } + #[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 ]); } $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 } $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); } } diff --git a/src/Twig/Components/ReadingListDropdown.php b/src/Twig/Components/ReadingListDropdown.php new file mode 100644 index 0000000..2c31a48 --- /dev/null +++ b/src/Twig/Components/ReadingListDropdown.php @@ -0,0 +1,40 @@ +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; + } +} + diff --git a/src/Twig/Components/ReadingListQuickAddComponent.php b/src/Twig/Components/ReadingListQuickAddComponent.php new file mode 100644 index 0000000..fdd9b84 --- /dev/null +++ b/src/Twig/Components/ReadingListQuickAddComponent.php @@ -0,0 +1,176 @@ +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; + } + } +} + diff --git a/src/Twig/Components/ReadingListQuickInputComponent.php b/src/Twig/Components/ReadingListQuickInputComponent.php new file mode 100644 index 0000000..d077d7b --- /dev/null +++ b/src/Twig/Components/ReadingListQuickInputComponent.php @@ -0,0 +1,172 @@ +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' => '']; + } +} diff --git a/src/Twig/Components/ReadingListSelectorComponent.php b/src/Twig/Components/ReadingListSelectorComponent.php new file mode 100644 index 0000000..208a309 --- /dev/null +++ b/src/Twig/Components/ReadingListSelectorComponent.php @@ -0,0 +1,50 @@ +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'); + } +} diff --git a/src/Twig/Components/ReadingListWorkflowStatus.php b/src/Twig/Components/ReadingListWorkflowStatus.php new file mode 100644 index 0000000..5b812a3 --- /dev/null +++ b/src/Twig/Components/ReadingListWorkflowStatus.php @@ -0,0 +1,79 @@ +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 => [], + }; + } +} + diff --git a/templates/components/ReadingListDraftComponent.html.twig b/templates/components/ReadingListDraftComponent.html.twig index c00099e..efcef08 100644 --- a/templates/components/ReadingListDraftComponent.html.twig +++ b/templates/components/ReadingListDraftComponent.html.twig @@ -1,43 +1,79 @@ -
-

Title: {{ draft.title ?: 'Reading List' }}

-

Slug: {{ draft.slug }}

+
+
+ {# Workflow Status #} + {% if draft %} + + {% endif %} - {% if draft.summary %}

{{ draft.summary }}

{% endif %} + {% if editingMeta %} +
+
+ + +
+
+ + +
+
+ + +
+
+ {% else %} +
+
+

{{ draft.title ?: 'My Reading List' }}

+ {% if draft.summary %} +

{{ draft.summary }}

+ {% endif %} +

Slug: {{ draft.slug }}

+
+ +
+ {% endif %} -

Articles

- {% if draft.articles is not empty %} -
    - {% for coord in draft.articles %} -
  • - {{ coord }} - -
  • - {% endfor %} -
- {% else %} -

No articles yet. Use search or paste an naddr to add some.

- {% endif %} +
-
-
- -
- -
-
- {% if naddrError %}
{{ naddrError }}
{% endif %} - {% if naddrSuccess %}
{{ naddrSuccess }}
{% endif %} -
+
+

Articles ({{ draft.articles|length }})

+ {% if draft.articles is not empty %} + + {% endif %} +
- + {% if draft.articles is not empty %} +
    + {% for coord in draft.articles %} +
  • + {{ coord }} + +
  • + {% endfor %} +
+ {% else %} +

No articles yet. Use the quick add or search below to add articles.

+ {% endif %} + +
+ + +
diff --git a/templates/components/ReadingListDropdown.html.twig b/templates/components/ReadingListDropdown.html.twig new file mode 100644 index 0000000..bf1ff38 --- /dev/null +++ b/templates/components/ReadingListDropdown.html.twig @@ -0,0 +1,66 @@ +{% set lists = this.getListsWithArticles() %} +{% set publishUrl = path('api-index-publish') %} +{% set csrfToken = csrf_token('nostr_publish') %} + +
+ + + +
+
diff --git a/templates/components/ReadingListQuickAddComponent.html.twig b/templates/components/ReadingListQuickAddComponent.html.twig new file mode 100644 index 0000000..f2dffc4 --- /dev/null +++ b/templates/components/ReadingListQuickAddComponent.html.twig @@ -0,0 +1,56 @@ +
+
+ + πŸ“š Reading List + {% if itemCount > 0 %} + {{ itemCount }} + {% endif %} + +
+ + {% if isExpanded %} +
+
+
+
Add to Reading List
+ +
+ + + +
+
+ +
+ +
+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if success %} +
{{ success }}
+ {% endif %} + +
+ {{ itemCount }} article{{ itemCount != 1 ? 's' : '' }} in list + +
+
+
+ {% endif %} +
diff --git a/templates/components/ReadingListQuickInputComponent.html.twig b/templates/components/ReadingListQuickInputComponent.html.twig new file mode 100644 index 0000000..18b2802 --- /dev/null +++ b/templates/components/ReadingListQuickInputComponent.html.twig @@ -0,0 +1,27 @@ +
+
+
+ + +
+ +
+ + {% if error %} +
{{ error }}
+ {% endif %} + {% if success %} +
{{ success }}
+ {% endif %} +
+ diff --git a/templates/components/ReadingListSelectorComponent.html.twig b/templates/components/ReadingListSelectorComponent.html.twig new file mode 100644 index 0000000..76821c0 --- /dev/null +++ b/templates/components/ReadingListSelectorComponent.html.twig @@ -0,0 +1,40 @@ +
+
+ + + + + {% if currentDraft %} +
+ + Current: {{ currentDraft.title ?: 'New Reading List' }} + {% if currentDraft.articles|length > 0 %} +
Articles: {{ currentDraft.articles|length }} + {% endif %} +
+
+ {% endif %} +
+
+ diff --git a/templates/components/ReadingListWorkflowStatus.html.twig b/templates/components/ReadingListWorkflowStatus.html.twig new file mode 100644 index 0000000..4711832 --- /dev/null +++ b/templates/components/ReadingListWorkflowStatus.html.twig @@ -0,0 +1,62 @@ +
+
+ +
+
Workflow Status
+ + {{ this.statusMessage }} + +
+ + {# Progress bar with Stimulus controller #} +
+
+
+ + {# Current state info #} +
+

+ Current State: + + {{ this.currentState|replace({'_': ' '})|title }} + +

+ + {% if this.nextSteps is not empty %} +
+

Next Steps:

+
    + {% for step in this.nextSteps %} +
  • {{ step }}
  • + {% endfor %} +
+
+ {% endif %} +
+ + {# Publish button state #} + {% if this.readyToPublish %} +
+ βœ“ Your reading list is ready to publish! +
+ {% elseif this.currentState == 'published' %} +
+ βœ“ Published successfully! +
+ {% endif %} +
+
diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index 189f2f8..7131f91 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -29,7 +29,7 @@ {{ 'heading.logIn'|trans }} -
{% block body %}{% endblock %} + + {# Floating reading list quick add widget #} + {% if app.user %} + + {% endif %}
- - +
+ +
{% endblock %} diff --git a/templates/reading_list/add_article_confirm.html.twig b/templates/reading_list/add_article_confirm.html.twig new file mode 100644 index 0000000..71b26c1 --- /dev/null +++ b/templates/reading_list/add_article_confirm.html.twig @@ -0,0 +1,77 @@ +{% extends 'layout.html.twig' %} + +{% block body %} +
+
+
+
+
+

πŸ“š Add Article to Reading List

+ +
+ + Article coordinate:
+ {{ coordinate }} +
+
+ +
+
+ + +
+ {# Create new list option #} + + + {# Existing lists #} + {% if availableLists is not empty %} + {% for list in availableLists %} + + {% endfor %} + {% endif %} +
+
+ +
+ + + Cancel + +
+
+
+
+ +
+ + After adding, you'll be taken to the compose page where you can add more articles or publish your list. + +
+
+
+
+{% endblock %} + diff --git a/templates/reading_list/compose.html.twig b/templates/reading_list/compose.html.twig index 454e034..20c0f3c 100644 --- a/templates/reading_list/compose.html.twig +++ b/templates/reading_list/compose.html.twig @@ -7,11 +7,69 @@ -
- -
-

Search articles and click β€œAdd to list”.

- +
+ + {% if addedArticle %} + + {% endif %} + +
+ {# Left sidebar - Reading List Preview #} +
+
+ +
+
+ + {# Main content - Simple tabbed interface #} +
+ {# List Selector #} +
+
+ +
+
+ + {# Tabbed content #} + + +
+ {# Paste tab #} +
+
+
+
Quick Add Articles
+

Paste article links below (one per line)

+ +
+
+
+ + {# Search tab #} + +
+
-
+ {% endblock %} diff --git a/templates/reading_list/index.html.twig b/templates/reading_list/index.html.twig index dafacb7..7fb78fd 100644 --- a/templates/reading_list/index.html.twig +++ b/templates/reading_list/index.html.twig @@ -11,34 +11,36 @@ - {% if lists is defined and lists|length %} -
    - {% for item in lists %} -
  • -
    -
    -

    {{ item.title }}

    - {% if item.summary %}

    {{ item.summary }}

    {% endif %} - slug: {{ item.slug ?: 'β€”' }} β€’ created: {{ item.createdAt|date('Y-m-d H:i') }} -
    -
    - Open Composer - {% if item.slug %} - View - - - - - {% endif %} -
    -
    -
  • - {% endfor %} -
- {% else %} -

No reading lists found.

- {% endif %} +
+ {% if lists is defined and lists|length %} +
    + {% for item in lists %} +
  • +
    +
    +

    {{ item.title }}

    + {% if item.summary %}

    {{ item.summary }}

    {% endif %} + slug: {{ item.slug ?: 'β€”' }} β€’ created: {{ item.createdAt|date('Y-m-d H:i') }} +
    +
    + Open Composer + {% if item.slug %} + View + + + + + {% endif %} +
    +
    +
  • + {% endfor %} +
+ {% else %} +

No reading lists found.

+ {% endif %} +
{% endblock %}