Browse Source

Revise routing, part 1

imwald
Nuša Pukšič 4 months ago
parent
commit
e298269920
  1. 1
      assets/app.js
  2. 115
      assets/controllers/sidebar_toggle_controller.js
  3. 27
      assets/styles/app.css
  4. 6
      assets/styles/button.css
  5. 43
      assets/styles/form.css
  6. 72
      assets/styles/landing.css
  7. 162
      assets/styles/layout.css
  8. 13
      assets/styles/theme.css
  9. 6
      assets/styles/utilities.css
  10. 3
      config/packages/twig.yaml
  11. 1
      config/packages/web_profiler.yaml
  12. 9
      src/Controller/Administration/MagazineAdminController.php
  13. 72
      src/Controller/DefaultController.php
  14. 18
      src/Controller/StaticController.php
  15. 4
      src/Entity/Event.php
  16. 18
      src/Twig/Components/Molecules/CategoryLink.php
  17. 40
      src/Twig/Components/Organisms/ZineList.php
  18. 2
      templates/admin/analytics.html.twig
  19. 2
      templates/admin/articles.html.twig
  20. 2
      templates/admin/magazine_editor.html.twig
  21. 2
      templates/admin/magazines.html.twig
  22. 2
      templates/admin/roles.html.twig
  23. 2
      templates/admin/transactions.html.twig
  24. 18
      templates/base.html.twig
  25. 2
      templates/bundles/TwigBundle/Exception/error.html.twig
  26. 2
      templates/bundles/TwigBundle/Exception/error404.html.twig
  27. 6
      templates/components/Header.html.twig
  28. 9
      templates/components/Molecules/CategoryLink.html.twig
  29. 2
      templates/components/Organisms/ReadingListList.html.twig
  30. 35
      templates/components/Organisms/ZineList.html.twig
  31. 7
      templates/components/UserMenu.html.twig
  32. 2
      templates/event/index.html.twig
  33. 115
      templates/home.html.twig
  34. 5
      templates/layout-full.html.twig
  35. 41
      templates/layout.html.twig
  36. 14
      templates/magazine/magazine_articles.html.twig
  37. 2
      templates/magazine/magazine_review.html.twig
  38. 4
      templates/magazine/magazine_setup.html.twig
  39. 2
      templates/pages/article.html.twig
  40. 2
      templates/pages/author.html.twig
  41. 2
      templates/pages/category.html.twig
  42. 124
      templates/pages/editor.html.twig
  43. 0
      templates/pages/journals.html.twig
  44. 5
      templates/pages/latest.html.twig
  45. 14
      templates/pages/lists.html.twig
  46. 2
      templates/pages/magazine.html.twig
  47. 18
      templates/pages/newsstand.html.twig
  48. 2
      templates/pages/nzine-editor.html.twig
  49. 2
      templates/pages/nzine.html.twig
  50. 8
      templates/pages/search.html.twig
  51. 2
      templates/reading_list/compose.html.twig
  52. 20
      templates/reading_list/index.html.twig
  53. 2
      templates/reading_list/reading_articles.html.twig
  54. 2
      templates/reading_list/reading_review.html.twig
  55. 2
      templates/reading_list/reading_setup.html.twig
  56. 71
      templates/static/about.html.twig
  57. 103
      templates/static/landing.html.twig
  58. 2
      templates/static/pricing.html.twig
  59. 2
      templates/static/roadmap.html.twig
  60. 2
      templates/static/tos.html.twig
  61. 45
      templates/static/unfold.html.twig

1
assets/app.js

@ -20,6 +20,7 @@ import './styles/a2hs.css';
import './styles/analytics.css'; import './styles/analytics.css';
import './styles/modal.css'; import './styles/modal.css';
import './styles/utilities.css'; import './styles/utilities.css';
import './styles/landing.css';
console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉'); console.log('This log comes from assets/app.js - welcome to AssetMapper! 🎉');

115
assets/controllers/sidebar_toggle_controller.js

@ -0,0 +1,115 @@
import { Controller } from '@hotwired/stimulus';
/*
* Sidebar toggle controller
* Controls showing/hiding nav (#leftNav) and aside (#rightNav) on mobile viewports.
* Uses aria-controls attribute of clicked toggle buttons.
*/
export default class extends Controller {
static targets = [ ];
connect() {
this.mediaQuery = window.matchMedia('(min-width: 769px)');
this.resizeListener = () => this.handleResize();
this.keyListener = (e) => this.handleKeydown(e);
this.clickOutsideListener = (e) => this.handleDocumentClick(e);
this.mediaQuery.addEventListener('change', this.resizeListener);
document.addEventListener('keydown', this.keyListener);
document.addEventListener('click', this.clickOutsideListener);
this.handleResize();
}
disconnect() {
this.mediaQuery.removeEventListener('change', this.resizeListener);
document.removeEventListener('keydown', this.keyListener);
document.removeEventListener('click', this.clickOutsideListener);
}
toggle(event) {
const controlId = event.currentTarget.getAttribute('aria-controls');
const el = document.getElementById(controlId);
if (!el) return;
if (el.classList.contains('is-open')) {
this.closeElement(el);
} else {
this.openElement(el);
}
this.syncAria(controlId);
}
close(event) {
// Close button inside a sidebar
const container = event.currentTarget.closest('nav, aside');
if (container) {
this.closeElement(container);
this.syncAria(container.id);
}
}
openElement(el) {
// Only apply overlay behavior on mobile
if (this.isDesktop()) return; // grid already shows them
el.classList.add('is-open');
document.body.classList.add('no-scroll');
}
closeElement(el) {
el.classList.remove('is-open');
if (!this.anyOpen()) {
document.body.classList.remove('no-scroll');
}
}
anyOpen() {
return !!document.querySelector('nav.is-open, aside.is-open');
}
syncAria(id) {
// Update any toggle buttons that control this id
const expanded = document.getElementById(id)?.classList.contains('is-open') || false;
document.querySelectorAll(`[aria-controls="${id}"]`).forEach(btn => {
btn.setAttribute('aria-expanded', expanded.toString());
});
}
handleResize() {
if (this.isDesktop()) {
// Ensure both sidebars are visible in desktop layout
['leftNav', 'rightNav'].forEach(id => {
const el = document.getElementById(id);
if (el) el.classList.remove('is-open');
this.syncAria(id);
});
document.body.classList.remove('no-scroll');
} else {
// On mobile ensure aria-expanded is false unless explicitly opened
['leftNav', 'rightNav'].forEach(id => this.syncAria(id));
}
}
handleKeydown(e) {
if (e.key === 'Escape') {
const open = document.querySelectorAll('nav.is-open, aside.is-open');
if (open.length) {
open.forEach(el => this.closeElement(el));
['leftNav', 'rightNav'].forEach(id => this.syncAria(id));
}
}
}
handleDocumentClick(e) {
if (this.isDesktop()) return; // only needed mobile
const open = document.querySelectorAll('nav.is-open, aside.is-open');
if (!open.length) return;
const inside = e.target.closest('nav, aside, .mobile-toggles');
if (!inside) {
open.forEach(el => this.closeElement(el));
['leftNav', 'rightNav'].forEach(id => this.syncAria(id));
}
}
isDesktop() {
return this.mediaQuery.matches;
}
}

27
assets/styles/app.css

