From 51cc73996828c99e9bbdf37745f4f6db689195d0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 30 Apr 2026 21:18:50 +0200 Subject: [PATCH] implemented subcategories --- assets/styles/app.css | 184 ++++++++++++++++++ src/Service/MagazineContentService.php | 146 ++++++++++++-- src/Service/MagazineRefresher.php | 59 ++++++ src/Service/NostrClient.php | 4 +- .../Components/Molecules/CategoryLink.php | 5 + .../Components/Organisms/FeaturedList.php | 13 +- src/Util/NostrEventTags.php | 40 ++++ .../Molecules/CategoryLink.html.twig | 44 ++++- 8 files changed, 466 insertions(+), 29 deletions(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index e3c725f..074be9a 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -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 { 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 { 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; diff --git a/src/Service/MagazineContentService.php b/src/Service/MagazineContentService.php index 1142154..2cb9763 100644 --- a/src/Service/MagazineContentService.php +++ b/src/Service/MagazineContentService.php @@ -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 */ 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 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 + */ + 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 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 } /** - * @return list `#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 + */ + public function getArticleSlugsFromCategoryIndexCoordinate(string $categoryCoord, int $maxSlugs = 40): array + { + return $this->slugsFromCategoryCoord($categoryCoord, max(1, $maxSlugs)); + } + + /** + * @return list 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 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 + */ + 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} */ @@ -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 $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]; diff --git a/src/Service/MagazineRefresher.php b/src/Service/MagazineRefresher.php index ef396e6..318a73a 100644 --- a/src/Service/MagazineRefresher.php +++ b/src/Service/MagazineRefresher.php @@ -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 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 $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 diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 5565b81..6c66c95 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -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). diff --git a/src/Twig/Components/Molecules/CategoryLink.php b/src/Twig/Components/Molecules/CategoryLink.php index 5f7bca0..f1a3c3b 100644 --- a/src/Twig/Components/Molecules/CategoryLink.php +++ b/src/Twig/Components/Molecules/CategoryLink.php @@ -13,6 +13,9 @@ final class CategoryLink public string $slug = ''; + /** @var list */ + public array $subcategories = []; + public function __construct( private readonly MagazineIndexStore $store, private readonly MagazineContentService $magazineContent, @@ -45,5 +48,7 @@ final class CategoryLink if ($first !== null) { $this->title = (string) $titleTags[$first][1]; } + + $this->subcategories = $this->magazineContent->getSubcategoryNavItemsForParentSlug($this->slug); } } diff --git a/src/Twig/Components/Organisms/FeaturedList.php b/src/Twig/Components/Organisms/FeaturedList.php index 558d694..e7c019b 100644 --- a/src/Twig/Components/Organisms/FeaturedList.php +++ b/src/Twig/Components/Organisms/FeaturedList.php @@ -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 public function __construct( private readonly MagazineIndexStore $store, + private readonly MagazineContentService $magazineContent, private readonly ArticleRepository $articleRepository, ) { } @@ -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; } diff --git a/src/Util/NostrEventTags.php b/src/Util/NostrEventTags.php index 42b9f25..3cbf28b 100644 --- a/src/Util/NostrEventTags.php +++ b/src/Util/NostrEventTags.php @@ -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 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 $tagRows + * + * @return list + */ + 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; + } } diff --git a/templates/components/Molecules/CategoryLink.html.twig b/templates/components/Molecules/CategoryLink.html.twig index 968b8ce..1b21465 100644 --- a/templates/components/Molecules/CategoryLink.html.twig +++ b/templates/components/Molecules/CategoryLink.html.twig @@ -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 %} {{ title }} +{% else %} +{% set sub_menu_id = 'mag-nav-sub-' ~ slug|replace({':': '-'}) %} +
+ + {{ title }} + + + +
+{% endif %}