fix teg
This commit is contained in:
@@ -15,7 +15,6 @@ ALLOWED_ORIGINS=http://localhost,http://localhost:80
|
|||||||
|
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=changeme
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
# VK импорт (необязательно)
|
# VK импорт (необязательно)
|
||||||
# Сервисный токен приложения: vk.com/apps → Настройки → Сервисный ключ доступа
|
# Сервисный токен приложения: vk.com/apps → Настройки → Сервисный ключ доступа
|
||||||
VK_ACCESS_TOKEN=
|
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
|
media_type: Mapped[str] = mapped_column(String(20)) # image | video
|
||||||
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
size_bytes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
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):
|
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 slowapi.errors import RateLimitExceeded
|
||||||
from rate_limiter import limiter
|
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.public import router as public_router
|
||||||
from route.admin_auth import router as auth_router, ensure_default_admin
|
from route.admin_auth import router as auth_router, ensure_default_admin
|
||||||
from route.admin_articles import router as articles_router
|
from route.admin_articles import router as articles_router
|
||||||
@@ -32,11 +32,22 @@ def _verify_docs(creds: HTTPBasicCredentials = Depends(_basic)):
|
|||||||
return creds.username
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
await wait_for_db()
|
await wait_for_db()
|
||||||
async with engine.begin() as conn:
|
_run_migrations()
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
await ensure_default_admin()
|
await ensure_default_admin()
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
yield
|
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
|
fastapi==0.115.0
|
||||||
uvicorn[standard]==0.30.6
|
uvicorn[standard]==0.30.6
|
||||||
sqlalchemy[asyncio]==2.0.36
|
sqlalchemy[asyncio]==2.0.36
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -9,9 +10,11 @@ from sqlalchemy.orm import selectinload
|
|||||||
import redis.asyncio as aioredis
|
import redis.asyncio as aioredis
|
||||||
|
|
||||||
from bd.database import get_db, get_redis
|
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
|
from route.deps import require_admin
|
||||||
|
|
||||||
|
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media")
|
||||||
|
|
||||||
router = APIRouter()
|
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()
|
article = (await db.execute(select(Article).where(Article.id == uuid.UUID(article_id)))).scalar_one_or_none()
|
||||||
if not article:
|
if not article:
|
||||||
raise HTTPException(status_code=404, detail="Not found")
|
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.delete(article)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await _invalidate_cache(redis)
|
await _invalidate_cache(redis)
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import S3Error
|
from minio.error import S3Error
|
||||||
|
from slugify import slugify
|
||||||
|
|
||||||
from bd.database import get_db
|
from bd.database import get_db
|
||||||
from bd.models import Media
|
from bd.models import Media, Article
|
||||||
from route.deps import require_admin
|
from route.deps import require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -50,6 +51,7 @@ def ensure_bucket(client: Minio):
|
|||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
async def upload_media(
|
async def upload_media(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
article_id: str | None = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(require_admin),
|
_: str = Depends(require_admin),
|
||||||
):
|
):
|
||||||
@@ -63,7 +65,27 @@ async def upload_media(
|
|||||||
|
|
||||||
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
|
media_type = "image" if content_type in ALLOWED_IMAGE else "video"
|
||||||
ext = (file.filename or "file").rsplit(".", 1)[-1].lower()
|
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()
|
client = get_minio()
|
||||||
ensure_bucket(client)
|
ensure_bucket(client)
|
||||||
@@ -78,10 +100,11 @@ async def upload_media(
|
|||||||
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}"
|
||||||
|
|
||||||
media = Media(
|
media = Media(
|
||||||
filename=file.filename or object_name,
|
filename=object_name.split("/")[-1],
|
||||||
url=public_url,
|
url=public_url,
|
||||||
media_type=media_type,
|
media_type=media_type,
|
||||||
size_bytes=len(data),
|
size_bytes=len(data),
|
||||||
|
article_id=art_uuid,
|
||||||
)
|
)
|
||||||
db.add(media)
|
db.add(media)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -101,20 +124,40 @@ async def list_media(
|
|||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
limit: int = Query(40, ge=1, le=200),
|
limit: int = Query(40, ge=1, le=200),
|
||||||
media_type: str | None = Query(None),
|
media_type: str | None = Query(None),
|
||||||
|
q: str | None = Query(None, max_length=200),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(require_admin),
|
_: str = Depends(require_admin),
|
||||||
):
|
):
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import outerjoin
|
||||||
base = select(Media)
|
base = select(Media)
|
||||||
if media_type:
|
if media_type:
|
||||||
base = base.where(Media.media_type == 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()
|
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()
|
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 {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"items": [
|
"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
|
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 pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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")
|
@router.post("/import")
|
||||||
async def vk_manual_import(background_tasks: BackgroundTasks, _: str = Depends(require_admin)):
|
async def vk_manual_import(_: str = Depends(require_admin)):
|
||||||
background_tasks.add_task(run_import)
|
result = await run_import()
|
||||||
return {"ok": True, "message": "Импорт последних постов запущен в фоне"}
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import/history")
|
@router.post("/import/history")
|
||||||
|
|||||||
@@ -75,13 +75,15 @@ async def list_news(
|
|||||||
|
|
||||||
if date_from:
|
if date_from:
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if date_to:
|
if date_to:
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
pass
|
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]] = {
|
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]:
|
def _detect_tags(text: str) -> list[str]:
|
||||||
lower = text.lower()
|
lower = text.lower()
|
||||||
|
# Пропускаем посты о ВОВ/Победе — они не попадают ни в одну категорию
|
||||||
|
if any(kw in lower for kw in _SKIP_KEYWORDS):
|
||||||
|
return []
|
||||||
found = []
|
found = []
|
||||||
for tag_name, keywords in TAG_KEYWORDS.items():
|
for tag_name, keywords in TAG_KEYWORDS.items():
|
||||||
if any(kw in lower for kw in keywords):
|
if any(kw in lower for kw in keywords):
|
||||||
@@ -163,7 +141,9 @@ def _ensure_bucket(mc: Minio):
|
|||||||
pass
|
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:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||||
r = await client.get(url)
|
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
|
data = r.content
|
||||||
ct = r.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
ct = r.headers.get("content-type", "image/jpeg").split(";")[0].strip()
|
||||||
ext = ct.split("/")[-1].replace("jpeg", "jpg")
|
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()
|
mc = _minio_client()
|
||||||
_ensure_bucket(mc)
|
_ensure_bucket(mc)
|
||||||
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct)
|
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:
|
if db is not None:
|
||||||
from bd.models import Media
|
from bd.models import Media
|
||||||
db.add(Media(
|
db.add(Media(
|
||||||
filename=object_name.split("/")[-1],
|
filename=filename,
|
||||||
url=public_url,
|
url=public_url,
|
||||||
media_type="image",
|
media_type="image",
|
||||||
size_bytes=len(data),
|
size_bytes=len(data),
|
||||||
|
# article_id выставляется после коммита статьи (см. _process_post)
|
||||||
))
|
))
|
||||||
return public_url
|
return public_url
|
||||||
except Exception as exc:
|
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,
|
async def _download_vk_video(owner_id: int, video_id: int,
|
||||||
db: AsyncSession | None = None,
|
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.
|
"""Скачать VK-видео в MinIO и вернуть публичный URL.
|
||||||
Использует yt-dlp для получения прямой mp4-ссылки.
|
Использует yt-dlp для получения прямой mp4-ссылки.
|
||||||
Стриминг — не держит весь файл в памяти.
|
Стриминг — не держит весь файл в памяти.
|
||||||
@@ -303,7 +292,13 @@ async def _download_vk_video(owner_id: int, video_id: int,
|
|||||||
return None
|
return None
|
||||||
chunks.append(chunk)
|
chunks.append(chunk)
|
||||||
data = b"".join(chunks)
|
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()
|
mc = _minio_client()
|
||||||
_ensure_bucket(mc)
|
_ensure_bucket(mc)
|
||||||
mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4")
|
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:
|
if db is not None:
|
||||||
from bd.models import Media
|
from bd.models import Media
|
||||||
db.add(Media(
|
db.add(Media(
|
||||||
filename=object_name.split("/")[-1],
|
filename=filename,
|
||||||
url=public_url,
|
url=public_url,
|
||||||
media_type="video",
|
media_type="video",
|
||||||
size_bytes=len(data),
|
size_bytes=len(data),
|
||||||
|
# article_id выставляется после коммита статьи (см. _process_post)
|
||||||
))
|
))
|
||||||
return public_url
|
return public_url
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -397,6 +393,14 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
attachments = post.get("attachments", [])
|
attachments = post.get("attachments", [])
|
||||||
ts = post["date"]
|
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
|
# VK Clips и некоторые видео-посты: post.text пустой, описание лежит в video.description
|
||||||
if not text:
|
if not text:
|
||||||
for att in attachments:
|
for att in attachments:
|
||||||
@@ -406,6 +410,15 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
text = desc
|
text = desc
|
||||||
break
|
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
|
cover_url = None
|
||||||
content_parts = []
|
content_parts = []
|
||||||
|
|
||||||
@@ -418,7 +431,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
if att_type == "photo":
|
if att_type == "photo":
|
||||||
url = _best_photo(att["photo"].get("sizes", []))
|
url = _best_photo(att["photo"].get("sizes", []))
|
||||||
if url:
|
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 недоступен
|
final_url = minio_url or url # fallback на оригинальный URL если MinIO недоступен
|
||||||
if cover_url is None:
|
if cover_url is None:
|
||||||
cover_url = final_url
|
cover_url = final_url
|
||||||
@@ -456,7 +470,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
# Всегда скачиваем превью в MinIO
|
# Всегда скачиваем превью в MinIO
|
||||||
minio_thumb = None
|
minio_thumb = None
|
||||||
if thumb_url:
|
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
|
final_thumb = minio_thumb or thumb_url
|
||||||
if cover_url is None:
|
if cover_url is None:
|
||||||
cover_url = final_thumb
|
cover_url = final_thumb
|
||||||
@@ -466,7 +481,8 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
# Скачиваем само видео через yt-dlp → MinIO
|
# Скачиваем само видео через yt-dlp → MinIO
|
||||||
minio_video_url = None
|
minio_video_url = None
|
||||||
if vid_id and vid_oid:
|
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:
|
if minio_video_url:
|
||||||
# 1. Видео лежит у нас — нативный <video>
|
# 1. Видео лежит у нас — нативный <video>
|
||||||
@@ -506,15 +522,14 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
if not content.strip():
|
if not content.strip():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
title = _extract_title(text, ts)
|
title = _early_title
|
||||||
title_slug = slugify(title)
|
title_slug = _early_slug
|
||||||
final_slug = title_slug if not await _slug_exists(title_slug, db) else vk_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)
|
category = await _get_or_create_category(category_name, db)
|
||||||
|
|
||||||
# Теги по ключевым словам
|
# Теги уже определены выше (tag_names)
|
||||||
tag_names = _detect_tags(text)
|
|
||||||
tags = [await _get_or_create_tag(n, db) for n in 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
|
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}"
|
source_url = f"https://vk.com/wall{owner_id}_{post_id}"
|
||||||
|
|
||||||
article = Article(
|
article = Article(
|
||||||
|
id=article_id,
|
||||||
title=title,
|
title=title,
|
||||||
slug=final_slug,
|
slug=final_slug,
|
||||||
content=content,
|
content=content,
|
||||||
@@ -540,6 +556,16 @@ async def _process_post(post: dict, category_name: str, db: AsyncSession) -> boo
|
|||||||
)
|
)
|
||||||
db.add(article)
|
db.add(article)
|
||||||
await db.commit()
|
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
|
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()
|
const p = new URLSearchParams()
|
||||||
if (type) p.set('media_type', type)
|
if (type) p.set('media_type', type)
|
||||||
|
if (q) p.set('q', q)
|
||||||
p.set('offset', offset)
|
p.set('offset', offset)
|
||||||
p.set('limit', limit)
|
p.set('limit', limit)
|
||||||
return req('GET', `admin/media?${p}`, token)
|
return req('GET', `admin/media?${p}`, token)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export default function MediaLibrary() {
|
|||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [type, setType] = useState('')
|
const [type, setType] = useState('')
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [confirm, setConfirm] = useState(null)
|
const [confirm, setConfirm] = useState(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
@@ -58,13 +60,13 @@ export default function MediaLibrary() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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 ?? [])
|
setItems(data.items ?? [])
|
||||||
setTotal(data.total ?? 0)
|
setTotal(data.total ?? 0)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [token, type, page])
|
}, [token, type, page, search])
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
@@ -129,6 +131,23 @@ export default function MediaLibrary() {
|
|||||||
onClick={() => { setType(val); setPage(1) }}
|
onClick={() => { setType(val); setPage(1) }}
|
||||||
>{label}</button>
|
>{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' }}>
|
<span style={{ marginLeft: 'auto', fontSize: '0.82rem', color: 'var(--c-text-2)', alignSelf: 'center' }}>
|
||||||
Всего: {total}
|
Всего: {total}
|
||||||
</span>
|
</span>
|
||||||
@@ -160,6 +179,13 @@ export default function MediaLibrary() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="media-item__info">
|
<div className="media-item__info">
|
||||||
<span className="media-item__name" title={m.filename}>{m.filename}</span>
|
<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>
|
<span style={{ fontSize: '0.75rem', color: 'var(--c-text-2)' }}>{fmtSize(m.size_bytes)} · {fmtDate(m.created_at)}</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn-icon btn-sm"
|
className="btn btn-icon btn-sm"
|
||||||
|
|||||||
@@ -67,7 +67,11 @@ export default function VkSources() {
|
|||||||
setImporting('new')
|
setImporting('new')
|
||||||
try {
|
try {
|
||||||
const r = await adminVkImport(token)
|
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', 'Ошибка импорта') }
|
} catch { flash('error', 'Ошибка импорта') }
|
||||||
finally { setImporting('') }
|
finally { setImporting('') }
|
||||||
}
|
}
|
||||||
@@ -76,7 +80,7 @@ export default function VkSources() {
|
|||||||
setImporting('history')
|
setImporting('history')
|
||||||
try {
|
try {
|
||||||
const r = await adminVkImportHistory(token, sourceId)
|
const r = await adminVkImportHistory(token, sourceId)
|
||||||
flash('success', `История загружена: +${r.imported ?? 0} постов`)
|
flash('success', r.message ?? `История загружена: +${r.imported ?? 0} постов`)
|
||||||
} catch { flash('error', 'Ошибка загрузки истории') }
|
} catch { flash('error', 'Ошибка загрузки истории') }
|
||||||
finally { setImporting('') }
|
finally { setImporting('') }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const DEFAULT_META = {
|
|||||||
slug: '',
|
slug: '',
|
||||||
excerpt: '',
|
excerpt: '',
|
||||||
cover_url: null,
|
cover_url: null,
|
||||||
|
source_url: null,
|
||||||
font_family: 'Merriweather',
|
font_family: 'Merriweather',
|
||||||
category_id: null,
|
category_id: null,
|
||||||
tag_names: [],
|
tag_names: [],
|
||||||
@@ -67,6 +68,7 @@ export default function App() {
|
|||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
excerpt: data.excerpt || '',
|
excerpt: data.excerpt || '',
|
||||||
cover_url: data.cover_url || null,
|
cover_url: data.cover_url || null,
|
||||||
|
source_url: data.source_url || null,
|
||||||
font_family: data.font_family || 'Merriweather',
|
font_family: data.font_family || 'Merriweather',
|
||||||
category_id: data.category?.id || null,
|
category_id: data.category?.id || null,
|
||||||
tag_names: (data.tags || []).map(t => t.name),
|
tag_names: (data.tags || []).map(t => t.name),
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
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'
|
import { api } from '../api'
|
||||||
|
|
||||||
export function MediaModal({ type, token, onSelect, onClose }) {
|
export function MediaModal({ type, token, onSelect, onClose }) {
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
|
const [allItems, setAllItems] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.listMedia(token, type)
|
api.listMedia(token, type)
|
||||||
.then(data => { setItems(data); setLoading(false) })
|
.then(data => { setAllItems(data); setItems(data); setLoading(false) })
|
||||||
.catch(() => 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 => {
|
const handleUpload = async file => {
|
||||||
setUploading(true)
|
setUploading(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.uploadMedia(file, token)
|
const res = await api.uploadMedia(file, token)
|
||||||
setItems(prev => [res, ...prev])
|
setAllItems(prev => [res, ...prev])
|
||||||
} catch {}
|
} catch {}
|
||||||
setUploading(false)
|
setUploading(false)
|
||||||
}
|
}
|
||||||
@@ -64,6 +75,29 @@ export function MediaModal({ type, token, onSelect, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -73,14 +107,16 @@ export function MediaModal({ type, token, onSelect, onClose }) {
|
|||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<div className="flex flex-col items-center justify-center h-48 text-slate-300 gap-3">
|
<div className="flex flex-col items-center justify-center h-48 text-slate-300 gap-3">
|
||||||
{isImage ? <ImageIcon size={36} /> : <Video size={36} />}
|
{isImage ? <ImageIcon size={36} /> : <Video size={36} />}
|
||||||
<p className="text-sm">Файлы не найдены</p>
|
<p className="text-sm">{search ? 'Ничего не найдено' : 'Файлы не найдены'}</p>
|
||||||
<button
|
{!search && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => fileRef.current?.click()}
|
type="button"
|
||||||
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
|
onClick={() => fileRef.current?.click()}
|
||||||
>
|
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
|
||||||
Загрузить первый файл
|
>
|
||||||
</button>
|
Загрузить первый файл
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={`grid gap-2.5 ${isImage ? 'grid-cols-4 sm:grid-cols-5' : 'grid-cols-2 sm:grid-cols-3'}`}>
|
<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" />
|
<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">
|
<div className="absolute inset-0 bg-indigo-600/0 group-hover:bg-indigo-600/25 transition-colors flex items-center justify-center">
|
||||||
<Check
|
<Check
|
||||||
size={22}
|
size={22}
|
||||||
className="text-white opacity-0 group-hover:opacity-100 drop-shadow-lg transition-opacity"
|
className="text-white opacity-0 group-hover:opacity-100 drop-shadow-lg transition-opacity"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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>
|
<p className="text-white text-[10px] truncate leading-none">{item.filename}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,6 +48,17 @@ export function Sidebar({ meta, categories, onChange, onUploadCover, wordCount,
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Excerpt */}
|
||||||
<div>
|
<div>
|
||||||
<Label>Анонс</Label>
|
<Label>Анонс</Label>
|
||||||
|
|||||||
@@ -157,7 +157,24 @@ function SizeDropdown({ editor }) {
|
|||||||
/* ── Table menu ── */
|
/* ── Table menu ── */
|
||||||
|
|
||||||
function TableMenu({ editor }) {
|
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 = [
|
const items = [
|
||||||
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||||
@@ -174,16 +191,14 @@ function TableMenu({ editor }) {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-shrink-0">
|
<div ref={wrapRef} className="flex-shrink-0">
|
||||||
<Btn
|
<Btn
|
||||||
onClick={e => toggle({ preventDefault: () => {}, ...e })}
|
onClick={toggle}
|
||||||
active={editor.isActive('table')}
|
active={editor.isActive('table')}
|
||||||
title="Таблица"
|
title="Таблица"
|
||||||
>
|
>
|
||||||
<Table2 size={14} />
|
<Table2 size={14} />
|
||||||
</Btn>
|
</Btn>
|
||||||
{/* Используем ref на обёртке для Btn */}
|
|
||||||
<span ref={btnRef} style={{ display: 'none' }} />
|
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user