@ -27,7 +27,14 @@ h1 {
h1.brand { h1.brand {
font-family: var(--brand-font), serif; font-family: var(--brand-font), serif;
font-size: 3.6rem; color: var(--color-primary);
font-size: 3.2rem;
}
@media screen and (max-width: 600px) {
h1.brand {
font-size: 2.3rem;
}
} }
h1.brand a { h1.brand a {
@ -38,6 +45,11 @@ h2 {
font-size: 2.2rem; font-size: 2.2rem;
} }
h2.brand {
font-family: var(--brand-font), serif;
color: var(--color-primary);
}
h3 { h3 {
font-size: 2rem; font-size: 2rem;
} }
@ -262,10 +274,19 @@ div:nth-child(odd) .featured-list {
z-index: 1000; /* Ensure it stays on top */ z-index: 1000; /* Ensure it stays on top */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-around; justify-content: space-between;
align-items: center; align-items: center;
background-color: var(--color-bg); /* Black background */ background-color: var(--color-bg); /* Black background */
border-bottom: 1px solid var(--color-border); /* White bottom border */ border-bottom: 1px solid var(--color-border);
}
.header .container {
display: flex;
flex-direction: column;
align-items: start;
width: 100%;
max-width: 1200px;
padding: 0 20px;
} }
.header__categories ul { .header__categories ul {

6
assets/styles/button.css

@ -22,6 +22,12 @@ button:active, .btn:active {
border-color: var(--color-text); border-color: var(--color-text);
} }
a.btn, a.btn:hover, a.btn:active {
text-decoration: none;
display: inline-block;
text-align: center;
}
.btn.btn-secondary { .btn.btn-secondary {
color: var(--color-secondary); color: var(--color-secondary);
background-color: var(--color-bg); background-color: var(--color-bg);

43
assets/styles/form.css

@ -1,28 +1,43 @@
form, form > div { form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
clear: both; clear: both;
margin-bottom: 1em; margin-bottom: 1em;
} }
label { form > div:not(.actions) {
display: flex; display: flex;
clear: both; flex-direction: column;
margin-bottom: 1.5em;
} }
input { label {
display: flex; display: flex;
clear: both; clear: both;
} }
input, textarea, select { input, textarea, select {
display: block;
clear: both;
}
input, textarea, select, .quill {
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
border: 2px solid var(--color-border); border: 1px solid var(--color-primary);
padding: 10px;
border-radius: 0; /* Sharp edges */ border-radius: 0; /* Sharp edges */
} }
input, textarea, select {
padding: 10px;
}
textarea, input, select {
font-family: var(--font-family), sans-serif;
font-size: 1rem;
line-height: 1.5;
}
input:focus, textarea:focus, select:focus { input:focus, textarea:focus, select:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
outline: none; outline: none;
@ -56,14 +71,6 @@ input:focus, textarea:focus, select:focus {
flex-grow: 1; flex-grow: 1;
} }
textarea, input {
font-family: var(--font-family), sans-serif;
}
.quill {
border: 2px solid var(--color-border);
}
#editor { #editor {
margin: 0; margin: 0;
} }
@ -72,3 +79,11 @@ button:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
fieldset {
border: none;
}
.actions {
margin-bottom: 1.5em;
}

72
assets/styles/landing.css

@ -0,0 +1,72 @@
.center{ text-align: center; }
/* Eyebrow + lede */
.eyebrow{
text-transform: uppercase;
letter-spacing: .12em;
font-size: .8rem;
color: var(--color-text-mid);
margin: 0 0 1rem;
}
/* Hero split */
.ln-hero{
margin-top: 100px;
background: var(--color-bg);
color: var(--color-text);
}
.ln-hero__copy{ }
.ln-hero__frame{ display:flex; justify-content:center; }
.frame{
width: min(560px, 40vw);
border-radius: 14px;
border: var(--border-hair);
background: linear-gradient(180deg, color-mix(in oklab, var(--color-bg-light) 80%, var(--color-bg)), var(--color-bg));
overflow: hidden;
}
/* Generic section shell */
.ln-section{ position: relative; padding: 3.2rem 0; }
/* Split layout for features */
.ln-split{
display: grid; gap: var(--gutter);
grid-template-columns: 280px 1fr;
align-items: start;
}
.ln-split__aside{ position: sticky; top: 24px; align-self: start; }
.ln-split__body .measure{ max-width: 70ch; }
.cta-row{ margin-top: .6rem; }
/* Section palettes (alternating) */
.ln-section--search{
background: var(--color-accent-300);
}
.ln-section--newsstand{
background: color-mix(in oklab, var(--color-primary) 18%, var(--color-bg));
}
.ln-section--reader{
background: var(--color-bg-light);
}
.ln-section--editor{
background: var(--color-teal-400);
}
.ln-section--newsroom{
background: color-mix(in oklab, var(--color-bg-light) 82%, var(--color-bg));
}
.ln-section--marketplace{
background: color-mix(in oklab, var(--color-primary) 18%, var(--color-bg));
}
.ln-section--unfold{
background: color-mix(in oklab, var(--color-accent-warm) 60%, var(--color-bg));
}
/* Motion */
@media (prefers-reduced-motion: reduce){
*{ transition: none !important; animation: none !important; }
}

162
assets/styles/layout.css

@ -9,27 +9,40 @@
* - Footer (footer) * - Footer (footer)
**/ **/
/* Layout Container */ /* Layout Container */
.layout { .layout {
max-width: 100%; max-width: 100%;
width: 1200px; width: 100%;
margin: 0 auto; margin: 0 auto;
display: flex;
flex-grow: 1; flex-grow: 1;
display: grid;
grid-template-columns: 200px auto 200px;
}
nav, aside {
position: sticky;
top: 70px;
} }
nav { nav {
width: 21vw;
min-width: 150px;
max-width: 280px;
flex-shrink: 0;
padding: 1em; padding: 1em;
overflow-y: auto; /* Ensure the menu is scrollable if content is too long */ overflow-y: auto; /* Ensure the menu is scrollable if content is too long */
} }
header {
position: fixed;
width: 100vw;
min-height: 60px;
top: 0;
left: 0;
}
.header__logo {
display: flex;
width: 100%;
margin-left: 10px;
}
nav ul { nav ul {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
@ -49,19 +62,6 @@ nav a:hover {
text-decoration: none; text-decoration: none;
} }
header {
position: fixed;
width: 100vw;
top: 0;
left: 0;
}
.header__logo {
display: flex;
width: 100%;
justify-content: center;
}
#progress-bar { #progress-bar {
position: absolute; position: absolute;
left: 0; left: 0;
@ -75,16 +75,33 @@ header {
/* Main content */ /* Main content */
main { main {
display: flex;
flex-direction: column;
margin-top: 90px; margin-top: 90px;
flex-grow: 1; flex-grow: 1;
padding: 1em; padding: 0 1em;
word-break: break-word; word-break: break-word;
} }
main.static {
margin-left: auto;
margin-right: auto;
max-width: 1000px;
}
.w-container {
max-width: 1000px;
margin-left: auto;
margin-right: auto;
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
}
.user-menu { .user-menu {
position: fixed; position: fixed;
top: 100px; top: 100px;
width: calc(21vw - 10px);
min-width: 150px; min-width: 150px;
max-width: 270px; max-width: 270px;
} }
@ -96,9 +113,10 @@ main {
/* Right sidebar */ /* Right sidebar */
aside { aside {
margin-top: 90px; display: flex;
width: 300px; flex-direction: column;
padding: 1em; margin-top: 80px;
padding: 0 1em;
} }
table { table {
@ -121,14 +139,93 @@ dt {
margin-top: 10px; margin-top: 10px;
} }
.mobile-toggles {
display: none;
}
nav header,
aside header {
display: none;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.layout {
grid-template-columns: auto;
}
nav header,
aside header {
display: block;
}
nav, aside { nav, aside {
display: none; /* Hide the sidebars on small screens */ display: none; /* Hide the sidebars on small screens */
} }
main {
margin-top: 90px; .mobile-toggles {
width: 100%; display: flex;
justify-content: space-between;
gap: .5rem;
position: sticky;
top: 61px; /* below main header */
background: var(--color-bg);
z-index: 1050;
padding: .5rem 0;
}
.mobile-toggles .toggle {
background: var(--color-primary);
color: var(--color-text-contrast);
border: none;
padding: .4rem .75rem;
cursor: pointer;
font: inherit;
}
nav.is-open, aside.is-open {
display: block;
position: fixed;
top: 0;
bottom: 0;
width: 80%;
max-width: 260px;
background: var(--color-bg);
box-shadow: 0 0 12px rgba(0,0,0,.4);
overflow-y: auto;
z-index: 1200;
padding: 90px 1em 1em; /* space for global header height */
animation: slideIn .25s ease;
}
nav.is-open { left: 0; }
aside.is-open { right: 0; }
body.no-scroll { overflow: hidden; }
nav.is-open > header, aside.is-open > header {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: flex-end;
padding: .5rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
nav.is-open button.close, aside.is-open button.close {
background: transparent;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--color-text);
}
@keyframes slideIn {
from { transform: translateX(-15px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
} }
} }
@ -149,3 +246,8 @@ footer .footer-links {
.search input { .search input {
flex-grow: 1; flex-grow: 1;
} }
nav > header, aside > header { /* prevent global header fixed rules applying to nested headers */
position: static;
width: auto;
}

13
assets/styles/theme.css

@ -19,6 +19,19 @@
--heading-font: 'EB Garamond', serif; /* Set the font for headings */ --heading-font: 'EB Garamond', serif; /* Set the font for headings */
--brand-font: 'Lobster', serif; /* A classic, refined branding font */ --brand-font: 'Lobster', serif; /* A classic, refined branding font */
--brand-color: white; --brand-color: white;
--color-accent: #8FCB7E; /* fresh moss (main accent) */
--color-accent-strong: #B98BDC; /* lilac pop for headings/CTAs */
--color-accent-teal: #78C8BD; /* teal for tags/pills */
--color-accent-warm: #E1B574; /* warm highlight (badges/notes) */
--color-accent-600: #7FBF70;
--color-accent-500: #8FCB7E;
--color-accent-400: #A5D692;
--color-accent-300: #BCE3A9;
--color-teal-500: #78C8BD;
--color-teal-400: #8ED5CC;
--color-lilac-500: #B98BDC;
--color-lilac-400: #C7A1E3;
} }
[data-theme="light"] { [data-theme="light"] {

6
assets/styles/utilities.css

@ -8,7 +8,7 @@
.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important} .me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}
.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important} .mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important}
.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} .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}
.mx-auto{margin-left:auto!important;margin-right:auto!important}
/* Display & layout */ /* Display & layout */
.d-flex{display:flex!important;flex-direction:column} .d-flex{display:flex!important;flex-direction:column}
.d-inline{display:inline!important} .d-inline{display:inline!important}
@ -16,6 +16,10 @@
.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} .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} .flex-row{flex-direction:row}
.justify-content-between{justify-content:space-between!important}
.justify-content-center{justify-content:center!important}
.align-items-center{align-items:center!important}
.align-items-start{align-items:flex-start!important}
/* Lists */ /* Lists */
.list-unstyled{list-style:none;padding-left:0;margin:0} .list-unstyled{list-style:none;padding-left:0;margin:0}

