new web ract
This commit is contained in:
@@ -2,7 +2,21 @@ import os
|
||||
import httpx
|
||||
|
||||
API_URL = os.getenv("API_URL", "http://api:8000")
|
||||
_TIMEOUT = 15.0
|
||||
_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
|
||||
|
||||
# Один клиент на весь процесс — TCP-соединения переиспользуются (keep-alive)
|
||||
_client: httpx.AsyncClient | None = None
|
||||
|
||||
|
||||
def _get() -> httpx.AsyncClient:
|
||||
global _client
|
||||
if _client is None or _client.is_closed:
|
||||
_client = httpx.AsyncClient(
|
||||
base_url=API_URL,
|
||||
timeout=_TIMEOUT,
|
||||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10),
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def _headers(token: str | None = None) -> dict:
|
||||
@@ -19,17 +33,22 @@ async def get_news(
|
||||
limit: int = 20,
|
||||
category: str | None = None,
|
||||
tag: str | None = None,
|
||||
date_from: str | None = None,
|
||||
date_to: str | None = None,
|
||||
) -> dict | None:
|
||||
params = {"offset": offset, "limit": limit}
|
||||
params: dict = {"offset": offset, "limit": limit}
|
||||
if category:
|
||||
params["category"] = category
|
||||
if tag:
|
||||
params["tag"] = tag
|
||||
if date_from:
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
params["date_to"] = date_to
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/news", params=params)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get("/news", params=params)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -37,10 +56,9 @@ async def get_news(
|
||||
|
||||
async def get_article(slug: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/news/{slug}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get(f"/news/{slug}")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -48,10 +66,19 @@ async def get_article(slug: str) -> dict | None:
|
||||
|
||||
async def get_categories() -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/news/categories")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get("/news/categories")
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_tags(limit: int = 30) -> list:
|
||||
try:
|
||||
r = await _get().get("/news/tags", params={"limit": limit})
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
@@ -61,14 +88,13 @@ async def get_categories() -> list:
|
||||
|
||||
async def admin_login(username: str, password: str) -> str | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(
|
||||
f"{API_URL}/admin/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("access_token")
|
||||
return None
|
||||
r = await _get().post(
|
||||
"/admin/login",
|
||||
json={"username": username, "password": password},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json().get("access_token")
|
||||
return None
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
@@ -76,14 +102,13 @@ async def admin_login(username: str, password: str) -> str | None:
|
||||
# ─── Admin: Articles ──────────────────────────────────────────────────────────
|
||||
|
||||
async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, status: str | None = None) -> dict | None:
|
||||
params = {"offset": offset, "limit": limit}
|
||||
params: dict = {"offset": offset, "limit": limit}
|
||||
if status:
|
||||
params["status"] = status
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/articles", params=params, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get("/admin/articles", params=params, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -91,10 +116,9 @@ async def admin_list_articles(token: str, offset: int = 0, limit: int = 50, stat
|
||||
|
||||
async def admin_get_article(token: str, article_id: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get(f"/admin/articles/{article_id}", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -102,10 +126,9 @@ async def admin_get_article(token: str, article_id: str) -> dict | None:
|
||||
|
||||
async def admin_create_article(token: str, data: dict) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{API_URL}/admin/articles", json=data, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().post("/admin/articles", json=data, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -113,10 +136,9 @@ async def admin_create_article(token: str, data: dict) -> dict | None:
|
||||
|
||||
async def admin_update_article(token: str, article_id: str, data: dict) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.put(f"{API_URL}/admin/articles/{article_id}", json=data, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().put(f"/admin/articles/{article_id}", json=data, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -124,18 +146,16 @@ async def admin_update_article(token: str, article_id: str, data: dict) -> dict
|
||||
|
||||
async def admin_delete_article(token: str, article_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.delete(f"{API_URL}/admin/articles/{article_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
r = await _get().delete(f"/admin/articles/{article_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
async def admin_publish_article(token: str, article_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{API_URL}/admin/articles/{article_id}/publish", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
r = await _get().post(f"/admin/articles/{article_id}/publish", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
@@ -144,10 +164,9 @@ async def admin_publish_article(token: str, article_id: str) -> bool:
|
||||
|
||||
async def admin_list_categories(token: str) -> list:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/categories", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get("/admin/categories", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
@@ -155,14 +174,13 @@ async def admin_list_categories(token: str) -> list:
|
||||
|
||||
async def admin_create_category(token: str, name: str, slug: str = "") -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(
|
||||
f"{API_URL}/admin/categories",
|
||||
json={"name": name, "slug": slug or None},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().post(
|
||||
"/admin/categories",
|
||||
json={"name": name, "slug": slug or None},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -170,14 +188,13 @@ async def admin_create_category(token: str, name: str, slug: str = "") -> dict |
|
||||
|
||||
async def admin_update_category(token: str, cat_id: str, name: str, slug: str = "") -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.put(
|
||||
f"{API_URL}/admin/categories/{cat_id}",
|
||||
json={"name": name, "slug": slug or None},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().put(
|
||||
f"/admin/categories/{cat_id}",
|
||||
json={"name": name, "slug": slug or None},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
@@ -185,9 +202,8 @@ async def admin_update_category(token: str, cat_id: str, name: str, slug: str =
|
||||
|
||||
async def admin_delete_category(token: str, cat_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.delete(f"{API_URL}/admin/categories/{cat_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
r = await _get().delete(f"/admin/categories/{cat_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
@@ -196,28 +212,26 @@ async def admin_delete_category(token: str, cat_id: str) -> bool:
|
||||
|
||||
async def admin_upload_media(token: str, file_bytes: bytes, filename: str, content_type: str) -> dict | None:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
r = await client.post(
|
||||
f"{API_URL}/admin/media/upload",
|
||||
files={"file": (filename, file_bytes, content_type)},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().post(
|
||||
"/admin/media/upload",
|
||||
files={"file": (filename, file_bytes, content_type)},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def admin_list_media(token: str, offset: int = 0, limit: int = 50, media_type: str | None = None) -> list:
|
||||
params = {"offset": offset, "limit": limit}
|
||||
params: dict = {"offset": offset, "limit": limit}
|
||||
if media_type:
|
||||
params["media_type"] = media_type
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.get(f"{API_URL}/admin/media", params=params, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
r = await _get().get("/admin/media", params=params, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
@@ -225,8 +239,88 @@ async def admin_list_media(token: str, offset: int = 0, limit: int = 50, media_t
|
||||
|
||||
async def admin_delete_media(token: str, media_id: str) -> bool:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.delete(f"{API_URL}/admin/media/{media_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
r = await _get().delete(f"/admin/media/{media_id}", headers=_headers(token))
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
# ─── Admin: VK Sources ────────────────────────────────────────────────────────
|
||||
|
||||
async def admin_vk_status(token: str) -> dict:
|
||||
try:
|
||||
r = await _get().get("/admin/vk/status", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return {"configured": False}
|
||||
|
||||
|
||||
async def admin_vk_list_sources(token: str) -> list:
|
||||
try:
|
||||
r = await _get().get("/admin/vk/sources", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def admin_vk_add_source(token: str, group_id: str, group_name: str, enabled: bool = True) -> dict | None:
|
||||
try:
|
||||
r = await _get().post(
|
||||
"/admin/vk/sources",
|
||||
json={"group_id": group_id, "group_name": group_name, "enabled": enabled},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 201:
|
||||
return r.json()
|
||||
return {"error": r.json().get("detail", "Ошибка")}
|
||||
except httpx.RequestError:
|
||||
return None
|
||||
|
||||
|
||||
async def admin_vk_update_source(token: str, source_id: str, group_id: str, group_name: str, enabled: bool) -> dict | None:
|
||||
try:
|
||||
r = await _get().patch(
|
||||
f"/admin/vk/sources/{source_id}",
|
||||
json={"group_id": group_id, "group_name": group_name, "enabled": enabled},
|
||||
headers=_headers(token),
|
||||
)
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def admin_vk_delete_source(token: str, source_id: str) -> bool:
|
||||
try:
|
||||
r = await _get().delete(f"/admin/vk/sources/{source_id}", headers=_headers(token))
|
||||
return r.status_code == 204
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
|
||||
async def admin_vk_import(token: str) -> dict | None:
|
||||
try:
|
||||
r = await _get().post("/admin/vk/import", headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
async def admin_vk_import_history(token: str, source_id: str | None = None) -> dict | None:
|
||||
url = "/admin/vk/import/history"
|
||||
if source_id:
|
||||
url += f"/{source_id}"
|
||||
try:
|
||||
r = await _get().post(url, headers=_headers(token))
|
||||
if r.status_code == 200:
|
||||
return r.json()
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
return None
|
||||
|
||||
@@ -26,18 +26,19 @@ GOOGLE_FONTS_JS = """
|
||||
"""
|
||||
|
||||
# ─── Цвета ───────────────────────────────────────────────────────────────────
|
||||
PRIMARY = "#1565C0"
|
||||
PRIMARY_LIGHT = "#1976D2"
|
||||
PRIMARY_DARK = "#0D47A1"
|
||||
ACCENT = "#FF6D00"
|
||||
SUCCESS = "#2E7D32"
|
||||
WARNING = "#F57F17"
|
||||
ERROR = "#C62828"
|
||||
PRIMARY = "#2787F5" # VK синий
|
||||
PRIMARY_LIGHT = "#5BA4F7"
|
||||
PRIMARY_DARK = "#1565C0"
|
||||
ACCENT = "#FF3F4B" # красный акцент (лайки / важное)
|
||||
SUCCESS = "#3DC47E"
|
||||
WARNING = "#FFA726"
|
||||
ERROR = "#E64646"
|
||||
SURFACE = "#FFFFFF"
|
||||
BACKGROUND = "#F5F5F5"
|
||||
TEXT_PRIMARY = "#212121"
|
||||
TEXT_SECONDARY = "#616161"
|
||||
DIVIDER = "#E0E0E0"
|
||||
BACKGROUND = "#F0F2F5" # VK светло-серый фон
|
||||
CARD_BG = "#FFFFFF"
|
||||
TEXT_PRIMARY = "#050505"
|
||||
TEXT_SECONDARY = "#818C99" # VK серый для мета-текста
|
||||
DIVIDER = "#E5E9EF"
|
||||
ADMIN_SIDEBAR = "#1A237E"
|
||||
ADMIN_SIDEBAR_TEXT = "#E8EAF6"
|
||||
ADMIN_SIDEBAR_ACTIVE = "#283593"
|
||||
|
||||
@@ -52,22 +52,62 @@ const DEFAULT_META = {
|
||||
status: 'draft',
|
||||
}
|
||||
|
||||
/* ── Загрузчик: сначала данные, потом редактор ── */
|
||||
export default function App() {
|
||||
const [meta, setMeta] = useState(DEFAULT_META)
|
||||
const [initContent, setInitContent] = useState(IS_NEW ? '' : null) // null = ещё не загружено
|
||||
const [initMeta, setInitMeta] = useState(DEFAULT_META)
|
||||
|
||||
useEffect(() => {
|
||||
if (IS_NEW) return
|
||||
api.getArticle(ARTICLE_ID, TOKEN)
|
||||
.then(data => {
|
||||
setInitMeta({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
cover_url: data.cover_url || null,
|
||||
font_family: data.font_family || 'Merriweather',
|
||||
category_id: data.category?.id || null,
|
||||
tag_names: (data.tags || []).map(t => t.name),
|
||||
status: data.status,
|
||||
})
|
||||
const html = data.content?.startsWith('<')
|
||||
? data.content
|
||||
: marked.parse(data.content || '')
|
||||
setInitContent(html || '')
|
||||
})
|
||||
.catch(() => setInitContent(''))
|
||||
}, [])
|
||||
|
||||
// Пока данные не загружены — показываем лоадер
|
||||
if (initContent === null) {
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100vh', alignItems: 'center', justifyContent: 'center',
|
||||
background: '#f8fafc', color: '#94a3b8', fontSize: 14 }}>
|
||||
Загрузка статьи…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <EditorApp initContent={initContent} initMeta={initMeta} />
|
||||
}
|
||||
|
||||
/* ── Сам редактор — рендерится только когда данные готовы ── */
|
||||
function EditorApp({ initContent, initMeta }) {
|
||||
const [meta, setMeta] = useState(initMeta)
|
||||
const [categories, setCategories] = useState([])
|
||||
const [saveStatus, setSaveStatus] = useState('saved')
|
||||
const [mediaState, setMediaState] = useState(null)
|
||||
|
||||
/* Refs so callbacks always see latest values without re-creating them */
|
||||
const metaRef = useRef(meta)
|
||||
metaRef.current = meta
|
||||
const currentIdRef = useRef(IS_NEW ? null : ARTICLE_ID)
|
||||
const saveTimerRef = useRef(null)
|
||||
const editorRef = useRef(null) // always points to live editor instance
|
||||
const articleLoadedRef = useRef(false)
|
||||
const doSaveRef = useRef(null) // always points to latest doSave
|
||||
const metaRef = useRef(meta)
|
||||
metaRef.current = meta
|
||||
const currentIdRef = useRef(initMeta.id ?? (IS_NEW ? null : ARTICLE_ID))
|
||||
const saveTimerRef = useRef(null)
|
||||
const editorRef = useRef(null)
|
||||
const doSaveRef = useRef(null)
|
||||
|
||||
/* ── Editor ── */
|
||||
/* ── Editor создаётся сразу с готовым контентом ── */
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -88,54 +128,22 @@ export default function App() {
|
||||
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
|
||||
CharacterCount,
|
||||
],
|
||||
content: initContent,
|
||||
onUpdate: () => {
|
||||
setSaveStatus('unsaved')
|
||||
clearTimeout(saveTimerRef.current)
|
||||
// Always call the latest doSave via ref — avoids stale closure
|
||||
saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000)
|
||||
},
|
||||
})
|
||||
|
||||
// Keep editorRef current every render so doSave never uses a stale instance
|
||||
editorRef.current = editor
|
||||
|
||||
/* ── Load categories once on mount ── */
|
||||
/* ── Категории ── */
|
||||
useEffect(() => {
|
||||
api.getCategories(TOKEN).then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
/* ── Load article once the editor is ready ── */
|
||||
useEffect(() => {
|
||||
if (!editor || IS_NEW || articleLoadedRef.current) return
|
||||
articleLoadedRef.current = true
|
||||
|
||||
api.getArticle(ARTICLE_ID, TOKEN)
|
||||
.then(data => {
|
||||
setMeta({
|
||||
id: data.id,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
cover_url: data.cover_url || null,
|
||||
font_family: data.font_family || 'Merriweather',
|
||||
category_id: data.category?.id || null,
|
||||
tag_names: (data.tags || []).map(t => t.name),
|
||||
status: data.status,
|
||||
})
|
||||
const html = data.content?.startsWith('<')
|
||||
? data.content
|
||||
: marked.parse(data.content || '')
|
||||
editor.commands.setContent(html, false)
|
||||
setSaveStatus('saved')
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [editor])
|
||||
|
||||
/* ── Save ─────────────────────────────────────────────────────────────────
|
||||
Uses editorRef + metaRef so this callback is stable (no deps that change).
|
||||
doSaveRef is updated every render so the auto-save timer always calls the
|
||||
freshest version.
|
||||
── */
|
||||
/* ── Сохранение ── */
|
||||
const doSave = useCallback(async overrideStatus => {
|
||||
const editor = editorRef.current
|
||||
if (!editor) return
|
||||
@@ -143,15 +151,15 @@ export default function App() {
|
||||
setSaveStatus('saving')
|
||||
|
||||
const payload = {
|
||||
title: m.title || 'Без названия',
|
||||
slug: m.slug || undefined,
|
||||
content: editor.getHTML(),
|
||||
excerpt: m.excerpt,
|
||||
cover_url: m.cover_url || null,
|
||||
title: m.title || 'Без названия',
|
||||
slug: m.slug || undefined,
|
||||
content: editor.getHTML(),
|
||||
excerpt: m.excerpt,
|
||||
cover_url: m.cover_url || null,
|
||||
font_family: m.font_family,
|
||||
category_id: m.category_id || null,
|
||||
tag_names: m.tag_names,
|
||||
status: overrideStatus ?? m.status,
|
||||
tag_names: m.tag_names,
|
||||
status: overrideStatus ?? m.status,
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -165,15 +173,12 @@ export default function App() {
|
||||
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
|
||||
}
|
||||
setSaveStatus('saved')
|
||||
if (overrideStatus) {
|
||||
setMeta(m => ({ ...m, status: overrideStatus }))
|
||||
}
|
||||
if (overrideStatus) setMeta(m => ({ ...m, status: overrideStatus }))
|
||||
} catch {
|
||||
setSaveStatus('error')
|
||||
}
|
||||
}, []) // stable: reads from editorRef + metaRef
|
||||
}, [])
|
||||
|
||||
// Keep ref current so the auto-save timer always calls the latest version
|
||||
doSaveRef.current = doSave
|
||||
|
||||
const handleUploadCover = async file => {
|
||||
@@ -183,7 +188,6 @@ export default function App() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/* ── Render ── */
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||
|
||||
|
||||
@@ -15,13 +15,20 @@ export function Header({ saveStatus, articleStatus, onSave, onPublish }) {
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 h-14 bg-slate-900 border-b border-slate-800 flex-shrink-0 z-20">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="javascript:history.back()"
|
||||
<button
|
||||
onClick={() => {
|
||||
// Если в iframe — говорим родителю закрыть редактор
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({ type: 'editor-close' }, '*')
|
||||
} else {
|
||||
window.close()
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 text-slate-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Назад</span>
|
||||
</a>
|
||||
<span>Закрыть</span>
|
||||
</button>
|
||||
<div className="w-px h-5 bg-slate-700" />
|
||||
<span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span>
|
||||
</div>
|
||||
|
||||
@@ -35,38 +35,60 @@ function Sep() {
|
||||
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
|
||||
}
|
||||
|
||||
/* ── Hook: позиция дропдауна через fixed (вырывается из overflow-контейнеров) ── */
|
||||
|
||||
function useFixedDropdown() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef(null)
|
||||
|
||||
const toggle = e => {
|
||||
e.preventDefault()
|
||||
if (!open && btnRef.current) {
|
||||
const r = btnRef.current.getBoundingClientRect()
|
||||
setPos({ top: r.bottom + 4, left: r.left })
|
||||
}
|
||||
setOpen(o => !o)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = e => {
|
||||
if (!btnRef.current?.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [open])
|
||||
|
||||
return { open, setOpen, pos, btnRef, toggle }
|
||||
}
|
||||
|
||||
/* ── Font family dropdown ── */
|
||||
|
||||
function FontDropdown({ editor, fonts }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||
onMouseDown={toggle}
|
||||
className="flex items-center gap-1 px-2 h-[30px] rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors max-w-[130px] cursor-pointer"
|
||||
title="Шрифт"
|
||||
>
|
||||
<span
|
||||
className="truncate leading-none"
|
||||
style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}
|
||||
>
|
||||
<span className="truncate leading-none" style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}>
|
||||
{current}
|
||||
</span>
|
||||
<ChevronDown size={11} className="flex-shrink-0 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 max-h-72 overflow-y-auto py-1.5">
|
||||
<div
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
|
||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto py-1.5"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
|
||||
@@ -95,21 +117,15 @@ function FontDropdown({ editor, fonts }) {
|
||||
/* ── Font size dropdown ── */
|
||||
|
||||
function SizeDropdown({ editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||
onMouseDown={toggle}
|
||||
className="flex items-center gap-1 px-1.5 h-[30px] w-14 rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors cursor-pointer"
|
||||
title="Размер шрифта"
|
||||
>
|
||||
@@ -118,7 +134,10 @@ function SizeDropdown({ editor }) {
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-20 max-h-56 overflow-y-auto py-1.5">
|
||||
<div
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 80 }}
|
||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-56 overflow-y-auto py-1.5"
|
||||
>
|
||||
{FONT_SIZES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
@@ -138,14 +157,7 @@ function SizeDropdown({ editor }) {
|
||||
/* ── Table menu ── */
|
||||
|
||||
function TableMenu({ editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
|
||||
|
||||
const items = [
|
||||
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
@@ -162,17 +174,22 @@ function TableMenu({ editor }) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<div className="flex-shrink-0">
|
||||
<Btn
|
||||
onClick={() => setOpen(o => !o)}
|
||||
onClick={e => toggle({ preventDefault: () => {}, ...e })}
|
||||
active={editor.isActive('table')}
|
||||
title="Таблица"
|
||||
>
|
||||
<Table2 size={14} />
|
||||
</Btn>
|
||||
{/* Используем ref на обёртке для Btn */}
|
||||
<span ref={btnRef} style={{ display: 'none' }} />
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 py-1.5">
|
||||
<div
|
||||
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
|
||||
className="bg-white border border-slate-200 rounded-xl shadow-2xl py-1.5"
|
||||
>
|
||||
{items.map((item, i) =>
|
||||
item === null ? (
|
||||
<div key={i} className="my-1 border-t border-slate-100" />
|
||||
@@ -181,9 +198,7 @@ function TableMenu({ editor }) {
|
||||
key={item.label}
|
||||
type="button"
|
||||
className={`w-full px-3 py-1.5 text-left text-xs transition-colors
|
||||
${item.danger
|
||||
? 'text-red-500 hover:bg-red-50'
|
||||
: 'text-slate-700 hover:bg-slate-50'}`}
|
||||
${item.danger ? 'text-red-500 hover:bg-red-50' : 'text-slate-700 hover:bg-slate-50'}`}
|
||||
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
|
||||
>
|
||||
{item.label}
|
||||
@@ -210,7 +225,7 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-wrap min-h-[46px]">
|
||||
<div className="flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-shrink-0 min-h-[46px]" style={{ scrollbarWidth: 'none' }}>
|
||||
{/* History */}
|
||||
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
|
||||
<Undo2 size={14} />
|
||||
|
||||
@@ -8,6 +8,7 @@ from views.articles_list import ArticlesListView
|
||||
from views.article_editor import ArticleEditorView
|
||||
from views.categories import CategoriesView
|
||||
from views.media_library import MediaLibraryView
|
||||
from views.vk_sources import VkSourcesView
|
||||
|
||||
_PUBLIC = {"/", "/admin/login"}
|
||||
|
||||
@@ -45,6 +46,8 @@ def resolve_view(page: ft.Page, route: str):
|
||||
return CategoriesView(page)
|
||||
if sub == "media":
|
||||
return MediaLibraryView(page)
|
||||
if sub == "vk":
|
||||
return VkSourcesView(page)
|
||||
return DashboardView(page)
|
||||
|
||||
return NewsFeedView(page)
|
||||
|
||||
@@ -42,15 +42,18 @@ class DashboardView:
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
import asyncio
|
||||
token = self.page.session.get("token")
|
||||
data = await api.admin_list_articles(token, limit=100)
|
||||
if not data:
|
||||
return
|
||||
published_data, draft_data, recent_data = await asyncio.gather(
|
||||
api.admin_list_articles(token, limit=1, status="published"),
|
||||
api.admin_list_articles(token, limit=1, status="draft"),
|
||||
api.admin_list_articles(token, limit=10),
|
||||
)
|
||||
|
||||
items = data.get("items", [])
|
||||
total = data.get("total", 0)
|
||||
published = sum(1 for a in items if a.get("status") == "published")
|
||||
drafts = total - published
|
||||
published = (published_data or {}).get("total", 0)
|
||||
drafts = (draft_data or {}).get("total", 0)
|
||||
total = published + drafts
|
||||
items = (recent_data or {}).get("items", [])
|
||||
|
||||
self._stats_row.controls = [
|
||||
d.stat_card("Всего статей", str(total), ft.icons.ARTICLE, d.PRIMARY),
|
||||
@@ -58,7 +61,7 @@ class DashboardView:
|
||||
d.stat_card("Черновики", str(drafts), ft.icons.EDIT_NOTE, d.WARNING),
|
||||
]
|
||||
|
||||
self._recent.controls = [_recent_item(a, self.page) for a in items[:10]]
|
||||
self._recent.controls = [_recent_item(a, self.page) for a in items]
|
||||
self.page.update()
|
||||
|
||||
|
||||
@@ -118,6 +121,7 @@ def _sidebar(page: ft.Page, active: str = "") -> ft.Container:
|
||||
(ft.icons.ARTICLE, "Статьи", "/admin/articles"),
|
||||
(ft.icons.CATEGORY, "Категории", "/admin/categories"),
|
||||
(ft.icons.PERM_MEDIA, "Медиа", "/admin/media"),
|
||||
(ft.icons.RSS_FEED, "ВКонтакте", "/admin/vk"),
|
||||
]
|
||||
|
||||
def nav_item(icon, label, route):
|
||||
|
||||
@@ -1,9 +1,41 @@
|
||||
import re
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
import theme_manager
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
"""Convert TipTap HTML output to Markdown for ft.Markdown rendering."""
|
||||
t = html or ""
|
||||
# Headings
|
||||
t = re.sub(r"<h([1-6])[^>]*>(.*?)</h\1>",
|
||||
lambda m: "#" * int(m[1]) + " " + m[2] + "\n\n",
|
||||
t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Bold / italic
|
||||
t = re.sub(r"<(strong|b)[^>]*>(.*?)</\1>", r"**\2**", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
t = re.sub(r"<(em|i)[^>]*>(.*?)</\1>", r"*\2*", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Links
|
||||
t = re.sub(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
|
||||
r"[\2](\1)", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Images
|
||||
t = re.sub(r'<img[^>]+src=["\']([^"\']+)["\'][^>]*>', r"", t, flags=re.IGNORECASE)
|
||||
# Lists
|
||||
t = re.sub(r"<li[^>]*>(.*?)</li>", r"- \1\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
t = re.sub(r"</?[uo]l[^>]*>", "\n", t, flags=re.IGNORECASE)
|
||||
# Paragraphs / line breaks
|
||||
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.IGNORECASE)
|
||||
t = re.sub(r"<p[^>]*>(.*?)</p>", r"\1\n\n", t, flags=re.DOTALL | re.IGNORECASE)
|
||||
# Strip remaining tags
|
||||
t = re.sub(r"<[^>]+>", "", t)
|
||||
# HTML entities
|
||||
t = (t.replace(" ", " ").replace("&", "&")
|
||||
.replace("<", "<").replace(">", ">").replace(""", '"'))
|
||||
# Collapse excessive blank lines
|
||||
t = re.sub(r"\n{3,}", "\n\n", t)
|
||||
return t.strip()
|
||||
|
||||
|
||||
class NewsDetailView:
|
||||
def __init__(self, page: ft.Page, slug: str):
|
||||
self.page = page
|
||||
@@ -159,7 +191,7 @@ class NewsDetailView:
|
||||
|
||||
controls.append(
|
||||
ft.Markdown(
|
||||
value=a.get("content", ""),
|
||||
value=_html_to_markdown(a.get("content", "")),
|
||||
selectable=True,
|
||||
extension_set=ft.MarkdownExtensionSet.GITHUB_WEB,
|
||||
on_tap_link=lambda e: self.page.launch_url(e.data),
|
||||
@@ -167,6 +199,26 @@ class NewsDetailView:
|
||||
)
|
||||
)
|
||||
|
||||
if a.get("source_url"):
|
||||
source_url = a["source_url"]
|
||||
controls.append(
|
||||
ft.Container(
|
||||
content=ft.Row([
|
||||
ft.Icon(ft.icons.OPEN_IN_NEW, size=15, color=d.PRIMARY),
|
||||
ft.Text("Источник:", size=13, color=d.TEXT_SECONDARY),
|
||||
ft.TextButton(
|
||||
source_url,
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, url=source_url: self.page.launch_url(url),
|
||||
),
|
||||
], spacing=6, vertical_alignment=ft.CrossAxisAlignment.CENTER, wrap=True),
|
||||
bgcolor=ft.colors.with_opacity(0.04, d.PRIMARY),
|
||||
border_radius=8,
|
||||
padding=12,
|
||||
border=ft.border.all(1, ft.colors.with_opacity(0.15, d.PRIMARY)),
|
||||
)
|
||||
)
|
||||
|
||||
controls.append(ft.Divider(height=32, color="transparent"))
|
||||
controls.append(
|
||||
ft.TextButton(
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
|
||||
|
||||
_DATE_PRESETS = [
|
||||
(None, "Всё время"),
|
||||
("week", "Неделя"),
|
||||
("month", "Месяц"),
|
||||
("3months", "3 месяца"),
|
||||
("year", "Год"),
|
||||
]
|
||||
|
||||
|
||||
def _preset_to_date_from(preset: str | None) -> str | None:
|
||||
if preset is None:
|
||||
return None
|
||||
deltas = {
|
||||
"week": timedelta(weeks=1),
|
||||
"month": timedelta(days=30),
|
||||
"3months": timedelta(days=90),
|
||||
"year": timedelta(days=365),
|
||||
}
|
||||
if preset in deltas:
|
||||
return (datetime.now() - deltas[preset]).isoformat()
|
||||
return None
|
||||
|
||||
|
||||
class NewsFeedView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
@@ -12,82 +38,103 @@ class NewsFeedView:
|
||||
self._has_more = True
|
||||
self._loading = False
|
||||
self._category: str | None = None
|
||||
self._tag: str | None = None
|
||||
self._date_preset: str | None = None
|
||||
self._categories: list = []
|
||||
self._tags: list = []
|
||||
|
||||
self._list_col = ft.Column(spacing=16)
|
||||
self._grid = ft.GridView(
|
||||
max_extent=290,
|
||||
child_aspect_ratio=0.72,
|
||||
spacing=12,
|
||||
run_spacing=12,
|
||||
expand=True,
|
||||
padding=0,
|
||||
)
|
||||
self._loader = ft.Container(
|
||||
content=d.loader(36),
|
||||
alignment=ft.alignment.center,
|
||||
padding=20,
|
||||
visible=True,
|
||||
)
|
||||
self._load_more_btn = ft.ElevatedButton(
|
||||
"Загрузить ещё",
|
||||
icon=ft.icons.EXPAND_MORE,
|
||||
on_click=lambda e: self.page.run_task(self._load_more),
|
||||
visible=False,
|
||||
style=ft.ButtonStyle(
|
||||
bgcolor=d.SURFACE,
|
||||
color=d.PRIMARY,
|
||||
shape=ft.RoundedRectangleBorder(radius=8),
|
||||
self._load_more_btn = ft.Container(
|
||||
content=ft.TextButton(
|
||||
"Показать ещё",
|
||||
icon=ft.icons.EXPAND_MORE,
|
||||
on_click=lambda e: self.page.run_task(self._load_more),
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
),
|
||||
alignment=ft.alignment.center,
|
||||
padding=ft.padding.only(bottom=24),
|
||||
visible=False,
|
||||
)
|
||||
self._cat_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=8)
|
||||
self._tag_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6)
|
||||
self._date_row = ft.Row(scroll=ft.ScrollMode.AUTO, spacing=6)
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
header = ft.Container(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.NEWSPAPER, color=ft.colors.WHITE, size=28),
|
||||
ft.Text("Новости", size=20, weight=ft.FontWeight.BOLD, color=ft.colors.WHITE),
|
||||
], spacing=8),
|
||||
ft.Row([
|
||||
ft.TextButton(
|
||||
"Войти",
|
||||
icon=ft.icons.ADMIN_PANEL_SETTINGS_OUTLINED,
|
||||
style=ft.ButtonStyle(color=ft.colors.WHITE70),
|
||||
on_click=lambda e: self.page.go("/admin/login"),
|
||||
)
|
||||
]),
|
||||
ft.Container(
|
||||
content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, color=ft.colors.WHITE, size=22),
|
||||
bgcolor=ft.colors.with_opacity(0.2, ft.colors.WHITE),
|
||||
border_radius=8,
|
||||
padding=6,
|
||||
),
|
||||
ft.Text(
|
||||
"Новости колледжей",
|
||||
size=18,
|
||||
weight=ft.FontWeight.W_700,
|
||||
color=ft.colors.WHITE,
|
||||
font_family=d.FONT_UI,
|
||||
),
|
||||
], spacing=10),
|
||||
ft.IconButton(
|
||||
icon=ft.icons.MANAGE_ACCOUNTS_OUTLINED,
|
||||
icon_color=ft.colors.WHITE70,
|
||||
tooltip="Войти в панель управления",
|
||||
on_click=lambda e: self.page.go("/admin/login"),
|
||||
icon_size=22,
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
bgcolor=d.ADMIN_SIDEBAR,
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=14),
|
||||
bgcolor=d.PRIMARY,
|
||||
padding=ft.padding.symmetric(horizontal=20, vertical=12),
|
||||
shadow=ft.BoxShadow(
|
||||
blur_radius=8,
|
||||
offset=ft.Offset(0, 2),
|
||||
color=ft.colors.with_opacity(0.18, ft.colors.BLACK),
|
||||
),
|
||||
)
|
||||
|
||||
cat_section = ft.Container(
|
||||
filters = ft.Container(
|
||||
content=ft.Column([
|
||||
ft.Container(content=self._cat_row, padding=ft.padding.symmetric(horizontal=24, vertical=8)),
|
||||
]),
|
||||
ft.Container(
|
||||
content=self._cat_row,
|
||||
padding=ft.padding.only(left=16, right=16, top=10, bottom=4),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._tag_row,
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=4),
|
||||
),
|
||||
ft.Container(
|
||||
content=self._date_row,
|
||||
padding=ft.padding.only(left=16, right=16, top=4, bottom=10),
|
||||
),
|
||||
], spacing=0),
|
||||
bgcolor=d.SURFACE,
|
||||
border=ft.Border(bottom=ft.BorderSide(1, d.DIVIDER)),
|
||||
)
|
||||
|
||||
main_content = ft.Container(
|
||||
feed = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self._list_col,
|
||||
self._loader,
|
||||
ft.Container(
|
||||
content=self._load_more_btn,
|
||||
alignment=ft.alignment.center,
|
||||
padding=16,
|
||||
),
|
||||
],
|
||||
spacing=16,
|
||||
),
|
||||
width=680,
|
||||
),
|
||||
],
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
[self._grid, self._loader, self._load_more_btn],
|
||||
spacing=0,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=16, vertical=24),
|
||||
alignment=ft.alignment.top_center,
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=16),
|
||||
expand=True,
|
||||
)
|
||||
|
||||
@@ -98,10 +145,9 @@ class NewsFeedView:
|
||||
ft.Column(
|
||||
[
|
||||
header,
|
||||
cat_section,
|
||||
filters,
|
||||
ft.Container(
|
||||
content=main_content,
|
||||
alignment=ft.alignment.top_center,
|
||||
content=feed,
|
||||
expand=True,
|
||||
),
|
||||
],
|
||||
@@ -113,27 +159,39 @@ class NewsFeedView:
|
||||
)
|
||||
|
||||
async def load(self):
|
||||
self._categories = await api.get_categories()
|
||||
self._render_categories()
|
||||
self._categories, self._tags = await _gather(
|
||||
api.get_categories(),
|
||||
api.get_tags(30),
|
||||
)
|
||||
# Все фильтры — один update вместо трёх
|
||||
self._render_categories(update=False)
|
||||
self._render_tags(update=False)
|
||||
self._render_dates(update=False)
|
||||
self.page.update()
|
||||
await self._load_articles()
|
||||
|
||||
async def _load_more(self):
|
||||
if self._loading or not self._has_more:
|
||||
return
|
||||
self._loading = True
|
||||
self._load_more_btn.disabled = True
|
||||
self._load_more_btn.visible = False
|
||||
self.page.update()
|
||||
self._offset += self._limit
|
||||
await self._load_articles()
|
||||
self._loading = False
|
||||
self._load_more_btn.disabled = False
|
||||
self.page.update()
|
||||
|
||||
async def _load_articles(self):
|
||||
self._loader.visible = True
|
||||
self.page.update()
|
||||
|
||||
data = await api.get_news(offset=self._offset, limit=self._limit, category=self._category)
|
||||
date_from = _preset_to_date_from(self._date_preset)
|
||||
data = await api.get_news(
|
||||
offset=self._offset,
|
||||
limit=self._limit,
|
||||
category=self._category,
|
||||
tag=self._tag,
|
||||
date_from=date_from,
|
||||
)
|
||||
self._loader.visible = False
|
||||
|
||||
if not data:
|
||||
@@ -145,120 +203,250 @@ class NewsFeedView:
|
||||
self._has_more = data.get("has_more", False)
|
||||
|
||||
for a in items:
|
||||
self._list_col.controls.append(self._build_card(a))
|
||||
self._grid.controls.append(self._build_card(a))
|
||||
|
||||
self._load_more_btn.visible = self._has_more
|
||||
self.page.update()
|
||||
|
||||
def _render_categories(self):
|
||||
def _reset_feed(self):
|
||||
self._articles = []
|
||||
self._offset = 0
|
||||
self._has_more = True
|
||||
self._grid.controls.clear()
|
||||
|
||||
# ── Category filter ────────────────────────────────────────────────────────
|
||||
|
||||
def _render_categories(self, update: bool = True):
|
||||
self._cat_row.controls = [
|
||||
self._cat_chip(None, "Все")
|
||||
] + [
|
||||
self._cat_chip(c["slug"], c["name"]) for c in self._categories
|
||||
]
|
||||
self.page.update()
|
||||
if update:
|
||||
self.page.update()
|
||||
|
||||
def _cat_chip(self, slug: str | None, label: str) -> ft.Chip:
|
||||
is_active = self._category == slug
|
||||
return ft.Chip(
|
||||
label=ft.Text(label, size=13),
|
||||
label=ft.Text(label, size=12, font_family=d.FONT_UI),
|
||||
bgcolor=d.PRIMARY if is_active else d.SURFACE,
|
||||
label_style=ft.TextStyle(color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY),
|
||||
label_style=ft.TextStyle(
|
||||
color=ft.colors.WHITE if is_active else d.TEXT_PRIMARY,
|
||||
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
|
||||
),
|
||||
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
|
||||
padding=ft.padding.symmetric(horizontal=12, vertical=6),
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=2),
|
||||
border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||
elevation=0,
|
||||
)
|
||||
|
||||
async def _filter_category(self, slug: str | None):
|
||||
self._category = slug
|
||||
self._articles = []
|
||||
self._offset = 0
|
||||
self._has_more = True
|
||||
self._list_col.controls.clear()
|
||||
self._reset_feed()
|
||||
self._render_categories()
|
||||
await self._load_articles()
|
||||
|
||||
# ── Tag filter ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_tags(self, update: bool = True):
|
||||
if not self._tags:
|
||||
self._tag_row.controls = []
|
||||
else:
|
||||
self._tag_row.controls = [
|
||||
ft.Container(
|
||||
content=ft.Text("Теги:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
padding=ft.padding.only(right=4),
|
||||
)
|
||||
] + [self._tag_chip(t["slug"], t["name"]) for t in self._tags]
|
||||
if update:
|
||||
self.page.update()
|
||||
|
||||
def _tag_chip(self, slug: str, label: str) -> ft.Container:
|
||||
is_active = self._tag == slug
|
||||
return ft.Container(
|
||||
content=ft.Text(
|
||||
f"#{label}",
|
||||
size=11,
|
||||
color=ft.colors.WHITE if is_active else d.PRIMARY,
|
||||
font_family=d.FONT_UI,
|
||||
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
|
||||
),
|
||||
bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.08, d.PRIMARY),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
on_click=lambda e, s=slug: self.page.run_task(self._filter_tag, s),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
async def _filter_tag(self, slug: str):
|
||||
self._tag = None if self._tag == slug else slug
|
||||
self._reset_feed()
|
||||
self._render_tags()
|
||||
await self._load_articles()
|
||||
|
||||
# ── Date filter ────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_dates(self, update: bool = True):
|
||||
self._date_row.controls = [
|
||||
ft.Container(
|
||||
content=ft.Text("Период:", size=11, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
padding=ft.padding.only(right=4),
|
||||
)
|
||||
] + [self._date_btn(preset, label) for preset, label in _DATE_PRESETS]
|
||||
if update:
|
||||
self.page.update()
|
||||
|
||||
def _date_btn(self, preset: str | None, label: str) -> ft.Container:
|
||||
is_active = self._date_preset == preset
|
||||
return ft.Container(
|
||||
content=ft.Text(
|
||||
label,
|
||||
size=11,
|
||||
color=ft.colors.WHITE if is_active else d.TEXT_SECONDARY,
|
||||
font_family=d.FONT_UI,
|
||||
weight=ft.FontWeight.W_600 if is_active else ft.FontWeight.NORMAL,
|
||||
),
|
||||
bgcolor=d.PRIMARY if is_active else ft.colors.with_opacity(0.06, ft.colors.BLACK),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
border=ft.border.all(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||
on_click=lambda e, p=preset: self.page.run_task(self._filter_date, p),
|
||||
ink=True,
|
||||
)
|
||||
|
||||
async def _filter_date(self, preset: str | None):
|
||||
self._date_preset = preset
|
||||
self._reset_feed()
|
||||
self._render_dates()
|
||||
await self._load_articles()
|
||||
|
||||
# ── Card ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_card(self, article: dict) -> ft.Container:
|
||||
slug = article["slug"]
|
||||
source_url = article.get("source_url", "") or ""
|
||||
|
||||
src_domain = ""
|
||||
if source_url:
|
||||
try:
|
||||
src_domain = urlparse(source_url).netloc.replace("www.", "")
|
||||
except Exception:
|
||||
src_domain = "vk.com"
|
||||
|
||||
pub_date = ""
|
||||
if article.get("published_at"):
|
||||
pub_date = article["published_at"][:10]
|
||||
|
||||
slug = article["slug"]
|
||||
try:
|
||||
dt = datetime.fromisoformat(article["published_at"].replace("Z", "+00:00"))
|
||||
pub_date = dt.strftime("%d.%m.%Y")
|
||||
except Exception:
|
||||
pub_date = article["published_at"][:10]
|
||||
|
||||
cat = article.get("category")
|
||||
meta_row_controls = []
|
||||
if cat:
|
||||
meta_row_controls.append(ft.Container(
|
||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600),
|
||||
bgcolor=d.PRIMARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
))
|
||||
if pub_date:
|
||||
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY))
|
||||
|
||||
cover_section = ft.Container(
|
||||
content=ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=float("inf"),
|
||||
height=200,
|
||||
fit=ft.ImageFit.COVER,
|
||||
),
|
||||
height=200,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
border_radius=ft.BorderRadius(12, 12, 0, 0),
|
||||
) if article.get("cover_url") else ft.Container(visible=False)
|
||||
# Cover
|
||||
if article.get("cover_url"):
|
||||
cover = ft.Container(
|
||||
content=ft.Image(
|
||||
src=article["cover_url"],
|
||||
fit=ft.ImageFit.COVER,
|
||||
width=float("inf"),
|
||||
height=170,
|
||||
error_content=ft.Container(
|
||||
bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY),
|
||||
content=ft.Icon(ft.icons.IMAGE_NOT_SUPPORTED_OUTLINED, size=28, color=d.DIVIDER),
|
||||
alignment=ft.alignment.center,
|
||||
height=170,
|
||||
),
|
||||
),
|
||||
height=170,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
)
|
||||
else:
|
||||
cover = ft.Container(
|
||||
height=120,
|
||||
bgcolor=ft.colors.with_opacity(0.06, d.PRIMARY),
|
||||
content=ft.Icon(ft.icons.NEWSPAPER_ROUNDED, size=32, color=ft.colors.with_opacity(0.25, d.PRIMARY)),
|
||||
alignment=ft.alignment.center,
|
||||
)
|
||||
|
||||
body = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
|
||||
ft.Text(
|
||||
article["title"],
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(
|
||||
article.get("excerpt", ""),
|
||||
size=13,
|
||||
color=d.TEXT_SECONDARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
) if article.get("excerpt") else ft.Container(visible=False),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
ft.TextButton(
|
||||
"Читать →",
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
# Category + date row
|
||||
meta_row = ft.Row([
|
||||
ft.Text(
|
||||
cat["name"] if cat else "",
|
||||
size=10,
|
||||
color=d.PRIMARY,
|
||||
weight=ft.FontWeight.W_600,
|
||||
font_family=d.FONT_UI,
|
||||
max_lines=1,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
expand=True,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=20, vertical=16),
|
||||
ft.Text(pub_date, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
], spacing=4, vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
title = ft.Text(
|
||||
article["title"],
|
||||
size=13,
|
||||
weight=ft.FontWeight.W_700,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=2,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
font_family=d.FONT_UI,
|
||||
)
|
||||
|
||||
tags = article.get("tags", [])
|
||||
tag_row = ft.Row(
|
||||
[
|
||||
ft.Container(
|
||||
content=ft.Text(f"#{t['name']}", size=10, color=d.PRIMARY, font_family=d.FONT_UI),
|
||||
bgcolor=ft.colors.with_opacity(0.08, d.PRIMARY),
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=6, vertical=2),
|
||||
)
|
||||
for t in tags[:3]
|
||||
],
|
||||
spacing=4,
|
||||
wrap=True,
|
||||
) if tags else ft.Container(height=2)
|
||||
|
||||
# Footer: source domain + "Читать →"
|
||||
footer = ft.Row([
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.LINK, size=11, color=d.TEXT_SECONDARY),
|
||||
ft.Text(src_domain, size=10, color=d.TEXT_SECONDARY, font_family=d.FONT_UI),
|
||||
], spacing=2) if src_domain else ft.Container(),
|
||||
ft.Container(expand=True),
|
||||
ft.Text("Читать →", size=11, color=d.PRIMARY, weight=ft.FontWeight.W_600, font_family=d.FONT_UI),
|
||||
], vertical_alignment=ft.CrossAxisAlignment.CENTER)
|
||||
|
||||
content_col = ft.Column(
|
||||
[meta_row, title, tag_row, ft.Container(expand=True), footer],
|
||||
spacing=6,
|
||||
expand=True,
|
||||
)
|
||||
|
||||
return ft.Container(
|
||||
content=ft.Column([cover_section, body], spacing=0),
|
||||
bgcolor=d.SURFACE,
|
||||
content=ft.Column([
|
||||
cover,
|
||||
ft.Container(
|
||||
content=content_col,
|
||||
padding=ft.padding.symmetric(horizontal=10, vertical=8),
|
||||
expand=True,
|
||||
),
|
||||
], spacing=0, expand=True),
|
||||
bgcolor=d.CARD_BG,
|
||||
border_radius=12,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.with_opacity(0.07, ft.colors.BLACK)),
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
shadow=ft.BoxShadow(
|
||||
blur_radius=6,
|
||||
offset=ft.Offset(0, 1),
|
||||
color=ft.colors.with_opacity(0.08, ft.colors.BLACK),
|
||||
),
|
||||
on_click=lambda e, s=slug: self.page.go(f"/news/{s}"),
|
||||
ink=True,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
)
|
||||
|
||||
|
||||
async def _gather(*coros):
|
||||
import asyncio
|
||||
return await asyncio.gather(*coros)
|
||||
|
||||
359
web/views/vk_sources.py
Normal file
359
web/views/vk_sources.py
Normal file
@@ -0,0 +1,359 @@
|
||||
import flet as ft
|
||||
import designer as d
|
||||
import api_client as api
|
||||
from views.dashboard import _admin_appbar, _sidebar
|
||||
|
||||
|
||||
class VkSourcesView:
|
||||
def __init__(self, page: ft.Page):
|
||||
self.page = page
|
||||
self._sources: list = []
|
||||
self._api_ok = False
|
||||
self._editing_id: str | None = None # ID источника в режиме редактирования
|
||||
|
||||
# Статус бейдж
|
||||
self._status_row = ft.Row(spacing=6)
|
||||
|
||||
# Уведомление (добавляется в page.overlay при load)
|
||||
self._snack = ft.SnackBar(content=ft.Text(""), open=False)
|
||||
|
||||
# Таблица источников
|
||||
self._table = ft.DataTable(
|
||||
columns=[
|
||||
ft.DataColumn(ft.Text("Группа", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("ID", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("Последний импорт", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("Статус", weight=ft.FontWeight.W_600, size=13)),
|
||||
ft.DataColumn(ft.Text("Действия", weight=ft.FontWeight.W_600, size=13)),
|
||||
],
|
||||
rows=[],
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
border_radius=8,
|
||||
vertical_lines=ft.BorderSide(1, d.DIVIDER),
|
||||
heading_row_color=ft.colors.with_opacity(0.04, ft.colors.BLACK),
|
||||
data_row_max_height=56,
|
||||
column_spacing=16,
|
||||
)
|
||||
|
||||
# Форма добавления / редактирования
|
||||
self._form_gid = ft.TextField(
|
||||
label="ID группы (числовой)",
|
||||
hint_text="95503797",
|
||||
width=220,
|
||||
dense=True,
|
||||
)
|
||||
self._form_gname = ft.TextField(
|
||||
label="Название группы",
|
||||
hint_text="ЮУрГК",
|
||||
width=240,
|
||||
dense=True,
|
||||
)
|
||||
self._form_enabled = ft.Checkbox(label="Активна", value=True)
|
||||
self._form_save_btn = ft.ElevatedButton(
|
||||
"Добавить",
|
||||
icon=ft.icons.ADD,
|
||||
bgcolor=d.PRIMARY,
|
||||
color=ft.colors.WHITE,
|
||||
on_click=lambda e: self.page.run_task(self._save_source),
|
||||
)
|
||||
self._form_cancel_btn = ft.TextButton(
|
||||
"Отмена",
|
||||
visible=False,
|
||||
on_click=lambda e: self._cancel_edit(),
|
||||
)
|
||||
|
||||
# ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def build_page_view(self) -> ft.View:
|
||||
token_info = ft.Container(
|
||||
content=ft.Column([
|
||||
self._status_row,
|
||||
ft.Text(
|
||||
"Без токена VK API — HTML-парсинг не работает (ВК перешёл на JS-рендеринг).\n"
|
||||
"Получи бесплатный сервисный ключ: vk.com/dev → Мои приложения → Создать → "
|
||||
"Standalone → вкладка «Настройки» → «Сервисный ключ доступа».\n"
|
||||
"Затем добавь в .env: VK_ACCESS_TOKEN=<ключ>",
|
||||
size=12,
|
||||
color=d.TEXT_SECONDARY,
|
||||
selectable=True,
|
||||
),
|
||||
], spacing=6),
|
||||
bgcolor=ft.colors.with_opacity(0.04, ft.colors.BLACK),
|
||||
border_radius=8,
|
||||
padding=14,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
)
|
||||
|
||||
form = ft.Container(
|
||||
content=ft.Column([
|
||||
d.headline("Добавить / редактировать группу", size=15),
|
||||
ft.Row(
|
||||
[self._form_gid, self._form_gname, self._form_enabled],
|
||||
spacing=10, wrap=True,
|
||||
),
|
||||
ft.Row(
|
||||
[self._form_save_btn, self._form_cancel_btn],
|
||||
spacing=10,
|
||||
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
], spacing=10),
|
||||
bgcolor=d.SURFACE,
|
||||
border_radius=10,
|
||||
padding=16,
|
||||
border=ft.border.all(1, d.DIVIDER),
|
||||
)
|
||||
|
||||
import_btns = ft.Row([
|
||||
ft.ElevatedButton(
|
||||
"Импорт новых постов",
|
||||
icon=ft.icons.SYNC,
|
||||
bgcolor=d.PRIMARY,
|
||||
color=ft.colors.WHITE,
|
||||
tooltip="Загрузить новые посты со всех активных групп",
|
||||
on_click=lambda e: self.page.run_task(self._import_recent),
|
||||
),
|
||||
ft.ElevatedButton(
|
||||
"Импорт за год (все)",
|
||||
icon=ft.icons.HISTORY,
|
||||
bgcolor=d.WARNING,
|
||||
color=ft.colors.WHITE,
|
||||
tooltip="Пагинированный импорт за 365 дней. Запускается в фоне.",
|
||||
on_click=lambda e: self.page.run_task(self._import_history_all),
|
||||
),
|
||||
], spacing=10, wrap=True)
|
||||
|
||||
content = ft.Column([
|
||||
d.headline("ВКонтакте — источники", size=22),
|
||||
token_info,
|
||||
import_btns,
|
||||
ft.Divider(height=4, color="transparent"),
|
||||
form,
|
||||
ft.Divider(height=4, color="transparent"),
|
||||
d.headline("Группы в базе", size=15),
|
||||
ft.Container(
|
||||
content=self._table,
|
||||
border_radius=8,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
),
|
||||
], scroll=ft.ScrollMode.AUTO, spacing=12, expand=True)
|
||||
|
||||
return ft.View(
|
||||
route="/admin/vk",
|
||||
bgcolor=d.BACKGROUND,
|
||||
appbar=_admin_appbar(self.page, "ВКонтакте"),
|
||||
controls=[
|
||||
ft.Row([
|
||||
_sidebar(self.page, active="/admin/vk"),
|
||||
ft.Container(content=content, expand=True, padding=24),
|
||||
], expand=True, spacing=0),
|
||||
],
|
||||
)
|
||||
|
||||
# ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async def load(self):
|
||||
self.page.overlay.append(self._snack)
|
||||
token = self.page.session.get("token")
|
||||
status = await api.admin_vk_status(token)
|
||||
self._api_ok = status.get("configured", False)
|
||||
self._status_row.controls = [
|
||||
ft.Icon(
|
||||
ft.icons.CHECK_CIRCLE if self._api_ok else ft.icons.WARNING_AMBER_ROUNDED,
|
||||
color=d.SUCCESS if self._api_ok else d.WARNING,
|
||||
size=18,
|
||||
),
|
||||
ft.Text(
|
||||
"API-токен подключён — VK API активен" if self._api_ok
|
||||
else "Токен не задан — парсинг недоступен",
|
||||
size=13,
|
||||
weight=ft.FontWeight.W_600,
|
||||
color=d.SUCCESS if self._api_ok else d.WARNING,
|
||||
),
|
||||
]
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
# ── Table ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _render_table(self):
|
||||
if not self._sources:
|
||||
self._table.rows = [
|
||||
ft.DataRow(cells=[
|
||||
ft.DataCell(ft.Text("Групп пока нет", color=d.TEXT_SECONDARY, italic=True)),
|
||||
ft.DataCell(ft.Text("")),
|
||||
ft.DataCell(ft.Text("")),
|
||||
ft.DataCell(ft.Text("")),
|
||||
ft.DataCell(ft.Text("")),
|
||||
])
|
||||
]
|
||||
return
|
||||
|
||||
self._table.rows = [self._build_row(s) for s in self._sources]
|
||||
|
||||
def _build_row(self, src: dict) -> ft.DataRow:
|
||||
last_run = src.get("last_run")
|
||||
if last_run:
|
||||
try:
|
||||
from datetime import datetime
|
||||
dt = datetime.fromisoformat(last_run.replace("Z", "+00:00"))
|
||||
last_run_str = dt.strftime("%d.%m.%Y %H:%M")
|
||||
except Exception:
|
||||
last_run_str = last_run[:16]
|
||||
else:
|
||||
last_run_str = "никогда"
|
||||
|
||||
enabled = src.get("enabled", True)
|
||||
|
||||
return ft.DataRow(
|
||||
cells=[
|
||||
ft.DataCell(ft.Text(src["group_name"], size=13, weight=ft.FontWeight.W_500)),
|
||||
ft.DataCell(ft.Text(src["group_id"], size=13, color=d.TEXT_SECONDARY, selectable=True)),
|
||||
ft.DataCell(ft.Text(last_run_str, size=12, color=d.TEXT_SECONDARY)),
|
||||
ft.DataCell(
|
||||
ft.Container(
|
||||
content=ft.Text(
|
||||
"Активна" if enabled else "Отключена",
|
||||
size=11,
|
||||
color=ft.colors.WHITE,
|
||||
),
|
||||
bgcolor=d.SUCCESS if enabled else d.TEXT_SECONDARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
)
|
||||
),
|
||||
ft.DataCell(
|
||||
ft.Row([
|
||||
ft.IconButton(
|
||||
ft.icons.EDIT_OUTLINED,
|
||||
icon_size=18,
|
||||
icon_color=d.PRIMARY,
|
||||
tooltip="Редактировать",
|
||||
on_click=lambda e, s=src: self._start_edit(s),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.TOGGLE_ON if enabled else ft.icons.TOGGLE_OFF,
|
||||
icon_size=22,
|
||||
icon_color=d.SUCCESS if enabled else d.TEXT_SECONDARY,
|
||||
tooltip="Вкл / Выкл",
|
||||
on_click=lambda e, s=src: self.page.run_task(self._toggle, s),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.HISTORY,
|
||||
icon_size=18,
|
||||
icon_color=d.WARNING,
|
||||
tooltip="Импорт за год для этой группы",
|
||||
on_click=lambda e, s=src: self.page.run_task(
|
||||
self._import_history_one, s["id"], s["group_name"]
|
||||
),
|
||||
),
|
||||
ft.IconButton(
|
||||
ft.icons.DELETE_OUTLINE,
|
||||
icon_size=18,
|
||||
icon_color=d.ERROR,
|
||||
tooltip="Удалить группу",
|
||||
on_click=lambda e, s=src: self.page.run_task(self._delete, s["id"]),
|
||||
),
|
||||
], spacing=0),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
# ── Edit form helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _start_edit(self, src: dict):
|
||||
self._editing_id = src["id"]
|
||||
self._form_gid.value = src["group_id"]
|
||||
self._form_gname.value = src["group_name"]
|
||||
self._form_enabled.value = src.get("enabled", True)
|
||||
self._form_save_btn.text = "Сохранить"
|
||||
self._form_save_btn.icon = ft.icons.SAVE
|
||||
self._form_cancel_btn.visible = True
|
||||
self.page.update()
|
||||
|
||||
def _cancel_edit(self):
|
||||
self._editing_id = None
|
||||
self._form_gid.value = ""
|
||||
self._form_gname.value = ""
|
||||
self._form_enabled.value = True
|
||||
self._form_save_btn.text = "Добавить"
|
||||
self._form_save_btn.icon = ft.icons.ADD
|
||||
self._form_cancel_btn.visible = False
|
||||
self.page.update()
|
||||
|
||||
# ── CRUD actions ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _save_source(self):
|
||||
gid = self._form_gid.value.strip()
|
||||
gname = self._form_gname.value.strip()
|
||||
if not gid or not gname:
|
||||
self._notify("Заполни ID и название", d.ERROR)
|
||||
return
|
||||
|
||||
token = self.page.session.get("token")
|
||||
|
||||
if self._editing_id:
|
||||
result = await api.admin_vk_update_source(
|
||||
token, self._editing_id, gid, gname, self._form_enabled.value
|
||||
)
|
||||
self._notify("Группа обновлена" if result else "Ошибка обновления",
|
||||
d.SUCCESS if result else d.ERROR)
|
||||
else:
|
||||
result = await api.admin_vk_add_source(token, gid, gname, self._form_enabled.value)
|
||||
if result and "error" not in result:
|
||||
self._notify(f"Группа «{gname}» добавлена", d.SUCCESS)
|
||||
else:
|
||||
err = result.get("error") if result else "Ошибка запроса"
|
||||
self._notify(str(err), d.ERROR)
|
||||
return
|
||||
|
||||
self._cancel_edit()
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
async def _toggle(self, src: dict):
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_update_source(
|
||||
token, src["id"], src["group_id"], src["group_name"], not src["enabled"]
|
||||
)
|
||||
if result:
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
async def _delete(self, source_id: str):
|
||||
token = self.page.session.get("token")
|
||||
ok = await api.admin_vk_delete_source(token, source_id)
|
||||
self._notify("Группа удалена" if ok else "Ошибка удаления",
|
||||
d.TEXT_SECONDARY if ok else d.ERROR)
|
||||
self._sources = await api.admin_vk_list_sources(token)
|
||||
self._render_table()
|
||||
self.page.update()
|
||||
|
||||
async def _import_recent(self):
|
||||
self._notify("Запускаю импорт новых постов…", d.PRIMARY)
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_import(token)
|
||||
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||
|
||||
async def _import_history_all(self):
|
||||
self._notify("Запускаю исторический импорт в фоне…", d.WARNING)
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_import_history(token)
|
||||
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||
|
||||
async def _import_history_one(self, source_id: str, name: str):
|
||||
self._notify(f"Запускаю год для «{name}»…", d.WARNING)
|
||||
token = self.page.session.get("token")
|
||||
result = await api.admin_vk_import_history(token, source_id=source_id)
|
||||
msg = result.get("message", "Запущен") if result else "Ошибка"
|
||||
self._notify(msg, d.SUCCESS if result and result.get("ok") else d.ERROR)
|
||||
|
||||
def _notify(self, text: str, color: str = d.SUCCESS):
|
||||
self._snack.content = ft.Text(text, color=ft.colors.WHITE)
|
||||
self._snack.bgcolor = color
|
||||
self._snack.open = True
|
||||
self.page.update()
|
||||
Reference in New Issue
Block a user