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

@@ -1,5 +0,0 @@
node_modules
data
.env
*.db
.git

View File

@@ -1,5 +0,0 @@
node_modules/
data/
dist/
.env
*.db

View File

@@ -1,14 +0,0 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN mkdir -p data && chmod +x entrypoint.sh
EXPOSE 4001
ENV PORT=4001
ENTRYPOINT ["./entrypoint.sh"]

View File

@@ -1,70 +0,0 @@
# Publications Service — всё для публикаций
Сервис публикаций в папке `posts-microservice`: CRUD, планирование, rich-контент (изображения, видео, аудио, таблицы и т.д.), календарь выпусков и API для SearXNG.
## Запуск
```bash
cd posts-microservice
npm install
npm run dev
```
Сервис: `http://localhost:4001`
## API
### Публикации
| Метод | URL | Описание |
|-------|-----|----------|
| POST | `/api/publications` | Создать |
| GET | `/api/publications` | Список (`?status=`, `?q=`, `?limit=`, `?offset=`) |
| GET | `/api/publications/calendar` | Календарь (`?year=`, `?month=`) |
| GET | `/api/publications/:id` | По ID |
| GET | `/api/publications/slug/:slug` | По slug |
| PATCH | `/api/publications/:id` | Обновить |
| DELETE | `/api/publications/:id` | Удалить |
### Поля публикации
- `title` — заголовок
- `slug` — URL-slug (генерируется из title)
- `excerpt` — краткое описание
- `contentBlocks` — массив блоков (текст, изображения, видео, аудио, таблицы, embed)
- `previewImage` — URL превью
- `url` — ссылка на страницу
- `author` — автор
- `status``draft` | `scheduled` | `published`
- `scheduledAt` — время публикации (ISO)
- `publishedAt` — время фактической публикации
### Блоки контента (`contentBlocks`)
```json
[
{ "type": "text", "data": { "html": "<p>Текст</p>" } },
{ "type": "image", "data": { "src": "https://...", "alt": "...", "caption": "..." } },
{ "type": "video", "data": { "src": "https://...", "poster": "...", "caption": "..." } },
{ "type": "audio", "data": { "src": "https://...", "caption": "..." } },
{ "type": "table", "data": { "html": "<table>...</table>", "caption": "..." } },
{ "type": "embed", "data": { "url": "https://...", "caption": "..." } },
{ "type": "heading", "data": { "level": 1, "text": "Заголовок" } },
{ "type": "divider" }
]
```
### Планирование
- Публикация со `status: "scheduled"` и `scheduledAt` автоматически переходит в `published` при наступлении времени.
- Планировщик запускается каждую минуту.
### SearXNG
Эндпоинт `/search?q={query}&page={pageno}` отдаёт только опубликованные публикации в формате JSON engine SearXNG.
Конфиг в `searxng-engine-example.yml`.
## Алиасы
- `/api/posts` → тот же API (для обратной совместимости)

View File

@@ -0,0 +1,42 @@
# Ghost + MySQL
# Требует: порт 2369, MySQL 8
services:
ghost:
image: ghost:latest
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
ports:
- "2369:2368"
environment:
database__client: mysql2
database__connection__host: mysql
database__connection__user: ghost
database__connection__password: ghost
database__connection__database: ghost
url: http://localhost:2369
volumes:
- ghost_content:/var/lib/ghost/content
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: ghost
MYSQL_USER: ghost
MYSQL_PASSWORD: ghost
volumes:
- ghost_mysql:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"]
interval: 3s
timeout: 5s
retries: 15
start_period: 30s
volumes:
ghost_content:
ghost_mysql:

View File

@@ -1,11 +0,0 @@
import { defineConfig } from 'drizzle-kit';
import path from 'path';
export default defineConfig({
dialect: 'sqlite',
schema: './src/db/schema.ts',
out: './drizzle',
dbCredentials: {
url: path.join(process.cwd(), 'data', 'posts.db'),
},
});

View File

@@ -1,5 +0,0 @@
#!/bin/sh
set -e
mkdir -p /app/data
npx tsx src/init-db.ts 2>/dev/null || true
exec npx tsx src/index.ts

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
{
"name": "posts-microservice",
"version": "1.0.0",
"type": "module",
"description": "Сервис постинга для SearXNG - API для отображения своих постов вместо Bing",
"main": "src/index.ts",
"scripts": {
"dev": "npx tsx watch src/index.ts",
"start": "npx tsx src/index.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"better-sqlite3": "^11.9.1",
"drizzle-orm": "^0.40.1",
"express": "^4.21.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/express": "^4.17.21",
"drizzle-kit": "^0.30.5",
"tsx": "^4.19.0",
"typescript": "^5.9.3"
}
}

