Browse Source

Curated lists

imwald
Nuša Pukšič 6 months ago
parent
commit
1b4dfab164
  1. 108
      assets/controllers/nostr_single_sign_controller.js
  2. 44
      assets/styles/layout.css
  3. 4
      assets/styles/utilities.css
  4. 24
      src/Controller/ReadingListController.php
  5. 136
      src/Controller/ReadingListWizardController.php
  6. 8
      src/Form/CategoryArticlesType.php
  7. 3
      src/Form/CategoryType.php
  8. 7
      src/Form/MagazineSetupType.php
  9. 64
      src/Twig/Components/ReadingListDraftComponent.php
  10. 29
      src/Twig/Components/SearchComponent.php
  11. 8
      templates/components/Header.html.twig
  12. 25
      templates/components/ReadingListDraftComponent.html.twig
  13. 34
      templates/components/SearchComponent.html.twig
  14. 9
      templates/components/UserMenu.html.twig
  15. 17
      templates/reading_list/compose.html.twig
  16. 10
      templates/reading_list/index.html.twig
  17. 32
      templates/reading_list/reading_articles.html.twig
  18. 62
      templates/reading_list/reading_review.html.twig
  19. 17
      templates/reading_list/reading_setup.html.twig

108
assets/controllers/nostr_single_sign_controller.js

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

44
assets/styles/layout.css

@ -56,13 +56,6 @@ header { @@ -56,13 +56,6 @@ header {
left: 0;
}
/* Hamburger button */
.hamburger {
cursor: pointer;
display: none; /* Hidden on desktop */
font-size: 26px;
}
.header__logo {
display: flex;
width: 100%;
@ -80,36 +73,9 @@ header { @@ -80,36 +73,9 @@ header {
z-index: 1000;
}
/* Mobile Styles */
@media (max-width: 768px) {
.header__logo {
justify-content: space-around;
}
.header__categories {
display: none;
flex-direction: column;
padding-top: 10px;
}
.header__categories.active {
display: flex;
}
.hamburger {
display: block;
align-self: center;
}
.header__categories ul {
flex-direction: column;
gap: 10px;
}
}
/* Main content */
main {
margin-top: 140px;
margin-top: 90px;
flex-grow: 1;
padding: 1em;
word-break: break-word;
@ -117,7 +83,7 @@ main { @@ -117,7 +83,7 @@ main {
.user-menu {
position: fixed;
top: 150px;
top: 100px;
width: calc(21vw - 10px);
min-width: 150px;
max-width: 270px;
@ -130,10 +96,8 @@ main { @@ -130,10 +96,8 @@ main {
/* Right sidebar */
aside {
width: 190px;
min-width: 150px;
flex-shrink: 0;
flex-grow: 0;
margin-top: 90px;
width: 300px;
padding: 1em;
}

4
assets/styles/utilities.css

@ -10,11 +10,13 @@ @@ -10,11 +10,13 @@
.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}
/* Display & layout */
.d-flex{display:flex!important}
.d-flex{display:flex!important;flex-direction:column}
.d-inline{display:inline!important}
.d-block{display:block!important}
.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}
.flex-row{flex-direction:row}
/* Lists */
.list-unstyled{list-style:none;padding-left:0;margin:0}

24
src/Controller/ReadingListController.php

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

136
src/Controller/ReadingListWizardController.php

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

8
src/Form/CategoryArticlesType.php

@ -23,10 +23,15 @@ class CategoryArticlesType extends AbstractType @@ -23,10 +23,15 @@ class CategoryArticlesType extends AbstractType
])
->add('articles', CollectionType::class, [
'entry_type' => TextType::class,
'entry_options' => [
'attr' => [
'placeholder' => '30023:pubkey:slug'
],
],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'label' => 'Article coordinates (kind:npub|pubkey:slug)',
'label' => 'Article coordinates (kind:pubkey:slug)',
'prototype' => true,
]);
}
@ -38,4 +43,3 @@ class CategoryArticlesType extends AbstractType @@ -38,4 +43,3 @@ class CategoryArticlesType extends AbstractType
]);
}
}

3
src/Form/CategoryType.php

@ -27,10 +27,12 @@ class CategoryType extends AbstractType @@ -27,10 +27,12 @@ class CategoryType extends AbstractType
->add('summary', TextType::class, [
'label' => 'Summary',
'required' => false,
'empty_data' => '',
])
->add('tags', TextType::class, [
'label' => 'Tags (comma separated)',
'required' => false,
'empty_data' => '',
]);
$builder->get('tags')->addModelTransformer($this->transformer);
@ -43,4 +45,3 @@ class CategoryType extends AbstractType @@ -43,4 +45,3 @@ class CategoryType extends AbstractType
]);
}
}

7
src/Form/MagazineSetupType.php

@ -28,19 +28,23 @@ class MagazineSetupType extends AbstractType @@ -28,19 +28,23 @@ class MagazineSetupType extends AbstractType
])
->add('summary', TextType::class, [
'label' => 'Description / summary',
'required' => true,
'required' => false,
'empty_data' => '',
])
->add('imageUrl', TextType::class, [
'label' => 'Logo / image URL',
'required' => false,
'empty_data' => '',
])
->add('language', TextType::class, [
'label' => 'Language (optional)',
'required' => false,
'empty_data' => '',
])
->add('tags', TextType::class, [
'label' => 'Tags (comma separated, optional)',
'required' => false,
'empty_data' => '',
])
->add('categories', CollectionType::class, [
'entry_type' => CategoryType::class,
@ -61,4 +65,3 @@ class MagazineSetupType extends AbstractType @@ -61,4 +65,3 @@ class MagazineSetupType extends AbstractType
]);
}
}

