Browse Source

implemented subcategories

imwald
Silberengel 2 days ago
parent
commit
51cc739968
  1. 184
      assets/styles/app.css
  2. 146
      src/Service/MagazineContentService.php
  3. 59
      src/Service/MagazineRefresher.php
  4. 4
      src/Service/NostrClient.php
  5. 5
      src/Twig/Components/Molecules/CategoryLink.php
  6. 13
      src/Twig/Components/Organisms/FeaturedList.php
  7. 40
      src/Util/NostrEventTags.php
  8. 44
      templates/components/Molecules/CategoryLink.html.twig

184
assets/styles/app.css

@ -791,6 +791,7 @@ svg.icon { @@ -791,6 +791,7 @@ svg.icon {
align-items: center;
background-color: var(--color-bg); /* Black background */
border-bottom: 1px solid var(--color-border); /* White bottom border */
overflow: visible;
}
.header__categories ul {
@ -801,10 +802,12 @@ svg.icon { @@ -801,10 +802,12 @@ svg.icon {
gap: 0.45rem 0.65rem;
padding: 0.45rem 0.65rem 0.55rem;
margin: 0;
overflow: visible;
}
.header__categories li {
list-style: none;
position: relative;
}
/* Top category row: primary navigation — weight + contrast above the brand wordmark. */
@ -874,6 +877,187 @@ svg.icon { @@ -874,6 +877,187 @@ svg.icon {
box-shadow: none;
}
/* Trigger that opens a nested magazine section menu */
.header__cat-link--dropdown {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.38rem;
}
.header__nav-dropdown__label {
min-width: 0;
}
/* Small chevron — scales with trigger font */
.header__nav-dropdown__chevron {
flex-shrink: 0;
display: block;
width: 0;
height: 0;
border-left: 0.28rem solid transparent;
border-right: 0.28rem solid transparent;
border-top: 0.32rem solid color-mix(in srgb, currentColor 72%, var(--color-text-mid) 28%);
margin-top: 0.06em;
transition:
border-top-color 0.15s ease,
transform 0.18s ease;
}
.header__nav-dropdown:hover .header__nav-dropdown__chevron,
.header__nav-dropdown:focus-within .header__nav-dropdown__chevron {
border-top-color: color-mix(in srgb, currentColor 88%, var(--color-text-mid) 12%);
transform: translateY(0.06rem);
}
/* Nested kind-30040 sections: stable hover (bridge padding) + tidy panel */
.header__nav-dropdown {
position: relative;
display: inline-block;
max-width: 100%;
vertical-align: middle;
}
.header__nav-dropdown__panel {
box-sizing: border-box;
}
.header__nav-dropdown__surface {
box-sizing: border-box;
background: var(--color-bg);
border: 1px solid color-mix(in srgb, var(--color-border) 70%, var(--color-primary) 30%);
border-radius: 8px;
box-shadow:
0 4px 6px -1px color-mix(in srgb, var(--color-text-mid) 8%, transparent),
0 12px 24px -4px color-mix(in srgb, var(--color-text-mid) 14%, transparent);
overflow: hidden;
}
.header__nav-dropdown__list {
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.header__nav-dropdown__row {
margin: 0;
padding: 0;
}
.header__nav-dropdown__item {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.52rem 1rem 0.52rem 1.05rem;
font-family: var(--font-family), sans-serif;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.055em;
text-transform: uppercase;
text-align: left;
text-decoration: none;
color: color-mix(in srgb, var(--color-primary) 85%, var(--color-text-mid) 15%);
border-left: 3px solid transparent;
line-height: 1.35;
transition:
color 0.12s ease,
background-color 0.12s ease,
border-color 0.12s ease;
}
.header__nav-dropdown__item:hover {
text-decoration: none;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg) 93%);
}
.header__nav-dropdown__item:focus-visible {
text-decoration: none;
outline: none;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 9%, var(--color-bg) 91%);
box-shadow: inset 0 0 0 2px var(--color-focus-ring);
}
.header__nav-dropdown__item--active {
color: var(--color-primary);
font-weight: 700;
background: color-mix(in srgb, var(--color-primary) 11%, var(--color-bg) 89%);
border-left-color: var(--color-secondary);
}
@media (min-width: 1025px) {
.header__nav-dropdown__panel {
position: absolute;
z-index: 1200;
left: 0;
top: 100%;
margin-top: -3px;
padding-top: 10px;
min-width: max(100%, 11.5rem);
max-width: min(18rem, calc(100vw - 2rem));
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(-4px);
transition:
opacity 0.16s ease,
visibility 0.16s ease,
transform 0.16s ease;
}
.header__nav-dropdown:hover .header__nav-dropdown__panel,
.header__nav-dropdown:focus-within .header__nav-dropdown__panel {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: translateY(0);
}
}
@media (max-width: 1024px) {
.header__nav-dropdown {
display: block;
width: 100%;
max-width: 20rem;
margin-inline: auto;
}
.header__nav-dropdown .header__cat-link--dropdown {
width: 100%;
max-width: 100%;
}
.header__nav-dropdown__panel {
position: static;
width: 100%;
max-width: 100%;
margin-top: 0.4rem;
padding-top: 0;
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: none;
}
.header__nav-dropdown__surface {
border-radius: 8px;
border-style: solid;
border-color: color-mix(in srgb, var(--color-border) 85%, var(--color-primary) 15%);
}
.header__nav-dropdown__item {
text-align: center;
padding-left: 1rem;
padding-right: 1rem;
border-left: none;
}
.header__nav-dropdown__item--active {
box-shadow: inset 0 -2px 0 0 var(--color-secondary);
}
}
.header__logo h1 {
font-weight: normal;
margin: 0;

146
src/Service/MagazineContentService.php

@ -93,15 +93,16 @@ final class MagazineContentService @@ -93,15 +93,16 @@ final class MagazineContentService
}
/**
* Category path slugs from the persisted root index (third segment of each category `a` tag).
* Category path slugs from the persisted magazine indices: root `a` tags plus any nested kind
* 30040 indices reachable from those categories (BFS over stored events only).
*
* @return list<string>
*/
public function getCategorySlugsFromStore(): array
{
$tags = $this->getHomeCategoryAIndexTagsFromStoreOnly();
$out = [];
foreach ($tags as $row) {
$queue = [];
$enqueued = [];
foreach ($this->getHomeCategoryAIndexTagsFromStoreOnly() as $row) {
$coord = $row[1] ?? '';
if (!\is_string($coord) || $coord === '') {
continue;
@ -111,12 +112,61 @@ final class MagazineContentService @@ -111,12 +112,61 @@ final class MagazineContentService
continue;
}
$slug = trim((string) $parts[2]);
if ($slug !== '') {
$out[] = $slug;
if ($slug === '' || isset($enqueued[$slug])) {
continue;
}
$enqueued[$slug] = true;
$queue[] = $slug;
}
return array_values(array_unique($out));
$out = [];
while ($queue !== []) {
$slug = array_shift($queue);
if (!\is_string($slug) || $slug === '') {
continue;
}
$out[] = $slug;
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
continue;
}
foreach (NostrEventTags::publicationIndexNestedDSlugs($catIndex->getTags()) as $child) {
if (!isset($enqueued[$child])) {
$enqueued[$child] = true;
$queue[] = $child;
}
}
}
return $out;
}
/**
* Nested kind-30040 section slugs from a parent category index, in `a` tag order, for header nav.
*
* @return list<array{slug: string, title: string}>
*/
public function getSubcategoryNavItemsForParentSlug(string $parentSlug): array
{
$parentSlug = trim($parentSlug);
if ($parentSlug === '') {
return [];
}
$this->warmCategoryIndexIfMissing($parentSlug);
$cat = $this->store->getCategory($parentSlug);
if ($cat === null) {
return [];
}
$items = [];
foreach (NostrEventTags::publicationIndexNestedDSlugs($cat->getTags()) as $childSlug) {
$this->warmCategoryIndexIfMissing($childSlug);
$items[] = [
'slug' => $childSlug,
'title' => $this->getCategoryDisplayTitle($childSlug),
];
}
return $items;
}
/**
@ -504,6 +554,10 @@ final class MagazineContentService @@ -504,6 +554,10 @@ final class MagazineContentService
if (\count($parts) < 3 || trim((string) $parts[2]) === '') {
continue;
}
$kind = (int) ($parts[0] ?? 0);
if (!\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
continue;
}
$out[] = $coordinate;
}
@ -895,7 +949,19 @@ final class MagazineContentService @@ -895,7 +949,19 @@ final class MagazineContentService
}
/**
* @return list<string> `#d` slugs from kind-30023 `a` tags in category index order (trimmed, non-empty)
* Article `#d` slugs for a category "a" coordinate (kind:pubkey:#d), in index order, including
* long-form listed under nested kind-30040 indices (those indices are warmed on demand).
*
* @return list<string>
*/
public function getArticleSlugsFromCategoryIndexCoordinate(string $categoryCoord, int $maxSlugs = 40): array
{
return $this->slugsFromCategoryCoord($categoryCoord, max(1, $maxSlugs));
}
/**
* @return list<string> Article `#d` slugs from kind 30023/30024 `a` tags in index order; follows nested
* kind-30040 section indices up to {@see $maxDepth} when the store has them.
*/
private function slugsFromCategoryCoord(string $categoryCoord, int $maxA): array
{
@ -906,27 +972,60 @@ final class MagazineContentService @@ -906,27 +972,60 @@ final class MagazineContentService
if (\count($parts) < 3) {
return [];
}
$slug = $parts[2];
$catIndex = $this->store->getCategory($slug);
$slug = trim((string) $parts[2]);
return $this->articleSlugsFromCategoryIndexSlug($slug, $maxA, 0, 4);
}
/**
* @return list<string>
*/
private function articleSlugsFromCategoryIndexSlug(string $categorySlug, int $maxA, int $depth, int $maxDepth): array
{
if ($maxA < 1 || $depth > $maxDepth || $categorySlug === '') {
return [];
}
$this->warmCategoryIndexIfMissing($categorySlug);
$catIndex = $this->store->getCategory($categorySlug);
if ($catIndex === null) {
return [];
}
$slugs = [];
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$segs = explode(':', (string) $tag[1], 3);
$slugs[] = \trim((string) end($segs));
if (($tag[0] ?? null) !== 'a' || !isset($tag[1])) {
continue;
}
$coord = (string) $tag[1];
$segs = explode(':', $coord, 3);
if (\count($segs) < 3) {
continue;
}
$kind = (int) ($segs[0] ?? 0);
$identifier = trim((string) $segs[2]);
if ($identifier === '') {
continue;
}
if (\in_array($kind, [KindsEnum::LONGFORM->value, KindsEnum::LONGFORM_DRAFT->value], true)) {
$slugs[] = $identifier;
if (\count($slugs) >= $maxA) {
break;
return $slugs;
}
} elseif ($kind === KindsEnum::PUBLICATION_INDEX->value && $depth < $maxDepth) {
foreach ($this->articleSlugsFromCategoryIndexSlug($identifier, $maxA - \count($slugs), $depth + 1, $maxDepth) as $nested) {
$slugs[] = $nested;
if (\count($slugs) >= $maxA) {
return $slugs;
}
}
}
}
return \array_values(\array_filter($slugs, static fn (string $s): bool => $s !== ''));
return $slugs;
}
/**
* Same resolution as {@see \App\Twig\Components\Organisms\FeaturedList} index tags; at most two cards per category for the home wall.
* Same article resolution as {@see \App\Twig\Components\Organisms\FeaturedList} (nested 30040 + long-form);
* at most two cards per root category for the home picture wall.
*
* @return null|array{title: string, cards: list<FeaturedArticleCard>}
*/
@ -936,7 +1035,8 @@ final class MagazineContentService @@ -936,7 +1035,8 @@ final class MagazineContentService
if (\count($parts) < 3) {
return null;
}
$slug = $parts[2];
$slug = trim((string) $parts[2]);
$this->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug);
if ($catIndex === null) {
return null;
@ -976,6 +1076,18 @@ final class MagazineContentService @@ -976,6 +1076,18 @@ final class MagazineContentService
$orderedList[] = $slugMap[$articleSlug];
}
}
if ($orderedList !== [] && NostrEventTags::publicationIndexNestedDSlugs($catIndex->getTags()) !== []) {
\usort($orderedList, function (FeaturedArticleCard $a, FeaturedArticleCard $b): int {
if ($this->featuredCardIsNewer($a, $b)) {
return -1;
}
if ($this->featuredCardIsNewer($b, $a)) {
return 1;
}
return 0;
});
}
$cards = \array_slice($orderedList, 0, 2);
return ['title' => $title, 'cards' => $cards];

