Pages -> App (#265)

* pages -> app

* add meta test

* fix gosuslugi
This commit is contained in:
tmshv authored and GitHub committed 2026-02-09 00:25:06 +07:00
1 parent 0c8d74b21d
commit a1e51e78f0
23 files changed
+522 -516

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
export default function Loading() {
return <div>loading...</div>
}
+71
View File
@@ -0,0 +1,71 @@
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { App } from "@/components/App"
import { PageContent } from "@/components/PageContent"
import { MetaBuilder, buildMetadata } from "@/lib/meta"
import { getMenu, getPageBySlug, getUrls } from "@/remote/api"
import { tail } from "@/lib/array"
export const revalidate = 30
export const dynamicParams = true
export async function generateStaticParams() {
const urls = await getUrls()
return urls.map(path => ({
slug: tail(path.split("/")),
}))
}
type Props = {
params: Promise<{ slug: string[] }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const slugPath = `/${slug.join("/")}`
const page = await getPageBySlug(slugPath)
if (!page) {
return {}
}
const m = new MetaBuilder()
.setImage(page.cover)
.setTitle(page.title)
.setDescription(page.description ?? undefined)
.build()
return buildMetadata(m)
}
export default async function SlugPage({ params }: Props) {
const { slug } = await params
const slugPath = `/${slug.join("/")}`
const page = await getPageBySlug(slugPath)
if (!page) {
notFound()
}
const breadcrumbSize = page.breadcrumb?.length ?? 0
const breadcrumbs = breadcrumbSize < 2 ? [] : page.breadcrumb ?? []
return (
<App
contentStyle={{
marginTop: "var(--size-l)",
marginBottom: "var(--size-xl)",
}}
breadcrumbs={breadcrumbs}
>
<PageContent
tags={page.tags}
date={page.date}
tokens={page.tokens}
documentSignature={{
date: "20.01.2021г.",
person: "Тимашева Марина Геннадьевна",
position: "Директор",
signature: "0ac4ea89753a4ba9893799442325fb41",
}}
/>
</App>
)
}
+81
View File
@@ -0,0 +1,81 @@
import "src/style/style.css"
import "src/style/content.css"
import "src/style/article.css"
import "src/style/marker/marker-table.css"
import "src/style/kazimir/video.css"
import type { Metadata, Viewport } from "next"
import Script from "next/script"
import { Roboto } from "next/font/google"
import { Providers } from "@/components/Providers"
import { YMetrika } from "@/components/YMetrika"
import { getMenu } from "@/remote/api"
const roboto = Roboto({
weight: ["300", "900"],
subsets: ["cyrillic", "latin"],
display: "swap",
})
export const metadata: Metadata = {
verification: {
google: [
"8BxOTw-Q02DS2KAdcGcnIMwiPZ58DVB18XpOfRibCcg",
"A-TEemUw4hHpULUDCd6xhxfHnyRbgKtss90JGKXnSpw",
],
other: {
"yandex-verification": "643c7dac144b9dac",
"msvalidate.01": "E878301FE3E3F12D233A6B1156889601",
"proculture-verification": "79110940fd847c3cfe52392ed68f3210",
},
},
icons: {
icon: { url: "/static/graphics/favicon-32.png", type: "image/png" },
apple: [
{ url: "/static/graphics/favicon-57.png", sizes: "57x57" },
{ url: "/static/graphics/favicon-72.png", sizes: "72x72" },
{ url: "/static/graphics/favicon-114.png", sizes: "114x114" },
{ url: "/static/graphics/favicon-144.png", sizes: "144x144" },
],
},
other: {
"apple-mobile-web-app-capable": "yes",
"referrer": "unsafe-url",
},
alternates: {
types: {
"application/feed+json": "/feed.json",
},
},
}
export const viewport: Viewport = {
width: "device-width",
maximumScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
{ media: "(prefers-color-scheme: dark)", color: "hsl(240, 10%, 10%)" },
],
}
export const revalidate = 30
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const menu = await getMenu()
return (
<html lang="ru" className={roboto.className}>
<head>
{process.env.NEXT_PUBLIC_YMETRIKA_ACCOUNT && (
<YMetrika account={Number(process.env.NEXT_PUBLIC_YMETRIKA_ACCOUNT)} />
)}
</head>
<body>
<Providers menu={menu}>
{children}
</Providers>
<Script src="https://culturaltracking.ru/static/js/spxl.js?pixelId=27154" data-pixel-id="27154" />
</body>
</html>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { App } from "@/components/App"
import { NotFound } from "@/components/NotFound"
export default function NotFoundPage() {
return (
<App>
<NotFound />
</App>
)
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from "next"
import { App } from "@/components/App"
import { HomeContent } from "@/components/HomeContent"
import { MetaBuilder, buildMetadata } from "@/lib/meta"
import { getHomeCards } from "@/remote/api"
export const revalidate = 30
export async function generateMetadata(): Promise<Metadata> {
const title = "Шлиссельбургская ДХШ"
const m = new MetaBuilder()
.setTitle(title)
.setData({ url: "/" })
.build()
return buildMetadata(m)
}
export default async function HomePage() {
const items = await getHomeCards()
return (
<App showAuthor>
<HomeContent items={items} />
</App>
)
}
-1
View File
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
-10
View File
@@ -16,7 +16,6 @@
"date-fns": "^4.0.0",
"markdown-it": "^14.1.0",
"next": "^16.1.0",
"next-seo": "^7.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
@@ -6166,15 +6165,6 @@
}
}
},
"node_modules/next-seo": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/next-seo/-/next-seo-7.1.0.tgz",
"integrity": "sha512-gTWm9wnAXvtqD8QPIpbBA6iMnqMp1WyNTGza9QJPabVIPoX+ZDt1/J0KUkziiIKRyad+nSGLC0BfHgekqcfq0Q==",
"peerDependencies": {
"next": ">=13.4.0",
"react": ">=18.2.0"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
-1
View File
@@ -26,7 +26,6 @@
"date-fns": "^4.0.0",
"markdown-it": "^14.1.0",
"next": "^16.1.0",
"next-seo": "^7.1.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-icons": "^5.5.0",
-27
View File
@@ -1,27 +0,0 @@
import type { NextPage } from "next"
import { Wrapper } from "@/components/Wrapper"
import { NotFound } from "@/components/NotFound"
import { Copyright } from "@/components/Copyright"
import { yearRange } from "@/const"
import { Spacer } from "@/components/Spacer"
import { Box } from "@/ui/Box"
const Index: NextPage = () => (
<Wrapper
header={null}
footer={(
<Box as={"footer"}>
<Spacer />
<Copyright years={yearRange} />
<Spacer />
</Box>
)}
>
<Box vertical>
<NotFound />
<Spacer />
</Box>
</Wrapper>
)
export default Index
-210
View File
@@ -1,210 +0,0 @@
import Head from "next/head"
import { App } from "@/components/App"
import { Page } from "@/components/Page"
import { generateNextSeo } from "next-seo/pages"
import { MetaBuilder } from "@/lib/meta"
import type { GetStaticProps, NextPage } from "next"
import type { BreadcrumbPart, MenuItem, Meta, Tag, Sign, Token } from "@/types"
import { Html } from "@/components/Html"
import { Youtube } from "@/components/Youtube"
import { paramsToSlug } from "@/remote/lib"
import { PageGrid } from "@/components/PageGrid"
import { useRouter } from "next/router"
import { FileCard } from "@/components/FileCard"
import { Picture } from "@/ui/Picture"
import { tail } from "@/lib/array"
import { getMenu, getPageBySlug, getUrls } from "@/remote/api"
type Props = {
title: string
tags: Tag[]
date: string
breadcrumb: BreadcrumbPart[]
meta?: Meta
menu: MenuItem[],
tokens: Token[]
documentSignature: Sign
}
const Index: NextPage<Props> = props => {
const router = useRouter()
if (router.isFallback) {
return (
<div>
loading...
</div>
)
}
return (
<>
<Head>
{!props.meta ? null : generateNextSeo({
title: props.meta.title,
description: props.meta.description,
canonical: "https://art.shlisselburg.org/",
openGraph: {
url: props.meta.url,
title: props.meta.title,
description: props.meta.description,
images: [
{
url: props.meta.image,
width: props.meta.imageWidth,
height: props.meta.imageHeight,
alt: props.meta.description,
type: "image/jpeg",
},
],
siteName: props.meta.siteName,
},
})}
<title>{props.title}</title>
</Head>
<App
contentStyle={{
marginTop: "var(--size-l)",
marginBottom: "var(--size-xl)",
}}
menu={props.menu}
breadcrumbs={props.breadcrumb}
>
<Page
tags={props.tags}
date={props.date ? new Date(props.date) : undefined}
>
<article className={"article"}>
{props.tokens.map((x, i) => {
switch (x.token) {
case "html":
return (
<Html
key={i}
html={x.data}
/>
)
case "instagram":
return (
<Html
key={i}
html={x.data.embed}
/>
)
case "youtube":
return (
<Youtube
key={i}
url={x.data.url}
/>
)
case "image":
return (
<Picture
key={i}
src={x.data.src}
alt={x.data.alt}
width={x.data.width}
height={x.data.height}
caption={x.data.caption}
blur={x.data.blur}
wide={x.wide}
/>
)
case "file":
return (
<FileCard
key={i}
sign={props.documentSignature}
{...x.data}
/>
)
case "grid":
return (
<PageGrid
key={i}
items={x.data.items}
/>
)
default:
return (
<pre key={i}>
{JSON.stringify(x)}
</pre>
)
}
})}
</article>
</Page>
</App>
</>
)
}
export const getStaticProps: GetStaticProps<Props> = async ctx => {
const slug = paramsToSlug(ctx.params?.slug ?? [])
const page = await getPageBySlug(slug)
if (!page) {
return {
notFound: true,
}
}
const menu = await getMenu()
const description = page.description ?? undefined
const breadcrumbSize = page?.breadcrumb?.length ?? 0
const breadcrumb = breadcrumbSize < 2 ? [] : page.breadcrumb ?? []
const meta = (new MetaBuilder())
.setImage(page.cover)
.setTitle(page.title)
.setDescription(description)
.build()
const tokens = page.tokens
return {
revalidate: 30,
props: {
tokens,
title: page.title,
tags: page.tags,
date: page.date,
meta,
menu,
breadcrumb,
documentSignature: {
date: "20.01.2021г.",
person: "Тимашева Марина Геннадьевна",
position: "Директор",
signature: "0ac4ea89753a4ba9893799442325fb41",
},
},
}
}
export const getStaticPaths = async () => {
const urls = await getUrls()
return {
fallback: true,
paths: urls
.map(path => {
const slug = tail(path.split("/"))
return {
params: {
slug,
},
}
}),
}
}
export default Index
-55
View File
@@ -1,55 +0,0 @@
import "src/style/style.css"
import "src/style/content.css"
import "src/style/article.css"
import "src/style/marker/marker-table.css"
import "src/style/kazimir/video.css"
import type { AppProps } from "next/app"
import Head from "next/head"
import Script from "next/script"
import { YMetrika } from "@/components/YMetrika"
import { ThemeColor } from "@/ui/ThemeColor"
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, maximum-scale=1.0" />
<meta name="referrer" content="unsafe-url" />
<meta name="google-site-verification" content="8BxOTw-Q02DS2KAdcGcnIMwiPZ58DVB18XpOfRibCcg" />
<meta name="google-site-verification" content="A-TEemUw4hHpULUDCd6xhxfHnyRbgKtss90JGKXnSpw" />
<meta name='yandex-verification' content='643c7dac144b9dac' />
<meta name="msvalidate.01" content="E878301FE3E3F12D233A6B1156889601" />
<meta name="proculture-verification" content="79110940fd847c3cfe52392ed68f3210" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/png" href="/static/graphics/favicon-32.png" />
<link rel="apple-touch-icon" sizes="57x57" href="/static/graphics/favicon-57.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/static/graphics/favicon-72.png" />
<link rel="apple-touch-icon" sizes="114x114" href="/static/graphics/favicon-114.png" />
<link rel="apple-touch-icon" sizes="144x144" href="/static/graphics/favicon-144.png" />
<link
rel="alternate"
type="application/feed+json"
title="Шлиссельбургская Детская Художественная Школа"
href="/feed.json"
/>
{process.env.NEXT_PUBLIC_YMETRIKA_ACCOUNT && (
<YMetrika account={Number(process.env.NEXT_PUBLIC_YMETRIKA_ACCOUNT)} />
)}
<ThemeColor
color="white"
darkColor="hsl(240, 10%, 10%)"
/>
</Head>
<Script src="https://culturaltracking.ru/static/js/spxl.js?pixelId=27154" data-pixel-id="27154"></Script>
<Component {...pageProps} />
</>
)
}
-20
View File
@@ -1,20 +0,0 @@
import { Html, Head, Main, NextScript } from "next/document"
export default function Document() {
return (
<Html>
<Head>
{/* Google Fonts */}
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;900&display=swap"
rel="stylesheet"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
-91
View File
@@ -1,91 +0,0 @@
import Head from "next/head"
import { App } from "@/components/App"
import { generateNextSeo } from "next-seo/pages"
import { PageGrid } from "@/components/PageGrid"
import { HudozkaTitle } from "@/components/HudozkaTitle"
import { MetaBuilder } from "@/lib/meta"
import type { GetStaticProps, NextPage } from "next"
import type { MenuItem, Meta, PageCardDto } from "@/types"
import { useMobile } from "@/hooks/useMobile"
import { getHomeCards, getMenu } from "@/remote/api"
type Props = {
title: string
meta: Meta
menu: MenuItem[]
items: PageCardDto[]
}
const Index: NextPage<Props> = props => {
const mobile = useMobile()
return (
<>
<Head>
{generateNextSeo({
title: props.meta.title,
description: props.meta.description,
canonical: "https://art.shlisselburg.org/",
openGraph: {
url: props.meta.url,
title: props.meta.title,
description: props.meta.description,
images: [
{
url: props.meta.image,
width: props.meta.imageWidth,
height: props.meta.imageHeight,
alt: props.meta.description,
type: "image/jpeg",
},
],
siteName: props.meta.siteName,
},
})}
<title>{props.title}</title>
</Head>
<App
showAuthor={true}
menu={props.menu}
>
{mobile ? null : (
<HudozkaTitle
style={{
marginTop: "var(--size-m)",
marginBottom: "var(--size-m)",
}}
/>
)}
<PageGrid
items={props.items}
/>
</App>
</>
)
}
export const getStaticProps: GetStaticProps<Props> = async () => {
const items = await getHomeCards()
const menu = await getMenu()
const title = "Шлиссельбургская ДХШ"
const meta = (new MetaBuilder())
.setTitle(title)
.setData({
url: "/",
})
.build()
return {
props: {
items,
title,
meta,
menu,
},
revalidate: 30,
}
}
export default Index
+90 -89
View File
@@ -1,12 +1,15 @@
"use client"
import s from "./app.module.css"
import { useContext } from "react"
import { Wrapper } from "../Wrapper"
import { MenuContext } from "@/context/MenuContext"
import { Footer } from "../Footer"
import { Content } from "../Content"
import { Breadcrumbs } from "../Breadcrumbs"
import type { BreadcrumbPart, MenuItem } from "@/types"
import { useRouter } from "next/router"
import type { BreadcrumbPart } from "@/types"
import { usePathname } from "next/navigation"
import { useMobile } from "@/hooks/useMobile"
import { Navigation } from "@/components/Navigation"
import { Box } from "@/ui/Box"
@@ -24,7 +27,7 @@ import Link from "next/link"
import AccessibilityPanel from "../AccessibilityPanel"
import useAccessibility from "@/hooks/useAccessibility"
const Gosuslugi = dynamic(import("../Gosuslugi").then(module => module.Gosuslugi), {
const Gosuslugi = dynamic(() => import("../Gosuslugi").then(module => ({ default: module.Gosuslugi })), {
ssr: false,
})
@@ -32,103 +35,101 @@ export type AppProps = {
showAuthor?: boolean
contentStyle?: React.CSSProperties
breadcrumbs?: BreadcrumbPart[]
menu: MenuItem[]
children?: React.ReactNode
}
export function App({ showAuthor = false, menu, ...props }: AppProps) {
export function App({ showAuthor = false, ...props }: AppProps) {
const hideBreadcrumbs = useMobile()
const accessibility = useAccessibility()
const router = useRouter()
const pathname = usePathname()
const menu = useContext(MenuContext)
return (
<MenuContext.Provider value={menu}>
<Wrapper
header={(
<header>
{!accessibility ? null : (
<AccessibilityPanel/>
)}
<Wrapper
header={(
<header>
{!accessibility ? null : (
<AccessibilityPanel/>
)}
<div className={s.navigation}>
<Navigation />
</div>
{hideBreadcrumbs || !props.breadcrumbs ? null : (
<Box center>
<Content>
<Breadcrumbs
items={props.breadcrumbs}
path={router.asPath}
/>
</Content>
</Box>
)}
</header>
)}
footer={(
<Footer>
<Box wrap gap={40} align={false} className={s.footerLinks}>
<Panel ghost>
<Contacts />
</Panel>
<Panel ghost className={s.footerPanel}>
<Title level={3}>
Версия для слабовидящих
</Title>
<AccessibilityButton />
</Panel>
<Panel ghost className={s.footerPanel}>
<Title level={3}>
Анкеты
</Title>
<ul>
<li>
<Link href={"https://bus.gov.ru/qrcode/rate/351942"}>
Независимая оценка качества оказания услуг
</Link>
</li>
<li>
<Link href={"https://forms.mkrf.ru/e/2579/xTPLeBU7/?ap_orgcode=600340071"}>
Анкета Министерства культуры РФ
</Link>
</li>
<li>
<Link href={"https://forms.yandex.ru/u/68ef8e9c90fa7b0bacf46e9d/"}>
Анкета получателей образовательных услуг ДОП Кировского муниципального района Ленинградской области 2025
</Link>
</li>
</ul>
</Panel>
<div className={s.navigation}>
<Navigation />
</div>
{hideBreadcrumbs || !props.breadcrumbs ? null : (
<Box center>
<Content>
<Breadcrumbs
items={props.breadcrumbs}
path={pathname}
/>
</Content>
</Box>
)}
</header>
)}
footer={(
<Footer>
<Box wrap gap={40} align={false} className={s.footerLinks}>
<Panel ghost>
<Contacts />
</Panel>
<div className={s.gosuslugi}>
<Gosuslugi />
</div>
<Panel ghost className={s.footerPanel}>
<Title level={3}>
Версия для слабовидящих
</Title>
<AccessibilityButton />
</Panel>
<Box>
<Copyright years={yearRange} />
<Panel ghost className={s.footerPanel}>
<Title level={3}>
Анкеты
</Title>
{!showAuthor ? null : (
<>
<Spacer as={"span"} />
<Author />
</>
)}
</Box>
</Footer>
)}
>
<Box center>
<Content style={props.contentStyle}>
{props.children}
</Content>
</Box>
</Wrapper>
</MenuContext.Provider>
<ul>
<li>
<Link href={"https://bus.gov.ru/qrcode/rate/351942"}>
Независимая оценка качества оказания услуг
</Link>
</li>
<li>
<Link href={"https://forms.mkrf.ru/e/2579/xTPLeBU7/?ap_orgcode=600340071"}>
Анкета Министерства культуры РФ
</Link>
</li>
<li>
<Link href={"https://forms.yandex.ru/u/68ef8e9c90fa7b0bacf46e9d/"}>
Анкета получателей образовательных услуг ДОП Кировского муниципального района Ленинградской области 2025
</Link>
</li>
</ul>
</Panel>
</Box>
<div className={s.gosuslugi}>
<Gosuslugi />
</div>
<Box>
<Copyright years={yearRange} />
{!showAuthor ? null : (
<>
<Spacer as={"span"} />
<Author />
</>
)}
</Box>
</Footer>
)}
>
<Box center>
<Content style={props.contentStyle}>
{props.children}
</Content>
</Box>
</Wrapper>
)
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { getResizedUrl } from "@/lib/image"
import Link from "next/link"
import { Box } from "@/ui/Box"
const Signature = dynamic(import("./Signature").then(m => m.Signature), {
const Signature = dynamic(() => import("./Signature").then(m => ({ default: m.Signature })), {
ssr: false,
})
+29
View File
@@ -0,0 +1,29 @@
"use client"
import { HudozkaTitle } from "@/components/HudozkaTitle"
import { PageGrid } from "@/components/PageGrid"
import { useMobile } from "@/hooks/useMobile"
import type { PageCardDto } from "@/types"
export type HomeContentProps = {
items: PageCardDto[]
}
export function HomeContent({ items }: HomeContentProps) {
const mobile = useMobile()
return (
<>
{mobile ? null : (
<HudozkaTitle
style={{
marginTop: "var(--size-m)",
marginBottom: "var(--size-m)",
}}
/>
)}
<PageGrid items={items} />
</>
)
}
+5 -3
View File
@@ -1,9 +1,11 @@
"use client"
import s from "./styles.module.css"
import type { MenuItem } from "@/types"
import * as NavigationMenu from "@radix-ui/react-navigation-menu"
import classnames from "classnames/bind"
import { useRouter } from "next/router"
import { usePathname } from "next/navigation"
import { ActiveLink } from "./ActiveLink"
let cx = classnames.bind(s)
@@ -15,13 +17,13 @@ export type MenuProps = {
}
export const Menu: React.FC<MenuProps> = ({ vertical = false, onItemClick, items }) => {
const router = useRouter()
const pathname = usePathname()
return (
<NavigationMenu.Root>
<NavigationMenu.List className={cx(s.menu, { vertical })}>
{
items.map(item => {
const current = router.asPath === item.href
const current = pathname === item.href
const href = current
? undefined
: item.href
@@ -1,3 +1,5 @@
"use client"
import s from "./nav.module.css"
import { Menu } from "../Menu"
@@ -8,7 +10,7 @@ import { Spacer } from "../Spacer"
import HudozkaLogo from "../HudozkaLogo"
import { HudozkaTitle } from "../HudozkaTitle"
import Link from "next/link"
import { useRouter } from "next/router"
import { usePathname } from "next/navigation"
import { HiOutlineMenuAlt4, HiOutlineX } from "react-icons/hi"
export type MobileNavigationProps = {
@@ -19,7 +21,7 @@ export type MobileNavigationProps = {
export function MobileNavigation({ className, style }: MobileNavigationProps) {
const menu = useContext(MenuContext)
const detailsRef = useRef<HTMLDetailsElement>(null)
const router = useRouter()
const pathname = usePathname()
const close = useCallback(() => {
if (detailsRef.current) {
@@ -28,11 +30,8 @@ export function MobileNavigation({ className, style }: MobileNavigationProps) {
}, [])
useEffect(() => {
router.events.on("routeChangeStart", close)
return () => {
router.events.off("routeChangeStart", close)
}
}, [router.events, close])
close()
}, [pathname, close])
return (
<nav className={className}>
+93
View File
@@ -0,0 +1,93 @@
"use client"
import { Page } from "@/components/Page"
import { Html } from "@/components/Html"
import { Youtube } from "@/components/Youtube"
import { Picture } from "@/ui/Picture"
import { FileCard } from "@/components/FileCard"
import { PageGrid } from "@/components/PageGrid"
import type { Tag, Token, Sign } from "@/types"
export type PageContentProps = {
tags: Tag[]
date?: string
tokens: Token[]
documentSignature: Sign
}
export function PageContent({ tags, date, tokens, documentSignature }: PageContentProps) {
return (
<Page
tags={tags}
date={date ? new Date(date) : undefined}
>
<article className={"article"}>
{tokens.map((x, i) => {
switch (x.token) {
case "html":
return (
<Html
key={i}
html={x.data}
/>
)
case "instagram":
return (
<Html
key={i}
html={x.data.embed}
/>
)
case "youtube":
return (
<Youtube
key={i}
url={x.data.url}
/>
)
case "image":
return (
<Picture
key={i}
src={x.data.src}
alt={x.data.alt}
width={x.data.width}
height={x.data.height}
caption={x.data.caption}
blur={x.data.blur}
wide={x.wide}
/>
)
case "file":
return (
<FileCard
key={i}
sign={documentSignature}
{...x.data}
/>
)
case "grid":
return (
<PageGrid
key={i}
items={x.data.items}
/>
)
default:
return (
<pre key={i}>
{JSON.stringify(x)}
</pre>
)
}
})}
</article>
</Page>
)
}
+17
View File
@@ -0,0 +1,17 @@
"use client"
import { MenuContext } from "@/context/MenuContext"
import type { MenuItem } from "@/types"
export type ProvidersProps = {
menu: MenuItem[]
children: React.ReactNode
}
export function Providers({ menu, children }: ProvidersProps) {
return (
<MenuContext.Provider value={menu}>
{children}
</MenuContext.Provider>
)
}
+2
View File
@@ -1,3 +1,5 @@
"use client"
import s from "./wrapper.module.css"
import cx from "classnames"
+66 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"
import { meta, MetaBuilder } from "./meta"
import { meta, MetaBuilder, buildMetadata } from "./meta"
describe("meta", () => {
it("should return defaults when called with empty object", () => {
@@ -59,3 +59,68 @@ describe("MetaBuilder", () => {
expect(result.type).toBe("article")
})
})
describe("buildMetadata", () => {
const defaultMeta = meta({})
it("should map title and description", () => {
const result = buildMetadata(defaultMeta)
expect(result.title).toBe("Шлиссельбургская ДХШ")
expect(result.description).toBe("Сайт Шлиссельбургской художественной школы")
})
it("should map openGraph fields", () => {
const result = buildMetadata(defaultMeta)
const og = result.openGraph
expect(og).toBeDefined()
expect(og).toMatchObject({
url: "https://art.shlisselburg.org/",
title: "Шлиссельбургская ДХШ",
description: "Сайт Шлиссельбургской художественной школы",
siteName: "Шлиссельбургская Детская Художественная Школа",
locale: "ru_RU",
type: "website",
})
})
it("should map openGraph images", () => {
const result = buildMetadata(defaultMeta)
const images = (result.openGraph as { images: unknown[] }).images
expect(images).toHaveLength(1)
expect(images[0]).toEqual({
url: "https://art.shlisselburg.org/entrance.jpg",
width: 1200,
height: 630,
})
})
it("should map twitter fields", () => {
const result = buildMetadata(defaultMeta)
expect(result.twitter).toEqual({
card: "summary_large_image",
site: "@",
creator: "@tmshv",
})
})
it("should use custom values from MetaBuilder", () => {
const m = new MetaBuilder()
.setTitle("Custom Page")
.setDescription("A custom description")
.setImage({ src: "https://example.com/cover.jpg", width: 800, height: 400 })
.setData({ url: "/custom" })
.build()
const result = buildMetadata(m)
expect(result.title).toBe("Custom Page")
expect(result.description).toBe("A custom description")
const og = result.openGraph as { url: string, images: { url: string, width: number, height: number }[] }
expect(og.url).toBe("https://art.shlisselburg.org/custom")
expect(og.images[0]).toEqual({
url: "https://example.com/cover.jpg",
width: 800,
height: 400,
})
})
})
+22
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next"
import type { Meta, Pic } from "@/types"
export function meta(props: Partial<Meta>): Meta {
@@ -80,3 +81,24 @@ export class MetaBuilder {
}
}
}
export function buildMetadata(m: Meta): Metadata {
return {
title: m.title,
description: m.description,
openGraph: {
url: m.url,
title: m.title,
description: m.description,
siteName: m.siteName,
locale: m.locale,
type: m.type as "website",
images: [{ url: m.image, width: m.imageWidth, height: m.imageHeight }],
},
twitter: {
card: m.twitterCard as "summary_large_image",
site: m.twitterSite,
creator: m.twitterCreator,
},
}
}