Browse Source

speed up progress bar and page loading

imwald
Silberengel 5 days ago
parent
commit
831f2118a4
  1. 6
      assets/bootstrap.js
  2. 49
      assets/controllers/magazine_sync_controller.js
  3. 129
      assets/controllers/progress_bar_controller.js
  4. 21
      assets/styles/layout.css
  5. 2
      docker/cron/README.md
  6. 13
      src/Command/PrewarmCommand.php
  7. 2
      src/Controller/DefaultController.php
  8. 103
      src/Controller/MagazineSyncController.php
  9. 5
      src/Service/CacheService.php
  10. 126
      src/Service/MagazineContentService.php
  11. 2
      src/Service/MagazineIndexStore.php
  12. 3
      src/Twig/Components/Header.php
  13. 28
      src/Twig/Components/Organisms/Comments.php
  14. 7
      templates/base.html.twig
  15. 2
      templates/components/Header.html.twig
  16. 4
      templates/home.html.twig
  17. 4
      templates/pages/category.html.twig
  18. 3
      templates/ux/magazine/category_body.html.twig
  19. 10
      templates/ux/magazine/header_ul.html.twig
  20. 5
      templates/ux/magazine/home_body.html.twig

6
assets/bootstrap.js vendored

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
import ArticleCommentsController from './controllers/article_comments_controller.js';
import CommentReplyController from './controllers/comment_reply_controller.js';
import MagazineSyncController from './controllers/magazine_sync_controller.js';
const app = startStimulusApp();
@ -11,11 +10,6 @@ try { @@ -11,11 +10,6 @@ try {
} catch {
/* already registered by the bundle */
}
try {
app.register('magazine-sync', MagazineSyncController);
} catch {
/* already registered by the bundle */
}
try {
app.register('comment-reply', CommentReplyController);
} catch {

49
assets/controllers/magazine_sync_controller.js

@ -1,49 +0,0 @@ @@ -1,49 +0,0 @@
import { Controller } from "@hotwired/stimulus";
/**
* After first paint, refreshes Nostr magazine indices (server-side, 5s) and swaps header/body HTML.
*/
export default class extends Controller {
static targets = ["headerNav", "pageBody"];
static values = {
page: String,
slug: String,
url: String,
};
connect() {
this.sync();
}
async sync() {
const base = this.urlValue || "/ux/magazine-sync";
const params = new URLSearchParams();
params.set("page", this.pageValue || "article");
const slug = this.slugValue || "";
if (slug !== "") {
params.set("slug", slug);
}
const url = `${base}?${params.toString()}`;
try {
const res = await fetch(url, {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
if (!res.ok) {
return;
}
const data = await res.json();
if (!data.ok) {
return;
}
if (this.hasHeaderNavTarget && data.header) {
this.headerNavTarget.outerHTML = data.header;
}
if (this.hasPageBodyTarget && data.body) {
this.pageBodyTarget.outerHTML = data.body;
}
} catch {
/* ignore network errors */
}
}
}

129
assets/controllers/progress_bar_controller.js

@ -1,20 +1,84 @@ @@ -1,20 +1,84 @@
// assets/controllers/progress_bar_controller.js
import { Controller } from "@hotwired/stimulus";
// Top-of-page progress: indeterminate while navigating; completes on the next page’s load.
import { Controller } from '@hotwired/stimulus';
const STORAGE_KEY = 'unfold_pb';
export default class extends Controller {
static targets = ["bar"];
static targets = ['bar'];
connect() {
this.boundHandleInteraction = this.handleInteraction.bind(this);
document.addEventListener("click", this.boundHandleInteraction);
document.addEventListener("touchstart", this.handleTouchStart);
document.addEventListener("touchend", this.handleTouchEnd);
this.boundPageShow = this.onPageShow.bind(this);
document.addEventListener('click', this.boundHandleInteraction);
document.addEventListener('touchstart', this.handleTouchStart);
document.addEventListener('touchend', this.handleTouchEnd);
window.addEventListener('pageshow', this.boundPageShow);
this.resumeIfPending();
}
disconnect() {
document.removeEventListener("click", this.boundHandleInteraction);
document.removeEventListener("touchstart", this.handleTouchStart);
document.removeEventListener("touchend", this.handleTouchEnd);
document.removeEventListener('click', this.boundHandleInteraction);
document.removeEventListener('touchstart', this.handleTouchStart);
document.removeEventListener('touchend', this.handleTouchEnd);
window.removeEventListener('pageshow', this.boundPageShow);
if (this.loadListener) {
window.removeEventListener('load', this.loadListener);
this.loadListener = null;
}
}
onPageShow(event) {
if (event.persisted) {
sessionStorage.removeItem(STORAGE_KEY);
this.resetBar();
}
}
/**
* After a same-tab navigation, finish the bar as soon as the new document is fully loaded
* (or immediately if the load event already happened).
*/
resumeIfPending() {
if (sessionStorage.getItem(STORAGE_KEY) !== '1' || !this.hasBarTarget) {
return;
}
this.barTarget.classList.add('pb-indeterminate');
const finish = () => {
this.completeToDone();
};
if (document.readyState === 'complete') {
requestAnimationFrame(finish);
} else {
this.loadListener = finish;
window.addEventListener('load', finish, { once: true });
}
}
completeToDone() {
if (sessionStorage.getItem(STORAGE_KEY) !== '1' || !this.hasBarTarget) {
return;
}
if (this.loadListener) {
window.removeEventListener('load', this.loadListener);
this.loadListener = null;
}
this.barTarget.classList.remove('pb-indeterminate');
this.barTarget.style.transition = 'width 0.18s ease-out';
this.barTarget.style.width = '100%';
window.setTimeout(() => {
this.barTarget.style.transition = 'none';
this.barTarget.style.width = '0';
this.barTarget.style.removeProperty('transition');
sessionStorage.removeItem(STORAGE_KEY);
}, 220);
}
resetBar() {
if (!this.hasBarTarget) {
return;
}
this.barTarget.classList.remove('pb-indeterminate');
this.barTarget.style.width = '0';
}
handleTouchStart = (event) => {
@ -33,19 +97,46 @@ export default class extends Controller { @@ -33,19 +97,46 @@ export default class extends Controller {
};
handleInteraction(event) {
const link = event.target.closest("a");
if (link && !link.hasAttribute("data-no-progress") &&
!event.ctrlKey && !event.metaKey && !event.shiftKey) {
this.start();
const link = event.target.closest('a');
if (!link || link.hasAttribute('data-no-progress')) {
return;
}
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
const t = link.getAttribute('target');
if (t && t !== '' && t !== '_self') {
return;
}
if (link.hasAttribute('download')) {
return;
}
const href = link.getAttribute('href');
if (!href || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('javascript:')) {
return;
}
let url;
try {
url = new URL(href, window.location.href);
} catch {
return;
}
if (url.origin !== window.location.origin) {
return;
}
if (url.href.split('#')[0] === window.location.href.split('#')[0] && url.hash) {
return;
}
this.start();
}
start() {
this.barTarget.style.width = "0";
this.barTarget.style.transition = "none";
setTimeout(() => {
this.barTarget.style.transition = "width 5s ease-in-out";
this.barTarget.style.width = "100%";
}, 10);
if (!this.hasBarTarget) {
return;
}
sessionStorage.setItem(STORAGE_KEY, '1');
this.barTarget.style.transition = 'none';
this.barTarget.style.width = '0';
this.barTarget.classList.add('pb-indeterminate');
}
}

21
assets/styles/layout.css

@ -84,11 +84,32 @@ header { @@ -84,11 +84,32 @@ header {
bottom: 0;
height: 4px;
width: 0;
transform-origin: left center;
background: var(--color-primary);
transition: width 0.4s ease;
z-index: 1000;
}
/* In-flight navigation: loops until the next page fires `load`, then the bar completes. */
#progress-bar.pb-indeterminate {
transition: none;
animation: pb-indeterminate 0.9s ease-in-out infinite;
}
@keyframes pb-indeterminate {
0% {
width: 20%;
}
50% {
width: 55%;
}
100% {
width: 28%;
}
}
/* Mobile Styles */
@media (max-width: 1024px) {
.header__logo {

2
docker/cron/README.md

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
# `cron` service (Docker)
The `cron` image runs a single job: **`php bin/console app:prewarm` every 10 minutes**, against the app tree bind-mounted at `/var/www/html`.
The `cron` image runs a single job: **`php bin/console app:prewarm` every 10 minutes**, against the app tree bind-mounted at `/var/www/html`. Magazine **30040** indices, **MySQL backfill** for category `a` long-form rows, profile metadata, and comment cache are updated here (or by running `app:prewarm` manually)—not from a browser request.
- **Flags:** set **`PREWARM_FLAGS`** in the project `.env` (Compose injects it). Example: `PREWARM_FLAGS="--metadata-limit=30 --no-magazine"`. After editing, run `docker compose up -d --force-recreate cron` (or `docker compose up -d cron`) so the container gets the new value. If unset, `app:prewarm` uses its **built-in defaults** (same idea as running the console with no args).

13
src/Command/PrewarmCommand.php

@ -117,6 +117,19 @@ final class PrewarmCommand extends Command @@ -117,6 +117,19 @@ final class PrewarmCommand extends Command
$io->note('Skipping magazine (--no-magazine).');
}
$io->section('Long-form in DB (category `a` tags missing from MySQL)');
try {
$n = $this->magazineContent->ingestMissingLongformForAllMagazineCategories();
if ($n === 0) {
$io->note('No missing long-form rows for category `a` coordinates (or empty magazine store).');
} else {
$io->writeln(sprintf('Fetched or attempted ingest for <info>%d</info> missing coordinate(s).', $n));
}
} catch (\Throwable $e) {
$this->logger->error('app:prewarm longform ingest failed', ['e' => $e]);
$io->warning('Long-form backfill failed: '.$e->getMessage());
}
// MagazineRefresher sets max_execution_time (e.g. 60 for budget 30); restore before metadata.
$this->disableCliExecutionTimeLimit();

2
src/Controller/DefaultController.php

@ -22,7 +22,7 @@ class DefaultController extends AbstractController @@ -22,7 +22,7 @@ class DefaultController extends AbstractController
public function index(): Response
{
return $this->render('home.html.twig', [
'indices' => $this->magazineContent->getHomeCategoryIndexTags(),
'indices' => $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly(),
]);
}

103
src/Controller/MagazineSyncController.php

@ -1,103 +0,0 @@ @@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Service\MagazineContentService;
use App\Service\MagazineRefresher;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Twig\Environment;
/** Stale-first: the main request only reads {@see \App\Service\MagazineIndexStore}; this refetches Nostr, updates that store, and returns HTML fragments for Stimulus to patch the document. */
#[AsController]
final class MagazineSyncController
{
public function __construct(
private readonly Environment $twig,
private readonly MagazineRefresher $refresher,
private readonly MagazineContentService $magazineContent,
private readonly ParameterBagInterface $params,
private readonly LoggerInterface $logger,
) {
}
#[Route('/ux/magazine-sync', name: 'ux_magazine_sync', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
try {
$page = (string) $request->query->get('page', 'article');
if (!\in_array($page, ['home', 'category', 'article', 'articles'], true)) {
$page = 'article';
}
$slug = (string) $request->query->get('slug', '');
$prefer = $slug !== '' ? [$slug] : [];
try {
$this->refresher->refreshFromRelays(20, $prefer);
} catch (\Throwable $e) {
$this->logger->warning('MagazineSyncController: refresh failed', [
'message' => $e->getMessage(),
'exception' => $e,
]);
return new JsonResponse(
['ok' => false, 'error' => 'refresh_failed', 'message' => $e->getMessage()],
Response::HTTP_OK
);
}
$community = (bool) $this->params->get('community_articles');
$tags = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
$globals = [
'magazine_community_articles' => $community,
];
$header = $this->twig->render('ux/magazine/header_ul.html.twig', array_merge($globals, [
'cats' => $tags,
]));
$body = null;
if ($page === 'home') {
$body = $this->twig->render('ux/magazine/home_body.html.twig', array_merge($globals, [
'indices' => $tags,
]));
} elseif ($page === 'category' && $slug !== '') {
$data = $this->magazineContent->getCategoryPageData($slug);
$body = $this->twig->render('ux/magazine/category_body.html.twig', array_merge($globals, [
'list' => $data['list'],
'category' => $data['category'],
]));
} elseif ($page === 'articles') {
$body = null;
}
return new JsonResponse([
'ok' => true,
'header' => $header,
'body' => $body,
]);
} catch (\Throwable $e) {
$this->logger->error('MagazineSyncController: unexpected failure', [
'message' => $e->getMessage(),
'exception' => $e,
]);
return new JsonResponse(
[
'ok' => false,
'error' => 'server_error',
'message' => 'Magazine UI sync could not be rendered.',
],
Response::HTTP_OK
);
}
}
}

5
src/Service/CacheService.php

@ -35,8 +35,9 @@ readonly class CacheService @@ -35,8 +35,9 @@ readonly class CacheService
*/
public function getMetadataBundle(string $npub): array
{
$aggr = $this->nostrClient->getNostrLandAggrReaderCacheSuffix();
$cacheKey = $aggr === '' ? '0_'.$npub : '0_'.$aggr.'_'.$npub;
// One key per author: do not split on Nostr.Land / aggr (see comment thread cache). Otherwise
// prewarm and anonymous hits do not match logged-in readers → cold Nostr on every article view.
$cacheKey = '0_'.$npub;
try {
$cached = $this->cache->get($cacheKey, function (ItemInterface $item) use ($npub) {
$item->expiresAfter(3600); // 1 hour, adjust as needed

126
src/Service/MagazineContentService.php

@ -11,18 +11,13 @@ use App\Repository\ArticleRepository; @@ -11,18 +11,13 @@ use App\Repository\ArticleRepository;
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
/**
* Magazine index events for templates. Reads {@see MagazineIndexStore} first; on a cold cache or when
* the last successful relay sync is older than {@see self::ROOT_REVALIDATE_SECONDS}, the service
* calls {@see MagazineRefresher} so the root index (and nav) can pick up new categories.
* Magazine index for templates. Reads {@see MagazineIndexStore} only on HTTP; relay refresh and DB
* backfill for category long-form are done by `app:prewarm` (cron) / CLI.
*/
final class MagazineContentService
{
/** Re-fetch root from relays at most this often so new `a` tags appear in the header. */
private const ROOT_REVALIDATE_SECONDS = 300;
public function __construct(
private readonly MagazineIndexStore $store,
private readonly MagazineRefresher $refresher,
private readonly ParameterBagInterface $params,
private readonly ArticleRepository $articleRepository,
private readonly NostrClient $nostrClient,
@ -30,26 +25,18 @@ final class MagazineContentService @@ -30,26 +25,18 @@ final class MagazineContentService
}
/**
* "indices" for the home template: Nostr `a` tag rows for each category.
* @deprecated use {@see getHomeCategoryAIndexTagsFromStoreOnly} (identical; no blocking relay I/O)
*
* @return list<array<int, string>>
*/
public function getHomeCategoryIndexTags(): array
{
$npub = (string) $this->params->get('npub');
$dTag = (string) $this->params->get('d_tag');
if ($this->store->getRoot($npub, $dTag) === null) {
$this->refresher->refreshFromRelays(20, []);
} elseif ($this->shouldRevalidateRootFromRelay()) {
$this->refresher->refreshFromRelays(20, []);
}
return $this->getHomeCategoryAIndexTagsFromStoreOnly();
}
/**
* Category `a` tags from the persisted root only (no relay). Used after /ux/magazine-sync
* has already called {@see MagazineRefresher::refreshFromRelays}.
* Category `a` tags from the persisted root only (no relay). The store is filled by
* `app:prewarm` / cron ({@see MagazineRefresher::refreshFromRelays}), not from HTTP.
*
* @return list<array<int, string>>
*/
@ -86,16 +73,6 @@ final class MagazineContentService @@ -86,16 +73,6 @@ final class MagazineContentService
return array_values($cats);
}
private function shouldRevalidateRootFromRelay(): bool
{
$age = $this->refresher->getSecondsSinceLastRelayRun();
if ($age === null) {
return true;
}
return $age > self::ROOT_REVALIDATE_SECONDS;
}
/**
* Category path slugs from the persisted root index (third segment of each category `a` tag).
*
@ -145,15 +122,14 @@ final class MagazineContentService @@ -145,15 +122,14 @@ final class MagazineContentService
}
/**
* Category listing from the persisted 30040 index and DB only. Does not call relays.
* Missing `Article` rows (not yet in MySQL) appear until `app:prewarm` backfills.
*
* @return array{list: list<Article>, category: array{title: string, summary: string}}
*/
public function getCategoryPageData(string $slug): array
{
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
$this->refresher->refreshFromRelays(20, [$slug]);
$catIndex = $this->store->getCategory($slug);
}
$list = [];
$coordinates = [];
$category = [];
@ -188,21 +164,6 @@ final class MagazineContentService @@ -188,21 +164,6 @@ final class MagazineContentService
];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$missing = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
if (!isset($byAddress[$k])) {
$missing[] = (string) $coordinate;
}
}
if ($missing !== []) {
$this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing);
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
}
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
@ -224,6 +185,77 @@ final class MagazineContentService @@ -224,6 +185,77 @@ final class MagazineContentService
];
}
/**
* For every category in the root index, fetch Nostr long-form for `a` tags missing in MySQL.
* Nostr I/O; intended for {@see PrewarmCommand} / cron only.
*/
public function ingestMissingLongformForAllMagazineCategories(): int
{
$n = 0;
foreach ($this->getCategorySlugsFromStore() as $catSlug) {
$missing = $this->findMissingLongformCoordinatesForCategory($catSlug);
if ($missing === []) {
continue;
}
$this->nostrClient->ingestMissingLongformForCategoryCoordinates($missing);
$n += \count($missing);
}
return $n;
}
/**
* @return list<string> Nostr coordinates kind:pubkey:identifier
*/
private function findMissingLongformCoordinatesForCategory(string $slug): array
{
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
return [];
}
$coordinates = [];
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$coordinates[] = (string) $tag[1];
}
}
if ($coordinates === []) {
return [];
}
$pairs = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$slugPart = trim((string) $parts[2]);
if ($slugPart === '') {
continue;
}
$pairs[] = [
'pubkey' => (string) $parts[1],
'slug' => $slugPart,
];
}
if ($pairs === []) {
return [];
}
$byAddress = $this->articleRepository->findByAuthorAndSlugIndexed($pairs);
$missing = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', (string) $coordinate, 3);
if (\count($parts) < 3) {
continue;
}
$k = (string) $parts[1]."\0".trim((string) $parts[2]);
if (!isset($byAddress[$k])) {
$missing[] = (string) $coordinate;
}
}
return $missing;
}
/**
* Union of every article referenced by a category index (root 30040). Use this for magazine-wide
* Atom and comment prewarm so "newest" tracks the magazine, not the generic community list.

2
src/Service/MagazineIndexStore.php

@ -10,7 +10,7 @@ use Psr\Cache\InvalidArgumentException; @@ -10,7 +10,7 @@ use Psr\Cache\InvalidArgumentException;
/**
* Read/write persisted magazine Nostr index events (kinds 30040) without callback-based relay I/O
* on the request path. Updated by {@see MagazineRefresher} or the /ux/magazine-sync action.
* on the request path. Updated by {@see MagazineRefresher} (via `app:prewarm` / cron, or explicit CLI use).
*/
final class MagazineIndexStore
{

3
src/Twig/Components/Header.php

@ -15,6 +15,7 @@ class Header @@ -15,6 +15,7 @@ class Header
public function __construct(
private readonly MagazineContentService $magazineContent,
) {
$this->cats = $this->magazineContent->getHomeCategoryIndexTags();
// Store only: never block the response on relay I/O (cron/pre-warm updates the store).
$this->cats = $this->magazineContent->getHomeCategoryAIndexTagsFromStoreOnly();
}
}

28
src/Twig/Components/Organisms/Comments.php

@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
<?php
namespace App\Twig\Components\Organisms;
use App\Service\ArticleCommentThreadLoader;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class Comments
{
public array $list = [];
public array $commentLinks = [];
public array $processedContent = [];
public function __construct(private readonly ArticleCommentThreadLoader $commentThreadLoader)
{
}
public function mount($current): void
{
$data = $this->commentThreadLoader->load((string) $current);
$this->list = $data['list'];
$this->commentLinks = $data['commentLinks'];
$this->processedContent = $data['processedContent'];
}
}

7
templates/base.html.twig

@ -29,12 +29,7 @@ @@ -29,12 +29,7 @@
<link rel="stylesheet" href="{{ asset('theme.css') }}">
{% endblock %}
</head>
<body
data-controller="service-worker magazine-sync"
data-magazine-sync-page-value="{% block magazine_sync_page %}article{% endblock %}"
data-magazine-sync-slug-value="{% block magazine_sync_slug %}{% endblock %}"
data-magazine-sync-url-value="{{ path('ux_magazine_sync') }}"
>
<body data-controller="service-worker">
<twig:Header />

2
templates/components/Header.html.twig

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
<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 data-magazine-sync-target="headerNav">
<ul>
{% for category in cats %}
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}