59
src/Service/MagazineRefresher.php

@ -152,6 +152,8 @@ final class MagazineRefresher @@ -152,6 +152,8 @@ final class MagazineRefresher
}
}
$this->fetchNestedPublicationIndicesUntilDeadline($npub, $deadline, $slugs);
try {
$this->featuredAuthorSync->reconcileListedAuthorsFromMagazineCategories();
} catch (\Throwable $e) {
@ -239,6 +241,63 @@ final class MagazineRefresher @@ -239,6 +241,63 @@ final class MagazineRefresher
return $slugs;
}
/**
* Fetches kind-30040 indices listed inside category indices (sub-sections), until the category
* phase deadline. Uses the same relay budget as the primary category loop.
*
* @param list<string> $rootCategorySlugs Slugs already refreshed in the main loop
*/
private function fetchNestedPublicationIndicesUntilDeadline(string $npub, float $deadline, array $rootCategorySlugs): void
{
$seen = [];
foreach ($rootCategorySlugs as $s) {
$seen[trim((string) $s)] = true;
}
$queue = [];
foreach ($rootCategorySlugs as $s) {
$cat = $this->store->getCategory(trim((string) $s));
if ($cat === null) {
continue;
}
foreach (NostrEventTags::publicationIndexNestedDSlugs($cat->getTags()) as $child) {
if (!isset($seen[$child])) {
$seen[$child] = true;
$queue[] = $child;
}
}
}
$defaultRelay = (string) $this->params->get('default_relay');
$relayLabel = (string) (parse_url($defaultRelay, \PHP_URL_HOST) ?: $defaultRelay);
while ($queue !== [] && microtime(true) < $deadline) {
$slug = array_shift($queue);
if (!\is_string($slug) || trim($slug) === '') {
continue;
}
try {
$cat = $this->nostrClient->getMagazineIndex($npub, $slug);
if ($cat !== null) {
$this->store->putCategory($slug, $cat);
foreach (NostrEventTags::publicationIndexNestedDSlugs($cat->getTags()) as $grandchild) {
if (!isset($seen[$grandchild])) {
$seen[$grandchild] = true;
$queue[] = $grandchild;
}
}
}
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'MagazineRefresher: nested category fetch failed (relays from %s): %s',
$relayLabel,
$e->getMessage()
), [
'slug' => $slug,
'message' => $e->getMessage(),
'relay' => $defaultRelay,
]);
}
}
}
/**
* Order: prefer (incl. MAGAZINE_PREWARM_PREFER_SLUGS), then MAGAZINE_PREWARM_ALSO_SLUGS, then
* each remaining category from the live root 30040. "Also" runs before the root tail so a

4
src/Service/NostrClient.php

@ -1609,8 +1609,8 @@ class NostrClient @@ -1609,8 +1609,8 @@ class NostrClient
* so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass).
*
* The magazine root uses the site d_tag from config. Each category uses the full child d
* (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not
* further nested 30040 indices.
* (third segment of the root "a" address). A category 30040 lists 30023/30024 article "a" tags
* and may also list nested kind-30040 section indices.
*
* Tries article relays first; if no 30040 is found, retries on config `profile_relays` not
* already listed in `article_relays` (see prewarm / category discovery).

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

@ -13,6 +13,9 @@ final class CategoryLink @@ -13,6 +13,9 @@ final class CategoryLink
public string $slug = '';
/** @var list<array{slug: string, title: string}> */
public array $subcategories = [];
public function __construct(
private readonly MagazineIndexStore $store,
private readonly MagazineContentService $magazineContent,
@ -45,5 +48,7 @@ final class CategoryLink @@ -45,5 +48,7 @@ final class CategoryLink
if ($first !== null) {
$this->title = (string) $titleTags[$first][1];
}
$this->subcategories = $this->magazineContent->getSubcategoryNavItemsForParentSlug($this->slug);
}
}