3
config/packages/twig.yaml

@ -1,5 +1,8 @@
twig: twig:
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
globals:
project_npub: 'npub1ez09adke4vy8udk3y2skwst8q5chjgqzym9lpq4u58zf96zcl7kqyry2lz'
dev_npub: 'npub1636uujeewag8zv8593lcvdrwlymgqre6uax4anuq3y5qehqey05sl8qpl4'
when@test: when@test:
twig: twig:

1
config/packages/web_profiler.yaml

@ -7,3 +7,4 @@ when@local:
profiler: profiler:
only_exceptions: false only_exceptions: false
collect_serializer_data: true collect_serializer_data: true
collect: false

9
src/Controller/Administration/MagazineAdminController.php

@ -114,15 +114,6 @@ class MagazineAdminController extends AbstractController
]; ];
} }
// Sort alphabetically
usort($magazines, fn($a, $b) => strcmp($a['name'], $b['name']));
foreach ($magazines as &$mag) {
usort($mag['categories'], fn($a, $b) => strcmp($a['name'], $b['name']));
foreach ($mag['categories'] as &$cat) {
usort($cat['files'], fn($a, $b) => strcmp($a['name'], $b['name']));
}
}
return $this->render('admin/magazines.html.twig', [ return $this->render('admin/magazines.html.twig', [
'magazines' => $magazines, 'magazines' => $magazines,
]); ]);

72
src/Controller/DefaultController.php

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use Elastica\Collapse;
use Elastica\Query; use Elastica\Query;
use Elastica\Query\Terms; use Elastica\Query\Terms;
use Exception; use Exception;
@ -36,8 +37,11 @@ class DefaultController extends AbstractController
$item->expiresAfter(13600); // about 4 hours $item->expiresAfter(13600); // about 4 hours
// get latest articles // get latest articles
$q = new Query(); $q = new Query();
$q->setSize(50); $q->setSize(12);
$q->setSort(['createdAt' => ['order' => 'desc']]); $q->setSort(['createdAt' => ['order' => 'desc']]);
$col = new Collapse();
$col->setFieldname('pubkey');
$q->setCollapse($col);
return $this->finder->find($q); return $this->finder->find($q);
}); });
@ -46,12 +50,62 @@ class DefaultController extends AbstractController
]); ]);
} }
/**
* @throws Exception
*/
#[Route('/newsstand', name: 'newsstand')]
public function newsstand(): Response
{
return $this->render('pages/newsstand.html.twig');
}
/**
* @throws Exception
*/
#[Route('/lists', name: 'lists')]
public function lists(): Response
{
return $this->render('pages/lists.html.twig');
}
/**
* @throws InvalidArgumentException
*/
#[Route('/latest', name: 'latest')]
public function latest() : Response
{
$cacheKey = 'home-latest-articles';
$latest = $this->redisCache->get($cacheKey, function (ItemInterface $item) {
$item->expiresAfter(13600); // about 4 hours
// get latest articles
$q = new Query();
$q->setSize(12);
$q->setSort(['createdAt' => ['order' => 'desc']]);
$col = new Collapse();
$col->setFieldname('pubkey');
$q->setCollapse($col);
return $this->finder->find($q);
});
return $this->render('home.html.twig', [
'latest' => $latest
]);
}
/**
* @throws InvalidArgumentException
*/
#[Route('/mag/{mag}', name: 'magazine-index')]
public function magIndex($mag) : Response
{
return new Response('Not implemented yet', 501);
}
/** /**
* @throws InvalidArgumentException * @throws InvalidArgumentException
*/ */
#[Route('/cat/{slug}', name: 'magazine-category')] #[Route('/mag/{mag}/cat/{slug}', name: 'magazine-category')]
public function magCategory($slug, CacheInterface $redisCache, public function magCategory($mag, $slug, CacheInterface $redisCache,
FinderInterface $finder, FinderInterface $finder,
LoggerInterface $logger): Response LoggerInterface $logger): Response
{ {
@ -163,6 +217,18 @@ class DefaultController extends AbstractController
]); ]);
} }
/**
* @throws InvalidArgumentException
*/
#[Route('/list/{slug}', name: 'reading-list')]
public function readingList($slug, CacheInterface $redisCache,
FinderInterface $finder,
LoggerInterface $logger): Response
{
return new Response('Not implemented yet', 501);
}
/** /**
* OG Preview endpoint for URLs * OG Preview endpoint for URLs
*/ */

