init: Vite+React+Tailwind v2 site with HTML content from WP, RSS feed, external feed aggregator, prerender

This commit is contained in:
striker
2026-05-21 01:11:26 +03:00
commit 76cdeb8b48
42 changed files with 6317 additions and 0 deletions

133
src/App.jsx Normal file
View File

@@ -0,0 +1,133 @@
import React, { useEffect, useState } from 'react';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import Footer from './components/Footer';
import Home from './pages/Home';
import Post from './pages/Post';
import Page from './pages/Page';
import Category from './pages/Category';
import News from './pages/News';
import NotFound from './pages/NotFound';
import { pages, posts, oldSlugRedirects } from './content';
export function navigate(path) {
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
function resolveRoute(pathname) {
const path = pathname.endsWith('/') || pathname.includes('.') ? pathname : pathname + '/';
if (oldSlugRedirects[path]) {
if (typeof window !== 'undefined') {
window.history.replaceState({}, '', oldSlugRedirects[path]);
}
return resolveRoute(oldSlugRedirects[path]);
}
if (path === '/') return { type: 'home' };
if (path === '/news/') return { type: 'news' };
const cat = path.match(/^\/cat\/([^/]+)\/$/);
if (cat) return { type: 'category', slug: decodeURIComponent(cat[1]) };
const seg = path.match(/^\/([^/]+)\/$/);
if (seg) {
const slug = decodeURIComponent(seg[1]);
if (pages[slug]) return { type: 'page', slug };
if (posts[slug]) return { type: 'post', slug };
}
return { type: 'notfound' };
}
function setMeta(name, content) {
if (typeof document === 'undefined') return;
let tag = document.querySelector(`meta[name="${name}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute('name', name);
document.head.appendChild(tag);
}
tag.setAttribute('content', content);
}
function setTitle(title) {
if (typeof document !== 'undefined') document.title = title;
}
export default function App() {
const [path, setPath] = useState(
typeof window !== 'undefined' ? window.location.pathname : '/'
);
useEffect(() => {
const onPop = () => setPath(window.location.pathname);
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
useEffect(() => {
const onClick = (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.startsWith('http') || href.startsWith('//') || href.startsWith('#') || href.startsWith('mailto:') || a.target === '_blank') return;
e.preventDefault();
navigate(href);
};
document.addEventListener('click', onClick);
return () => document.removeEventListener('click', onClick);
}, []);
const route = resolveRoute(path);
const SITE_NAME = 'История города Пушкино';
const SITE_DESC = 'Прошлое, настоящее, будущее города Пушкино: история, фото, новости, форум.';
let main = null;
let title = SITE_NAME;
let desc = SITE_DESC;
if (route.type === 'home') {
main = <Home />;
} else if (route.type === 'news') {
main = <News />;
title = `Новости — ${SITE_NAME}`;
} else if (route.type === 'post') {
const p = posts[route.slug];
main = <Post post={p} />;
title = `${p.title}${SITE_NAME}`;
desc = p.excerpt || p.title;
} else if (route.type === 'page') {
const p = pages[route.slug];
main = <Page page={p} />;
title = `${p.title}${SITE_NAME}`;
desc = p.excerpt || p.title;
} else if (route.type === 'category') {
main = <Category slug={route.slug} />;
title = `Категория — ${SITE_NAME}`;
} else {
main = <NotFound />;
title = `Не найдено — ${SITE_NAME}`;
}
useEffect(() => {
setTitle(title);
setMeta('description', desc);
}, [title, desc]);
useEffect(() => {
window.scrollTo(0, 0);
}, [path]);
return (
<div className="min-h-screen flex flex-col">
<Header />
<div className="max-w-6xl mx-auto w-full px-4 sm:px-6 py-6 grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-8 flex-grow">
<main className="min-w-0">{main}</main>
<Sidebar />
</div>
<Footer />
</div>
);
}

17
src/components/Footer.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-rule mt-8 py-6 bg-paper">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-col sm:flex-row sm:justify-between gap-2 text-xs text-muted">
<div>© 2010{year} pushkinohistory.ru</div>
<div className="flex gap-4">
<a href="/feed/">RSS</a>
<a href="/sitemap.xml">Карта сайта</a>
<a href="https://forum.pushkinohistory.ru/" target="_blank" rel="noopener noreferrer">Форум </a>
</div>
</div>
</footer>
);
}

62
src/components/Header.jsx Normal file
View File

@@ -0,0 +1,62 @@
import React from 'react';
const NAV = [
{ href: '/', label: 'Главная' },
{ href: '/history/', label: 'История' },
{ href: '/news/', label: 'Новости' },
{ href: '/foto/', label: 'Фото' },
{ href: 'https://forum.pushkinohistory.ru/', label: 'Форум', external: true },
];
export default function Header() {
return (
<header className="border-b border-rule bg-paper">
<div
className="relative overflow-hidden"
style={{
backgroundImage:
'linear-gradient(rgba(248,244,236,0.85), rgba(248,244,236,0.78)), url(/uploads/IMG_2156.jpg)',
backgroundSize: 'cover',
backgroundPosition: 'center 60%',
backgroundColor: '#f8f4ec',
}}
>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-10">
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3">
<a href="/" className="block no-underline hover:no-underline">
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-ink leading-tight drop-shadow-[0_1px_0_rgba(248,244,236,0.6)]">
История города Пушкино
</h1>
<p className="font-serif italic text-sm sm:text-base text-muted mt-1">
от давних времён до наших дней
</p>
</a>
<a
href="/feed/"
className="text-xs uppercase tracking-wider text-muted hover:text-accent self-start sm:self-end"
title="RSS-фид"
>
RSS
</a>
</div>
</div>
</div>
<nav className="border-t border-rule">
<div className="max-w-6xl mx-auto px-4 sm:px-6 flex flex-wrap gap-x-6 gap-y-2 py-3 text-sm">
{NAV.map((n) => (
<a
key={n.href}
href={n.href}
target={n.external ? '_blank' : undefined}
rel={n.external ? 'noopener noreferrer' : undefined}
className="text-ink hover:text-accent no-underline font-medium"
>
{n.label}
{n.external ? ' ↗' : ''}
</a>
))}
</div>
</nav>
</header>
);
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
function formatDate(s) {
const d = new Date(s.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
function stripHtml(html, max = 280) {
const text = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
return text.length > max ? text.slice(0, max).trimEnd() + '…' : text;
}
export default function PostCard({ post }) {
return (
<article className="border-b border-rule pb-6 mb-6 last:border-0">
<h2 className="font-serif text-2xl font-bold leading-tight">
<a href={`/${post.slug}/`} className="text-ink no-underline hover:text-accent">
{post.title}
</a>
</h2>
<div className="text-xs text-muted mt-1 mb-3">
<time dateTime={post.date}>{formatDate(post.date)}</time>
{post.categories?.length > 0 && (
<>
{' · '}
{post.categories.map((c, i) => (
<span key={c}>
{i > 0 && ', '}
<a href={`/cat/${post.categorySlugs[i]}/`} className="text-muted hover:text-accent">
{c}
</a>
</span>
))}
</>
)}
</div>
<p className="font-serif text-[1.02rem] leading-relaxed text-ink/90">
{post.excerpt || stripHtml(post.html)}
</p>
<div className="mt-3 text-sm">
<a href={`/${post.slug}/`}>Читать далее </a>
</div>
</article>
);
}

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { transport, partners, ads } from '../content';
function Box({ title, children }) {
return (
<section className="border border-rule bg-white/40 p-4">
<h3 className="font-serif text-base font-bold mb-2 pb-2 border-b border-rule">
{title}
</h3>
{children}
</section>
);
}
export default function Sidebar() {
return (
<aside className="space-y-6 text-sm">
<Box title="Транспорт">
<ul className="space-y-2">
{transport.trains.map((t) => (
<li key={t.url}>
<a href={t.url} target="_blank" rel="noopener noreferrer">{t.label}</a>
</li>
))}
{transport.buses.map((b) => (
<li key={b.url}>
<a href={b.url} target="_blank" rel="noopener noreferrer">{b.label}</a>
</li>
))}
</ul>
</Box>
{partners.length > 0 && (
<Box title="Наши партнёры">
<ul className="space-y-2">
{partners.map((p) => (
<li key={p.name}>
{p.url ? (
<a href={p.url} target="_blank" rel="noopener noreferrer">{p.name}</a>
) : (
<span>{p.name}</span>
)}
{p.note && <span className="text-muted"> {p.note}</span>}
</li>
))}
</ul>
</Box>
)}
{ads.length > 0 && (
<Box title="Объявления">
<ul className="space-y-2">
{ads.map((a, i) => (
<li key={i}>
{a.url ? (
<a href={a.url} target="_blank" rel="noopener noreferrer">{a.text}</a>
) : (
<span>{a.text}</span>
)}
</li>
))}
</ul>
</Box>
)}
</aside>
);
}

1
src/content/ads.json Normal file
View File

@@ -0,0 +1 @@
[]

8
src/content/feeds.json Normal file
View File

@@ -0,0 +1,8 @@
[
{
"name": "Подмосковье сегодня — Пушкино",
"url": "https://mosregtoday.ru/tags/пушкино/rss/",
"enabled": false,
"max": 10
}
]

38
src/content/index.js Normal file
View File

@@ -0,0 +1,38 @@
import postsArr from './posts.json';
import pagesArr from './pages.json';
import partnersArr from './partners.json';
import adsArr from './ads.json';
import transportArr from './transport.json';
import feedsArr from './feeds.json';
const byField = (arr, field) => Object.fromEntries(arr.map((x) => [x[field], x]));
export const postsList = postsArr;
export const pagesList = pagesArr;
export const posts = byField(postsArr, 'slug');
export const pages = byField(pagesArr, 'slug');
export const oldSlugRedirects = (() => {
const map = {};
for (const p of postsArr) {
if (p.oldSlug && p.oldSlug !== p.slug) map[`/${p.oldSlug}/`] = `/${p.slug}/`;
}
for (const p of pagesArr) {
if (p.oldSlug && p.oldSlug !== p.slug) map[`/${p.oldSlug}/`] = `/${p.slug}/`;
}
return map;
})();
export const partners = partnersArr;
export const ads = adsArr;
export const transport = transportArr;
export const externalFeeds = feedsArr;
export const categories = (() => {
const set = new Map();
for (const p of postsArr) {
p.categorySlugs?.forEach((s, i) => {
if (!set.has(s)) set.set(s, { slug: s, name: p.categories[i], count: 0 });
set.get(s).count += 1;
});
}
return [...set.values()];
})();

46
src/content/pages.json Normal file

File diff suppressed because one or more lines are too long

12
src/content/partners.json Normal file
View File

@@ -0,0 +1,12 @@
[
{
"name": "Музей города Пушкино",
"url": "https://museum-pushkino.ru/",
"note": ""
},
{
"name": "Краеведческий архив Пушкино",
"url": "",
"note": "по запросу"
}
]

113
src/content/posts.json Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,14 @@
{
"trains": [
{
"label": "Расписание электричек со ст. Пушкино",
"url": "https://rasp.yandex.ru/station/9601728/"
}
],
"buses": [
{
"label": "Маршруты автобусов по Пушкино",
"url": "https://mostransport.ru/"
}
]
}

50
src/index.css Normal file
View File

@@ -0,0 +1,50 @@
@import '@fontsource/pt-serif/400.css';
@import '@fontsource/pt-serif/400-italic.css';
@import '@fontsource/pt-serif/700.css';
@import '@fontsource/ibm-plex-sans/400.css';
@import '@fontsource/ibm-plex-sans/500.css';
@import '@fontsource/ibm-plex-sans/600.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html { -webkit-text-size-adjust: 100%; }
body {
@apply font-sans bg-paper text-ink antialiased;
}
h1, h2, h3, h4, h5, h6 {
@apply font-serif text-ink;
}
a {
@apply text-accent hover:underline underline-offset-2;
}
}
@layer components {
.prose-article {
@apply font-serif text-[1.05rem] leading-[1.7] text-ink;
}
.prose-article p { @apply my-4; }
.prose-article h1, .prose-article h2, .prose-article h3 {
@apply font-serif font-bold mt-8 mb-3;
}
.prose-article h1 { @apply text-2xl; }
.prose-article h2 { @apply text-xl; }
.prose-article h3 { @apply text-lg; }
.prose-article img {
@apply my-4 max-w-full h-auto rounded shadow-sm border border-rule;
}
.prose-article a { @apply text-accent underline underline-offset-2; }
.prose-article ul { @apply list-disc list-inside my-4; }
.prose-article ol { @apply list-decimal list-inside my-4; }
.prose-article blockquote {
@apply border-l-2 border-accent pl-4 italic my-4 text-muted;
}
.prose-article strong { @apply font-bold; }
.prose-article em { @apply italic; }
.rule-line {
@apply border-t border-rule;
}
}

12
src/main.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const container = document.getElementById('root');
if (container.hasChildNodes()) {
hydrateRoot(container, <App />);
} else {
createRoot(container).render(<App />);
}

20
src/pages/Category.jsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react';
import { postsList, categories } from '../content';
import PostCard from '../components/PostCard';
export default function Category({ slug }) {
const cat = categories.find((c) => c.slug === slug);
const filtered = postsList.filter((p) => p.categorySlugs?.includes(slug));
return (
<div>
<h1 className="font-serif text-2xl font-bold mb-2">
Категория: {cat?.name || slug}
</h1>
<div className="text-xs text-muted mb-6 pb-4 border-b border-rule">
{filtered.length} {filtered.length === 1 ? 'запись' : filtered.length < 5 ? 'записи' : 'записей'}
</div>
{filtered.map((p) => <PostCard key={p.slug} post={p} />)}
{filtered.length === 0 && <p className="text-muted">В этой категории пока нет записей.</p>}
</div>
);
}

13
src/pages/Home.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import { postsList } from '../content';
import PostCard from '../components/PostCard';
export default function Home() {
return (
<div>
{postsList.map((p) => (
<PostCard key={p.slug} post={p} />
))}
</div>
);
}

54
src/pages/News.jsx Normal file
View File

@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
function formatDate(s) {
const d = new Date(s);
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export default function News() {
const [state, setState] = useState({ loading: true, items: [], error: null });
useEffect(() => {
fetch('/api/news.json', { cache: 'no-store' })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((data) => setState({ loading: false, items: data.items || [], error: null }))
.catch((e) => setState({ loading: false, items: [], error: e.message }));
}, []);
return (
<div>
<h1 className="font-serif text-3xl font-bold mb-2">Новости</h1>
<p className="text-xs text-muted mb-6 pb-4 border-b border-rule">
Агрегатор новостей о Пушкино из внешних источников. Обновляется автоматически.
</p>
{state.loading && <p className="text-muted">Загружаем новости</p>}
{state.error && (
<p className="text-muted">
Не удалось загрузить новости. Загляните позже.
</p>
)}
{!state.loading && !state.error && state.items.length === 0 && (
<p className="text-muted">Пока нет новостей.</p>
)}
<ul className="space-y-6">
{state.items.map((item, i) => (
<li key={item.guid || item.link || i} className="border-b border-rule pb-4 last:border-0">
<a href={item.link} target="_blank" rel="noopener noreferrer" className="font-serif text-lg font-bold text-ink hover:text-accent no-underline">
{item.title}
</a>
<div className="text-xs text-muted mt-1">
{item.source && <span>{item.source}</span>}
{item.pubDate && <span> · {formatDate(item.pubDate)}</span>}
</div>
{item.description && (
<p className="text-sm text-ink/80 mt-2">{item.description}</p>
)}
</li>
))}
</ul>
</div>
);
}

11
src/pages/NotFound.jsx Normal file
View File

@@ -0,0 +1,11 @@
import React from 'react';
export default function NotFound() {
return (
<div className="py-12 text-center">
<h1 className="font-serif text-4xl font-bold mb-2">404</h1>
<p className="text-muted mb-6">Такой страницы здесь нет.</p>
<a href="/" className="text-accent">На главную</a>
</div>
);
}

12
src/pages/Page.jsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
export default function Page({ page }) {
return (
<article>
<h1 className="font-serif text-3xl font-bold leading-tight mb-6 pb-4 border-b border-rule">
{page.title}
</h1>
<div className="prose-article" dangerouslySetInnerHTML={{ __html: page.html }} />
</article>
);
}

30
src/pages/Post.jsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
function formatDate(s) {
const d = new Date(s.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return s;
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
export default function Post({ post }) {
return (
<article>
<h1 className="font-serif text-3xl font-bold leading-tight mb-2">{post.title}</h1>
<div className="text-xs text-muted mb-6 pb-4 border-b border-rule">
<time dateTime={post.date}>{formatDate(post.date)}</time>
{post.categories?.length > 0 && (
<>
{' · '}
{post.categories.map((c, i) => (
<span key={c}>
{i > 0 && ', '}
<a href={`/cat/${post.categorySlugs[i]}/`} className="text-muted hover:text-accent">{c}</a>
</span>
))}
</>
)}
</div>
<div className="prose-article" dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}