19 changed files with 585 additions and 58 deletions
@ -0,0 +1,108 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
export default class extends Controller { |
||||||
|
static targets = ['status', 'publishButton', 'computedPreview']; |
||||||
|
static values = { |
||||||
|
event: String, |
||||||
|
publishUrl: String, |
||||||
|
csrfToken: String |
||||||
|
}; |
||||||
|
|
||||||
|
async connect() { |
||||||
|
try { |
||||||
|
await this.preparePreview(); |
||||||
|
} catch (_) {} |
||||||
|
} |
||||||
|
|
||||||
|
async preparePreview() { |
||||||
|
try { |
||||||
|
const skeleton = JSON.parse(this.eventValue || '{}'); |
||||||
|
let pubkey = '<pubkey>'; |
||||||
|
if (window.nostr && typeof window.nostr.getPublicKey === 'function') { |
||||||
|
try { pubkey = await window.nostr.getPublicKey(); } catch (_) {} |
||||||
|
} |
||||||
|
const preview = JSON.parse(JSON.stringify(skeleton)); |
||||||
|
preview.pubkey = pubkey; |
||||||
|
if (this.hasComputedPreviewTarget) { |
||||||
|
this.computedPreviewTarget.textContent = JSON.stringify(preview, null, 2); |
||||||
|
} |
||||||
|
} catch (_) {} |
||||||
|
} |
||||||
|
|
||||||
|
async signAndPublish(event) { |
||||||
|
event.preventDefault(); |
||||||
|
|
||||||
|
if (!window.nostr) { |
||||||
|
this.showError('Nostr extension not found'); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (!this.publishUrlValue || !this.csrfTokenValue) { |
||||||
|
this.showError('Missing config'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
this.publishButtonTarget.disabled = true; |
||||||
|
try { |
||||||
|
const pubkey = await window.nostr.getPublicKey(); |
||||||
|
const skeleton = JSON.parse(this.eventValue || '{}'); |
||||||
|
|
||||||
|
this.ensureCreatedAt(skeleton); |
||||||
|
this.ensureContent(skeleton); |
||||||
|
skeleton.pubkey = pubkey; |
||||||
|
|
||||||
|
this.showStatus('Signing reading list…'); |
||||||
|
const signed = await window.nostr.signEvent(skeleton); |
||||||
|
|
||||||
|
this.showStatus('Publishing…'); |
||||||
|
await this.publishSigned(signed); |
||||||
|
|
||||||
|
this.showSuccess('Published reading list successfully'); |
||||||
|
} catch (e) { |
||||||
|
console.error(e); |
||||||
|
this.showError(e.message || 'Publish failed'); |
||||||
|
} finally { |
||||||
|
this.publishButtonTarget.disabled = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async publishSigned(signedEvent) { |
||||||
|
const res = 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 (!res.ok) { |
||||||
|
const data = await res.json().catch(() => ({})); |
||||||
|
throw new Error(data.error || `HTTP ${res.status}`); |
||||||
|
} |
||||||
|
return res.json(); |
||||||
|
} |
||||||
|
|
||||||
|
ensureCreatedAt(evt) { |
||||||
|
if (!evt.created_at) evt.created_at = Math.floor(Date.now() / 1000); |
||||||
|
} |
||||||
|
ensureContent(evt) { |
||||||
|
if (typeof evt.content !== 'string') evt.content = ''; |
||||||
|
} |
||||||
|
|
||||||
|
showStatus(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
showSuccess(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
showError(message) { |
||||||
|
if (this.hasStatusTarget) { |
||||||
|
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,24 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
class ReadingListController extends AbstractController |
||||||
|
{ |
||||||
|
#[Route('/reading-list', name: 'reading_list_index')] |
||||||
|
public function index(): Response |
||||||
|
{ |
||||||
|
return $this->render('reading_list/index.html.twig'); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/reading-list/compose', name: 'reading_list_compose')] |
||||||
|
public function compose(): Response |
||||||
|
{ |
||||||
|
return $this->render('reading_list/compose.html.twig'); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,136 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
use App\Form\CategoryArticlesType; |
||||||
|
use App\Form\CategoryType; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Request; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
class ReadingListWizardController extends AbstractController |
||||||
|
{ |
||||||
|
private const SESSION_KEY = 'read_wizard'; |
||||||
|
|
||||||
|
#[Route('/reading-list/wizard/setup', name: 'read_wizard_setup')] |
||||||
|
public function setup(Request $request): Response |
||||||
|
{ |
||||||
|
$draft = $this->getDraft($request) ?? new CategoryDraft(); |
||||||
|
|
||||||
|
$form = $this->createForm(CategoryType::class, $draft); |
||||||
|
$form->handleRequest($request); |
||||||
|
if ($form->isSubmitted() && $form->isValid()) { |
||||||
|
/** @var CategoryDraft $draft */ |
||||||
|
$draft = $form->getData(); |
||||||
|
if (!$draft->slug) { |
||||||
|
$draft->slug = $this->slugifyWithRandom($draft->title); |
||||||
|
} |
||||||
|
$this->saveDraft($request, $draft); |
||||||
|
return $this->redirectToRoute('read_wizard_articles'); |
||||||
|
} |
||||||
|
|
||||||
|
return $this->render('reading_list/reading_setup.html.twig', [ |
||||||
|
'form' => $form->createView(), |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/reading-list/wizard/articles', name: 'read_wizard_articles')] |
||||||
|
public function articles(Request $request): Response |
||||||
|
{ |
||||||
|
$draft = $this->getDraft($request); |
||||||
|
if (!$draft) { |
||||||
|
return $this->redirectToRoute('read_wizard_setup'); |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure at least one input is visible initially |
||||||
|
if (empty($draft->articles)) { |
||||||
|
$draft->articles = ['']; |
||||||
|
} |
||||||
|
|
||||||
|
$form = $this->createForm(CategoryArticlesType::class, $draft); |
||||||
|
$form->handleRequest($request); |
||||||
|
if ($form->isSubmitted() && $form->isValid()) { |
||||||
|
/** @var CategoryDraft $draft */ |
||||||
|
$draft = $form->getData(); |
||||||
|
// ensure slug exists |
||||||
|
if (!$draft->slug) { |
||||||
|
$draft->slug = $this->slugifyWithRandom($draft->title); |
||||||
|
} |
||||||
|
$this->saveDraft($request, $draft); |
||||||
|
return $this->redirectToRoute('read_wizard_review'); |
||||||
|
} |
||||||
|
|
||||||
|
return $this->render('reading_list/reading_articles.html.twig', [ |
||||||
|
'form' => $form->createView(), |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/reading-list/wizard/review', name: 'read_wizard_review')] |
||||||
|
public function review(Request $request): Response |
||||||
|
{ |
||||||
|
$draft = $this->getDraft($request); |
||||||
|
if (!$draft) { |
||||||
|
return $this->redirectToRoute('read_wizard_setup'); |
||||||
|
} |
||||||
|
|
||||||
|
// Build a single category event skeleton |
||||||
|
$tags = []; |
||||||
|
$tags[] = ['d', $draft->slug]; |
||||||
|
$tags[] = ['type', 'reading-list']; |
||||||
|
if ($draft->title) { $tags[] = ['title', $draft->title]; } |
||||||
|
if ($draft->summary) { $tags[] = ['summary', $draft->summary]; } |
||||||
|
foreach ($draft->tags as $t) { $tags[] = ['t', $t]; } |
||||||
|
foreach ($draft->articles as $a) { |
||||||
|
if (is_string($a) && $a !== '') { $tags[] = ['a', $a]; } |
||||||
|
} |
||||||
|
|
||||||
|
$event = [ |
||||||
|
'kind' => 30040, |
||||||
|
'created_at' => time(), |
||||||
|
'tags' => $tags, |
||||||
|
'content' => '', |
||||||
|
]; |
||||||
|
|
||||||
|
return $this->render('reading_list/reading_review.html.twig', [ |
||||||
|
'draft' => $draft, |
||||||
|
'eventJson' => json_encode($event, JSON_UNESCAPED_SLASHES), |
||||||
|
'csrfToken' => $this->container->get('security.csrf.token_manager')->getToken('nostr_publish')->getValue(), |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/reading-list/wizard/cancel', name: 'read_wizard_cancel', methods: ['GET'])] |
||||||
|
public function cancel(Request $request): Response |
||||||
|
{ |
||||||
|
$this->clearDraft($request); |
||||||
|
$this->addFlash('info', 'Reading list creation canceled.'); |
||||||
|
return $this->redirectToRoute('home'); |
||||||
|
} |
||||||
|
|
||||||
|
private function getDraft(Request $request): ?CategoryDraft |
||||||
|
{ |
||||||
|
$data = $request->getSession()->get(self::SESSION_KEY); |
||||||
|
return $data instanceof CategoryDraft ? $data : null; |
||||||
|
} |
||||||
|
|
||||||
|
private function saveDraft(Request $request, CategoryDraft $draft): void |
||||||
|
{ |
||||||
|
$request->getSession()->set(self::SESSION_KEY, $draft); |
||||||
|
} |
||||||
|
|
||||||
|
private function clearDraft(Request $request): void |
||||||
|
{ |
||||||
|
$request->getSession()->remove(self::SESSION_KEY); |
||||||
|
} |
||||||
|
|
||||||
|
private function slugifyWithRandom(string $title): string |
||||||
|
{ |
||||||
|
$slug = strtolower(trim(preg_replace('/[^A-Za-z0-9-]+/', '-', $title)) ?? ''); |
||||||
|
$slug = trim(preg_replace('/-+/', '-', $slug) ?? '', '-'); |
||||||
|
$rand = substr(bin2hex(random_bytes(4)), 0, 6); |
||||||
|
return $slug !== '' ? ($slug . '-' . $rand) : $rand; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Twig\Components; |
||||||
|
|
||||||
|
use App\Dto\CategoryDraft; |
||||||
|
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\DefaultActionTrait; |
||||||
|
|
||||||
|
#[AsLiveComponent] |
||||||
|
final class ReadingListDraftComponent |
||||||
|
{ |
||||||
|
use DefaultActionTrait; |
||||||
|
|
||||||
|
public ?CategoryDraft $draft = null; |
||||||
|
|
||||||
|
public function __construct(private readonly RequestStack $requestStack) |
||||||
|
{ |
||||||
|
} |
||||||
|
|
||||||
|
public function mount(): void |
||||||
|
{ |
||||||
|
$this->reloadFromSession(); |
||||||
|
} |
||||||
|
|
||||||
|
#[LiveListener('readingListUpdated')] |
||||||
|
public function refresh(): void |
||||||
|
{ |
||||||
|
$this->reloadFromSession(); |
||||||
|
} |
||||||
|
|
||||||
|
#[LiveAction] |
||||||
|
public function remove(string $coordinate): void |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$draft = $session->get('read_wizard'); |
||||||
|
if ($draft instanceof CategoryDraft) { |
||||||
|
$draft->articles = array_values(array_filter($draft->articles, fn($c) => $c !== $coordinate)); |
||||||
|
$session->set('read_wizard', $draft); |
||||||
|
$this->draft = $draft; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function reloadFromSession(): void |
||||||
|
{ |
||||||
|
$session = $this->requestStack->getSession(); |
||||||
|
$data = $session->get('read_wizard'); |
||||||
|
if ($data instanceof CategoryDraft) { |
||||||
|
$this->draft = $data; |
||||||
|
if (!$this->draft->slug) { |
||||||
|
$this->draft->slug = substr(bin2hex(random_bytes(6)), 0, 8); |
||||||
|
$session->set('read_wizard', $this->draft); |
||||||
|
} |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
$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); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
<div {{ attributes }}> |
||||||
|
<h2>Title: <b>{{ draft.title ?: 'Reading List' }}</b></h2> |
||||||
|
<p><small>Slug: {{ draft.slug }}</small></p> |
||||||
|
|
||||||
|
{% if draft.summary %}<p>{{ draft.summary }}</p>{% endif %} |
||||||
|
|
||||||
|
<h3>Articles</h3> |
||||||
|
{% if draft.articles is not empty %} |
||||||
|
<ul class="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> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
{% else %} |
||||||
|
<p><small>No articles yet. Use search to add some.</small></p> |
||||||
|
{% endif %} |
||||||
|
|
||||||
|
<div class="mt-3"> |
||||||
|
<a class="btn btn-primary" href="{{ path('read_wizard_review') }}">Review & Sign</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
@ -0,0 +1,17 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Compose Reading List</h1> |
||||||
|
|
||||||
|
<section> |
||||||
|
<twig:ReadingListDraftComponent /> |
||||||
|
</section> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block aside %} |
||||||
|
<div class="mt-2"> |
||||||
|
<h1>Search & Add</h1> |
||||||
|
<p>Search articles and click “Add to list”.</p> |
||||||
|
<twig:SearchComponent :selectMode="true" currentRoute="compose" /> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Your Reading Lists</h1> |
||||||
|
<p>Create and share curated reading lists.</p> |
||||||
|
<div class="d-flex flex-row gap-2"> |
||||||
|
<a class="btn btn-primary" href="{{ path('read_wizard_setup') }}">Create a Reading List</a> |
||||||
|
<a class="btn btn-secondary" href="{{ path('reading_list_compose') }}">Open Composer</a> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Attach Articles</h1> |
||||||
|
|
||||||
|
<div class="notice info"> |
||||||
|
<p>Add article coordinates to your reading list. Use format <code>30023:pubkey:slug</code>.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{{ form_start(form) }} |
||||||
|
{{ form_row(form.title, {label: 'Reading list title'}) }} |
||||||
|
|
||||||
|
<h3>Articles</h3> |
||||||
|
<div |
||||||
|
{{ stimulus_controller('form-collection') }} |
||||||
|
data-form-collection-index-value="{{ form.articles|length > 0 ? form.articles|last.vars.name + 1 : 0 }}" |
||||||
|
data-form-collection-prototype-value="{{ form_widget(form.articles.vars.prototype)|e('html_attr') }}"> |
||||||
|
<ul class="d-flex gap-2 list-unstyled" {{ stimulus_target('form-collection', 'collectionContainer') }}> |
||||||
|
{% for item in form.articles %} |
||||||
|
<li class="d-flex flex-row">{{ form_row(item) }}</li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
<button type="button" class="btn btn-secondary mt-2" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add article</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mt-3 d-flex flex-row gap-2"> |
||||||
|
<a class="btn btn-secondary" href="{{ path('read_wizard_setup') }}">Back</a> |
||||||
|
<a class="btn btn-secondary" href="{{ path('read_wizard_cancel') }}">Cancel</a> |
||||||
|
<button class="btn btn-primary">Review</button> |
||||||
|
</div> |
||||||
|
{{ form_end(form) }} |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,62 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Review & Sign Reading List</h1> |
||||||
|
|
||||||
|
<div class="notice info"> |
||||||
|
<p>Review your reading list. When ready, click Sign & Publish. Your NIP-07 extension will be used to sign the event.</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<section class="mb-4"> |
||||||
|
<h2>Reading List</h2> |
||||||
|
<ul> |
||||||
|
<li><strong>Title:</strong> {{ draft.title }}</li> |
||||||
|
<li><strong>Summary:</strong> {{ draft.summary ?: '—' }}</li> |
||||||
|
<li><strong>Tags:</strong> {{ draft.tags is defined and draft.tags|length ? draft.tags|join(', ') : '—' }}</li> |
||||||
|
<li><strong>Slug:</strong> {{ draft.slug }}</li> |
||||||
|
</ul> |
||||||
|
|
||||||
|
{% if draft.articles is defined and draft.articles|length %} |
||||||
|
<div class="mt-2"> |
||||||
|
<strong>Articles:</strong> |
||||||
|
<ul> |
||||||
|
{% for a in draft.articles %} |
||||||
|
<li><code>{{ a }}</code></li> |
||||||
|
{% endfor %} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<div class="mt-2"><small>No articles yet.</small></div> |
||||||
|
{% endif %} |
||||||
|
</section> |
||||||
|
|
||||||
|
<section class="mb-4"> |
||||||
|
<details> |
||||||
|
<summary>Show event preview (JSON)</summary> |
||||||
|
<div class="mt-2"> |
||||||
|
<pre class="small">{{ eventJson }}</pre> |
||||||
|
</div> |
||||||
|
</details> |
||||||
|
</section> |
||||||
|
|
||||||
|
<div |
||||||
|
{{ stimulus_controller('nostr-single-sign', { |
||||||
|
event: eventJson, |
||||||
|
publishUrl: path('api-index-publish'), |
||||||
|
csrfToken: csrfToken |
||||||
|
}) }} |
||||||
|
> |
||||||
|
<section class="mb-3"> |
||||||
|
<h3>Final event (with your pubkey)</h3> |
||||||
|
<pre class="small" data-nostr-single-sign-target="computedPreview"></pre> |
||||||
|
</section> |
||||||
|
|
||||||
|
<div class="d-flex flex-row gap-2"> |
||||||
|
<a class="btn btn-secondary" href="{{ path('read_wizard_cancel') }}">Cancel</a> |
||||||
|
<button class="btn btn-primary" data-nostr-single-sign-target="publishButton" data-action="click->nostr-single-sign#signAndPublish">Sign & Publish</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="mt-3" data-nostr-single-sign-target="status"></div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
@ -0,0 +1,17 @@ |
|||||||
|
{% extends 'base.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<h1>Create Reading List</h1> |
||||||
|
|
||||||
|
{{ form_start(form) }} |
||||||
|
{{ form_row(form.title, {label: 'Title'}) }} |
||||||
|
{{ form_row(form.summary) }} |
||||||
|
{{ form_row(form.tags) }} |
||||||
|
|
||||||
|
<div class="mt-3 d-flex flex-row gap-2"> |
||||||
|
<a class="btn btn-secondary" href="{{ path('read_wizard_cancel') }}">Cancel</a> |
||||||
|
<button class="btn btn-primary">Add articles</button> |
||||||
|
</div> |
||||||
|
{{ form_end(form) }} |
||||||
|
{% endblock %} |
||||||
|
|
||||||
Loading…
Reference in new issue