diff --git a/assets/controllers/nostr_single_sign_controller.js b/assets/controllers/nostr_single_sign_controller.js new file mode 100644 index 0000000..ecedf7a --- /dev/null +++ b/assets/controllers/nostr_single_sign_controller.js @@ -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 = ''; + 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 = `
${message}
`; + } + } + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } +} + diff --git a/assets/styles/layout.css b/assets/styles/layout.css index 0076c36..a3b2d00 100644 --- a/assets/styles/layout.css +++ b/assets/styles/layout.css @@ -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 { 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 { .user-menu { position: fixed; - top: 150px; + top: 100px; width: calc(21vw - 10px); min-width: 150px; max-width: 270px; @@ -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; } diff --git a/assets/styles/utilities.css b/assets/styles/utilities.css index 59d98ac..797a9f8 100644 --- a/assets/styles/utilities.css +++ b/assets/styles/utilities.css @@ -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} diff --git a/src/Controller/ReadingListController.php b/src/Controller/ReadingListController.php new file mode 100644 index 0000000..9452280 --- /dev/null +++ b/src/Controller/ReadingListController.php @@ -0,0 +1,24 @@ +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'); + } +} diff --git a/src/Controller/ReadingListWizardController.php b/src/Controller/ReadingListWizardController.php new file mode 100644 index 0000000..5f08240 --- /dev/null +++ b/src/Controller/ReadingListWizardController.php @@ -0,0 +1,136 @@ +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; + } +} diff --git a/src/Form/CategoryArticlesType.php b/src/Form/CategoryArticlesType.php index 28e5344..69cf5be 100644 --- a/src/Form/CategoryArticlesType.php +++ b/src/Form/CategoryArticlesType.php @@ -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 ]); } } - diff --git a/src/Form/CategoryType.php b/src/Form/CategoryType.php index 1a1a547..f8d8719 100644 --- a/src/Form/CategoryType.php +++ b/src/Form/CategoryType.php @@ -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 ]); } } - diff --git a/src/Form/MagazineSetupType.php b/src/Form/MagazineSetupType.php index ec9097e..582efb2 100644 --- a/src/Form/MagazineSetupType.php +++ b/src/Form/MagazineSetupType.php @@ -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 ]); } } - diff --git a/src/Twig/Components/ReadingListDraftComponent.php b/src/Twig/Components/ReadingListDraftComponent.php new file mode 100644 index 0000000..c071120 --- /dev/null +++ b/src/Twig/Components/ReadingListDraftComponent.php @@ -0,0 +1,64 @@ +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); + } +} diff --git a/src/Twig/Components/SearchComponent.php b/src/Twig/Components/SearchComponent.php index 70bc41b..8d7d0b9 100644 --- a/src/Twig/Components/SearchComponent.php +++ b/src/Twig/Components/SearchComponent.php @@ -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; final class SearchComponent { use DefaultActionTrait; + use ComponentToolsTrait; #[LiveProp(writable: true, useSerializerForHydration: true)] public string $query = ''; @@ -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 } } + #[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 $boolQuery = new BoolQuery(); $boolQuery->addMust($multiMatch); $boolQuery->addMustNot(new Query\Wildcard('slug', '*/*')); - $mainQuery->setQuery($boolQuery); // Use the collapse field to prevent duplicate content diff --git a/templates/components/Header.html.twig b/templates/components/Header.html.twig index b2b9794..523e73d 100644 --- a/templates/components/Header.html.twig +++ b/templates/components/Header.html.twig @@ -1,14 +1,6 @@
-
-
    - {% for category in cats %} -
  • - {% endfor %} -
diff --git a/templates/components/ReadingListDraftComponent.html.twig b/templates/components/ReadingListDraftComponent.html.twig new file mode 100644 index 0000000..d770ac5 --- /dev/null +++ b/templates/components/ReadingListDraftComponent.html.twig @@ -0,0 +1,25 @@ +
+

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

+

Slug: {{ draft.slug }}

+ + {% if draft.summary %}

{{ draft.summary }}

{% endif %} + +

Articles

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

No articles yet. Use search to add some.

+ {% endif %} + + +
+ diff --git a/templates/components/SearchComponent.html.twig b/templates/components/SearchComponent.html.twig index 99b7385..b3f92c6 100644 --- a/templates/components/SearchComponent.html.twig +++ b/templates/components/SearchComponent.html.twig @@ -47,7 +47,41 @@ {% if this.results is not empty %} - + {% if this.selectMode %} +
+
    + {% 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 %} +
  • +
    +
    + +

    {{ art.title }}

    + {% if art.summary %}

    {{ art.summary }}

    {% endif %} + {{ coordinate }} +
    +
    + +
    +
    +
  • + {% endif %} + {% endfor %} +
+
+ {% else %} + + {% endif %} {% elseif this.query is not empty %}

{{ 'text.noResults'|trans }}

{% endif %} diff --git a/templates/components/UserMenu.html.twig b/templates/components/UserMenu.html.twig index a5c0a0c..5423c55 100644 --- a/templates/components/UserMenu.html.twig +++ b/templates/components/UserMenu.html.twig @@ -12,11 +12,16 @@ {% endif %}
  • - Write an article + Write Article
  • - Create magazine + Compose List
  • + {% if is_granted('ROLE_EDITOR') %} +
  • + Create Magazine +
  • + {% endif %}
  • {{ 'heading.search'|trans }}
  • diff --git a/templates/reading_list/compose.html.twig b/templates/reading_list/compose.html.twig new file mode 100644 index 0000000..c53aa37 --- /dev/null +++ b/templates/reading_list/compose.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Compose Reading List

    + +
    + +
    +{% endblock %} + +{% block aside %} +
    +

    Search & Add

    +

    Search articles and click “Add to list”.

    + +
    +{% endblock %} diff --git a/templates/reading_list/index.html.twig b/templates/reading_list/index.html.twig new file mode 100644 index 0000000..54770b1 --- /dev/null +++ b/templates/reading_list/index.html.twig @@ -0,0 +1,10 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Your Reading Lists

    +

    Create and share curated reading lists.

    + +{% endblock %} diff --git a/templates/reading_list/reading_articles.html.twig b/templates/reading_list/reading_articles.html.twig new file mode 100644 index 0000000..d1b4bfe --- /dev/null +++ b/templates/reading_list/reading_articles.html.twig @@ -0,0 +1,32 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Attach Articles

    + +
    +

    Add article coordinates to your reading list. Use format 30023:pubkey:slug.

    +
    + + {{ form_start(form) }} + {{ form_row(form.title, {label: 'Reading list title'}) }} + +

    Articles

    +
    +
      + {% for item in form.articles %} +
    • {{ form_row(item) }}
    • + {% endfor %} +
    + +
    + +
    + Back + Cancel + +
    + {{ form_end(form) }} +{% endblock %} diff --git a/templates/reading_list/reading_review.html.twig b/templates/reading_list/reading_review.html.twig new file mode 100644 index 0000000..d583ce1 --- /dev/null +++ b/templates/reading_list/reading_review.html.twig @@ -0,0 +1,62 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Review & Sign Reading List

    + +
    +

    Review your reading list. When ready, click Sign & Publish. Your NIP-07 extension will be used to sign the event.

    +
    + +
    +

    Reading List

    +
      +
    • Title: {{ draft.title }}
    • +
    • Summary: {{ draft.summary ?: '—' }}
    • +
    • Tags: {{ draft.tags is defined and draft.tags|length ? draft.tags|join(', ') : '—' }}
    • +
    • Slug: {{ draft.slug }}
    • +
    + + {% if draft.articles is defined and draft.articles|length %} +
    + Articles: +
      + {% for a in draft.articles %} +
    • {{ a }}
    • + {% endfor %} +
    +
    + {% else %} +
    No articles yet.
    + {% endif %} +
    + +
    +
    + Show event preview (JSON) +
    +
    {{ eventJson }}
    +
    +
    +
    + +
    +
    +

    Final event (with your pubkey)

    +
    
    +    
    + +
    + Cancel + +
    + +
    +
    +{% endblock %} + diff --git a/templates/reading_list/reading_setup.html.twig b/templates/reading_list/reading_setup.html.twig new file mode 100644 index 0000000..c20afa6 --- /dev/null +++ b/templates/reading_list/reading_setup.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Create Reading List

    + + {{ form_start(form) }} + {{ form_row(form.title, {label: 'Title'}) }} + {{ form_row(form.summary) }} + {{ form_row(form.tags) }} + +
    + Cancel + +
    + {{ form_end(form) }} +{% endblock %} +