Update UI and migrations, fix dockerfile
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
DATABASE_URL=postgresql://forma_n8:change-me@127.0.0.1:5432/forma_n8
|
||||
SITE_BASE_URL=http://127.0.0.1:8090
|
||||
SITE_NAME=Forma N8
|
||||
SITE_TAGLINE=Новости снаряжения
|
||||
SITE_INDEXING_ENABLED=false
|
||||
PUBLISH_API_TOKEN=change-me
|
||||
UPLOAD_DIR=/var/lib/forma-n8/uploads
|
||||
PAGE_SIZE=12
|
||||
@@ -0,0 +1,55 @@
|
||||
# Forma N8 Site
|
||||
|
||||
Отдельное SSR-приложение публичного сайта Forma N8. Контент и постоянные URL не зависят от будущего дизайна.
|
||||
|
||||
## Возможности
|
||||
|
||||
- лента новостей, страницы статей, категорий и производителей;
|
||||
- PostgreSQL со стабильными `external_id` и `slug`;
|
||||
- idempotent API публикации и отдельный upload API;
|
||||
- RSS 2.0, sitemap, robots.txt;
|
||||
- canonical, Open Graph и JSON-LD `NewsArticle`;
|
||||
- системный деплой через nginx + systemd.
|
||||
|
||||
## Локальный запуск
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
export DATABASE_URL=postgresql://forma_n8:forma_n8@127.0.0.1:5432/forma_n8
|
||||
PYTHONPATH=src .venv/bin/python scripts/migrate.py
|
||||
PYTHONPATH=src .venv/bin/uvicorn forma_n8_site.app:app --port 8090
|
||||
```
|
||||
|
||||
## Публикационный API
|
||||
|
||||
Сначала медиа загружается через `POST /api/v1/media` с заголовком `X-Forma-Token`, затем полученный относительный URL передаётся в статью.
|
||||
|
||||
`POST /api/v1/articles`:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_id": "parser:518",
|
||||
"title": "Заголовок новости",
|
||||
"lead": "Краткое описание",
|
||||
"body": "Основной текст без HTML",
|
||||
"category_name": "Снаряжение",
|
||||
"category_tag": "snaryazhenie",
|
||||
"producer_name": "Производитель",
|
||||
"producer_tag": "producer",
|
||||
"source_url": "https://example.com/source",
|
||||
"status": "published",
|
||||
"published_at": "2026-06-21T10:00:00+05:00",
|
||||
"media": [
|
||||
{"type": "image", "url": "/media/ab/hash.jpg", "alt": "Описание изображения"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Повторный запрос с тем же `external_id` обновляет материал, но сохраняет первоначальные slug и дату первой публикации. Это предотвращает дубли URL и потерю поисковой истории.
|
||||
|
||||
Публичное название категории берётся из таблицы `categories` по нормализованному `category_tag`. Описательные названия категорий из AI-контура не используются в URL, H1 и SEO-метаданных.
|
||||
|
||||
После подключения домена меняется только `SITE_BASE_URL` в `/etc/forma-n8.env`; затем настраивается TLS и отправляются sitemap в Google Search Console и Яндекс Вебмастер.
|
||||
|
||||
До подключения домена `SITE_INDEXING_ENABLED=false`: страницы получают `noindex`, а `robots.txt` запрещает обход IP-адреса. После настройки домена, HTTPS и 301-редиректа с IP значение меняется на `true`.
|
||||
@@ -0,0 +1,46 @@
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
external_id TEXT NOT NULL UNIQUE,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
lead TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL,
|
||||
category_name TEXT NOT NULL,
|
||||
category_tag TEXT NOT NULL,
|
||||
producer_name TEXT NOT NULL,
|
||||
producer_tag TEXT NOT NULL,
|
||||
source_url TEXT,
|
||||
seo_title TEXT NOT NULL DEFAULT '',
|
||||
seo_description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
|
||||
published_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_feed
|
||||
ON articles(status, published_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_category
|
||||
ON articles(category_tag, status, published_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_producer
|
||||
ON articles(producer_tag, status, published_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS article_media (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
article_id BIGINT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
||||
media_type TEXT NOT NULL CHECK (media_type IN ('image', 'video')),
|
||||
url TEXT NOT NULL,
|
||||
alt_text TEXT NOT NULL DEFAULT '',
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_article_media_order
|
||||
ON article_media(article_id, sort_order, id);
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
tag TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
INSERT INTO categories(tag, name, sort_order) VALUES
|
||||
('odezhda', 'Одежда', 1),
|
||||
('snaryazhenie', 'Снаряжение', 2),
|
||||
('bronya', 'Броня', 3),
|
||||
('aksessuary', 'Аксессуары', 4),
|
||||
('svyaz', 'Связь', 5),
|
||||
('patchi', 'Патчи', 6),
|
||||
('navigaciya', 'Навигация', 7),
|
||||
('pricely', 'Прицелы', 8),
|
||||
('nochnye-pribory', 'Ночные приборы', 9),
|
||||
('medicina', 'Медицина', 10),
|
||||
('svet', 'Свет', 11),
|
||||
('elektronika', 'Электроника', 12),
|
||||
('nozhi', 'Ножи и мультитулы', 13),
|
||||
('oruzheynyy-obves', 'Оружейный обвес', 14),
|
||||
('praktika', 'Практика', 15),
|
||||
('teoriya', 'Теория', 16),
|
||||
('pushki', 'Оружие', 17)
|
||||
ON CONFLICT(tag) DO UPDATE SET
|
||||
name=EXCLUDED.name,
|
||||
sort_order=EXCLUDED.sort_order;
|
||||
|
||||
UPDATE articles a
|
||||
SET category_name=c.name,
|
||||
updated_at=NOW()
|
||||
FROM categories c
|
||||
WHERE a.category_tag=c.tag
|
||||
AND a.category_name<>c.name;
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Forma N8 public website
|
||||
After=network.target postgresql.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=forma-n8
|
||||
Group=forma-n8
|
||||
WorkingDirectory=/opt/forma-n8
|
||||
EnvironmentFile=/etc/forma-n8.env
|
||||
Environment=PYTHONPATH=/opt/forma-n8/src
|
||||
ExecStart=/opt/forma-n8/.venv/bin/uvicorn forma_n8_site.app:app --host 127.0.0.1 --port 8090 --proxy-headers
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/forma-n8
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,31 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 52m;
|
||||
|
||||
location /static/ {
|
||||
alias /opt/forma-n8/static/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /media/ {
|
||||
alias /var/lib/forma-n8/uploads/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -0,0 +1,431 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
src: url("../fonts/roboto-condensed-cyrillic-400.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
src: url("../fonts/roboto-condensed-cyrillic-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090c0d;
|
||||
--surface: #101415;
|
||||
--surface-strong: #151a1b;
|
||||
--text: #eceee8;
|
||||
--muted: #9ba19b;
|
||||
--faint: #686f6b;
|
||||
--border: #29302e;
|
||||
--border-strong: #3a423f;
|
||||
--olive: #a5ad62;
|
||||
--red: #df4a40;
|
||||
--max: 1240px;
|
||||
--gutter: 24px;
|
||||
--font: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg); scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 18px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .22;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.08'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: var(--olive); }
|
||||
img, video { display: block; max-width: 100%; }
|
||||
button, input { font: inherit; letter-spacing: 0; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--olive); outline-offset: 4px; }
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
transform: translateY(-160%);
|
||||
padding: 10px 14px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
}
|
||||
.skip-link:focus { transform: none; }
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(9, 12, 13, .96);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
width: min(var(--max), calc(100% - var(--gutter) * 2));
|
||||
min-height: 76px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 170px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: .95;
|
||||
}
|
||||
.brand img { width: 42px; height: 42px; border: 1px solid var(--border-strong); }
|
||||
|
||||
.main-nav { display: flex; justify-content: center; align-self: stretch; gap: 38px; }
|
||||
.main-nav .nav { display: contents; margin: 0; padding: 0; list-style: none; }
|
||||
.main-nav .nav li { display: contents; }
|
||||
.nav-dropdown { position: relative; display: flex; align-items: center; }
|
||||
.nav-dropdown-toggle { display: inline-flex; height: 100%; align-items: center; gap: 8px; padding: 0; border: 0; background: transparent; color: var(--muted); cursor: pointer; }
|
||||
.nav-dropdown-toggle:hover, .nav-dropdown-toggle[aria-expanded="true"] { color: var(--text); }
|
||||
.nav-dropdown-icon { width: 7px; height: 7px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: translateY(-2px) rotate(45deg); transition: transform .18s ease; }
|
||||
.nav-dropdown-toggle[aria-expanded="true"] .nav-dropdown-icon { transform: translateY(2px) rotate(225deg); }
|
||||
.category-menu { position: absolute; z-index: 30; top: calc(100% - 1px); left: 50%; display: none; grid-template-columns: repeat(2, minmax(170px, 1fr)); width: 430px; padding: 12px; border: 1px solid var(--border-strong); background: var(--bg); transform: translateX(-50%); box-shadow: 0 18px 44px rgba(0,0,0,.38); }
|
||||
.category-menu.is-open { display: grid; }
|
||||
.category-menu a { min-height: 40px; padding: 9px 11px; border-bottom: 1px solid var(--border); color: var(--muted); }
|
||||
.category-menu a:hover { background: var(--surface-strong); color: var(--text); }
|
||||
.main-nav a {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.main-nav a:hover, .main-nav a[aria-current="page"] { color: var(--text); }
|
||||
.main-nav a[aria-current="page"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: var(--olive);
|
||||
}
|
||||
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
.header-actions > .social-links { margin-right: 5px; padding-right: 10px; border-right: 1px solid var(--border); }
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-button:hover { background: var(--surface-strong); color: var(--olive); }
|
||||
.icon-button svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: square; }
|
||||
.menu-button { display: none; }
|
||||
|
||||
.search-form {
|
||||
width: min(760px, calc(100% - var(--gutter) * 2));
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 20px;
|
||||
}
|
||||
.search-form[hidden] { display: none; }
|
||||
.search-form label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 14px; }
|
||||
.search-form > div { display: grid; grid-template-columns: 1fr auto; }
|
||||
.search-form input {
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
}
|
||||
.search-form button, .read-link, .empty-state a {
|
||||
border: 1px solid var(--olive);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-form button { padding: 0 24px; }
|
||||
.search-form button:hover, .read-link:hover, .empty-state a:hover { background: var(--olive); color: var(--bg); }
|
||||
|
||||
.feed-shell, .article-page, .related-section, .site-footer {
|
||||
position: relative;
|
||||
width: min(var(--max), calc(100% - var(--gutter) * 2));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-heading { padding: 56px 0 46px; }
|
||||
.page-heading h1 {
|
||||
max-width: 900px;
|
||||
margin: 0;
|
||||
font-size: 68px;
|
||||
font-weight: 700;
|
||||
line-height: .98;
|
||||
}
|
||||
@media (min-width: 981px) {
|
||||
.home-template .page-heading {
|
||||
position: relative;
|
||||
left: 50%;
|
||||
width: min(1600px, calc(100vw - var(--gutter) * 2));
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
.home-template .page-heading h1 {
|
||||
max-width: none;
|
||||
font-size: clamp(58px, 4vw, 68px);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
.page-heading > p { margin: 20px 0 0; color: var(--muted); }
|
||||
|
||||
.lead-story {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(340px, .95fr);
|
||||
min-height: 480px;
|
||||
max-height: 680px;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.lead-media { min-height: 480px; max-height: 680px; overflow: hidden; background: var(--surface); }
|
||||
.lead-media img, .lead-media video { width: 100%; height: 100%; object-fit: cover; transition: transform .45s ease; }
|
||||
.lead-media:hover img { transform: scale(1.015); }
|
||||
.lead-copy { display: flex; flex-direction: column; justify-content: center; padding: 34px 0 36px 44px; }
|
||||
.lead-copy h2 { margin: 24px 0 22px; font-size: 40px; line-height: 1.14; }
|
||||
.lead-copy h2 a:hover { color: var(--text); text-decoration: underline; text-decoration-color: var(--olive); text-underline-offset: 7px; }
|
||||
.lead-copy > p { max-width: 580px; margin: 0; color: var(--muted); font-size: 20px; line-height: 1.5; }
|
||||
.read-link { width: fit-content; margin-top: 30px; padding: 10px 15px; font-size: 15px; }
|
||||
.read-link span { margin-left: 8px; }
|
||||
|
||||
.article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 0; color: var(--muted); font-size: 14px; }
|
||||
.article-meta > * { display: inline-flex; align-items: center; }
|
||||
.article-meta > * + *::before { content: ""; width: 1px; height: 18px; margin: 0 16px; background: var(--border-strong); }
|
||||
.article-meta .category-link { color: var(--olive); }
|
||||
.article-meta .producer-link { color: var(--red); }
|
||||
|
||||
.feed-list { width: 100%; }
|
||||
.feed-list-top { border-top: 1px solid var(--border-strong); }
|
||||
.feed-row {
|
||||
display: grid;
|
||||
grid-template-columns: 350px minmax(0, 1fr);
|
||||
gap: 34px;
|
||||
padding: 22px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.feed-row-media { aspect-ratio: 16 / 9; overflow: hidden; background: var(--surface); }
|
||||
.feed-row-media img, .feed-row-media video { width: 100%; height: 100%; object-fit: cover; transition: transform .35s ease; }
|
||||
.feed-row-media:hover img { transform: scale(1.02); }
|
||||
.feed-row-copy { align-self: center; min-width: 0; }
|
||||
.feed-row h2 { margin: 16px 0 8px; font-size: 25px; line-height: 1.2; }
|
||||
.feed-row h2 a:hover { color: var(--text); text-decoration: underline; text-decoration-color: var(--olive); text-underline-offset: 5px; }
|
||||
.feed-row p { max-width: 760px; margin: 0; color: var(--muted); font-size: 17px; line-height: 1.5; }
|
||||
.preview-social { margin-top: 14px; }
|
||||
.media-placeholder { display: grid; width: 100%; height: 100%; place-items: center; background: var(--surface); }
|
||||
.media-placeholder img { width: 76px; height: 76px; opacity: .28; object-fit: contain; }
|
||||
.feed-media { position: relative; }
|
||||
.feed-media-feature, .feed-media-feature > img, .feed-media-feature > .media-placeholder { display: block; width: 100%; height: 100%; }
|
||||
.feed-media-track { display: flex; width: 100%; height: 100%; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; scrollbar-width: none; }
|
||||
.feed-media-track::-webkit-scrollbar { display: none; }
|
||||
.feed-media-slide { flex: 0 0 100%; width: 100%; height: 100%; scroll-snap-align: start; }
|
||||
.feed-media-slide > a, .feed-media-slide img, .feed-media-slide video { display: block; width: 100%; height: 100%; }
|
||||
.feed-media-slide img, .feed-media-slide video { object-fit: cover; }
|
||||
.feed-media-count { position: absolute; top: 10px; right: 10px; z-index: 2; padding: 4px 8px; border-radius: 999px; background: rgba(0,0,0,.68); color: #fff; font-size: 12px; line-height: 1; pointer-events: none; }
|
||||
|
||||
.pagination { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; min-height: 90px; font-size: 15px; }
|
||||
.pagination > :last-child { justify-self: end; }
|
||||
.pagination span { color: var(--faint); }
|
||||
.pagination .older-posts { grid-column: 3; justify-self: end; }
|
||||
.pagination .newer-posts { grid-column: 1; }
|
||||
.pagination .page-number { grid-column: 2; grid-row: 1; }
|
||||
|
||||
.home-seo {
|
||||
max-width: 960px;
|
||||
margin: 54px auto 0;
|
||||
padding: 40px 0 0;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
}
|
||||
.home-seo h2 { margin: 0 0 24px; font-size: 34px; line-height: 1.15; }
|
||||
.home-seo p { margin: 0 0 18px; color: var(--muted); font-size: 17px; line-height: 1.7; }
|
||||
.home-seo p:last-child { margin-bottom: 0; }
|
||||
|
||||
.taxonomy-heading { padding-bottom: 34px; }
|
||||
.taxonomy-heading > p { margin: 0 0 10px; color: var(--olive); font-size: 15px; text-transform: uppercase; }
|
||||
.taxonomy-heading h1 { font-size: 56px; }
|
||||
|
||||
.article-page { padding-top: 56px; }
|
||||
.article-header { max-width: 960px; margin: 0 auto 42px; }
|
||||
.article-breadcrumbs { display: flex; gap: 12px; margin-bottom: 28px; color: var(--muted); font-size: 14px; }
|
||||
.article-breadcrumbs span { color: var(--faint); }
|
||||
.article-header h1 { margin: 0; font-size: 58px; line-height: 1.06; }
|
||||
.article-lead { max-width: 850px; margin: 26px 0 0; color: var(--muted); font-size: 23px; line-height: 1.45; }
|
||||
.article-byline { display: flex; gap: 24px; margin-top: 28px; color: var(--muted); font-size: 14px; }
|
||||
.article-byline a { color: var(--red); }
|
||||
.article-media { display: grid; gap: 12px; margin-bottom: 46px; }
|
||||
.article-media figure { margin: 0; background: var(--surface); }
|
||||
.article-media img, .article-media video { width: 100%; max-height: 760px; object-fit: contain; }
|
||||
.article-layout { display: grid; grid-template-columns: minmax(0, 760px) 260px; justify-content: center; gap: 78px; }
|
||||
.article-body { font-size: 21px; line-height: 1.7; }
|
||||
.article-body p { margin: 0 0 26px; }
|
||||
.gh-content > * { max-width: 100%; }
|
||||
.gh-content a { color: var(--olive); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.gh-content figure, .gh-content .kg-card { margin: 32px 0; }
|
||||
.gh-content img, .gh-content video { width: 100%; height: auto; }
|
||||
.gh-content blockquote { margin: 30px 0; padding-left: 24px; border-left: 3px solid var(--olive); color: var(--muted); }
|
||||
.kg-width-wide, .kg-width-full { width: 100%; }
|
||||
.kg-gallery-container { display: flex; flex-direction: column; gap: 10px; }
|
||||
.kg-gallery-row { display: flex; gap: 10px; }
|
||||
.kg-gallery-image { flex: 1 1 0; min-width: 0; margin: 0; overflow: hidden; background: var(--surface); }
|
||||
.kg-gallery-image img { width: 100%; height: 100%; margin: 0; object-fit: cover; cursor: zoom-in; }
|
||||
.media-lightbox { width: 100%; height: 100%; max-width: none; max-height: none; padding: 0; border: 0; background: rgba(0,0,0,.94); }
|
||||
.media-lightbox::backdrop { background: rgba(0,0,0,.94); }
|
||||
.media-lightbox img { width: 100%; height: 100%; object-fit: contain; }
|
||||
.media-lightbox button { position: fixed; top: 18px; right: 18px; z-index: 2; width: 44px; height: 44px; border: 1px solid var(--border-strong); background: var(--bg); color: var(--text); font-size: 28px; cursor: pointer; }
|
||||
.article-facts { align-self: start; border-top: 2px solid var(--olive); }
|
||||
.article-facts > div { padding: 18px 0; border-bottom: 1px solid var(--border); }
|
||||
.article-facts span { display: block; margin-bottom: 4px; color: var(--faint); font-size: 13px; text-transform: uppercase; }
|
||||
.article-facts a { font-size: 17px; }
|
||||
.article-facts .source-link { display: block; margin-top: 22px; color: var(--muted); font-size: 14px; }
|
||||
.social-links { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 13px; }
|
||||
.social-link { display: inline-flex; min-height: 34px; align-items: center; gap: 7px; padding: 5px 10px; border: 1px solid var(--border); color: var(--muted); font-size: 14px; line-height: 1; transition: border-color .18s ease, color .18s ease, background .18s ease; }
|
||||
.social-link:hover { border-color: var(--border-strong); background: var(--surface-strong); color: var(--text); }
|
||||
.social-link svg { width: 18px; height: 18px; fill: currentColor; }
|
||||
.social-link--vk:hover { color: #7ca8e8; }
|
||||
.social-link--tg:hover { color: #58b9e8; }
|
||||
.social-links--compact { gap: 4px; margin-top: 0; }
|
||||
.social-links--compact .social-link { width: 34px; min-height: 34px; justify-content: center; padding: 0; }
|
||||
.social-links--compact .social-link svg { width: 17px; height: 17px; }
|
||||
.about-social { margin-top: 42px; padding-top: 24px; border-top: 1px solid var(--border-strong); }
|
||||
.about-social > span { color: var(--faint); font-size: 13px; text-transform: uppercase; }
|
||||
.related-section { margin-top: 90px; border-top: 1px solid var(--border-strong); }
|
||||
.related-section > h2 { margin: 0; padding: 34px 0 18px; font-size: 32px; }
|
||||
|
||||
.empty-state, .not-found { min-height: 56vh; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
|
||||
.empty-state h2, .not-found h1 { margin: 0; font-size: 42px; }
|
||||
.empty-state p { color: var(--muted); }
|
||||
.empty-state a { margin-top: 16px; padding: 10px 15px; }
|
||||
.not-found > p { margin: 0; color: var(--red); font-size: 18px; }
|
||||
.not-found a { margin-top: 24px; color: var(--olive); }
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-top: 84px;
|
||||
padding: 34px 0 46px;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer-brand { color: var(--text); font-size: 20px; font-weight: 700; }
|
||||
.site-footer p { margin: 7px 0 0; }
|
||||
.site-footer nav { display: flex; gap: 24px; }
|
||||
.site-footer .social-links { margin-top: 18px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header-inner { grid-template-columns: 150px 1fr auto; gap: 16px; }
|
||||
.main-nav { gap: 22px; }
|
||||
.main-nav a { font-size: 14px; }
|
||||
.page-heading h1 { font-size: 56px; }
|
||||
.lead-story { grid-template-columns: 1.1fr .9fr; min-height: 420px; }
|
||||
.lead-media { min-height: 420px; }
|
||||
.lead-copy { padding-left: 30px; }
|
||||
.lead-copy h2 { font-size: 33px; }
|
||||
.feed-row { grid-template-columns: 290px minmax(0, 1fr); gap: 26px; }
|
||||
.article-layout { grid-template-columns: minmax(0, 680px) 220px; gap: 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
:root { --gutter: 16px; }
|
||||
body { font-size: 17px; }
|
||||
.home-seo { margin-top: 38px; padding-top: 30px; }
|
||||
.home-seo h2 { font-size: 28px; }
|
||||
.home-seo p { font-size: 16px; line-height: 1.65; }
|
||||
.header-inner { min-height: 64px; grid-template-columns: 1fr auto; }
|
||||
.brand img { width: 38px; height: 38px; }
|
||||
.main-nav {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: none;
|
||||
padding: 12px var(--gutter) 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-nav.is-open { display: grid; }
|
||||
.main-nav a { min-height: 44px; border-bottom: 1px solid var(--border); font-size: 16px; }
|
||||
.nav-dropdown { display: block; }
|
||||
.nav-dropdown-toggle { width: 100%; min-height: 44px; border-bottom: 1px solid var(--border); text-align: left; font-size: 16px; }
|
||||
.category-menu { position: static; width: 100%; grid-template-columns: 1fr; padding: 0 0 8px 14px; border: 0; box-shadow: none; transform: none; }
|
||||
.category-menu a { font-size: 15px; }
|
||||
.main-nav a[aria-current="page"]::after { display: none; }
|
||||
.menu-button { display: grid; }
|
||||
.page-heading { padding: 38px 0 30px; }
|
||||
.page-heading h1, .taxonomy-heading h1 { font-size: 44px; line-height: 1; }
|
||||
.lead-story { display: block; min-height: 0; max-height: none; }
|
||||
.lead-media { min-height: 0; max-height: min(72vh, 560px); aspect-ratio: 4 / 3; }
|
||||
.lead-copy { padding: 25px 0 34px; }
|
||||
.lead-copy h2 { margin: 20px 0 15px; font-size: 32px; }
|
||||
.lead-copy > p { font-size: 18px; }
|
||||
.feed-row { display: block; padding: 24px 0; }
|
||||
.feed-row-media { width: 100%; max-height: min(72vh, 560px); aspect-ratio: 4 / 3; }
|
||||
.feed-row-copy { padding-top: 16px; }
|
||||
.feed-row h2 { margin: 10px 0 8px; font-size: 24px; }
|
||||
.feed-row p { display: block; font-size: 16px; }
|
||||
.preview-social { margin-top: 10px; }
|
||||
.preview-social .social-link { width: 30px; min-height: 30px; }
|
||||
.article-meta { font-size: 12px; }
|
||||
.article-meta time { display: none; }
|
||||
.article-meta > * + *::before { height: 14px; margin: 0 10px; }
|
||||
.article-meta .category-link::before { display: none; }
|
||||
.article-header { margin-bottom: 30px; }
|
||||
.article-header h1 { font-size: 42px; }
|
||||
.article-lead { font-size: 20px; }
|
||||
.article-layout { display: block; }
|
||||
.article-body { font-size: 19px; }
|
||||
.article-facts { margin-top: 42px; }
|
||||
.related-section { margin-top: 64px; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.feed-row h2 { font-size: 22px; line-height: 1.2; }
|
||||
.article-meta .producer-link::before { display: none; }
|
||||
.article-meta .category-link { display: none; }
|
||||
.article-header h1 { font-size: 36px; }
|
||||
.article-byline { display: grid; gap: 7px; }
|
||||
.site-footer { display: grid; gap: 24px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { transition-duration: .01ms !important; }
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,86 @@
|
||||
(() => {
|
||||
document.querySelectorAll('[data-feed-media]').forEach((container) => {
|
||||
const source = container.querySelector('.feed-media-source');
|
||||
const feature = container.querySelector('.feed-media-feature');
|
||||
if (!source || !feature) return;
|
||||
|
||||
const videos = [...source.querySelectorAll('video')].map((item) => {
|
||||
const video = item.cloneNode(true);
|
||||
video.removeAttribute('poster');
|
||||
video.controls = true;
|
||||
video.playsInline = true;
|
||||
video.preload = 'metadata';
|
||||
return video;
|
||||
});
|
||||
const images = [...source.querySelectorAll('.kg-gallery-card img, .kg-image-card img')].map((item) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = item.currentSrc || item.src;
|
||||
link.setAttribute('aria-label', item.alt ? `Открыть: ${item.alt}` : 'Открыть изображение');
|
||||
link.appendChild(item.cloneNode(true));
|
||||
return link;
|
||||
});
|
||||
|
||||
const items = videos.length ? [...videos, ...images] : [feature, ...images];
|
||||
if (items.length === 1 && !videos.length) return;
|
||||
const track = document.createElement('div');
|
||||
track.className = 'feed-media-track';
|
||||
items.forEach((item) => {
|
||||
const slide = document.createElement('div');
|
||||
slide.className = 'feed-media-slide';
|
||||
slide.appendChild(item);
|
||||
track.appendChild(slide);
|
||||
});
|
||||
container.replaceChildren(track, source);
|
||||
if (items.length > 1) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'feed-media-count';
|
||||
count.textContent = `1 / ${items.length}`;
|
||||
container.appendChild(count);
|
||||
track.addEventListener('scroll', () => {
|
||||
const index = Math.round(track.scrollLeft / Math.max(track.clientWidth, 1));
|
||||
count.textContent = `${Math.min(index + 1, items.length)} / ${items.length}`;
|
||||
}, { passive: true });
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const button = document.querySelector('[data-menu-toggle]');
|
||||
const nav = document.querySelector('#main-nav');
|
||||
if (!button || !nav) return;
|
||||
button.addEventListener('click', () => {
|
||||
const open = nav.classList.toggle('is-open');
|
||||
button.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const toggle = document.querySelector('[data-category-toggle]');
|
||||
const menu = document.querySelector('[data-category-menu]');
|
||||
if (!toggle || !menu) return;
|
||||
const close = () => { menu.classList.remove('is-open'); toggle.setAttribute('aria-expanded', 'false'); };
|
||||
toggle.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const open = menu.classList.toggle('is-open');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
});
|
||||
document.addEventListener('click', (event) => { if (!menu.contains(event.target)) close(); });
|
||||
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') close(); });
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const images = document.querySelectorAll('.gh-content .kg-gallery-image img');
|
||||
if (!images.length || !('HTMLDialogElement' in window)) return;
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.className = 'media-lightbox';
|
||||
dialog.innerHTML = '<button type="button" aria-label="Закрыть">×</button><img alt="">';
|
||||
const preview = dialog.querySelector('img');
|
||||
dialog.querySelector('button').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', (event) => { if (event.target === dialog) dialog.close(); });
|
||||
document.body.appendChild(dialog);
|
||||
images.forEach((image) => image.addEventListener('click', () => {
|
||||
preview.src = image.currentSrc || image.src;
|
||||
preview.alt = image.alt || '';
|
||||
dialog.showModal();
|
||||
}));
|
||||
})();
|
||||
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Google Tag Manager -->
|
||||
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-MM4FR8LJ');</script>
|
||||
<!-- End Google Tag Manager -->
|
||||
{{#is "home"}}
|
||||
<title>Forma N8 — все новости снаряжения в одном месте</title>
|
||||
{{else}}
|
||||
{{#is "post"}}
|
||||
{{#post}}<title>Forma N8 — {{title}}</title>{{/post}}
|
||||
{{else}}
|
||||
{{#is "tag"}}
|
||||
{{#tag}}<title>Forma N8 — {{name}}</title>{{/tag}}
|
||||
{{else}}
|
||||
<title>Forma N8 — {{meta_title}}</title>
|
||||
{{/is}}
|
||||
{{/is}}
|
||||
{{/is}}
|
||||
<link rel="icon" type="image/png" href="{{asset "images/favi.png"}}">
|
||||
<link rel="stylesheet" href="{{asset "css/screen.css"}}">
|
||||
{{ghost_head}}
|
||||
</head>
|
||||
<body class="{{body_class}}">
|
||||
<!-- Google Tag Manager (noscript) -->
|
||||
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-MM4FR8LJ"
|
||||
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
|
||||
<!-- End Google Tag Manager (noscript) -->
|
||||
<a class="skip-link" href="#content">К содержанию</a>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<a class="brand" href="{{@site.url}}" aria-label="Forma N8, главная">
|
||||
<img src="{{asset "images/favi.png"}}" width="42" height="42" alt="">
|
||||
<span>FORMA<br>N8</span>
|
||||
</a>
|
||||
<nav class="main-nav" id="main-nav" aria-label="Основная навигация">
|
||||
<a href="{{@site.url}}">Лента</a>
|
||||
<div class="nav-dropdown">
|
||||
<button type="button" class="nav-dropdown-toggle" data-category-toggle aria-expanded="false">Категории <span class="nav-dropdown-icon" aria-hidden="true"></span></button>
|
||||
<div class="category-menu" data-category-menu>
|
||||
{{#get "tags" filter="visibility:public" limit="100" order="name asc"}}
|
||||
{{#foreach tags}}<a href="{{url}}">{{name}}</a>{{/foreach}}
|
||||
{{/get}}
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{@site.url}}/about/">О проекте</a>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
{{> "social-links" compact=true}}
|
||||
<button class="icon-button" type="button" data-ghost-search aria-label="Открыть поиск" title="Поиск">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"></circle><path d="m16 16 4 4"></path></svg>
|
||||
</button>
|
||||
<button class="icon-button menu-button" type="button" data-menu-toggle aria-expanded="false" aria-controls="main-nav" aria-label="Открыть меню" title="Меню">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main id="content">{{{body}}}</main>
|
||||
<footer class="site-footer">
|
||||
<div><a class="footer-brand" href="{{@site.url}}">FORMA N8</a><p>Новости снаряжения и экипировки.</p>{{> "social-links"}}</div>
|
||||
<nav aria-label="Служебная навигация"><a href="{{@site.url}}/rss/">RSS</a><a href="{{@site.url}}/sitemap.xml">Карта сайта</a></nav>
|
||||
</footer>
|
||||
<script src="{{asset "js/site.js"}}" defer></script>
|
||||
<script>
|
||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}
|
||||
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||
(window,document,"script","https://mc.yandex.ru/metrika/tag.js","ym");
|
||||
ym(110060335,"init",{clickmap:true,trackLinks:true,accurateTrackBounce:true,webvisor:true});
|
||||
</script>
|
||||
<noscript><div><img src="https://mc.yandex.ru/watch/110060335" style="position:absolute;left:-9999px" alt=""></div></noscript>
|
||||
{{ghost_foot}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,2 @@
|
||||
{{!< default}}
|
||||
<section class="feed-shell not-found"><p>{{statusCode}}</p><h1>{{message}}</h1><a href="{{@site.url}}">Вернуться в ленту</a></section>
|
||||
@@ -0,0 +1,38 @@
|
||||
{{!< default}}
|
||||
<section class="feed-shell">
|
||||
<header class="page-heading"><h1>Новости тактического снаряжения и экипировки</h1></header>
|
||||
{{#foreach posts}}
|
||||
{{#if @first}}
|
||||
{{#is "home"}}
|
||||
<article class="lead-story">
|
||||
<div class="lead-media feed-media" data-feed-media>
|
||||
<a class="feed-media-feature" href="{{url}}">{{#if feature_image}}<img src="{{img_url feature_image size="xl"}}" alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}">{{else}}<span class="media-placeholder"><img src="{{asset "images/favi.png"}}" alt=""></span>{{/if}}</a>
|
||||
<div class="feed-media-source" hidden>{{content}}</div>
|
||||
</div>
|
||||
<div class="lead-copy">
|
||||
<div class="article-meta">{{#if primary_tag}}<a class="category-link" href="{{primary_tag.url}}">{{primary_tag.name}}</a>{{/if}}<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD.MM.YYYY"}}</time></div>
|
||||
<h2><a href="{{url}}">{{title}}</a></h2>
|
||||
<p>{{excerpt words="42"}}</p>
|
||||
<a class="read-link" href="{{url}}">Читать новость <span aria-hidden="true">→</span></a>
|
||||
<div class="preview-social">{{> "social-links" compact=true}}</div>
|
||||
</div>
|
||||
</article>
|
||||
{{else}}
|
||||
{{> "post-row"}}
|
||||
{{/is}}
|
||||
{{else}}
|
||||
{{> "post-row"}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<div class="empty-state"><h2>Публикаций пока нет</h2><p>Лента скоро наполнится новостями.</p></div>
|
||||
{{/foreach}}
|
||||
{{> "pagination-ru"}}
|
||||
{{#is "home"}}
|
||||
<section class="home-seo" aria-labelledby="home-seo-title">
|
||||
<h2 id="home-seo-title">О новостной ленте Forma N8</h2>
|
||||
<p>Forma N8 — лента новостей о тактическом снаряжении, экипировке и EDC. Мы собираем новинки с десятков источников и публикуем в одном месте: плитоносцы и бронезащита, подсумки и разгрузочные системы, боевые брюки и рубашки, прицелы и оптика, тактические фонари, средства связи, медицина и ночные приборы.</p>
|
||||
<p>В ленте — новинки российских и зарубежных производителей: Ars Arma, Wartech, SSO, Stich Profi, Giena Tactical, 5.45 Design, Red Cliff, VOIN, Sturmer, БАРС, SRVV, ANA Tactical, Mordor Tac, Centurion Gear, ENOTACTICAL, Black Buckle, MILGEAR, SKIF Armor, Atlant Armour, БронеЁж, R5 GEAR, VINDEX, JAEGER Equipment, Margul Gear, Gordeev Tactical, IGLA TAC, STRIX TAC, Колчуга, APEX, Пересвет, Гарсинг и многих других. Отслеживаем оптику Диполь, ARTELV и ARKON, гарнитуры VULCAN, Fidem и ОКБ Октава, фонари Nicron, патчи BAD GRINGO и RedWar Patch, ножи DAGGERR, Morpheus Knives и N.C.Custom.</p>
|
||||
<p>Темы ленты охватывают весь комплект тактической экипировки: системы MOLLE и Molle-Minus, плиты стандартов Гранит, SAPI и ESAPI, ткани Cordura и NYCO, расцветки Multicam, Tiger Stripe и EMR. Новинки магазинов Академия Снаряжения, Semper Fi, ФИНИСТ и Империя ножей, обновления линеек, цены и отзывы пользователей — всё в одной новостной ленте о снаряжении.</p>
|
||||
</section>
|
||||
{{/is}}
|
||||
</section>
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "forma-n8",
|
||||
"description": "Strict editorial theme for Forma N8",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Forma N8",
|
||||
"email": "admin@f-n8.ru"
|
||||
},
|
||||
"keywords": ["ghost-theme", "forma-n8", "news"],
|
||||
"engines": {
|
||||
"ghost": ">=6.0.0"
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"config": {
|
||||
"posts_per_page": 25,
|
||||
"card_assets": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{{!< default}}
|
||||
{{#post}}<article class="article-page">{{#if @page.show_title_and_feature_image}}<header class="article-header"><h1>{{title}}</h1>{{#if custom_excerpt}}<p class="article-lead">{{custom_excerpt}}</p>{{/if}}</header>{{#if feature_image}}<figure class="article-media"><img src="{{img_url feature_image size="xl"}}" alt="{{title}}"></figure>{{/if}}{{/if}}<div class="article-layout"><div class="article-body gh-content">{{content}}<aside class="about-social"><span>Forma N8 в социальных сетях</span>{{> "social-links"}}</aside></div></div></article>{{/post}}
|
||||
@@ -0,0 +1,9 @@
|
||||
{{#if pagination.pages}}
|
||||
{{#match pagination.pages ">" 1}}
|
||||
<nav class="pagination" role="navigation" aria-label="Пагинация">
|
||||
{{#if pagination.prev}}<a class="newer-posts" href="{{page_url pagination.prev}}">← Новые публикации</a>{{/if}}
|
||||
<span class="page-number">Страница {{pagination.page}} из {{pagination.pages}}</span>
|
||||
{{#if pagination.next}}<a class="older-posts" href="{{page_url pagination.next}}">Старые публикации →</a>{{/if}}
|
||||
</nav>
|
||||
{{/match}}
|
||||
{{/if}}
|
||||
@@ -0,0 +1,12 @@
|
||||
<article class="feed-row">
|
||||
<div class="feed-row-media feed-media" data-feed-media>
|
||||
<a class="feed-media-feature" href="{{url}}">{{#if feature_image}}<img src="{{img_url feature_image size="m"}}" alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}" loading="lazy">{{else}}<span class="media-placeholder"><img src="{{asset "images/favi.png"}}" alt=""></span>{{/if}}</a>
|
||||
<div class="feed-media-source" hidden>{{content}}</div>
|
||||
</div>
|
||||
<div class="feed-row-copy">
|
||||
<div class="article-meta">{{#if primary_tag}}<a class="category-link" href="{{primary_tag.url}}">{{primary_tag.name}}</a>{{/if}}<time datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD.MM.YYYY"}}</time></div>
|
||||
<h2><a href="{{url}}">{{title}}</a></h2>
|
||||
<p>{{excerpt words="28"}}</p>
|
||||
<div class="preview-social">{{> "social-links" compact=true}}</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="social-links{{#if compact}} social-links--compact{{/if}}" aria-label="Forma N8 в социальных сетях">
|
||||
<a class="social-link social-link--vk" href="https://vk.com/forma_n8" target="_blank" rel="noopener noreferrer" aria-label="Forma N8 во ВКонтакте" title="ВКонтакте">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12.8 17.6C5.4 17.6 1.2 12.5 1 4h3.7c.1 6.2 2.9 8.8 5.1 9.3V4h3.5v5.3c2.2-.2 4.5-2.6 5.3-5.3H22c-.6 3.3-3.1 5.7-4.9 6.7 1.8.8 4.7 2.9 5.8 6.9h-3.8c-.9-2.6-3.2-4.6-5.8-4.9v4.9h-.5Z"/></svg>
|
||||
{{#unless compact}}<span>ВКонтакте</span>{{/unless}}
|
||||
</a>
|
||||
<a class="social-link social-link--tg" href="https://t.me/forma_n8" target="_blank" rel="noopener noreferrer" aria-label="Forma N8 в Telegram" title="Telegram">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21.7 3.4 18.5 20.5c-.2 1.2-.9 1.5-1.9.9l-4.9-3.6-2.4 2.3c-.3.3-.5.5-1 .5l.3-5 9.1-8.2c.4-.4-.1-.6-.6-.2L5.9 14.3 1.1 12.8c-1-.3-1-1 .2-1.5L20 2.6c.9-.3 1.8.2 1.7.8Z"/></svg>
|
||||
{{#unless compact}}<span>Telegram</span>{{/unless}}
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
{{!< default}}
|
||||
{{#post}}
|
||||
<article class="article-page">
|
||||
<header class="article-header">
|
||||
<div class="article-breadcrumbs"><a href="{{@site.url}}">Лента</a>{{#if primary_tag}}<span>/</span><a href="{{primary_tag.url}}">{{primary_tag.name}}</a>{{/if}}</div>
|
||||
<h1>{{title}}</h1>
|
||||
{{#if custom_excerpt}}<p class="article-lead">{{custom_excerpt}}</p>{{/if}}
|
||||
<div class="article-byline"><time datetime="{{date format="YYYY-MM-DDTHH:mm:ssZ"}}">{{date format="DD.MM.YYYY · HH:mm"}} ЕКБ</time></div>
|
||||
</header>
|
||||
{{#if feature_image}}<figure class="article-media"><img src="{{img_url feature_image size="xl"}}" alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"></figure>{{/if}}
|
||||
<div class="article-layout">
|
||||
<div class="article-body gh-content">{{content}}</div>
|
||||
<aside class="article-facts">
|
||||
{{#if primary_tag}}<div><span>Категория</span><a href="{{primary_tag.url}}">{{primary_tag.name}}</a></div>{{/if}}
|
||||
<div><span>Опубликовано</span><time datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD.MM.YYYY"}}</time></div>
|
||||
<div><span>Наши каналы</span>{{> "social-links"}}</div>
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
{{/post}}
|
||||
@@ -0,0 +1,6 @@
|
||||
{{!< default}}
|
||||
<section class="feed-shell">
|
||||
{{#tag}}<header class="page-heading taxonomy-heading"><p>Тема</p><h1>{{name}}</h1>{{#if description}}<p>{{description}}</p>{{/if}}</header>{{/tag}}
|
||||
<div class="feed-list">{{#foreach posts}}{{> "post-row"}}{{/foreach}}</div>
|
||||
{{> "pagination-ru"}}
|
||||
</section>
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
asyncpg==0.30.0
|
||||
jinja2==3.1.6
|
||||
python-multipart==0.0.20
|
||||
pydantic-settings==2.9.1
|
||||
@@ -0,0 +1,28 @@
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
conn = await asyncpg.connect(os.environ["DATABASE_URL"])
|
||||
try:
|
||||
await conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())"
|
||||
)
|
||||
for path in sorted((root / "db" / "migrations").glob("*.sql")):
|
||||
exists = await conn.fetchval("SELECT 1 FROM schema_migrations WHERE version=$1", path.name)
|
||||
if exists:
|
||||
continue
|
||||
async with conn.transaction():
|
||||
await conn.execute(path.read_text(encoding="utf-8"))
|
||||
await conn.execute("INSERT INTO schema_migrations(version) VALUES($1)", path.name)
|
||||
print(f"applied {path.name}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""Forma N8 public website."""
|
||||
@@ -0,0 +1,448 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from .config import settings
|
||||
from .db import connect, disconnect, get_pool
|
||||
from .schemas import ArticleUpsert
|
||||
from .text import excerpt, paragraphs, slugify
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
STATIC_DIR = ROOT / "static"
|
||||
TEMPLATE_DIR = ROOT / "templates"
|
||||
ALLOWED_UPLOADS = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
settings.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
await connect()
|
||||
yield
|
||||
await disconnect()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.site_name, docs_url=None, redoc_url=None, lifespan=lifespan)
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
app.mount("/media", StaticFiles(directory=settings.upload_dir, check_dir=False), name="media")
|
||||
templates = Jinja2Templates(directory=TEMPLATE_DIR)
|
||||
templates.env.filters["paragraphs"] = paragraphs
|
||||
templates.env.filters["excerpt"] = excerpt
|
||||
LOCAL_TZ = ZoneInfo("Asia/Yekaterinburg")
|
||||
templates.env.filters["ekb_date"] = lambda value: value.astimezone(LOCAL_TZ).strftime("%d.%m.%Y") if value else ""
|
||||
templates.env.filters["ekb_time"] = lambda value: value.astimezone(LOCAL_TZ).strftime("%H:%M") if value else ""
|
||||
|
||||
|
||||
def site_url(path: str = "") -> str:
|
||||
return f"{settings.base_url}{path if path.startswith('/') else '/' + path if path else ''}"
|
||||
|
||||
|
||||
def article_path(article: dict) -> str:
|
||||
return f"/news/{article['slug']}"
|
||||
|
||||
|
||||
templates.env.globals.update(
|
||||
site_name=settings.site_name,
|
||||
site_tagline=settings.site_tagline,
|
||||
site_url=site_url,
|
||||
article_path=article_path,
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
return response
|
||||
|
||||
|
||||
def base_context(request: Request, **extra):
|
||||
return {
|
||||
"request": request,
|
||||
"current_path": request.url.path,
|
||||
"canonical_url": site_url(request.url.path),
|
||||
"meta_title": f"{settings.site_tagline} — {settings.site_name}",
|
||||
"meta_description": "Новости производителей тактического снаряжения, одежды, брони и аксессуаров.",
|
||||
"noindex": not settings.site_indexing_enabled or bool(request.query_params.get("q")),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
ARTICLE_COLUMNS = """
|
||||
a.*,
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'type', m.media_type,
|
||||
'url', m.url,
|
||||
'alt', m.alt_text,
|
||||
'width', m.width,
|
||||
'height', m.height
|
||||
) ORDER BY m.sort_order, m.id
|
||||
) FILTER (WHERE m.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS media
|
||||
"""
|
||||
|
||||
|
||||
def normalize_article(row) -> dict:
|
||||
article = dict(row)
|
||||
media = article.get("media") or []
|
||||
if isinstance(media, str):
|
||||
try:
|
||||
media = json.loads(media)
|
||||
except json.JSONDecodeError:
|
||||
media = []
|
||||
article["media"] = media if isinstance(media, list) else []
|
||||
return article
|
||||
|
||||
|
||||
async def fetch_feed(*, page: int, q: str = "", category: str = "", producer: str = "", page_size: int | None = None):
|
||||
pool = await get_pool()
|
||||
where = ["a.status='published'", "a.published_at <= NOW()"]
|
||||
args: list[object] = []
|
||||
if q:
|
||||
args.append(f"%{q}%")
|
||||
where.append(f"(a.title ILIKE ${len(args)} OR a.lead ILIKE ${len(args)} OR a.body ILIKE ${len(args)} OR a.producer_name ILIKE ${len(args)})")
|
||||
if category:
|
||||
args.append(category)
|
||||
where.append(f"a.category_tag=${len(args)}")
|
||||
if producer:
|
||||
args.append(producer)
|
||||
where.append(f"a.producer_tag=${len(args)}")
|
||||
where_sql = " AND ".join(where)
|
||||
total = int(await pool.fetchval(f"SELECT COUNT(*) FROM articles a WHERE {where_sql}", *args) or 0)
|
||||
limit = max(1, min(50, page_size or settings.page_size))
|
||||
offset = (page - 1) * limit
|
||||
rows = await pool.fetch(
|
||||
f"""
|
||||
SELECT {ARTICLE_COLUMNS}
|
||||
FROM articles a
|
||||
LEFT JOIN article_media m ON m.article_id=a.id
|
||||
WHERE {where_sql}
|
||||
GROUP BY a.id
|
||||
ORDER BY a.published_at DESC, a.id DESC
|
||||
LIMIT ${len(args) + 1} OFFSET ${len(args) + 2}
|
||||
""",
|
||||
*args,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
return [normalize_article(row) for row in rows], total, limit
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def feed(request: Request, page: int = Query(1, ge=1), q: str = Query("", max_length=100)):
|
||||
articles, total, limit = await fetch_feed(page=page, q=q.strip())
|
||||
pages = max(1, (total + limit - 1) // limit)
|
||||
canonical = site_url("/") if page == 1 else site_url(f"/?page={page}")
|
||||
return templates.TemplateResponse(
|
||||
"feed.html",
|
||||
base_context(
|
||||
request,
|
||||
articles=articles,
|
||||
page=page,
|
||||
pages=pages,
|
||||
total=total,
|
||||
q=q.strip(),
|
||||
canonical_url=canonical,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/category/{tag}", response_class=HTMLResponse)
|
||||
async def category_feed(request: Request, tag: str, page: int = Query(1, ge=1)):
|
||||
pool = await get_pool()
|
||||
category_name = await pool.fetchval("SELECT name FROM categories WHERE tag=$1 AND is_active=TRUE", tag)
|
||||
if not category_name:
|
||||
raise HTTPException(status_code=404)
|
||||
articles, total, limit = await fetch_feed(page=page, category=tag)
|
||||
name = str(category_name)
|
||||
pages = max(1, (total + limit - 1) // limit)
|
||||
return templates.TemplateResponse(
|
||||
"taxonomy.html",
|
||||
base_context(
|
||||
request,
|
||||
articles=articles,
|
||||
page=page,
|
||||
pages=pages,
|
||||
heading=name,
|
||||
taxonomy_label="Категория",
|
||||
canonical_url=site_url(f"/category/{tag}" + (f"?page={page}" if page > 1 else "")),
|
||||
meta_title=f"{name}: новости — {settings.site_name}",
|
||||
meta_description=f"Последние новости в категории «{name}»: производители, новинки и обзоры снаряжения.",
|
||||
noindex=total == 0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/brand/{tag}", response_class=HTMLResponse)
|
||||
async def producer_feed(request: Request, tag: str, page: int = Query(1, ge=1)):
|
||||
articles, total, limit = await fetch_feed(page=page, producer=tag)
|
||||
if not articles and page == 1:
|
||||
raise HTTPException(status_code=404)
|
||||
name = articles[0]["producer_name"] if articles else tag
|
||||
pages = max(1, (total + limit - 1) // limit)
|
||||
return templates.TemplateResponse(
|
||||
"taxonomy.html",
|
||||
base_context(
|
||||
request,
|
||||
articles=articles,
|
||||
page=page,
|
||||
pages=pages,
|
||||
heading=name,
|
||||
taxonomy_label="Производитель",
|
||||
canonical_url=site_url(f"/brand/{tag}" + (f"?page={page}" if page > 1 else "")),
|
||||
meta_title=f"{name}: новости и новинки — {settings.site_name}",
|
||||
meta_description=f"Новости и новинки производителя {name}: снаряжение, одежда и экипировка.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@app.get("/news/{slug}", response_class=HTMLResponse)
|
||||
async def article(request: Request, slug: str):
|
||||
pool = await get_pool()
|
||||
row = await pool.fetchrow(
|
||||
f"""
|
||||
SELECT {ARTICLE_COLUMNS}
|
||||
FROM articles a
|
||||
LEFT JOIN article_media m ON m.article_id=a.id
|
||||
WHERE a.slug=$1 AND a.status='published' AND a.published_at <= NOW()
|
||||
GROUP BY a.id
|
||||
""",
|
||||
slug,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404)
|
||||
item = normalize_article(row)
|
||||
related = await pool.fetch(
|
||||
f"""
|
||||
SELECT {ARTICLE_COLUMNS}
|
||||
FROM articles a
|
||||
LEFT JOIN article_media m ON m.article_id=a.id
|
||||
WHERE a.status='published' AND a.id<>$1
|
||||
AND (a.category_tag=$2 OR a.producer_tag=$3)
|
||||
GROUP BY a.id
|
||||
ORDER BY (a.producer_tag=$3) DESC, a.published_at DESC
|
||||
LIMIT 3
|
||||
""",
|
||||
item["id"],
|
||||
item["category_tag"],
|
||||
item["producer_tag"],
|
||||
)
|
||||
canonical = site_url(article_path(item))
|
||||
image = item["media"][0]["url"] if item["media"] else site_url("/static/favi.png")
|
||||
if image.startswith("/"):
|
||||
image = site_url(image)
|
||||
json_ld = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "NewsArticle",
|
||||
"headline": item["title"],
|
||||
"description": item["seo_description"] or item["lead"] or excerpt(item["body"]),
|
||||
"datePublished": item["published_at"].isoformat(),
|
||||
"dateModified": item["updated_at"].isoformat(),
|
||||
"mainEntityOfPage": canonical,
|
||||
"image": [image],
|
||||
"author": {"@type": "Organization", "name": settings.site_name},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": settings.site_name,
|
||||
"logo": {"@type": "ImageObject", "url": site_url("/static/favi.png")},
|
||||
},
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
"article.html",
|
||||
base_context(
|
||||
request,
|
||||
article=item,
|
||||
related=[normalize_article(x) for x in related],
|
||||
canonical_url=canonical,
|
||||
meta_title=item["seo_title"] or f"{item['title']} — {settings.site_name}",
|
||||
meta_description=item["seo_description"] or item["lead"] or excerpt(item["body"]),
|
||||
og_image=image,
|
||||
json_ld=json.dumps(json_ld, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def require_api_token(x_forma_token: str = Header(default="")) -> None:
|
||||
if not settings.publish_api_token or not hmac.compare_digest(x_forma_token, settings.publish_api_token):
|
||||
raise HTTPException(status_code=401, detail="invalid token")
|
||||
|
||||
|
||||
@app.post("/api/v1/media", dependencies=[Depends(require_api_token)])
|
||||
async def upload_media(file: UploadFile = File(...)):
|
||||
content_type = (file.content_type or "").split(";", 1)[0].lower()
|
||||
if content_type not in ALLOWED_UPLOADS:
|
||||
raise HTTPException(status_code=415, detail="unsupported media type")
|
||||
data = await file.read(50 * 1024 * 1024 + 1)
|
||||
if len(data) > 50 * 1024 * 1024:
|
||||
raise HTTPException(status_code=413, detail="file too large")
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
suffix = ALLOWED_UPLOADS[content_type]
|
||||
target = settings.upload_dir / digest[:2] / f"{digest}{suffix}"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not target.exists():
|
||||
target.write_bytes(data)
|
||||
return {"url": f"/media/{digest[:2]}/{target.name}", "sha256": digest, "content_type": content_type}
|
||||
|
||||
|
||||
@app.post("/api/v1/articles", dependencies=[Depends(require_api_token)])
|
||||
async def upsert_article(payload: ArticleUpsert):
|
||||
pool = await get_pool()
|
||||
existing = await pool.fetchrow("SELECT id, slug, published_at FROM articles WHERE external_id=$1", payload.external_id)
|
||||
requested_slug = slugify(payload.slug or payload.title)
|
||||
stable_slug = existing["slug"] if existing else f"{requested_slug}-{slugify(payload.external_id)[-24:]}"
|
||||
published_at = payload.published_at or datetime.now(timezone.utc)
|
||||
category_tag = slugify(payload.category_tag)
|
||||
category_name = await pool.fetchval("SELECT name FROM categories WHERE tag=$1 AND is_active=TRUE", category_tag)
|
||||
category_name = str(category_name or payload.category_name).strip()
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO articles(
|
||||
external_id, slug, title, lead, body, category_name, category_tag,
|
||||
producer_name, producer_tag, source_url, seo_title, seo_description,
|
||||
status, published_at
|
||||
)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
|
||||
ON CONFLICT(external_id) DO UPDATE SET
|
||||
title=EXCLUDED.title,
|
||||
lead=EXCLUDED.lead,
|
||||
body=EXCLUDED.body,
|
||||
category_name=EXCLUDED.category_name,
|
||||
category_tag=EXCLUDED.category_tag,
|
||||
producer_name=EXCLUDED.producer_name,
|
||||
producer_tag=EXCLUDED.producer_tag,
|
||||
source_url=EXCLUDED.source_url,
|
||||
seo_title=EXCLUDED.seo_title,
|
||||
seo_description=EXCLUDED.seo_description,
|
||||
status=EXCLUDED.status,
|
||||
published_at=COALESCE(articles.published_at, EXCLUDED.published_at),
|
||||
updated_at=NOW()
|
||||
RETURNING id, slug, status, published_at, updated_at
|
||||
""",
|
||||
payload.external_id,
|
||||
stable_slug,
|
||||
payload.title.strip(),
|
||||
payload.lead.strip(),
|
||||
payload.body.strip(),
|
||||
category_name,
|
||||
category_tag,
|
||||
payload.producer_name.strip(),
|
||||
slugify(payload.producer_tag),
|
||||
str(payload.source_url) if payload.source_url else None,
|
||||
(payload.seo_title or payload.title).strip(),
|
||||
(payload.seo_description or payload.lead or excerpt(payload.body)).strip(),
|
||||
payload.status,
|
||||
published_at,
|
||||
)
|
||||
await conn.execute("DELETE FROM article_media WHERE article_id=$1", row["id"])
|
||||
for index, media in enumerate(payload.media):
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO article_media(article_id, media_type, url, alt_text, width, height, sort_order)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7)
|
||||
""",
|
||||
row["id"], media.type, media.url, media.alt or payload.title,
|
||||
media.width, media.height, media.sort_order or index,
|
||||
)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"external_id": payload.external_id,
|
||||
"status": row["status"],
|
||||
"url": site_url(f"/news/{row['slug']}") if row["status"] == "published" else None,
|
||||
"published_at": row["published_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/rss.xml")
|
||||
async def rss():
|
||||
articles, _, _ = await fetch_feed(page=1, page_size=50)
|
||||
root = ET.Element("rss", {"version": "2.0", "xmlns:media": "http://search.yahoo.com/mrss/"})
|
||||
channel = ET.SubElement(root, "channel")
|
||||
ET.SubElement(channel, "title").text = f"{settings.site_name} — {settings.site_tagline}"
|
||||
ET.SubElement(channel, "link").text = settings.base_url
|
||||
ET.SubElement(channel, "description").text = "Новости производителей тактического снаряжения и экипировки."
|
||||
ET.SubElement(channel, "language").text = "ru"
|
||||
for item in articles[:50]:
|
||||
node = ET.SubElement(channel, "item")
|
||||
url = site_url(article_path(item))
|
||||
ET.SubElement(node, "title").text = item["title"]
|
||||
ET.SubElement(node, "link").text = url
|
||||
ET.SubElement(node, "guid", {"isPermaLink": "true"}).text = url
|
||||
ET.SubElement(node, "description").text = item["lead"] or excerpt(item["body"])
|
||||
ET.SubElement(node, "pubDate").text = item["published_at"].strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
ET.SubElement(node, "category").text = item["category_name"]
|
||||
if item["media"]:
|
||||
ET.SubElement(node, "media:content", {"url": site_url(item["media"][0]["url"]) if item["media"][0]["url"].startswith("/") else item["media"][0]["url"], "medium": "image"})
|
||||
return Response(ET.tostring(root, encoding="utf-8", xml_declaration=True), media_type="application/rss+xml")
|
||||
|
||||
|
||||
@app.get("/sitemap.xml")
|
||||
async def sitemap():
|
||||
pool = await get_pool()
|
||||
rows = await pool.fetch("SELECT slug, updated_at FROM articles WHERE status='published' ORDER BY id")
|
||||
categories = await pool.fetch("SELECT category_tag AS tag, MAX(updated_at) AS updated_at FROM articles WHERE status='published' GROUP BY category_tag")
|
||||
producers = await pool.fetch("SELECT producer_tag AS tag, MAX(updated_at) AS updated_at FROM articles WHERE status='published' GROUP BY producer_tag")
|
||||
root = ET.Element("urlset", {"xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9"})
|
||||
entries = [(site_url("/"), None)]
|
||||
entries += [(site_url(f"/category/{row['tag']}"), row["updated_at"]) for row in categories]
|
||||
entries += [(site_url(f"/brand/{row['tag']}"), row["updated_at"]) for row in producers]
|
||||
entries += [(site_url(f"/news/{row['slug']}"), row["updated_at"]) for row in rows]
|
||||
for loc, updated in entries:
|
||||
node = ET.SubElement(root, "url")
|
||||
ET.SubElement(node, "loc").text = loc
|
||||
if updated:
|
||||
ET.SubElement(node, "lastmod").text = updated.date().isoformat()
|
||||
return Response(ET.tostring(root, encoding="utf-8", xml_declaration=True), media_type="application/xml")
|
||||
|
||||
|
||||
@app.get("/robots.txt", response_class=PlainTextResponse)
|
||||
async def robots():
|
||||
if not settings.site_indexing_enabled:
|
||||
return "User-agent: *\nDisallow: /\n"
|
||||
return f"User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: {site_url('/sitemap.xml')}\n"
|
||||
|
||||
|
||||
@app.get("/favicon.ico", include_in_schema=False)
|
||||
async def favicon():
|
||||
return FileResponse(STATIC_DIR / "favi.png", media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
pool = await get_pool()
|
||||
await pool.fetchval("SELECT 1")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found(request: Request, _):
|
||||
return templates.TemplateResponse(
|
||||
"404.html",
|
||||
base_context(request, meta_title=f"Страница не найдена — {settings.site_name}", noindex=True),
|
||||
status_code=404,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql://forma_n8:forma_n8@127.0.0.1:5432/forma_n8"
|
||||
site_base_url: str = "http://127.0.0.1:8090"
|
||||
site_name: str = "Forma N8"
|
||||
site_tagline: str = "Новости снаряжения"
|
||||
site_indexing_enabled: bool = False
|
||||
publish_api_token: str = ""
|
||||
upload_dir: Path = Path("/var/lib/forma-n8/uploads")
|
||||
page_size: int = 12
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return self.site_base_url.rstrip("/")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,24 @@
|
||||
import asyncpg
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
pool: asyncpg.Pool | None = None
|
||||
|
||||
|
||||
async def connect() -> asyncpg.Pool:
|
||||
global pool
|
||||
if pool is None:
|
||||
pool = await asyncpg.create_pool(settings.database_url, min_size=1, max_size=10, command_timeout=30)
|
||||
return pool
|
||||
|
||||
|
||||
async def disconnect() -> None:
|
||||
global pool
|
||||
if pool is not None:
|
||||
await pool.close()
|
||||
pool = None
|
||||
|
||||
|
||||
async def get_pool() -> asyncpg.Pool:
|
||||
return pool or await connect()
|
||||
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
|
||||
|
||||
class MediaInput(BaseModel):
|
||||
type: Literal["image", "video"] = "image"
|
||||
url: str = Field(min_length=1, max_length=2000)
|
||||
alt: str = Field(default="", max_length=500)
|
||||
width: int | None = Field(default=None, ge=1)
|
||||
height: int | None = Field(default=None, ge=1)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ArticleUpsert(BaseModel):
|
||||
external_id: str = Field(min_length=1, max_length=160)
|
||||
slug: str | None = Field(default=None, max_length=220)
|
||||
title: str = Field(min_length=5, max_length=300)
|
||||
lead: str = Field(default="", max_length=1000)
|
||||
body: str = Field(min_length=20, max_length=50000)
|
||||
category_name: str = Field(min_length=1, max_length=120)
|
||||
category_tag: str = Field(min_length=1, max_length=120)
|
||||
producer_name: str = Field(min_length=1, max_length=200)
|
||||
producer_tag: str = Field(min_length=1, max_length=160)
|
||||
source_url: HttpUrl | None = None
|
||||
seo_title: str | None = Field(default=None, max_length=300)
|
||||
seo_description: str | None = Field(default=None, max_length=500)
|
||||
status: Literal["draft", "published"] = "published"
|
||||
published_at: datetime | None = None
|
||||
media: list[MediaInput] = Field(default_factory=list, max_length=20)
|
||||
@@ -0,0 +1,37 @@
|
||||
import re
|
||||
from html import escape
|
||||
|
||||
from markupsafe import Markup
|
||||
|
||||
|
||||
TRANSLIT = str.maketrans(
|
||||
{
|
||||
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e",
|
||||
"ж": "zh", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m",
|
||||
"н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u",
|
||||
"ф": "f", "х": "h", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sch",
|
||||
"ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = value.strip().lower().translate(TRANSLIT)
|
||||
value = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
|
||||
return value[:180] or "article"
|
||||
|
||||
|
||||
def paragraphs(value: str) -> Markup:
|
||||
blocks = []
|
||||
for part in re.split(r"\n\s*\n", value.strip()):
|
||||
lines = "<br>".join(escape(line.strip()) for line in part.splitlines() if line.strip())
|
||||
if lines:
|
||||
blocks.append(f"<p>{lines}</p>")
|
||||
return Markup("".join(blocks))
|
||||
|
||||
|
||||
def excerpt(value: str, limit: int = 180) -> str:
|
||||
compact = re.sub(r"\s+", " ", value or "").strip()
|
||||
if len(compact) <= limit:
|
||||
return compact
|
||||
return compact[: limit - 1].rsplit(" ", 1)[0] + "…"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,348 @@
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
src: url("/static/fonts/roboto-condensed-cyrillic-400.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Roboto Condensed";
|
||||
src: url("/static/fonts/roboto-condensed-cyrillic-700.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #090c0d;
|
||||
--surface: #101415;
|
||||
--surface-strong: #151a1b;
|
||||
--text: #eceee8;
|
||||
--muted: #9ba19b;
|
||||
--faint: #686f6b;
|
||||
--border: #29302e;
|
||||
--border-strong: #3a423f;
|
||||
--olive: #a5ad62;
|
||||
--red: #df4a40;
|
||||
--max: 1240px;
|
||||
--gutter: 24px;
|
||||
--font: "Roboto Condensed", "Arial Narrow", Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg); scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 18px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .22;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.08'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a:hover { color: var(--olive); }
|
||||
img, video { display: block; max-width: 100%; }
|
||||
button, input { font: inherit; letter-spacing: 0; }
|
||||
|
||||
:focus-visible { outline: 2px solid var(--olive); outline-offset: 4px; }
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
transform: translateY(-160%);
|
||||
padding: 10px 14px;
|
||||
background: var(--text);
|
||||
color: var(--bg);
|
||||
}
|
||||
.skip-link:focus { transform: none; }
|
||||
|
||||
.site-header {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(9, 12, 13, .96);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.header-inner {
|
||||
width: min(var(--max), calc(100% - var(--gutter) * 2));
|
||||
min-height: 76px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 170px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
line-height: .95;
|
||||
}
|
||||
.brand img { width: 42px; height: 42px; border: 1px solid var(--border-strong); }
|
||||
|
||||
.main-nav { display: flex; justify-content: center; align-self: stretch; gap: 38px; }
|
||||
.main-nav a {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.main-nav a:hover, .main-nav a[aria-current="page"] { color: var(--text); }
|
||||
.main-nav a[aria-current="page"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
background: var(--olive);
|
||||
}
|
||||
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
.icon-button {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.icon-button:hover { background: var(--surface-strong); color: var(--olive); }
|
||||
.icon-button svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: square; }
|
||||
.menu-button { display: none; }
|
||||
|
||||
.search-form {
|
||||
width: min(760px, calc(100% - var(--gutter) * 2));
|
||||
margin: 0 auto;
|
||||
padding: 16px 0 20px;
|
||||
}
|
||||
.search-form[hidden] { display: none; }
|
||||
.search-form label { display: block; margin-bottom: 8px; color: var(--muted); font-size: 14px; }
|
||||
.search-form > div { display: grid; grid-template-columns: 1fr auto; }
|
||||
.search-form input {
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-right: 0;
|
||||
border-radius: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 17px;
|
||||
}
|
||||
.search-form button, .read-link, .empty-state a {
|
||||
border: 1px solid var(--olive);
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-form button { padding: 0 24px; }
|
||||
.search-form button:hover, .read-link:hover, .empty-state a:hover { background: var(--olive); color: var(--bg); }
|
||||
|
||||
.feed-shell, .article-page, .related-section, .site-footer {
|
||||
position: relative;
|
||||
width: min(var(--max), calc(100% - var(--gutter) * 2));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-heading { padding: 56px 0 46px; }
|
||||
.page-heading h1 {
|
||||
max-width: 900px;
|
||||
margin: 0;
|
||||
font-size: 68px;
|
||||
font-weight: 700;
|
||||
line-height: .98;
|
||||
}
|
||||
.page-heading > p { margin: 20px 0 0; color: var(--muted); }
|
||||
|
||||
.lead-story {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(340px, .95fr);
|
||||
min-height: 480px;
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.lead-media { min-height: 480px; overflow: hidden; background: var(--surface); }
|
||||
.lead-media img, .lead-media video { width: 100%; height: 100%; object-fit: cover; transition: transform .45s ease; }
|
||||
.lead-media:hover img { transform: scale(1.015); }
|
||||
.lead-copy { display: flex; flex-direction: column; justify-content: center; padding: 34px 0 36px 44px; }
|
||||
.lead-copy h2 { margin: 24px 0 22px; font-size: 40px; line-height: 1.14; }
|
||||
.lead-copy h2 a:hover { color: var(--text); text-decoration: underline; text-decoration-color: var(--olive); text-underline-offset: 7px; }
|
||||
.lead-copy > p { max-width: 580px; margin: 0; color: var(--muted); font-size: 20px; line-height: 1.5; }
|
||||
.read-link { width: fit-content; margin-top: 30px; padding: 10px 15px; font-size: 15px; }
|
||||
.read-link span { margin-left: 8px; }
|
||||
|
||||
.article-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 0; color: var(--muted); font-size: 14px; }
|
||||
.article-meta > * { display: inline-flex; align-items: center; }
|
||||
.article-meta > * + *::before { content: ""; width: 1px; height: 18px; margin: 0 16px; background: var(--border-strong); }
|
||||
.article-meta .category-link { color: var(--olive); }
|
||||
.article-meta .producer-link { color: var(--red); }
|
||||
|
||||
.feed-list { width: 100%; }
|
||||
.feed-list-top { border-top: 1px solid var(--border-strong); }
|
||||
.feed-row {
|
||||
display: grid;
|
||||
grid-template-columns: 350px minmax(0, 1fr);
|
||||
gap: 34px;
|
||||
padding: 22px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.feed-row-media { aspect-ratio: 16 / 9; overflow: hidden; background: var(--surface); }
|
||||
.feed-row-media img, .feed-row-media video { width: 100%; height: 100%; object-fit: cover; transition: transform .35s ease; }
|
||||
.feed-row-media:hover img { transform: scale(1.02); }
|
||||
.feed-row-copy { align-self: center; min-width: 0; }
|
||||
.feed-row h2 { margin: 16px 0 8px; font-size: 25px; line-height: 1.2; }
|
||||
.feed-row h2 a:hover { color: var(--text); text-decoration: underline; text-decoration-color: var(--olive); text-underline-offset: 5px; }
|
||||
.feed-row p { max-width: 760px; margin: 0; color: var(--muted); font-size: 17px; line-height: 1.5; }
|
||||
.media-placeholder { display: grid; width: 100%; height: 100%; place-items: center; background: var(--surface); }
|
||||
.media-placeholder img { width: 76px; height: 76px; opacity: .28; object-fit: contain; }
|
||||
|
||||
.pagination { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; min-height: 90px; font-size: 15px; }
|
||||
.pagination > :last-child { justify-self: end; }
|
||||
.pagination span { color: var(--faint); }
|
||||
|
||||
.taxonomy-heading { padding-bottom: 34px; }
|
||||
.taxonomy-heading > p { margin: 0 0 10px; color: var(--olive); font-size: 15px; text-transform: uppercase; }
|
||||
.taxonomy-heading h1 { font-size: 56px; }
|
||||
|
||||
.article-page { padding-top: 56px; }
|
||||
.article-header { max-width: 960px; margin: 0 auto 42px; }
|
||||
.article-breadcrumbs { display: flex; gap: 12px; margin-bottom: 28px; color: var(--muted); font-size: 14px; }
|
||||
.article-breadcrumbs span { color: var(--faint); }
|
||||
.article-header h1 { margin: 0; font-size: 58px; line-height: 1.06; }
|
||||
.article-lead { max-width: 850px; margin: 26px 0 0; color: var(--muted); font-size: 23px; line-height: 1.45; }
|
||||
.article-byline { display: flex; gap: 24px; margin-top: 28px; color: var(--muted); font-size: 14px; }
|
||||
.article-byline a { color: var(--red); }
|
||||
.article-media { display: grid; gap: 12px; margin-bottom: 46px; }
|
||||
.article-media figure { margin: 0; background: var(--surface); }
|
||||
.article-media img, .article-media video { width: 100%; max-height: 760px; object-fit: contain; }
|
||||
.article-layout { display: grid; grid-template-columns: minmax(0, 760px) 260px; justify-content: center; gap: 78px; }
|
||||
.article-body { font-size: 21px; line-height: 1.7; }
|
||||
.article-body p { margin: 0 0 26px; }
|
||||
.article-facts { align-self: start; border-top: 2px solid var(--olive); }
|
||||
.article-facts > div { padding: 18px 0; border-bottom: 1px solid var(--border); }
|
||||
.article-facts span { display: block; margin-bottom: 4px; color: var(--faint); font-size: 13px; text-transform: uppercase; }
|
||||
.article-facts a { font-size: 17px; }
|
||||
.article-facts .source-link { display: block; margin-top: 22px; color: var(--muted); font-size: 14px; }
|
||||
.related-section { margin-top: 90px; border-top: 1px solid var(--border-strong); }
|
||||
.related-section > h2 { margin: 0; padding: 34px 0 18px; font-size: 32px; }
|
||||
|
||||
.empty-state, .not-found { min-height: 56vh; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }
|
||||
.empty-state h2, .not-found h1 { margin: 0; font-size: 42px; }
|
||||
.empty-state p { color: var(--muted); }
|
||||
.empty-state a { margin-top: 16px; padding: 10px 15px; }
|
||||
.not-found > p { margin: 0; color: var(--red); font-size: 18px; }
|
||||
.not-found a { margin-top: 24px; color: var(--olive); }
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-top: 84px;
|
||||
padding: 34px 0 46px;
|
||||
border-top: 1px solid var(--border-strong);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer-brand { color: var(--text); font-size: 20px; font-weight: 700; }
|
||||
.site-footer p { margin: 7px 0 0; }
|
||||
.site-footer nav { display: flex; gap: 24px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.header-inner { grid-template-columns: 150px 1fr auto; gap: 16px; }
|
||||
.main-nav { gap: 22px; }
|
||||
.main-nav a { font-size: 14px; }
|
||||
.page-heading h1 { font-size: 56px; }
|
||||
.lead-story { grid-template-columns: 1.1fr .9fr; min-height: 420px; }
|
||||
.lead-media { min-height: 420px; }
|
||||
.lead-copy { padding-left: 30px; }
|
||||
.lead-copy h2 { font-size: 33px; }
|
||||
.feed-row { grid-template-columns: 290px minmax(0, 1fr); gap: 26px; }
|
||||
.article-layout { grid-template-columns: minmax(0, 680px) 220px; gap: 44px; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
:root { --gutter: 16px; }
|
||||
body { font-size: 17px; }
|
||||
.header-inner { min-height: 64px; grid-template-columns: 1fr auto; }
|
||||
.brand img { width: 38px; height: 38px; }
|
||||
.main-nav {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: none;
|
||||
padding: 12px var(--gutter) 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-nav.is-open { display: grid; }
|
||||
.main-nav a { min-height: 44px; border-bottom: 1px solid var(--border); font-size: 16px; }
|
||||
.main-nav a[aria-current="page"]::after { display: none; }
|
||||
.menu-button { display: grid; }
|
||||
.page-heading { padding: 38px 0 30px; }
|
||||
.page-heading h1, .taxonomy-heading h1 { font-size: 44px; line-height: 1; }
|
||||
.lead-story { display: block; min-height: 0; }
|
||||
.lead-media { min-height: 0; aspect-ratio: 4 / 3; }
|
||||
.lead-copy { padding: 25px 0 34px; }
|
||||
.lead-copy h2 { margin: 20px 0 15px; font-size: 32px; }
|
||||
.lead-copy > p { font-size: 18px; }
|
||||
.feed-row { grid-template-columns: 132px minmax(0, 1fr); gap: 16px; padding: 18px 0; }
|
||||
.feed-row-media { aspect-ratio: 4 / 3; }
|
||||
.feed-row h2 { margin: 10px 0 0; font-size: 20px; }
|
||||
.feed-row p { display: none; }
|
||||
.article-meta { font-size: 12px; }
|
||||
.article-meta time { display: none; }
|
||||
.article-meta > * + *::before { height: 14px; margin: 0 10px; }
|
||||
.article-meta .category-link::before { display: none; }
|
||||
.article-header { margin-bottom: 30px; }
|
||||
.article-header h1 { font-size: 42px; }
|
||||
.article-lead { font-size: 20px; }
|
||||
.article-layout { display: block; }
|
||||
.article-body { font-size: 19px; }
|
||||
.article-facts { margin-top: 42px; }
|
||||
.related-section { margin-top: 64px; }
|
||||
}
|
||||
|
||||
@media (max-width: 460px) {
|
||||
.feed-row { grid-template-columns: 112px minmax(0, 1fr); gap: 13px; }
|
||||
.feed-row h2 { font-size: 18px; line-height: 1.18; }
|
||||
.article-meta .producer-link::before { display: none; }
|
||||
.article-meta .category-link { display: none; }
|
||||
.article-header h1 { font-size: 36px; }
|
||||
.article-byline { display: grid; gap: 7px; }
|
||||
.site-footer { display: grid; gap: 24px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
*, *::before, *::after { transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const menuButton = document.querySelector('[data-menu-toggle]');
|
||||
const nav = document.querySelector('#main-nav');
|
||||
const searchButton = document.querySelector('[data-search-toggle]');
|
||||
const searchForm = document.querySelector('[data-search-form]');
|
||||
|
||||
menuButton?.addEventListener('click', () => {
|
||||
const open = menuButton.getAttribute('aria-expanded') === 'true';
|
||||
menuButton.setAttribute('aria-expanded', String(!open));
|
||||
nav?.classList.toggle('is-open', !open);
|
||||
});
|
||||
|
||||
searchButton?.addEventListener('click', () => {
|
||||
const opening = searchForm?.hasAttribute('hidden');
|
||||
searchForm?.toggleAttribute('hidden', !opening);
|
||||
if (opening) searchForm?.querySelector('input')?.focus();
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<section class="not-found"><p>404</p><h1>Страница не найдена</h1><a href="/">Вернуться в ленту</a></section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% macro media(article, eager=false) %}
|
||||
{% set item = article.media[0] if article.media else none %}
|
||||
{% if item and item.type == 'image' %}
|
||||
<img src="{{ item.url }}" alt="{{ item.alt or article.title }}" {% if item.width %}width="{{ item.width }}"{% endif %} {% if item.height %}height="{{ item.height }}"{% endif %} loading="{{ 'eager' if eager else 'lazy' }}" decoding="async">
|
||||
{% elif item and item.type == 'video' %}
|
||||
<video src="{{ item.url }}" muted playsinline preload="metadata" aria-label="{{ item.alt or article.title }}"></video>
|
||||
{% else %}
|
||||
<span class="media-placeholder"><img src="/static/favi.png" alt="" width="96" height="96"></span>
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro meta(article) %}
|
||||
<div class="article-meta">
|
||||
<time datetime="{{ article.published_at.isoformat() }}">{{ article.published_at|ekb_date }} · {{ article.published_at|ekb_time }}</time>
|
||||
<a class="category-link" href="/category/{{ article.category_tag }}">{{ article.category_name }}</a>
|
||||
<a class="producer-link" href="/brand/{{ article.producer_tag }}">{{ article.producer_name }}</a>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro row(article) %}
|
||||
<article class="feed-row">
|
||||
<a class="feed-row-media" href="{{ article_path(article) }}" tabindex="-1">{{ media(article) }}</a>
|
||||
<div class="feed-row-copy">
|
||||
{{ meta(article) }}
|
||||
<h2><a href="{{ article_path(article) }}">{{ article.title }}</a></h2>
|
||||
<p>{{ article.lead or article.body|excerpt(210) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endmacro %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{% if pages > 1 %}
|
||||
<nav class="pagination" aria-label="Страницы ленты">
|
||||
{% if page > 1 %}<a rel="prev" href="?page={{ page - 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">← Новее</a>{% else %}<span></span>{% endif %}
|
||||
<span>{{ page }} / {{ pages }}</span>
|
||||
{% if page < pages %}<a rel="next" href="?page={{ page + 1 }}{% if q %}&q={{ q|urlencode }}{% endif %}">Старее →</a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_article_rows.html" import media, row %}
|
||||
{% block og_type %}article{% endblock %}
|
||||
{% block content %}
|
||||
<article class="article-page">
|
||||
<header class="article-header">
|
||||
<div class="article-breadcrumbs"><a href="/">Лента</a><span>/</span><a href="/category/{{ article.category_tag }}">{{ article.category_name }}</a></div>
|
||||
<h1>{{ article.title }}</h1>
|
||||
{% if article.lead %}<p class="article-lead">{{ article.lead }}</p>{% endif %}
|
||||
<div class="article-byline">
|
||||
<time datetime="{{ article.published_at.isoformat() }}">{{ article.published_at|ekb_date }} · {{ article.published_at|ekb_time }} ЕКБ</time>
|
||||
<a href="/brand/{{ article.producer_tag }}">{{ article.producer_name }}</a>
|
||||
</div>
|
||||
</header>
|
||||
{% if article.media %}
|
||||
<div class="article-media">
|
||||
{% for item in article.media %}
|
||||
{% if item.type == 'image' %}<figure><img src="{{ item.url }}" alt="{{ item.alt or article.title }}" loading="{{ 'eager' if loop.first else 'lazy' }}" decoding="async"></figure>{% endif %}
|
||||
{% if item.type == 'video' %}<figure><video src="{{ item.url }}" controls playsinline preload="metadata"></video></figure>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="article-layout">
|
||||
<div class="article-body">{{ article.body|paragraphs }}</div>
|
||||
<aside class="article-facts">
|
||||
<div><span>Категория</span><a href="/category/{{ article.category_tag }}">{{ article.category_name }}</a></div>
|
||||
<div><span>Производитель</span><a href="/brand/{{ article.producer_tag }}">{{ article.producer_name }}</a></div>
|
||||
{% if article.source_url %}<a class="source-link" href="{{ article.source_url }}" rel="nofollow noopener" target="_blank">Первоисточник ↗</a>{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
</article>
|
||||
{% if related %}
|
||||
<section class="related-section">
|
||||
<h2>По теме</h2>
|
||||
<div class="feed-list">{% for item in related %}{{ row(item) }}{% endfor %}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ meta_title }}</title>
|
||||
<meta name="description" content="{{ meta_description }}">
|
||||
{% if noindex %}<meta name="robots" content="noindex,follow">{% else %}<meta name="robots" content="index,follow,max-image-preview:large">{% endif %}
|
||||
<link rel="canonical" href="{{ canonical_url }}">
|
||||
<link rel="alternate" type="application/rss+xml" title="Forma N8 RSS" href="{{ site_url('/rss.xml') }}">
|
||||
<link rel="icon" type="image/png" href="/static/favi.png">
|
||||
<meta property="og:site_name" content="Forma N8">
|
||||
<meta property="og:locale" content="ru_RU">
|
||||
<meta property="og:type" content="{% block og_type %}website{% endblock %}">
|
||||
<meta property="og:title" content="{{ meta_title }}">
|
||||
<meta property="og:description" content="{{ meta_description }}">
|
||||
<meta property="og:url" content="{{ canonical_url }}">
|
||||
{% if og_image %}<meta property="og:image" content="{{ og_image }}">{% endif %}
|
||||
<link rel="stylesheet" href="/static/site.css?v=1">
|
||||
{% if json_ld %}<script type="application/ld+json">{{ json_ld|safe }}</script>{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#content">К содержанию</a>
|
||||
<header class="site-header">
|
||||
<div class="header-inner">
|
||||
<a class="brand" href="/" aria-label="Forma N8, главная">
|
||||
<img src="/static/favi.png" width="44" height="44" alt="">
|
||||
<span>FORMA<br>N8</span>
|
||||
</a>
|
||||
<nav class="main-nav" id="main-nav" aria-label="Основная навигация">
|
||||
<a href="/" {% if current_path == '/' %}aria-current="page"{% endif %}>Лента</a>
|
||||
<a href="/category/snaryazhenie">Снаряжение</a>
|
||||
<a href="/category/odezhda">Одежда</a>
|
||||
<a href="/category/bronya">Броня</a>
|
||||
<a href="/category/aksessuary">Аксессуары</a>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="icon-button" type="button" data-search-toggle aria-label="Открыть поиск" title="Поиск">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"></circle><path d="m16 16 4 4"></path></svg>
|
||||
</button>
|
||||
<button class="icon-button menu-button" type="button" data-menu-toggle aria-expanded="false" aria-controls="main-nav" aria-label="Открыть меню" title="Меню">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form class="search-form" action="/" method="get" data-search-form {% if not q %}hidden{% endif %}>
|
||||
<label for="site-search">Поиск по новостям</label>
|
||||
<div>
|
||||
<input id="site-search" name="q" value="{{ q or '' }}" type="search" maxlength="100" placeholder="Название, производитель, снаряжение">
|
||||
<button type="submit">Найти</button>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
<main id="content">{% block content %}{% endblock %}</main>
|
||||
<footer class="site-footer">
|
||||
<div>
|
||||
<a class="footer-brand" href="/">FORMA N8</a>
|
||||
<p>Новости снаряжения и экипировки.</p>
|
||||
</div>
|
||||
<nav aria-label="Служебная навигация">
|
||||
<a href="/rss.xml">RSS</a>
|
||||
<a href="/sitemap.xml">Sitemap</a>
|
||||
</nav>
|
||||
</footer>
|
||||
<script src="/static/site.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_article_rows.html" import media, meta, row %}
|
||||
{% block content %}
|
||||
<section class="feed-shell">
|
||||
<header class="page-heading">
|
||||
<h1>Новости снаряжения</h1>
|
||||
{% if q %}<p>Результаты поиска: «{{ q }}»</p>{% endif %}
|
||||
</header>
|
||||
{% if articles %}
|
||||
{% if page == 1 and not q %}
|
||||
{% set lead = articles[0] %}
|
||||
<article class="lead-story">
|
||||
<a class="lead-media" href="{{ article_path(lead) }}">{{ media(lead, true) }}</a>
|
||||
<div class="lead-copy">
|
||||
{{ meta(lead) }}
|
||||
<h2><a href="{{ article_path(lead) }}">{{ lead.title }}</a></h2>
|
||||
<p>{{ lead.lead or lead.body|excerpt(340) }}</p>
|
||||
<a class="read-link" href="{{ article_path(lead) }}">Читать новость <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
<div class="feed-list">
|
||||
{% for article in articles[1:] %}{{ row(article) }}{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="feed-list feed-list-top">
|
||||
{% for article in articles %}{{ row(article) }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "_pagination.html" %}
|
||||
{% else %}
|
||||
<div class="empty-state"><h2>Ничего не найдено</h2><p>Попробуйте изменить запрос или вернуться к ленте.</p><a href="/">Вернуться в ленту</a></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_article_rows.html" import row %}
|
||||
{% block content %}
|
||||
<section class="feed-shell taxonomy-shell">
|
||||
<header class="page-heading taxonomy-heading">
|
||||
<p>{{ taxonomy_label }}</p>
|
||||
<h1>{{ heading }}</h1>
|
||||
</header>
|
||||
{% if articles %}
|
||||
<div class="feed-list feed-list-top">
|
||||
{% for article in articles %}{{ row(article) }}{% endfor %}
|
||||
</div>
|
||||
{% include "_pagination.html" %}
|
||||
{% else %}
|
||||
<div class="empty-state"><h2>Публикаций пока нет</h2><p>Новые материалы появятся здесь после публикации.</p><a href="/">Вернуться в ленту</a></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user