18
src/Controller/StaticController.php

@ -39,4 +39,22 @@ class StaticController extends AbstractController
{ {
return $this->render('static/manifest.webmanifest.twig', [], new Response('', 200, ['Content-Type' => 'application/manifest+json'])); return $this->render('static/manifest.webmanifest.twig', [], new Response('', 200, ['Content-Type' => 'application/manifest+json']));
} }
#[Route('/landing', name: 'landing')]
public function landing(): Response
{
return $this->render('static/landing.html.twig');
}
#[Route('/unfold', name: 'unfold')]
public function unfold(): Response
{
return $this->render('static/unfold.html.twig');
}
#[Route('/journals', name: 'journals_index')]
public function journalsIndex(): Response
{
return $this->render('pages/journals.html.twig');
}
} }

4
src/Entity/Event.php

@ -115,8 +115,8 @@ class Event
public function getTitle(): ?string public function getTitle(): ?string
{ {
foreach ($this->getTags() as $tag) { foreach ($this->getTags() as $tag) {
if (array_key_first($tag) === 'title') { if ($tag[0] === 'title') {
return $tag['title']; return $tag[1];
} }
} }
return null; return null;

18
src/Twig/Components/Molecules/CategoryLink.php

@ -10,6 +10,7 @@ final class CategoryLink
{ {
public string $title; public string $title;
public string $slug; public string $slug;
public ?string $mag = null; // magazine slug passed from parent (optional)
public function __construct(private CacheInterface $redisCache) public function __construct(private CacheInterface $redisCache)
{ {
@ -18,21 +19,28 @@ final class CategoryLink
public function mount($coordinate): void public function mount($coordinate): void
{ {
if (key_exists(1, $coordinate)) { if (key_exists(1, $coordinate)) {
$parts = explode(':', $coordinate[1]); $parts = explode(':', $coordinate[1], 3);
$this->slug = $parts[2]; // Expect format kind:pubkey:slug
$cat = $this->redisCache->get('magazine-' . $parts[2], function (){ $this->slug = $parts[2] ?? '';
$cat = $this->redisCache->get('magazine-' . $this->slug, function (){
return null; return null;
}); });
if ($cat === null) {
$this->title = $this->slug ?: 'Category';
return;
}
$tags = $cat->getTags(); $tags = $cat->getTags();
$title = array_filter($tags, function($tag) { $title = array_filter($tags, function($tag) {
return ($tag[0] === 'title'); return ($tag[0] === 'title');
}); });
$this->title = $title[array_key_first($title)][1]; $this->title = $title[array_key_first($title)][1] ?? ($this->slug ?: 'Category');
} else { } else {
dump($coordinate);die(); $this->title = 'Category';
$this->slug = '';
} }
} }

40
src/Twig/Components/Organisms/ZineList.php

@ -12,26 +12,40 @@ use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
final class ZineList final class ZineList
{ {
public array $nzines = []; public array $nzines = [];
public array $indices = [];
public function __construct(private readonly EntityManagerInterface $entityManager) public function __construct(private readonly EntityManagerInterface $entityManager)
{ {
} }
public function mount(?array $nzines = null): void public function mount(): void
{ {
$this->nzines = $nzines ?? $this->entityManager->getRepository(Nzine::class)->findAll();
if (count($this->nzines) > 0) { $nzines = $this->entityManager->getRepository(Event::class)->findBy(['kind' => KindsEnum::PUBLICATION_INDEX]);
// find indices for each nzine
foreach ($this->nzines as $zine) { // filter, only keep type === magazine
$ids = $this->entityManager->getRepository(Event::class)->findBy(['pubkey' => $zine->getNpub(), 'kind' => KindsEnum::PUBLICATION_INDEX]); $this->nzines = array_filter($nzines, function ($index) {
$id = array_filter($ids, function($k) use ($zine) { // look for tags
return $k->getSlug() == $zine->getSlug(); $tags = $index->getTags();
}); $isMagType = false;
if ($id) { $isTopLevel = false;
$this->indices[$zine->getNpub()] = array_pop($id); foreach ($tags as $tag) {
// only if tag 'type' with value 'magazine'
if ($tag[0] === 'type' && $tag[1] === 'magazine') {
$isMagType = true;
}
// and only contains other indices:
// a tags with kind 30040
if ($tag[0] === 'a' && $isTopLevel === false) {
// tag format: ['a', 'kind:pubkey:slug']
$parts = explode(':', $tag[1]);
if ($parts[0] == (string)KindsEnum::PUBLICATION_INDEX->value) {
$isTopLevel = true;
}
} }
} }
} return $isMagType && $isTopLevel;
});
} }
} }

2
templates/admin/analytics.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block title %}Visitor Analytics{% endblock %} {% block title %}Visitor Analytics{% endblock %}

2
templates/admin/articles.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Latest 50 Articles</h1> <h1>Latest 50 Articles</h1>

2
templates/admin/magazine_editor.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Edit Index: {{ title }}</h1> <h1>Edit Index: {{ title }}</h1>

2
templates/admin/magazines.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Magazines</h1> <h1>Magazines</h1>

2
templates/admin/roles.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>{{ 'heading.roles'|trans }}</h1> <h1>{{ 'heading.roles'|trans }}</h1>

2
templates/admin/transactions.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block title %}Credit Transactions{% endblock %} {% block title %}Credit Transactions{% endblock %}

18
templates/base.html.twig

@ -19,8 +19,7 @@
<meta property="og:image" content="{{ asset('icons/favicon.ico') }}" /> <meta property="og:image" content="{{ asset('icons/favicon.ico') }}" />
{% endblock %} {% endblock %}
{% block stylesheets %} {% block stylesheets %}{% endblock %}
{% endblock %}
{% block javascripts %} {% block javascripts %}
{% block importmap %}{{ importmap('app') }}{% endblock %} {% block importmap %}{{ importmap('app') }}{% endblock %}
{% endblock %} {% endblock %}
@ -29,19 +28,7 @@
<twig:Header /> <twig:Header />
{% block layout %}{% endblock %}
<div class="layout">
<nav>
<twig:UserMenu />
{% block nav %}{% endblock %}
</nav>
<main>
{% block body %}{% endblock %}
</main>
<aside>
{% block aside %}{% endblock %}
</aside>
</div>
<div data-controller="install-prompt"> <div data-controller="install-prompt">
<div <div
@ -54,7 +41,6 @@
</div> </div>
</div> </div>
<footer> <footer>
<twig:Footer /> <twig:Footer />
</footer> </footer>

2
templates/bundles/TwigBundle/Exception/error.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Internal Server Error</h1> <h1>Internal Server Error</h1>

2
templates/bundles/TwigBundle/Exception/error404.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Page not found</h1> <h1>Page not found</h1>

6
templates/components/Header.html.twig

@ -1,6 +1,8 @@
<header class="header" data-controller="menu" {{ attributes }}> <header class="header" data-controller="menu" {{ attributes }}>
<div class="header__logo"> <div class="container">
<h1 class="brand"><a href="/">newsroom</a></h1> <div class="header__logo">
<h1 class="brand"><a href="/">Decent Newsroom</a></h1>
</div>
</div> </div>
<div data-controller="progress-bar"> <div data-controller="progress-bar">
<div id="progress-bar" data-progress-bar-target="bar"></div> <div id="progress-bar" data-progress-bar-target="bar"></div>

9
templates/components/Molecules/CategoryLink.html.twig

@ -1,4 +1,9 @@
<a {% if path('magazine-category', { 'slug' : slug }) in app.request.uri %}class="active"{% endif %} {% if mag is defined and mag and slug %}
href="{{ path('magazine-category', { 'slug' : slug }) }}"> {% set catPath = path('magazine-category', { 'mag': mag, 'slug': slug }) %}
{% else %}
{% set catPath = '#' %}
{% endif %}
<a {% if catPath in app.request.uri %}class="active"{% endif %}
href="{{ catPath }}">
{{ title }} {{ title }}
</a> </a>

2
templates/components/Organisms/ReadingListList.html.twig

@ -4,7 +4,7 @@
<ul class="list-unstyled small d-grid gap-2"> <ul class="list-unstyled small d-grid gap-2">
{% for item in items %} {% for item in items %}
<li> <li>
<a href="{{ path('magazine-category', { slug: item.slug }) }}">{{ item.title }}</a> <a href="{{ path('reading-list', { slug: item.slug }) }}">{{ item.title }}</a>
<div><small class="text-muted">{{ item.createdAt|date('Y-m-d') }}</small></div> <div><small class="text-muted">{{ item.createdAt|date('Y-m-d') }}</small></div>
</li> </li>
{% endfor %} {% endfor %}

35
templates/components/Organisms/ZineList.html.twig

@ -1,23 +1,40 @@
<div {{ attributes }}> <div {{ attributes }}>
{% if nzines is not empty %} {% if nzines is not empty %}
{% for item in nzines %} {% for item in nzines %}
{% set idx = indices[item.npub] is defined ? indices[item.npub] : null %} <div class="card">
<a class="card" href="{{ path('nzine_view', { npub: item.npub })}}"> <div class="card-body text-center">
<div class="card-body">
<h3 class="card-title"> <h3 class="card-title">
{% if idx and idx.title %} <a href="{{ path('magazine-index', {mag: item.slug}) }}">
{{ idx.title }} {% if item and item.title %}
{{ item.title }}
{% elseif item.slug %} {% elseif item.slug %}
{{ item.slug }} {{ item.slug }}
{% else %} {% else %}
{{ item.npub }} Untitled Magazine
{% endif %} {% endif %}
</a>
</h3> </h3>
{% if idx and idx.summary %} {% if item and item.summary %}
<p class="hidden">{{ idx.summary }}</p> <p class="lede">{{ item.summary }}</p>
{% endif %}
{# Category links (from 'a' tags) #}
{% set categoryTags = [] %}
{% if item.tags is defined %}
{% for tag in item.tags %}
{% if tag[0] is defined and tag[0] == 'a' %}
{% set categoryTags = categoryTags|merge([tag]) %}
{% endif %}
{% endfor %}
{% endif %}
{% if categoryTags is not empty %}
<ul class="list-unstyled d-flex flex-row gap-3 justify-content-center mt-3">
{% for catTag in categoryTags %}
<li class="list-inline-item"><twig:Molecules:CategoryLink :coordinate="catTag" :mag="item.slug" /></li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</div> </div>
</a> </div>
<br> <br>
{% endfor %} {% endfor %}
{% else %} {% else %}

7
templates/components/UserMenu.html.twig

@ -1,4 +1,4 @@
<div class="user-menu" {{ attributes.defaults(stimulus_controller('login')) }}> <div {{ attributes.defaults(stimulus_controller('login')) }}>
{% if app.user %} {% if app.user %}
<div class="notice info"> <div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" /> <twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
@ -17,14 +17,11 @@
<li> <li>
<a href="{{ path('reading_list_index') }}">Compose List</a> <a href="{{ path('reading_list_index') }}">Compose List</a>
</li> </li>
{% if is_granted('ROLE_EDITOR') %} {% if is_granted('ROLE_ADMIN') %}
<li> <li>
<a href="{{ path('mag_wizard_setup') }}">Create Magazine</a> <a href="{{ path('mag_wizard_setup') }}">Create Magazine</a>
</li> </li>
{% endif %} {% endif %}
<li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>
</li>
<li> <li>
<a href="/logout" data-action="live#$render">{{ 'heading.logout'|trans }}</a> <a href="/logout" data-action="live#$render">{{ 'heading.logout'|trans }}</a>
</li> </li>

2
templates/event/index.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block title %}Nostr Event{% endblock %} {% block title %}Nostr Event{% endblock %}

115
templates/home.html.twig

@ -1,24 +1,103 @@
{% extends 'base.html.twig' %} {% extends 'layout-full.html.twig' %}
{% block nav %} {% block title %}Decent Newsroom — A decentralized platform for collaborative publishing{% endblock %}
{% endblock %}
{% block body %} {% block body %}
<div
data-controller="search-visibility"
data-action="search:changed@window->search-visibility#toggle"
>
<twig:SearchComponent :currentRoute="app.current_route" />
<div data-search-visibility-target="list">
<twig:Organisms:CardList :list="latest" />
</div>
</div>
{% endblock %}
{% block aside %} <section class="ln-hero d-flex gap-3 center">
<h6>Magazines</h6> <h1 class="brand">Decent Newsroom</h1>
<twig:Organisms:ZineList /> <p class="eyebrow">Collaborative publishing on Nostr</p>
<p class="lede mb-5">Explore, publish articles, and create magazines.</p>
</section>
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-5">
<h1>Newsstand</h1>
<p class="eyebrow">for the digital age</p>
</div>
<div class="mb-5">
<p class="measure">Flip through a world of magazines in one place. Build your lineup, catch fresh releases, anywhere.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('newsstand') }}">Explore the rack</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--search">
<div class="container mt-5">
<h1>Discover</h1>
<p class="eyebrow">hidden gems</p>
</div>
<div class="mb-5">
<p class="measure">Our search is specialized for long-form Nostr content.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('app_search_index') }}">Find what you like</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--lists">
<div class="container mt-5">
<h1>Reading Lists</h1>
<p class="eyebrow">for collections, curations, courses and more</p>
</div>
<div class="mb-5">
<p class="measure">Create ordered reading lists. Add more articles, reorder, republish.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('reading_list_index') }}">Start a list</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--editor">
<div class="container mt-5">
<h1>Article Editor</h1>
<p class="eyebrow">for anyone</p>
</div>
<div class="mb-5">
<p class="measure">Write, revise, and preview with ease. Save drafts, keep notes, and publish.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('editor-create') }}">Write an article</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--newsroom">
<div class="container mt-5">
<h1>Newsroom</h1>
<p class="eyebrow">like no other</p>
</div>
<div class="mb-5">
<p class="measure">Create your own magazine, define categories, manage submissions, and collaborate with editors and contributors.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('mag_wizard_setup') }}">Create a magazine</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--unfold">
<div class="container mt-5">
<h1>Unfold</h1>
<p class="eyebrow">your magazine</p>
</div>
<div class="mb-5">
<p class="measure">Unfold is a companion web app. Any magazine created on Decent Newsroom can be plugged in,
styled and deployed to a custom domain.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('unfold') }}">Learn more</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--marketplace">
<div class="container mt-5">
<h1>Media Marketplace</h1>
<p class="eyebrow">Coming soon</p>
</div>
<div class="mb-5">
<p class="measure">Commission custom visuals, discover stock you can actually use, and keep media and text in one creative flow.</p>
</div>
</section>
<h6>Lists</h6>
<twig:Organisms:ReadingListList />
{% endblock %} {% endblock %}

