infinity serch

This commit is contained in:
jze9
2026-05-19 17:48:35 +05:00
parent 57ac3fc317
commit eb4e71ee77
17 changed files with 694 additions and 150 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
# ── Secrets ──────────────────────────────────────────────────────────────── # ── Secrets ────────────────────────────────────────────────────────────────
.env .env
.env.internal
.env.external
# .env.example is intentionally committed as a setup template # .env.example is intentionally committed as a setup template
# ── Local data volumes (postgres / redis / minio) ───────────────────────── # ── Local data volumes (postgres / redis / minio) ─────────────────────────

View File

@@ -83,5 +83,6 @@ class VkSource(Base):
group_id: Mapped[str] = mapped_column(String(50), unique=True) # числовой ID без минуса group_id: Mapped[str] = mapped_column(String(50), unique=True) # числовой ID без минуса
group_name: Mapped[str] = mapped_column(String(200)) # станет названием категории group_name: Mapped[str] = mapped_column(String(200)) # станет названием категории
enabled: Mapped[bool] = mapped_column(Boolean, default=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True)
strict_filter: Mapped[bool] = mapped_column(Boolean, default=True) # True = только посты с тегами
last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_run: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -17,6 +17,7 @@ from route.admin_articles import router as articles_router
from route.admin_categories import router as categories_router from route.admin_categories import router as categories_router
from route.admin_media import router as media_router from route.admin_media import router as media_router
from route.admin_vk import router as vk_router from route.admin_vk import router as vk_router
from route.admin_system import router as system_router
import scheduler import scheduler
DOCS_USER = os.getenv("DOCS_USERNAME", "admin") DOCS_USER = os.getenv("DOCS_USERNAME", "admin")
@@ -102,6 +103,7 @@ app.include_router(articles_router, prefix="/admin/articles", tags=["admin-art
app.include_router(categories_router,prefix="/admin/categories",tags=["admin-categories"]) app.include_router(categories_router,prefix="/admin/categories",tags=["admin-categories"])
app.include_router(media_router, prefix="/admin/media", tags=["admin-media"]) app.include_router(media_router, prefix="/admin/media", tags=["admin-media"])
app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"]) app.include_router(vk_router, prefix="/admin/vk", tags=["admin-vk"])
app.include_router(system_router, prefix="/admin/system", tags=["admin-system"])
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,26 @@
"""add strict_filter to vk_sources
Revision ID: a1b2c3d4e5f6
Revises: 25d6ed4a524a
Create Date: 2026-05-19 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '25d6ed4a524a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('vk_sources',
sa.Column('strict_filter', sa.Boolean(), nullable=False, server_default=sa.true())
)
def downgrade() -> None:
op.drop_column('vk_sources', 'strict_filter')

115
api/route/admin_system.py Normal file
View File

@@ -0,0 +1,115 @@
import os
import time
import asyncio
from urllib.parse import urlparse
from fastapi import APIRouter, Depends
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from bd.database import get_db, get_redis, DATABASE_URL, REDIS_URL
from route.deps import require_admin
router = APIRouter()
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000")
def _parse_db_url(url: str) -> dict:
"""Вытащить host/port/db/user из DATABASE_URL без пароля."""
try:
# postgresql+asyncpg://user:pass@host:port/db
p = urlparse(url.replace("+asyncpg", ""))
return {
"host": p.hostname or "?",
"port": p.port or 5432,
"database": (p.path or "/").lstrip("/") or "?",
"user": p.username or "?",
}
except Exception:
return {"host": "?", "port": 5432, "database": "?", "user": "?"}
def _parse_redis_url(url: str) -> dict:
"""Вытащить host/port из REDIS_URL без пароля."""
try:
p = urlparse(url)
return {
"host": p.hostname or "?",
"port": p.port or 6379,
"db": (p.path or "/0").lstrip("/") or "0",
}
except Exception:
return {"host": "?", "port": 6379, "db": "0"}
async def _check_postgres(db: AsyncSession) -> dict:
info = _parse_db_url(DATABASE_URL)
t0 = time.monotonic()
try:
result = await db.execute(text("SELECT version()"))
version = result.scalar() or ""
latency_ms = round((time.monotonic() - t0) * 1000, 1)
# Извлекаем короткую версию: "PostgreSQL 15.3 ..."
short_ver = version.split(",")[0] if version else "?"
return {"ok": True, "latency_ms": latency_ms, "version": short_ver, **info}
except Exception as e:
return {"ok": False, "error": str(e), "latency_ms": None, **info}
async def _check_redis() -> dict:
info = _parse_redis_url(REDIS_URL)
t0 = time.monotonic()
try:
redis = await get_redis()
pong = await redis.ping()
latency_ms = round((time.monotonic() - t0) * 1000, 1)
server_info = await redis.info("server")
version = server_info.get("redis_version", "?")
return {"ok": bool(pong), "latency_ms": latency_ms, "version": f"Redis {version}", **info}
except Exception as e:
return {"ok": False, "error": str(e), "latency_ms": None, **info}
async def _check_minio() -> dict:
t0 = time.monotonic()
try:
from minio import Minio
loop = asyncio.get_event_loop()
client = Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False)
exists = await loop.run_in_executor(None, client.bucket_exists, MINIO_BUCKET)
latency_ms = round((time.monotonic() - t0) * 1000, 1)
return {
"ok": True,
"latency_ms": latency_ms,
"endpoint": MINIO_ENDPOINT,
"bucket": MINIO_BUCKET,
"public_url": MINIO_PUBLIC_URL,
"bucket_exists": exists,
}
except Exception as e:
return {
"ok": False,
"error": str(e),
"latency_ms": None,
"endpoint": MINIO_ENDPOINT,
"bucket": MINIO_BUCKET,
"public_url": MINIO_PUBLIC_URL,
}
@router.get("/status")
async def system_status(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)):
pg, redis, minio = await asyncio.gather(
_check_postgres(db),
_check_redis(),
_check_minio(),
)
return {
"postgres": pg,
"redis": redis,
"minio": minio,
}

View File

@@ -15,6 +15,7 @@ class SourceIn(BaseModel):
group_id: str group_id: str
group_name: str group_name: str
enabled: bool = True enabled: bool = True
strict_filter: bool = True
def _source_dict(s: VkSource) -> dict: def _source_dict(s: VkSource) -> dict:
@@ -23,6 +24,7 @@ def _source_dict(s: VkSource) -> dict:
"group_id": s.group_id, "group_id": s.group_id,
"group_name": s.group_name, "group_name": s.group_name,
"enabled": s.enabled, "enabled": s.enabled,
"strict_filter": s.strict_filter,
"last_run": s.last_run.isoformat() if s.last_run else None, "last_run": s.last_run.isoformat() if s.last_run else None,
"created_at": s.created_at.isoformat(), "created_at": s.created_at.isoformat(),
} }
@@ -56,7 +58,7 @@ async def add_source(data: SourceIn, db: AsyncSession = Depends(get_db), _: str
exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none() exists = (await db.execute(select(VkSource).where(VkSource.group_id == group_id))).scalar_one_or_none()
if exists: if exists:
raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть") raise HTTPException(status_code=409, detail="Источник с таким group_id уже есть")
source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled) source = VkSource(group_id=group_id, group_name=data.group_name, enabled=data.enabled, strict_filter=data.strict_filter)
db.add(source) db.add(source)
await db.commit() await db.commit()
await db.refresh(source) await db.refresh(source)
@@ -71,6 +73,7 @@ async def update_source(source_id: str, data: SourceIn, db: AsyncSession = Depen
source.group_id = _clean_group_id(data.group_id) source.group_id = _clean_group_id(data.group_id)
source.group_name = data.group_name source.group_name = data.group_name
source.enabled = data.enabled source.enabled = data.enabled
source.strict_filter = data.strict_filter
await db.commit() await db.commit()
await db.refresh(source) await db.refresh(source)
return _source_dict(source) return _source_dict(source)
@@ -94,20 +97,25 @@ async def vk_manual_import(_: str = Depends(require_admin)):
@router.post("/import/history") @router.post("/import/history")
async def vk_history_import_all(background_tasks: BackgroundTasks, _: str = Depends(require_admin)): async def vk_history_import_all(
background_tasks.add_task(run_history_import) background_tasks: BackgroundTasks,
return {"ok": True, "message": "Исторический импорт всех групп запущен в фоне (до 5 мин)"} since_days: int = 365,
_: str = Depends(require_admin),
):
background_tasks.add_task(run_history_import, since_days=since_days)
return {"ok": True, "message": f"Исторический импорт всех групп запущен в фоне (глубина {since_days} дн.)"}
@router.post("/import/history/{source_id}") @router.post("/import/history/{source_id}")
async def vk_history_import_one( async def vk_history_import_one(
source_id: str, source_id: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
since_days: int = 365,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(require_admin), _: str = Depends(require_admin),
): ):
source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none() source = (await db.execute(select(VkSource).where(VkSource.id == source_id))).scalar_one_or_none()
if not source: if not source:
raise HTTPException(status_code=404, detail="Источник не найден") raise HTTPException(status_code=404, detail="Источник не найден")
background_tasks.add_task(run_history_import, group_id=source.group_id) background_tasks.add_task(run_history_import, group_id=source.group_id, since_days=since_days)
return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"} return {"ok": True, "message": f"Исторический импорт группы «{source.group_name}» запущен в фоне"}

View File

@@ -359,6 +359,12 @@ async def _slug_exists(slug: str, db: AsyncSession) -> bool:
return r.scalar_one_or_none() is not None return r.scalar_one_or_none() is not None
async def _vk_post_exists(owner_id: int, post_id: int, db: AsyncSession) -> bool:
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
r = await db.execute(select(Article.id).where(Article.source_url == source_url))
return r.scalar_one_or_none() is not None
async def _get_or_create_category(name: str, db: AsyncSession) -> Category: async def _get_or_create_category(name: str, db: AsyncSession) -> Category:
s = slugify(name) s = slugify(name)
cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none() cat = (await db.execute(select(Category).where(Category.slug == s))).scalar_one_or_none()
@@ -381,21 +387,39 @@ async def _get_or_create_tag(name: str, db: AsyncSession) -> Tag:
# ── Post processing ─────────────────────────────────────────────────────────── # ── Post processing ───────────────────────────────────────────────────────────
async def _process_post(post: dict, category_name: str, db: AsyncSession) -> bool: async def _process_post(post: dict, category_name: str, db: AsyncSession, strict_filter: bool = True) -> bool:
owner_id = post["owner_id"] owner_id = post["owner_id"]
post_id = post["id"] post_id = post["id"]
vk_slug = _vk_slug(owner_id, post_id) vk_slug = _vk_slug(owner_id, post_id)
if await _slug_exists(vk_slug, db): if await _vk_post_exists(owner_id, post_id, db):
return False return False
text = post.get("text", "") text = post.get("text", "")
attachments = post.get("attachments", []) attachments = post.get("attachments", [])
ts = post["date"] ts = post["date"]
# Фильтр: импортируем только посты с разрешёнными тегами # Обработка репостов (copy_history)
copy_history = post.get("copy_history", [])
if copy_history:
original = copy_history[0]
orig_gid = str(abs(original.get("owner_id", 0)))
in_db = (await db.execute(
select(VkSource.id).where(VkSource.group_id == orig_gid)
)).scalar_one_or_none()
if in_db:
# Оригинал придёт сам из своей группы — пропускаем
return False
# Берём содержимое оригинала; комментарий репостера добавляем в начало
orig_text = original.get("text", "")
orig_atts = original.get("attachments", [])
text = (text + "\n" + orig_text).strip() if text else orig_text
attachments = orig_atts if orig_atts else attachments
tag_names = _detect_tags(text) tag_names = _detect_tags(text)
if not tag_names: # strict_filter=True: пропускаем пост если нет тегов
# strict_filter=False: импортируем всё, теги назначаем если найдены
if strict_filter and not tag_names:
return False return False
# Заранее генерируем UUID и slug — используются в путях MinIO # Заранее генерируем UUID и slug — используются в путях MinIO
@@ -648,7 +672,7 @@ async def run_import() -> dict:
batch_new = 0 batch_new = 0
for post in posts: for post in posts:
try: try:
ok = await _process_post(post, source.group_name, db) ok = await _process_post(post, source.group_name, db, source.strict_filter)
if ok: if ok:
imported += 1; grp_new += 1; batch_new += 1 imported += 1; grp_new += 1; batch_new += 1
else: else:
@@ -721,7 +745,7 @@ async def run_history_import(group_id: str | None = None, since_days: int = 365)
hit_cutoff = True hit_cutoff = True
continue continue
try: try:
ok = await _process_post(post, source.group_name, db) ok = await _process_post(post, source.group_name, db, source.strict_filter)
if ok: if ok:
imported += 1; grp_new += 1; batch_new += 1 imported += 1; grp_new += 1; batch_new += 1
else: else:

View File

@@ -0,0 +1,61 @@
services:
# ── FastAPI backend ────────────────────────────────────────────────────────
api:
build:
context: ./api
dockerfile: Dockerfile
restart: unless-stopped
environment:
DATABASE_URL: ${DATABASE_URL}
REDIS_URL: ${REDIS_URL}
JWT_SECRET: ${JWT_SECRET}
MINIO_ENDPOINT: ${MINIO_ENDPOINT}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL}
DOCS_USERNAME: ${DOCS_USERNAME:-admin}
DOCS_PASSWORD: ${DOCS_PASSWORD:-admin}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme}
VK_ACCESS_TOKEN: ${VK_ACCESS_TOKEN:-}
VK_IMPORT_STATUS: ${VK_IMPORT_STATUS:-published}
VK_POSTS_PER_RUN: ${VK_POSTS_PER_RUN:-100}
VK_IMPORT_HOUR: ${VK_IMPORT_HOUR:-6}
VK_IMPORT_MINUTE: ${VK_IMPORT_MINUTE:-0}
ports:
- "8000:8000"
healthcheck:
test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\" || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- news_net
# ── React SPA + TipTap editor + nginx ─────────────────────────────────────
web:
build:
context: .
dockerfile: web-react/Dockerfile
restart: unless-stopped
ports:
- "80:80"
depends_on:
api:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks:
- news_net
networks:
news_net:
driver: bridge

View File

@@ -1,18 +1,82 @@
services: services:
# ── PostgreSQL ────────────────────────────────────────────────────────────
db:
image: postgres:15-alpine
restart: unless-stopped
environment:
POSTGRES_DB: news
POSTGRES_USER: news_admin
POSTGRES_PASSWORD: ${NEWS_DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U news_admin -d news"]
interval: 5s
timeout: 5s
retries: 10
networks:
- news_net
# ── Redis ─────────────────────────────────────────────────────────────────
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 5s
retries: 10
networks:
- news_net
# ── MinIO ─────────────────────────────────────────────────────────────────
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
ports:
- "9000:9000"
- "9001:9001"
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks:
- news_net
# ── FastAPI backend ──────────────────────────────────────────────────────── # ── FastAPI backend ────────────────────────────────────────────────────────
api: api:
build: build:
context: ./api context: ./api
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
environment: environment:
DATABASE_URL: ${DATABASE_URL} DATABASE_URL: postgresql+asyncpg://news_admin:${NEWS_DB_PASSWORD}@db:5432/news
REDIS_URL: ${REDIS_URL} REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379/0
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
MINIO_ENDPOINT: ${MINIO_ENDPOINT:-192.168.1.21:9000} MINIO_ENDPOINT: minio:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-minioadmin} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_BUCKET: ${MINIO_BUCKET:-news-media} MINIO_BUCKET: ${MINIO_BUCKET:-news-media}
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000} MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL:-http://localhost:9000}
DOCS_USERNAME: ${DOCS_USERNAME:-admin} DOCS_USERNAME: ${DOCS_USERNAME:-admin}
@@ -47,7 +111,7 @@ services:
api: api:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost/ || exit 1"] test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/ || exit 1"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -56,7 +120,11 @@ services:
- news_net - news_net
volumes:
db_data:
redis_data:
minio_data:
networks: networks:
news_net: news_net:
external: true driver: bridge
name: news_net

View File

@@ -13,6 +13,7 @@ import ArticleEditor from './pages/admin/ArticleEditor'
import Categories from './pages/admin/Categories' import Categories from './pages/admin/Categories'
import MediaLibrary from './pages/admin/MediaLibrary' import MediaLibrary from './pages/admin/MediaLibrary'
import VkSources from './pages/admin/VkSources' import VkSources from './pages/admin/VkSources'
import SystemStatus from './pages/admin/SystemStatus'
function PrivateRoute({ children }) { function PrivateRoute({ children }) {
const { isAuth } = useAuth() const { isAuth } = useAuth()
@@ -36,6 +37,7 @@ export default function App() {
<Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} /> <Route path="/admin/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
<Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} /> <Route path="/admin/media" element={<PrivateRoute><MediaLibrary /></PrivateRoute>} />
<Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} /> <Route path="/admin/vk" element={<PrivateRoute><VkSources /></PrivateRoute>} />
<Route path="/admin/system" element={<PrivateRoute><SystemStatus /></PrivateRoute>} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -97,11 +97,11 @@ export const adminVkStatus = token => req('GET', 'admin/vk/status', token)
export const adminVkListSources = token => req('GET', 'admin/vk/sources', token) export const adminVkListSources = token => req('GET', 'admin/vk/sources', token)
export const adminVkAddSource = (token, group_id, group_name, enabled = true) => export const adminVkAddSource = (token, group_id, group_name, enabled = true, strict_filter = true) =>
req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled }) req('POST', 'admin/vk/sources', token, { group_id, group_name, enabled, strict_filter })
export const adminVkUpdateSource = (token, id, group_id, group_name, enabled) => export const adminVkUpdateSource = (token, id, group_id, group_name, enabled, strict_filter = true) =>
req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled }) req('PATCH', `admin/vk/sources/${id}`, token, { group_id, group_name, enabled, strict_filter })
export const adminVkDeleteSource = (token, id) => export const adminVkDeleteSource = (token, id) =>
req('DELETE', `admin/vk/sources/${id}`, token) req('DELETE', `admin/vk/sources/${id}`, token)
@@ -109,5 +109,8 @@ export const adminVkDeleteSource = (token, id) =>
export const adminVkImport = token => export const adminVkImport = token =>
req('POST', 'admin/vk/import', token) req('POST', 'admin/vk/import', token)
export const adminVkImportHistory = (token, sourceId) => export const adminVkImportHistory = (token, sourceId, sinceDays = 365) =>
req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}`, token) req('POST', `admin/vk/import/history${sourceId ? `/${sourceId}` : ''}?since_days=${sinceDays}`, token)
export const adminSystemStatus = token =>
req('GET', 'admin/system/status', token)

View File

@@ -9,6 +9,7 @@ const NAV = [
{ to: '/admin/categories', label: 'Категории', icon: '🏷️' }, { to: '/admin/categories', label: 'Категории', icon: '🏷️' },
{ to: '/admin/media', label: 'Медиа', icon: '🖼️' }, { to: '/admin/media', label: 'Медиа', icon: '🖼️' },
{ to: '/admin/vk', label: 'ВКонтакте', icon: '📥' }, { to: '/admin/vk', label: 'ВКонтакте', icon: '📥' },
{ to: '/admin/system', label: 'Сервисы', icon: '🖥️' },
] ]
export default function AdminLayout({ children, title }) { export default function AdminLayout({ children, title }) {

View File

@@ -9,7 +9,7 @@ const DATE_PRESETS = [
] ]
export default function FilterBar({ categories, tags, filters, onChange }) { export default function FilterBar({ categories, tags, filters, onChange }) {
const set = key => val => onChange({ ...filters, [key]: val, page: 1 }) const set = key => val => onChange({ ...filters, [key]: val })
return ( return (
<div className="filter-bar"> <div className="filter-bar">

View File

@@ -24,7 +24,13 @@ export default function NewsFeed() {
const [categories, setCategories] = useState([]) const [categories, setCategories] = useState([])
const [tags, setTags] = useState([]) const [tags, setTags] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '', page: 1 }) const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false)
const [filters, setFilters] = useState({ category: null, tag: null, datePreset: '' })
const offsetRef = useRef(0)
const busyRef = useRef(false)
const sentinelRef = useRef(null)
// Поиск с debounce // Поиск с debounce
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
@@ -35,19 +41,14 @@ export default function NewsFeed() {
const val = e.target.value const val = e.target.value
setSearchInput(val) setSearchInput(val)
clearTimeout(debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => setSearchQuery(val.trim()), 400)
setSearchQuery(val.trim())
setFilters(f => ({ ...f, page: 1 }))
}, 400)
} }
const clearSearch = () => { const clearSearch = () => {
setSearchInput('') setSearchInput('')
setSearchQuery('') setSearchQuery('')
setFilters(f => ({ ...f, page: 1 }))
} }
// Модальное окно
const [modalArticle, setModalArticle] = useState(null) const [modalArticle, setModalArticle] = useState(null)
useEffect(() => { useEffect(() => {
@@ -57,30 +58,71 @@ export default function NewsFeed() {
}).catch(() => {}) }).catch(() => {})
}, []) }, [])
const loadNews = useCallback(async () => { const buildParams = useCallback((offset) => {
setLoading(true) const params = { limit: PAGE_SIZE, offset }
try {
const params = {
limit: PAGE_SIZE,
offset: (filters.page - 1) * PAGE_SIZE,
}
if (filters.category) params.category = filters.category if (filters.category) params.category = filters.category
if (filters.tag) params.tag = filters.tag if (filters.tag) params.tag = filters.tag
const df = presetToDateFrom(filters.datePreset) const df = presetToDateFrom(filters.datePreset)
if (df) params.date_from = df if (df) params.date_from = df
if (searchQuery) params.q = searchQuery if (searchQuery) params.q = searchQuery
return params
const data = await getNews(params)
setArticles(data.items ?? [])
setTotal(data.total ?? 0)
} catch {
setArticles([])
} finally {
setLoading(false)
}
}, [filters, searchQuery]) }, [filters, searchQuery])
useEffect(() => { loadNews() }, [loadNews]) // Сброс и первая загрузка при смене фильтров/поиска
useEffect(() => {
let cancelled = false
offsetRef.current = 0
busyRef.current = true
setLoading(true)
setArticles([])
setHasMore(false)
getNews(buildParams(0)).then(data => {
if (cancelled) return
setArticles(data.items ?? [])
setTotal(data.total ?? 0)
setHasMore(data.has_more ?? false)
offsetRef.current = PAGE_SIZE
}).catch(() => {
if (!cancelled) setArticles([])
}).finally(() => {
if (!cancelled) {
setLoading(false)
busyRef.current = false
}
})
return () => { cancelled = true }
}, [buildParams])
// Подгрузка следующего батча
const loadMore = useCallback(async () => {
if (busyRef.current || !hasMore) return
busyRef.current = true
setLoadingMore(true)
try {
const data = await getNews(buildParams(offsetRef.current))
setArticles(prev => [...prev, ...(data.items ?? [])])
setHasMore(data.has_more ?? false)
offsetRef.current += PAGE_SIZE
} catch {}
finally {
setLoadingMore(false)
busyRef.current = false
}
}, [hasMore, buildParams])
// IntersectionObserver на sentinel-div внизу списка
useEffect(() => {
const el = sentinelRef.current
if (!el) return
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore() },
{ rootMargin: '300px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [loadMore])
const openArticle = async (slug) => { const openArticle = async (slug) => {
setModalArticle({ _loading: true }) setModalArticle({ _loading: true })
@@ -92,8 +134,6 @@ export default function NewsFeed() {
} }
} }
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
return ( return (
<> <>
<header className="public-header"> <header className="public-header">
@@ -104,7 +144,6 @@ export default function NewsFeed() {
</button> </button>
</div> </div>
{/* Строка поиска */}
<div className="search-bar-wrap"> <div className="search-bar-wrap">
<div className="search-bar"> <div className="search-bar">
<svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg className="search-bar__icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -129,10 +168,9 @@ export default function NewsFeed() {
categories={categories} categories={categories}
tags={tags} tags={tags}
filters={filters} filters={filters}
onChange={setFilters} onChange={f => setFilters(f)}
/> />
{/* Счётчик результатов при поиске */}
{searchQuery && !loading && ( {searchQuery && !loading && (
<p className="search-results-hint"> <p className="search-results-hint">
{total > 0 {total > 0
@@ -142,48 +180,47 @@ export default function NewsFeed() {
</p> </p>
)} )}
{loading {loading ? (
? <Loader /> <Loader />
: articles.length === 0 ) : articles.length === 0 ? (
? <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}> <p style={{ textAlign: 'center', color: 'var(--c-text-2)', marginTop: '3rem' }}>
Новостей не найдено Новостей не найдено
</p> </p>
: <div className="news-list"> ) : (
<div className="news-list">
{articles.map(a => ( {articles.map(a => (
<NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} /> <NewsCard key={a.id} article={a} onClick={() => openArticle(a.slug)} />
))} ))}
</div> </div>
} )}
{!loading && totalPages > 1 && ( {/* Sentinel для IntersectionObserver */}
<div className="pagination"> <div ref={sentinelRef} style={{ height: 1 }} />
<button
className="btn btn-ghost btn-sm" {loadingMore && (
disabled={filters.page <= 1} <div style={{ display: 'flex', justifyContent: 'center', padding: '1.5rem 0' }}>
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))} <Loader />
> Назад</button>
<span className="pagination__info">{filters.page} / {totalPages}</span>
<button
className="btn btn-ghost btn-sm"
disabled={filters.page >= totalPages}
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
>Вперёд </button>
</div> </div>
)} )}
{!loading && !hasMore && articles.length > 0 && (
<p style={{ textAlign: 'center', color: 'var(--c-text-2)', fontSize: '0.85rem', padding: '1.5rem 0' }}>
Все новости загружены · {total}
</p>
)}
</div> </div>
{/* Модальное окно статьи */}
{modalArticle && ( {modalArticle && (
modalArticle._loading modalArticle._loading ? (
? (
<div className="article-modal-overlay" onClick={() => setModalArticle(null)}> <div className="article-modal-overlay" onClick={() => setModalArticle(null)}>
<div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }} <div className="article-modal" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 200 }}
onClick={e => e.stopPropagation()}> onClick={e => e.stopPropagation()}>
<Loader /> <Loader />
</div> </div>
</div> </div>
) : (
<ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
) )
: <ArticleModal article={modalArticle} onClose={() => setModalArticle(null)} />
)} )}
</> </>
) )

View File

@@ -0,0 +1,144 @@
import React, { useState, useEffect, useCallback } from 'react'
import { useAuth } from '../../hooks/useAuth'
import { adminSystemStatus } from '../../api/index'
import AdminLayout from '../../components/AdminLayout'
import Loader from '../../components/Loader'
function StatusBadge({ ok }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 10px', borderRadius: 99, fontSize: '0.8rem', fontWeight: 600,
background: ok ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
color: ok ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
}}>
<span style={{ fontSize: '0.65rem' }}>{ok ? '●' : '●'}</span>
{ok ? 'OK' : 'Ошибка'}
</span>
)
}
function ServiceCard({ title, icon, data }) {
if (!data) return null
const rows = Object.entries(data).filter(([k]) => !['ok', 'error'].includes(k))
return (
<div style={{
background: 'var(--c-surface)',
borderRadius: 'var(--r-card)',
boxShadow: 'var(--shadow-card)',
padding: '1.25rem 1.5rem',
borderLeft: `4px solid ${data.ok ? 'var(--c-success, #10b981)' : 'var(--c-danger, #ef4444)'}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.4rem' }}>{icon}</span>
<span style={{ fontWeight: 700, fontSize: '1rem' }}>{title}</span>
</div>
<StatusBadge ok={data.ok} />
</div>
{data.error && (
<div style={{
background: 'var(--c-danger-bg, #fee2e2)',
color: 'var(--c-danger, #991b1b)',
borderRadius: 6, padding: '0.5rem 0.75rem',
fontSize: '0.8rem', fontFamily: 'monospace',
marginBottom: '0.75rem', wordBreak: 'break-all',
}}>
{data.error}
</div>
)}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<tbody>
{rows.map(([key, value]) => (
<tr key={key} style={{ borderBottom: '1px solid var(--c-border, #e5e7eb)' }}>
<td style={{ padding: '0.4rem 0', color: 'var(--c-text-2)', width: '40%', fontWeight: 500 }}>
{LABELS[key] ?? key}
</td>
<td style={{ padding: '0.4rem 0', fontFamily: 'monospace', wordBreak: 'break-all' }}>
{value === null ? <span style={{ color: 'var(--c-text-2)' }}></span>
: value === true ? '✅ да'
: value === false ? '❌ нет'
: String(value)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const LABELS = {
host: 'Хост',
port: 'Порт',
database: 'База данных',
user: 'Пользователь',
version: 'Версия',
latency_ms: 'Задержка (мс)',
db: 'Номер БД',
endpoint: 'Эндпоинт',
bucket: 'Бакет',
public_url: 'Публичный URL',
bucket_exists: 'Бакет существует',
}
export default function SystemStatus() {
const { token } = useAuth()
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [lastUpdated, setLastUpdated] = useState(null)
const load = useCallback(async () => {
setLoading(true)
try {
const result = await adminSystemStatus(token)
setData(result)
setLastUpdated(new Date())
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}, [token])
useEffect(() => { load() }, [load])
const allOk = data && Object.values(data).every(s => s.ok)
return (
<AdminLayout title="Состояние сервисов">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
{data && (
<span style={{
padding: '4px 14px', borderRadius: 99, fontWeight: 700, fontSize: '0.85rem',
background: allOk ? 'var(--c-success-bg, #d1fae5)' : 'var(--c-danger-bg, #fee2e2)',
color: allOk ? 'var(--c-success, #065f46)' : 'var(--c-danger, #991b1b)',
}}>
{allOk ? '✅ Все сервисы работают' : '⚠️ Есть проблемы'}
</span>
)}
{lastUpdated && (
<span style={{ fontSize: '0.8rem', color: 'var(--c-text-2)' }}>
Обновлено: {lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
<button className="btn btn-ghost btn-sm" onClick={load} disabled={loading}>
{loading ? 'Проверяю...' : '🔄 Обновить'}
</button>
</div>
{loading && !data ? <Loader /> : (
<div style={{ display: 'grid', gap: '1rem', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))' }}>
<ServiceCard title="PostgreSQL" icon="🐘" data={data?.postgres} />
<ServiceCard title="Redis" icon="⚡" data={data?.redis} />
<ServiceCard title="MinIO" icon="🗄️" data={data?.minio} />
</div>
)}
</AdminLayout>
)
}

View File

@@ -17,8 +17,9 @@ export default function VkSources() {
const [confirm, setConfirm] = useState(null) const [confirm, setConfirm] = useState(null)
const [alert, setAlert] = useState(null) const [alert, setAlert] = useState(null)
const [importing, setImporting] = useState('') const [importing, setImporting] = useState('')
const [sinceDays, setSinceDays] = useState(365)
const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true }) const [form, setForm] = useState({ group_id: '', group_name: '', enabled: true, strict_filter: true })
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
const load = async () => { const load = async () => {
@@ -40,8 +41,8 @@ export default function VkSources() {
const doAdd = async e => { const doAdd = async e => {
e.preventDefault() e.preventDefault()
try { try {
await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled) await adminVkAddSource(token, form.group_id.trim(), form.group_name.trim(), form.enabled, form.strict_filter)
setForm({ group_id: '', group_name: '', enabled: true }) setForm({ group_id: '', group_name: '', enabled: true, strict_filter: true })
flash('success', 'Источник добавлен') flash('success', 'Источник добавлен')
load() load()
} catch(err) { flash('error', String(err.message)) } } catch(err) { flash('error', String(err.message)) }
@@ -50,7 +51,7 @@ export default function VkSources() {
const doUpdate = async () => { const doUpdate = async () => {
if (!editing) return if (!editing) return
try { try {
await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled) await adminVkUpdateSource(token, editing.id, editing.group_id, editing.group_name, editing.enabled, editing.strict_filter)
setEditing(null) setEditing(null)
flash('success', 'Сохранено') flash('success', 'Сохранено')
load() load()
@@ -79,7 +80,7 @@ export default function VkSources() {
const doHistory = async (sourceId = null) => { const doHistory = async (sourceId = null) => {
setImporting('history') setImporting('history')
try { try {
const r = await adminVkImportHistory(token, sourceId) const r = await adminVkImportHistory(token, sourceId, sinceDays)
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`) flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
} catch { flash('error', 'Ошибка загрузки истории') } } catch { flash('error', 'Ошибка загрузки истории') }
finally { setImporting('') } finally { setImporting('') }
@@ -103,7 +104,17 @@ export default function VkSources() {
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}> <span style={{ fontWeight: 600, fontSize: '0.9rem' }}>
{status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'} {status?.configured ? `ВК API настроен (токен: ${status.token_preview ?? '***'})` : 'ВК API не настроен (нет токена)'}
</span> </span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}> <div style={{ marginLeft: 'auto', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: '0.85rem' }}>
<span style={{ color: 'var(--c-text-2)' }}>Глубина:</span>
<input
type="number" min={1} max={3650} value={sinceDays}
onChange={e => setSinceDays(Number(e.target.value))}
className="form-input"
style={{ width: 70, padding: '3px 6px', fontSize: '0.85rem' }}
/>
<span style={{ color: 'var(--c-text-2)' }}>дн.</span>
</label>
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={doImport} onClick={doImport}
@@ -136,6 +147,11 @@ export default function VkSources() {
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} /> onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} />
Активен Активен
</label> </label>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: '0.875rem', paddingBottom: 4 }}>
<input type="checkbox" checked={form.strict_filter}
onChange={e => setForm(f => ({ ...f, strict_filter: e.target.checked }))} />
Строгий фильтр
</label>
<button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button> <button className="btn btn-primary" type="submit" style={{ paddingBottom: 9, paddingTop: 9 }}>Добавить</button>
</div> </div>
</form> </form>
@@ -148,6 +164,7 @@ export default function VkSources() {
<th>ID группы</th> <th>ID группы</th>
<th>Название</th> <th>Название</th>
<th>Активен</th> <th>Активен</th>
<th title="Строгий фильтр: только посты с тегами. Выкл — все посты паблика">Фильтр тегов</th>
<th>Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
@@ -178,7 +195,7 @@ export default function VkSources() {
className="toggle-btn" className="toggle-btn"
data-on={s.enabled} data-on={s.enabled}
title={s.enabled ? 'Отключить' : 'Включить'} title={s.enabled ? 'Отключить' : 'Включить'}
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled) onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, !s.enabled, s.strict_filter)
.then(load).catch(() => flash('error', 'Ошибка'))} .then(load).catch(() => flash('error', 'Ошибка'))}
> >
{s.enabled ? '✅' : '❌'} {s.enabled ? '✅' : '❌'}
@@ -186,6 +203,23 @@ export default function VkSources() {
) )
} }
</td> </td>
<td>
{editing?.id === s.id
? <input type="checkbox" checked={editing.strict_filter}
onChange={e => setEditing(ed => ({ ...ed, strict_filter: e.target.checked }))} />
: (
<button
className="toggle-btn"
data-on={s.strict_filter}
title={s.strict_filter ? 'Строгий фильтр: только посты с тегами. Нажмите чтобы отключить' : 'Фильтр выключен: импортируются все посты. Нажмите чтобы включить'}
onClick={() => adminVkUpdateSource(token, s.id, s.group_id, s.group_name, s.enabled, !s.strict_filter)
.then(load).catch(() => flash('error', 'Ошибка'))}
>
{s.strict_filter ? '🔒' : '🔓'}
</button>
)
}
</td>
<td> <td>
<div className="td-actions"> <div className="td-actions">
{editing?.id === s.id {editing?.id === s.id
@@ -208,7 +242,7 @@ export default function VkSources() {
</tr> </tr>
))} ))}
{sources.length === 0 && ( {sources.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}> <tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--c-text-2)', padding: '2rem' }}>
Источников нет Источников нет
</td></tr> </td></tr>
)} )}

View File

@@ -73,6 +73,9 @@ export default function App() {
const articleLoadedRef = useRef(false) const articleLoadedRef = useRef(false)
const doSaveRef = useRef(null) // always points to latest doSave const doSaveRef = useRef(null) // always points to latest doSave
// Loaded article data — fetched independently of editor lifecycle
const [articleData, setArticleData] = useState(null)
/* ── Editor ── */ /* ── Editor ── */
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
@@ -84,7 +87,14 @@ export default function App() {
Color, Color,
Highlight.configure({ multicolor: true }), Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }), TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
Image.configure({ allowBase64: false }), Image.extend({
addAttributes() {
return {
...this.parent?.(),
style: { default: null },
}
},
}).configure({ allowBase64: false }),
Video, Video,
Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }), Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }),
Table.configure({ resizable: true }), Table.configure({ resizable: true }),
@@ -105,45 +115,51 @@ export default function App() {
// Keep editorRef current every render so doSave never uses a stale instance // Keep editorRef current every render so doSave never uses a stale instance
editorRef.current = editor editorRef.current = editor
/* ── Load categories once on mount ── */ /* ── Load categories + article data on mount (independent of editor) ── */
useEffect(() => { useEffect(() => {
api.getCategories(TOKEN).then(setCategories).catch(() => {}) api.getCategories(TOKEN).then(setCategories).catch(() => {})
if (!IS_NEW) {
api.getArticle(ARTICLE_ID, TOKEN).then(setArticleData).catch(() => {})
}
}, []) }, [])
/* ── Load article once the editor is ready ── */ /* ── Apply article data once BOTH editor AND data are ready ── */
useEffect(() => { useEffect(() => {
if (!editor || IS_NEW || articleLoadedRef.current) return if (!editor || !articleData || articleLoadedRef.current) return
articleLoadedRef.current = true articleLoadedRef.current = true
api.getArticle(ARTICLE_ID, TOKEN)
.then(data => {
setMeta({ setMeta({
id: data.id, id: articleData.id,
title: data.title, title: articleData.title,
slug: data.slug, slug: articleData.slug,
excerpt: data.excerpt || '', excerpt: articleData.excerpt || '',
cover_url: data.cover_url || null, cover_url: articleData.cover_url || null,
source_url: data.source_url || null, source_url: articleData.source_url || null,
font_family: data.font_family || 'Merriweather', font_family: articleData.font_family || 'Merriweather',
category_id: data.category?.id || null, category_id: articleData.category?.id || null,
tag_names: (data.tags || []).map(t => t.name), tag_names: (articleData.tags || []).map(t => t.name),
status: data.status, status: articleData.status,
}) })
const html = data.content?.startsWith('<')
? data.content const html = articleData.content?.startsWith('<')
: marked.parse(data.content || '') ? articleData.content
: marked.parse(articleData.content || '')
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(html) const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(html)
// Use setTimeout to ensure React has committed meta state before setContent
setTimeout(() => {
const ed = editorRef.current
if (!ed) return
if (isFullDoc) { if (isFullDoc) {
setRawHtml(html) setRawHtml(html)
editor.commands.setContent('', false) ed.commands.setContent('', false)
} else { } else {
setRawHtml(null) setRawHtml(null)
editor.commands.setContent(html, false) ed.commands.setContent(html, false)
} }
setSaveStatus('saved') setSaveStatus('saved')
}) }, 0)
.catch(() => {}) }, [editor, articleData])
}, [editor])
/* ── Save ───────────────────────────────────────────────────────────────── /* ── Save ─────────────────────────────────────────────────────────────────
Uses editorRef + metaRef so this callback is stable (no deps that change). Uses editorRef + metaRef so this callback is stable (no deps that change).