From 55e887aeeccf7173d4b8c9063cf6b3f18ead543a Mon Sep 17 00:00:00 2001 From: Roman Timashev Date: Sat, 25 Apr 2026 04:13:03 +0300 Subject: [PATCH] Add implementation plan for tag pages and tags index --- docs/plans/20260425-tags-page.md | 1177 ++++++++++++++++++++++++++++++ 1 file changed, 1177 insertions(+) create mode 100644 docs/plans/20260425-tags-page.md diff --git a/docs/plans/20260425-tags-page.md b/docs/plans/20260425-tags-page.md new file mode 100644 index 00000000..a115469e --- /dev/null +++ b/docs/plans/20260425-tags-page.md @@ -0,0 +1,1177 @@ +# Tag pages and tags index — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Activate the dormant `/tags/` links by building a paginated listing of pages per tag, plus a `/tags` index of all tags ordered by page count. + +**Architecture:** Two new App Router routes (`tags/page.tsx` and `tags/[slug]/page.tsx`) following the existing SSG/ISR pattern with `revalidate = 30`. Data is fetched from PocketBase via three new functions in `src/remote/api.ts`. Tag counts on the index are computed once via a single full-list scan of non-draft pages and aggregated in memory. Listings reuse the existing `PageGrid` component. + +**Tech Stack:** Next.js 16 App Router, React 19, PocketBase 0.26 SDK, Vitest + happy-dom, Biome. + +**Spec:** `docs/specs/20260425-tags-page.md`. + +--- + +## File Structure + +Created: + +- `apps/hudozka/src/app/tags/page.tsx` — `/tags` index route. +- `apps/hudozka/src/app/tags/[slug]/page.tsx` — `/tags/[slug]` listing route. +- `apps/hudozka/src/components/TagsIndex/index.tsx` — index list of tag buttons. +- `apps/hudozka/src/components/TagsIndex/styles.module.css` — index list styles. +- `apps/hudozka/src/components/TagsIndex/TagsIndex.test.tsx` — index unit tests. +- `apps/hudozka/src/components/Pagination/index.tsx` — page navigation. +- `apps/hudozka/src/components/Pagination/styles.module.css` — pagination styles. +- `apps/hudozka/src/components/Pagination/Pagination.test.tsx` — pagination unit tests. + +Modified: + +- `apps/hudozka/src/types.ts` — add `TagListing`. +- `apps/hudozka/src/remote/factory.ts` — extract `createTag`, add `createTagPageCards`. +- `apps/hudozka/src/remote/factory.test.ts` — cases for the new factories. +- `apps/hudozka/src/remote/api.ts` — add `getAllTagSlugs`, `getPagesByTag`, `getAllTagsWithCounts`. +- `apps/hudozka/src/components/TagList/index.tsx` — drop `disabled={true}`. +- `apps/hudozka/src/app/sitemap.ts` — append tag URLs. + +All commits are one-line subject only (per the user's global rule). All commands run from the repo root unless stated. + +--- + +## Task 1: Add `TagListing` type + +**Files:** +- Modify: `apps/hudozka/src/types.ts` + +- [ ] **Step 1: Append `TagListing` to `src/types.ts`** + +After the existing `Tag` type (around line 86), append: + +```ts +export type TagListing = { + tag: Tag + items: PageCardDto[] + total: number + page: number + perPage: number +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npx tsc -b apps/hudozka --noEmit` +Expected: no new errors related to `types.ts`. + +- [ ] **Step 3: Commit** + +```bash +git add apps/hudozka/src/types.ts +git commit -m "Add TagListing type" +``` + +--- + +## Task 2: Extract `createTag` factory + +Currently `factory.ts:175-181` builds `Tag` inline inside `createPage`. Extract it so the new tag routes can build `Tag` from the same source of truth. + +**Files:** +- Modify: `apps/hudozka/src/remote/factory.ts` +- Modify: `apps/hudozka/src/remote/factory.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `apps/hudozka/src/remote/factory.test.ts`: + +```ts +import { createFeedPages, createTag } from "./factory" +import type { PbTag } from "./types" + +function stubTag(overrides: Partial = {}): PbTag { + return { + id: "tag1", + collectionId: "pbc_tags", + collectionName: "tags", + created: "2026-01-01T00:00:00.000Z", + updated: "2026-01-01T00:00:00.000Z", + slug: "watercolor", + label: "Акварель", + ...overrides, + } +} + +describe("createTag", () => { + it("maps PB tag to app Tag with href and count", () => { + const tag = createTag(stubTag(), 7) + expect(tag).toEqual({ + id: "tag1", + name: "Акварель", + slug: "watercolor", + href: "/tags/watercolor", + count: 7, + }) + }) +}) +``` + +Note: the existing top-of-file import is `import { createFeedPages } from "./factory"` — replace it with the combined import shown above. The `stubTag` helper is added once at the top of the test file (alongside the existing `stubPage`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -w apps/hudozka -- factory.test.ts` +Expected: FAIL — `createTag is not a function` or similar export error. + +- [ ] **Step 3: Implement `createTag`** + +In `apps/hudozka/src/remote/factory.ts`, add the new function below `getCoverPic` and replace the inline mapping in `createPage`: + +```ts +export function createTag(record: PbTag, count: number): Tag { + return { + id: record.id, + name: record.label, + slug: record.slug, + href: `/tags/${record.slug}`, + count, + } +} +``` + +Replace `factory.ts:175-181` (the `pageTags` block inside `createPage`) with: + +```ts +const pageTags: Tag[] = tags.map(tag => createTag(tag, 0)) +``` + +- [ ] **Step 4: Run tests** + +Run: `npm test -w apps/hudozka -- factory.test.ts` +Expected: PASS for `createTag`. The existing `createFeedPages` tests still pass. + +- [ ] **Step 5: Verify lint** + +Run: `npm run lint` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/hudozka/src/remote/factory.ts apps/hudozka/src/remote/factory.test.ts +git commit -m "Extract createTag factory" +``` + +--- + +## Task 3: Add `createTagPageCards` factory + +Maps `PbPage[]` to `PageCardDto[]` for tag listings, with `featured: false` for now and the default cover fallback already used by the rest of the codebase. + +**Files:** +- Modify: `apps/hudozka/src/remote/factory.ts` +- Modify: `apps/hudozka/src/remote/factory.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `apps/hudozka/src/remote/factory.test.ts`: + +```ts +import { createFeedPages, createTag, createTagPageCards } from "./factory" +import type { PbImage, PbPage, PbTag } from "./types" + +function stubImage(overrides: Partial = {}): PbImage { + return { + id: "img1", + collectionId: "pbc_images", + collectionName: "images", + created: "2026-01-01T00:00:00.000Z", + updated: "2026-01-01T00:00:00.000Z", + file: "cover.jpg", + filename: "cover.jpg", + width: 800, + height: 600, + blurhash: "", + alt: "", + caption: "", + ...overrides, + } +} + +describe("createTagPageCards", () => { + it("maps pages to non-featured cards with their covers", () => { + const pages = [stubPage({ id: "p1", title: "Hello", slug: "/hello", cover: "img1", date: "2024-03-01" })] + const images = new Map([["img1", stubImage({ id: "img1" })]]) + + const result = createTagPageCards(pages, images) + + expect(result).toHaveLength(1) + expect(result[0].id).toBe("p1") + expect(result[0].url).toBe("/hello") + expect(result[0].title).toBe("Hello") + expect(result[0].featured).toBe(false) + expect(result[0].date).toBe("2024-03-01") + expect(result[0].cover.width).toBe(800) + }) + + it("falls back to default cover when image is missing", () => { + const pages = [stubPage({ id: "p1", cover: "missing" })] + const images = new Map() + + const result = createTagPageCards(pages, images) + + expect(result[0].cover.src).toBe("/static/img/main.jpg") + }) + + it("keeps order from input and uses null for empty date", () => { + const pages = [ + stubPage({ id: "a", date: "2024-03-01" }), + stubPage({ id: "b", date: "" }), + ] + const result = createTagPageCards(pages, new Map()) + expect(result.map(c => c.id)).toEqual(["a", "b"]) + expect(result[1].date).toBeNull() + }) +}) +``` + +The previous import line (added in Task 2) is replaced with the combined form shown above. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -w apps/hudozka -- factory.test.ts` +Expected: FAIL — `createTagPageCards is not a function`. + +- [ ] **Step 3: Implement `createTagPageCards`** + +Append to `apps/hudozka/src/remote/factory.ts`: + +```ts +export function createTagPageCards(pages: PbPage[], images: Map): PageCardDto[] { + return pages.map(page => ({ + id: page.id, + url: page.slug, + title: page.title, + featured: false, + date: page.date || null, + cover: getCoverPic(page.cover, images), + })) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `npm test -w apps/hudozka -- factory.test.ts` +Expected: PASS for all three new cases. + +- [ ] **Step 5: Verify lint** + +Run: `npm run lint` +Expected: clean. + +- [ ] **Step 6: Commit** + +```bash +git add apps/hudozka/src/remote/factory.ts apps/hudozka/src/remote/factory.test.ts +git commit -m "Add createTagPageCards factory" +``` + +--- + +## Task 4: Add `getAllTagSlugs` API + +Lightweight helper used by `generateStaticParams` and `sitemap.ts`. + +**Files:** +- Modify: `apps/hudozka/src/remote/api.ts` + +- [ ] **Step 1: Add the function** + +In `apps/hudozka/src/remote/api.ts`, append below `getRecentPages`: + +```ts +export async function getAllTagSlugs(): Promise { + try { + const records = await pb.collection("tags").getFullList({ + fields: "slug", + }) + return records.map(r => r.slug) + } catch (error) { + console.error(`Failed to fetch tag slugs: ${error}`) + return [] + } +} +``` + +- [ ] **Step 2: Verify typecheck** + +Run: `npx tsc -b apps/hudozka --noEmit` +Expected: clean. + +- [ ] **Step 3: Verify lint** + +Run: `npm run lint` +Expected: clean. + +- [ ] **Step 4: Commit** + +```bash +git add apps/hudozka/src/remote/api.ts +git commit -m "Add getAllTagSlugs API" +``` + +--- + +## Task 5: Add `getPagesByTag` API + +Returns the listing for a single tag at a given page number, or `null` when the tag doesn't exist. + +**Files:** +- Modify: `apps/hudozka/src/remote/api.ts` + +- [ ] **Step 1: Update imports** + +At the top of `apps/hudozka/src/remote/api.ts`, ensure factory imports include `createTag` and `createTagPageCards`: + +```ts +import { createFeedPages, createHomeCards, createPage, createTag, createTagPageCards } from "./factory" +``` + +And the type imports include `TagListing`: + +```ts +import type { BreadcrumbPart, FeedPage, Page, PageCardDto, TagListing } from "@/types" +``` + +- [ ] **Step 2: Add the function** + +Append to `apps/hudozka/src/remote/api.ts`: + +```ts +export async function getPagesByTag(slug: string, page: number, perPage: number): Promise { + try { + const tag = await pb.collection("tags").getFirstListItem(`slug="${slug}"`) + + const result = await pb.collection("pages").getList(page, perPage, { + filter: `tags ~ "${tag.id}" && draft=false`, + sort: "-updated", + fields: "id,slug,title,cover,updated,date", + }) + + const coverIds = result.items.map(p => p.cover).filter(Boolean) + const images = await fetchImagesByIds(coverIds) + + return { + tag: createTag(tag, result.totalItems), + items: createTagPageCards(result.items, images), + total: result.totalItems, + page, + perPage, + } + } catch (error) { + console.error(`Failed to fetch pages by tag "${slug}": ${error}`) + return null + } +} +``` + +Note: PocketBase throws when `getFirstListItem` finds nothing; the `catch` returns `null`, which the route maps to `notFound()`. A real "no pages tagged" case still returns a `TagListing` with `items: []` and `total: 0`. + +- [ ] **Step 3: Verify typecheck** + +Run: `npx tsc -b apps/hudozka --noEmit` +Expected: clean. + +- [ ] **Step 4: Verify lint** + +Run: `npm run lint` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hudozka/src/remote/api.ts +git commit -m "Add getPagesByTag API" +``` + +--- + +## Task 6: Add `getAllTagsWithCounts` API + +Single-scan in-memory aggregation of tag counts across non-draft pages, then joined with the tags collection. + +**Files:** +- Modify: `apps/hudozka/src/remote/api.ts` + +- [ ] **Step 1: Update type imports** + +Ensure `Tag` is imported from `@/types`: + +```ts +import type { BreadcrumbPart, FeedPage, Page, PageCardDto, Tag, TagListing } from "@/types" +``` + +- [ ] **Step 2: Add the function** + +Append to `apps/hudozka/src/remote/api.ts`: + +```ts +export async function getAllTagsWithCounts(): Promise { + try { + const pages = await pb.collection("pages").getFullList<{ tags: string[] }>({ + filter: "draft=false", + fields: "tags", + }) + + const counts = new Map() + for (const p of pages) { + for (const tagId of p.tags) { + counts.set(tagId, (counts.get(tagId) ?? 0) + 1) + } + } + + const tags = await pb.collection("tags").getFullList() + + return tags + .map(t => createTag(t, counts.get(t.id) ?? 0)) + .filter(t => t.count > 0) + .sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + return a.name.localeCompare(b.name, "ru") + }) + } catch (error) { + console.error(`Failed to fetch tags with counts: ${error}`) + return [] + } +} +``` + +- [ ] **Step 3: Verify typecheck** + +Run: `npx tsc -b apps/hudozka --noEmit` +Expected: clean. + +- [ ] **Step 4: Verify lint** + +Run: `npm run lint` +Expected: clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hudozka/src/remote/api.ts +git commit -m "Add getAllTagsWithCounts API" +``` + +--- + +## Task 7: Build `Pagination` component + +Server component (no hooks). Hidden when `total <= perPage`. Renders prev/next, page numbers with ellipsis (window: first, last, current ±1), and marks the current page with `aria-current="page"` rendered as a non-link ``. + +**Files:** +- Create: `apps/hudozka/src/components/Pagination/index.tsx` +- Create: `apps/hudozka/src/components/Pagination/styles.module.css` +- Create: `apps/hudozka/src/components/Pagination/Pagination.test.tsx` + +- [ ] **Step 1: Write the failing tests** + +Create `apps/hudozka/src/components/Pagination/Pagination.test.tsx`: + +```tsx +import { render, within } from "@testing-library/react" +import { describe, expect, it } from "vitest" +import { Pagination } from "." + +function getNav(container: HTMLElement) { + const nav = container.querySelector("nav") + if (!nav) throw new Error("expected