View File

@@ -1,18 +0,0 @@
# Добавьте этот engine в /etc/searxng/settings.yml в секцию engines:
#
# Ваши публикации будут отображаться в результатах поиска Perplexica
# Замените http://localhost:4001 на ваш URL (например http://posts:4001 в Docker)
- name: my_publications
engine: json_engine
paging: true
search_url: http://localhost:4001/search?q={query}&page={pageno}
results_query: results
url_query: url
title_query: title
content_query: content
disabled: false
# Чтобы отключить Bing и использовать только свои посты:
# - name: bing
# disabled: true

View File

@@ -1,13 +0,0 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema';
import path from 'path';
import fs from 'fs';
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sqlite = new Database(path.join(dataDir, 'posts.db'));
export const db = drizzle(sqlite, { schema });

View File

@@ -1,41 +0,0 @@
import { sql } from 'drizzle-orm';
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// Типы блоков контента (изображения, видео, аудио, таблицы и т.д.)
export type ContentBlock =
| { type: 'text'; data: { html: string } }
| { type: 'image'; data: { src: string; alt?: string; caption?: string } }
| { type: 'video'; data: { src: string; poster?: string; caption?: string } }
| { type: 'audio'; data: { src: string; caption?: string } }
| { type: 'table'; data: { html: string; caption?: string } }
| { type: 'embed'; data: { url: string; caption?: string } }
| { type: 'divider' }
| { type: 'heading'; data: { level: 1 | 2 | 3; text: string } };
export const publications = sqliteTable('publications', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
// Простой текст для поиска; полный контент в contentBlocks
excerpt: text('excerpt'),
// JSON массив ContentBlock[]
contentBlocks: text('contentBlocks', { mode: 'json' })
.$type<ContentBlock[]>()
.default(sql`'[]'`),
// Превью изображение
previewImage: text('previewImage'),
url: text('url').notNull(),
author: text('author'),
status: text({ enum: ['draft', 'scheduled', 'published'] }).notNull().default('draft'),
publishedAt: text('publishedAt'),
scheduledAt: text('scheduledAt'),
createdAt: text('createdAt')
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text('updatedAt')
.notNull()
.$defaultFn(() => new Date().toISOString()),
});
export type Publication = typeof publications.$inferSelect;
export type NewPublication = typeof publications.$inferInsert;

View File

@@ -1,41 +0,0 @@
import express from 'express';
import publicationsRouter from './routes/publications.js';
import searchRouter from './routes/search.js';
import { runScheduledPublish } from './lib/scheduler.js';
const app = express();
const PORT = process.env.PORT || 4001;
app.use(express.json({ limit: '10mb' }));
// API публикаций
app.use('/api/publications', publicationsRouter);
// Алиас /api/posts -> publications (обратная совместимость)
app.use('/api/posts', (req, res, next) => {
const origJson = res.json.bind(res);
res.json = (body: unknown) => {
if (body && typeof body === 'object' && 'publications' in body) {
return origJson({ ...body, posts: (body as any).publications });
}
return origJson(body);
};
next();
}, publicationsRouter);
// SearXNG JSON Engine — поиск по опубликованным
app.use('/search', searchRouter);
// Health
app.get('/health', (_, res) => res.json({ status: 'ok' }));
// Планировщик: каждую минуту проверяет и публикует запланированные
setInterval(runScheduledPublish, 60_000);
runScheduledPublish();
app.listen(PORT, () => {
console.log(`Publications Service: http://localhost:${PORT}`);
console.log(` API: /api/publications`);
console.log(` Calendar: /api/publications/calendar`);
console.log(` SearXNG: /search?q=...`);
});

View File

@@ -1,26 +0,0 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const dbPath = path.join(dataDir, 'posts.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE IF NOT EXISTS publications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT,
contentBlocks TEXT DEFAULT '[]',
previewImage TEXT,
url TEXT NOT NULL,
author TEXT,
status TEXT NOT NULL DEFAULT 'draft',
publishedAt TEXT,
scheduledAt TEXT,
createdAt TEXT NOT NULL DEFAULT (datetime('now')),
updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
db.close();

View File

@@ -1,22 +0,0 @@
import type { ContentBlock } from '../db/schema.js';
/** Извлекает поисковый текст из блоков контента */
export function blocksToSearchText(blocks: ContentBlock[] | null): string {
if (!blocks || !Array.isArray(blocks)) return '';
return blocks
.map((b) => {
if (b.type === 'text' && b.data?.html) return stripHtml(String(b.data.html));
if (b.type === 'heading' && b.data?.text) return b.data.text;
if (b.type === 'image' && b.data?.caption) return b.data.caption;
if (b.type === 'video' && b.data?.caption) return b.data.caption;
if (b.type === 'audio' && b.data?.caption) return b.data.caption;
if (b.type === 'table' && b.data?.html) return stripHtml(String(b.data.html));
return '';
})
.filter(Boolean)
.join(' ');
}
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}

