Tags page (#298)
* Add spec for tag pages and tags index * button no underscore * Add implementation plan for tag pages and tags index * Add TagListing type * Extract createTag factory * Add createTagPageCards factory * Add getAllTagSlugs API * Add getPagesByTag API * Add getAllTagsWithCounts API * Parallelize tag and page fetches in getAllTagsWithCounts * Add Pagination component * Use cx for Pagination class composition * Add TagsIndex component * Add /tags/[slug] route * Parallelize params/searchParams awaits in /tags/[slug] * Add /tags index route * Activate tag buttons * Add tag URLs to sitemap * Mark PageGrid and Overlay as client components * Filter zero-count tags from getAllTagSlugs * Fix tag filter operator and draft filter for nullable field
This commit is contained in:
1 parent
467a218d00
commit
285527f43c
20 files changed
+1990
-15
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import type { MetadataRoute } from "next"
|
||||
import { siteUrl } from "@/const"
|
||||
import { getUrls } from "@/remote/api"
|
||||
import { getAllTagSlugs, getUrls } from "@/remote/api"
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const urls = await getUrls()
|
||||
const [urls, tagSlugs] = await Promise.all([getUrls(), getAllTagSlugs()])
|
||||
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
@@ -14,6 +14,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
url: `${siteUrl}/collective`,
|
||||
changeFrequency: "daily",
|
||||
},
|
||||
{
|
||||
url: `${siteUrl}/tags`,
|
||||
changeFrequency: "weekly",
|
||||
},
|
||||
]
|
||||
|
||||
const dynamicPages: MetadataRoute.Sitemap = urls.map(path => ({
|
||||
@@ -21,5 +25,10 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
changeFrequency: "daily",
|
||||
}))
|
||||
|
||||
return [...staticPages, ...dynamicPages]
|
||||
const tagPages: MetadataRoute.Sitemap = tagSlugs.map(slug => ({
|
||||
url: `${siteUrl}/tags/${slug}`,
|
||||
changeFrequency: "weekly",
|
||||
}))
|
||||
|
||||
return [...staticPages, ...dynamicPages, ...tagPages]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Title } from "@hudozka/ui"
|
||||
import type { Metadata } from "next"
|
||||
import { notFound } from "next/navigation"
|
||||
import { App } from "@/components/App"
|
||||
import { PageGrid } from "@/components/PageGrid"
|
||||
import { Pagination } from "@/components/Pagination"
|
||||
import { menu } from "@/const"
|
||||
import { buildMetadata, MetaBuilder } from "@/lib/meta"
|
||||
import { getAllTagSlugs, getPagesByTag } from "@/remote/api"
|
||||
|
||||
export const revalidate = 30
|
||||
export const dynamicParams = true
|
||||
|
||||
const PER_PAGE = 24
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllTagSlugs()
|
||||
return slugs.map(slug => ({ slug }))
|
||||
}
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ slug: string }>
|
||||
searchParams: Promise<{ page?: string }>
|
||||
}
|
||||
|
||||
function parsePage(value: string | undefined): number {
|
||||
const n = Number.parseInt(value ?? "1", 10)
|
||||
return Number.isFinite(n) && n >= 1 ? n : 1
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
|
||||
const [{ slug }, sp] = await Promise.all([params, searchParams])
|
||||
const page = parsePage(sp.page)
|
||||
|
||||
const listing = await getPagesByTag(slug, page, PER_PAGE)
|
||||
if (!listing) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const titleBase = `${listing.tag.name} — метка`
|
||||
const title = page > 1 ? `${titleBase}, страница ${page}` : titleBase
|
||||
|
||||
const meta = new MetaBuilder().setTitle(title).setDescription(`Записи с меткой «${listing.tag.name}».`).build()
|
||||
|
||||
const metadata = buildMetadata(meta)
|
||||
if (page > 1) {
|
||||
return { ...metadata, robots: { index: false, follow: true } }
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
export default async function TagPage({ params, searchParams }: Props) {
|
||||
const [{ slug }, sp] = await Promise.all([params, searchParams])
|
||||
const page = parsePage(sp.page)
|
||||
|
||||
const listing = await getPagesByTag(slug, page, PER_PAGE)
|
||||
if (!listing) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(listing.total / listing.perPage))
|
||||
if (listing.total > 0 && page > totalPages) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const breadcrumbs = [
|
||||
{ name: menu[0].name, href: "/" },
|
||||
{ name: "Метки", href: "/tags" },
|
||||
{ name: listing.tag.name, href: listing.tag.href },
|
||||
]
|
||||
|
||||
return (
|
||||
<App
|
||||
contentStyle={{
|
||||
marginTop: "var(--size-l)",
|
||||
marginBottom: "var(--size-xl)",
|
||||
}}
|
||||
breadcrumbs={breadcrumbs}
|
||||
>
|
||||
<Title level={1}>{listing.tag.name}</Title>
|
||||
{listing.items.length === 0 ? (
|
||||
<p>Пока нет записей.</p>
|
||||
) : (
|
||||
<>
|
||||
<PageGrid items={listing.items} />
|
||||
<Pagination
|
||||
basePath={listing.tag.href}
|
||||
page={listing.page}
|
||||
total={listing.total}
|
||||
perPage={listing.perPage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</App>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Title } from "@hudozka/ui"
|
||||
import type { Metadata } from "next"
|
||||
import { App } from "@/components/App"
|
||||
import { TagsIndex } from "@/components/TagsIndex"
|
||||
import { menu } from "@/const"
|
||||
import { buildMetadata, MetaBuilder } from "@/lib/meta"
|
||||
import { getAllTagsWithCounts } from "@/remote/api"
|
||||
|
||||
export const revalidate = 30
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const meta = new MetaBuilder().setTitle("Метки").setDescription("Список всех меток сайта.").build()
|
||||
return buildMetadata(meta)
|
||||
}
|
||||
|
||||
export default async function TagsIndexPage() {
|
||||
const tags = await getAllTagsWithCounts()
|
||||
|
||||
const breadcrumbs = [
|
||||
{ name: menu[0].name, href: "/" },
|
||||
{ name: "Метки", href: "/tags" },
|
||||
]
|
||||
|
||||
return (
|
||||
<App
|
||||
contentStyle={{
|
||||
marginTop: "var(--size-l)",
|
||||
marginBottom: "var(--size-xl)",
|
||||
}}
|
||||
breadcrumbs={breadcrumbs}
|
||||
>
|
||||
<Title level={1}>Метки</Title>
|
||||
<TagsIndex items={tags} />
|
||||
</App>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { useMobile } from "@hudozka/hooks"
|
||||
import { Box } from "@hudozka/ui"
|
||||
import Image from "next/image"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
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 <nav>")
|
||||
return within(nav)
|
||||
}
|
||||
|
||||
describe("Pagination", () => {
|
||||
it("renders nothing when total fits one page", () => {
|
||||
const { container } = render(<Pagination basePath="/tags/watercolor" page={1} total={10} perPage={24} />)
|
||||
expect(container.querySelector("nav")).toBeNull()
|
||||
})
|
||||
|
||||
it("renders prev/next and page links when there are multiple pages", () => {
|
||||
const { container } = render(<Pagination basePath="/tags/watercolor" page={2} total={72} perPage={24} />)
|
||||
const nav = getNav(container)
|
||||
|
||||
const prev = nav.getByText("← Назад").closest("a")
|
||||
const next = nav.getByText("Вперёд →").closest("a")
|
||||
expect(prev).toHaveAttribute("href", "/tags/watercolor")
|
||||
expect(next).toHaveAttribute("href", "/tags/watercolor?page=3")
|
||||
})
|
||||
|
||||
it("disables prev on the first page and next on the last page", () => {
|
||||
const first = render(<Pagination basePath="/tags/x" page={1} total={50} perPage={24} />)
|
||||
const navFirst = getNav(first.container)
|
||||
expect(navFirst.getByText("← Назад").tagName).toBe("SPAN")
|
||||
|
||||
const last = render(<Pagination basePath="/tags/x" page={3} total={50} perPage={24} />)
|
||||
const navLast = getNav(last.container)
|
||||
expect(navLast.getByText("Вперёд →").tagName).toBe("SPAN")
|
||||
})
|
||||
|
||||
it("marks the current page with aria-current and renders it as a span", () => {
|
||||
const { container } = render(<Pagination basePath="/tags/x" page={2} total={72} perPage={24} />)
|
||||
const nav = getNav(container)
|
||||
const current = nav.getByText("2")
|
||||
expect(current.tagName).toBe("SPAN")
|
||||
expect(current).toHaveAttribute("aria-current", "page")
|
||||
})
|
||||
|
||||
it("shows an ellipsis when there are more than 7 pages", () => {
|
||||
const { container } = render(<Pagination basePath="/tags/x" page={5} total={24 * 10} perPage={24} />)
|
||||
const nav = getNav(container)
|
||||
expect(nav.getAllByText("…").length).toBeGreaterThan(0)
|
||||
expect(nav.getByText("1")).toBeInTheDocument()
|
||||
expect(nav.getByText("10")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import cx from "classnames"
|
||||
import Link from "next/link"
|
||||
import s from "./styles.module.css"
|
||||
|
||||
export type PaginationProps = {
|
||||
basePath: string
|
||||
page: number
|
||||
total: number
|
||||
perPage: number
|
||||
}
|
||||
|
||||
type PageEntry = { key: string; value: number | "..." }
|
||||
|
||||
function buildPageList(current: number, last: number): PageEntry[] {
|
||||
if (last <= 7) {
|
||||
return Array.from({ length: last }, (_, i) => ({ key: String(i + 1), value: i + 1 }))
|
||||
}
|
||||
const pageSet = new Set<number>([1, last, current, current - 1, current + 1])
|
||||
const sorted = [...pageSet].filter(n => n >= 1 && n <= last).sort((a, b) => a - b)
|
||||
const out: PageEntry[] = []
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
out.push({ key: String(sorted[i]), value: sorted[i] })
|
||||
const next = sorted[i + 1]
|
||||
if (next !== undefined && next - sorted[i] > 1) {
|
||||
out.push({ key: `ellipsis-${sorted[i]}`, value: "..." })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function pageHref(basePath: string, page: number): string {
|
||||
return page === 1 ? basePath : `${basePath}?page=${page}`
|
||||
}
|
||||
|
||||
export function Pagination(props: PaginationProps) {
|
||||
const last = Math.ceil(props.total / props.perPage)
|
||||
if (last <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pages = buildPageList(props.page, last)
|
||||
const isFirst = props.page <= 1
|
||||
const isLast = props.page >= last
|
||||
|
||||
return (
|
||||
<nav aria-label="Постраничная навигация">
|
||||
<ul className={s.pagination}>
|
||||
<li>
|
||||
{isFirst ? (
|
||||
<span className={cx(s.item, s.disabled)}>← Назад</span>
|
||||
) : (
|
||||
<Link className={s.item} href={pageHref(props.basePath, props.page - 1)}>
|
||||
← Назад
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
{pages.map(({ key, value }) => (
|
||||
<li key={key}>
|
||||
{value === "..." ? (
|
||||
<span className={cx(s.item, s.ellipsis)}>…</span>
|
||||
) : value === props.page ? (
|
||||
<span className={cx(s.item, s.current)} aria-current="page">
|
||||
{value}
|
||||
</span>
|
||||
) : (
|
||||
<Link className={s.item} href={pageHref(props.basePath, value)}>
|
||||
{value}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
{isLast ? (
|
||||
<span className={cx(s.item, s.disabled)}>Вперёд →</span>
|
||||
) : (
|
||||
<Link className={s.item} href={pageHref(props.basePath, props.page + 1)}>
|
||||
Вперёд →
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--size-s);
|
||||
|
||||
margin: var(--size-l) 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--size-xs) var(--size-s);
|
||||
border-radius: var(--size-s);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: var(--color-back);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--color-text-second);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
background-color: var(--color-back);
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
color: var(--color-text-second);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export type TagItemProps = {
|
||||
|
||||
export const TagItem: React.FC<TagItemProps> = props => (
|
||||
<li>
|
||||
<Button href={props.href} size={"small"} disabled={true}>
|
||||
<Button href={props.href} size={"small"}>
|
||||
{props.children}
|
||||
</Button>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render } from "@testing-library/react"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { Tag } from "@/types"
|
||||
import { TagsIndex } from "."
|
||||
|
||||
const tags: Tag[] = [
|
||||
{ id: "1", name: "Акварель", slug: "watercolor", href: "/tags/watercolor", count: 12 },
|
||||
{ id: "2", name: "Графика", slug: "graphics", href: "/tags/graphics", count: 5 },
|
||||
]
|
||||
|
||||
describe("TagsIndex", () => {
|
||||
it("renders each tag with label and count", () => {
|
||||
const { getByText } = render(<TagsIndex items={tags} />)
|
||||
expect(getByText(/Акварель/)).toBeInTheDocument()
|
||||
expect(getByText(/12/)).toBeInTheDocument()
|
||||
expect(getByText(/Графика/)).toBeInTheDocument()
|
||||
expect(getByText(/5/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("links each tag to its href", () => {
|
||||
const { container } = render(<TagsIndex items={tags} />)
|
||||
const links = container.querySelectorAll("a")
|
||||
expect(links[0]).toHaveAttribute("href", "/tags/watercolor")
|
||||
expect(links[1]).toHaveAttribute("href", "/tags/graphics")
|
||||
})
|
||||
|
||||
it("preserves input order", () => {
|
||||
const reversed = [...tags].reverse()
|
||||
const { container } = render(<TagsIndex items={reversed} />)
|
||||
const links = container.querySelectorAll("a")
|
||||
expect(links[0]).toHaveAttribute("href", "/tags/graphics")
|
||||
expect(links[1]).toHaveAttribute("href", "/tags/watercolor")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Button } from "@hudozka/ui"
|
||||
import type { Tag } from "@/types"
|
||||
import s from "./styles.module.css"
|
||||
|
||||
export type TagsIndexProps = {
|
||||
items: Tag[]
|
||||
}
|
||||
|
||||
export function TagsIndex({ items }: TagsIndexProps) {
|
||||
return (
|
||||
<ul className={s.list}>
|
||||
{items.map(tag => (
|
||||
<li key={tag.id}>
|
||||
<Button href={tag.href} size={"small"}>
|
||||
{tag.name}
|
||||
<span className={s.count}>· {tag.count}</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-s);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: var(--size-m) 0 var(--size-xl);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: var(--size-xs);
|
||||
color: var(--color-text-second);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { menu } from "@/const"
|
||||
import type { BreadcrumbPart, FeedPage, Page, PageCardDto } from "@/types"
|
||||
import type { BreadcrumbPart, FeedPage, Page, PageCardDto, Tag, TagListing } from "@/types"
|
||||
import type { DocV1Block } from "./doc"
|
||||
import { createFeedPages, createHomeCards, createPage } from "./factory"
|
||||
import { createFeedPages, createHomeCards, createPage, createTag, createTagPageCards } from "./factory"
|
||||
import { pb } from "./pb"
|
||||
import type { PbFile, PbHomeData, PbImage, PbPage, PbTag } from "./types"
|
||||
|
||||
@@ -185,3 +185,64 @@ export async function getRecentPages(limit: number = 30): Promise<FeedPage[]> {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllTagSlugs(): Promise<string[]> {
|
||||
const tags = await getAllTagsWithCounts()
|
||||
return tags.map(t => t.slug)
|
||||
}
|
||||
|
||||
export async function getPagesByTag(slug: string, page: number, perPage: number): Promise<TagListing | null> {
|
||||
try {
|
||||
const tag = await pb.collection("tags").getFirstListItem<PbTag>(`slug="${slug}"`)
|
||||
|
||||
const result = await pb.collection("pages").getList<PbPage>(page, perPage, {
|
||||
filter: `tags.id ?= "${tag.id}" && draft!=true`,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAllTagsWithCounts(): Promise<Tag[]> {
|
||||
try {
|
||||
const [pages, tags] = await Promise.all([
|
||||
pb.collection("pages").getFullList<{ tags: string[] }>({
|
||||
filter: "draft!=true",
|
||||
fields: "tags",
|
||||
}),
|
||||
pb.collection("tags").getFullList<PbTag>(),
|
||||
])
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
for (const p of pages) {
|
||||
for (const tagId of p.tags) {
|
||||
counts.set(tagId, (counts.get(tagId) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { createFeedPages } from "./factory"
|
||||
import type { PbPage } from "./types"
|
||||
import { createFeedPages, createTag, createTagPageCards } from "./factory"
|
||||
import type { PbImage, PbPage, PbTag } from "./types"
|
||||
|
||||
function stubPage(overrides: Partial<PbPage> = {}): PbPage {
|
||||
return {
|
||||
@@ -47,3 +47,80 @@ describe("createFeedPages", () => {
|
||||
expect(createFeedPages([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
function stubTag(overrides: Partial<PbTag> = {}): 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function stubImage(overrides: Partial<PbImage> = {}): 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<string, PbImage>([["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<string, PbImage>()
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,16 @@ function getCoverPic(coverId: string, images: Map<string, PbImage>): Pic {
|
||||
return DEFAULT_COVER
|
||||
}
|
||||
|
||||
export function createTag(record: PbTag, count: number): Tag {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.label,
|
||||
slug: record.slug,
|
||||
href: `/tags/${record.slug}`,
|
||||
count,
|
||||
}
|
||||
}
|
||||
|
||||
export function isYoutubeUrl(url: string): boolean {
|
||||
return /youtube\.com/.test(url)
|
||||
}
|
||||
@@ -172,13 +182,7 @@ export function createPage(
|
||||
): Page {
|
||||
const cover = getCoverPic(record.cover, images)
|
||||
|
||||
const pageTags: Tag[] = tags.map(tag => ({
|
||||
id: tag.id,
|
||||
name: tag.label,
|
||||
slug: tag.slug,
|
||||
href: `/tags/${tag.slug}`,
|
||||
count: 0,
|
||||
}))
|
||||
const pageTags: Tag[] = tags.map(tag => createTag(tag, 0))
|
||||
|
||||
const tokens: Token[] = record.doc.blocks.map(block =>
|
||||
createPageToken(block, images, files, cardGridPages, cardGridImages),
|
||||
@@ -264,3 +268,14 @@ export function createFeedPages(pages: PbPage[]): FeedPage[] {
|
||||
excerpt: page.excerpt,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createTagPageCards(pages: PbPage[], images: Map<string, PbImage>): 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),
|
||||
}))
|
||||
}
|
||||
@@ -85,6 +85,14 @@ export type Tag = {
|
||||
count: number
|
||||
}
|
||||
|
||||
export type TagListing = {
|
||||
tag: Tag
|
||||
items: PageCardDto[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
}
|
||||
|
||||
export type Pic = {
|
||||
src: string
|
||||
alt?: string
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,247 @@
|
||||
# Spec: Tag pages and tags index
|
||||
|
||||
## Goal
|
||||
|
||||
Make tag buttons on every page navigate to a paginated listing of pages sharing
|
||||
that tag, and add an index page that lists all tags. Tag links currently target
|
||||
`/tags/<slug>` (built in `factory.ts:179`) but no route handles them, so they
|
||||
404 and the buttons are rendered with `disabled={true}`. Activate the links and
|
||||
build the destination.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- New route `/tags/[slug]` — paginated listing of pages tagged `<slug>`.
|
||||
- New route `/tags` — index of all tags ordered by page count.
|
||||
- Activation of tag buttons in `TagList` (drop `disabled={true}`).
|
||||
- Sitemap entries for both new route shapes.
|
||||
- Unit tests for new factory and pagination logic.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Filtering or searching within a tag listing.
|
||||
- Multi-tag intersection (`/tags/a+b` etc.).
|
||||
- "Related tags" widget on a page.
|
||||
- Editorial featuring of pages within a tag listing — when a `featured`/`starred`
|
||||
field is added to PocketBase later, `createTagPageCards` is the one place to
|
||||
flip `featured` from the source field.
|
||||
- Russian collation niceties beyond `String#localeCompare("ru")` if a tiebreak
|
||||
is ever needed.
|
||||
|
||||
## Routes
|
||||
|
||||
### `/tags/[slug]?page=N`
|
||||
|
||||
- Pages tagged with `<slug>`, sorted by PocketBase `updated` desc.
|
||||
- 24 items per page.
|
||||
- Page 1 statically generated via `generateStaticParams` (one entry per tag
|
||||
slug); pages 2..N rendered on demand and cached by ISR (`revalidate = 30`).
|
||||
Next.js `generateStaticParams` does not accept search params.
|
||||
- Renders `<App>` with breadcrumb `Главная → Метки → <label>`, `<h1>{label}</h1>`,
|
||||
`<PageGrid items={items} />`, and `<Pagination ... />`.
|
||||
- Missing or invalid `?page` (non-integer, `<1`): treat as page 1, do not
|
||||
redirect.
|
||||
- `?page=N` exceeds the page count for a non-empty tag: `notFound()`.
|
||||
- Empty tag (no non-draft pages): render heading, breadcrumb, and the copy
|
||||
`Пока нет записей.` — no 404, regardless of `?page`.
|
||||
- Unknown slug (no `tags` record): `notFound()`.
|
||||
|
||||
### `/tags`
|
||||
|
||||
- Single static route, ISR-revalidated.
|
||||
- Renders `<App>` with breadcrumb `Главная → Метки`, `<h1>Метки</h1>`, and
|
||||
a wrapped flex list of tag buttons rendering `<label> · <count>`.
|
||||
- Tags sorted by `count desc`, ties broken by `label asc` (`localeCompare("ru")`).
|
||||
- Tags with `count === 0` are omitted.
|
||||
|
||||
## Data layer
|
||||
|
||||
### New API functions in `src/remote/api.ts`
|
||||
|
||||
```ts
|
||||
async function getPagesByTag(slug: string, page: number, perPage: number)
|
||||
: Promise<TagListing | null>
|
||||
|
||||
async function getAllTagsWithCounts(): Promise<Tag[]>
|
||||
|
||||
async function getAllTagSlugs(): Promise<string[]>
|
||||
```
|
||||
|
||||
`getPagesByTag` flow:
|
||||
|
||||
1. `pb.collection("tags").getFirstListItem<PbTag>(\`slug="${slug}"\`)` — return
|
||||
`null` on not-found.
|
||||
2. `pb.collection("pages").getList<PbPage>(page, perPage, { filter: \`tags ~
|
||||
"${tag.id}" && draft=false\`, sort: "-updated", fields: "id,slug,title,cover,
|
||||
updated,date" })`.
|
||||
3. `fetchImagesByIds()` for the page's cover IDs only.
|
||||
4. `createTagPageCards(pages, images)` to build `PageCardDto[]` (`featured`
|
||||
always `false` for now).
|
||||
5. Return `{ tag: createTag(record, total), items, total, page, perPage }`.
|
||||
|
||||
`getAllTagsWithCounts` flow (single-scan in-memory aggregation):
|
||||
|
||||
1. `pb.collection("pages").getFullList<{ tags: string[] }>({ filter:
|
||||
"draft=false", fields: "tags" })` → `Map<tagId, count>`.
|
||||
2. `pb.collection("tags").getFullList<PbTag>()`.
|
||||
3. Join; drop `count === 0`; sort by `count desc` then `label asc`.
|
||||
|
||||
`getAllTagSlugs` is a lighter variant used by `generateStaticParams` and
|
||||
`sitemap.ts` — `pb.collection("tags").getFullList<PbTag>({ fields: "slug" })`.
|
||||
|
||||
### New types in `src/types.ts`
|
||||
|
||||
```ts
|
||||
export type TagListing = {
|
||||
tag: Tag
|
||||
items: PageCardDto[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
}
|
||||
```
|
||||
|
||||
The existing `Tag.count` field becomes meaningful (it is currently hard-coded to
|
||||
`0` in `createPage`).
|
||||
|
||||
### New factory functions in `src/remote/factory.ts`
|
||||
|
||||
```ts
|
||||
export function createTag(record: PbTag, count: number): Tag
|
||||
export function createTagPageCards(
|
||||
pages: PbPage[],
|
||||
images: Map<string, PbImage>,
|
||||
): PageCardDto[]
|
||||
```
|
||||
|
||||
The inline tag-mapping at `factory.ts:175-181` is replaced with `createTag(record, 0)`.
|
||||
|
||||
`createTagPageCards` always sets `featured: false`. When a `featured`/`starred`
|
||||
field lands on PocketBase pages later, this function is the single place to
|
||||
read it.
|
||||
|
||||
## UI components
|
||||
|
||||
### New `src/components/TagsIndex/`
|
||||
|
||||
- `index.tsx` — server component.
|
||||
- Props: `{ items: Tag[] }`.
|
||||
- Renders a wrapped flex list of `Button` links from `@hudozka/ui`. Each button
|
||||
reads `<label> · <count>` and links to `tag.href`.
|
||||
- `styles.module.css` for layout (gap, wrap).
|
||||
|
||||
### New `src/components/Pagination/`
|
||||
|
||||
- `index.tsx` — server component.
|
||||
- Props: `{ basePath: string; page: number; total: number; perPage: number }`.
|
||||
- Renders `← Назад`, page numbers with ellipsis (window: first, last, current
|
||||
±1; ellipsis when >7 pages), `Вперёд →`.
|
||||
- Page links are `<Link href="${basePath}?page=N">`. The current page renders
|
||||
as a non-link `<span>` with `aria-current="page"`.
|
||||
- Hidden entirely when `total <= perPage`.
|
||||
- Wrapper is `<nav aria-label="Постраничная навигация">`.
|
||||
- `styles.module.css` for layout.
|
||||
|
||||
### Tag listing page body — composed inline in `src/app/tags/[slug]/page.tsx`
|
||||
|
||||
- `<App breadcrumbs={[home, { name: 'Метки', href: '/tags' }, { name: tag.name,
|
||||
href: tag.href }]}>`.
|
||||
- `<Title level={1}>{tag.name}</Title>` from `@hudozka/ui`.
|
||||
- Empty: `<p>Пока нет записей.</p>`.
|
||||
- Otherwise: `<PageGrid items={items} />` then `<Pagination ... />`.
|
||||
|
||||
### Tags index page body — composed inline in `src/app/tags/page.tsx`
|
||||
|
||||
- `<App breadcrumbs={[home, { name: 'Метки', href: '/tags' }]}>`.
|
||||
- `<Title level={1}>Метки</Title>`.
|
||||
- `<TagsIndex items={tags} />`.
|
||||
|
||||
### Activate `TagList`
|
||||
|
||||
- Remove `disabled={true}` from `TagItem` in `src/components/TagList/index.tsx:22`.
|
||||
- Tag buttons become live links to `/tags/<slug>`. The underline removal from
|
||||
`Button` (added earlier in the session) already covers the visual side.
|
||||
|
||||
## Metadata
|
||||
|
||||
`generateMetadata` per route, built with the existing `MetaBuilder` +
|
||||
`buildMetadata()` helper for consistency with `[...slug]/page.tsx`.
|
||||
|
||||
`tags/page.tsx`:
|
||||
|
||||
- `title: "Метки"`.
|
||||
- `description: "Список всех меток сайта."`.
|
||||
|
||||
`tags/[slug]/page.tsx`:
|
||||
|
||||
- Unknown slug: return `{}` (matches `[...slug]/page.tsx`).
|
||||
- Page 1: `title: "{tag.name} — метка"`,
|
||||
`description: "Записи с меткой «{tag.name}»."`.
|
||||
- Pages 2+: `title: "{tag.name} — метка, страница {page}"`,
|
||||
`robots: { index: false, follow: true }` so search engines don't index every
|
||||
paginated variant.
|
||||
|
||||
## Sitemap
|
||||
|
||||
`src/app/sitemap.ts`:
|
||||
|
||||
- Add `/tags`.
|
||||
- Add `/tags/<slug>` for every tag (page 1 only — paginated variants excluded).
|
||||
- `lastModified: new Date()`, `changeFrequency: "weekly"`, `priority: 0.5`.
|
||||
- Reuses `getAllTagSlugs()`.
|
||||
|
||||
## File plan
|
||||
|
||||
Created:
|
||||
|
||||
- `apps/hudozka/src/app/tags/page.tsx`
|
||||
- `apps/hudozka/src/app/tags/[slug]/page.tsx`
|
||||
- `apps/hudozka/src/components/TagsIndex/index.tsx`
|
||||
- `apps/hudozka/src/components/TagsIndex/styles.module.css`
|
||||
- `apps/hudozka/src/components/Pagination/index.tsx`
|
||||
- `apps/hudozka/src/components/Pagination/styles.module.css`
|
||||
- `apps/hudozka/src/components/Pagination/Pagination.test.tsx`
|
||||
- `apps/hudozka/src/components/TagsIndex/TagsIndex.test.tsx`
|
||||
|
||||
Modified:
|
||||
|
||||
- `apps/hudozka/src/types.ts` — add `TagListing`.
|
||||
- `apps/hudozka/src/remote/api.ts` — add `getPagesByTag`, `getAllTagsWithCounts`,
|
||||
`getAllTagSlugs`.
|
||||
- `apps/hudozka/src/remote/factory.ts` — extract `createTag`, add
|
||||
`createTagPageCards`.
|
||||
- `apps/hudozka/src/remote/factory.test.ts` — cases for `createTag` and
|
||||
`createTagPageCards`.
|
||||
- `apps/hudozka/src/components/TagList/index.tsx` — drop `disabled={true}`.
|
||||
- `apps/hudozka/src/app/sitemap.ts` — append tag URLs.
|
||||
|
||||
## Testing
|
||||
|
||||
Vitest, co-located.
|
||||
|
||||
- `factory.test.ts` — `createTag(record, count)` propagates count and href;
|
||||
`createTagPageCards(pages, images)` falls back to default cover when missing
|
||||
and always sets `featured: false`.
|
||||
- `Pagination.test.tsx` — hidden when `total <= perPage`, renders correct page
|
||||
links, current page is non-link with `aria-current="page"`, ellipsis logic
|
||||
for >7 pages.
|
||||
- `TagsIndex.test.tsx` — renders `<label> · <count>`; component does not sort
|
||||
(sorting is the API's job).
|
||||
|
||||
No tests for the App Router page modules — the existing codebase tests data and
|
||||
components in isolation, not routes.
|
||||
|
||||
## Open questions
|
||||
|
||||
None. All resolved during brainstorming:
|
||||
|
||||
- Pagination style: page-based with `?page=N`.
|
||||
- Item appearance: `PageGrid` reused, `featured: false` for now, future-ready
|
||||
when starred pages land.
|
||||
- Sort order: PocketBase `updated` desc.
|
||||
- Heading and breadcrumb: `<label>` heading, `Главная → Метки → <label>` path.
|
||||
- Page size: 24.
|
||||
- Empty tag: render empty-state copy. Unknown tag: 404.
|
||||
- `/tags` index: built now, sorted by count desc.
|
||||
- Tag count strategy: single scan + in-memory aggregation.
|
||||
@@ -5,6 +5,7 @@
|
||||
font-size: var(--font-size-default);
|
||||
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
|
||||
border-radius: var(--size-s);
|
||||
border: 1px solid var(--color-shadow);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
export type TransitionProps = {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { useLockBodyScroll, useReducedMotion } from "@hudozka/hooks"
|
||||
import s from "./styles.module.css"
|
||||
import { Transition } from "./Transition"
|
||||
|
||||
Reference in new issue
Block a user