5
templates/layout-full.html.twig

@ -0,0 +1,5 @@
{% extends 'base.html.twig' %}
{% block layout %}
{% block body %}{% endblock %}
{% endblock %}

41
templates/layout.html.twig

@ -0,0 +1,41 @@
{% extends 'base.html.twig' %}
{% block layout %}
<div class="layout" data-controller="sidebar-toggle">
<div>
<nav id="leftNav">
<header>
<button class="close" data-action="click->sidebar-toggle#close" aria-label="Close left sidebar">✕</button>
</header>
<ul class="user-nav">
<li>
<a href="{{ path('newsstand') }}">Newsstand</a>
</li>
<li>
<a href="{{ path('lists') }}">Lists</a>
</li>
<li>
<a href="{{ path('app_search_index') }}">{{ 'heading.search'|trans }}</a>
</li>
</ul>
<twig:UserMenu />
{% block nav %}{% endblock %}
</nav>
</div>
<main>
<div class="mobile-toggles">
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->sidebar-toggle#toggle">☰ Left</button>
<button class="toggle" aria-controls="rightNav" aria-expanded="false" data-action="click->sidebar-toggle#toggle">Right ☰</button>
</div>
{% block body %}{% endblock %}
</main>
<div>
<aside id="rightNav">
<header>
<button class="close" data-action="click->sidebar-toggle#close" aria-label="Close right sidebar">✕</button>
</header>
{% block aside %}{% endblock %}
</aside>
</div>
</div>
{% endblock %}