View File

@@ -1,25 +0,0 @@
import { db } from '../db/index.js';
import { publications } from '../db/schema.js';
import { eq, sql } from 'drizzle-orm';
/** Переводит запланированные публикации в published, когда наступило время */
export function runScheduledPublish() {
const now = new Date().toISOString();
const rows = db
.select({ id: publications.id })
.from(publications)
.where(
sql`${publications.status} = 'scheduled' AND ${publications.scheduledAt} IS NOT NULL AND ${publications.scheduledAt} <= ${now}`,
)
.all();
for (const row of rows) {
db.update(publications)
.set({ status: 'published', publishedAt: now, updatedAt: now })
.where(eq(publications.id, row.id))
.run();
}
if (rows.length > 0) {
console.log(`[scheduler] Опубликовано: ${rows.length}`);
}
}

View File

@@ -1,226 +0,0 @@
import { Router } from 'express';
import { db } from '../db/index.js';
import { publications } from '../db/schema.js';
import { desc, eq, and } from 'drizzle-orm';
import { blocksToSearchText } from '../lib/content.js';
import type { ContentBlock } from '../db/schema.js';
const router = Router();
function slugify(s: string): string {
const slug = s
.toLowerCase()
.replace(/[^\p{L}\p{N}\s-]/gu, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
return slug || `post-${Date.now()}`;
}
// Создать публикацию
router.post('/', (req, res) => {
try {
const {
title,
slug,
excerpt,
content,
contentBlocks,
previewImage,
url,
author,
status,
scheduledAt,
} = req.body;
if (!title || !url) {
return res.status(400).json({ error: 'Необходимы поля: title, url' });
}
const finalSlug = slug || slugify(title);
const pubStatus = status || 'draft';
const now = new Date().toISOString();
const blocks: ContentBlock[] =
contentBlocks && Array.isArray(contentBlocks)
? contentBlocks
: content
? [{ type: 'text', data: { html: content } }]
: [];
let publishedAt: string | null = null;
if (pubStatus === 'published') publishedAt = now;
if (pubStatus === 'scheduled' && scheduledAt) publishedAt = null;
const result = db
.insert(publications)
.values({
title,
slug: finalSlug,
excerpt: excerpt || (typeof content === 'string' ? content.slice(0, 300) : null),
contentBlocks: blocks,
previewImage: previewImage || null,
url,
author: author || null,
status: pubStatus,
publishedAt,
scheduledAt: scheduledAt || null,
})
.run();
res.status(201).json({ id: result.lastInsertRowid, message: 'Публикация создана' });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Ошибка создания публикации' });
}
});
// Список публикаций (с фильтрами)
router.get('/', (req, res) => {
try {
const q = req.query.q as string | undefined;
const status = req.query.status as string | undefined;
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
const offset = parseInt(req.query.offset as string) || 0;
let baseQuery = db.select().from(publications).orderBy(desc(publications.createdAt));
const conditions = [];
if (status) {
conditions.push(eq(publications.status, status as 'draft' | 'scheduled' | 'published'));
}
const whereClause = conditions.length ? and(...conditions) : undefined;
const items = (whereClause
? baseQuery.where(whereClause)
: baseQuery
)
.limit(limit)
.offset(offset)
.all();
let result = items;
if (q) {
const qLower = q.toLowerCase();
result = items.filter(
(p) =>
p.title?.toLowerCase().includes(qLower) ||
(p.excerpt && p.excerpt.toLowerCase().includes(qLower)) ||
p.url?.toLowerCase().includes(qLower) ||
blocksToSearchText(p.contentBlocks).toLowerCase().includes(qLower),
);
}
res.json({ publications: result });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Ошибка получения публикаций' });
}
});
// Календарь: публикации по датам (для редакторского календаря)
router.get('/calendar', (req, res) => {
try {
const year = parseInt(req.query.year as string) || new Date().getFullYear();
const month = parseInt(req.query.month as string);
const items = db
.select({
id: publications.id,
title: publications.title,
status: publications.status,
scheduledAt: publications.scheduledAt,
publishedAt: publications.publishedAt,
createdAt: publications.createdAt,
})
.from(publications)
.all();
const byDate: Record<string, typeof items> = {};
for (const p of items) {
const dateStr =
p.scheduledAt?.slice(0, 10) || p.publishedAt?.slice(0, 10) || p.createdAt?.slice(0, 10);
if (!dateStr) continue;
if (month !== undefined) {
const [, m] = dateStr.split('-').map(Number);
if (m !== month) continue;
}
const [y] = dateStr.split('-').map(Number);
if (y !== year) continue;
if (!byDate[dateStr]) byDate[dateStr] = [];
byDate[dateStr].push(p);
}
res.json({ calendar: byDate });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Ошибка календаря' });
}
});
// Одна публикация по ID
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const row = db.select().from(publications).where(eq(publications.id, id)).get();
if (!row) return res.status(404).json({ error: 'Публикация не найдена' });
res.json(row);
});
// По slug
router.get('/slug/:slug', (req, res) => {
const row = db
.select()
.from(publications)
.where(eq(publications.slug, req.params.slug))
.get();
if (!row) return res.status(404).json({ error: 'Публикация не найдена' });
res.json(row);
});
// Обновить
router.patch('/:id', (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const {
title,
slug,
excerpt,
contentBlocks,
previewImage,
url,
author,
status,
scheduledAt,
publishedAt,
} = req.body;
const updates: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
};
if (title !== undefined) updates.title = title;
if (slug !== undefined) updates.slug = slug;
if (excerpt !== undefined) updates.excerpt = excerpt;
if (contentBlocks !== undefined) updates.contentBlocks = contentBlocks;
if (previewImage !== undefined) updates.previewImage = previewImage;
if (url !== undefined) updates.url = url;
if (author !== undefined) updates.author = author;
if (status !== undefined) updates.status = status;
if (scheduledAt !== undefined) updates.scheduledAt = scheduledAt;
if (publishedAt !== undefined) updates.publishedAt = publishedAt;
const result = db.update(publications).set(updates as any).where(eq(publications.id, id)).run();
if (result.changes === 0) return res.status(404).json({ error: 'Публикация не найдена' });
res.json({ message: 'Публикация обновлена' });
});
// Удалить
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const result = db.delete(publications).where(eq(publications.id, id)).run();
if (result.changes === 0) return res.status(404).json({ error: 'Публикация не найдена' });
res.json({ message: 'Публикация удалена' });
});
export default router;