13
src/Twig/Components/Organisms/FeaturedList.php

@ -4,6 +4,7 @@ namespace App\Twig\Components\Organisms; @@ -4,6 +4,7 @@ namespace App\Twig\Components\Organisms;
use App\Dto\FeaturedArticleCard;
use App\Repository\ArticleRepository;
use App\Service\MagazineContentService;
use App\Service\MagazineIndexStore;
use Psr\Cache\InvalidArgumentException;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
@ -19,6 +20,7 @@ final class FeaturedList @@ -19,6 +20,7 @@ final class FeaturedList
public function __construct(
private readonly MagazineIndexStore $store,
private readonly MagazineContentService $magazineContent,
private readonly ArticleRepository $articleRepository,
) {
}
@ -40,29 +42,24 @@ final class FeaturedList @@ -40,29 +42,24 @@ final class FeaturedList
$slug = $parts[2];
$this->magazineContent->warmCategoryIndexIfMissing($slug);
$catIndex = $this->store->getCategory($slug);
if (!\is_object($catIndex) || !\method_exists($catIndex, 'getTags')) {
return;
}
$slugs = [];
foreach ($catIndex->getTags() as $tag) {
if (($tag[0] ?? null) === 'title' && isset($tag[1])) {
$this->title = (string) $tag[1];
}
if (($tag[0] ?? null) === 'a' && isset($tag[1])) {
$segs = explode(':', (string) $tag[1], 3);
$slugs[] = trim((string) end($segs));
if (\count($slugs) >= 5) {
break;
}
}
}
if ($this->title === '') {
$this->title = $slug;
}
$slugs = $this->magazineContent->getArticleSlugsFromCategoryIndexCoordinate($this->category, 24);
if ($slugs === []) {
return;
}