14
templates/magazine/magazine_articles.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Attach Articles</h1> <h1>Attach Articles</h1>
@ -6,9 +6,8 @@
{{ form_start(form) }} {{ form_start(form) }}
{% for cat in form.categories %} {% for cat in form.categories %}
<div class="card mb-3"> <fieldset class="mb-3">
<div class="card-body"> <div class="card-title mb-3">{{ form_row(cat.title) }}</div>
<h5 class="card-title">{{ form_row(cat.title) }}</h5>
<div <div
{{ stimulus_controller('form-collection') }} {{ stimulus_controller('form-collection') }}
data-form-collection-index-value="{{ cat.articles|length > 0 ? cat.articles|last.vars.name + 1 : 0 }}" data-form-collection-index-value="{{ cat.articles|length > 0 ? cat.articles|last.vars.name + 1 : 0 }}"
@ -21,12 +20,11 @@
</ul> </ul>
<button class="btn btn-sm btn-secondary" type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add coordinate</button> <button class="btn btn-sm btn-secondary" type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add coordinate</button>
</div> </div>
</div> </fieldset>
</div>
{% endfor %} {% endfor %}
<div class="mt-3 d-flex gap-2"> <div class="mt-3 d-flex flex-row gap-2">
<a class="btn btn-outline-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a> <a class="btn btn-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a>
<button class="btn btn-primary">Next: Review & Sign</button> <button class="btn btn-primary">Next: Review & Sign</button>
</div> </div>

2
templates/magazine/magazine_review.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Review & Sign</h1> <h1>Review & Sign</h1>

4
templates/magazine/magazine_setup.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Create Magazine</h1> <h1>Create Magazine</h1>
@ -23,7 +23,7 @@
<button type="button" class="btn btn-secondary" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add category</button> <button type="button" class="btn btn-secondary" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add category</button>
</div> </div>
<div class="mt-3 d-flex gap-2"> <div class="mt-3 d-flex flex-row gap-2">
<a class="btn btn-outline-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a> <a class="btn btn-outline-secondary" href="{{ path('mag_wizard_cancel') }}">Cancel</a>
<button class="btn btn-primary">Next: Attach articles</button> <button class="btn btn-primary">Next: Attach articles</button>
</div> </div>

2
templates/pages/article.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block ogtags %} {% block ogtags %}
<meta property="og:title" content="{{ article.title }}"> <meta property="og:title" content="{{ article.title }}">

2
templates/pages/author.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}

2
templates/pages/category.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block ogtags %} {% block ogtags %}
<meta property="og:title" content="{{ category.title }} - Newsroom"> <meta property="og:title" content="{{ category.title }} - Newsroom">

124
templates/pages/editor.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% form_theme form _self %} {% form_theme form _self %}
@ -12,73 +12,73 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div class="w-container">
<div {{ stimulus_controller('nostr-publish', { <div {{ stimulus_controller('nostr-publish', {
publishUrl: path('api-article-publish'), publishUrl: path('api-article-publish'),
csrfToken: csrf_token('nostr_publish') csrfToken: csrf_token('nostr_publish')
}) }} data-nostr-publish-target="form" data-slug="{{ article.slug|default('') }}"> }) }} data-nostr-publish-target="form" data-slug="{{ article.slug|default('') }}">
<!-- Status messages --> <!-- Status messages -->
<div data-nostr-publish-target="status"></div> <div data-nostr-publish-target="status"></div>
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.title) }} {{ form_row(form.title) }}
{{ form_row(form.summary) }} {{ form_row(form.summary) }}
{{ form_row(form.content) }} {{ form_row(form.content) }}
{{ form_row(form.image) }} {{ form_row(form.image) }}
<div data-controller="image-upload"> <div class="actions" data-controller="image-upload">
<button type="button" <button type="button"
class="btn btn-secondary" class="btn btn-secondary"
data-action="click->image-upload#openDialog"> data-action="click->image-upload#openDialog">
Upload Image Upload Image
</button> </button>
<div data-image-upload-target="dialog" style="display:none;"> <div data-image-upload-target="dialog" style="display:none;">
<div class="iu-backdrop" data-action="click->image-upload#closeDialog"></div> <div class="iu-backdrop" data-action="click->image-upload#closeDialog"></div>
<div class="iu-modal"> <div class="iu-modal">
<div class="modal-header"> <div class="modal-header">
<h5>Upload Image</h5> <h5>Upload Image</h5>
<button type="button" class="close" data-action="click->image-upload#closeDialog">&times;</button> <button type="button" class="close" data-action="click->image-upload#closeDialog">&times;</button>
</div>
<div class="modal-body">
<div style="margin-bottom:1em;">
<label for="upload-provider">Upload to</label>
<select id="upload-provider" data-image-upload-target="provider">
<option value="sovbit">files.sovbit.host</option>
<option value="nostrbuild">nostr.build</option>
<option value="nostrcheck">nostrcheck.me</option>
</select>
</div> </div>
<div class="modal-body">
<div data-image-upload-target="dropArea" <div style="margin-bottom:1em;">
class="upload-area" <label for="upload-provider">Upload to</label>
style="border:2px dashed #ccc;padding:2em;text-align:center;cursor:pointer;min-height:4em;"> <select id="upload-provider" data-image-upload-target="provider">
<span>Drag &amp; drop or click to select an image</span> <option value="sovbit">files.sovbit.host</option>
<input type="file" accept="image/*" style="display:none;" data-image-upload-target="fileInput"> <option value="nostrbuild">nostr.build</option>
<option value="nostrcheck">nostrcheck.me</option>
</select>
</div>
<div data-image-upload-target="dropArea"
class="upload-area"
style="border:2px dashed #ccc;padding:2em;text-align:center;cursor:pointer;min-height:4em;">
<span>Drag &amp; drop or click to select an image</span>
<input type="file" accept="image/*" style="display:none;" data-image-upload-target="fileInput">
</div>
<div data-image-upload-target="progress" style="display:none;margin-top:1em;"></div>
<div data-image-upload-target="error" style="color:red;margin-top:1em;"></div>
</div> </div>
<div data-image-upload-target="progress" style="display:none;margin-top:1em;"></div>
<div data-image-upload-target="error" style="color:red;margin-top:1em;"></div>
</div> </div>
</div> </div>
</div> </div>
</div>
{{ form_row(form.topics) }} {{ form_row(form.topics) }}
<div class="actions"> <div class="actions">
<button type="button" <button type="button"
class="btn btn-primary" class="btn btn-primary"
data-nostr-publish-target="publishButton" data-nostr-publish-target="publishButton"
data-action="click->nostr-publish#publish"> data-action="click->nostr-publish#publish">
Publish Publish
</button> </button>
</div> </div>
{{ form_end(form) }} {{ form_end(form) }}
</div>
</div> </div>
{% endblock %} {% endblock %}

