feat(sidebar): узкое меню, субменю историй с hover, эффект text-fade

- Сужено боковое меню (56px), убрана иконка Home
- Субменю историй при наведении: полная высота, на всю ширину, z-9999
- Класс text-fade для плавного обрезания длинного текста
- Убраны скругления в субменю
- Chatwoot, изменения в posts-mcs и прочие обновления

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
home
2026-02-21 16:36:58 +03:00
parent 3fa83bc605
commit 8fc82a3b90
53 changed files with 894 additions and 4015 deletions

View File

@@ -2,14 +2,34 @@ import { searchSearxng, type SearxngSearchResult } from '@/lib/searxng';
import configManager from '@/lib/config';
import { getSearxngURL } from '@/lib/config/serverRegistry';
const GHOST_URL = process.env.GHOST_URL?.trim() ?? '';
const GHOST_CONTENT_API_KEY = process.env.GHOST_CONTENT_API_KEY?.trim() ?? '';
const PLACEHOLDER_IMAGE = 'https://placehold.co/400x225/e5e7eb/6b7280?text=Post';
type Region = 'america' | 'eu' | 'russia' | 'china';
type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment';
type Topic = 'tech' | 'finance' | 'art' | 'sports' | 'entertainment' | 'gooseek';
interface GhostPost {
title: string;
excerpt?: string | null;
custom_excerpt?: string | null;
meta_description?: string | null;
plaintext?: string | null;
html?: string | null;
feature_image?: string | null;
url: string;
}
function stripHtml(html: string): string {
return html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
const SOURCES_BY_REGION: Record<
Region,
Record<Topic, { query: string[]; links: string[] }>
> = {
america: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'latest tech', 'AI', 'science and innovation'],
links: ['techcrunch.com', 'wired.com', 'theverge.com', 'arstechnica.com'],
@@ -32,6 +52,7 @@ const SOURCES_BY_REGION: Record<
},
},
eu: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'AI', 'innovation'],
links: ['techcrunch.com', 'bbc.com', 'theguardian.com', 'reuters.com', 'euronews.com'],
@@ -54,6 +75,7 @@ const SOURCES_BY_REGION: Record<
},
},
russia: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'IT', 'innovation'],
links: ['tass.com', 'ria.ru', 'interfax.com', 'kommersant.ru', 'vedomosti.ru'],
@@ -76,6 +98,7 @@ const SOURCES_BY_REGION: Record<
},
},
china: {
gooseek: { query: [], links: [] },
tech: {
query: ['technology news', 'tech', 'AI', 'innovation'],
links: ['scmp.com', 'xinhuanet.com', 'chinadaily.com.cn', 'reuters.com'],
@@ -137,8 +160,91 @@ const COUNTRY_TO_REGION: Record<string, Region> = {
DK: 'eu',
};
async function fetchDooseekPosts(): Promise<
{ title: string; content: string; url: string; thumbnail: string }[]
> {
if (!GHOST_URL || !GHOST_CONTENT_API_KEY) {
throw new Error(
'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env',
);
}
const base = GHOST_URL.replace(/\/$/, '');
const apiUrl = `${base}/ghost/api/content/posts/?key=${GHOST_CONTENT_API_KEY}&limit=50&fields=title,excerpt,custom_excerpt,meta_description,html,feature_image,url&formats=html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15_000);
try {
const res = await fetch(apiUrl, {
signal: controller.signal,
headers: { Accept: 'application/json' },
});
clearTimeout(timeoutId);
if (!res.ok) {
if (res.status === 401) {
throw new Error(
'Неверный Ghost Content API Key. Получите ключ в Ghost Admin → Settings → Integrations → Add custom integration.',
);
}
throw new Error(`Ghost API: HTTP ${res.status}`);
}
const data = await res.json();
const posts: GhostPost[] = data.posts ?? [];
return posts.map((p) => {
const excerpt =
p.custom_excerpt?.trim() ||
p.meta_description?.trim() ||
p.excerpt?.trim() ||
p.plaintext?.trim() ||
(p.html ? stripHtml(p.html) : '');
const content = excerpt
? excerpt.slice(0, 300) + (excerpt.length > 300 ? '…' : '')
: (p.title ? `${p.title}. Читать далее →` : 'Читать далее →');
return {
title: p.title || 'Без названия',
content,
url: p.url,
thumbnail: p.feature_image?.trim() || PLACEHOLDER_IMAGE,
};
});
} catch (e) {
clearTimeout(timeoutId);
throw e;
}
}
export const GET = async (req: Request) => {
try {
const params = new URL(req.url).searchParams;
const mode: 'normal' | 'preview' =
(params.get('mode') as 'normal' | 'preview') || 'normal';
const topic: Topic = (params.get('topic') as Topic) || 'tech';
if (topic === 'gooseek') {
try {
const blogs = await fetchDooseekPosts();
return Response.json({ blogs }, { status: 200 });
} catch (e) {
const msg =
e instanceof Error ? e.message : String(e);
const isConnect =
msg.includes('ECONNREFUSED') ||
msg.includes('fetch failed') ||
msg.includes('Failed to fetch') ||
msg.includes('AbortError');
return Response.json(
{
message:
!GHOST_URL || !GHOST_CONTENT_API_KEY
? 'Ghost не настроен. Укажите GHOST_URL и GHOST_CONTENT_API_KEY в .env'
: isConnect
? 'Не удалось подключиться к Ghost. Проверьте GHOST_URL и доступность сайта.'
: `Ошибка загрузки постов: ${msg}`,
},
{ status: isConnect ? 503 : 500 },
);
}
}
const searxngURL = getSearxngURL();
if (!searxngURL?.trim()) {
return Response.json(
@@ -149,12 +255,6 @@ export const GET = async (req: Request) => {
{ status: 503 },
);
}
const params = new URL(req.url).searchParams;
const mode: 'normal' | 'preview' =
(params.get('mode') as 'normal' | 'preview') || 'normal';
const topic: Topic = (params.get('topic') as Topic) || 'tech';
const regionParam = params.get('region') as Region | null;
let region: Region = 'america';