19 changed files with 585 additions and 58 deletions
@ -0,0 +1,108 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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