updated widgets
This commit is contained in:
51
bun/index.ts
51
bun/index.ts
@@ -13,6 +13,9 @@ const server = Bun.serve({
|
||||
const url = new URL(req.url);
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") ?? 1));
|
||||
|
||||
const UPLOADS_BASE_URL =
|
||||
process.env.WP_UPLOADS_URL ?? "https://new.jkhsakha.ru/wp-content/uploads";
|
||||
|
||||
// 1. Load grid config post
|
||||
const gridPost = await connection.run(`
|
||||
SELECT ID, post_title
|
||||
@@ -22,16 +25,15 @@ const server = Bun.serve({
|
||||
`, { id: gridId });
|
||||
|
||||
const [grid] = await gridPost.getRowObjectsJson();
|
||||
if (!grid) {
|
||||
return Response.json(null, { status: 404 });
|
||||
}
|
||||
if (!grid) return Response.json(null, { status: 404 });
|
||||
|
||||
|
||||
// 2. Load grid meta
|
||||
const metaRes = await connection.run(`
|
||||
SELECT meta_key, meta_value
|
||||
FROM wp_postmeta
|
||||
WHERE post_id = $id;
|
||||
`, { id: gridId });
|
||||
SELECT meta_key, meta_value
|
||||
FROM wp_postmeta
|
||||
WHERE post_id = $id;
|
||||
`, { id: gridId });
|
||||
|
||||
const meta = Object.fromEntries(
|
||||
(await metaRes.getRowObjectsJson())
|
||||
@@ -47,33 +49,32 @@ const server = Bun.serve({
|
||||
|
||||
// 3. Total count (for pagination)
|
||||
const totalRes = await connection.run(`
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type = $postType;
|
||||
`, { postType });
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type = $postType;
|
||||
`, { postType });
|
||||
|
||||
const [{ total }] = await totalRes.getRowObjectsJson();
|
||||
const totalPages = Math.max(1, Math.ceil(total / perPage));
|
||||
|
||||
// clamp page if out of range
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const safeOffset = (safePage - 1) * perPage;
|
||||
|
||||
// 4. Fetch paginated posts
|
||||
const postsRes = await connection.run(`
|
||||
SELECT
|
||||
ID,
|
||||
post_title,
|
||||
post_name,
|
||||
post_excerpt,
|
||||
post_date
|
||||
FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type = $postType
|
||||
ORDER BY ${orderBy} ${order}
|
||||
LIMIT $perPage OFFSET $offset;
|
||||
`, {
|
||||
SELECT
|
||||
ID,
|
||||
post_title,
|
||||
post_name,
|
||||
post_excerpt,
|
||||
post_date
|
||||
FROM wp_posts
|
||||
WHERE post_status = 'publish'
|
||||
AND post_type = $postType
|
||||
ORDER BY ${orderBy} ${order}
|
||||
LIMIT $perPage OFFSET $offset;
|
||||
`, {
|
||||
postType,
|
||||
perPage,
|
||||
offset: safeOffset
|
||||
|
||||
897
package-lock.json
generated
897
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -11,15 +11,35 @@
|
||||
"dependencies": {
|
||||
"@puckeditor/core": "^0.21.1",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.19.0",
|
||||
"@tiptap/extension-file-handler": "^3.20.0",
|
||||
"@tiptap/extension-highlight": "^3.19.0",
|
||||
"@tiptap/extension-image": "^3.19.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-table": "^3.19.0",
|
||||
"@tiptap/extension-task-item": "^3.19.0",
|
||||
"@tiptap/extension-task-list": "^3.19.0",
|
||||
"@tiptap/extension-text-align": "^3.19.0",
|
||||
"@tiptap/extension-typography": "^3.19.0",
|
||||
"@tiptap/extension-underline": "^3.19.0",
|
||||
"@tiptap/extension-youtube": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@wordpress/block-serialization-default-parser": "^5.39.0",
|
||||
"axios": "^1.13.2",
|
||||
"daisyui": "^5.5.14",
|
||||
"embla-carousel-auto-scroll": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"html-react-parser": "^5.2.17",
|
||||
"interweave": "^13.1.1",
|
||||
"next": "16.1.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^4.6.4",
|
||||
"sass": "^1.97.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
BIN
public/karta-s-logo2.png
Normal file
BIN
public/karta-s-logo2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
6
public/robots.txt
Normal file
6
public/robots.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
User-agent: *
|
||||
Disallow: /wp-admin/
|
||||
Allow: /wp-admin/admin-ajax.php
|
||||
Disallow: /wp-content/uploads/wpforms/
|
||||
|
||||
Sitemap: https://new.jkhsakha.ru/wp-sitemap.xml
|
||||
@@ -1,10 +1,11 @@
|
||||
// app/[slug]/page.tsx
|
||||
import Post from '@/components/Post/Post';
|
||||
import { renderPostContent } from '@/components/WPRenderer/WPRenderer';
|
||||
|
||||
// ISR: regenerate every 60 seconds
|
||||
export const revalidate = 60;
|
||||
|
||||
interface PageProps {
|
||||
export interface PageProps {
|
||||
params: {
|
||||
slug: string;
|
||||
};
|
||||
@@ -28,20 +29,6 @@ export default async function PostPage({ params }: PageProps) {
|
||||
const post = await res.json();
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-5xl">
|
||||
<article className="prose lg:prose-xl max-w-none">
|
||||
<h1>{post.post_title}</h1>
|
||||
|
||||
{post.post_type === 'post' && (
|
||||
<div className="text-gray-600 mb-6">
|
||||
<time>
|
||||
{new Date(post.post_date).toLocaleString('ru-RU')}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderPostContent(post.post_content)}
|
||||
</article>
|
||||
</div>
|
||||
<Post post={post} />
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export default async function PostPage({ params }: PageProps) {
|
||||
{pageData.post_type === 'post' && (
|
||||
<div className="text-gray-600 mb-6">
|
||||
<time>
|
||||
{new Date(pageData.post_date).toLocaleString('ru-RU')}
|
||||
{pageData.post_date}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
39
src/app/(client)/layout.tsx
Normal file
39
src/app/(client)/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import Header from "@/components/Blocks/Header/Header";
|
||||
import CookieNotice from "@/components/CookieNotice/CookieNotice";
|
||||
import Footer from "@/components/Footer/Footer";
|
||||
import "@/styles/globals.css";
|
||||
import { Montserrat, Roboto, Roboto_Condensed } from 'next/font/google'
|
||||
|
||||
export const montserratFont = Montserrat({
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
})
|
||||
|
||||
const mainFont = Roboto({
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
})
|
||||
|
||||
export const condensedFont = Roboto_Condensed({
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
})
|
||||
|
||||
export const revalidate = 10
|
||||
|
||||
export default function ClientLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 overflow-x-auto">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<CookieNotice />
|
||||
</>
|
||||
);
|
||||
}
|
||||
250
src/app/(client)/page.tsx
Normal file
250
src/app/(client)/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
// app/page.tsx
|
||||
import Section from '@/components/Blocks/Section/Section';
|
||||
import EmblaCarousel from '@/components/UI/EmblaCarousel/EmblaCarousel';
|
||||
import Areas from '@/components/WP/Areas/Areas';
|
||||
import { SmartSlider } from '@/components/WPRenderer/SmartSlider';
|
||||
import { renderPostContent } from '@/components/WPRenderer/WPRenderer';
|
||||
import { CarouselSlide, PostData } from '@/types/entities';
|
||||
|
||||
export const revalidate = 10;
|
||||
|
||||
export default async function HomePage() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/home`, { next: { revalidate: 10 }, });
|
||||
|
||||
if (!res.ok) {
|
||||
// 🚨 REQUIRED for stale reuse
|
||||
throw new Error('Failed to fetch home posts');
|
||||
}
|
||||
|
||||
const posts: PostData[] = await res.json();
|
||||
|
||||
const stats: { title: string, value: string }[] = [
|
||||
{
|
||||
title: 'территории Якутии',
|
||||
value: '74 %'
|
||||
},
|
||||
{
|
||||
title: 'муниципальных районов',
|
||||
value: '26'
|
||||
},
|
||||
{
|
||||
title: 'теплоснабжающих объектов',
|
||||
value: '637'
|
||||
},
|
||||
{
|
||||
title: 'Гкал/ч установленная мощность',
|
||||
value: '2645,4'
|
||||
},
|
||||
{
|
||||
title: 'км. инженерных сетей',
|
||||
value: '2818,5'
|
||||
},
|
||||
{
|
||||
title: 'работников',
|
||||
value: '8446'
|
||||
}
|
||||
]
|
||||
|
||||
const areaSlides: CarouselSlide[] = [
|
||||
{
|
||||
id: 0,
|
||||
title: 'Абыйский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%90%D0%B1%D1%8B%D0%B9%D1%81%D0%BA%D0%B8%D0%B9-pxq84cwhvn5ot9eugai2c8bxn40u3rd9vx6c1fzro0.jpg'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Аллаиховский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%90%D0%BB%D0%BB%D0%B0%D0%B8%D1%85%D0%BE%D0%B2%D1%81%D0%BA%D0%B8%D0%B9-pxq84gnumzau3p9duc4km7ds0niayjs78fs9yju6z4.jpg'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Амгинский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%90%D0%BC%D0%B3%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq84kf7ebfze53x8dr2w6fme6zrtc74kye7vnoma8.jpg'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Анабарский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%90%D0%BD%D0%B0%D0%B1%D0%B0%D1%80%D1%81%D0%BA%D0%B8%D0%B9-pxq84o6k5nl4okygmfdl65hgrqh8o4m1xh05srj1lc.jpg'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Булунский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%91%D1%83%D0%BB%D1%83%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq84rxwwzq9z0t00h03g4jb59ypix0z9zm3pvdgwg.jpg'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Верхневилюйский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%92%D0%B5%D1%80%D1%85%D0%BD%D0%B5%D0%B2%D0%B8%D0%BB%D1%8E%D0%B9%D1%81%D0%BA%D0%B8%D0%B9-pxq84vp9obvf9gnjeimlq3l5itg6dpfwmi81mz7w7k.jpg'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Верхнеколымский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%92%D0%B5%D1%80%D1%85%D0%BD%D0%B5%D0%BA%D0%BE%D0%BB%D1%8B%D0%BC%D1%81%D0%BA%D0%B8%D0%B9-pxq84zgmfo0kjwi2sk9402mzwcxn8hutz0tzk32bio.jpg'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Вилюйский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%92%D0%B8%D0%BB%D1%8E%D0%B9%D1%81%D0%BA%D0%B8%D0%B9-pxq857x656c5ge5sf5wr4ii58try5rsf06pcvkpryo.jpg'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: 'Верхоянский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%92%D0%B5%D1%80%D1%85%D0%BE%D1%8F%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq8545tdu705yb914a8ujgavaahazdhno3eygvcnk.jpg'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: 'Горный',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%93%D0%BE%D1%80%D0%BD%D1%8B%D0%B9-pxq85cmd3cil2fyynpxvyzbg7r4s89b2otys9yit3k.jpg'
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: 'Жиганский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%96%D0%B8%D0%B3%D0%B0%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq85gdpuonqcvti1rke8ydalam931q01ckq72d8eo.jpg'
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'Заречный Кобяй',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%97%D0%B0%D1%80%D0%B5%D1%87%D0%BD%D1%8B%D0%B9-%D0%9A%D0%BE%D0%B1%D1%8F%D0%B9-pxq85k52m0svnbo1ft6wixf4yu3pxu4xdv6o467nps.jpg'
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: 'Кобяйский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9A%D0%BE%D0%B1%D1%8F%D0%B9%D1%81%D0%BA%D0%B8%D0%B9-pxq85nwfdcy0xriktuteswgzcdl6smjuqdsm1a230w.jpg'
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
title: 'Мегино-Кангаласский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9C%D0%B5%D0%B3%D0%B8%D0%BD%D0%BE-%D0%9A%D0%B0%D0%BD%D0%B3%D0%B0%D0%BB%D0%B0%D1%81%D1%81%D0%BA%D0%B8%D0%B9-pxq85rns4p3687d47wfx2vitpx2nneys2wejydwic0.jpg'
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
title: 'Момский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9C%D0%BE%D0%BC%D1%81%D0%BA%D0%B8%D0%B9-pxq85vf4w18bin7nly2fcuko3gk4i7dpff0hvhqxn4.jpg'
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
title: 'Нижнеколымский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9D%D0%B8%D0%B6%D0%BD%D0%B5%D0%BA%D0%BE%D0%BB%D1%8B%D0%BC%D1%81%D0%BA%D0%B8%D0%B9-pxq8604bu7er4p0tui3k7bdz2dwykowd429x9vjys0.jpg'
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
title: 'Нюрбинский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9D%D1%8E%D1%80%D0%B1%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq863voljjwf4vd8jq2haftfxeffhbagkvv6zee34.jpg'
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
title: 'Олекминский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9E%D0%BB%D0%B5%D0%BA%D0%BC%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq867n1cvp1pkpwmlckr9hntgvwa9q7t3ht438te8.jpg'
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
title: 'Оленекский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%9E%D0%BB%D0%B5%D0%BD%D0%B5%D0%BA%D1%81%D0%BA%D0%B8%D0%B9-pxq86bee47u700kg0mz318ji70dd52555m3r1738pc.jpg'
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
title: 'Среднеколымский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A1%D1%80%D0%B5%D0%B4%D0%BD%D0%B5%D0%BA%D0%BE%D0%BB%D1%8B%D0%BC%D1%81%D0%BA%D0%B8%D0%B9-pxq86f5qvjzcagezeollb7lckjutzuk2i4poyaxo0g.jpg'
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
title: 'Сунтарский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A1%D1%83%D0%BD%D1%82%D0%B0%D1%80%D1%81%D0%BA%D0%B8%D0%B9-pxq86juxtq5rwi85n8mq5oenjh7o2c2q6rz4coqp5c.jpg'
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
title: 'Таттинский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A2%D0%B0%D1%82%D1%82%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq86nmal2ax6y2p1a98fnghx0p4x4hnjal29sl4gg.jpg'
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
title: 'Томпонский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A2%D0%BE%D0%BC%D0%BF%D0%BE%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq86rdnceg2hdx8fbvqpmicak6lrwwkvt706wfjrk.jpg'
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
title: 'Усть-Алданский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A3%D1%81%D1%82%D1%8C-%D0%90%D0%BB%D0%B4%D0%B0%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq86w2uakmi3fqenvwvk3bn9hjfuef8kggfla8kwg.jpg'
|
||||
},
|
||||
{
|
||||
id: 25,
|
||||
title: 'Хангаласский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A5%D0%B0%D0%BD%D0%B3%D0%B0%D0%BB%D0%B0%D1%81%D1%81%D0%BA%D0%B8%D0%B9-pxq86zu71wrndvky1xjdu2dhn10wp6u5wz2die307k.jpg'
|
||||
},
|
||||
{
|
||||
id: 26,
|
||||
title: 'Чурапчинский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%A7%D1%83%D1%80%D0%B0%D0%BF%D1%87%D0%B8%D0%BD%D1%81%D0%BA%D0%B8%D0%B9-pxq873ljt8wsobfhfz5w41fc0kidjz939hobfhxfio.jpg'
|
||||
},
|
||||
{
|
||||
id: 27,
|
||||
title: 'Эвено-Бытантайский',
|
||||
src: 'https://new.jkhsakha.ru/wp-content/uploads/elementor/thumbs/%D0%AD%D0%B2%D0%B5%D0%BD%D0%BE-%D0%91%D1%8B%D1%82%D0%B0%D0%BD%D1%82%D0%B0%D0%B9%D1%81%D0%BA%D0%B8%D0%B9-pxq877cwkl1xyra0u0see0h6e3zuero0m0a9clruts.jpg'
|
||||
}
|
||||
]
|
||||
|
||||
const slides: CarouselSlide[] = [
|
||||
{
|
||||
id: 0,
|
||||
title: "Тепло сердец - людям!",
|
||||
src: "/first_slide2.jpg",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "Личный кабинет",
|
||||
src: "/Личный-кабинет.jpg",
|
||||
href: "https://lk.jkhsakha.ru",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Способы оплаты",
|
||||
src: "/Способ-оплаты.jpg",
|
||||
href: "/sposoby-oplaty/",
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center sm:gap-8 mb-8">
|
||||
{/* {posts.map(post => (
|
||||
<div key={post.ID} className="max-w-5xl">
|
||||
{renderPostContent(post.post_content)}
|
||||
</div>
|
||||
))} */}
|
||||
|
||||
<EmblaCarousel title_align='left' title_position='top' show_dots={true} show_title={true} slides={slides} />
|
||||
|
||||
<Section title='Новости'>
|
||||
<div className='grid grid-cols-2 space-y-4 space-x-4'>
|
||||
<EmblaCarousel rounded title_align='left' shape='square' show_title title_position='bottom' autoplay={true} show_dots={true} slides_per_view={1} slides={slides} dots_position='bottom_inside' />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title='О предприятии'>
|
||||
<div className={`grid grid-cols-1 lg:grid-cols-2 justify-center items-center`}>
|
||||
<div>
|
||||
<Areas />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 space-y-4 space-x-4">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="grid grid-rows-[min-content_1fr] space-y-2">
|
||||
<div className="stat-value text-3xl sm:text-5xl text-[#0063A7]">{stat.value}</div>
|
||||
<div className="stat-title text-wrap text-lg sm:text-2xl text-neutral">{stat.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title='Филиалы'>
|
||||
<EmblaCarousel rounded autoscroll={true} show_dots={false} slides_per_view={4} slides={areaSlides} dots_position='bottom' />
|
||||
</Section>
|
||||
|
||||
<Section title="Контакты">
|
||||
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/app/(editor)/[slug]/edit/page.tsx
Normal file
17
src/app/(editor)/[slug]/edit/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { PageProps } from "@/app/(client)/[slug]/page";
|
||||
import PostPage from "@/app/(client)/[slug]/page";
|
||||
import ClientLayout from "@/app/(client)/layout";
|
||||
import Post from "@/components/Post/Post";
|
||||
import Tiptap from "@/components/Tiptap/Tiptap";
|
||||
|
||||
export default async function EditorPage({ params }: PageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
const pageData = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/pages/${slug}`, {
|
||||
//next: { revalidate: 60 },
|
||||
}).then(res => res.json());
|
||||
|
||||
return (
|
||||
<Tiptap slug={slug} data={pageData} />
|
||||
)
|
||||
}
|
||||
91
src/app/(editor)/admin/page.tsx
Normal file
91
src/app/(editor)/admin/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import { WebVitalsCard } from "@/components/WebVitalsCard"
|
||||
import { useWebVitals } from "@/hooks/useWebVitals"
|
||||
|
||||
const AdminPage = () => {
|
||||
const metrics = useWebVitals()
|
||||
|
||||
const vitalsConfig = [
|
||||
{
|
||||
title: 'Time to First Byte (TTFB)',
|
||||
value: metrics.TTFB,
|
||||
unit: 'ms',
|
||||
description: 'Time between request and first byte of response',
|
||||
thresholds: { good: 200, needsImprovement: 500 }
|
||||
},
|
||||
{
|
||||
title: 'First Contentful Paint (FCP)',
|
||||
value: metrics.FCP,
|
||||
unit: 'ms',
|
||||
description: 'Time until first content is rendered',
|
||||
thresholds: { good: 1800, needsImprovement: 3000 }
|
||||
},
|
||||
{
|
||||
title: 'Largest Contentful Paint (LCP)',
|
||||
value: metrics.LCP,
|
||||
unit: 'ms',
|
||||
description: 'Time until largest content element is rendered',
|
||||
thresholds: { good: 2500, needsImprovement: 4000 }
|
||||
},
|
||||
{
|
||||
title: 'First Input Delay (FID)',
|
||||
value: metrics.FID,
|
||||
unit: 'ms',
|
||||
description: 'Time from first user interaction to browser response',
|
||||
thresholds: { good: 100, needsImprovement: 300 }
|
||||
},
|
||||
{
|
||||
title: 'Cumulative Layout Shift (CLS)',
|
||||
value: metrics.CLS,
|
||||
unit: '',
|
||||
description: 'Measures visual stability (lower is better)',
|
||||
thresholds: { good: 0.1, needsImprovement: 0.25 }
|
||||
},
|
||||
{
|
||||
title: 'Interaction to Next Paint (INP)',
|
||||
value: metrics.INP,
|
||||
unit: 'ms',
|
||||
description: 'Measures responsiveness to user interactions',
|
||||
thresholds: { good: 200, needsImprovement: 500 }
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Web Vitals Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Real-time Core Web Vitals metrics for your Next.js application
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{vitalsConfig.map((vital) => (
|
||||
<WebVitalsCard
|
||||
key={vital.title}
|
||||
title={vital.title}
|
||||
value={vital.value}
|
||||
unit={vital.unit}
|
||||
description={vital.description}
|
||||
thresholds={vital.thresholds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h2 className="text-lg font-semibold text-blue-900 mb-2">About Core Web Vitals</h2>
|
||||
<p className="text-sm text-blue-800">
|
||||
Core Web Vitals are a set of real-world, user-centered metrics that quantify key aspects
|
||||
of user experience. They measure dimensions of web usability such as load time,
|
||||
interactivity, and the stability of content as it loads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export default AdminPage
|
||||
354
src/app/(editor)/admin/posts/page.tsx
Normal file
354
src/app/(editor)/admin/posts/page.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
import React from 'react'
|
||||
|
||||
const PostsPage = () => {
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<div className="">
|
||||
<table className="table table-pin-rows table-pin-cols">
|
||||
{/* head */}
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Job</th>
|
||||
<th>Favorite Color</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* row 1 */}
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/2@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Hart Hagerty</div>
|
||||
<div className="text-sm opacity-50">United States</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Zemlak, Daniel and Leannon
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Desktop Support Technician</span>
|
||||
</td>
|
||||
<td>Purple</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
{/* row 2 */}
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/3@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Brice Swyre</div>
|
||||
<div className="text-sm opacity-50">China</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Carroll Group
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Tax Accountant</span>
|
||||
</td>
|
||||
<td>Red</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
{/* row 3 */}
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/4@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Marjy Ferencz</div>
|
||||
<div className="text-sm opacity-50">Russia</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Rowe-Schoen
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Office Assistant I</span>
|
||||
</td>
|
||||
<td>Crimson</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
{/* row 4 */}
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<label>
|
||||
<input type="checkbox" className="checkbox" />
|
||||
</label>
|
||||
</th>
|
||||
<td>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="avatar">
|
||||
<div className="mask mask-squircle h-12 w-12">
|
||||
<img
|
||||
src="https://img.daisyui.com/images/profile/demo/5@94.webp"
|
||||
alt="Avatar Tailwind CSS Component" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Yancy Tear</div>
|
||||
<div className="text-sm opacity-50">Brazil</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
Wyman-Ledner
|
||||
<br />
|
||||
<span className="badge badge-ghost badge-sm">Community Outreach Specialist</span>
|
||||
</td>
|
||||
<td>Indigo</td>
|
||||
<th>
|
||||
<button className="btn btn-ghost btn-xs">details</button>
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
{/* foot */}
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Name</th>
|
||||
<th>Job</th>
|
||||
<th>Favorite Color</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostsPage
|
||||
70
src/app/(editor)/layout.tsx
Normal file
70
src/app/(editor)/layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { IconBook2, IconHome, IconSettings } from "@tabler/icons-react";
|
||||
|
||||
export default function EditLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
<input id="my-drawer-4" type="checkbox" className="drawer-toggle" />
|
||||
<div className="drawer-content h-screen grid grid-rows-[min-content_1fr]">
|
||||
{/* Navbar */}
|
||||
<nav className="navbar w-full bg-base-300">
|
||||
<label htmlFor="my-drawer-4" aria-label="open sidebar" className="btn btn-square btn-ghost">
|
||||
{/* Sidebar toggle icon */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" strokeLinejoin="round" strokeLinecap="round" strokeWidth="2" fill="none" stroke="currentColor" className="my-1.5 inline-block size-4"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
|
||||
</label>
|
||||
<div id="editor-navbar-title" className="px-4">
|
||||
{/* Editor Navbar title goes here */}
|
||||
</div>
|
||||
<div id="editor-navbar" className="ml-auto flex space-x-2">
|
||||
{/* Editor Navbar items go here */}
|
||||
</div>
|
||||
</nav>
|
||||
{/* Page content here */}
|
||||
<div className="overflow-auto max-h-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="drawer-side is-drawer-close:overflow-visible">
|
||||
<label htmlFor="my-drawer-4" aria-label="close sidebar" className="drawer-overlay"></label>
|
||||
<div className="flex min-h-full flex-col items-start bg-base-200 is-drawer-close:w-14 is-drawer-open:w-64">
|
||||
{/* Sidebar content here */}
|
||||
<ul className="menu w-full grow border-r border-r-gray-300">
|
||||
{/* List item */}
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
href="/admin"
|
||||
className="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Главная">
|
||||
{/* Home icon */}
|
||||
<IconHome className="my-1.5 inline-block size-4" size={16} />
|
||||
<span className="is-drawer-close:hidden">Главная</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
role="button"
|
||||
href="/admin/posts"
|
||||
className="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Записи">
|
||||
<IconBook2 className="my-1.5 inline-block size-4" size={16} />
|
||||
<span className="is-drawer-close:hidden">Записи</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{/* List item */}
|
||||
<li>
|
||||
<button className="is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Настройки">
|
||||
<IconSettings className="my-1.5 inline-block size-4" size={16} />
|
||||
<span className="is-drawer-close:hidden">Настройки</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import Header from "@/components/Blocks/Header/Header";
|
||||
import Footer from "@/components/Footer/Footer";
|
||||
import "@/styles/globals.css";
|
||||
import { Montserrat, Roboto, Roboto_Condensed } from 'next/font/google'
|
||||
|
||||
@@ -25,13 +23,16 @@ export default function GlobalLayout({
|
||||
return (
|
||||
<html lang="en" className={mainFont.className}>
|
||||
<body className="relative min-h-screen flex flex-col overflow-auto">
|
||||
<Header />
|
||||
{/* <Header />
|
||||
|
||||
<main className="flex-1 px-4 sm:px-0 overflow-x-auto">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<CookieNotice /> */}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// app/page.tsx
|
||||
import { renderPostContent } from '@/components/WPRenderer/WPRenderer';
|
||||
import { PostData } from '@/types/entities';
|
||||
|
||||
export const revalidate = 10;
|
||||
|
||||
export default async function HomePage() {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/home`, { next: { revalidate: 10 }, });
|
||||
|
||||
if (!res.ok) {
|
||||
// 🚨 REQUIRED for stale reuse
|
||||
throw new Error('Failed to fetch home posts');
|
||||
}
|
||||
|
||||
const posts: PostData[] = await res.json();
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center">
|
||||
{posts.map(post => (
|
||||
<div key={post.ID} className="max-w-5xl">
|
||||
{renderPostContent(post.post_content)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/app/test/page.tsx
Normal file
13
src/app/test/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// app/page.tsx
|
||||
|
||||
import Areas from "@/components/WP/Areas/Areas";
|
||||
|
||||
export const revalidate = 10;
|
||||
|
||||
export default async function TestPage() {
|
||||
return (
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<Areas />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import { ContentScheme } from '@/types/elements';
|
||||
export const revalidate = 60;
|
||||
|
||||
async function Header() {
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/menu/navbar`, {
|
||||
next: { revalidate: 60 },
|
||||
|
||||
11
src/components/Blocks/HomeStats/HomeStats.tsx
Normal file
11
src/components/Blocks/HomeStats/HomeStats.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
const HomeStats = () => {
|
||||
return (
|
||||
<div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeStats
|
||||
21
src/components/Blocks/Section/Section.tsx
Normal file
21
src/components/Blocks/Section/Section.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { montserratFont } from '@/app/layout'
|
||||
import React, { PropsWithChildren } from 'react'
|
||||
|
||||
interface SectionProps extends PropsWithChildren {
|
||||
title?: string
|
||||
}
|
||||
|
||||
const Section = ({ children, title }: SectionProps) => {
|
||||
return (
|
||||
<div className='max-w-5xl w-full p-4 sm:p-0 flex flex-col space-y-8'>
|
||||
{title &&
|
||||
<span className={`${montserratFont.className} font-semibold text-3xl text-[#0063A7]`}>{title}</span>
|
||||
}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Section
|
||||
51
src/components/CookieNotice/CookieNotice.tsx
Normal file
51
src/components/CookieNotice/CookieNotice.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
import React from 'react'
|
||||
|
||||
interface CookieNoticeButton {
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
interface CookieNotice {
|
||||
buttons: CookieNoticeButton[];
|
||||
message: string;
|
||||
order: number
|
||||
}
|
||||
|
||||
const CookieNotice = () => {
|
||||
const cookieNoticeData: CookieNotice = {
|
||||
order: 0,
|
||||
message: "Продолжая использовать сайт, Вы принимаете условия <a href='https://new.jkhsakha.ru/wp-content/uploads/2025/09/politika-obrabotki-personalnyh-dannyh-gup-zhkh-rsja.pdf'>ПОЛИТИКИ</a> и даёте согласие на обработку пользовательских данных (файлов cookie).",
|
||||
buttons: [
|
||||
{
|
||||
label: "Понятно",
|
||||
action: () => {
|
||||
// Логика принятия cookies
|
||||
console.log("Cookies accepted");
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Отклонить",
|
||||
action: () => {
|
||||
// Логика отклонения cookies
|
||||
console.log("Cookies rejected");
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* <div dangerouslySetInnerHTML={{ __html: cookieNoticeData.message }} />
|
||||
{cookieNoticeData.buttons.map((button, index) => (
|
||||
<button key={index} onClick={button.action}>
|
||||
{button.label}
|
||||
</button>
|
||||
))} */}
|
||||
{/* <IconX /> */}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default CookieNotice
|
||||
@@ -4,9 +4,9 @@ import { IconBrandOkRu, IconBrandTelegram, IconBrandVk } from '@tabler/icons-rea
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<div className='border-t border-t-gray-300'>
|
||||
<div className='border-t border-t-gray-300 bg-[#0063A7]'>
|
||||
<Centered>
|
||||
<footer className={`${montserratFont.className} footer sm:footer-horizontal text-base-content py-10 max-w-5xl space-x-4`}>
|
||||
<footer className={`${montserratFont.className} footer sm:footer-horizontal text-white py-10 max-w-5xl space-x-4`}>
|
||||
<nav>
|
||||
<h6 className="footer-title">О предприятии</h6>
|
||||
<a className="link link-hover" href='/rukovodstvo/'>Руководство</a>
|
||||
|
||||
24
src/components/Post/Post.tsx
Normal file
24
src/components/Post/Post.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { renderPostContent } from '../WPRenderer/WPRenderer'
|
||||
import { PostData } from '@/types/entities'
|
||||
|
||||
const Post = ({ post }: { post: PostData }) => {
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-5xl">
|
||||
<article className="prose lg:prose-xl max-w-none">
|
||||
<h1>{post.post_title}</h1>
|
||||
|
||||
{post.post_type === 'post' && (
|
||||
<div className="text-gray-600 mb-6">
|
||||
<time>
|
||||
{post.post_date}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renderPostContent(post.post_content)}
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Post
|
||||
25
src/components/Post/PostSettings.tsx
Normal file
25
src/components/Post/PostSettings.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { PostData } from '@/types/entities'
|
||||
import React from 'react'
|
||||
|
||||
const PostSettings = ({
|
||||
data
|
||||
}: {
|
||||
data: PostData
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="collapse collapse-arrow bg-base-100 border-base-300 border">
|
||||
<input type="checkbox" />
|
||||
<div className="collapse-title font-semibold">Категории</div>
|
||||
<div className="collapse-content text-sm">
|
||||
<label className="label">
|
||||
<input type="checkbox" defaultChecked className="checkbox" />
|
||||
Рубрики
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostSettings
|
||||
218
src/components/Tiptap/Tiptap.tsx
Normal file
218
src/components/Tiptap/Tiptap.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useEditor, EditorContent, ResizableNodeView } from '@tiptap/react'
|
||||
import { FloatingMenu, BubbleMenu } from '@tiptap/react/menus'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import Post from '../Post/Post'
|
||||
import { PostData } from '@/types/entities'
|
||||
import TiptapToolbar from './TiptapToolbar'
|
||||
import Text from '@tiptap/extension-text'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Paragraph from '@tiptap/extension-paragraph'
|
||||
import { TaskItem, TaskList } from '@tiptap/extension-list'
|
||||
import { Highlight } from '@tiptap/extension-highlight'
|
||||
import FileHandler from '@tiptap/extension-file-handler'
|
||||
import Underline from '@tiptap/extension-underline'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
||||
import Typography from '@tiptap/extension-typography';
|
||||
import { Group, Panel, usePanelRef } from 'react-resizable-panels'
|
||||
import { createPortal } from 'react-dom'
|
||||
import PostSettings from '../Post/PostSettings'
|
||||
import { Dropcursor } from '@tiptap/extensions'
|
||||
import TiptapBubbleMenu from './TiptapBubbleMenu'
|
||||
import TiptapFloatingMenu from './TiptapFloatingMenu'
|
||||
|
||||
const Tiptap = ({
|
||||
slug,
|
||||
data
|
||||
}: {
|
||||
slug: string
|
||||
data: PostData
|
||||
}) => {
|
||||
const [content, setContent] = useState(data.post_content)
|
||||
|
||||
const [showPreview, setShowPreview] = useState(true)
|
||||
|
||||
const previewModal = useRef<HTMLDialogElement>(null)
|
||||
|
||||
const editor = useEditor({
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose lg:prose-xl p-4 focus:outline-none max-w-none',// focus:outline-none',
|
||||
},
|
||||
},
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false
|
||||
}),
|
||||
Image.configure({
|
||||
resize: {
|
||||
enabled: true,
|
||||
alwaysPreserveAspectRatio: true,
|
||||
},
|
||||
inline: false,
|
||||
}),
|
||||
TextAlign.configure({
|
||||
types: ['heading', 'paragraph'],
|
||||
}),
|
||||
TaskList,
|
||||
TaskItem.configure({
|
||||
nested: true
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true
|
||||
}),
|
||||
FileHandler.configure({
|
||||
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
onDrop: (currentEditor, files, pos) => {
|
||||
files.forEach(file => {
|
||||
const fileReader = new FileReader()
|
||||
|
||||
fileReader.readAsDataURL(file)
|
||||
fileReader.onload = () => {
|
||||
currentEditor
|
||||
.chain()
|
||||
.insertContentAt(pos, {
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: fileReader.result,
|
||||
},
|
||||
})
|
||||
.focus()
|
||||
.run()
|
||||
}
|
||||
})
|
||||
},
|
||||
onPaste: (currentEditor, files, htmlContent) => {
|
||||
files.forEach(file => {
|
||||
if (htmlContent) {
|
||||
// if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
|
||||
// you could extract the pasted file from this url string and upload it to a server for example
|
||||
console.log(htmlContent) // eslint-disable-line no-console
|
||||
return false
|
||||
}
|
||||
|
||||
const fileReader = new FileReader()
|
||||
|
||||
fileReader.readAsDataURL(file)
|
||||
fileReader.onload = () => {
|
||||
currentEditor
|
||||
.chain()
|
||||
.insertContentAt(currentEditor.state.selection.anchor, {
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: fileReader.result,
|
||||
},
|
||||
})
|
||||
.focus()
|
||||
.run()
|
||||
}
|
||||
})
|
||||
},
|
||||
}),
|
||||
TableRow, TableHeader, TableCell,
|
||||
Highlight,
|
||||
Typography,
|
||||
Underline,
|
||||
Text,
|
||||
Paragraph,
|
||||
Dropcursor
|
||||
],
|
||||
content: content,
|
||||
// Don't render immediately on the server to avoid SSR issues
|
||||
immediatelyRender: false,
|
||||
onUpdate: (({ editor }) => {
|
||||
setContent(editor.getHTML())
|
||||
}),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && content !== editor.getHTML()) {
|
||||
editor.commands.setContent(content);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
const editorNavbar = useRef<HTMLElement | null>(null)
|
||||
const editorNavbarTitle = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
editorNavbar.current = document.getElementById("editor-navbar")
|
||||
editorNavbarTitle.current = document.getElementById("editor-navbar-title")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rightPanelRef = usePanelRef()
|
||||
|
||||
return (
|
||||
<div className='w-full h-full'>
|
||||
{editorNavbarTitle.current &&
|
||||
createPortal(<span>{data.post_title}</span>, editorNavbarTitle.current)
|
||||
}
|
||||
|
||||
{editorNavbar.current &&
|
||||
createPortal(
|
||||
<div className='space-x-2'>
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
if (previewModal) {
|
||||
previewModal.current?.showModal()
|
||||
}
|
||||
}}>
|
||||
Предпросмотр
|
||||
</button>
|
||||
<button className="btn btn-sm" onClick={() => {
|
||||
if (previewModal) {
|
||||
previewModal.current?.showModal()
|
||||
}
|
||||
}}>
|
||||
Опубликовать
|
||||
</button>
|
||||
</div>
|
||||
, editorNavbar.current)}
|
||||
|
||||
|
||||
<Group orientation='horizontal' className='w-full max-h-screen'>
|
||||
<Panel className='border-r border-r-gray-300 overflow-auto'>
|
||||
{editor &&
|
||||
<TiptapToolbar editor={editor} />
|
||||
}
|
||||
|
||||
{editor ?
|
||||
<div className='relative'>
|
||||
<EditorContent editor={editor} />
|
||||
<TiptapFloatingMenu editor={editor}/>
|
||||
<TiptapBubbleMenu editor={editor} />
|
||||
</div>
|
||||
:
|
||||
<div className='loading'></div>
|
||||
}
|
||||
|
||||
</Panel>
|
||||
|
||||
<Panel panelRef={rightPanelRef} defaultSize={'25%'} minSize={'15%'} maxSize={'40%'} className='p-4 overflow-auto'>
|
||||
<PostSettings data={data} />
|
||||
</Panel>
|
||||
</Group>
|
||||
|
||||
{
|
||||
<dialog ref={previewModal} id="preview_modal" className="modal">
|
||||
<div className="modal-box w-full! max-w-full!">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<Post post={{ ...data, post_content: content }} />
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default Tiptap
|
||||
46
src/components/Tiptap/TiptapBubbleMenu.tsx
Normal file
46
src/components/Tiptap/TiptapBubbleMenu.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BubbleMenu } from '@tiptap/react/menus'
|
||||
import React from 'react'
|
||||
import { Editor, useEditorState } from '@tiptap/react';
|
||||
import { tiptapToolBarStateSelector } from './TiptapToolbarState';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import { IconBold, IconCode, IconItalic, IconStrikethrough } from '@tabler/icons-react';
|
||||
|
||||
const TiptapBubbleMenu = ({
|
||||
editor
|
||||
}: {
|
||||
editor: Editor
|
||||
}) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: tiptapToolBarStateSelector,
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
className='p-1 backdrop-blur-sm border border-gray-300 rounded-sm'
|
||||
options={{
|
||||
strategy: 'absolute'
|
||||
}} editor={editor}>
|
||||
{<>
|
||||
{editorState.canBold && <ToolbarButton isActive={editorState.isBold} disabled={!editorState.canBold} title='Bold' icon={<IconBold size={18} />} onClick={() => editor.chain().focus().toggleBold().run()} />}
|
||||
{editorState.canItalic && <ToolbarButton isActive={editorState.isItalic} disabled={!editorState.canItalic} title='Italic' icon={<IconItalic size={18} />} onClick={() => editor.chain().focus().toggleItalic().run()} />}
|
||||
{editorState.canStrike && <ToolbarButton isActive={editorState.isStrike} disabled={!editorState.canStrike} title='Strikethrough' icon={<IconStrikethrough size={18} />} onClick={() => editor.chain().focus().toggleStrike().run()} />}
|
||||
{editorState.canCode && <ToolbarButton
|
||||
isActive={editorState.isCode}
|
||||
disabled={!editorState.canCode}
|
||||
title='Code'
|
||||
icon={<IconCode size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
/>}
|
||||
</>}
|
||||
|
||||
</BubbleMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default TiptapBubbleMenu
|
||||
53
src/components/Tiptap/TiptapFloatingMenu.tsx
Normal file
53
src/components/Tiptap/TiptapFloatingMenu.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Editor, useEditorState } from '@tiptap/react'
|
||||
import { FloatingMenu } from '@tiptap/react/menus'
|
||||
import { tiptapToolBarStateSelector } from './TiptapToolbarState';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import { addHorizontalRule, addImage, addTable } from './tiptapActions';
|
||||
import { IconMinus, IconPolaroid, IconSeparatorHorizontal, IconTable } from '@tabler/icons-react';
|
||||
|
||||
const TiptapFloatingMenu = ({
|
||||
editor
|
||||
}: {
|
||||
editor: Editor
|
||||
}) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: tiptapToolBarStateSelector,
|
||||
})
|
||||
|
||||
return (
|
||||
<FloatingMenu
|
||||
className='p-1 backdrop-blur-sm border border-gray-300 rounded-sm'
|
||||
editor={editor}>
|
||||
<ToolbarButton
|
||||
title='Insert Image'
|
||||
icon={<IconPolaroid size={18} />}
|
||||
onClick={() => addImage(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
title='Insert Table'
|
||||
icon={<IconTable size={18} />}
|
||||
onClick={() => addTable(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
title='Horizontal Rule'
|
||||
icon={<IconMinus size={18} />}
|
||||
onClick={() => addHorizontalRule(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
title='Line Break'
|
||||
icon={<IconSeparatorHorizontal size={18} />}
|
||||
onClick={() => editor.chain().focus().setHardBreak().run()}
|
||||
/>
|
||||
</FloatingMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default TiptapFloatingMenu
|
||||
222
src/components/Tiptap/TiptapToolbar.tsx
Normal file
222
src/components/Tiptap/TiptapToolbar.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
// components/TiptapToolbar.tsx
|
||||
import { IconAlignCenter, IconAlignLeft, IconAlignRight, IconArrowBack, IconArrowForward, IconBold, IconClearFormatting, IconClipboard, IconCode, IconCodeDots, IconH1, IconH2, IconH3, IconItalic, IconLink, IconList, IconListCheck, IconListNumbers, IconMinus, IconPilcrow, IconPolaroid, IconQuote, IconSeparatorHorizontal, IconSparkles, IconStrikethrough, IconTable, IconUnlink } from '@tabler/icons-react';
|
||||
import { Editor, useEditorState } from '@tiptap/react';
|
||||
import { tiptapToolBarStateSelector } from './TiptapToolbarState';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ToolbarButton from './ToolbarButton';
|
||||
import { addHorizontalRule, addImage, addTable, setLink } from './tiptapActions';
|
||||
|
||||
interface ToolbarProps {
|
||||
editor: Editor | null;
|
||||
}
|
||||
|
||||
const TiptapToolbar = ({ editor }: ToolbarProps) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: tiptapToolBarStateSelector,
|
||||
})
|
||||
|
||||
const editorNavbar = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
editorNavbar.current = document.getElementById("editor-navbar")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-300 p-2 flex flex-wrap gap-1 bg-base-200/90 backdrop-blur-xs sticky top-0 z-10">
|
||||
{/* Text Formatting */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-400 pr-2">
|
||||
<ToolbarButton isActive={editorState.isBold} disabled={!editorState.canBold} title='Bold' icon={<IconBold size={18} />} onClick={() => editor.chain().focus().toggleBold().run()} />
|
||||
<ToolbarButton isActive={editorState.isItalic} disabled={!editorState.canItalic} title='Italic' icon={<IconItalic size={18} />} onClick={() => editor.chain().focus().toggleItalic().run()} />
|
||||
<ToolbarButton isActive={editorState.isStrike} disabled={!editorState.canStrike} title='Strikethrough' icon={<IconStrikethrough size={18} />} onClick={() => editor.chain().focus().toggleStrike().run()} />
|
||||
<ToolbarButton
|
||||
isActive={editorState.isCode}
|
||||
disabled={!editorState.canCode}
|
||||
title='Code'
|
||||
icon={<IconCode size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
title='Clear marks'
|
||||
icon={<IconClearFormatting size={18} />}
|
||||
onClick={() => editor.chain().focus().unsetAllMarks().run()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Headings */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-400 pr-2">
|
||||
<ToolbarButton
|
||||
isActive={editorState.isHeading1}
|
||||
title='Heading 1'
|
||||
icon={<IconH1 size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isHeading2}
|
||||
title='Heading 2'
|
||||
icon={<IconH2 size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isHeading3}
|
||||
title='Heading 3'
|
||||
icon={<IconH3 size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isParagraph}
|
||||
title='Paragraph'
|
||||
icon={<IconPilcrow size={18} />}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lists */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-400 pr-2">
|
||||
<ToolbarButton
|
||||
isActive={editorState.isBulletList}
|
||||
title='Bullet List'
|
||||
icon={<IconList size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isOrderedList}
|
||||
title='Ordered List'
|
||||
icon={<IconListNumbers size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alignment */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-400 pr-2">
|
||||
<ToolbarButton
|
||||
isActive={editorState.isAlignLeft}
|
||||
title='Align Left'
|
||||
icon={<IconAlignLeft size={18} />}
|
||||
onClick={() => editor.chain().focus().setTextAlign('left').run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isAlignCenter}
|
||||
title='Align Center'
|
||||
icon={<IconAlignCenter size={18} />}
|
||||
onClick={() => editor.chain().focus().setTextAlign('center').run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isAlignRight}
|
||||
title='Align Right'
|
||||
icon={<IconAlignRight size={18} />}
|
||||
onClick={() => editor.chain().focus().setTextAlign('right').run()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media & Links */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-400 pr-2">
|
||||
<ToolbarButton
|
||||
title='Insert Image'
|
||||
icon={<IconPolaroid size={18} />}
|
||||
onClick={() => addImage(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isLink}
|
||||
title='Insert Link'
|
||||
icon={<IconLink size={18} />}
|
||||
onClick={() => setLink(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={!editorState.isLink}
|
||||
title='Remove Link'
|
||||
icon={<IconUnlink size={18} />}
|
||||
disabled={!editorState.isLink}
|
||||
onClick={() => editor.chain().focus().unsetLink().run()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced Features */}
|
||||
<div className="flex items-center gap-1 mr-2 border-r border-gray-400 pr-2">
|
||||
<ToolbarButton
|
||||
title='Insert Table'
|
||||
icon={<IconTable size={18} />}
|
||||
onClick={() => addTable(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isCodeBlock}
|
||||
title='Code Block'
|
||||
icon={<IconCodeDots size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
isActive={editorState.isBlockquote}
|
||||
title='Quote'
|
||||
icon={<IconQuote size={18} />}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
title='Horizontal Rule'
|
||||
icon={<IconMinus size={18} />}
|
||||
onClick={() => addHorizontalRule(editor)}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
title='Line Break'
|
||||
icon={<IconSeparatorHorizontal size={18} />}
|
||||
onClick={() => editor.chain().focus().setHardBreak().run()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* History */}
|
||||
{editorNavbar.current ?
|
||||
createPortal(<div className="flex items-center gap-1 ml-auto">
|
||||
<ToolbarButton
|
||||
disabled={!editorState.canUndo}
|
||||
title='Undo'
|
||||
icon={<IconArrowBack size={18} />}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
disabled={!editorState.canRedo}
|
||||
title='Redo'
|
||||
icon={<IconArrowForward size={18} />}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
/>
|
||||
</div>, editorNavbar.current) :
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<ToolbarButton
|
||||
disabled={!editorState.canUndo}
|
||||
title='Undo'
|
||||
icon={<IconArrowBack size={18} />}
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
/>
|
||||
|
||||
<ToolbarButton
|
||||
disabled={!editorState.canRedo}
|
||||
title='Redo'
|
||||
icon={<IconArrowForward size={18} />}
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TiptapToolbar;
|
||||
43
src/components/Tiptap/TiptapToolbarState.ts
Normal file
43
src/components/Tiptap/TiptapToolbarState.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Editor, EditorStateSnapshot } from "@tiptap/react"
|
||||
|
||||
export function tiptapToolBarStateSelector(ctx: EditorStateSnapshot<Editor>) {
|
||||
return {
|
||||
// Text formatting
|
||||
isBold: ctx.editor.isActive('bold') ?? false,
|
||||
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
|
||||
isItalic: ctx.editor.isActive('italic') ?? false,
|
||||
canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
|
||||
isStrike: ctx.editor.isActive('strike') ?? false,
|
||||
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
|
||||
isCode: ctx.editor.isActive('code') ?? false,
|
||||
canCode: ctx.editor.can().chain().toggleCode().run() ?? false,
|
||||
canClearMarks: ctx.editor.can().chain().unsetAllMarks().run() ?? false,
|
||||
isLink: ctx.editor.isActive('link') ?? false,
|
||||
|
||||
// Block types
|
||||
isParagraph: ctx.editor.isActive('paragraph') ?? false,
|
||||
isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
|
||||
isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
|
||||
isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
|
||||
isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
|
||||
isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
|
||||
isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
|
||||
|
||||
// Lists and blocks
|
||||
isBulletList: ctx.editor.isActive('bulletList') ?? false,
|
||||
isOrderedList: ctx.editor.isActive('orderedList') ?? false,
|
||||
isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
|
||||
isBlockquote: ctx.editor.isActive('blockquote') ?? false,
|
||||
|
||||
// Align
|
||||
isAlignLeft: ctx.editor.isActive({textAlign: 'left'}) ?? false,
|
||||
isAlignRight: ctx.editor.isActive({textAlign: 'right'}) ?? false,
|
||||
isAlignCenter: ctx.editor.isActive({textAlign: 'center'}) ?? false,
|
||||
|
||||
// History
|
||||
canUndo: ctx.editor.can().chain().undo().run() ?? false,
|
||||
canRedo: ctx.editor.can().chain().redo().run() ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export type TiptapToolbarState = ReturnType<typeof tiptapToolBarStateSelector>
|
||||
28
src/components/Tiptap/ToolbarButton.tsx
Normal file
28
src/components/Tiptap/ToolbarButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
|
||||
const ToolbarButton = ({
|
||||
isActive,
|
||||
onClick,
|
||||
icon,
|
||||
title,
|
||||
disabled
|
||||
}: {
|
||||
isActive?: boolean
|
||||
onClick: () => void
|
||||
icon?: React.ReactNode
|
||||
title?: string
|
||||
disabled?: boolean
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`btn btn-sm btn-square ${isActive ? 'btn-neutral' : 'btn-ghost'}`}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon ?? icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ToolbarButton
|
||||
36
src/components/Tiptap/tiptapActions.ts
Normal file
36
src/components/Tiptap/tiptapActions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
export const addImage = (editor: Editor) => {
|
||||
const url = window.prompt('Enter image URL:');
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
};
|
||||
|
||||
export const addHorizontalRule = (editor: Editor) => {
|
||||
editor.chain().focus().setHorizontalRule().run();
|
||||
};
|
||||
|
||||
export const addTable = (editor: Editor) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run();
|
||||
};
|
||||
|
||||
export const setLink = (editor: Editor) => {
|
||||
const previousUrl = editor.getAttributes('link').href;
|
||||
const url = window.prompt('Enter URL:', previousUrl);
|
||||
|
||||
if (url === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === '') {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
};
|
||||
62
src/components/UI/EmblaCarousel/EmblaCarousel.css
Normal file
62
src/components/UI/EmblaCarousel/EmblaCarousel.css
Normal file
@@ -0,0 +1,62 @@
|
||||
.embla {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embla__viewport {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embla__container {
|
||||
display: flex;
|
||||
touch-action: pan-y pinch-zoom;
|
||||
}
|
||||
|
||||
.embla__slide {
|
||||
display: flex;
|
||||
flex: 0 0 100%;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Navigation buttons */
|
||||
.embla__buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.embla__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.embla__button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Dots navigation */
|
||||
.embla__dots {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.embla__dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.embla__dot--selected {
|
||||
background-color: #0063A7;
|
||||
width: 1.5rem;
|
||||
/* Makes the selected dot wider */
|
||||
}
|
||||
227
src/components/UI/EmblaCarousel/EmblaCarousel.tsx
Normal file
227
src/components/UI/EmblaCarousel/EmblaCarousel.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
import './EmblaCarousel.css'
|
||||
import React, { HTMLAttributes, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import useEmblaCarousel from 'embla-carousel-react'
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
import AutoScroll from 'embla-carousel-auto-scroll'
|
||||
import { CarouselSlide } from '@/types/entities'
|
||||
import { IconChevronLeft, IconChevronRight } from '@tabler/icons-react'
|
||||
import { montserratFont } from '@/app/layout'
|
||||
|
||||
interface EmblaCarouselProps {
|
||||
slides: CarouselSlide[]
|
||||
slides_per_view?: number
|
||||
show_dots?: boolean
|
||||
dots_position?: 'bottom_inside' | 'bottom'
|
||||
buttons_position?: 'middle_inside' | 'bottom'
|
||||
show_title?: boolean
|
||||
title_align?: 'center' | 'left' | 'right'
|
||||
title_position?: 'middle' | 'top' | 'bottom'
|
||||
autoplay?: boolean
|
||||
autoscroll?: boolean
|
||||
autoscroll_speed?: number
|
||||
autoscroll_direction?: 'forward' | 'backward'
|
||||
shape?: 'fit' | 'square'
|
||||
rounded?: boolean
|
||||
}
|
||||
|
||||
const EmblaCarousel = ({
|
||||
slides,
|
||||
slides_per_view,
|
||||
show_dots = false,
|
||||
dots_position = 'bottom_inside',
|
||||
buttons_position = 'middle_inside',
|
||||
show_title = false,
|
||||
title_align = 'center',
|
||||
title_position = 'middle',
|
||||
autoplay = false,
|
||||
autoscroll = false,
|
||||
autoscroll_speed = 1.5,
|
||||
autoscroll_direction = 'forward',
|
||||
shape = 'fit',
|
||||
rounded = false
|
||||
}: EmblaCarouselProps) => {
|
||||
const autoScrollPlugin = useRef(
|
||||
AutoScroll({
|
||||
active: autoscroll,
|
||||
speed: autoscroll_speed,
|
||||
direction: autoscroll_direction,
|
||||
stopOnInteraction: false, // Stop when user interacts
|
||||
stopOnMouseEnter: false, // Stop on hover
|
||||
//startDelay: 500, // Delay before starting
|
||||
playOnInit: autoscroll, // Start playing on init
|
||||
})
|
||||
)
|
||||
|
||||
const autoplayPlugin = useRef(
|
||||
Autoplay({
|
||||
active: autoplay,
|
||||
stopOnInteraction: false, // Stop when user interacts
|
||||
stopOnMouseEnter: false, // Stop on hover
|
||||
jump: false, // false = smooth scroll, true = instant jump
|
||||
playOnInit: autoplay, // Start playing on init
|
||||
})
|
||||
)
|
||||
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
align: 'start',
|
||||
loop: true
|
||||
}, [autoplayPlugin.current, autoScrollPlugin.current])
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
|
||||
|
||||
const scrollTo = useCallback((index: number) => {
|
||||
emblaApi?.scrollTo(index)
|
||||
|
||||
autoScrollPlugin.current.stop()
|
||||
setTimeout(() => {
|
||||
if (autoscroll) autoScrollPlugin.current.play()
|
||||
}, 1000)
|
||||
}, [emblaApi])
|
||||
|
||||
const scrollPrev = useCallback(() => {
|
||||
emblaApi?.scrollPrev()
|
||||
|
||||
autoScrollPlugin.current.stop()
|
||||
setTimeout(() => {
|
||||
if (autoscroll) autoScrollPlugin.current.play()
|
||||
}, 1000)
|
||||
}, [emblaApi])
|
||||
|
||||
const scrollNext = useCallback(() => {
|
||||
emblaApi?.scrollNext()
|
||||
|
||||
autoScrollPlugin.current.stop()
|
||||
setTimeout(() => {
|
||||
if (autoscroll) autoScrollPlugin.current.play()
|
||||
}, 1000)
|
||||
}, [emblaApi])
|
||||
|
||||
const onSelect = useCallback(() => {
|
||||
if (!emblaApi) return
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||
}, [emblaApi])
|
||||
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
|
||||
onSelect()
|
||||
setScrollSnaps(emblaApi.scrollSnapList())
|
||||
emblaApi.on('select', onSelect)
|
||||
emblaApi.on('reInit', onSelect)
|
||||
|
||||
if (autoplay) {
|
||||
autoplayPlugin.current.play()
|
||||
} else {
|
||||
autoplayPlugin.current.stop()
|
||||
}
|
||||
|
||||
if (autoscroll) {
|
||||
autoScrollPlugin.current.play()
|
||||
} else {
|
||||
autoplayPlugin.current.stop()
|
||||
}
|
||||
|
||||
return () => {
|
||||
emblaApi.off('select', onSelect)
|
||||
emblaApi.off('reInit', onSelect)
|
||||
emblaApi.off('autoScroll:play', () => { })
|
||||
emblaApi.off('autoScroll:stop', () => { })
|
||||
}
|
||||
}, [emblaApi, autoplay, autoscroll, onSelect])
|
||||
|
||||
const slideTitleAlign = (title_align?: EmblaCarouselProps['title_align']) => {
|
||||
switch (title_align) {
|
||||
case 'center':
|
||||
return 'justify-center'
|
||||
case 'left':
|
||||
return 'justify-start'
|
||||
case 'right':
|
||||
return 'justify-end'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const slideTitlePosition = (title_position?: EmblaCarouselProps['title_position']) => {
|
||||
switch (title_position) {
|
||||
case 'bottom':
|
||||
return 'items-end'
|
||||
case 'middle':
|
||||
return 'items-center'
|
||||
case 'top':
|
||||
return 'items-start'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const slideClass = (slides_per_view?: number) => {
|
||||
switch (slides_per_view) {
|
||||
case 1:
|
||||
return ''
|
||||
case 2:
|
||||
return 'sm:basis-1/2!'
|
||||
case 3:
|
||||
return 'sm:basis-1/3!'
|
||||
case 4:
|
||||
return 'basis-1/2! sm:basis-1/4!'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`embla ${montserratFont.className} ${rounded ? 'rounded-lg overflow-hidden' : ''}`}>
|
||||
<div className="embla__viewport" ref={emblaRef}>
|
||||
<div className="embla__container">
|
||||
{slides.map((slide, index) => (
|
||||
<a href={slide.href} target='_blank' key={`slide-${index}`} className={`embla__slide ${slideClass(slides_per_view)}`}>
|
||||
<img className={`h-full min-h-48 object-cover ${shape === 'fit' ? '' : 'aspect-square'}`} src={slide.src} />
|
||||
{show_title && slide.title &&
|
||||
<div className={`${title_position === 'top' ? 'bg-linear-to-b' : 'bg-linear-to-t'} from-black/75 via-40% via-black/50 to-transparent font-semibold text-white text-lg sm:text-3xl absolute top-0 w-full h-full flex justify-center pointer-events-none z-10`}>
|
||||
<span className={`flex px-8 py-16 max-w-5xl! w-full h-full justify-self-center ${slideTitleAlign(title_align)} ${slideTitlePosition(title_position)}`}>
|
||||
<span>{slide.title}</span>
|
||||
</span>
|
||||
</div>}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`embla__buttons ${buttons_position === 'middle_inside' ? 'absolute z-1 inset-0 flex justify-between items-center p-4 pointer-events-none' : ''}`}>
|
||||
<button
|
||||
className="embla__button h-full text-gray-300/75 hover:text-gray-100 embla__button--prev pointer-events-auto"
|
||||
onClick={scrollPrev}
|
||||
aria-label="Previous slide"
|
||||
>
|
||||
<IconChevronLeft size={32} />
|
||||
</button>
|
||||
<button
|
||||
className="embla__button h-full text-gray-300/75 hover:text-gray-100 embla__button--next pointer-events-auto"
|
||||
onClick={scrollNext}
|
||||
aria-label="Next slide"
|
||||
>
|
||||
<IconChevronRight size={32} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{show_dots && <div className={`embla__dots ${dots_position === 'bottom_inside' ? 'absolute bottom-4 w-full justify-center z-1' : dots_position === 'bottom' ? 'relative my-4 justify-center' : ''}`}>
|
||||
<div className='flex gap-2 bg-gray-300/75 bg-blend-difference p-1 rounded-2xl'>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`embla__dot ${index === selectedIndex ? 'embla__dot--selected' : ''}`}
|
||||
onClick={() => scrollTo(index)}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EmblaCarousel
|
||||
214
src/components/WP/Areas/Areas.tsx
Normal file
214
src/components/WP/Areas/Areas.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
export const revalidate = 1000
|
||||
|
||||
interface Area {
|
||||
name: string
|
||||
svgPath: string
|
||||
href: string
|
||||
}
|
||||
|
||||
const Areas = () => {
|
||||
const areas: Area[] = [
|
||||
{
|
||||
name: 'Аллаиховский ГУП "ЖКХ РС (Я)"',
|
||||
svgPath: "M336 115L335 110L342 100L341 96L377 93L375 99L389 99L405 109L406 115L411 125L410 130L407 134L397 143L392 143L391 147L388 151L384 152L382 157L376 155L370 155L362 159L354 157L348 159L345 162L341 158L333 155L333 137L336 133L336 129L332 122L335 119z",
|
||||
href: '/аллаиховский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Нижнеколымский филиал ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M404 108L412 125L411 129L414 132L421 134L427 131L435 133L445 130L453 133L464 128L467 128L474 133L485 123L488 119L488 116L493 115L495 113L494 106L491 105L492 102L488 100L487 96L484 95L479 90L469 98L459 98L456 89L453 85L439 83L427 89L410 104z",
|
||||
href: '/нижнеколымский-филиал-гуп-жкх-рся'
|
||||
},
|
||||
{
|
||||
name: 'Среднеколымский ГУП "ЖКХ РС (Я)"',
|
||||
svgPath: "M410 129L406 135L395 143L397 155L401 158L399 162L394 165L394 168L396 172L400 173L406 179L428 181L436 186L444 188L446 192L468 196L466 186L481 182L483 179L486 178L491 178L492 174L496 170L491 157L488 153L479 152L474 148L474 144L476 141L473 139L473 132L469 129L464 127L456 130L453 133L444 131L434 134L425 131L421 134L415 133z",
|
||||
href: '/среднеколымский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Верхнеколымский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M406 179L405 187L410 192L413 198L409 209L412 216L424 221L429 225L431 237L433 241L436 243L444 241L444 237L447 237L454 243L458 235L462 233L464 229L468 231L468 222L465 219L464 214L467 205L464 202L465 196L454 192L450 192L445 190L443 187L435 186L431 182L425 180L413 180z",
|
||||
href: '/верхнеколымский-филиал-гуп-жкх-рся'
|
||||
},
|
||||
{
|
||||
name: 'Момский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M405 187L414 200L410 206L412 217L425 222L429 228L436 256L431 253L429 257L417 257L411 255L405 256L397 249L389 251L383 245L379 247L372 245L369 247L359 247L355 244L354 240L349 233L351 229L350 226L337 219L333 216L331 212L331 209L340 210L344 208L347 206L352 198L357 198L362 203L384 210L390 198L397 196L398 190L401 189z",
|
||||
href: '/момский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Верхоянский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M331 203L332 215L336 219L352 227L350 236L353 238L354 243L349 241L334 246L324 243L317 248L314 254L310 257L306 267L300 267L296 269L295 273L299 280L298 284L293 284L289 281L281 264L265 251L264 247L263 241L264 237L267 235L267 228L275 223L277 215L284 208L284 200L277 194L274 195L274 182L276 178L288 168L292 172L297 168L302 168L307 183L317 183L319 187L325 192L329 200z",
|
||||
href: '/верхоянский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Томпонский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M357 248L353 256L354 262L358 267L365 269L365 274L370 274L372 286L368 296L370 307L373 310L373 314L364 316L361 319L358 324L357 334L353 337L338 338L337 342L334 339L334 334L331 329L325 325L319 324L316 320L314 311L301 311L300 308L296 306L290 306L300 299L300 287L298 284L298 279L295 274L296 270L299 268L304 268L307 265L309 258L314 254L320 245L326 243L330 246L333 246A344.01,344.01,0,0,0,344,242C349,242,357,245,357,248Z",
|
||||
href: '/томпонский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Таттинский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M301 311L313 310L318 324L328 325L329 328L333 330L334 339L337 342L332 343L329 346L329 350L327 344L324 342L311 341L301 330L302 315z",
|
||||
href: '/таттинский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Чурапчинский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M301 330L311 341L323 341L326 343L329 347L328 351L325 354L316 356L311 351L303 350L301 352L297 350L292 337L298 333z",
|
||||
href: '/чурапчинский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Амгинский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M304 350L309 350L313 352L317 369L311 374L310 378L307 381L307 384L304 386L300 386L293 393L280 393L276 389L274 384L274 370L277 370L281 373L285 373L288 371L290 369L289 360L291 356L295 353L299 353z",
|
||||
href: '/амгинский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Хангаласский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M286 363L288 372L281 373L279 370L261 369L241 376L239 379L240 382L237 382L236 379L231 378L227 374L225 367L229 369L233 367L234 362L243 360L250 356L254 356L256 354L256 351L259 349L276 351L278 358L282 359z",
|
||||
href: '/хангаласский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Олекминский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M264 368L263 375L258 378L253 385L247 387L243 385L243 389L238 389L233 396L227 397L223 404L217 406L210 413L209 418L211 423L211 435L217 435L214 441L211 443L212 450L209 455L207 463L203 463L194 456L182 454L173 459L173 449L169 450L168 432L166 429L164 419L165 410L161 406L158 406L156 410L148 406L149 402L144 390L151 378L160 373L161 368L167 366L168 363A176.01,176.01,0,0,0,176,356C193,360,216,357,217,362C224,365,230,378,240,383C238,379,256,370,261,369Z",
|
||||
href: '/олекминский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Сунтарский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M151 378L158 376L161 372L161 369L167 366L171 360L183 351L181 346L168 336L159 334L155 329L150 329L144 326L134 315L128 305L119 306L119 319L123 319L123 325L116 324L116 328L119 330L118 335L124 342L124 355L127 358L136 357L137 360L142 361L150 373z",
|
||||
href: '/сунтарский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Нюрбинский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M175 316L171 326L172 337L160 333L157 334L156 330L141 325L128 305L124 304L123 293L128 291L130 287L148 287L149 282L156 276L160 270L164 270L164 275L171 292L170 297L172 311L175 312z",
|
||||
href: '/нюрбинский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Оленекский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M123 293L126 290L129 290L131 287L137 285L143 285L148 287L149 281L153 279L161 270L161 263L163 259L166 257L170 257L171 252L174 248L168 243L158 243L158 240L162 236L159 230L164 229L169 220L175 219L177 215L181 213L175 199L181 183L181 171L188 171L191 155L191 150L183 131L157 133L151 139L151 143L147 142L140 145L140 149L145 154L136 160L131 159L130 156L133 150L130 138L131 134L127 130L124 131L124 152L121 155L110 159L107 165L102 165L99 167L99 177L96 179L93 177L72 179L74 195L73 216L69 219L67 228L63 229L58 233L59 246L65 243L66 247L73 247L75 243L78 241L83 245L83 259L87 257L95 249L103 249L105 247L114 250L120 257L119 267L114 268L112 273L109 274L111 278L110 281L119 291z",
|
||||
href: '/оленекский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Анабарский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M129 159L136 160L140 158L144 154L141 151L140 147L142 144L146 142L151 142L150 139L158 133L182 132L179 123L168 120L166 117L161 116L161 112L172 108L171 104L164 105L155 100L151 100L139 103L137 108L135 104L137 100L136 96L129 97L123 94L121 98L116 102L120 127L131 135L131 144L133 149L132 153z",
|
||||
href: '/анабарский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Булунский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M169 108L169 112L164 110L161 112L163 115L160 115L165 116L170 122L179 123L189 143L191 152L189 170L181 173L179 188L175 198L187 193L195 199L203 199L209 204L213 204L216 201L220 201L222 204L232 206L235 203L246 202L254 198L257 193L267 193L270 198L274 195L273 183L280 174L277 171L275 165L268 162L265 152L262 152L259 157L258 154L251 154L247 151L242 141L238 137L242 133L244 127L236 123L240 113L235 106L228 102L222 103L220 106L207 98L198 106L202 112L201 119L198 115L193 113L193 117L190 118L179 111L176 112L174 115L171 114z",
|
||||
href: '/булунский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Эвено-Бытантайский ГУП "ЖКХ РС (Я)"',
|
||||
svgPath: "M269 196L271 198L275 195L279 195L283 198L285 202L282 212L279 213L276 217L276 221L273 226L269 228L267 235L263 239L266 252L262 250L249 248L243 249L241 244L241 234L234 227L232 219L235 210L231 206L235 203L245 202L248 199L252 199L258 193L267 193z",
|
||||
href: '/эвено-бытантайский-филиал-гуп-жкх-рс'
|
||||
},
|
||||
{
|
||||
name: 'Жиганский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M232 206L235 211L233 221L234 226L241 235L242 249L239 251L244 267L241 274L225 281L222 288L210 293L202 291L202 287L193 285L187 275L182 272L182 258L178 254L178 250L170 246L168 243L158 242L161 234L157 230L165 229L168 220L172 221L176 214L181 213L181 209L176 202L176 199L187 193L190 194L196 200L199 198L205 199L206 203L210 204L218 201L221 204L227 206z",
|
||||
href: '/жиганский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Вилюйский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M174 248L178 251L182 260L182 273L185 275L191 284L202 288L203 291L212 293L222 288L223 292L227 297L224 298L213 317L218 323L227 325L232 329L234 333L226 335L224 338L211 338L206 341L202 341L202 346L199 344L197 325L191 317L194 311L192 304L189 306L184 306L184 302L178 295L177 286L174 284L173 277L176 264L173 263L170 256L172 251z",
|
||||
href: '/вилюйский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Верхневилюйский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M169 257L176 263L172 279L178 288L179 297L184 305L193 305L192 316L193 320L197 323L198 344L202 345L207 351L206 359L194 360L176 356L184 350L178 341L172 337L172 326L176 315L172 309L169 286L166 282L162 265L164 257z",
|
||||
href: '/верхневилюйский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Горный ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M202 340L202 345L208 353L206 359L215 357L219 363L229 369L233 368L234 361L241 361L249 357L254 357L258 350L266 347L265 344L260 342L259 336L262 333L261 330L259 329L255 320L252 319L249 321L248 325L241 327L236 326L234 329L232 329L234 333L227 334L224 338L215 336L207 339z",
|
||||
href: '/горный-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Кобяйский ГУП "ЖКХ РС (Я)"',
|
||||
svgPath: "M217 323L214 313L224 299L227 298L222 288L223 284L232 277L240 274L244 268L244 263L239 256L239 251L243 249L260 250L273 257L281 263L283 270L291 283L295 285L298 283L300 291L299 300L288 308L278 309L275 315L271 315L269 312L266 314L261 324L258 324L255 319L248 321L247 326L238 326L237 329L232 329L230 326L222 324z",
|
||||
href: '/кобяйский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Мегино-кангаласский ГУП "ЖКХ РС(Я)"',
|
||||
svgPath: "M287 371L289 369L291 356L300 351L296 348L294 340L285 334L280 339L282 342L280 349L277 353L278 358L287 362L287 367z",
|
||||
href: '/мегино-кангаласский-филиал-гуп-жкх-рс'
|
||||
},
|
||||
{
|
||||
name: 'г. Якутск',
|
||||
svgPath: "M259 341L261 339L275 337L281 340L277 352L268 350L268 347L264 343z",
|
||||
href: '/rukovodstvo'
|
||||
},
|
||||
{
|
||||
name: 'Абыйский ГУП "ЖКХ РС (Я)"',
|
||||
svgPath: "M396 144L396 151L398 156L401 157L400 161L393 166L396 171L406 180L407 187L404 190L398 189L397 198L392 198L389 201L386 212L372 208L365 204L361 204L353 198L351 200L344 189L346 184L344 168L347 160L351 157L356 156L363 160L367 158L369 154L374 154L382 158L385 150L389 149L392 145z",
|
||||
href: '/абыйский-филиал-гуп-жкх-рс-я'
|
||||
},
|
||||
{
|
||||
name: 'Усть-Алданский ГУП "ЖКХ РС (Я)"',
|
||||
svgPath: "M284 335L294 335L301 331L302 310L291 305L288 308L277 309L275 314L277 319L276 325L279 329L280 334L278 336L280 339L283 338z",
|
||||
href: '/усть-алданский-филиал-гуп-жкх-рс-я'
|
||||
}
|
||||
]
|
||||
|
||||
const [tooltip, setTooltip] = useState<{ name: string, x: number, y: number } | null>(null)
|
||||
|
||||
return (
|
||||
<div className='relative w-full h-96 flex flex-col justify-center items-center'>
|
||||
<img className='absolute w-full h-auto' src={'/karta-s-logo2.png'} />
|
||||
|
||||
{tooltip && <span className="rounded-sm text-nowrap absolute p-2 z-20 pointer-events-none bg-black/90 text-white text-sm text-center" style={{
|
||||
left: tooltip?.x,
|
||||
top: tooltip?.y
|
||||
}}>
|
||||
{tooltip?.name}
|
||||
</span>}
|
||||
<svg
|
||||
className='absolute w-full h-auto'
|
||||
viewBox="15 0 520 520"
|
||||
aria-labelledby="img-title-1161"
|
||||
aria-describedby="img-desc-1161"
|
||||
role="group"
|
||||
>
|
||||
<title id="img-title-1161" />
|
||||
<g role="list" className="relative">
|
||||
{
|
||||
areas.map((area, index) => (
|
||||
<a key={index} target="_blank" href={area.href} className="relative transition-all duration-300 opacity-0 hover:opacity-100 outline-none" tabIndex={0} aria-label={area.name} role="listitem">
|
||||
<title>{area.name}</title>
|
||||
<path
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
onMouseOver={(e) => {
|
||||
const bbox = e.currentTarget.getBBox();
|
||||
const svg = e.currentTarget.ownerSVGElement;
|
||||
const point = svg?.createSVGPoint();
|
||||
if (point) {
|
||||
point.x = bbox.x + bbox.width / 2;
|
||||
point.y = bbox.y;
|
||||
|
||||
setTooltip({
|
||||
name: area.name,
|
||||
x: point.x,// - (e.currentTarget.getBoundingClientRect().width / 2),
|
||||
y: point.y// - (e.currentTarget.getBoundingClientRect().height / 2)
|
||||
})
|
||||
}
|
||||
}}
|
||||
role="listitem"
|
||||
stroke="#3388ff"
|
||||
strokeOpacity={1}
|
||||
strokeWidth={3}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="#3388ff"
|
||||
fillOpacity={0.2}
|
||||
fillRule="evenodd"
|
||||
d={area.svgPath}
|
||||
/>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Areas
|
||||
@@ -1,34 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { montserratFont } from "@/app/layout";
|
||||
import { CarouselSlide } from "@/types/entities";
|
||||
|
||||
interface SmartSliderProps {
|
||||
sliderId: string
|
||||
slidesPerView?: number
|
||||
slides: CarouselSlide[]
|
||||
showIndicator?: boolean
|
||||
showTitle?: boolean
|
||||
padding?: boolean
|
||||
}
|
||||
|
||||
export const SmartSlider = ({ sliderId, padding, showTitle, slidesPerView, slides, showIndicator }: SmartSliderProps) => {
|
||||
// return (
|
||||
// <>
|
||||
// <style>
|
||||
// {`
|
||||
// .smart-slider {
|
||||
// ul::-webkit-scrollbar {
|
||||
// display: none;
|
||||
// }
|
||||
// ul {
|
||||
// scroll-behavior: smooth;
|
||||
// padding: 20px;
|
||||
// display: flex;
|
||||
|
||||
// overflow-x: scroll;
|
||||
// scroll-snap-type: x mandatory;
|
||||
// scroll-marker-group: after;
|
||||
// /* Hide scrollbar for IE, Edge, and Firefox */
|
||||
// -ms-overflow-style: none; /* IE and Edge */
|
||||
// scrollbar-width: none; /* Firefox */
|
||||
// }
|
||||
|
||||
// li {
|
||||
// list-style-type: none;
|
||||
// background-color: #eeeeee;
|
||||
// border: 1px solid #dddddd;
|
||||
// padding: 20px;
|
||||
|
||||
// flex: 0 0 100%;
|
||||
|
||||
// scroll-snap-align: center;
|
||||
// }
|
||||
|
||||
// ul::scroll-button(*) {
|
||||
// border: 0;
|
||||
// font-size: 2rem;
|
||||
// background: none;
|
||||
// color: black;
|
||||
// opacity: 0.7;
|
||||
// cursor: pointer;
|
||||
// }
|
||||
|
||||
// ul::scroll-button(*):hover,
|
||||
// ul::scroll-button(*):focus {
|
||||
// opacity: 1;
|
||||
// }
|
||||
|
||||
// ul::scroll-button(*):active {
|
||||
// translate: 1px 1px;
|
||||
// }
|
||||
|
||||
// ul::scroll-button(*):disabled {
|
||||
// opacity: 0.2;
|
||||
// cursor: unset;
|
||||
// }
|
||||
|
||||
// ul::scroll-button(left) {
|
||||
// content: "◄" / "Previous";
|
||||
// }
|
||||
|
||||
// ul::scroll-button(right) {
|
||||
// content: "►" / "Next";
|
||||
// }
|
||||
|
||||
// ul {
|
||||
// anchor-name: --my-carousel;
|
||||
// }
|
||||
|
||||
// ul::scroll-button(*) {
|
||||
// position: absolute;
|
||||
// position-anchor: --my-carousel;
|
||||
// }
|
||||
|
||||
|
||||
// ul::scroll-button(left) {
|
||||
// right: calc(anchor(left) - 70px);
|
||||
// bottom: calc(anchor(top) + 13px);
|
||||
// }
|
||||
|
||||
// ul::scroll-button(right) {
|
||||
// left: calc(anchor(right) - 70px);
|
||||
// bottom: calc(anchor(top) + 13px);
|
||||
// }
|
||||
|
||||
// ul::scroll-marker-group {
|
||||
// position: absolute;
|
||||
// position-anchor: --my-carousel;
|
||||
// top: calc(anchor(bottom) - 70px);
|
||||
// justify-self: anchor-center;
|
||||
|
||||
// display: flex;
|
||||
// justify-content: center;
|
||||
// gap: 20px;
|
||||
// }
|
||||
|
||||
// li::scroll-marker {
|
||||
// content: attr(data-accname);
|
||||
// width: 16px;
|
||||
// height: 16px;
|
||||
// background-color: transparent;
|
||||
// border: 2px solid black;
|
||||
// border-radius: 50%;
|
||||
// overflow: hidden;
|
||||
// text-indent: 16px;
|
||||
// }
|
||||
|
||||
// li::scroll-marker:target-current {
|
||||
// background-color: black;
|
||||
// }
|
||||
// }
|
||||
// `}
|
||||
// </style>
|
||||
// <div className="w-full">
|
||||
// <div className="smart-slider">
|
||||
// <ul className="w-full">
|
||||
// <li data-accname="Item 1">
|
||||
// <h2>Page 1</h2>
|
||||
// </li>
|
||||
// <li data-accname="Item 2">
|
||||
// <h2>Page 2</h2>
|
||||
// </li>
|
||||
// <li data-accname="Item 3">
|
||||
// <h2>Page 3</h2>
|
||||
// </li>
|
||||
// <li data-accname="Item 4">
|
||||
// <h2>Page 4</h2>
|
||||
// </li>
|
||||
// </ul>
|
||||
// </div>
|
||||
// </div>
|
||||
// </>
|
||||
// )
|
||||
|
||||
export const SmartSlider = ({ sliderId }: { sliderId: string }) => {
|
||||
return (
|
||||
<div className="smart-slider">
|
||||
<div className="smart-slider relative">
|
||||
<div className="carousel w-full">
|
||||
<a role="button" href="https://lk.jkhsakha.ru" id="item1" className="carousel-item w-full relative overflow-hidden">
|
||||
<div className="flex justify-center items-center w-full h-full absolute bg-black/50">
|
||||
<span className={`text-xl ${montserratFont.className} text-white`} >Личный кабинет</span>
|
||||
</div>
|
||||
<img
|
||||
src="/Личный-кабинет.jpg"
|
||||
className="w-full" />
|
||||
|
||||
</a>
|
||||
<div id="item2" className="carousel-item w-full">
|
||||
<img
|
||||
src="/Способ-оплаты.jpg"
|
||||
className="w-full" />
|
||||
</div>
|
||||
<div id="item3" className="carousel-item w-full">
|
||||
<img
|
||||
src="/first_slide2.jpg"
|
||||
className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-center gap-2 py-2">
|
||||
<a href="#item1" className="btn btn-xs">1</a>
|
||||
<a href="#item2" className="btn btn-xs">2</a>
|
||||
<a href="#item3" className="btn btn-xs">3</a>
|
||||
{slides.map((slide) => (
|
||||
<a
|
||||
id={`item${slide.id}`}
|
||||
key={slide.id}
|
||||
href={slide.href || '#'}
|
||||
className={`carousel-item ${slidesPerView === 2 ? 'w-1/2' : slidesPerView === 3 ? 'w-1/3' : slidesPerView === 4 ? 'w-1/4' : slidesPerView === 5 ? 'w-1/5' : slidesPerView === 6 ? 'w-1/6' : 'w-full'} relative overflow-hidden`}
|
||||
>
|
||||
{showTitle &&
|
||||
<div className="flex justify-center items-center w-full h-full absolute bg-black/50">
|
||||
<span className={`text-xl ${montserratFont.className} text-white`} >{slide.title}</span>
|
||||
</div>
|
||||
}
|
||||
<img
|
||||
src={slide.src}
|
||||
className={`w-full ${padding ? 'p-4' : ''}`}
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{showIndicator &&
|
||||
<div className="absolute bottom-4 flex w-full justify-center gap-2 py-2">
|
||||
{slides.map((slide) => (
|
||||
<a key={slide.id} href={`#item${slide.id}`} className="focus:bg-[#0063A7] btn btn-xs w-4 h-4 bg-gray-800 btn-circle border-none">
|
||||
</a>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import parse, { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { SmartSlider } from './SmartSlider';
|
||||
import PostGrid from '../WP/PostGrid/PostGrid.server';
|
||||
import { PagePayload } from '@/types/entities';
|
||||
import { CarouselSlide, PagePayload } from '@/types/entities';
|
||||
import { transformLinks } from '@/lib/utils';
|
||||
import EmblaCarousel from '../UI/EmblaCarousel/EmblaCarousel';
|
||||
|
||||
const options: HTMLReactParserOptions = {
|
||||
replace: (domNode) => {
|
||||
@@ -36,14 +38,14 @@ const replaceShortcodes = (html: string) => {
|
||||
);
|
||||
};
|
||||
|
||||
const transformLinks = (html: string) => {
|
||||
// Regex to match https://specificdomain.com/?page_id= followed by numbers
|
||||
// Using a more specific pattern to avoid matching other domains
|
||||
const regex = /(https?:\/\/)?(www\.)?new.jkhsakha\.ru\/(?!wp-content)(\/[^\s"']*)/g
|
||||
// const transformLinks = (html: string) => {
|
||||
// // Regex to match https://specificdomain.com/?page_id= followed by numbers
|
||||
// // Using a more specific pattern to avoid matching other domains
|
||||
// const regex = /(https?:\/\/)?(www\.)?new.jkhsakha\.ru\/(?!wp-content)(\/[^\s"']*)/g
|
||||
|
||||
// Replace only the specific domain links with relative paths
|
||||
return html.replace(regex, '$3');
|
||||
};
|
||||
// // Replace only the specific domain links with relative paths
|
||||
// return html.replace(regex, '$3');
|
||||
// };
|
||||
|
||||
|
||||
export const renderPostContent = (content: string, payload?: PagePayload) => {
|
||||
@@ -52,6 +54,26 @@ export const renderPostContent = (content: string, payload?: PagePayload) => {
|
||||
transformLinks(content)
|
||||
)
|
||||
|
||||
const slides: CarouselSlide[] = [
|
||||
{
|
||||
id: 0,
|
||||
title: "Тепло сердец - людям!",
|
||||
src: "/first_slide2.jpg",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: "Личный кабинет",
|
||||
src: "/Личный-кабинет.jpg",
|
||||
href: "https://lk.jkhsakha.ru",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Способы оплаты",
|
||||
src: "/Способ-оплаты.jpg",
|
||||
href: "/sposoby-oplaty/",
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
{parse(transformedContent, {
|
||||
@@ -60,8 +82,12 @@ export const renderPostContent = (content: string, payload?: PagePayload) => {
|
||||
domNode &&
|
||||
domNode.type === 'tag' && domNode.attribs?.['data-shortcode'] === 'smartslider3'
|
||||
) {
|
||||
return (
|
||||
<EmblaCarousel slides={slides} />
|
||||
)
|
||||
return (
|
||||
<SmartSlider
|
||||
slides={slides}
|
||||
sliderId={(domNode as any).attribs['data-slider-id']}
|
||||
/>
|
||||
);
|
||||
@@ -72,7 +98,7 @@ export const renderPostContent = (content: string, payload?: PagePayload) => {
|
||||
const gridId = Number(domNode.attribs["data-grid-id"]);
|
||||
if (!gridId) return null;
|
||||
return (
|
||||
<PostGrid id={gridId} page={payload?.page}/>
|
||||
<PostGrid id={gridId} page={payload?.page} />
|
||||
);
|
||||
} else if (
|
||||
domNode &&
|
||||
|
||||
75
src/components/WebVitalsCard.tsx
Normal file
75
src/components/WebVitalsCard.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// components/WebVitalsCard.tsx
|
||||
'use client'
|
||||
|
||||
interface WebVitalsCardProps {
|
||||
title: string
|
||||
value: number | null
|
||||
unit: string
|
||||
description: string
|
||||
thresholds: {
|
||||
good: number
|
||||
needsImprovement: number
|
||||
}
|
||||
}
|
||||
|
||||
export function WebVitalsCard({ title, value, unit, description, thresholds }: WebVitalsCardProps) {
|
||||
const getStatusColor = () => {
|
||||
if (value === null) return 'bg-gray-100 border-gray-200'
|
||||
if (value <= thresholds.good) return 'bg-green-50 border-green-200'
|
||||
if (value <= thresholds.needsImprovement) return 'bg-yellow-50 border-yellow-200'
|
||||
return 'bg-red-50 border-red-200'
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
if (value === null) return 'Not measured'
|
||||
if (value <= thresholds.good) return 'Good'
|
||||
if (value <= thresholds.needsImprovement) return 'Needs Improvement'
|
||||
return 'Poor'
|
||||
}
|
||||
|
||||
const getStatusColorBadge = () => {
|
||||
if (value === null) return 'bg-gray-200 text-gray-700'
|
||||
if (value <= thresholds.good) return 'bg-green-200 text-green-800'
|
||||
if (value <= thresholds.needsImprovement) return 'bg-yellow-200 text-yellow-800'
|
||||
return 'bg-red-200 text-red-800'
|
||||
}
|
||||
|
||||
const formattedValue = value !== null ? value.toFixed(2) : '—'
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-6 shadow-sm ${getStatusColor()}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColorBadge()}`}>
|
||||
{getStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<span className="text-4xl font-bold text-gray-900">{formattedValue}</span>
|
||||
<span className="ml-2 text-gray-600">{unit}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">{description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-green-600">Good</span>
|
||||
<span className="text-yellow-600">Needs Improvement</span>
|
||||
<span className="text-red-600">Poor</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-gray-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 via-yellow-500 to-red-500"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>≤ {thresholds.good}{unit}</span>
|
||||
<span>≤ {thresholds.needsImprovement}{unit}</span>
|
||||
<span>> {thresholds.needsImprovement}{unit}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
src/hooks/useWebVitals.ts
Normal file
89
src/hooks/useWebVitals.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// hooks/useWebVitals.ts
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface WebVitalsMetrics {
|
||||
FCP: number | null
|
||||
LCP: number | null
|
||||
FID: number | null
|
||||
CLS: number | null
|
||||
TTFB: number | null
|
||||
INP: number | null
|
||||
}
|
||||
|
||||
export function useWebVitals() {
|
||||
const [metrics, setMetrics] = useState<WebVitalsMetrics>({
|
||||
FCP: null,
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: null,
|
||||
TTFB: null,
|
||||
INP: null,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !('performance' in window)) return
|
||||
|
||||
// Create a PerformanceObserver to capture metrics
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
// First Contentful Paint
|
||||
if (entry.entryType === 'paint' && entry.name === 'first-contentful-paint') {
|
||||
setMetrics(prev => ({ ...prev, FCP: entry.startTime }))
|
||||
}
|
||||
|
||||
// Largest Contentful Paint
|
||||
if (entry.entryType === 'largest-contentful-paint') {
|
||||
setMetrics(prev => ({ ...prev, LCP: entry.startTime }))
|
||||
}
|
||||
|
||||
// First Input Delay
|
||||
if (entry.entryType === 'first-input') {
|
||||
const fidEntry = entry as PerformanceEventTiming
|
||||
setMetrics(prev => ({ ...prev, FID: fidEntry.processingStart - fidEntry.startTime }))
|
||||
}
|
||||
|
||||
// Cumulative Layout Shift
|
||||
if (entry.entryType === 'layout-shift') {
|
||||
const clsEntry = entry as any // LayoutShift
|
||||
if (!clsEntry.hadRecentInput) {
|
||||
setMetrics(prev => ({
|
||||
...prev,
|
||||
CLS: (prev.CLS || 0) + clsEntry.value
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Interaction to Next Paint
|
||||
if (entry.entryType === 'event' || entry.entryType === 'first-input') {
|
||||
const inpEntry = entry as any // PerformanceEventTiming
|
||||
if (inpEntry.interactionId) {
|
||||
setMetrics(prev => ({
|
||||
...prev,
|
||||
INP: Math.max(prev.INP || 0, inpEntry.duration)
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// TTFB can be obtained from navigation timing
|
||||
const navigationEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
||||
if (navigationEntry) {
|
||||
setMetrics(prev => ({
|
||||
...prev,
|
||||
TTFB: navigationEntry.responseStart - navigationEntry.requestStart
|
||||
}))
|
||||
}
|
||||
|
||||
// Observe different metric types
|
||||
observer.observe({
|
||||
entryTypes: ['paint', 'largest-contentful-paint', 'first-input', 'layout-shift', 'event']
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return metrics
|
||||
}
|
||||
99
src/lib/transliterate.ts
Normal file
99
src/lib/transliterate.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
// utils/transliterate.ts
|
||||
|
||||
// Define the type for the transliteration map
|
||||
type TransliterationMap = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
export const transliterationMap: TransliterationMap = {
|
||||
// Russian/Ukrainian/Bulgarian Cyrillic
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd',
|
||||
'е': 'e', 'ё': 'jo', 'ж': 'zh', 'з': 'z', 'и': 'i',
|
||||
'й': 'j', // 'й' -> 'j'
|
||||
'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n',
|
||||
'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't',
|
||||
'у': 'u', 'ф': 'f', 'х': 'h', // 'х' -> 'h' (not 'kh')
|
||||
'ц': 'c', // 'ц' -> 'c' (not 'ts')
|
||||
'ч': 'ch', 'ш': 'sh', 'щ': 'shch',
|
||||
'ъ': '', 'ы': 'y', 'ь': '', // Soft sign becomes empty
|
||||
'э': 'je', // 'э' -> 'je' (crucial for your example)
|
||||
'ю': 'ju', // 'ю' -> 'ju'
|
||||
'я': 'ja', // 'я' -> 'ja'
|
||||
|
||||
// Capital letters
|
||||
'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D',
|
||||
'Е': 'E', 'Ё': 'Jo', 'Ж': 'Zh', 'З': 'Z', 'И': 'I',
|
||||
'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', 'Н': 'N',
|
||||
'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T',
|
||||
'У': 'U', 'Ф': 'F', 'Х': 'H', 'Ц': 'C',
|
||||
'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Shch',
|
||||
'Ъ': '', 'Ы': 'Y', 'Ь': '',
|
||||
'Э': 'Je', // 'Э' -> 'Je'
|
||||
'Ю': 'Ju', 'Я': 'Ja'
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Transliterates Cyrillic text to Latin characters
|
||||
* @param text - The input text to transliterate
|
||||
* @returns Transliterated text with Latin characters
|
||||
*/
|
||||
export function transliterate(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
return text.split('').map((char: string): string => {
|
||||
return transliterationMap[char] || char;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Optional: Add a more comprehensive version with options
|
||||
interface TransliterateOptions {
|
||||
preserveCase?: boolean;
|
||||
ignoreUnknown?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advanced transliteration with options
|
||||
* @param text - The input text to transliterate
|
||||
* @param options - Configuration options
|
||||
* @returns Transliterated text
|
||||
*/
|
||||
export function transliterateAdvanced(
|
||||
text: string,
|
||||
options: TransliterateOptions = {}
|
||||
): string {
|
||||
if (!text) return '';
|
||||
|
||||
const { preserveCase = false, ignoreUnknown = true } = options;
|
||||
|
||||
return text.split('').map((char: string): string => {
|
||||
const transliterated = transliterationMap[char];
|
||||
|
||||
if (transliterated !== undefined) {
|
||||
return transliterated;
|
||||
}
|
||||
|
||||
// Handle unknown characters
|
||||
if (!ignoreUnknown) {
|
||||
// You might want to log or handle unknown characters differently
|
||||
console.warn(`Unknown character found: ${char}`);
|
||||
}
|
||||
|
||||
return char;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
export const containsCyrillic = (text: string): boolean => {
|
||||
// First, decode the URL-encoded string
|
||||
try {
|
||||
const decodedText = decodeURIComponent(text);
|
||||
|
||||
const cyrillicPattern = /[а-яА-ЯёЁіІїЇєЄґҐ]/;
|
||||
return cyrillicPattern.test(decodedText);
|
||||
} catch (e) {
|
||||
// If decoding fails, test the original text
|
||||
console.error('Error decoding URL:', e);
|
||||
const cyrillicPattern = /[а-яА-ЯёЁіІїЇєЄґҐ]/;
|
||||
return cyrillicPattern.test(text);
|
||||
}
|
||||
};
|
||||
@@ -1,12 +1,51 @@
|
||||
export const transformLinks = (html: string) => {
|
||||
// Regex to match https://specificdomain.com/?page_id= followed by numbers
|
||||
// Using a more specific pattern to avoid matching other domains
|
||||
const regex = /(https?:\/\/)?(www\.)?new.jkhsakha\.ru\/(?!wp-content)(\/[^\s"']*)/g
|
||||
import { containsCyrillic, transliterate } from "./transliterate";
|
||||
|
||||
// Replace only the specific domain links with relative paths
|
||||
return html.replace(regex, '$3');
|
||||
export const transformLinks = (html: string): string => {
|
||||
if (!html) return html;
|
||||
|
||||
// Regex to match specific domain links, excluding wp-content
|
||||
// Now also captures the full URL to handle different parts
|
||||
const regex = /(https?:\/\/)?(www\.)?new\.jkhsakha\.ru\/(?!wp-content)([^\s"']*)/g;
|
||||
|
||||
return html.replace(regex, (match, protocol, www, path) => {
|
||||
// Extract the full path including leading slash
|
||||
let fullPath = path;
|
||||
|
||||
// Make sure path starts with /
|
||||
if (!fullPath.startsWith('/')) {
|
||||
fullPath = '/' + fullPath;
|
||||
}
|
||||
|
||||
fullPath = decodeURIComponent(fullPath)
|
||||
|
||||
// Check if the path contains Cyrillic characters
|
||||
if (containsCyrillic(fullPath)) {
|
||||
console.log("contains Cyrillic, transliterating:", fullPath);
|
||||
// Split the path into segments
|
||||
const pathSegments = fullPath.split('/').filter((segment: any) => segment.length > 0);
|
||||
|
||||
// Transliterate each segment that contains Cyrillic
|
||||
const transliteratedSegments = pathSegments.map((segment: any) => {
|
||||
return containsCyrillic(segment) ? transliterate(segment) : segment;
|
||||
});
|
||||
|
||||
// Reconstruct the path
|
||||
const transliteratedPath = '/' + transliteratedSegments.join('/');
|
||||
|
||||
// Ensure the path ends with trailing slash if original had it
|
||||
if (fullPath.endsWith('/') && !transliteratedPath.endsWith('/')) {
|
||||
return transliteratedPath + '/';
|
||||
}
|
||||
|
||||
return transliteratedPath;
|
||||
}
|
||||
|
||||
// If no Cyrillic, return the original path
|
||||
return fullPath;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export function toRelativePath(input: string): string {
|
||||
try {
|
||||
// Handles absolute URLs
|
||||
@@ -18,3 +57,13 @@ export function toRelativePath(input: string): string {
|
||||
return '/' + input;
|
||||
}
|
||||
}
|
||||
|
||||
const extractPathFromUrl = (url: string): string => {
|
||||
try {
|
||||
const urlObject = new URL(url);
|
||||
return urlObject.pathname;
|
||||
} catch {
|
||||
// If URL parsing fails, return the original string
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,3 +11,97 @@ h1 {
|
||||
@apply text-3xl;
|
||||
/* Or any size you prefer */
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
img {
|
||||
margin: 0;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
[data-resize-handle] {
|
||||
position: absolute;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||
border-radius: 2px;
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Corner handles */
|
||||
&[data-resize-handle='top-left'],
|
||||
&[data-resize-handle='top-right'],
|
||||
&[data-resize-handle='bottom-left'],
|
||||
&[data-resize-handle='bottom-right'] {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&[data-resize-handle='top-left'] {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
&[data-resize-handle='top-right'] {
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
&[data-resize-handle='bottom-left'] {
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
&[data-resize-handle='bottom-right'] {
|
||||
bottom: -4px;
|
||||
right: -4px;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
/* Edge handles */
|
||||
&[data-resize-handle='top'],
|
||||
&[data-resize-handle='bottom'] {
|
||||
height: 6px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
&[data-resize-handle='top'] {
|
||||
top: -3px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&[data-resize-handle='bottom'] {
|
||||
bottom: -3px;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
&[data-resize-handle='left'],
|
||||
&[data-resize-handle='right'] {
|
||||
width: 6px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
&[data-resize-handle='left'] {
|
||||
left: -3px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
&[data-resize-handle='right'] {
|
||||
right: -3px;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
}
|
||||
|
||||
[data-resize-state='true'] [data-resize-wrapper] {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import CookieNotice from "@/components/CookieNotice/CookieNotice";
|
||||
|
||||
export interface ContentScheme {
|
||||
content: ElementTypes[]
|
||||
}
|
||||
|
||||
export type ElementTypes = Banner | Center | Navbar | BannerCompact
|
||||
export type ElementTypes = Banner | Center | Navbar | BannerCompact | CookieNotice
|
||||
|
||||
export type ElementBase = {
|
||||
order: number
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface PostData {
|
||||
post_author: string;
|
||||
post_name: string;
|
||||
post_title: string;
|
||||
post_type: string;
|
||||
post_excerpt: string;
|
||||
post_date: string;
|
||||
post_date_gmt: string;
|
||||
@@ -13,3 +14,11 @@ export interface PostData {
|
||||
export interface PagePayload {
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface CarouselSlide {
|
||||
id: number
|
||||
title?: string
|
||||
description?: string
|
||||
src: string
|
||||
href?: string
|
||||
}
|
||||
Reference in New Issue
Block a user