rewrite: Vite+React → Astro 5 + Content Collections
Some checks failed
deploy / deploy (push) Failing after 12s
Some checks failed
deploy / deploy (push) Failing after 12s
- Бэкап старой версии на ветке vite-react-backup - Stack: Astro 5 + nginx:alpine runtime, образ ~50 МБ (был ~600 МБ) - @astrojs/rss заменён ручным buildRss() — гарантия CDATA в content:encoded для IPB Importer - @astrojs/sitemap → sitemap-index.xml + sitemap.txt - 152-ФЗ cookie consent + privacy.astro + Analytics с gating - AI-файлы: robots.txt с явным allow для AI-краулеров, ai.txt, llms.txt - Гибридный визуал: фото-фон шапки (аэрофото Пушкино) + PT Serif + IBM Plex Sans - Иерархия: hero "Главная история" с рамкой + "Ещё из истории" + "Хроника" - Категория "main" (псевдо) скрыта из плашек и из Рубрик в сайдбаре - hideFromList: true для технических постов - featuredImage в frontmatter для постов без хорошей первой <img> - WP resized-URL (-WxH.ext) автоматически → оригинал - CI/CD: .gitea/workflows/deploy.yml (push → SSH-build) - Внешние RSS: scripts/pull-external-rss.mjs пишет news.json в bind-mount, фронт фетчит client-side
This commit is contained in:
23
src/pages/404.astro
Normal file
23
src/pages/404.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Не найдено" description="Страница не найдена">
|
||||
<div class="not-found">
|
||||
<h1>404</h1>
|
||||
<p>Такой страницы здесь нет. Возможно, она переехала или давно удалена.</p>
|
||||
<p><a href="/">← На главную</a></p>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.not-found { text-align: center; padding: 3rem 1rem; max-width: 520px; margin: 0 auto; }
|
||||
.not-found h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(3rem, 8vw, 5rem);
|
||||
color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
.not-found p { color: var(--muted); margin: 0.5rem 0; }
|
||||
.not-found a { color: var(--accent); }
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
95
src/pages/[slug].astro
Normal file
95
src/pages/[slug].astro
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { formatDateRu } from '../lib/extract';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const [posts, pages] = await Promise.all([
|
||||
getCollection('posts'),
|
||||
getCollection('pages'),
|
||||
]);
|
||||
const out: { params: { slug: string }; props: { kind: 'post' | 'page'; entry: any } }[] = [];
|
||||
for (const p of posts) {
|
||||
out.push({ params: { slug: p.data.slug }, props: { kind: 'post', entry: p } });
|
||||
}
|
||||
for (const p of pages) {
|
||||
if (p.data.slug === 'forum') continue;
|
||||
out.push({ params: { slug: p.data.slug }, props: { kind: 'page', entry: p } });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const { kind, entry } = Astro.props;
|
||||
const { Content } = await render(entry);
|
||||
---
|
||||
|
||||
<BaseLayout title={entry.data.title} description={entry.data.description ?? ''}>
|
||||
<article class="article">
|
||||
<header class="article-head">
|
||||
<h1>{entry.data.title}</h1>
|
||||
{kind === 'post' && entry.data.pubDate && (
|
||||
<div class="meta">
|
||||
<time datetime={entry.data.pubDate.toISOString()}>
|
||||
<span class="date-mark">●</span> {formatDateRu(entry.data.pubDate)}
|
||||
</time>
|
||||
{entry.data.categories?.filter((_: string, i: number) => entry.data.categorySlugs?.[i] !== 'main').length > 0 && (
|
||||
<span class="cats">
|
||||
{entry.data.categories.map((c: string, i: number) => {
|
||||
const slug = entry.data.categorySlugs?.[i] ?? c;
|
||||
if (slug === 'main') return null;
|
||||
return (
|
||||
<>
|
||||
<a href={`/cat/${slug}/`}>{c}</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<div class="prose">
|
||||
<Content />
|
||||
</div>
|
||||
{kind === 'post' && (
|
||||
<footer class="article-foot">
|
||||
<a class="back" href="/">← К списку записей</a>
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.article { max-width: var(--reading-max); }
|
||||
.article-head {
|
||||
border-bottom: 1px solid var(--rule);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.article-head h1 {
|
||||
font-family: var(--font-serif);
|
||||
font-size: clamp(1.7rem, 3.5vw, 2.4rem);
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.4rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.7rem;
|
||||
align-items: baseline;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.date-mark { color: var(--accent); margin-right: 0.2rem; }
|
||||
.cats a { color: var(--ink-soft); text-decoration: none; border-bottom: 1px dotted var(--rule-strong); }
|
||||
.cats a:hover { color: var(--accent); }
|
||||
.sep { color: var(--rule-strong); margin: 0 0.2rem; }
|
||||
.article-foot {
|
||||
margin-top: 2.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
.back { font-family: var(--font-sans); font-size: 0.92rem; color: var(--accent); text-decoration: none; }
|
||||
.back:hover { color: var(--accent-soft); }
|
||||
</style>
|
||||
49
src/pages/cat/[slug].astro
Normal file
49
src/pages/cat/[slug].astro
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import PostCard from '../../components/PostCard.astro';
|
||||
import { plural } from '../../consts';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const all = await getCollection('posts');
|
||||
const slugs = new Map<string, string>(); // slug -> display name
|
||||
for (const p of all) {
|
||||
p.data.categorySlugs.forEach((s, i) => {
|
||||
if (!slugs.has(s)) slugs.set(s, p.data.categories[i] ?? s);
|
||||
});
|
||||
}
|
||||
return [...slugs.entries()].map(([slug, name]) => ({
|
||||
params: { slug },
|
||||
props: { catSlug: slug, catName: name },
|
||||
}));
|
||||
}
|
||||
|
||||
const { catSlug, catName } = Astro.props;
|
||||
const all = await getCollection('posts');
|
||||
const posts = all
|
||||
.filter((p) => p.data.categorySlugs.includes(catSlug))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
|
||||
<BaseLayout title={`Категория: ${catName}`} description={`Записи в рубрике «${catName}»`}>
|
||||
<div class="cat-head">
|
||||
<h1>Категория: {catName}</h1>
|
||||
<p class="meta">
|
||||
{posts.length} {plural(posts.length, ['запись', 'записи', 'записей'])}
|
||||
·
|
||||
<a href={`/cat/${catSlug}/feed.xml`}>RSS этой рубрики</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="post-list">
|
||||
{posts.map((p) => <PostCard post={p} />)}
|
||||
{posts.length === 0 && <p class="empty">В этой рубрике пока нет записей.</p>}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.cat-head { border-bottom: 1px solid var(--rule); padding-bottom: 1rem; margin-bottom: 1rem; }
|
||||
.cat-head h1 { font-family: var(--font-serif); font-size: 1.8rem; margin: 0; }
|
||||
.cat-head .meta { margin: 0.5rem 0 0; font-size: 0.85rem; color: var(--muted); }
|
||||
.cat-head a { color: var(--accent); }
|
||||
.empty { color: var(--muted); margin: 1rem 0; }
|
||||
</style>
|
||||
58
src/pages/cat/[slug]/feed.xml.ts
Normal file
58
src/pages/cat/[slug]/feed.xml.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import {
|
||||
SITE_TITLE,
|
||||
SITE_URL,
|
||||
SITE_LANG,
|
||||
RSS_CUTOFF,
|
||||
RSS_LIMIT,
|
||||
} from '../../../consts';
|
||||
import { buildRss, plainTextExcerpt, sanitizeForRss } from '../../../lib/rss-helpers';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const all = await getCollection('posts');
|
||||
const slugs = new Map<string, string>();
|
||||
for (const p of all) {
|
||||
p.data.categorySlugs.forEach((s, i) => {
|
||||
if (!slugs.has(s)) slugs.set(s, p.data.categories[i] ?? s);
|
||||
});
|
||||
}
|
||||
return [...slugs.entries()].map(([slug, name]) => ({
|
||||
params: { slug },
|
||||
props: { catSlug: slug, catName: name },
|
||||
}));
|
||||
}
|
||||
|
||||
export const GET = async ({ props }: { props: { catSlug: string; catName: string } }) => {
|
||||
const { catSlug, catName } = props;
|
||||
const all = await getCollection('posts');
|
||||
const items = all
|
||||
.filter((p) => p.data.categorySlugs.includes(catSlug) && p.data.pubDate >= RSS_CUTOFF)
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
|
||||
.slice(0, RSS_LIMIT)
|
||||
.map((p) => {
|
||||
const link = `${SITE_URL}/${p.data.slug}/`;
|
||||
const contentHtml = sanitizeForRss(p.body ?? '');
|
||||
const description = p.data.description || plainTextExcerpt(contentHtml, 500);
|
||||
return {
|
||||
title: p.data.title,
|
||||
link,
|
||||
pubDate: p.data.pubDate,
|
||||
description,
|
||||
contentHtml,
|
||||
author: p.data.author,
|
||||
categories: [catName],
|
||||
};
|
||||
});
|
||||
|
||||
const xml = buildRss({
|
||||
title: `${SITE_TITLE} — ${catName}`,
|
||||
description: `Рубрика «${catName}»`,
|
||||
selfUrl: `${SITE_URL}/cat/${catSlug}/feed.xml`,
|
||||
language: SITE_LANG,
|
||||
items,
|
||||
});
|
||||
|
||||
return new Response(xml, {
|
||||
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
44
src/pages/feed.xml.ts
Normal file
44
src/pages/feed.xml.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import {
|
||||
SITE_TITLE,
|
||||
SITE_DESCRIPTION,
|
||||
SITE_URL,
|
||||
SITE_LANG,
|
||||
RSS_CUTOFF,
|
||||
RSS_LIMIT,
|
||||
} from '../consts';
|
||||
import { buildRss, plainTextExcerpt, sanitizeForRss } from '../lib/rss-helpers';
|
||||
|
||||
export const GET = async () => {
|
||||
const all = await getCollection('posts');
|
||||
const items = all
|
||||
.filter((p) => p.data.pubDate >= RSS_CUTOFF)
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
|
||||
.slice(0, RSS_LIMIT)
|
||||
.map((p) => {
|
||||
const link = `${SITE_URL}/${p.data.slug}/`;
|
||||
const contentHtml = sanitizeForRss(p.body ?? '');
|
||||
const description = p.data.description || plainTextExcerpt(contentHtml, 500);
|
||||
return {
|
||||
title: p.data.title,
|
||||
link,
|
||||
pubDate: p.data.pubDate,
|
||||
description,
|
||||
contentHtml,
|
||||
author: p.data.author,
|
||||
categories: p.data.categories,
|
||||
};
|
||||
});
|
||||
|
||||
const xml = buildRss({
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
selfUrl: `${SITE_URL}/feed.xml`,
|
||||
language: SITE_LANG,
|
||||
items,
|
||||
});
|
||||
|
||||
return new Response(xml, {
|
||||
headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
59
src/pages/index.astro
Normal file
59
src/pages/index.astro
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import PostCard from '../components/PostCard.astro';
|
||||
|
||||
const allPosts = await getCollection('posts');
|
||||
|
||||
// Скрываем служебные (hideFromList) из ленты главной.
|
||||
const visible = allPosts.filter((p) => !p.data.hideFromList);
|
||||
|
||||
// Pinned featured-посты идут сверху, в порядке pubDate desc внутри группы.
|
||||
const featured = visible
|
||||
.filter((p) => p.data.featured)
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
const rest = visible
|
||||
.filter((p) => !p.data.featured)
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
|
||||
const lead = featured[0];
|
||||
const otherFeatured = featured.slice(1);
|
||||
---
|
||||
|
||||
<BaseLayout>
|
||||
{lead && (
|
||||
<section class="featured-section">
|
||||
<PostCard post={lead} featured />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{otherFeatured.length > 0 && (
|
||||
<section class="more-history">
|
||||
<h2 class="section-title">Ещё из истории</h2>
|
||||
{otherFeatured.map((p) => <PostCard post={p} />)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{rest.length > 0 && (
|
||||
<section class="chronicle">
|
||||
<h2 class="section-title">Хроника</h2>
|
||||
{rest.map((p) => <PostCard post={p} />)}
|
||||
</section>
|
||||
)}
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.featured-section { margin-bottom: 1rem; }
|
||||
.more-history, .chronicle { margin-top: 1.5rem; }
|
||||
.section-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--accent);
|
||||
margin: 0 0 0.8rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--rule-strong);
|
||||
}
|
||||
</style>
|
||||
94
src/pages/news.astro
Normal file
94
src/pages/news.astro
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Новости"
|
||||
description="Агрегатор новостей о Пушкино из внешних источников."
|
||||
>
|
||||
<div class="news-head">
|
||||
<h1>Новости</h1>
|
||||
<p class="meta">
|
||||
Агрегатор новостей о Пушкино из внешних источников. Обновляется автоматически каждый час.
|
||||
</p>
|
||||
</div>
|
||||
<div id="news-list" class="news-list">
|
||||
<p class="loading">Загружаем новости…</p>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.news-head { border-bottom: 1px solid var(--rule); padding-bottom: 1rem; margin-bottom: 1.5rem; }
|
||||
.news-head h1 { font-family: var(--font-serif); font-size: 1.8rem; margin: 0; }
|
||||
.news-head .meta { margin: 0.5rem 0 0; color: var(--muted); font-size: 0.9rem; }
|
||||
.news-list { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.news-item {
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.news-item:last-child { border-bottom: 0; }
|
||||
.news-item .ni-title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
display: inline-block;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.news-item .ni-title:hover { color: var(--accent); }
|
||||
.news-item .ni-meta {
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.news-item .ni-desc {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.96rem;
|
||||
color: var(--ink-soft);
|
||||
margin: 0;
|
||||
}
|
||||
.loading { color: var(--muted); }
|
||||
.empty, .error { color: var(--muted); font-style: italic; }
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
(async function () {
|
||||
const list = document.getElementById('news-list');
|
||||
if (!list) return;
|
||||
function fmtDate(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'
|
||||
});
|
||||
}
|
||||
function esc(s) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(s ?? '');
|
||||
return div.innerHTML;
|
||||
}
|
||||
try {
|
||||
const r = await fetch('/api/news.json', { cache: 'no-store' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const data = await r.json();
|
||||
const items = data.items || [];
|
||||
if (items.length === 0) {
|
||||
list.innerHTML = '<p class="empty">Пока нет внешних новостей.</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = items.map((it) => `
|
||||
<article class="news-item">
|
||||
<a class="ni-title" href="${esc(it.link)}" target="_blank" rel="noopener noreferrer">${esc(it.title)}</a>
|
||||
<div class="ni-meta">
|
||||
${it.source ? `<span>${esc(it.source)}</span>` : ''}
|
||||
${it.pubDate ? ` · <time datetime="${esc(it.pubDate)}">${esc(fmtDate(it.pubDate))}</time>` : ''}
|
||||
</div>
|
||||
${it.description ? `<p class="ni-desc">${esc(it.description)}</p>` : ''}
|
||||
</article>
|
||||
`).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<p class="error">Не удалось загрузить новости. Загляните позже.</p>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
57
src/pages/privacy.astro
Normal file
57
src/pages/privacy.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Политика конфиденциальности" description="Политика обработки данных pushkinohistory.ru" noSidebar>
|
||||
<article class="prose">
|
||||
<h1>Политика конфиденциальности</h1>
|
||||
<p><em>В соответствии с Федеральным законом от 27.07.2006 № 152-ФЗ «О персональных данных».</em></p>
|
||||
|
||||
<h2>1. Какие данные мы обрабатываем</h2>
|
||||
<p>Сайт pushkinohistory.ru — статический проект без регистрации, форм обратной связи и личных кабинетов. Мы не запрашиваем у вас имя, телефон, электронную почту и иные персональные данные напрямую.</p>
|
||||
<p>Автоматически при посещении сайта собираются обезличенные технические данные: IP-адрес, тип браузера, версия операционной системы, источник перехода, посещённые страницы. Эти данные используются исключительно для анализа аудитории и улучшения сайта.</p>
|
||||
|
||||
<h2>2. Cookies и системы аналитики</h2>
|
||||
<p>Если вы согласились в баннере, на сайте работают:</p>
|
||||
<ul>
|
||||
<li><strong>Яндекс.Метрика</strong> — счётчик посещаемости с включённым «Вебвизором». Политика — <a href="https://yandex.ru/legal/confidential/" target="_blank" rel="noopener noreferrer">yandex.ru/legal/confidential</a>.</li>
|
||||
<li><strong>Google Analytics</strong> — обезличенная статистика посещений. Политика — <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">policies.google.com/privacy</a>.</li>
|
||||
</ul>
|
||||
<p>Согласие сохраняется в браузере (localStorage + cookie <code>ph-consent</code>) на 12 месяцев. До получения согласия скрипты аналитики не выполняются.</p>
|
||||
|
||||
<h2>3. Отозвать согласие</h2>
|
||||
<p>Вы можете отозвать согласие на использование cookies одним кликом:</p>
|
||||
<p><button type="button" id="cc-revoke" class="cc-revoke">Отозвать согласие</button></p>
|
||||
|
||||
<h2>4. Хранение и передача</h2>
|
||||
<p>Обезличенные данные о посещениях хранятся в сервисах Яндекс.Метрика и Google Analytics в течение установленных ими сроков. Мы не передаём данные третьим лицам сверх указанных систем аналитики.</p>
|
||||
|
||||
<h2>5. Изменения политики</h2>
|
||||
<p>Действующая редакция всегда доступна по этой странице. Существенные изменения отмечаются датой обновления.</p>
|
||||
<p><em>Обновлено: 21 мая 2026 г.</em></p>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.prose h1 { font-size: clamp(1.7rem, 3.5vw, 2.2rem); margin: 0 0 1rem; }
|
||||
.prose h2 { font-size: 1.2rem; margin: 1.8rem 0 0.5rem; }
|
||||
.cc-revoke {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.95rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border: 1px solid var(--rule-strong);
|
||||
background: var(--paper);
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
}
|
||||
.cc-revoke:hover { color: var(--accent); border-color: var(--accent); }
|
||||
</style>
|
||||
|
||||
<script is:inline>
|
||||
document.getElementById('cc-revoke')?.addEventListener('click', () => {
|
||||
try { localStorage.setItem('ph-consent', 'deny'); } catch {}
|
||||
const exp = new Date(Date.now() + 365 * 24 * 3600 * 1000).toUTCString();
|
||||
document.cookie = `ph-consent=deny; expires=${exp}; path=/; SameSite=Lax`;
|
||||
alert('Согласие отозвано. Скрипты аналитики не загружаются. Перезагрузите страницу, чтобы изменения вступили в силу.');
|
||||
});
|
||||
</script>
|
||||
29
src/pages/sitemap.txt.ts
Normal file
29
src/pages/sitemap.txt.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getCollection } from 'astro:content';
|
||||
import { SITE_URL } from '../consts';
|
||||
|
||||
export const GET = async () => {
|
||||
const [posts, pages] = await Promise.all([
|
||||
getCollection('posts'),
|
||||
getCollection('pages'),
|
||||
]);
|
||||
const urls = [
|
||||
`${SITE_URL}/`,
|
||||
`${SITE_URL}/news/`,
|
||||
`${SITE_URL}/privacy/`,
|
||||
];
|
||||
for (const p of pages) {
|
||||
if (p.data.slug === 'forum') continue;
|
||||
urls.push(`${SITE_URL}/${p.data.slug}/`);
|
||||
}
|
||||
for (const p of posts) {
|
||||
urls.push(`${SITE_URL}/${p.data.slug}/`);
|
||||
}
|
||||
// categories
|
||||
const cats = new Set<string>();
|
||||
for (const p of posts) p.data.categorySlugs.forEach((s) => cats.add(s));
|
||||
for (const c of cats) urls.push(`${SITE_URL}/cat/${c}/`);
|
||||
|
||||
return new Response(urls.join('\n') + '\n', {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user