0
templates/pages/journals.html.twig

5
templates/pages/latest.html.twig

@ -0,0 +1,5 @@
{% extends 'layout.html.twig' %}
{% block body %}
<twig:Organisms:CardList :list="latest" />
{% endblock %}

14
templates/pages/lists.html.twig

@ -0,0 +1,14 @@
{% extends 'layout.html.twig' %}
{% block body %}
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-5 mb-5">
<h1>Reading Lists</h1>
<p class="eyebrow">for collections, curations, courses and more</p>
</div>
</section>
<section class="mb-5">
<twig:Organisms:ReadingListList />
</section>
{% endblock %}

2
templates/pages/magazine.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block ogtags %} {% block ogtags %}
<meta property="og:title" content="{{ magazine.title }}"> <meta property="og:title" content="{{ magazine.title }}">

18
templates/pages/newsstand.html.twig

@ -0,0 +1,18 @@
{% extends 'layout.html.twig' %}
{% block body %}
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-5 mb-5">
<h1>Newsstand</h1>
<p class="eyebrow">for the digital age</p>
</div>
</section>
<section class="mb-5">
<twig:Organisms:ZineList />
</section>
{% endblock %}
{% block aside %}
<h6>Lists</h6>
<twig:Organisms:ReadingListList />
{% endblock %}

2
templates/pages/nzine-editor.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
{% if nzine is not defined %} {% if nzine is not defined %}

2
templates/pages/nzine.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<div> <div>

8
templates/pages/search.html.twig

@ -1,8 +1,14 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<section class="d-flex gap-3 center ln-section--newsstand mb-5">
<div class="container mb-5 mt-5">
<h1>Discover</h1>
<p class="eyebrow">hidden gems</p>
</div>
</section>
<twig:SearchComponent /> <twig:SearchComponent />
{% endblock %} {% endblock %}

2
templates/reading_list/compose.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Compose Reading List</h1> <h1>Compose Reading List</h1>

20
templates/reading_list/index.html.twig

@ -1,8 +1,15 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Your Reading Lists</h1> <section class="d-flex gap-3 center ln-section--newsstand">
<p>Create and share curated reading lists.</p> <div class="container mt-5 mb-1">
<h1>Your Reading Lists</h1>
<p class="eyebrow">create and share curated reading lists</p>
</div>
<div class="cta-row mb-5">
<a class="btn btn-primary" href="{{ path('read_wizard_setup') }}">Create new</a>
</div>
</section>
{% if lists is defined and lists|length %} {% if lists is defined and lists|length %}
<ul class="list-unstyled d-grid gap-2 mb-4"> <ul class="list-unstyled d-grid gap-2 mb-4">
@ -17,9 +24,9 @@
<div class="d-flex flex-row gap-2"> <div class="d-flex flex-row gap-2">
<a class="btn btn-sm btn-primary" href="{{ path('reading_list_compose') }}">Open Composer</a> <a class="btn btn-sm btn-primary" href="{{ path('reading_list_compose') }}">Open Composer</a>
{% if item.slug %} {% if item.slug %}
<a class="btn btn-sm btn-outline-primary" href="{{ path('magazine-category', { slug: item.slug }) }}">View</a> <a class="btn btn-sm btn-outline-primary" href="{{ path('reading-list', { slug: item.slug }) }}">View</a>
<span data-controller="copy-to-clipboard"> <span data-controller="copy-to-clipboard">
<span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ absolute_url(path('magazine-category', { slug: item.slug })) }}</span> <span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ absolute_url(path('reading-list', { slug: item.slug })) }}</span>
<button class="btn btn-sm btn-secondary" <button class="btn btn-sm btn-secondary"
data-copy-to-clipboard-target="copyButton" data-copy-to-clipboard-target="copyButton"
data-action="click->copy-to-clipboard#copyToClipboard">Copy link</button> data-action="click->copy-to-clipboard#copyToClipboard">Copy link</button>
@ -34,7 +41,4 @@
<p><small>No reading lists found.</small></p> <p><small>No reading lists found.</small></p>
{% endif %} {% endif %}
<div class="d-flex flex-row gap-2">
<a class="btn btn-primary" href="{{ path('read_wizard_setup') }}">Create a Reading List</a>
</div>
{% endblock %} {% endblock %}

2
templates/reading_list/reading_articles.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Attach Articles</h1> <h1>Attach Articles</h1>

2
templates/reading_list/reading_review.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Review & Sign Reading List</h1> <h1>Review & Sign Reading List</h1>

2
templates/reading_list/reading_setup.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block body %} {% block body %}
<h1>Create Reading List</h1> <h1>Create Reading List</h1>

71
templates/static/about.html.twig

