Merge branch 'master' of github.com:tmshv/hudozka

This commit is contained in:
tmshv committed 2026-04-26 01:18:09 +03:00
commit d9cf3a1536
17 files changed
+565 -15

No files matched your search

+12 -3
View File
@@ -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]
}
+96
View File
@@ -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>
)
}
+36
View File
@@ -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);
}
+63 -2
View File
@@ -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 []
}
}
+79 -2
View File
@@ -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()
})
})
+22 -7
View File
@@ -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),
}))
}
+8
View File
@@ -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
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
export type TransitionProps = {
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { useLockBodyScroll, useReducedMotion } from "@hudozka/hooks"
import s from "./styles.module.css"
import { Transition } from "./Transition"