init: Vite+React+Tailwind v2 site with HTML content from WP, RSS feed, external feed aggregator, prerender
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vite/
|
||||
data/news.json
|
||||
scripts/posts-raw.jsonl
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ─── Stage 1: build SPA + prerender via puppeteer ──────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
RUN apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont
|
||||
ENV PUPPETEER_SKIP_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:prerender
|
||||
|
||||
# ─── Stage 2: runtime (Express) ─────────────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/server ./server
|
||||
COPY --from=builder /app/scripts/pull-external-rss.js ./scripts/pull-external-rss.js
|
||||
COPY --from=builder /app/src/content/feeds.json ./src/content/feeds.json
|
||||
|
||||
RUN mkdir -p /app/public/uploads /app/data && chown -R node:node /app/public /app/data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
USER node
|
||||
CMD ["node", "server/index.js"]
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
pushkinohistory-ru-v2:
|
||||
build:
|
||||
context: .
|
||||
image: pushkinohistory-ru-v2:latest
|
||||
container_name: pushkinohistory-ru-v2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:4146:3000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
volumes:
|
||||
- /opt/www/pushkinohistory.ru/uploads:/app/public/uploads:ro
|
||||
- /opt/docker/sites/pushkinohistory-ru-v2/data:/app/data:ro
|
||||
cap_drop: [ALL]
|
||||
cap_add: [NET_BIND_SERVICE, CHOWN, SETUID, SETGID]
|
||||
security_opt: [no-new-privileges:true]
|
||||
tmpfs:
|
||||
- /tmp:noexec,nosuid,size=16m
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>История города Пушкино</title>
|
||||
<meta name="description" content="История города Пушкино, фотографии, новости и форум." />
|
||||
<link rel="alternate" type="application/rss+xml" title="История города Пушкино — RSS" href="/feed/" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4910
package-lock.json
generated
Normal file
4910
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "pushkinohistory-ru-v2",
|
||||
"version": "0.1.0",
|
||||
"description": "История города Пушкино — статический сайт + RSS",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js",
|
||||
"build:prerender": "vite build && node scripts/build-slugs.js && node scripts/build-sitemap.js && node scripts/build-rss.js && node scripts/prerender.js",
|
||||
"prerender": "node scripts/prerender.js",
|
||||
"preview": "vite preview",
|
||||
"start": "node server/index.js",
|
||||
"pull-rss": "node scripts/pull-external-rss.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/pt-serif": "^5.2.5",
|
||||
"@fontsource/ibm-plex-sans": "^5.2.5",
|
||||
"compression": "^1.7.5",
|
||||
"express": "^4.21.2",
|
||||
"fast-xml-parser": "^4.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"puppeteer": "^23.10.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
BIN
public/uploads/IMG_2156.jpg
Normal file
BIN
public/uploads/IMG_2156.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 321 KiB |
BIN
public/uploads/IMG_2754.jpg
Normal file
BIN
public/uploads/IMG_2754.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 327 KiB |
BIN
public/uploads/image.png
Normal file
BIN
public/uploads/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 776 KiB |
BIN
public/uploads/post-78-1227537465.jpg
Normal file
BIN
public/uploads/post-78-1227537465.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
public/uploads/pushkin1.gif
Normal file
BIN
public/uploads/pushkin1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/uploads/pushkino.jpg
Normal file
BIN
public/uploads/pushkino.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
73
scripts/build-rss.js
Normal file
73
scripts/build-rss.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
|
||||
const SITE = 'https://pushkinohistory.ru';
|
||||
const TITLE = 'История города Пушкино';
|
||||
const DESC = 'Прошлое, настоящее, будущее города Пушкино.';
|
||||
|
||||
const posts = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/content/posts.json'), 'utf8'));
|
||||
|
||||
const escapeXml = (s) =>
|
||||
String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const cdata = (s) => `<![CDATA[${String(s).replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
||||
|
||||
const rfc2822 = (s) => {
|
||||
const d = new Date(s.replace(' ', 'T') + '+03:00');
|
||||
return d.toUTCString();
|
||||
};
|
||||
|
||||
const absoluteImages = (html) =>
|
||||
html.replace(/(src|href)="\/uploads\//g, `$1="${SITE}/uploads/`);
|
||||
|
||||
const items = posts.map((p) => {
|
||||
const html = absoluteImages(p.html);
|
||||
const description = p.excerpt
|
||||
? p.excerpt
|
||||
: html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 500);
|
||||
const url = `${SITE}/${p.slug}/`;
|
||||
return ` <item>
|
||||
<title>${escapeXml(p.title)}</title>
|
||||
<link>${url}</link>
|
||||
<guid isPermaLink="true">${url}</guid>
|
||||
<pubDate>${rfc2822(p.date)}</pubDate>
|
||||
<dc:creator>${cdata('История города Пушкино')}</dc:creator>
|
||||
${(p.categories || []).map((c) => `<category>${escapeXml(c)}</category>`).join('\n ')}
|
||||
<description>${cdata(description)}</description>
|
||||
<content:encoded>${cdata(html)}</content:encoded>
|
||||
</item>`;
|
||||
}).join('\n');
|
||||
|
||||
const lastBuild = new Date().toUTCString();
|
||||
|
||||
const rss = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0"
|
||||
xmlns:content="http://purl.org/rss/1.0/modules/content/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>${escapeXml(TITLE)}</title>
|
||||
<link>${SITE}/</link>
|
||||
<atom:link href="${SITE}/feed/" rel="self" type="application/rss+xml" />
|
||||
<description>${escapeXml(DESC)}</description>
|
||||
<language>ru-RU</language>
|
||||
<lastBuildDate>${lastBuild}</lastBuildDate>
|
||||
<ttl>60</ttl>
|
||||
${items}
|
||||
</channel>
|
||||
</rss>
|
||||
`;
|
||||
|
||||
fs.mkdirSync(DIST, { recursive: true });
|
||||
fs.writeFileSync(path.join(DIST, 'feed.xml'), rss);
|
||||
console.log(`rss: ${posts.length} items → dist/feed.xml`);
|
||||
58
scripts/build-sitemap.js
Normal file
58
scripts/build-sitemap.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
|
||||
const SITE = 'https://pushkinohistory.ru';
|
||||
|
||||
const posts = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/content/posts.json'), 'utf8'));
|
||||
const pages = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/content/pages.json'), 'utf8'));
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const urls = [
|
||||
{ loc: `${SITE}/`, lastmod: today, priority: '1.0', changefreq: 'weekly' },
|
||||
{ loc: `${SITE}/news/`, lastmod: today, priority: '0.8', changefreq: 'hourly' },
|
||||
];
|
||||
for (const p of pages) {
|
||||
urls.push({
|
||||
loc: `${SITE}/${p.slug}/`,
|
||||
lastmod: p.date.slice(0, 10),
|
||||
priority: '0.7',
|
||||
changefreq: 'yearly',
|
||||
});
|
||||
}
|
||||
for (const p of posts) {
|
||||
urls.push({
|
||||
loc: `${SITE}/${p.slug}/`,
|
||||
lastmod: p.date.slice(0, 10),
|
||||
priority: '0.6',
|
||||
changefreq: 'monthly',
|
||||
});
|
||||
}
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((u) => ` <url>
|
||||
<loc>${u.loc}</loc>
|
||||
<lastmod>${u.lastmod}</lastmod>
|
||||
<changefreq>${u.changefreq}</changefreq>
|
||||
<priority>${u.priority}</priority>
|
||||
</url>`).join('\n')}
|
||||
</urlset>
|
||||
`;
|
||||
|
||||
fs.mkdirSync(DIST, { recursive: true });
|
||||
fs.writeFileSync(path.join(DIST, 'sitemap.xml'), xml);
|
||||
|
||||
const robots = `User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: ${SITE}/sitemap.xml
|
||||
`;
|
||||
fs.writeFileSync(path.join(DIST, 'robots.txt'), robots);
|
||||
|
||||
console.log(`sitemap: ${urls.length} URLs → dist/sitemap.xml`);
|
||||
22
scripts/build-slugs.js
Normal file
22
scripts/build-slugs.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
|
||||
const posts = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/content/posts.json'), 'utf8'));
|
||||
const pages = JSON.parse(fs.readFileSync(path.join(ROOT, 'src/content/pages.json'), 'utf8'));
|
||||
|
||||
const cats = new Set();
|
||||
for (const p of posts) (p.categorySlugs || []).forEach((s) => cats.add(s));
|
||||
|
||||
const routes = ['/', '/news/'];
|
||||
for (const p of posts) routes.push(`/${p.slug}/`);
|
||||
for (const p of pages) routes.push(`/${p.slug}/`);
|
||||
for (const c of cats) routes.push(`/cat/${c}/`);
|
||||
|
||||
fs.mkdirSync(DIST, { recursive: true });
|
||||
fs.writeFileSync(path.join(DIST, 'routes.json'), JSON.stringify(routes, null, 2));
|
||||
console.log(`routes: ${routes.length} → dist/routes.json`);
|
||||
97
scripts/convert_posts.py
Normal file
97
scripts/convert_posts.py
Normal file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert WP posts-raw.jsonl → src/content/{posts,pages}.json with image URL rewrite + translit slugs."""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RAW = ROOT / "scripts" / "posts-raw.jsonl"
|
||||
OUT_DIR = ROOT / "src" / "content"
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
TRANSLIT = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch',
|
||||
'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya',
|
||||
}
|
||||
|
||||
def slugify_ru(s: str) -> str:
|
||||
s = unquote(s).lower()
|
||||
out = []
|
||||
for ch in s:
|
||||
if ch in TRANSLIT:
|
||||
out.append(TRANSLIT[ch])
|
||||
elif ch.isalnum() or ch in '-_':
|
||||
out.append(ch)
|
||||
elif ch in ' \t':
|
||||
out.append('-')
|
||||
res = ''.join(out)
|
||||
res = re.sub(r'-+', '-', res).strip('-')
|
||||
return res or 'untitled'
|
||||
|
||||
UPLOAD_RE = re.compile(r'https?://(?:www\.)?pushkinohistory\.ru/wp-content/uploads/[^/]+/[^/]+/([^"\'\s)]+)')
|
||||
|
||||
def rewrite_uploads(html: str) -> str:
|
||||
return UPLOAD_RE.sub(r'/uploads/\1', html)
|
||||
|
||||
CATEGORIES = {
|
||||
20: [], 23: [], 73: [], 94: [], # pages — no category
|
||||
137: ['main'], 139: ['main'],
|
||||
142: ['main'], 145: ['main', 'today', 'tech'],
|
||||
158: ['main', 'tech'], 226: ['main'], 235: ['main'],
|
||||
}
|
||||
|
||||
CATEGORY_NAMES = {
|
||||
'main': 'Главная',
|
||||
'today': 'Настоящее',
|
||||
'tech': 'Техническое',
|
||||
}
|
||||
|
||||
def main() -> None:
|
||||
posts: list[dict] = []
|
||||
pages: list[dict] = []
|
||||
with RAW.open(encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
row = json.loads(line)
|
||||
old_slug = row['name']
|
||||
new_slug = slugify_ru(old_slug if '%' in old_slug else old_slug)
|
||||
html = rewrite_uploads(row['content'])
|
||||
item = {
|
||||
'id': row['id'],
|
||||
'slug': new_slug,
|
||||
'oldSlug': old_slug,
|
||||
'title': row['title'],
|
||||
'date': row['date'],
|
||||
'excerpt': row.get('excerpt') or '',
|
||||
'html': html,
|
||||
'categories': [CATEGORY_NAMES[c] for c in CATEGORIES.get(row['id'], [])],
|
||||
'categorySlugs': CATEGORIES.get(row['id'], []),
|
||||
}
|
||||
if row['type'] == 'post':
|
||||
posts.append(item)
|
||||
else:
|
||||
pages.append(item)
|
||||
posts.sort(key=lambda p: p['date'], reverse=True)
|
||||
pages.sort(key=lambda p: p['id'])
|
||||
(OUT_DIR / 'posts.json').write_text(
|
||||
json.dumps(posts, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
(OUT_DIR / 'pages.json').write_text(
|
||||
json.dumps(pages, ensure_ascii=False, indent=2), encoding='utf-8'
|
||||
)
|
||||
print(f'posts: {len(posts)} → src/content/posts.json')
|
||||
print(f'pages: {len(pages)} → src/content/pages.json')
|
||||
for p in posts:
|
||||
print(f" post: /{p['slug']}/ (was: {p['oldSlug'][:40]}{'...' if len(p['oldSlug']) > 40 else ''}) — {p['title']}")
|
||||
for p in pages:
|
||||
print(f" page: /{p['slug']}/ — {p['title']}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
53
scripts/prerender.js
Normal file
53
scripts/prerender.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import http from 'node:http';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
|
||||
const routes = JSON.parse(fs.readFileSync(path.join(DIST, 'routes.json'), 'utf8'));
|
||||
|
||||
const app = express();
|
||||
app.use(express.static(DIST));
|
||||
app.get('*', (_req, res) => res.sendFile(path.join(DIST, 'index.html')));
|
||||
const server = http.createServer(app);
|
||||
|
||||
async function start() {
|
||||
await new Promise((resolve) => server.listen(0, resolve));
|
||||
const port = server.address().port;
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const launchOpts = { headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'] };
|
||||
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
|
||||
launchOpts.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
}
|
||||
const browser = await puppeteer.launch(launchOpts);
|
||||
|
||||
for (const route of routes) {
|
||||
const page = await browser.newPage();
|
||||
const url = `${baseUrl}${route}`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle0', timeout: 15000 });
|
||||
} catch (e) {
|
||||
console.warn(`prerender warn ${route}: ${e.message}`);
|
||||
}
|
||||
const html = await page.content();
|
||||
const outDir = path.join(DIST, route);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'index.html'), html);
|
||||
console.log(`prerender: ${route}`);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
|
||||
start().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
125
scripts/pull-external-rss.js
Normal file
125
scripts/pull-external-rss.js
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Тянет внешние RSS-фиды из src/content/feeds.json, дедуплицирует по guid/link,
|
||||
* пишет агрегированный news.json в DATA_DIR (default: ./data).
|
||||
* Запускается по cron на хосте.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const FEEDS_FILE = path.join(ROOT, 'src/content/feeds.json');
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
||||
const OUT_FILE = path.join(DATA_DIR, 'news.json');
|
||||
const TIMEOUT_MS = 15000;
|
||||
const HARD_CAP = 200;
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
attributeNamePrefix: '@_',
|
||||
textNodeName: '#text',
|
||||
});
|
||||
|
||||
async function fetchFeed(url, timeoutMs) {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
signal: ctl.signal,
|
||||
headers: { 'User-Agent': 'pushkinohistory-ru-v2 RSS aggregator' },
|
||||
});
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return await r.text();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
function extractItems(xml, feed) {
|
||||
const parsed = parser.parse(xml);
|
||||
// RSS 2.0
|
||||
const rssItems = parsed?.rss?.channel?.item;
|
||||
if (rssItems) {
|
||||
const arr = Array.isArray(rssItems) ? rssItems : [rssItems];
|
||||
return arr.map((it) => ({
|
||||
title: typeof it.title === 'string' ? it.title : it.title?.['#text'] || '',
|
||||
link: typeof it.link === 'string' ? it.link : it.link?.['#text'] || '',
|
||||
guid: typeof it.guid === 'string' ? it.guid : it.guid?.['#text'] || it.link || '',
|
||||
pubDate: it.pubDate ? new Date(it.pubDate).toISOString() : null,
|
||||
description: stripHtml(it.description || it['content:encoded'] || ''),
|
||||
source: feed.name,
|
||||
}));
|
||||
}
|
||||
// Atom
|
||||
const atomEntries = parsed?.feed?.entry;
|
||||
if (atomEntries) {
|
||||
const arr = Array.isArray(atomEntries) ? atomEntries : [atomEntries];
|
||||
return arr.map((e) => {
|
||||
const link = Array.isArray(e.link) ? e.link[0]?.['@_href'] : e.link?.['@_href'] || e.link;
|
||||
return {
|
||||
title: typeof e.title === 'string' ? e.title : e.title?.['#text'] || '',
|
||||
link: link || '',
|
||||
guid: e.id || link || '',
|
||||
pubDate: e.updated || e.published ? new Date(e.updated || e.published).toISOString() : null,
|
||||
description: stripHtml(e.summary?.['#text'] || e.summary || e.content?.['#text'] || ''),
|
||||
source: feed.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function stripHtml(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const feeds = JSON.parse(fs.readFileSync(FEEDS_FILE, 'utf8')).filter((f) => f.enabled);
|
||||
if (feeds.length === 0) {
|
||||
console.log('no enabled feeds — writing empty news.json');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify({ updatedAt: new Date().toISOString(), items: [] }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const all = [];
|
||||
for (const feed of feeds) {
|
||||
try {
|
||||
const xml = await fetchFeed(feed.url, TIMEOUT_MS);
|
||||
const items = extractItems(xml, feed);
|
||||
const max = feed.max || 20;
|
||||
all.push(...items.slice(0, max));
|
||||
console.log(`✓ ${feed.name}: ${items.length} items (kept ${Math.min(items.length, max)})`);
|
||||
} catch (e) {
|
||||
console.warn(`✗ ${feed.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const deduped = [];
|
||||
for (const it of all) {
|
||||
const key = it.guid || it.link;
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
deduped.push(it);
|
||||
}
|
||||
deduped.sort((a, b) => (b.pubDate || '').localeCompare(a.pubDate || ''));
|
||||
|
||||
const out = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
items: deduped.slice(0, HARD_CAP),
|
||||
};
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
fs.writeFileSync(OUT_FILE, JSON.stringify(out, null, 2));
|
||||
console.log(`→ ${OUT_FILE}: ${out.items.length} items`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
56
server/index.js
Normal file
56
server/index.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const DIST = path.join(ROOT, 'dist');
|
||||
const DATA = path.join(ROOT, 'data');
|
||||
const UPLOADS = path.join(ROOT, 'public', 'uploads');
|
||||
const NEWS_FILE = path.join(DATA, 'news.json');
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
const app = express();
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(compression());
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true, ts: Date.now() }));
|
||||
|
||||
app.get('/api/news.json', (_req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync(NEWS_FILE)) {
|
||||
return res.json({ updatedAt: null, items: [] });
|
||||
}
|
||||
const stat = fs.statSync(NEWS_FILE);
|
||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||
res.setHeader('Last-Modified', stat.mtime.toUTCString());
|
||||
res.sendFile(NEWS_FILE);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/uploads', express.static(UPLOADS, { maxAge: '7d', fallthrough: true }));
|
||||
|
||||
const FEED_FILE = path.join(DIST, 'feed.xml');
|
||||
app.get(['/feed', '/feed/', '/feed/rss2', '/feed/rss2/', '/rss', '/rss.xml'], (_req, res) => {
|
||||
res.type('application/rss+xml; charset=utf-8');
|
||||
res.sendFile(FEED_FILE);
|
||||
});
|
||||
|
||||
app.use(express.static(DIST, { maxAge: '1h', etag: true }));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
const candidate = path.join(DIST, req.path, 'index.html');
|
||||
if (fs.existsSync(candidate)) {
|
||||
return res.sendFile(candidate);
|
||||
}
|
||||
res.status(200).sendFile(path.join(DIST, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`pushkinohistory-ru-v2 listening on :${PORT}`);
|
||||
});
|
||||
133
src/App.jsx
Normal file
133
src/App.jsx
Normal 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
17
src/components/Footer.jsx
Normal 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
62
src/components/Header.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/PostCard.jsx
Normal file
46
src/components/PostCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/Sidebar.jsx
Normal file
67
src/components/Sidebar.jsx
Normal 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
1
src/content/ads.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
8
src/content/feeds.json
Normal file
8
src/content/feeds.json
Normal file
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "Подмосковье сегодня — Пушкино",
|
||||
"url": "https://mosregtoday.ru/tags/пушкино/rss/",
|
||||
"enabled": false,
|
||||
"max": 10
|
||||
}
|
||||
]
|
||||
38
src/content/index.js
Normal file
38
src/content/index.js
Normal 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
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
12
src/content/partners.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"name": "Музей города Пушкино",
|
||||
"url": "https://museum-pushkino.ru/",
|
||||
"note": ""
|
||||
},
|
||||
{
|
||||
"name": "Краеведческий архив Пушкино",
|
||||
"url": "",
|
||||
"note": "по запросу"
|
||||
}
|
||||
]
|
||||
113
src/content/posts.json
Normal file
113
src/content/posts.json
Normal file
File diff suppressed because one or more lines are too long
14
src/content/transport.json
Normal file
14
src/content/transport.json
Normal 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
50
src/index.css
Normal 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
12
src/main.jsx
Normal 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
20
src/pages/Category.jsx
Normal 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
13
src/pages/Home.jsx
Normal 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
54
src/pages/News.jsx
Normal 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
11
src/pages/NotFound.jsx
Normal 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
12
src/pages/Page.jsx
Normal 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
30
src/pages/Post.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
tailwind.config.js
Normal file
20
tailwind.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
serif: ['"PT Serif"', 'Georgia', 'serif'],
|
||||
sans: ['"IBM Plex Sans"', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
paper: '#f8f4ec',
|
||||
ink: '#1f1a15',
|
||||
accent: '#7a3b14',
|
||||
muted: '#5b554b',
|
||||
rule: '#d6cdb8',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
14
vite.config.js
Normal file
14
vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
open: false,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user