64
src/Twig/Components/ReadingListDraftComponent.php

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

29
src/Twig/Components/SearchComponent.php

@ -13,6 +13,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveAction; @@ -13,6 +13,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\Contracts\Cache\CacheInterface;
use Elastica\Query;
use Elastica\Query\BoolQuery;
@ -22,6 +23,7 @@ use Elastica\Query\MultiMatch; @@ -22,6 +23,7 @@ use Elastica\Query\MultiMatch;
final class SearchComponent
{
use DefaultActionTrait;
use ComponentToolsTrait;
#[LiveProp(writable: true, useSerializerForHydration: true)]
public string $query = '';
@ -42,6 +44,10 @@ final class SearchComponent @@ -42,6 +44,10 @@ final class SearchComponent
#[LiveProp]
public int $resultsPerPage = 12;
// New: render results with add-to-list buttons when true
#[LiveProp(writable: true)]
public bool $selectMode = false;
private const string SESSION_KEY = 'last_search_results';
private const string SESSION_QUERY_KEY = 'last_search_query';
@ -143,6 +149,28 @@ final class SearchComponent @@ -143,6 +149,28 @@ final class SearchComponent
}
}
#[LiveAction]
public function addToReadingList(?string $coordinate = null): void
{
if ($coordinate === null || $coordinate === '') {
return; // nothing to add
}
$session = $this->requestStack->getSession();
$draft = $session->get('read_wizard');
if (!$draft instanceof \App\Dto\CategoryDraft) {
$draft = new \App\Dto\CategoryDraft();
$draft->title = $draft->title ?: 'Reading List';
if (!$draft->slug) {
$draft->slug = substr(bin2hex(random_bytes(6)), 0, 8);
}
}
if (!in_array($coordinate, $draft->articles, true)) {
$draft->articles[] = $coordinate;
}
$session->set('read_wizard', $draft);
$this->emit('readingListUpdated');
}
/**
* Perform a quick search on title and summary only
*/
@ -163,7 +191,6 @@ final class SearchComponent @@ -163,7 +191,6 @@ final class SearchComponent
$boolQuery = new BoolQuery();
$boolQuery->addMust($multiMatch);
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
$mainQuery->setQuery($boolQuery);
// Use the collapse field to prevent duplicate content

8
templates/components/Header.html.twig

@ -1,14 +1,6 @@ @@ -1,14 +1,6 @@
<header class="header" data-controller="menu" {{ attributes }}>
<div class="header__logo">
<h1 class="brand"><a href="/">newsroom</a></h1>
<button class="hamburger btn btn-secondary" data-action="click->menu#toggle" aria-label="Menu">&#9776;</button>
</div>
<div class="header__categories" data-menu-target="menu">
<ul>
{% for category in cats %}
<li><twig:Molecules:CategoryLink coordinate="{{ category }}" /></li>
{% endfor %}
</ul>
</div>
<div data-controller="progress-bar">
<div id="progress-bar" data-progress-bar-target="bar"></div>

25
templates/components/ReadingListDraftComponent.html.twig

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

34
templates/components/SearchComponent.html.twig

@ -47,7 +47,41 @@ @@ -47,7 +47,41 @@
<!-- Results -->
{% if this.results is not empty %}
{% if this.selectMode %}
<div class="article-list">
<ul class="list-unstyled d-grid gap-2">
{% for art in this.results %}
{% if art.slug is not empty and art.title is not empty %}
{% set artKind = art.kind ? art.kind.value : 30023 %}
{% set coordinate = artKind ~ ':' ~ art.pubkey ~ ':' ~ art.slug %}
<li class="card p-3">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-fill">
<div class="metadata">
<small>by <twig:Molecules:UserFromNpub ident="{{ art.pubkey }}" /></small>
<small class="ms-2">{{ art.createdAt|date('F j Y') }}</small>
</div>
<a href="{{ path('article-slug', {slug: art.slug}) }}"><h3 class="h5 m-0">{{ art.title }}</h3></a>
{% if art.summary %}<p class="mt-2 small line-clamp-5">{{ art.summary }}</p>{% endif %}
<code class="small text-muted">{{ coordinate }}</code>
</div>
<div>
<button
class="btn btn-sm btn-primary"
data-action="live#action"
data-live-action-param="addToReadingList"
data-live-coordinate-param="{{ coordinate }}"
>Add to list</button>
</div>
</div>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% else %}
<twig:Organisms:CardList :list="this.results" class="article-list" />
{% endif %}
{% elseif this.query is not empty %}
<p><small>{{ 'text.noResults'|trans }}</small></p>
{% endif %}

9
templates/components/UserMenu.html.twig

@ -12,11 +12,16 @@ @@ -12,11 +12,16 @@
{% endif %}
<ul class="user-nav">
<li>
<a href="{{ path('editor-create') }}">Write an article</a>
<a href="{{ path('editor-create') }}">Write Article</a>
</li>
<li>
<a href="{{ path('mag_wizard_setup') }}">Create magazine</a>
<a href="{{ path('reading_list_index') }}">Compose List</a>
</li>
{% if is_granted('ROLE_EDITOR') %}
<li>
<a href="{{ path('mag_wizard_setup') }}">Create Magazine</a>
</li>
{% endif %}
<li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>
</li>

17
templates/reading_list/compose.html.twig

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

10
templates/reading_list/index.html.twig

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

32
templates/reading_list/reading_articles.html.twig

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

62
templates/reading_list/reading_review.html.twig

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

17
templates/reading_list/reading_setup.html.twig

@ -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…
Cancel
Save