fix teg
This commit is contained in:
@@ -15,7 +15,6 @@ ALLOWED_ORIGINS=http://localhost,http://localhost:80
|
||||
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# VK импорт (необязательно)
|
||||
# Сервисный токен приложения: vk.com/apps → Настройки → Сервисный ключ доступа
|
||||
VK_ACCESS_TOKEN=
|
||||
|
||||
149
api/alembic.ini
Normal file
149
api/alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -72,6 +72,9 @@ class Media(Base):
|
||||
media_type: Mapped[str] = mapped_column(String(20)) # image | video
|
||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
article_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("articles.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class VkSource(Base):
|
||||
|
||||
17
api/main.py
17
api/main.py
@@ -10,7 +10,7 @@ from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from rate_limiter import limiter
|
||||
|
||||
from bd.database import wait_for_db, engine, Base
|
||||
from bd.database import wait_for_db
|
||||
from route.public import router as public_router
|
||||
from route.admin_auth import router as auth_router, ensure_default_admin
|
||||
from route.admin_articles import router as articles_router
|
||||
@@ -32,11 +32,22 @@ def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
|
||||
return creds.username
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
import subprocess, sys
|
||||
result = subprocess.run(
|
||||
["alembic", "upgrade", "head"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[Alembic] Ошибка миграции:\n{result.stderr}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[Alembic] {result.stdout.strip() or 'Миграции применены'}")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await wait_for_db()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
_run_migrations()
|
||||
await ensure_default_admin()
|
||||
scheduler.start()
|
||||
yield
|
||||
|
||||
1
api/migrations/README
Normal file
1
api/migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
63
api/migrations/env.py
Normal file
63
api/migrations/env.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import asyncio
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Подключаем модели для autogenerate
|
||||
from bd.database import Base
|
||||
import bd.models # noqa: F401
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# URL из ENV (приоритет) или из alembic.ini
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", config.get_main_option("sqlalchemy.url", ""))
|
||||
config.set_main_option("sqlalchemy.url", DATABASE_URL)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
api/migrations/script.py.mako
Normal file
28
api/migrations/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
109
api/migrations/versions/25d6ed4a524a_initial.py
Normal file
109
api/migrations/versions/25d6ed4a524a_initial.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""initial
|
||||
|
||||
Revision ID: 25d6ed4a524a
|
||||
Revises:
|
||||
Create Date: 2026-05-15 14:46:07.892008
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '25d6ed4a524a'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('admins',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=300), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_table('categories',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_categories_slug'), 'categories', ['slug'], unique=True)
|
||||
op.create_table('tags',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('slug', sa.String(length=100), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_tags_slug'), 'tags', ['slug'], unique=True)
|
||||
op.create_table('vk_sources',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('group_id', sa.String(length=50), nullable=False),
|
||||
sa.Column('group_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('enabled', sa.Boolean(), nullable=False),
|
||||
sa.Column('last_run', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('group_id')
|
||||
)
|
||||
op.create_table('articles',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('title', sa.String(length=500), nullable=False),
|
||||
sa.Column('slug', sa.String(length=500), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('excerpt', sa.String(length=1000), nullable=False),
|
||||
sa.Column('cover_url', sa.String(length=1000), nullable=True),
|
||||
sa.Column('font_family', sa.String(length=100), nullable=False),
|
||||
sa.Column('source_url', sa.String(length=1000), nullable=True),
|
||||
sa.Column('status', sa.Enum('draft', 'published', name='articlestatus'), nullable=False),
|
||||
sa.Column('view_count', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('published_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('category_id', sa.UUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_articles_slug'), 'articles', ['slug'], unique=True)
|
||||
op.create_table('article_tag',
|
||||
sa.Column('article_id', sa.UUID(), nullable=True),
|
||||
sa.Column('tag_id', sa.UUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['article_id'], ['articles.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete='CASCADE')
|
||||
)
|
||||
op.create_table('media',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('filename', sa.String(length=500), nullable=False),
|
||||
sa.Column('url', sa.String(length=1000), nullable=False),
|
||||
sa.Column('media_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('size_bytes', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('article_id', sa.UUID(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['article_id'], ['articles.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('media')
|
||||
op.drop_table('article_tag')
|
||||
op.drop_index(op.f('ix_articles_slug'), table_name='articles')
|
||||
op.drop_table('articles')
|
||||
op.drop_table('vk_sources')
|
||||
op.drop_index(op.f('ix_tags_slug'), table_name='tags')
|
||||
op.drop_table('tags')
|
||||
op.drop_index(op.f('ix_categories_slug'), table_name='categories')
|
||||
op.drop_table('categories')
|
||||
op.drop_table('admins')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,3 +1,4 @@
|
||||
alembic==1.13.3
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
sqlalchemy[asyncio]==2.0.36
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@@ -9,9 +10,11 @@ from sqlalchemy.orm import selectinload
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from bd.database import get_db, get_redis
|
||||
from bd.models import Article, Category, Tag, ArticleStatus
|
||||
from bd.models import Article, Category, Tag, ArticleStatus, Media
|
||||
from route.deps import require_admin
|
||||
|
||||
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -208,6 +211,26 @@ async def delete_article(
|
||||
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
# Удаляем все связанные медиафайлы из MinIO и БД
|
||||
media_items = (await db.execute(
|
||||
select(Media).where(Media.article_id == article.id)
|
||||
)).scalars().all()
|
||||
|
||||
if media_items:
|
||||
from route.admin_media import get_minio
|
||||
try:
|
||||
client = get_minio()
|
||||
for m in media_items:
|
||||
obj = m.url.split(f"/{MINIO_BUCKET}/", 1)[-1]
|
||||
try:
|
||||
client.remove_object(MINIO_BUCKET, obj)
|
||||
except Exception:
|
||||
pass
|
||||
await db.delete(m)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await db.delete(article)
|
||||
await db.commit()
|
||||
await _invalidate_cache(redis)
|
||||
|
||||
@@ -7,9 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
from slugify import slugify
|
||||
|
||||
from bd.database import get_db
|
||||
from bd.models import Media
|
||||
from bd.models import Media, Article
|
||||
from route.deps import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
@@ -50,6 +51,7 @@ def ensure_bucket(client: Minio):
|
||||
@router.post("/upload")
|
||||
async def upload_media(
|
||||
file: UploadFile = File(...),
|
||||
article_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
@@ -63,7 +65,27 @@ async def upload_media(
|
||||
|
||||
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
|
||||
ext = (file.filename or "file").rsplit(".", 1)[-1].lower()
|
||||
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
# Определяем путь в MinIO
|
||||
art_uuid = None
|
||||
if article_id:
|
||||
try:
|
||||
art_uuid = uuid.UUID(article_id)
|
||||
# Проверяем существование статьи
|
||||
art = (await db.execute(select(Article).where(Article.id == art_uuid))).scalar_one_or_none()
|
||||
if not art:
|
||||
art_uuid = None
|
||||
except ValueError:
|
||||
art_uuid = None
|
||||
|
||||
if art_uuid:
|
||||
folder = "images" if media_type == "image" else "videos"
|
||||
art_slug = slugify(art.title) if art and art.title else str(art_uuid)[:8]
|
||||
art_dir = f"{art_slug}-{str(art_uuid)[:8]}"
|
||||
base_name = f"{art_slug}-{uuid.uuid4().hex[:6]}.{ext}"
|
||||
object_name = f"articles/{art_dir}/{folder}/{base_name}"
|
||||
else:
|
||||
object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
client = get_minio()
|
||||
ensure_bucket(client)
|
||||
@@ -78,10 +100,11 @@ async def upload_media(
|
||||
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||
|
||||
media = Media(
|
||||
filename=file.filename or object_name,
|
||||
filename=object_name.split("/")[-1],
|
||||
url=public_url,
|
||||
media_type=media_type,
|
||||
size_bytes=len(data),
|
||||
article_id=art_uuid,
|
||||
)
|
||||
db.add(media)
|
||||
await db.commit()
|
||||
@@ -101,20 +124,40 @@ async def list_media(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(40, ge=1, le=200),
|
||||
media_type: str | None = Query(None),
|
||||
q: str | None = Query(None, max_length=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(require_admin),
|
||||
):
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import outerjoin
|
||||
base = select(Media)
|
||||
if media_type:
|
||||
base = base.where(Media.media_type == media_type)
|
||||
if q:
|
||||
base = base.where(Media.filename.ilike(f"%{q}%"))
|
||||
total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one()
|
||||
items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all()
|
||||
|
||||
# Fetch article titles for media that have an article_id
|
||||
art_ids = {m.article_id for m in items if m.article_id}
|
||||
art_titles: dict = {}
|
||||
if art_ids:
|
||||
arts = (await db.execute(select(Article.id, Article.title).where(Article.id.in_(art_ids)))).all()
|
||||
art_titles = {a.id: a.title for a in arts}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"items": [
|
||||
{"id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type,
|
||||
"size_bytes": m.size_bytes, "created_at": m.created_at.isoformat()}
|
||||
{
|
||||
"id": str(m.id),
|
||||
"url": m.url,
|
||||
"filename": m.filename,
|
||||
"media_type": m.media_type,
|
||||
"size_bytes": m.size_bytes,
|
||||
"created_at": m.created_at.isoformat(),
|
||||
"article_id": str(m.article_id) if m.article_id else None,
|
||||
"article_title": art_titles.get(m.article_id) if m.article_id else None,
|
||||
}
|
||||
for m in items
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException # BackgroundTasks используется в history endpoints
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -88,9 +88,9 @@ async def delete_source(source_id: str, db: AsyncSession = Depends(get_db), _: s
|
||||
# ── Ручной запуск импорта ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/import")
|
||||
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
||||
background_tasks.add_task(run_import)
|
||||
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
|
||||
async def vk_manual_import(_: str = Depends(require_admin)):
|
||||
result = await run_import()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/import/history")
|
||||
|
||||
@@ -75,13 +75,15 @@ async def list_news(
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
base = base.where(Article.published_at >= datetime.fromisoformat(date_from))
|
||||
dt = datetime.fromisoformat(date_from.replace("Z", "+00:00"))
|
||||
base = base.where(Article.published_at >= dt.replace(tzinfo=None))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
base = base.where(Article.published_at <= datetime.fromisoformat(date_to))
|
||||
dt = datetime.fromisoformat(date_to.replace("Z", "+00:00"))
|
||||
base = base.where(Article.published_at <= dt.replace(tzinfo=None))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
190
api/vk_parser.py
190
api/vk_parser.py
@@ -60,82 +60,60 @@ def _sanitize(html: str) -> str:
|
||||
# ── Теги по ключевым словам ───────────────────────────────────────────────────
|
||||
# Ключ = название тега, значение = список подстрок (регистронезависимо)
|
||||
TAG_KEYWORDS: dict[str, list[str]] = {
|
||||
# ── Учёба и поступление ───────────────────────────────────────────────────
|
||||
"экзамены": ["экзамен", "зачёт", "зачет", "сессия", "огэ", "егэ",
|
||||
"гиа", "защит", "диплом", "аттестац", "промежуточн"],
|
||||
"абитуриентам": ["абитуриент", "поступ", "приёмн", "приемн", "зачислен",
|
||||
"набор студент", "подай документ", "приглашаем поступ",
|
||||
"бюджетн мест", "целевой приём", "контрольные цифры"],
|
||||
"день открытых дверей": ["день открытых дверей", "открытые двери", "день открытых",
|
||||
"экскурси", "познакомиться с колледж"],
|
||||
"практика": ["практик", "стажировк", "производственн", "учебно-производ",
|
||||
"на предприяти", "работодател", "наставник"],
|
||||
"достижения": ["победител", "призёр", "призер", "награда", "грамот",
|
||||
"диплом лауреат", "1 место", "2 место", "3 место",
|
||||
"лучший студент", "гордост", "поздравляем", "медал"],
|
||||
"студенческая жизнь": ["студсовет", "студенческ жизн", "общежити", "капустник",
|
||||
"квн", "студенч", "первокурсник", "посвящение",
|
||||
"студент год", "актив"],
|
||||
"профессионалитет": ["профессионалитет", "фп профессионалитет",
|
||||
"кластер", "федеральн проект"],
|
||||
|
||||
# ── Направления колледжей ─────────────────────────────────────────────────
|
||||
"медицина": ["медицин", "сестринск", "фельдшер", "фармацевт", "лечебн",
|
||||
"анатоми", "патологи", "здравоохранен", "санитар",
|
||||
"первая помощ", "реанимац", "клиническ"],
|
||||
"педагогика": ["педагог", "воспитател", "учител", "начальн класс",
|
||||
"дошкольн", "детский сад", "логопед", "коррекцион",
|
||||
"инклюзив", "тьютор"],
|
||||
"юриспруденция": ["юрист", "юридическ", "правоохранительн", "право",
|
||||
"законодательств", "суд", "прокурат", "полиц",
|
||||
"юриспруденц", "правовед"],
|
||||
"IT и технологии": ["программирован", "it-", "ит-", "информационн технолог",
|
||||
"цифров", "кибербезопасн", "веб-разраб", "1с",
|
||||
"компьютерн", "разработчик", "python", "frontend",
|
||||
"backend", "хакатон", "ворлдскиллс"],
|
||||
"кулинария и торговля": ["кулинар", "повар", "кондитер", "гастроном", "блюд",
|
||||
"рецепт", "ресторан", "кафе", "торговл", "продавец",
|
||||
"мерчандайзинг", "товаровед", "общепит"],
|
||||
"строительство": ["строительств", "архитектур", "проектирован", "чертёж",
|
||||
"чертеж", "монтаж", "сварк", "электромонтаж",
|
||||
"сантехник", "отделочн"],
|
||||
"техника и механика": ["механик", "двигател", "автомобил", "станок",
|
||||
"металлообраб", "токар", "слесар", "техническ обслуж",
|
||||
"ремонт оборудован", "машиностроен", "технолог производ"],
|
||||
"сельское хозяйство": ["агроном", "агроинженер", "сельск хоз", "животновод",
|
||||
"агротехник", "землепользован", "агро"],
|
||||
"экономика и бухучёт": ["бухгалтер", "экономик", "финансов", "налог",
|
||||
"аудит", "бизнес", "предпринимател", "менеджмент",
|
||||
"маркетинг", "логистик"],
|
||||
"социальная работа": ["социальн работ", "соцработник", "психолог",
|
||||
"реабилитац", "инвалид", "ограниченн возможн",
|
||||
"социальн помощ", "опека"],
|
||||
|
||||
# ── Общественные события ──────────────────────────────────────────────────
|
||||
"спорт": ["спорт", "соревнован", "олимпиад", "футбол", "баскетбол",
|
||||
"волейбол", "чемпион", "кубок", "турнир", "атлет",
|
||||
"фитнес", "зарядк", "ворлдскиллс хайскул"],
|
||||
"военка": ["военн", "армия", "призыв", "нво", "сво", "оборон",
|
||||
"патриот", "защитник", "военно-патриот", "стрельб",
|
||||
"зарниц", "юнармия", "допризывн"],
|
||||
"воздушная опасность": ["воздушн тревог", "воздушн опасност", "ракет",
|
||||
"бпла", "дрон", "обстрел", "укрытие", "сирен",
|
||||
"эвакуац", "бомбоубежищ"],
|
||||
"день открытых дверей": ["день открытых дверей", "день открытых",
|
||||
"приходи в колледж", "познакомиться с колледж",
|
||||
"двери открыты", "приглашаем на экскурс"],
|
||||
"общественная деятельность": ["волонтёр", "волонтер", "благотвор", "субботник",
|
||||
"экологическ акц", "посадк дерев", "уборк территор",
|
||||
"донорств", "гуманитарн", "помощ фронт"],
|
||||
"праздники и события": ["праздник", "концерт", "фестиваль", "выставк",
|
||||
"торжеств", "церемони", "день колледж", "юбилей",
|
||||
"8 марта", "23 февраля", "новый год", "масленица",
|
||||
"день знаний", "последний звонок"],
|
||||
"конкурсы и олимпиады": ["конкурс", "олимпиад", "чемпионат профессий",
|
||||
"worldskills", "ворлдскиллс", "хакатон", "викторин",
|
||||
"интеллектуальн", "акселератор"],
|
||||
"донорств", "гуманитарн помощ", "добровольч",
|
||||
"общественн деятельн", "социальн акц", "акция добра"],
|
||||
"чемпионаты и конкурсы": ["чемпионат",
|
||||
"олимпиад",
|
||||
"хакатон",
|
||||
"всероссийск конкурс", "региональн конкурс",
|
||||
"областн конкурс", "международн конкурс",
|
||||
"городск конкурс", "межвузовск конкурс",
|
||||
"конкурс профессионал", "конкурс мастерств",
|
||||
"конкурс молодых специалист",
|
||||
"заняли 1 место", "заняли 2 место", "заняли 3 место",
|
||||
"1 место среди", "2 место среди", "3 место среди",
|
||||
"заняли первое место", "заняли второе место", "заняли третье место",
|
||||
"золото на", "серебро на", "бронзу на",
|
||||
"завоевали золот", "завоевали серебр", "завоевали бронз"],
|
||||
"профессионалы": ["профессионалитет", "worldskills", "ворлдскиллс",
|
||||
"ворлдскиллс хайскул", "хайскул",
|
||||
"компетенц", "демонстрацион экзамен",
|
||||
"федеральн проект профессионалите"],
|
||||
"абилимпикс": ["абилимпикс", "abilympics",
|
||||
"ограниченн возможн здоровь",
|
||||
"особенн образовател потребн",
|
||||
"инклюзивн образован"],
|
||||
"экзамены": ["расписани экзамен", "подготовк к экзамен",
|
||||
"экзаменацион сессия", "зачётн неделя", "зачетн неделя",
|
||||
"промежуточн аттестац", "государственн итог аттестац",
|
||||
"государственн экзамен", "итоговая аттестац",
|
||||
"защит диплом", "защита выпускн",
|
||||
"огэ", "егэ", "гиа"],
|
||||
}
|
||||
|
||||
# Слова-исключения: если они есть в тексте — пост скипается полностью
|
||||
# (посты о ВОВ/Победе не относятся ни к одной из 6 категорий)
|
||||
_SKIP_KEYWORDS = [
|
||||
"великая отечественная война",
|
||||
"великой отечественной войн",
|
||||
"день победы",
|
||||
"9 мая",
|
||||
"вов ",
|
||||
"ветеран войн",
|
||||
"павш за родин",
|
||||
]
|
||||
|
||||
|
||||
def _detect_tags(text: str) -> list[str]:
|
||||
lower = text.lower()
|
||||
# Пропускаем посты о ВОВ/Победе — они не попадают ни в одну категорию
|
||||
if any(kw in lower for kw in _SKIP_KEYWORDS):
|
||||
return []
|
||||
found = []
|
||||
for tag_name, keywords in TAG_KEYWORDS.items():
|
||||
if any(kw in lower for kw in keywords):
|
||||
@@ -163,7 +141,9 @@ def _ensure_bucket(mc: Minio):
|
||||
pass
|
||||
|
||||
|
||||
async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | None:
|
||||
async def _upload_from_url(url: str, db: AsyncSession | None = None,
|
||||
article_dir: str | None = None,
|
||||
index: int = 1) -> str | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
r = await client.get(url)
|
||||
@@ -172,7 +152,13 @@ async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | No
|
||||
data = r.content
|
||||
ct = r.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
||||
ext = ct.split("/")[-1].replace("jpeg", "jpg")
|
||||
object_name = f"vk/images/{uuid.uuid4().hex}.{ext}"
|
||||
if article_dir:
|
||||
prefix = f"articles/{article_dir}/images"
|
||||
filename = f"{article_dir}-{index}.{ext}"
|
||||
else:
|
||||
prefix = "vk/images"
|
||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
object_name = f"{prefix}/{filename}"
|
||||
mc = _minio_client()
|
||||
_ensure_bucket(mc)
|
||||
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct)
|
||||
@@ -180,10 +166,11 @@ async def _upload_from_url(url: str, db: AsyncSession | None = None) -> str | No
|
||||
if db is not None:
|
||||
from bd.models import Media
|
||||
db.add(Media(
|
||||
filename=object_name.split("/")[-1],
|
||||
filename=filename,
|
||||
url=public_url,
|
||||
media_type="image",
|
||||
size_bytes=len(data),
|
||||
# article_id выставляется после коммита статьи (см. _process_post)
|
||||
))
|
||||
return public_url
|
||||
except Exception as exc:
|
||||
@@ -257,7 +244,9 @@ def _ytdlp_extract_url(vk_url: str, max_mb: int = 150) -> str | None:
|
||||
|
||||
async def _download_vk_video(owner_id: int, video_id: int,
|
||||
db: AsyncSession | None = None,
|
||||
max_mb: int = 150) -> str | None:
|
||||
max_mb: int = 150,
|
||||
article_dir: str | None = None,
|
||||
index: int = 1) -> str | None:
|
||||
"""Скачать VK-видео в MinIO и вернуть публичный URL.
|
||||
Использует yt-dlp для получения прямой mp4-ссылки.
|
||||
Стриминг — не держит весь файл в памяти.
|
||||
@@ -303,7 +292,13 @@ async def _download_vk_video(owner_id: int, video_id: int,
|
||||
return None
|
||||
chunks.append(chunk)
|
||||
data = b"".join(chunks)
|
||||
object_name = f"vk/videos/{uuid.uuid4().hex}.mp4"
|
||||
if article_dir:
|
||||
prefix = f"articles/{article_dir}/videos"
|
||||
filename = f"{article_dir}-{index}.mp4"
|
||||
else:
|
||||
prefix = "vk/videos"
|
||||
filename = f"{uuid.uuid4().hex}.mp4"
|
||||
object_name = f"{prefix}/{filename}"
|
||||
mc = _minio_client()
|
||||
_ensure_bucket(mc)
|
||||
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4")
|
||||
@@ -312,10 +307,11 @@ async def _download_vk_video(owner_id: int, video_id: int,
|
||||
if db is not None:
|
||||
from bd.models import Media
|
||||
db.add(Media(
|
||||
filename=object_name.split("/")[-1],
|
||||
filename=filename,
|
||||
url=public_url,
|
||||
media_type="video",
|
||||
size_bytes=len(data),
|
||||
# article_id выставляется после коммита статьи (см. _process_post)
|
||||
))
|
||||
return public_url
|
||||
except Exception as exc:
|
||||
@@ -397,6 +393,14 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
attachments = post.get("attachments", [])
|
||||
ts = post["date"]
|
||||
|
||||
# Фильтр: импортируем только посты с разрешёнными тегами
|
||||
tag_names = _detect_tags(text)
|
||||
if not tag_names:
|
||||
return False
|
||||
|
||||
# Заранее генерируем UUID и slug — используются в путях MinIO
|
||||
article_id = uuid.uuid4()
|
||||
|
||||
# VK Clips и некоторые видео-посты: post.text пустой, описание лежит в video.description
|
||||
if not text:
|
||||
for att in attachments:
|
||||
@@ -406,6 +410,15 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
text = desc
|
||||
break
|
||||
|
||||
# Генерируем slug до загрузки медиа: используем его как имя директории и файлов
|
||||
_early_title = _extract_title(text, ts)
|
||||
_early_slug = (slugify(_early_title) or vk_slug)[:60] # макс 60 символов
|
||||
# Уникальный префикс: slug + 8 символов UUID (защита от коллизий одинаковых заголовков)
|
||||
article_dir = f"{_early_slug}-{str(article_id)[:8]}"
|
||||
|
||||
img_idx = 0 # счётчик изображений для нумерации файлов
|
||||
vid_idx = 0 # счётчик видео
|
||||
|
||||
cover_url = None
|
||||
content_parts = []
|
||||
|
||||
@@ -418,7 +431,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
if att_type == "photo":
|
||||
url = _best_photo(att["photo"].get("sizes", []))
|
||||
if url:
|
||||
minio_url = await _upload_from_url(url, db=db)
|
||||
img_idx += 1
|
||||
minio_url = await _upload_from_url(url, db=db, article_dir=article_dir, index=img_idx)
|
||||
final_url = minio_url or url # fallback на оригинальный URL если MinIO недоступен
|
||||
if cover_url is None:
|
||||
cover_url = final_url
|
||||
@@ -456,7 +470,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
# Всегда скачиваем превью в MinIO
|
||||
minio_thumb = None
|
||||
if thumb_url:
|
||||
minio_thumb = await _upload_from_url(thumb_url, db=db)
|
||||
img_idx += 1
|
||||
minio_thumb = await _upload_from_url(thumb_url, db=db, article_dir=article_dir, index=img_idx)
|
||||
final_thumb = minio_thumb or thumb_url
|
||||
if cover_url is None:
|
||||
cover_url = final_thumb
|
||||
@@ -466,7 +481,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
# Скачиваем само видео через yt-dlp → MinIO
|
||||
minio_video_url = None
|
||||
if vid_id and vid_oid:
|
||||
minio_video_url = await _download_vk_video(vid_oid, vid_id, db=db)
|
||||
vid_idx += 1
|
||||
minio_video_url = await _download_vk_video(vid_oid, vid_id, db=db, article_dir=article_dir, index=vid_idx)
|
||||
|
||||
if minio_video_url:
|
||||
# 1. Видео лежит у нас — нативный <video>
|
||||
@@ -506,15 +522,14 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
if not content.strip():
|
||||
return False
|
||||
|
||||
title = _extract_title(text, ts)
|
||||
title_slug = slugify(title)
|
||||
title = _early_title
|
||||
title_slug = _early_slug
|
||||
final_slug = title_slug if not await _slug_exists(title_slug, db) else vk_slug
|
||||
|
||||
# Категория = название группы
|
||||
category = await _get_or_create_category(category_name, db)
|
||||
|
||||
# Теги по ключевым словам
|
||||
tag_names = _detect_tags(text)
|
||||
# Теги уже определены выше (tag_names)
|
||||
tags = [await _get_or_create_tag(n, db) for n in tag_names]
|
||||
|
||||
status = ArticleStatus.published if VK_IMPORT_AS == "published" else ArticleStatus.draft
|
||||
@@ -525,6 +540,7 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
|
||||
|
||||
article = Article(
|
||||
id=article_id,
|
||||
title=title,
|
||||
slug=final_slug,
|
||||
content=content,
|
||||
@@ -540,6 +556,16 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
||||
)
|
||||
db.add(article)
|
||||
await db.commit()
|
||||
|
||||
# Связываем загруженные медиафайлы с этой статьёй по URL-префиксу
|
||||
from sqlalchemy import update as sa_update
|
||||
from bd.models import Media as _Media
|
||||
await db.execute(
|
||||
sa_update(_Media)
|
||||
.where(_Media.url.contains(f"/articles/{article_dir}/"))
|
||||
.values(article_id=article_id)
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -73,9 +73,10 @@ export const adminDeleteCategory = (token, id) =>
|
||||
|
||||
// ── Медиа (админ) ──────────────────────────────────────────────────────────
|
||||
|
||||
export const adminListMedia = (token, { type, offset = 0, limit = 40 } = {}) => {
|
||||
export const adminListMedia = (token, { type, offset = 0, limit = 40, q } = {}) => {
|
||||
const p = new URLSearchParams()
|
||||
if (type) p.set('media_type', type)
|
||||
if (q) p.set('q', q)
|
||||
p.set('offset', offset)
|
||||
p.set('limit', limit)
|
||||
return req('GET', `admin/media?${p}`, token)
|
||||
|
||||
@@ -48,6 +48,8 @@ export default function MediaLibrary() {
|
||||
const [total, setTotal] = useState(0)
|
||||
const [type, setType] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirm, setConfirm] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
@@ -58,13 +60,13 @@ export default function MediaLibrary() {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE })
|
||||
const data = await adminListMedia(token, { type: type || undefined, offset: (page - 1) * PAGE, limit: PAGE, q: search || undefined })
|
||||
setItems(data.items ?? [])
|
||||
setTotal(data.total ?? 0)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [token, type, page])
|
||||
}, [token, type, page, search])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
@@ -129,6 +131,23 @@ export default function MediaLibrary() {
|
||||
onClick={() => { setType(val); setPage(1) }}
|
||||
>{label}</button>
|
||||
))}
|
||||
<form
|
||||
onSubmit={e => { e.preventDefault(); setSearch(searchInput); setPage(1) }}
|
||||
style={{ display: 'flex', gap: '0.25rem', marginLeft: '0.5rem' }}
|
||||
>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: 200, padding: '0.3rem 0.6rem', fontSize: '0.85rem' }}
|
||||
placeholder="Поиск по имени..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-ghost btn-sm" type="submit">🔍</button>
|
||||
{search && (
|
||||
<button className="btn btn-ghost btn-sm" type="button"
|
||||
onClick={() => { setSearch(''); setSearchInput(''); setPage(1) }}>✕</button>
|
||||
)}
|
||||
</form>
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
|
||||
Всего: {total}
|
||||
</span>
|
||||
@@ -160,6 +179,13 @@ export default function MediaLibrary() {
|
||||
</div>
|
||||
<div className="media-item__info">
|
||||
<span className="media-item__name" title={m.filename}>{m.filename}</span>
|
||||
{m.article_title && (
|
||||
<span style={{ fontSize: '0.7rem', color: 'var(--c-primary)', overflow: 'hidden',
|
||||
textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}
|
||||
title={m.article_title}>
|
||||
📄 {m.article_title}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--c-text-2)' }}>{fmtSize(m.size_bytes)} · {fmtDate(m.created_at)}</span>
|
||||
<button
|
||||
className="btn btn-icon btn-sm"
|
||||
|
||||
@@ -67,7 +67,11 @@ export default function VkSources() {
|
||||
setImporting('new')
|
||||
try {
|
||||
const r = await adminVkImport(token)
|
||||
flash('success', `Импорт завершён: +${r.imported ?? 0} новых`)
|
||||
if (r.imported !== undefined) {
|
||||
flash('success', `Импорт завершён: +${r.imported} новых, пропущено ${r.skipped ?? 0}`)
|
||||
} else {
|
||||
flash('success', r.message ?? 'Импорт запущен')
|
||||
}
|
||||
} catch { flash('error', 'Ошибка импорта') }
|
||||
finally { setImporting('') }
|
||||
}
|
||||
@@ -76,7 +80,7 @@ export default function VkSources() {
|
||||
setImporting('history')
|
||||
try {
|
||||
const r = await adminVkImportHistory(token, sourceId)
|
||||
flash('success', `История загружена: +${r.imported ?? 0} постов`)
|
||||
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
|
||||
} catch { flash('error', 'Ошибка загрузки истории') }
|
||||
finally { setImporting('') }
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ const DEFAULT_META = {
|
||||
slug: '',
|
||||
excerpt: '',
|
||||
cover_url: null,
|
||||
source_url: null,
|
||||
font_family: 'Merriweather',
|
||||
category_id: null,
|
||||
tag_names: [],
|
||||
@@ -67,6 +68,7 @@ export default function App() {
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
cover_url: data.cover_url || null,
|
||||
source_url: data.source_url || null,
|
||||
font_family: data.font_family || 'Merriweather',
|
||||
category_id: data.category?.id || null,
|
||||
tag_names: (data.tags || []).map(t => t.name),
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Upload, X, ImageIcon, Video, Loader2, Check } from 'lucide-react'
|
||||
import { Upload, X, ImageIcon, Video, Loader2, Check, Search } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
|
||||
export function MediaModal({ type, token, onSelect, onClose }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [allItems, setAllItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const fileRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listMedia(token, type)
|
||||
.then(data => { setItems(data); setLoading(false) })
|
||||
.then(data => { setAllItems(data); setItems(data); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.trim()) {
|
||||
setItems(allItems)
|
||||
} else {
|
||||
const q = search.toLowerCase()
|
||||
setItems(allItems.filter(it => it.filename?.toLowerCase().includes(q)))
|
||||
}
|
||||
}, [search, allItems])
|
||||
|
||||
const handleUpload = async file => {
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await api.uploadMedia(file, token)
|
||||
setItems(prev => [res, ...prev])
|
||||
setAllItems(prev => [res, ...prev])
|
||||
} catch {}
|
||||
setUploading(false)
|
||||
}
|
||||
@@ -64,6 +75,29 @@ export function MediaModal({ type, token, onSelect, onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="px-5 py-3 border-b border-slate-100">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Поиск по имени файла..."
|
||||
className="w-full pl-8 pr-4 py-1.5 text-sm border border-slate-200 rounded-lg outline-none focus:border-indigo-400 focus:ring-1 focus:ring-indigo-200"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-slate-100 rounded"
|
||||
>
|
||||
<X size={12} className="text-slate-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
@@ -73,14 +107,16 @@ export function MediaModal({ type, token, onSelect, onClose }) {
|
||||
) : isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-slate-300 gap-3">
|
||||
{isImage ? <ImageIcon size={36} /> : <Video size={36} />}
|
||||
<p className="text-sm">Файлы не найдены</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
|
||||
>
|
||||
Загрузить первый файл
|
||||
</button>
|
||||
<p className="text-sm">{search ? 'Ничего не найдено' : 'Файлы не найдены'}</p>
|
||||
{!search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
|
||||
>
|
||||
Загрузить первый файл
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid gap-2.5 ${isImage ? 'grid-cols-4 sm:grid-cols-5' : 'grid-cols-2 sm:grid-cols-3'}`}>
|
||||
@@ -101,14 +137,12 @@ export function MediaModal({ type, token, onSelect, onClose }) {
|
||||
) : (
|
||||
<video src={item.url} className="w-full h-full object-cover" />
|
||||
)}
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-indigo-600/0 group-hover:bg-indigo-600/25 transition-colors flex items-center justify-center">
|
||||
<Check
|
||||
size={22}
|
||||
className="text-white opacity-0 group-hover:opacity-100 drop-shadow-lg transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
{/* Filename tooltip */}
|
||||
<div className="absolute bottom-0 inset-x-0 px-2 py-1.5 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-white text-[10px] truncate leading-none">{item.filename}</p>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,17 @@ export function Sidebar({ meta, categories, onChange, onUploadCover, wordCount,
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source URL */}
|
||||
<div>
|
||||
<Label>Первоисточник</Label>
|
||||
<input
|
||||
className="input-dark text-xs"
|
||||
placeholder="https://vk.com/..."
|
||||
value={meta.source_url || ''}
|
||||
onChange={e => onChange({ source_url: e.target.value || null })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<div>
|
||||
<Label>Анонс</Label>
|
||||
|
||||
@@ -157,7 +157,24 @@ function SizeDropdown({ editor }) {
|
||||
/* ── Table menu ── */
|
||||
|
||||
function TableMenu({ editor }) {
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const wrapRef = useRef(null)
|
||||
|
||||
const toggle = () => {
|
||||
if (!open && wrapRef.current) {
|
||||
const r = wrapRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(o => !o)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = e => { if (!wrapRef.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [open])
|
||||
|
||||
const items = [
|
||||
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
@@ -174,16 +191,14 @@ function TableMenu({ editor }) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0">
|
||||
<div ref={wrapRef} className="flex-shrink-0">
|
||||
<Btn
|
||||
onClick={e => toggle({ preventDefault: () => {}, ...e })}
|
||||
onClick={toggle}
|
||||
active={editor.isActive('table')}
|
||||
title="Таблица"
|
||||
>
|
||||
<Table2 size={14} />
|
||||
</Btn>
|
||||
{/* Используем ref на обёртке для Btn */}
|
||||
<span ref={btnRef} style={{ display: 'none' }} />
|
||||
|
||||
{open && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user