4
templates/home.html.twig

@ -6,8 +6,6 @@ @@ -6,8 +6,6 @@
<meta name="description" content="{{ website_description|e('html_attr') }}">
{% endblock %}
{% block magazine_sync_page %}home{% endblock %}
{% block ogtags %}
{% set _og_image = absolute_url(asset('og-image.jpg')) %}
<link rel="canonical" href="{{ url('home') }}">
@ -28,7 +26,7 @@ @@ -28,7 +26,7 @@
{% endblock %}
{% block body %}
<div class="home-body" data-magazine-sync-target="pageBody">
<div class="home-body">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}

4
templates/pages/category.html.twig

@ -1,7 +1,5 @@ @@ -1,7 +1,5 @@
{% extends 'base.html.twig' %}
{% block magazine_sync_page %}{% if app.request.attributes.get('_route') == 'articles' %}articles{% else %}category{% endif %}{% endblock %}
{% block magazine_sync_slug %}{{ (sync_slug|default(''))|e('html_attr') }}{% endblock %}
{% block title %}{{ (category.title|default(''))|trim != '' ? category.title|trim ~ ' — ' ~ website_name : website_name }}{% endblock %}
@ -31,7 +29,7 @@ @@ -31,7 +29,7 @@
{% endblock %}
{% block body %}
<div class="category-body" data-magazine-sync-target="pageBody">
<div class="category-body">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>
{% endblock %}

3
templates/ux/magazine/category_body.html.twig

@ -1,3 +0,0 @@ @@ -1,3 +0,0 @@
<div class="category-body" data-magazine-sync-target="pageBody">
<twig:Organisms:CardList :list="list" class="article-list" />
</div>

10
templates/ux/magazine/header_ul.html.twig

@ -1,10 +0,0 @@ @@ -1,10 +0,0 @@
<ul data-magazine-sync-target="headerNav">
{% for category in cats %}
<li><twig:Molecules:CategoryLink :category="category" /></li>
{% endfor %}
{% if magazine_community_articles %}
<li>
<a href="{{ path('articles') }}">Latest Articles</a>
</li>
{% endif %}
</ul>

5
templates/ux/magazine/home_body.html.twig

@ -1,5 +0,0 @@ @@ -1,5 +0,0 @@
<div class="home-body" data-magazine-sync-target="pageBody">
{% for item in indices %}
<twig:Organisms:FeaturedList :category="item" class="featured-list"/>
{% endfor %}
</div>
Loading…
Cancel
Save