40
src/Util/NostrEventTags.php

@ -4,6 +4,8 @@ declare(strict_types=1); @@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Util;
use App\Enum\KindsEnum;
/**
* Tag rows from Nostr events may be JSON arrays, associative arrays, or object-shaped. Normalize
* to a name-first list of string values (see NIP-01 tag structure).
@ -42,4 +44,42 @@ final class NostrEventTags @@ -42,4 +44,42 @@ final class NostrEventTags
return strtolower($seq[0] ?? '') === strtolower($name);
}
/**
* Kind-30040 publication indices may nest further 30040 indices via {@code a} tags
* (kind:pubkey:#d). Returns each nested index #d in tag order, deduped on first occurrence.
*
* @param iterable<mixed> $tagRows
*
* @return list<string>
*/
public static function publicationIndexNestedDSlugs(iterable $tagRows): array
{
$out = [];
$seen = [];
foreach ($tagRows as $tag) {
if (!self::tagNameMatches($tag, 'a')) {
continue;
}
$seq = self::rowToStringList($tag);
if ($seq === null || !isset($seq[1]) || (string) $seq[1] === '') {
continue;
}
$parts = explode(':', (string) $seq[1], 3);
if (\count($parts) < 3) {
continue;
}
if ((int) ($parts[0] ?? 0) !== KindsEnum::PUBLICATION_INDEX->value) {
continue;
}
$d = trim((string) $parts[2]);
if ($d === '' || isset($seen[$d])) {
continue;
}
$seen[$d] = true;
$out[] = $d;
}
return $out;
}
}

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