@ -1,36 +1,43 @@
{% extends 'base.html.twig' %} {% extends 'layout-full.html.twig' %}
{% block nav %}
{% endblock %}
{% block body %} {% block body %}
<h1>About Decent Newsroom</h1> <main class="d-flex gap-3 static">
<h2>Rebuilding Trust in Journalism</h2> <h1>About Decent Newsroom</h1>
<section>
<p>The internet, and especially social media, made news instant and abundant, but also cheap and unreliable. The value of simply reporting what happened and where has dropped to zero, <h2>Rebuilding Trust in Journalism</h2>
buried under waves of misinformation, clickbait, and AI-generated noise. Worse, sorting truth from falsehood now costs more than spreading lies.</p>
<p>The internet, and especially social media, made news instant and abundant, but also cheap and unreliable. The value of simply reporting what happened and where has dropped to zero,
<p>Decent Newsroom is our answer to this crisis. We are building a curated, decentralized magazine featuring high-quality, long-form journalism published on Nostr.</p> buried under waves of misinformation, clickbait, and AI-generated noise. Worse, sorting truth from falsehood now costs more than spreading lies.</p>
<h2>How It Works</h2> <p>Decent Newsroom is our answer to this crisis. We are building a curated, decentralized magazine featuring high-quality, long-form journalism published on Nostr.</p>
<dl> </section>
<dt><strong>Curated Excellence</strong></dt>
<dd>We showcase a selection of featured articles.</dd> <section>
<h2>Why It Matters</h2>
<dt><strong>Indexed & Searchable</strong></dt> <p>The age of the newsroom isn’t over—it’s just evolving. We believe in bringing back editorial standards, collaboration, and high-value reporting in a decentralized way. Decent Newsroom is here to cut through the noise and rebuild trust in digital journalism.</p>
<dd>Every article in Decent Newsroom is easily discoverable, improving access for readers and exposure for authors.</dd> </section>
<dd>Simple search is available to logged-in users.</dd>
<dd>Semantic search is in development.</dd> <section class="mb-5">
<p>To support us and get early access to the future of publishing, visit our crowdfunding page <a href="https://geyser.fund/project/newsroom" target="_blank">Geyser fund - Newsroom</a>.</p>
<dt><strong>Open to Writers & Publishers</strong> <span class="badge">Soon</span></dt> <br>
<dd>Content creators can request indexing for their work, making it searchable and eligible for inclusion.</dd> <p class="measure">For more info reach out to
<dd>Publishers can create and manage their own magazines.</dd> <twig:Molecules:UserFromNpub ident="{{ project_npub }}" /> or
</dl> <twig:Molecules:UserFromNpub ident="{{ dev_npub }}" />.
</p>
<h2>Why It Matters</h2> </section>
<p>The age of the newsroom isn’t over—it’s just evolving. We believe in bringing back editorial standards, collaboration, and high-value reporting in a decentralized way. Decent Newsroom is here to cut through the noise and rebuild trust in digital journalism.</p> </main>
<br> <section class="d-flex gap-3 center ln-section--newsstand">
<p>To support us and get early access to the future of publishing, visit our crowdfunding page <a href="https://geyser.fund/project/newsroom" target="_blank">Geyser fund - Newsroom</a>.</p> <div class="container mt-5">
<h1>Newsstand</h1>
<p class="eyebrow">for the digital age</p>
</div>
<div class="mb-5">
<p class="measure">Flip through a world of magazines in one place. Build your lineup, catch fresh releases, anywhere.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('newsstand') }}">Explore the rack</a>
</div>
</div>
</section>
{% endblock %} {% endblock %}

103
templates/static/landing.html.twig

@ -0,0 +1,103 @@
{% extends 'layout-full.html.twig' %}
{% block title %}Decent Newsroom — A decentralized platform for collaborative publishing{% endblock %}
{% block body %}
<section class="ln-hero d-flex gap-3 center">
<h1 class="brand">Decent Newsroom</h1>
<p class="eyebrow">Collaborative publishing on Nostr</p>
<p class="lede mb-5">Explore, publish articles, and create magazines.</p>
</section>
<section class="d-flex gap-3 center ln-section--newsstand">
<div class="container mt-5">
<h1>Newsstand</h1>
<p class="eyebrow">for the digital age</p>
</div>
<div class="mb-5">
<p class="measure">Flip through a world of magazines in one place. Build your lineup, catch fresh releases, anywhere.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('newsstand') }}">Explore the rack</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--search">
<div class="container mt-5">
<h1>Discover</h1>
<p class="eyebrow">hidden gems</p>
</div>
<div class="mb-5">
<p class="measure">Our search is specialized for long-form Nostr content.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('app_search_index') }}">Find what you like</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--lists">
<div class="container mt-5">
<h1>Reading Lists</h1>
<p class="eyebrow">for collections, curations, courses and more</p>
</div>
<div class="mb-5">
<p class="measure">Create ordered reading lists. Add more articles, reorder, republish.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('reading_list_index') }}">Browse</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--editor">
<div class="container mt-5">
<h1>Article Editor</h1>
<p class="eyebrow">for anyone</p>
</div>
<div class="mb-5">
<p class="measure">Write, revise, and preview with ease. Save drafts, keep notes, and publish.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('editor-create') }}">Write an article</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--newsroom">
<div class="container mt-5">
<h1>Newsroom</h1>
<p class="eyebrow">like no other</p>
</div>
<div class="mb-5">
<p class="measure">Create your own magazine, define categories, manage submissions, and collaborate with editors and contributors.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('mag_wizard_setup') }}">Create a magazine</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--unfold">
<div class="container mt-5">
<h1>Unfold</h1>
<p class="eyebrow">your magazine</p>
</div>
<div class="mb-5">
<p class="measure">Unfold is a companion web app. Any magazine created on Decent Newsroom can be plugged in,
styled and deployed to a custom domain.</p>
<div class="cta-row">
<a class="btn btn--primary" href="{{ path('unfold') }}">Learn more</a>
</div>
</div>
</section>
<section class="d-flex gap-3 center ln-section--marketplace">
<div class="container mt-5">
<h1>Media Marketplace</h1>
<p class="eyebrow">Coming soon</p>
</div>
<div class="mb-5">
<p class="measure">Commission custom visuals, discover stock you can actually use, and keep media and text in one creative flow.</p>
</div>
</section>
{% endblock %}

2
templates/static/pricing.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}

2
templates/static/roadmap.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}

2
templates/static/tos.html.twig

@ -1,4 +1,4 @@
{% extends 'base.html.twig' %} {% extends 'layout.html.twig' %}
{% block nav %} {% block nav %}
{% endblock %} {% endblock %}

45
templates/static/unfold.html.twig

@ -0,0 +1,45 @@
{% extends 'layout-full.html.twig' %}
{% block title %}Decent Newsroom — A decentralized platform for collaborative publishing{% endblock %}
{% block body %}
<section class="ln-hero d-flex gap-3 center">
<h1 class="brand">Unfold</h1>
<p class="eyebrow">by Decent Newsroom</p>
<p class="lede mb-5">Your magazine, your way.</p>
</section>
<section class="d-flex gap-3 center ln-section--unfold">
<div class="container mt-5">
<h2>Unfold is a skin for your magazine</h2>
</div>
<div class="mb-5">
<p class="measure">Any magazine created on Decent Newsroom can be plugged in,
styled according to your wishes and deployed to a custom domain.</p>
</div>
</section>
<section class="d-flex gap-3 center ln-section--newsroom">
<div class="container mt-5">
<h2>Unfold Lite</h2>
</div>
<div class="mb-5">
<p class="measure">We also offer subdomains, if rolling out your own website feels daunting.</p>
</div>
</section>
<section class="d-flex gap-3 center ln-section--marketplace">
<div class="container mt-5">
<h2>Interested?</h2>
</div>
<div class="mb-5">
<p class="measure">Reach out to
<twig:Molecules:UserFromNpub ident="{{ project_npub }}" /> or
<twig:Molecules:UserFromNpub ident="{{ dev_npub }}" />.
</p>
</div>
</section>
{% endblock %}
Loading…
Cancel
Save