View File

@@ -1,63 +0,0 @@
import { Router } from 'express';
import { db } from '../db/index.js';
import { publications } from '../db/schema.js';
import { sql } from 'drizzle-orm';
import { blocksToSearchText } from '../lib/content.js';
const router = Router();
/** Только опубликованные или запланированные (когда время наступило) */
function getPublishedItems() {
const now = new Date().toISOString();
const items = db
.select()
.from(publications)
.where(
sql`(${publications.status} = 'published' OR (${publications.status} = 'scheduled' AND ${publications.scheduledAt} <= ${now}))`,
)
.all();
return items.sort((a, b) => {
const da = a.publishedAt || a.scheduledAt || a.createdAt || '';
const dbVal = b.publishedAt || b.scheduledAt || b.createdAt || '';
return dbVal.localeCompare(da);
});
}
// SearXNG JSON Engine API
router.get('/', (req, res) => {
try {
const query = (req.query.q as string)?.trim() || '';
const pageno = Math.max(1, parseInt((req.query.page || req.query.pageno) as string) || 1);
const pageSize = 10;
const offset = (pageno - 1) * pageSize;
const allItems = getPublishedItems();
let filtered = allItems;
if (query) {
const q = query.toLowerCase();
filtered = allItems.filter(
(p) =>
p.title?.toLowerCase().includes(q) ||
(p.excerpt && p.excerpt.toLowerCase().includes(q)) ||
p.url?.toLowerCase().includes(q) ||
blocksToSearchText(p.contentBlocks).toLowerCase().includes(q),
);
}
const items = filtered.slice(offset, offset + pageSize);
const results = filtered.map((p) => ({
url: p.url,
title: p.title,
content: (p.excerpt || blocksToSearchText(p.contentBlocks) || '').slice(0, 500),
...(p.author && { author: p.author }),
...(p.previewImage && { thumbnail: p.previewImage }),
}));
res.json({ results });
} catch (e) {
console.error(e);
res.status(500).json({ results: [] });
}
});
export default router;

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}