@ -1,6 +1,46 @@ @@ -1,6 +1,46 @@
{% set nav_active = app.request.attributes.get('_route') == 'magazine-category' and app.request.attributes.get('slug') == slug %}
{% set sub_active = false %}
{% for sc in subcategories %}
{% if app.request.attributes.get('_route') == 'magazine-category' and app.request.attributes.get('slug') == sc.slug %}
{% set sub_active = true %}
{% endif %}
{% endfor %}
{% set branch_active = nav_active or sub_active %}
{% if subcategories is empty %}
<a
class="header__cat-link{{ nav_active ? ' header__cat-link--active' : '' }}"
class="header__cat-link{{ branch_active ? ' header__cat-link--active' : '' }}"
href="{{ path('magazine-category', { slug: slug }) }}"
{% if nav_active %}aria-current="page"{% endif %}
{% if branch_active %}aria-current="page"{% endif %}
>{{ title }}</a>
{% else %}
{% set sub_menu_id = 'mag-nav-sub-' ~ slug|replace({':': '-'}) %}
<div class="header__nav-dropdown">
<a
class="header__cat-link header__cat-link--dropdown{{ branch_active ? ' header__cat-link--active' : '' }}"
href="{{ path('magazine-category', { slug: slug }) }}"
{% if nav_active %}aria-current="page"{% endif %}
aria-haspopup="menu"
aria-controls="{{ sub_menu_id }}"
>
<span class="header__nav-dropdown__label">{{ title }}</span>
<span class="header__nav-dropdown__chevron" aria-hidden="true"></span>
</a>
<div class="header__nav-dropdown__panel" id="{{ sub_menu_id }}">
<div class="header__nav-dropdown__surface">
<ul class="header__nav-dropdown__list" role="menu" aria-label="{{ title }}">
{% for sc in subcategories %}
{% set sub_nav_active = app.request.attributes.get('_route') == 'magazine-category' and app.request.attributes.get('slug') == sc.slug %}
<li class="header__nav-dropdown__row" role="none">
<a
role="menuitem"
class="header__nav-dropdown__item{{ sub_nav_active ? ' header__nav-dropdown__item--active' : '' }}"
href="{{ path('magazine-category', { slug: sc.slug }) }}"
{% if sub_nav_active %}aria-current="page"{% endif %}
>{{ sc.title }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}

Loading…
Cancel
Save