test ract
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
DATABASE_URL=postgresql+asyncpg://user:password@host:5432/news
|
||||||
|
REDIS_URL=redis://host:6379/0
|
||||||
|
JWT_SECRET=your-very-secret-key-change-me
|
||||||
|
|
||||||
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
|
MINIO_SECRET_KEY=minioadmin
|
||||||
|
MINIO_BUCKET=news-media
|
||||||
|
MINIO_PUBLIC_URL=http://localhost:9000
|
||||||
|
|
||||||
|
DOCS_USERNAME=admin
|
||||||
|
DOCS_PASSWORD=admin
|
||||||
|
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -1,4 +1,33 @@
|
|||||||
.claude/
|
# ── Secrets ────────────────────────────────────────────────────────────────
|
||||||
data/
|
|
||||||
.env
|
.env
|
||||||
.env.example
|
# .env.example is intentionally committed as a setup template
|
||||||
|
|
||||||
|
# ── Local data volumes (postgres / redis / minio) ─────────────────────────
|
||||||
|
data/
|
||||||
|
|
||||||
|
# ── Python ─────────────────────────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
.venv
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
uv.lock
|
||||||
|
|
||||||
|
# ── Node / React editor UI ─────────────────────────────────────────────────
|
||||||
|
web/editor-ui/node_modules/
|
||||||
|
web/editor-ui/dist/
|
||||||
|
web/editor-ui/.vite/
|
||||||
|
|
||||||
|
# ── IDE / OS ───────────────────────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# ── Claude Code internal ───────────────────────────────────────────────────
|
||||||
|
.claude/
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -70,6 +70,11 @@ async def openapi(username: str = Depends(_verify_docs)):
|
|||||||
return JSONResponse(app.openapi())
|
return JSONResponse(app.openapi())
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", include_in_schema=False)
|
||||||
|
async def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
app.include_router(public_router, prefix="/news", tags=["public"])
|
app.include_router(public_router, prefix="/news", tags=["public"])
|
||||||
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
|
app.include_router(auth_router, prefix="/admin", tags=["admin-auth"])
|
||||||
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])
|
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
version: "3.9"
|
version: "3.9"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
# ── FastAPI backend ────────────────────────────────────────────────────────
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
@@ -22,9 +24,16 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8000/health | grep ok || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 15s
|
||||||
networks:
|
networks:
|
||||||
- news_net
|
- news_net
|
||||||
|
|
||||||
|
# ── Flet SPA + React editor (multi-stage build) ────────────────────────────
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./web
|
context: ./web
|
||||||
@@ -35,10 +44,18 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
API_URL: http://api:8000
|
API_URL: http://api:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:8080/ > /dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
networks:
|
networks:
|
||||||
- news_net
|
- news_net
|
||||||
|
|
||||||
|
# ── MinIO object storage ───────────────────────────────────────────────────
|
||||||
minio:
|
minio:
|
||||||
image: minio/minio:latest
|
image: minio/minio:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
|
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
6
main.py
Normal file
6
main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from news-flet!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "news-flet"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.136.1",
|
||||||
|
"flet>=0.85.1",
|
||||||
|
"httpx>=0.28.1",
|
||||||
|
]
|
||||||
15
web/.dockerignore
Normal file
15
web/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Node — не нужен в Python-слое, dist строится в Node-слое
|
||||||
|
editor-ui/node_modules/
|
||||||
|
editor-ui/.vite/
|
||||||
|
# dist намеренно НЕ исключаем — COPY --from=editor-build записывает его сам
|
||||||
|
|
||||||
|
# Python кэш
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Временные файлы загрузок Flet
|
||||||
|
/tmp/
|
||||||
|
|
||||||
|
# Секреты и среда
|
||||||
|
.env
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
|
# Stage 1: build React editor UI
|
||||||
|
FROM node:20-alpine AS editor-build
|
||||||
|
WORKDIR /build
|
||||||
|
COPY editor-ui/package*.json ./
|
||||||
|
RUN npm ci --prefer-offline
|
||||||
|
COPY editor-ui/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Python app
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY . .
|
COPY . .
|
||||||
|
COPY --from=editor-build /build/dist ./editor-ui/dist
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
|||||||
15
web/editor-ui/index.html
Normal file
15
web/editor-ui/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Редактор статьи</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Arimo:ital,wght@0,400;0,700;1,400;1,700&family=Caladea:ital,wght@0,400;0,700;1,400;1,700&family=Cormorant+Garamond:ital,wght@0,400;0,700;1,400;1,700&family=Cousine:ital,wght@0,400;0,700;1,400;1,700&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&family=EB+Garamond:ital,wght@0,400;0,700;1,400;1,700&family=Fira+Code:wght@400;700&family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Josefin+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Libre+Franklin:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400;0,700;1,400;1,700&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&family=Nunito:ital,wght@0,400;0,700;1,400;1,700&family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Oswald:wght@400;500;700&family=Outfit:wght@400;700&family=Patrick+Hand&family=Playfair+Display:ital,wght@0,400;0,700;1,400;1,700&family=Poppins:ital,wght@0,400;0,700;1,400;1,700&family=Roboto+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Roboto:ital,wght@0,400;0,700;1,400;1,700&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&family=Tinos:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3698
web/editor-ui/package-lock.json
generated
Normal file
3698
web/editor-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
web/editor-ui/package.json
Normal file
41
web/editor-ui/package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "news-editor-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tiptap/extension-character-count": "^2.10.4",
|
||||||
|
"@tiptap/extension-color": "^2.10.4",
|
||||||
|
"@tiptap/extension-font-family": "^2.10.4",
|
||||||
|
"@tiptap/extension-highlight": "^2.10.4",
|
||||||
|
"@tiptap/extension-image": "^2.10.4",
|
||||||
|
"@tiptap/extension-link": "^2.10.4",
|
||||||
|
"@tiptap/extension-placeholder": "^2.10.4",
|
||||||
|
"@tiptap/extension-table": "^2.10.4",
|
||||||
|
"@tiptap/extension-table-cell": "^2.10.4",
|
||||||
|
"@tiptap/extension-table-header": "^2.10.4",
|
||||||
|
"@tiptap/extension-table-row": "^2.10.4",
|
||||||
|
"@tiptap/extension-text-align": "^2.10.4",
|
||||||
|
"@tiptap/extension-text-style": "^2.10.4",
|
||||||
|
"@tiptap/extension-underline": "^2.10.4",
|
||||||
|
"@tiptap/pm": "^2.10.4",
|
||||||
|
"@tiptap/react": "^2.10.4",
|
||||||
|
"@tiptap/starter-kit": "^2.10.4",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"marked": "^15.0.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.5.2",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
web/editor-ui/postcss.config.js
Normal file
6
web/editor-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
260
web/editor-ui/src/App.jsx
Normal file
260
web/editor-ui/src/App.jsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { useEditor, EditorContent } from '@tiptap/react'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import Underline from '@tiptap/extension-underline'
|
||||||
|
import TextStyle from '@tiptap/extension-text-style'
|
||||||
|
import FontFamily from '@tiptap/extension-font-family'
|
||||||
|
import { Color } from '@tiptap/extension-color'
|
||||||
|
import Highlight from '@tiptap/extension-highlight'
|
||||||
|
import TextAlign from '@tiptap/extension-text-align'
|
||||||
|
import Image from '@tiptap/extension-image'
|
||||||
|
import Link from '@tiptap/extension-link'
|
||||||
|
import Table from '@tiptap/extension-table'
|
||||||
|
import TableRow from '@tiptap/extension-table-row'
|
||||||
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
|
import TableHeader from '@tiptap/extension-table-header'
|
||||||
|
import CharacterCount from '@tiptap/extension-character-count'
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
|
||||||
|
import { FontSize } from './extensions/FontSize'
|
||||||
|
import { Video } from './extensions/Video'
|
||||||
|
import { Header } from './components/Header'
|
||||||
|
import { Toolbar } from './components/Toolbar'
|
||||||
|
import { Sidebar } from './components/Sidebar'
|
||||||
|
import { MediaModal } from './components/MediaModal'
|
||||||
|
import { api } from './api'
|
||||||
|
|
||||||
|
/* ── Route params ── */
|
||||||
|
const _parts = window.location.pathname.split('/')
|
||||||
|
const ARTICLE_ID = _parts[_parts.length - 1]
|
||||||
|
const TOKEN = new URLSearchParams(window.location.search).get('t') || ''
|
||||||
|
const IS_NEW = ARTICLE_ID === 'new'
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
'Arimo', 'Caladea', 'Cormorant Garamond', 'Cousine', 'Crimson Text',
|
||||||
|
'EB Garamond', 'Fira Code', 'Inter', 'JetBrains Mono', 'Josefin Sans',
|
||||||
|
'Lato', 'Libre Baskerville', 'Libre Franklin', 'Lora', 'Merriweather',
|
||||||
|
'Nunito', 'Open Sans', 'Oswald', 'Outfit', 'Patrick Hand',
|
||||||
|
'Playfair Display', 'Poppins', 'Roboto Mono', 'Roboto', 'Source Code Pro',
|
||||||
|
'Tinos',
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_META = {
|
||||||
|
id: null,
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
excerpt: '',
|
||||||
|
cover_url: null,
|
||||||
|
font_family: 'Merriweather',
|
||||||
|
category_id: null,
|
||||||
|
tag_names: [],
|
||||||
|
status: 'draft',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [meta, setMeta] = useState(DEFAULT_META)
|
||||||
|
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
|
||||||
|
|
||||||
|
/* ── Editor ── */
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
TextStyle,
|
||||||
|
FontFamily.configure({ types: ['textStyle'] }),
|
||||||
|
FontSize,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({ multicolor: true }),
|
||||||
|
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
|
||||||
|
Image.configure({ allowBase64: false }),
|
||||||
|
Video,
|
||||||
|
Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }),
|
||||||
|
Table.configure({ resizable: true }),
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableHeader,
|
||||||
|
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
|
||||||
|
CharacterCount,
|
||||||
|
],
|
||||||
|
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
|
||||||
|
const m = metaRef.current
|
||||||
|
setSaveStatus('saving')
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let saved
|
||||||
|
if (!currentIdRef.current) {
|
||||||
|
saved = await api.createArticle(payload, TOKEN)
|
||||||
|
currentIdRef.current = saved.id
|
||||||
|
setMeta(m => ({ ...m, id: saved.id, slug: saved.slug }))
|
||||||
|
window.history.replaceState(null, '', `/editor/${saved.id}?t=${TOKEN}`)
|
||||||
|
} else {
|
||||||
|
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
|
||||||
|
}
|
||||||
|
setSaveStatus('saved')
|
||||||
|
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 => {
|
||||||
|
try {
|
||||||
|
const res = await api.uploadMedia(file, TOKEN)
|
||||||
|
setMeta(m => ({ ...m, cover_url: res.url }))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render ── */
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
|
||||||
|
|
||||||
|
<Header
|
||||||
|
saveStatus={saveStatus}
|
||||||
|
articleStatus={meta.status}
|
||||||
|
onSave={() => doSave()}
|
||||||
|
onPublish={() => doSave('published')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
|
||||||
|
{/* Editor column */}
|
||||||
|
<div className="flex flex-col flex-1 min-w-0 bg-white">
|
||||||
|
<Toolbar
|
||||||
|
editor={editor}
|
||||||
|
onInsertImage={() => setMediaState({
|
||||||
|
type: 'image',
|
||||||
|
cb: url => editor.chain().focus().setImage({ src: url }).run(),
|
||||||
|
})}
|
||||||
|
onInsertVideo={() => setMediaState({
|
||||||
|
type: 'video',
|
||||||
|
cb: url => editor.chain().focus().insertVideo({ src: url }).run(),
|
||||||
|
})}
|
||||||
|
fonts={FONTS}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-[720px] mx-auto px-10 py-10">
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<input
|
||||||
|
className="w-full text-[2.2rem] font-bold text-slate-900 border-none outline-none bg-transparent placeholder-slate-300 mb-8 leading-tight"
|
||||||
|
style={{ fontFamily: meta.font_family }}
|
||||||
|
placeholder="Заголовок статьи"
|
||||||
|
value={meta.title}
|
||||||
|
onChange={e => setMeta(m => ({ ...m, title: e.target.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TipTap content */}
|
||||||
|
<div style={{ fontFamily: meta.font_family }}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<Sidebar
|
||||||
|
meta={meta}
|
||||||
|
categories={categories}
|
||||||
|
onChange={updates => setMeta(m => ({ ...m, ...updates }))}
|
||||||
|
onUploadCover={handleUploadCover}
|
||||||
|
wordCount={editor?.storage.characterCount?.words() ?? 0}
|
||||||
|
charCount={editor?.storage.characterCount?.characters() ?? 0}
|
||||||
|
fonts={FONTS}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Media picker modal */}
|
||||||
|
{mediaState && (
|
||||||
|
<MediaModal
|
||||||
|
type={mediaState.type}
|
||||||
|
token={TOKEN}
|
||||||
|
onSelect={url => { mediaState.cb(url); setMediaState(null) }}
|
||||||
|
onClose={() => setMediaState(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
46
web/editor-ui/src/api.js
Normal file
46
web/editor-ui/src/api.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
async function req(method, path, token, body) {
|
||||||
|
const res = await fetch(`${BASE}/${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
throw new Error(text || res.statusText)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
getArticle: (id, token) =>
|
||||||
|
req('GET', `admin/articles/${id}`, token),
|
||||||
|
|
||||||
|
createArticle: (data, token) =>
|
||||||
|
req('POST', 'admin/articles', token, data),
|
||||||
|
|
||||||
|
updateArticle: (id, data, token) =>
|
||||||
|
req('PUT', `admin/articles/${id}`, token, data),
|
||||||
|
|
||||||
|
getCategories: token =>
|
||||||
|
req('GET', 'admin/categories', token),
|
||||||
|
|
||||||
|
listMedia: (token, type) =>
|
||||||
|
req('GET', `admin/media${type ? `?media_type=${type}` : ''}`, token),
|
||||||
|
|
||||||
|
uploadMedia: async (file, token) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
const res = await fetch(`${BASE}/admin/media/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await res.text())
|
||||||
|
return res.json()
|
||||||
|
},
|
||||||
|
}
|
||||||
53
web/editor-ui/src/components/Header.jsx
Normal file
53
web/editor-ui/src/components/Header.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { ArrowLeft, Check, Loader2, AlertCircle, Clock } from 'lucide-react'
|
||||||
|
|
||||||
|
const SAVE_STATES = {
|
||||||
|
saving: { icon: Loader2, text: 'Сохранение…', color: 'text-slate-400', spin: true },
|
||||||
|
saved: { icon: Check, text: 'Сохранено', color: 'text-emerald-400', spin: false },
|
||||||
|
unsaved: { icon: Clock, text: 'Есть изменения', color: 'text-amber-400', spin: false },
|
||||||
|
error: { icon: AlertCircle, text: 'Ошибка сохр.', color: 'text-red-400', spin: false },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ saveStatus, articleStatus, onSave, onPublish }) {
|
||||||
|
const s = SAVE_STATES[saveStatus] ?? SAVE_STATES.saved
|
||||||
|
const Icon = s.icon
|
||||||
|
const published = articleStatus === 'published'
|
||||||
|
|
||||||
|
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()"
|
||||||
|
className="flex items-center gap-1.5 text-slate-400 hover:text-white transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
<span>Назад</span>
|
||||||
|
</a>
|
||||||
|
<div className="w-px h-5 bg-slate-700" />
|
||||||
|
<span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`flex items-center gap-1.5 text-xs font-medium ${s.color}`}>
|
||||||
|
<Icon size={13} className={s.spin ? 'animate-spin' : ''} />
|
||||||
|
<span>{s.text}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
className="px-3 py-1.5 text-sm text-slate-300 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onPublish}
|
||||||
|
className={`px-4 py-1.5 text-sm font-semibold rounded-lg transition-all shadow-sm
|
||||||
|
${published
|
||||||
|
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||||
|
: 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}
|
||||||
|
>
|
||||||
|
{published ? 'Обновить' : 'Опубликовать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
web/editor-ui/src/components/MediaModal.jsx
Normal file
131
web/editor-ui/src/components/MediaModal.jsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Upload, X, ImageIcon, Video, Loader2, Check } from 'lucide-react'
|
||||||
|
import { api } from '../api'
|
||||||
|
|
||||||
|
export function MediaModal({ type, token, onSelect, onClose }) {
|
||||||
|
const [items, setItems] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listMedia(token, type)
|
||||||
|
.then(data => { setItems(data); setLoading(false) })
|
||||||
|
.catch(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleUpload = async file => {
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.uploadMedia(file, token)
|
||||||
|
setItems(prev => [res, ...prev])
|
||||||
|
} catch {}
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accept = type === 'image' ? 'image/*' : 'video/*'
|
||||||
|
const isEmpty = !loading && items.length === 0
|
||||||
|
const isImage = type === 'image'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900 text-base">Медиатека</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
{isImage ? 'Изображения' : 'Видео'} · кликните для вставки
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading
|
||||||
|
? <Loader2 size={14} className="animate-spin" />
|
||||||
|
: <Upload size={14} />}
|
||||||
|
Загрузить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 hover:bg-slate-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} className="text-slate-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-48">
|
||||||
|
<Loader2 size={28} className="animate-spin text-slate-300" />
|
||||||
|
</div>
|
||||||
|
) : isEmpty ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 text-slate-300 gap-3">
|
||||||
|
{isImage ? <ImageIcon size={36} /> : <Video size={36} />}
|
||||||
|
<p className="text-sm">Файлы не найдены</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
|
||||||
|
>
|
||||||
|
Загрузить первый файл
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`grid gap-2.5 ${isImage ? 'grid-cols-4 sm:grid-cols-5' : 'grid-cols-2 sm:grid-cols-3'}`}>
|
||||||
|
{items.map(item => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(item.url)}
|
||||||
|
className="group relative rounded-xl overflow-hidden border-2 border-transparent hover:border-indigo-500 transition-all bg-slate-100 aspect-square"
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt={item.filename}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<video src={item.url} className="w-full h-full object-cover" />
|
||||||
|
)}
|
||||||
|
{/* Hover overlay */}
|
||||||
|
<div className="absolute inset-0 bg-indigo-600/0 group-hover:bg-indigo-600/25 transition-colors flex items-center justify-center">
|
||||||
|
<Check
|
||||||
|
size={22}
|
||||||
|
className="text-white opacity-0 group-hover:opacity-100 drop-shadow-lg transition-opacity"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* Filename tooltip */}
|
||||||
|
<div className="absolute bottom-0 inset-x-0 px-2 py-1.5 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<p className="text-white text-[10px] truncate leading-none">{item.filename}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => { if (e.target.files[0]) handleUpload(e.target.files[0]) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
156
web/editor-ui/src/components/Sidebar.jsx
Normal file
156
web/editor-ui/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
|
import { X, ImageIcon, ChevronDown, BarChart2 } from 'lucide-react'
|
||||||
|
import { TagInput } from './TagInput'
|
||||||
|
|
||||||
|
export function Sidebar({ meta, categories, onChange, onUploadCover, wordCount, charCount, fonts }) {
|
||||||
|
const coverRef = useRef(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-[280px] flex-shrink-0 bg-slate-900 border-l border-slate-800 flex flex-col overflow-y-auto">
|
||||||
|
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="flex items-center gap-4 px-4 py-3 border-b border-slate-800">
|
||||||
|
<BarChart2 size={14} className="text-slate-500 flex-shrink-0" />
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-white">{wordCount}</span>
|
||||||
|
<span className="text-slate-400 ml-1">слов</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-white">{charCount}</span>
|
||||||
|
<span className="text-slate-400 ml-1">симв.</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="px-4 pt-4 pb-1">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium
|
||||||
|
${meta.status === 'published'
|
||||||
|
? 'bg-emerald-900/60 text-emerald-300 border border-emerald-800'
|
||||||
|
: 'bg-amber-900/40 text-amber-300 border border-amber-800/60'}`}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${meta.status === 'published' ? 'bg-emerald-400' : 'bg-amber-400'}`} />
|
||||||
|
{meta.status === 'published' ? 'Опубликовано' : 'Черновик'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 space-y-5">
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div>
|
||||||
|
<Label>URL (slug)</Label>
|
||||||
|
<input
|
||||||
|
className="input-dark font-mono text-xs"
|
||||||
|
placeholder="url-stati"
|
||||||
|
value={meta.slug}
|
||||||
|
onChange={e => onChange({ slug: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Excerpt */}
|
||||||
|
<div>
|
||||||
|
<Label>Анонс</Label>
|
||||||
|
<textarea
|
||||||
|
className="input-dark resize-none text-sm"
|
||||||
|
placeholder="Краткое описание статьи…"
|
||||||
|
rows={3}
|
||||||
|
value={meta.excerpt}
|
||||||
|
onChange={e => onChange({ excerpt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<Label>Категория</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="input-dark appearance-none cursor-pointer pr-8 text-sm"
|
||||||
|
value={meta.category_id || ''}
|
||||||
|
onChange={e => onChange({ category_id: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Без категории</option>
|
||||||
|
{categories.map(c => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={13} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<Label>Теги</Label>
|
||||||
|
<TagInput tags={meta.tag_names} onChange={tags => onChange({ tag_names: tags })} />
|
||||||
|
<p className="text-[11px] text-slate-500 mt-1.5">Enter или запятая для добавления</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font */}
|
||||||
|
<div>
|
||||||
|
<Label>Шрифт статьи</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
className="input-dark appearance-none cursor-pointer pr-8 text-sm"
|
||||||
|
style={{ fontFamily: meta.font_family }}
|
||||||
|
value={meta.font_family}
|
||||||
|
onChange={e => onChange({ font_family: e.target.value })}
|
||||||
|
>
|
||||||
|
{fonts.map(f => (
|
||||||
|
<option key={f} value={f} style={{ fontFamily: f }}>{f}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ChevronDown size={13} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover image */}
|
||||||
|
<div>
|
||||||
|
<Label>Обложка</Label>
|
||||||
|
{meta.cover_url ? (
|
||||||
|
<div className="relative rounded-xl overflow-hidden ring-1 ring-slate-700">
|
||||||
|
<img
|
||||||
|
src={meta.cover_url}
|
||||||
|
alt="cover"
|
||||||
|
className="w-full h-36 object-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange({ cover_url: null })}
|
||||||
|
className="absolute top-2 right-2 p-1.5 bg-slate-900/80 backdrop-blur-sm rounded-full hover:bg-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={12} className="text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => coverRef.current?.click()}
|
||||||
|
className="w-full h-28 border-2 border-dashed border-slate-700 rounded-xl flex flex-col items-center justify-center gap-2 hover:border-indigo-500 hover:bg-slate-800/50 transition-all group"
|
||||||
|
>
|
||||||
|
<ImageIcon size={20} className="text-slate-500 group-hover:text-indigo-400 transition-colors" />
|
||||||
|
<span className="text-xs text-slate-500 group-hover:text-slate-300 transition-colors">
|
||||||
|
Загрузить обложку
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={coverRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => e.target.files[0] && onUploadCover(e.target.files[0])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Label({ children }) {
|
||||||
|
return (
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 mb-1.5">
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
56
web/editor-ui/src/components/TagInput.jsx
Normal file
56
web/editor-ui/src/components/TagInput.jsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
export function TagInput({ tags, onChange }) {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
|
||||||
|
const add = val => {
|
||||||
|
const tag = val.trim()
|
||||||
|
if (tag && !tags.includes(tag)) onChange([...tags, tag])
|
||||||
|
setInput('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = tag => onChange(tags.filter(t => t !== tag))
|
||||||
|
|
||||||
|
const onKey = e => {
|
||||||
|
if (e.key === 'Enter' || e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
add(input)
|
||||||
|
} else if (e.key === 'Backspace' && !input && tags.length) {
|
||||||
|
remove(tags[tags.length - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap gap-1.5 min-h-[40px] bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-2 cursor-text focus-within:border-indigo-500 transition-colors"
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
>
|
||||||
|
{tags.map(tag => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="flex items-center gap-1 px-2 py-0.5 bg-indigo-900/70 text-indigo-200 text-xs rounded-full border border-indigo-700/50"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={e => { e.stopPropagation(); remove(tag) }}
|
||||||
|
className="hover:text-white transition-colors leading-none"
|
||||||
|
>
|
||||||
|
<X size={9} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="flex-1 min-w-[80px] bg-transparent text-sm text-slate-200 outline-none placeholder-slate-500"
|
||||||
|
placeholder={tags.length === 0 ? 'Добавить тег…' : ''}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={onKey}
|
||||||
|
onBlur={() => { if (input) add(input) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
331
web/editor-ui/src/components/Toolbar.jsx
Normal file
331
web/editor-ui/src/components/Toolbar.jsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
|
||||||
|
Heading1, Heading2, Heading3, Type,
|
||||||
|
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||||
|
List, ListOrdered, Quote, Code2,
|
||||||
|
Link2, ImageIcon, Video, Table2,
|
||||||
|
Undo2, Redo2, Palette, Highlighter, ChevronDown,
|
||||||
|
Minus,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const FONT_SIZES = ['10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48', '64', '72']
|
||||||
|
|
||||||
|
/* ── Primitive components ── */
|
||||||
|
|
||||||
|
function Btn({ onClick, active, disabled, title, children }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); onClick?.() }}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
className={`
|
||||||
|
relative flex items-center justify-center w-[30px] h-[30px] rounded-md text-[13px] transition-colors flex-shrink-0
|
||||||
|
${active ? 'bg-indigo-100 text-indigo-700' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-800'}
|
||||||
|
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sep() {
|
||||||
|
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Font family dropdown ── */
|
||||||
|
|
||||||
|
function FontDropdown({ editor, fonts }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
|
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}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
{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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
|
||||||
|
onMouseDown={e => { e.preventDefault(); editor.chain().focus().unsetFontFamily().run(); setOpen(false) }}
|
||||||
|
>
|
||||||
|
По умолчанию
|
||||||
|
</button>
|
||||||
|
<div className="my-1 border-t border-slate-100" />
|
||||||
|
{fonts.map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-1.5 text-left text-sm hover:bg-slate-50 transition-colors"
|
||||||
|
style={{ fontFamily: f }}
|
||||||
|
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setFontFamily(f).run(); setOpen(false) }}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Font size dropdown ── */
|
||||||
|
|
||||||
|
function SizeDropdown({ editor }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
|
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}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||||
|
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="Размер шрифта"
|
||||||
|
>
|
||||||
|
<span className="flex-1 text-center">{current}</span>
|
||||||
|
<ChevronDown size={11} className="opacity-60" />
|
||||||
|
</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">
|
||||||
|
{FONT_SIZES.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className="w-full px-3 py-1 text-left text-xs hover:bg-slate-50 transition-colors"
|
||||||
|
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setFontSize(`${s}px`).run(); setOpen(false) }}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 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 items = [
|
||||||
|
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||||
|
null,
|
||||||
|
{ label: 'Добавить строку ниже', fn: () => editor.chain().focus().addRowAfter().run() },
|
||||||
|
{ label: 'Добавить строку выше', fn: () => editor.chain().focus().addRowBefore().run() },
|
||||||
|
{ label: 'Удалить строку', fn: () => editor.chain().focus().deleteRow().run(), danger: true },
|
||||||
|
null,
|
||||||
|
{ label: 'Добавить столбец справа', fn: () => editor.chain().focus().addColumnAfter().run() },
|
||||||
|
{ label: 'Добавить столбец слева', fn: () => editor.chain().focus().addColumnBefore().run() },
|
||||||
|
{ label: 'Удалить столбец', fn: () => editor.chain().focus().deleteColumn().run(), danger: true },
|
||||||
|
null,
|
||||||
|
{ label: 'Удалить таблицу', fn: () => editor.chain().focus().deleteTable().run(), danger: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-shrink-0" ref={ref}>
|
||||||
|
<Btn
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
active={editor.isActive('table')}
|
||||||
|
title="Таблица"
|
||||||
|
>
|
||||||
|
<Table2 size={14} />
|
||||||
|
</Btn>
|
||||||
|
|
||||||
|
{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">
|
||||||
|
{items.map((item, i) =>
|
||||||
|
item === null ? (
|
||||||
|
<div key={i} className="my-1 border-t border-slate-100" />
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
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'}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Toolbar ── */
|
||||||
|
|
||||||
|
export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
const setLink = () => {
|
||||||
|
const prev = editor.getAttributes('link').href || ''
|
||||||
|
const url = window.prompt('URL ссылки:', prev)
|
||||||
|
if (url === null) return
|
||||||
|
if (!url) editor.chain().focus().unsetLink().run()
|
||||||
|
else editor.chain().focus().setLink({ href: url }).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
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]">
|
||||||
|
{/* History */}
|
||||||
|
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
|
||||||
|
<Undo2 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Повторить (Ctrl+Y)">
|
||||||
|
<Redo2 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Text format */}
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Жирный (Ctrl+B)">
|
||||||
|
<Bold size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Курсив (Ctrl+I)">
|
||||||
|
<Italic size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Подчёркивание (Ctrl+U)">
|
||||||
|
<UnderlineIcon size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Зачёркивание">
|
||||||
|
<Strikethrough size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Headings */}
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Заголовок 1">
|
||||||
|
<Heading1 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Заголовок 2">
|
||||||
|
<Heading2 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Заголовок 3">
|
||||||
|
<Heading3 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().setParagraph().run()} active={editor.isActive('paragraph') && !editor.isActive('heading')} title="Обычный текст">
|
||||||
|
<Type size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Alignment */}
|
||||||
|
<Btn onClick={() => editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} title="По левому краю">
|
||||||
|
<AlignLeft size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} title="По центру">
|
||||||
|
<AlignCenter size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} title="По правому краю">
|
||||||
|
<AlignRight size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} title="По ширине">
|
||||||
|
<AlignJustify size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Lists + blocks */}
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Маркированный список">
|
||||||
|
<List size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Нумерованный список">
|
||||||
|
<ListOrdered size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Цитата">
|
||||||
|
<Quote size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Блок кода">
|
||||||
|
<Code2 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Разделитель">
|
||||||
|
<Minus size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Insert media */}
|
||||||
|
<Btn onClick={setLink} active={editor.isActive('link')} title="Ссылка">
|
||||||
|
<Link2 size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={onInsertImage} title="Вставить изображение">
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
</Btn>
|
||||||
|
<Btn onClick={onInsertVideo} title="Вставить видео">
|
||||||
|
<Video size={14} />
|
||||||
|
</Btn>
|
||||||
|
<TableMenu editor={editor} />
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Typography */}
|
||||||
|
<FontDropdown editor={editor} fonts={fonts} />
|
||||||
|
<SizeDropdown editor={editor} />
|
||||||
|
|
||||||
|
{/* Text color */}
|
||||||
|
<label
|
||||||
|
className="relative flex items-center justify-center w-[30px] h-[30px] rounded-md cursor-pointer hover:bg-slate-100 transition-colors flex-shrink-0"
|
||||||
|
title="Цвет текста"
|
||||||
|
>
|
||||||
|
<Palette size={14} className="text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="absolute opacity-0 w-0 h-0"
|
||||||
|
onInput={e => editor.chain().focus().setColor(e.target.value).run()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Highlight color */}
|
||||||
|
<label
|
||||||
|
className="relative flex items-center justify-center w-[30px] h-[30px] rounded-md cursor-pointer hover:bg-slate-100 transition-colors flex-shrink-0"
|
||||||
|
title="Выделение цветом"
|
||||||
|
>
|
||||||
|
<Highlighter size={14} className="text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="absolute opacity-0 w-0 h-0"
|
||||||
|
defaultValue="#fef08a"
|
||||||
|
onInput={e => editor.chain().focus().setHighlight({ color: e.target.value }).run()}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
web/editor-ui/src/extensions/FontSize.js
Normal file
34
web/editor-ui/src/extensions/FontSize.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Extension } from '@tiptap/core'
|
||||||
|
|
||||||
|
export const FontSize = Extension.create({
|
||||||
|
name: 'fontSize',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return { types: ['textStyle'] }
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
fontSize: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: el => el.style.fontSize || null,
|
||||||
|
renderHTML: ({ fontSize }) => {
|
||||||
|
if (!fontSize) return {}
|
||||||
|
return { style: `font-size: ${fontSize}` }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFontSize: fontSize => ({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize }).run(),
|
||||||
|
unsetFontSize: () => ({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
47
web/editor-ui/src/extensions/Video.jsx
Normal file
47
web/editor-ui/src/extensions/Video.jsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Node } from '@tiptap/core'
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
|
|
||||||
|
function VideoView({ node }) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper className="my-4">
|
||||||
|
<video
|
||||||
|
src={node.attrs.src}
|
||||||
|
controls
|
||||||
|
className="max-w-full rounded-lg mx-auto block shadow-md"
|
||||||
|
style={{ maxHeight: '480px' }}
|
||||||
|
/>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Video = Node.create({
|
||||||
|
name: 'video',
|
||||||
|
group: 'block',
|
||||||
|
atom: true,
|
||||||
|
draggable: true,
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: { default: null },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'video[src]' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['video', { controls: '', ...HTMLAttributes, style: 'max-width:100%;border-radius:8px;' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(VideoView)
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertVideo: attrs => ({ commands }) =>
|
||||||
|
commands.insertContent({ type: this.name, attrs }),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
198
web/editor-ui/src/index.css
Normal file
198
web/editor-ui/src/index.css
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ─── TipTap editor content ─── */
|
||||||
|
.ProseMirror {
|
||||||
|
outline: none;
|
||||||
|
min-height: 480px;
|
||||||
|
color: #1e293b;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror > * + * {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h2 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 0.4em;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror h3 {
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 1em;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul,
|
||||||
|
.ProseMirror ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror li {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror blockquote {
|
||||||
|
border-left: 3px solid #6366f1;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
margin: 1em 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-style: italic;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror code {
|
||||||
|
background: #f1f5f9;
|
||||||
|
padding: 0.15em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror pre {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1.25em 1.5em;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.875em;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1.25em 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror td,
|
||||||
|
.ProseMirror th {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
padding: 0.6em 0.9em;
|
||||||
|
min-width: 80px;
|
||||||
|
vertical-align: top;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror th {
|
||||||
|
background: #f8fafc;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.875em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror .selectedCell::after {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror a {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror a:hover {
|
||||||
|
color: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder */
|
||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
color: #94a3b8;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight */
|
||||||
|
.ProseMirror mark {
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1em 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resizable table handle */
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar inputs */
|
||||||
|
.input-dark {
|
||||||
|
@apply w-full bg-slate-800 text-slate-200 px-3 py-2 rounded-lg border border-slate-700
|
||||||
|
focus:border-indigo-500 focus:outline-none transition-colors placeholder-slate-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
10
web/editor-ui/src/main.jsx
Normal file
10
web/editor-ui/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
12
web/editor-ui/tailwind.config.js
Normal file
12
web/editor-ui/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
7
web/editor-ui/vite.config.js
Normal file
7
web/editor-ui/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: '/editor-assets/',
|
||||||
|
})
|
||||||
50
web/editor_page.py
Normal file
50
web/editor_page.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_API_URL = os.getenv("API_URL", "http://api:8000")
|
||||||
|
_DIST = Path(__file__).parent / "editor-ui" / "dist"
|
||||||
|
|
||||||
|
_SKIP_REQ = {"host", "content-length", "transfer-encoding"}
|
||||||
|
_SKIP_RES = {"content-encoding", "transfer-encoding", "content-length"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── API proxy: browser → Flet server → internal API ──────────────────────────
|
||||||
|
|
||||||
|
@router.api_route("/api/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
async def api_proxy(path: str, request: Request):
|
||||||
|
headers = {k: v for k, v in request.headers.items() if k.lower() not in _SKIP_REQ}
|
||||||
|
body = await request.body()
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
r = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=f"{_API_URL}/{path}",
|
||||||
|
headers=headers,
|
||||||
|
content=body,
|
||||||
|
params=dict(request.query_params),
|
||||||
|
)
|
||||||
|
res_headers = {k: v for k, v in r.headers.items() if k.lower() not in _SKIP_RES}
|
||||||
|
return Response(
|
||||||
|
content=r.content,
|
||||||
|
status_code=r.status_code,
|
||||||
|
headers=res_headers,
|
||||||
|
media_type=r.headers.get("content-type"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── React editor SPA ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/editor/{article_id}")
|
||||||
|
async def editor_page(article_id: str):
|
||||||
|
index = _DIST / "index.html"
|
||||||
|
if not index.exists():
|
||||||
|
return HTMLResponse(
|
||||||
|
"<h2>Editor not built.</h2><p>Run <code>docker-compose up --build</code></p>",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
return HTMLResponse(index.read_text(encoding="utf-8"))
|
||||||
26
web/main.py
26
web/main.py
@@ -1,9 +1,18 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
import flet as ft
|
import flet as ft
|
||||||
import flet.fastapi as flet_fastapi
|
import flet.fastapi as flet_fastapi
|
||||||
|
|
||||||
import designer as d
|
import designer as d
|
||||||
import theme_manager
|
import theme_manager
|
||||||
from router import handle_route
|
from router import handle_route
|
||||||
|
from editor_page import router as editor_router
|
||||||
|
from public_pages import router as public_router
|
||||||
|
|
||||||
|
UPLOAD_DIR = os.getenv("FLET_UPLOAD_DIR", "/tmp/flet_uploads")
|
||||||
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
async def main(page: ft.Page):
|
async def main(page: ft.Page):
|
||||||
@@ -38,4 +47,19 @@ async def main(page: ft.Page):
|
|||||||
await handle_route(page)
|
await handle_route(page)
|
||||||
|
|
||||||
|
|
||||||
app = flet_fastapi.app(main)
|
# Custom routes must be registered on a WRAPPER app BEFORE mounting Flet.
|
||||||
|
# flet_fastapi registers a catch-all handler internally; anything added to
|
||||||
|
# the same app via include_router() ends up AFTER that catch-all and is
|
||||||
|
# never reached. The wrapper ensures our routes are checked first.
|
||||||
|
_flet = flet_fastapi.app(main, upload_dir=UPLOAD_DIR)
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(public_router) # GET /article/{slug}
|
||||||
|
app.include_router(editor_router) # GET /editor/{id} + /api/* proxy
|
||||||
|
|
||||||
|
# Serve React editor static assets (JS/CSS chunks built by Vite)
|
||||||
|
_editor_dist = Path(__file__).parent / "editor-ui" / "dist"
|
||||||
|
if _editor_dist.exists():
|
||||||
|
app.mount("/editor-assets", StaticFiles(directory=str(_editor_dist)), name="editor-assets")
|
||||||
|
|
||||||
|
app.mount("/", _flet) # everything else → Flet SPA
|
||||||
|
|||||||
358
web/public_pages.py
Normal file
358
web/public_pages.py
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
import json
|
||||||
|
import html as _esc
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from editor_page import _API_URL
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── Google Fonts ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_GFONTS = (
|
||||||
|
"https://fonts.googleapis.com/css2?family=Arimo:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Caladea:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Cormorant+Garamond:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Cousine:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700"
|
||||||
|
"&family=EB+Garamond:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Fira+Code:wght@400;700"
|
||||||
|
"&family=Inter:wght@400;500;700"
|
||||||
|
"&family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Josefin+Sans:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700"
|
||||||
|
"&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400"
|
||||||
|
"&family=Libre+Franklin:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Lora:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700"
|
||||||
|
"&family=Nunito:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Oswald:wght@400;500;700"
|
||||||
|
"&family=Outfit:wght@400;700"
|
||||||
|
"&family=Patrick+Hand"
|
||||||
|
"&family=Playfair+Display:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Poppins:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Roboto+Mono:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Roboto:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&family=Tinos:ital,wght@0,400;0,700;1,400;1,700"
|
||||||
|
"&display=swap"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── HTML template ───────────────────────────────────────────────────────────
|
||||||
|
# Placeholders use %%NAME%% — never clashes with CSS/JS { } braces.
|
||||||
|
|
||||||
|
_TMPL = """\
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="%%EXCERPT_TEXT%%">
|
||||||
|
<title>%%TITLE%% — Новости</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="%%GFONTS%%" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
html{scroll-behavior:smooth}
|
||||||
|
body{font-family:Inter,sans-serif;background:#f0f2f5;color:#1a1a1a;min-height:100vh}
|
||||||
|
|
||||||
|
/* top bar */
|
||||||
|
#topbar{
|
||||||
|
position:sticky;top:0;z-index:100;
|
||||||
|
background:#1a237e;padding:10px 20px;
|
||||||
|
display:flex;align-items:center;justify-content:space-between;
|
||||||
|
box-shadow:0 2px 8px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.site-name{
|
||||||
|
color:#fff;font-size:18px;font-weight:700;
|
||||||
|
text-decoration:none;display:flex;align-items:center;gap:8px;
|
||||||
|
}
|
||||||
|
.site-name::before{content:"📰";font-size:20px}
|
||||||
|
.btn-back{
|
||||||
|
color:rgba(255,255,255,.85);font-size:13px;
|
||||||
|
background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.25);
|
||||||
|
border-radius:6px;padding:6px 14px;cursor:pointer;
|
||||||
|
text-decoration:none;transition:background .15s;
|
||||||
|
}
|
||||||
|
.btn-back:hover{background:rgba(255,255,255,.22)}
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
#page{max-width:820px;margin:32px auto 60px;padding:0 16px}
|
||||||
|
|
||||||
|
/* card */
|
||||||
|
#article{
|
||||||
|
background:#fff;border-radius:16px;
|
||||||
|
box-shadow:0 2px 16px rgba(0,0,0,.08);overflow:hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* cover */
|
||||||
|
.cover-wrap{width:100%;max-height:460px;overflow:hidden;background:#e8eaf0}
|
||||||
|
.cover-wrap img{width:100%;height:460px;object-fit:cover;display:block}
|
||||||
|
|
||||||
|
/* header */
|
||||||
|
.art-header{padding:28px 36px 0}
|
||||||
|
.meta-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:16px}
|
||||||
|
.cat-chip{
|
||||||
|
background:#1565c0;color:#fff;font-size:11px;font-weight:700;
|
||||||
|
letter-spacing:.5px;border-radius:4px;padding:3px 10px;text-transform:uppercase;
|
||||||
|
}
|
||||||
|
.meta-sep{color:#ccc;font-size:13px}
|
||||||
|
.meta-text{color:#888;font-size:13px;display:flex;align-items:center;gap:4px}
|
||||||
|
.art-title{font-size:32px;font-weight:800;line-height:1.25;color:#111;margin-bottom:16px}
|
||||||
|
.art-excerpt{
|
||||||
|
font-size:17px;line-height:1.65;color:#555;
|
||||||
|
border-left:4px solid #1565c0;
|
||||||
|
padding:10px 16px;background:#f5f8ff;
|
||||||
|
border-radius:0 8px 8px 0;font-style:italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.art-divider{border:none;border-top:1px solid #eee;margin:28px 36px}
|
||||||
|
|
||||||
|
/* content */
|
||||||
|
#content{padding:0 36px 36px;font-size:17px;line-height:1.75}
|
||||||
|
#content p{margin:14px 0}
|
||||||
|
#content h1{font-size:2em;margin:28px 0 12px;line-height:1.2}
|
||||||
|
#content h2{font-size:1.6em;margin:24px 0 10px;line-height:1.3}
|
||||||
|
#content h3{font-size:1.3em;margin:20px 0 8px;line-height:1.35}
|
||||||
|
#content h4,#content h5,#content h6{font-size:1.1em;margin:16px 0 6px}
|
||||||
|
#content ul,#content ol{padding-left:26px;margin:14px 0}
|
||||||
|
#content li{margin:6px 0;line-height:1.65}
|
||||||
|
#content a{color:#1565c0;text-decoration:underline;text-underline-offset:2px}
|
||||||
|
#content a:hover{color:#0d47a1}
|
||||||
|
#content strong{font-weight:700}
|
||||||
|
#content em{font-style:italic}
|
||||||
|
#content sup{font-size:.75em;vertical-align:super}
|
||||||
|
#content sub{font-size:.75em;vertical-align:sub}
|
||||||
|
#content blockquote{
|
||||||
|
border-left:4px solid #1565c0;background:#f5f8ff;
|
||||||
|
padding:12px 20px;margin:20px 0;border-radius:0 8px 8px 0;
|
||||||
|
color:#444;font-style:italic;
|
||||||
|
}
|
||||||
|
#content blockquote p{margin:6px 0}
|
||||||
|
#content code{
|
||||||
|
background:#f0f0f0;padding:2px 7px;border-radius:4px;
|
||||||
|
font-family:"JetBrains Mono","Fira Code",monospace;
|
||||||
|
font-size:.88em;color:#c7254e;
|
||||||
|
}
|
||||||
|
#content pre{
|
||||||
|
background:#1e1e2e;color:#cdd6f4;
|
||||||
|
padding:20px 24px;border-radius:10px;overflow-x:auto;
|
||||||
|
margin:20px 0;
|
||||||
|
font-family:"JetBrains Mono","Fira Code",monospace;
|
||||||
|
font-size:14px;line-height:1.6;
|
||||||
|
}
|
||||||
|
#content pre code{background:none;padding:0;color:inherit;font-size:inherit;border-radius:0}
|
||||||
|
#content img{
|
||||||
|
max-width:100%;height:auto;border-radius:8px;
|
||||||
|
display:block;margin:20px auto;
|
||||||
|
box-shadow:0 2px 12px rgba(0,0,0,.10);
|
||||||
|
}
|
||||||
|
.img-error{
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
background:#f5f5f5;border:1px dashed #ccc;border-radius:8px;
|
||||||
|
height:120px;color:#999;font-size:14px;margin:20px 0;gap:8px;
|
||||||
|
}
|
||||||
|
#content video{
|
||||||
|
max-width:100%;height:auto;border-radius:8px;
|
||||||
|
display:block;margin:20px auto;
|
||||||
|
box-shadow:0 2px 12px rgba(0,0,0,.10);
|
||||||
|
}
|
||||||
|
.video-wrap{
|
||||||
|
position:relative;padding-bottom:56.25%;
|
||||||
|
height:0;overflow:hidden;border-radius:8px;margin:20px 0;
|
||||||
|
}
|
||||||
|
.video-wrap video{
|
||||||
|
position:absolute;top:0;left:0;
|
||||||
|
width:100%;height:100%;border-radius:8px;margin:0;box-shadow:none;
|
||||||
|
}
|
||||||
|
#content table{
|
||||||
|
width:100%;border-collapse:collapse;
|
||||||
|
margin:20px 0;overflow-x:auto;
|
||||||
|
display:block;font-size:15px;
|
||||||
|
}
|
||||||
|
#content thead{background:#1565c0;color:#fff}
|
||||||
|
#content th{padding:10px 16px;text-align:left;font-weight:600;border:1px solid #1255a8}
|
||||||
|
#content td{padding:9px 16px;border:1px solid #e0e0e0;vertical-align:top}
|
||||||
|
#content tbody tr:nth-child(even){background:#f7f9fc}
|
||||||
|
#content tbody tr:hover{background:#eef2ff}
|
||||||
|
#content hr{border:none;border-top:2px solid #eee;margin:28px 0}
|
||||||
|
|
||||||
|
/* tags */
|
||||||
|
.tags-row{
|
||||||
|
display:flex;flex-wrap:wrap;gap:8px;
|
||||||
|
padding:20px 36px 28px;border-top:1px solid #eee;
|
||||||
|
}
|
||||||
|
.tag-chip{
|
||||||
|
background:#eef2ff;color:#3949ab;font-size:12px;font-weight:600;
|
||||||
|
border-radius:20px;padding:4px 12px;border:1px solid #c5cae9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mobile */
|
||||||
|
@media(max-width:600px){
|
||||||
|
#page{margin:16px auto 40px;padding:0 8px}
|
||||||
|
.art-header{padding:20px 20px 0}
|
||||||
|
.art-divider{margin:20px}
|
||||||
|
#content{padding:0 20px 24px}
|
||||||
|
.art-title{font-size:24px}
|
||||||
|
.cover-wrap img{height:220px}
|
||||||
|
.tags-row{padding:16px 20px 20px}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="topbar">
|
||||||
|
<a class="site-name" href="/">Новости</a>
|
||||||
|
<a class="btn-back" href="javascript:history.back()">← Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="page">
|
||||||
|
<article id="article">
|
||||||
|
|
||||||
|
%%COVER_HTML%%
|
||||||
|
|
||||||
|
<div class="art-header">
|
||||||
|
<div class="meta-row">
|
||||||
|
%%CAT_HTML%%
|
||||||
|
%%META_SEP%%
|
||||||
|
<span class="meta-text">📅 %%PUB_DATE%%</span>
|
||||||
|
<span class="meta-sep">·</span>
|
||||||
|
<span class="meta-text">👁 %%VIEW_COUNT%%</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="art-title">%%TITLE%%</h1>
|
||||||
|
%%EXCERPT_HTML%%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="art-divider">
|
||||||
|
|
||||||
|
<div id="content" style="font-family:%%FONT_FAMILY%%">
|
||||||
|
<!-- rendered by JS below -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
%%TAGS_HTML%%
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var raw = %%CONTENT_JSON%%;
|
||||||
|
var el = document.getElementById('content');
|
||||||
|
|
||||||
|
if (raw.trim().charAt(0) === '<') {
|
||||||
|
el.innerHTML = raw;
|
||||||
|
} else {
|
||||||
|
el.innerHTML = marked.parse(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lazy-load + error fallback for images
|
||||||
|
el.querySelectorAll('img').forEach(function(img){
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.decoding = 'async';
|
||||||
|
img.onerror = function(){
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.className = 'img-error';
|
||||||
|
d.innerHTML = '<span>🖼</span><span>Изображение недоступно</span>';
|
||||||
|
if (this.parentNode) this.parentNode.replaceChild(d, this);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// wrap videos + force controls
|
||||||
|
el.querySelectorAll('video').forEach(function(v){
|
||||||
|
v.controls = true;
|
||||||
|
v.setAttribute('playsinline','');
|
||||||
|
v.preload = 'metadata';
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'video-wrap';
|
||||||
|
v.parentNode.insertBefore(wrap, v);
|
||||||
|
wrap.appendChild(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
// open external links in new tab
|
||||||
|
el.querySelectorAll('a[href]').forEach(function(a){
|
||||||
|
try {
|
||||||
|
var u = new URL(a.href);
|
||||||
|
if (u.origin !== window.location.origin){
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
}
|
||||||
|
} catch(e){}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_article_page(a: dict) -> str:
|
||||||
|
title = _esc.escape(a.get("title") or "Статья")
|
||||||
|
excerpt_text = _esc.escape(a.get("excerpt") or "")
|
||||||
|
font_family = _esc.escape(a.get("font_family") or "Merriweather")
|
||||||
|
pub_date = (a.get("published_at") or "")[:10]
|
||||||
|
view_count = str(a.get("view_count") or 0)
|
||||||
|
|
||||||
|
cover_url = a.get("cover_url") or ""
|
||||||
|
cover_html = (
|
||||||
|
f'<div class="cover-wrap">'
|
||||||
|
f'<img src="{_esc.escape(cover_url)}" alt="{title}" loading="lazy" decoding="async">'
|
||||||
|
f'</div>'
|
||||||
|
) if cover_url else ""
|
||||||
|
|
||||||
|
cat = a.get("category")
|
||||||
|
cat_html = (
|
||||||
|
f'<span class="cat-chip">{_esc.escape(cat["name"])}</span>' if cat else ""
|
||||||
|
)
|
||||||
|
meta_sep = '<span class="meta-sep">·</span>' if cat else ""
|
||||||
|
|
||||||
|
excerpt_html = (
|
||||||
|
f'<p class="art-excerpt">{excerpt_text}</p>' if excerpt_text else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
tags = a.get("tags") or []
|
||||||
|
tags_html = ""
|
||||||
|
if tags:
|
||||||
|
chips = "".join(
|
||||||
|
f'<span class="tag-chip">#{_esc.escape(t["name"])}</span>' for t in tags
|
||||||
|
)
|
||||||
|
tags_html = f'<div class="tags-row">{chips}</div>'
|
||||||
|
|
||||||
|
# Content is safely embedded as a JS string via json.dumps —
|
||||||
|
# no risk of HTML injection or format-string collisions.
|
||||||
|
content_json = json.dumps(a.get("content") or "")
|
||||||
|
|
||||||
|
return (
|
||||||
|
_TMPL
|
||||||
|
.replace("%%GFONTS%%", _GFONTS)
|
||||||
|
.replace("%%TITLE%%", title)
|
||||||
|
.replace("%%EXCERPT_TEXT%%", excerpt_text)
|
||||||
|
.replace("%%FONT_FAMILY%%", font_family)
|
||||||
|
.replace("%%PUB_DATE%%", pub_date)
|
||||||
|
.replace("%%VIEW_COUNT%%", view_count)
|
||||||
|
.replace("%%COVER_HTML%%", cover_html)
|
||||||
|
.replace("%%CAT_HTML%%", cat_html)
|
||||||
|
.replace("%%META_SEP%%", meta_sep)
|
||||||
|
.replace("%%EXCERPT_HTML%%", excerpt_html)
|
||||||
|
.replace("%%TAGS_HTML%%", tags_html)
|
||||||
|
.replace("%%CONTENT_JSON%%", content_json)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/article/{slug}", response_class=HTMLResponse)
|
||||||
|
async def article_page(slug: str):
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
r = await client.get(f"{_API_URL}/news/{slug}")
|
||||||
|
if r.status_code == 404:
|
||||||
|
return HTMLResponse(
|
||||||
|
content='<h1 style="font-family:sans-serif;padding:40px">Статья не найдена</h1>',
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return HTMLResponse(
|
||||||
|
content='<h1 style="font-family:sans-serif;padding:40px">Ошибка сервера</h1>',
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=_build_article_page(r.json()))
|
||||||
@@ -52,6 +52,7 @@ def resolve_view(page: ft.Page, route: str):
|
|||||||
|
|
||||||
async def handle_route(page: ft.Page):
|
async def handle_route(page: ft.Page):
|
||||||
route = page.route or "/"
|
route = page.route or "/"
|
||||||
|
page.window.on_focus = None
|
||||||
|
|
||||||
if _is_admin(route):
|
if _is_admin(route):
|
||||||
token = page.session.get("token")
|
token = page.session.get("token")
|
||||||
|
|||||||
@@ -1,112 +1,11 @@
|
|||||||
|
import os
|
||||||
|
import asyncio
|
||||||
import flet as ft
|
import flet as ft
|
||||||
import designer as d
|
import designer as d
|
||||||
import api_client as api
|
import api_client as api
|
||||||
from views.dashboard import _admin_appbar, _sidebar
|
from views.dashboard import _admin_appbar, _sidebar
|
||||||
|
|
||||||
|
_UPLOAD_DIR = os.getenv("FLET_UPLOAD_DIR", "/tmp/flet_uploads")
|
||||||
def _js_font_span(font_name: str) -> str:
|
|
||||||
fn = font_name.replace("'", "\\'")
|
|
||||||
return f"""
|
|
||||||
(function(){{
|
|
||||||
var sel=window.__editorSel;
|
|
||||||
if(!sel||!sel.el){{var tas=document.querySelectorAll('textarea');if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};else return;}}
|
|
||||||
var ta=sel.el,val=ta.value,st=sel.start,en=sel.end;
|
|
||||||
var selected=val.substring(st,en)||'текст';
|
|
||||||
var before=val.substring(0,st),after=val.substring(en);
|
|
||||||
var ins='<span style="font-family:\\'{fn}\\'">' + selected + '</span>';
|
|
||||||
ta.value=before+ins+after;
|
|
||||||
var newSt=st+ins.length-selected.length-7;
|
|
||||||
ta.selectionStart=newSt; ta.selectionEnd=newSt+selected.length;
|
|
||||||
ta.focus();
|
|
||||||
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
|
||||||
window.__editorSel={{el:ta,start:ta.selectionStart,end:ta.selectionEnd}};
|
|
||||||
return ta.value;
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
# JS: inject once on view mount — tracks last focused textarea + selection
|
|
||||||
_TRACK_JS = """
|
|
||||||
window.__editorSel = {start:0, end:0, el:null};
|
|
||||||
document.addEventListener('mousedown', function(ev){
|
|
||||||
var t = document.activeElement;
|
|
||||||
if(t && t.tagName==='TEXTAREA'){
|
|
||||||
window.__editorSel = {start:t.selectionStart||0, end:t.selectionEnd||0, el:t};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
document.addEventListener('keyup', function(ev){
|
|
||||||
var t = document.activeElement;
|
|
||||||
if(t && t.tagName==='TEXTAREA'){
|
|
||||||
window.__editorSel = {start:t.selectionStart||0, end:t.selectionEnd||0, el:t};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
"""
|
|
||||||
|
|
||||||
# JS: insert markdown syntax around selection (called from toolbar buttons)
|
|
||||||
def _js_insert(prefix: str, suffix: str = "", placeholder: str = "текст") -> str:
|
|
||||||
p = prefix.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
|
||||||
s = suffix.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
|
||||||
ph = placeholder.replace("'", "\\'")
|
|
||||||
return f"""
|
|
||||||
(function(){{
|
|
||||||
var sel = window.__editorSel;
|
|
||||||
if(!sel||!sel.el){{
|
|
||||||
var tas=document.querySelectorAll('textarea');
|
|
||||||
if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};
|
|
||||||
else return;
|
|
||||||
}}
|
|
||||||
var ta=sel.el, val=ta.value;
|
|
||||||
var st=sel.start, en=sel.end;
|
|
||||||
var selected=val.substring(st,en)||'{ph}';
|
|
||||||
var before=val.substring(0,st), after=val.substring(en);
|
|
||||||
var inserted='{p}'+selected+'{s}';
|
|
||||||
ta.value=before+inserted+after;
|
|
||||||
ta.selectionStart=st+{len(prefix)};
|
|
||||||
ta.selectionEnd=st+{len(prefix)}+selected.length;
|
|
||||||
ta.focus();
|
|
||||||
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
|
||||||
window.__editorSel={{el:ta,start:ta.selectionStart,end:ta.selectionEnd}};
|
|
||||||
return ta.value;
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _js_prepend_line(prefix: str) -> str:
|
|
||||||
p = prefix.replace("'", "\\'")
|
|
||||||
return f"""
|
|
||||||
(function(){{
|
|
||||||
var sel=window.__editorSel;
|
|
||||||
if(!sel||!sel.el){{
|
|
||||||
var tas=document.querySelectorAll('textarea');
|
|
||||||
if(tas.length>0)sel={{el:tas[tas.length-1],start:0,end:0}};
|
|
||||||
else return;
|
|
||||||
}}
|
|
||||||
var ta=sel.el, val=ta.value, pos=sel.start;
|
|
||||||
var lineStart=val.lastIndexOf('\\n',pos-1)+1;
|
|
||||||
var line=val.substring(lineStart);
|
|
||||||
var lineEnd=line.indexOf('\\n'); if(lineEnd===-1)lineEnd=line.length;
|
|
||||||
line=line.substring(0,lineEnd);
|
|
||||||
var newLine=line.startsWith('{p}')?line.substring({len(prefix)}):'{p}'+line;
|
|
||||||
ta.value=val.substring(0,lineStart)+newLine+val.substring(lineStart+lineEnd);
|
|
||||||
ta.selectionStart=ta.selectionEnd=lineStart+newLine.length;
|
|
||||||
ta.focus();
|
|
||||||
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
|
||||||
return ta.value;
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _js_append(text: str) -> str:
|
|
||||||
t = text.replace("\\", "\\\\").replace("'", "\\'").replace("\n", "\\n")
|
|
||||||
return f"""
|
|
||||||
(function(){{
|
|
||||||
var sel=window.__editorSel;
|
|
||||||
var ta=sel&&sel.el;
|
|
||||||
if(!ta){{var tas=document.querySelectorAll('textarea');if(tas.length>0)ta=tas[tas.length-1];else return;}}
|
|
||||||
ta.value=(ta.value?ta.value+'\\n':'')+'{t}';
|
|
||||||
ta.selectionStart=ta.selectionEnd=ta.value.length;
|
|
||||||
ta.focus();
|
|
||||||
ta.dispatchEvent(new InputEvent('input',{{bubbles:true,cancelable:true}}));
|
|
||||||
return ta.value;
|
|
||||||
}})();
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class ArticleEditorView:
|
class ArticleEditorView:
|
||||||
@@ -140,16 +39,6 @@ class ArticleEditorView:
|
|||||||
border_radius=8,
|
border_radius=8,
|
||||||
on_change=self._on_article_font_change,
|
on_change=self._on_article_font_change,
|
||||||
)
|
)
|
||||||
self._inline_font_dd = ft.Dropdown(
|
|
||||||
label="Шрифт фрагмента",
|
|
||||||
hint_text="Выбери и примени к выделению",
|
|
||||||
width=200,
|
|
||||||
options=[ft.dropdown.Option(k, k) for k in d.ARTICLE_FONTS],
|
|
||||||
border_color=d.DIVIDER,
|
|
||||||
focused_border_color=d.PRIMARY,
|
|
||||||
border_radius=8,
|
|
||||||
on_change=self._on_inline_font_change,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Content editor
|
# Content editor
|
||||||
self._content_field = ft.TextField(
|
self._content_field = ft.TextField(
|
||||||
@@ -181,10 +70,15 @@ class ArticleEditorView:
|
|||||||
visible=False,
|
visible=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Pending upload state for web mode (f.path is None)
|
||||||
|
self._pending_img: tuple | None = None
|
||||||
|
self._pending_vid: tuple | None = None
|
||||||
|
self._pending_cover = None
|
||||||
|
|
||||||
# File pickers
|
# File pickers
|
||||||
self._img_picker = ft.FilePicker(on_result=self._on_image_picked)
|
self._img_picker = ft.FilePicker(on_result=self._on_image_picked, on_upload=self._on_img_upload)
|
||||||
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked)
|
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked, on_upload=self._on_vid_upload)
|
||||||
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked)
|
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked, on_upload=self._on_cover_upload)
|
||||||
|
|
||||||
# Save button
|
# Save button
|
||||||
self._save_btn = d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save), icon=ft.icons.SAVE)
|
self._save_btn = d.btn_primary("Сохранить", on_click=lambda e: self.page.run_task(self._save), icon=ft.icons.SAVE)
|
||||||
@@ -324,49 +218,50 @@ class ArticleEditorView:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _build_toolbar(self) -> ft.Container:
|
def _build_toolbar(self) -> ft.Container:
|
||||||
def tb_btn(icon, tooltip: str, js_code: str) -> ft.IconButton:
|
def tb_btn(icon, tooltip: str, on_click) -> ft.IconButton:
|
||||||
return ft.IconButton(
|
return ft.IconButton(
|
||||||
icon=icon,
|
icon=icon,
|
||||||
tooltip=tooltip,
|
tooltip=tooltip,
|
||||||
icon_size=18,
|
icon_size=18,
|
||||||
icon_color=d.TEXT_PRIMARY,
|
icon_color=d.TEXT_PRIMARY,
|
||||||
on_click=lambda e, js=js_code: self.page.run_task(self._exec_js, js),
|
on_click=on_click,
|
||||||
style=ft.ButtonStyle(
|
style=ft.ButtonStyle(
|
||||||
shape=ft.RoundedRectangleBorder(radius=4),
|
shape=ft.RoundedRectangleBorder(radius=4),
|
||||||
padding=ft.padding.all(6),
|
padding=ft.padding.all(6),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def sep():
|
||||||
|
return ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4))
|
||||||
|
|
||||||
buttons = [
|
buttons = [
|
||||||
tb_btn(ft.icons.FORMAT_BOLD, "Жирный (Ctrl+B)", _js_insert("**", "**", "жирный текст")),
|
tb_btn(ft.icons.FORMAT_BOLD, "Жирный", lambda e: self._fmt("**", "**", "жирный текст")),
|
||||||
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив (Ctrl+I)", _js_insert("*", "*", "курсив")),
|
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив", lambda e: self._fmt("*", "*", "курсив")),
|
||||||
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", _js_insert("~~", "~~", "текст")),
|
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", lambda e: self._fmt("~~", "~~", "текст")),
|
||||||
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
sep(),
|
||||||
tb_btn(ft.icons.TITLE, "Заголовок H1", _js_prepend_line("# ")),
|
tb_btn(ft.icons.TITLE, "Заголовок H1", lambda e: self._fmt("# ", "", "Заголовок")),
|
||||||
ft.Container(
|
ft.Container(
|
||||||
content=ft.Text("H2", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
|
content=ft.Text("H2", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
|
||||||
on_click=lambda e: self.page.run_task(self._exec_js, _js_prepend_line("## ")),
|
on_click=lambda e: self._fmt("## ", "", "Заголовок"),
|
||||||
ink=True,
|
ink=True, border_radius=4,
|
||||||
border_radius=4,
|
|
||||||
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
||||||
tooltip="Заголовок H2",
|
tooltip="Заголовок H2",
|
||||||
),
|
),
|
||||||
ft.Container(
|
ft.Container(
|
||||||
content=ft.Text("H3", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
|
content=ft.Text("H3", size=12, weight=ft.FontWeight.BOLD, color=d.TEXT_PRIMARY),
|
||||||
on_click=lambda e: self.page.run_task(self._exec_js, _js_prepend_line("### ")),
|
on_click=lambda e: self._fmt("### ", "", "Заголовок"),
|
||||||
ink=True,
|
ink=True, border_radius=4,
|
||||||
border_radius=4,
|
|
||||||
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
padding=ft.padding.symmetric(horizontal=8, vertical=6),
|
||||||
tooltip="Заголовок H3",
|
tooltip="Заголовок H3",
|
||||||
),
|
),
|
||||||
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
sep(),
|
||||||
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", _js_prepend_line("- ")),
|
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", lambda e: self._fmt("- ", "", "пункт")),
|
||||||
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", _js_prepend_line("1. ")),
|
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", lambda e: self._fmt("1. ", "", "пункт")),
|
||||||
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", _js_prepend_line("> ")),
|
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", lambda e: self._fmt("> ", "", "цитата")),
|
||||||
tb_btn(ft.icons.CODE, "Код", _js_insert("`", "`", "код")),
|
tb_btn(ft.icons.CODE, "Инлайн-код", lambda e: self._fmt("`", "`", "код")),
|
||||||
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", _js_insert("```\n", "\n```", "код")),
|
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", lambda e: self._fmt("```\n", "\n```", "код")),
|
||||||
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
sep(),
|
||||||
tb_btn(ft.icons.LINK, "Ссылка", _js_insert("[", "](https://)", "текст ссылки")),
|
tb_btn(ft.icons.LINK, "Ссылка", lambda e: self._fmt("[", "](https://)", "текст ссылки")),
|
||||||
ft.IconButton(
|
ft.IconButton(
|
||||||
icon=ft.icons.IMAGE,
|
icon=ft.icons.IMAGE,
|
||||||
tooltip="Вставить картинку",
|
tooltip="Вставить картинку",
|
||||||
@@ -387,10 +282,8 @@ class ArticleEditorView:
|
|||||||
dialog_title="Выберите видео",
|
dialog_title="Выберите видео",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
sep(),
|
||||||
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", _js_append("\n---\n")),
|
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", lambda e: self._fmt("", "", "\n---\n")),
|
||||||
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
|
|
||||||
ft.Container(content=self._inline_font_dd, width=210),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return ft.Container(
|
return ft.Container(
|
||||||
@@ -401,17 +294,16 @@ class ArticleEditorView:
|
|||||||
padding=ft.padding.symmetric(horizontal=8, vertical=4),
|
padding=ft.padding.symmetric(horizontal=8, vertical=4),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ─── Events ──────────────────────────────────────────────────────────────
|
# ─── Formatting ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def _exec_js(self, js: str):
|
def _fmt(self, prefix: str, suffix: str = "", placeholder: str = "текст"):
|
||||||
try:
|
cur = self._content_field.value or ""
|
||||||
new_val = self.page.run_javascript(js, wait_for_result=True)
|
sep = "\n" if cur and not cur.endswith("\n") else ""
|
||||||
if isinstance(new_val, str):
|
self._content_field.value = cur + sep + prefix + placeholder + suffix
|
||||||
self._content_field.value = new_val
|
self._preview.value = self._content_field.value
|
||||||
self._preview.value = new_val
|
|
||||||
self.page.update()
|
self.page.update()
|
||||||
except Exception:
|
|
||||||
self.page.run_javascript(js)
|
# ─── Events ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _on_article_font_change(self, e):
|
def _on_article_font_change(self, e):
|
||||||
font = e.control.value or "Merriweather"
|
font = e.control.value or "Merriweather"
|
||||||
@@ -419,13 +311,6 @@ class ArticleEditorView:
|
|||||||
self._preview.value = self._content_field.value or ""
|
self._preview.value = self._content_field.value or ""
|
||||||
self.page.update()
|
self.page.update()
|
||||||
|
|
||||||
def _on_inline_font_change(self, e):
|
|
||||||
font = e.control.value
|
|
||||||
if font:
|
|
||||||
self.page.run_task(self._exec_js, _js_font_span(font))
|
|
||||||
e.control.value = None
|
|
||||||
self.page.update()
|
|
||||||
|
|
||||||
def _on_title_change(self, e):
|
def _on_title_change(self, e):
|
||||||
if not self.article_id:
|
if not self.article_id:
|
||||||
self._slug.value = _slugify(e.control.value)
|
self._slug.value = _slugify(e.control.value)
|
||||||
@@ -445,23 +330,84 @@ class ArticleEditorView:
|
|||||||
if not e.files:
|
if not e.files:
|
||||||
return
|
return
|
||||||
f = e.files[0]
|
f = e.files[0]
|
||||||
await self._upload_and_insert(f, "image")
|
file_bytes = await _read_file(f)
|
||||||
|
if file_bytes is not None:
|
||||||
|
await self._upload_and_insert(f, "image", file_bytes)
|
||||||
|
else:
|
||||||
|
self._pending_img = (f, "image")
|
||||||
|
d.snack(self.page, f"Загрузка {f.name}...")
|
||||||
|
upload_url = self.page.get_upload_url(f.name, 600)
|
||||||
|
self._img_picker.upload([ft.FilePickerUploadFile(name=f.name, upload_url=upload_url)])
|
||||||
|
|
||||||
async def _on_video_picked(self, e: ft.FilePickerResultEvent):
|
async def _on_video_picked(self, e: ft.FilePickerResultEvent):
|
||||||
if not e.files:
|
if not e.files:
|
||||||
return
|
return
|
||||||
f = e.files[0]
|
f = e.files[0]
|
||||||
await self._upload_and_insert(f, "video")
|
file_bytes = await _read_file(f)
|
||||||
|
if file_bytes is not None:
|
||||||
|
await self._upload_and_insert(f, "video", file_bytes)
|
||||||
|
else:
|
||||||
|
self._pending_vid = (f, "video")
|
||||||
|
d.snack(self.page, f"Загрузка {f.name}...")
|
||||||
|
upload_url = self.page.get_upload_url(f.name, 600)
|
||||||
|
self._vid_picker.upload([ft.FilePickerUploadFile(name=f.name, upload_url=upload_url)])
|
||||||
|
|
||||||
|
async def _on_img_upload(self, e: ft.FilePickerUploadEvent):
|
||||||
|
if e.error:
|
||||||
|
d.snack(self.page, "Ошибка загрузки файла", error=True)
|
||||||
|
self._pending_img = None
|
||||||
|
return
|
||||||
|
if e.progress == 1.0 and self._pending_img:
|
||||||
|
f, kind = self._pending_img
|
||||||
|
self._pending_img = None
|
||||||
|
file_bytes = _read_upload(e.file_name)
|
||||||
|
if file_bytes:
|
||||||
|
await self._upload_and_insert(f, kind, file_bytes)
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Не удалось прочитать загруженный файл", error=True)
|
||||||
|
|
||||||
|
async def _on_vid_upload(self, e: ft.FilePickerUploadEvent):
|
||||||
|
if e.error:
|
||||||
|
d.snack(self.page, "Ошибка загрузки видео", error=True)
|
||||||
|
self._pending_vid = None
|
||||||
|
return
|
||||||
|
if e.progress == 1.0 and self._pending_vid:
|
||||||
|
f, kind = self._pending_vid
|
||||||
|
self._pending_vid = None
|
||||||
|
file_bytes = _read_upload(e.file_name)
|
||||||
|
if file_bytes:
|
||||||
|
await self._upload_and_insert(f, kind, file_bytes)
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Не удалось прочитать загруженный файл", error=True)
|
||||||
|
|
||||||
async def _on_cover_picked(self, e: ft.FilePickerResultEvent):
|
async def _on_cover_picked(self, e: ft.FilePickerResultEvent):
|
||||||
if not e.files:
|
if not e.files:
|
||||||
return
|
return
|
||||||
f = e.files[0]
|
f = e.files[0]
|
||||||
token = self.page.session.get("token")
|
|
||||||
file_bytes = await _read_file(f)
|
file_bytes = await _read_file(f)
|
||||||
if file_bytes is None:
|
if file_bytes is None:
|
||||||
d.snack(self.page, "Не удалось прочитать файл", error=True)
|
self._pending_cover = f
|
||||||
|
upload_url = self.page.get_upload_url(f.name, 600)
|
||||||
|
self._cover_picker.upload([ft.FilePickerUploadFile(name=f.name, upload_url=upload_url)])
|
||||||
return
|
return
|
||||||
|
await self._save_cover(f, file_bytes)
|
||||||
|
|
||||||
|
async def _on_cover_upload(self, e: ft.FilePickerUploadEvent):
|
||||||
|
if e.error:
|
||||||
|
d.snack(self.page, "Ошибка загрузки обложки", error=True)
|
||||||
|
self._pending_cover = None
|
||||||
|
return
|
||||||
|
if e.progress == 1.0 and self._pending_cover:
|
||||||
|
f = self._pending_cover
|
||||||
|
self._pending_cover = None
|
||||||
|
file_bytes = _read_upload(e.file_name)
|
||||||
|
if file_bytes:
|
||||||
|
await self._save_cover(f, file_bytes)
|
||||||
|
else:
|
||||||
|
d.snack(self.page, "Не удалось прочитать загруженный файл", error=True)
|
||||||
|
|
||||||
|
async def _save_cover(self, f, file_bytes: bytes):
|
||||||
|
token = self.page.session.get("token")
|
||||||
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
||||||
ct = _content_type(ext, "image")
|
ct = _content_type(ext, "image")
|
||||||
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
|
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
|
||||||
@@ -474,13 +420,8 @@ class ArticleEditorView:
|
|||||||
else:
|
else:
|
||||||
d.snack(self.page, "Ошибка загрузки обложки", error=True)
|
d.snack(self.page, "Ошибка загрузки обложки", error=True)
|
||||||
|
|
||||||
async def _upload_and_insert(self, f, media_kind: str):
|
async def _upload_and_insert(self, f, media_kind: str, file_bytes: bytes):
|
||||||
token = self.page.session.get("token")
|
token = self.page.session.get("token")
|
||||||
d.snack(self.page, f"Загрузка {f.name}...")
|
|
||||||
file_bytes = await _read_file(f)
|
|
||||||
if file_bytes is None:
|
|
||||||
d.snack(self.page, "Не удалось прочитать файл", error=True)
|
|
||||||
return
|
|
||||||
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
ext = (f.name or "").rsplit(".", 1)[-1].lower()
|
||||||
ct = _content_type(ext, media_kind)
|
ct = _content_type(ext, media_kind)
|
||||||
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
|
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
|
||||||
@@ -494,8 +435,9 @@ class ArticleEditorView:
|
|||||||
f' <source src="{url}" type="{ct}">\n'
|
f' <source src="{url}" type="{ct}">\n'
|
||||||
f'</video>'
|
f'</video>'
|
||||||
)
|
)
|
||||||
self.page.run_javascript(_js_append(md))
|
cur = self._content_field.value or ""
|
||||||
self._content_field.value = (self._content_field.value or "") + "\n" + md
|
sep = "\n" if cur and not cur.endswith("\n") else ""
|
||||||
|
self._content_field.value = cur + sep + md
|
||||||
self._preview.value = self._content_field.value
|
self._preview.value = self._content_field.value
|
||||||
self.page.update()
|
self.page.update()
|
||||||
d.snack(self.page, f"Файл загружен: {f.name}")
|
d.snack(self.page, f"Файл загружен: {f.name}")
|
||||||
@@ -516,7 +458,6 @@ class ArticleEditorView:
|
|||||||
if self._article:
|
if self._article:
|
||||||
self._fill_form(self._article)
|
self._fill_form(self._article)
|
||||||
|
|
||||||
self.page.run_javascript(_TRACK_JS)
|
|
||||||
self.page.update()
|
self.page.update()
|
||||||
|
|
||||||
def _fill_form(self, a: dict):
|
def _fill_form(self, a: dict):
|
||||||
@@ -608,8 +549,16 @@ def _content_type(ext: str, kind: str) -> str:
|
|||||||
async def _read_file(f) -> bytes | None:
|
async def _read_file(f) -> bytes | None:
|
||||||
try:
|
try:
|
||||||
if hasattr(f, "path") and f.path:
|
if hasattr(f, "path") and f.path:
|
||||||
with open(f.path, "rb") as fh:
|
loop = asyncio.get_running_loop()
|
||||||
return fh.read()
|
return await loop.run_in_executor(None, lambda: open(f.path, "rb").read())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_upload(file_name: str) -> bytes | None:
|
||||||
|
try:
|
||||||
|
with open(os.path.join(_UPLOAD_DIR, file_name), "rb") as fh:
|
||||||
|
return fh.read()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -37,12 +37,15 @@ class ArticlesListView:
|
|||||||
d.headline("Статьи", size=22),
|
d.headline("Статьи", size=22),
|
||||||
ft.Row(
|
ft.Row(
|
||||||
[
|
[
|
||||||
|
d.btn_icon(ft.icons.REFRESH, "Обновить список",
|
||||||
|
on_click=lambda e: self.page.run_task(self._load_articles)),
|
||||||
d.btn_primary(
|
d.btn_primary(
|
||||||
"Новая статья",
|
"Новая статья",
|
||||||
icon=ft.icons.ADD,
|
icon=ft.icons.ADD,
|
||||||
on_click=lambda e: self.page.go("/admin/articles/new"),
|
on_click=lambda e: self._open_editor("new"),
|
||||||
)
|
),
|
||||||
]
|
],
|
||||||
|
spacing=8,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
@@ -64,7 +67,13 @@ class ArticlesListView:
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _open_editor(self, article_id: str):
|
||||||
|
token = self.page.session.get("token") or ""
|
||||||
|
url = f"/editor/{article_id}?t={token}"
|
||||||
|
self.page.launch_url(url, web_window_name="_blank")
|
||||||
|
|
||||||
async def load(self):
|
async def load(self):
|
||||||
|
self.page.window.on_focus = lambda e: self.page.run_task(self._load_articles)
|
||||||
await self._load_articles()
|
await self._load_articles()
|
||||||
|
|
||||||
async def _on_filter(self, e):
|
async def _on_filter(self, e):
|
||||||
@@ -147,7 +156,7 @@ class ArticlesListView:
|
|||||||
d.btn_icon(
|
d.btn_icon(
|
||||||
ft.icons.EDIT_OUTLINED,
|
ft.icons.EDIT_OUTLINED,
|
||||||
"Редактировать",
|
"Редактировать",
|
||||||
on_click=lambda e, aid=article["id"]: self.page.go(f"/admin/articles/{aid}"),
|
on_click=lambda e, aid=article["id"]: self._open_editor(aid),
|
||||||
color=d.PRIMARY,
|
color=d.PRIMARY,
|
||||||
),
|
),
|
||||||
*([
|
*([
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ class NewsFeedView:
|
|||||||
)
|
)
|
||||||
|
|
||||||
main_content = ft.Container(
|
main_content = ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
|
[
|
||||||
|
ft.Container(
|
||||||
content=ft.Column(
|
content=ft.Column(
|
||||||
[
|
[
|
||||||
self._list_col,
|
self._list_col,
|
||||||
@@ -76,10 +79,16 @@ class NewsFeedView:
|
|||||||
padding=16,
|
padding=16,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
spacing=0,
|
spacing=16,
|
||||||
),
|
),
|
||||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
width=680,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
padding=ft.padding.symmetric(horizontal=16, vertical=24),
|
||||||
alignment=ft.alignment.top_center,
|
alignment=ft.alignment.top_center,
|
||||||
|
expand=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ft.View(
|
return ft.View(
|
||||||
@@ -157,7 +166,7 @@ class NewsFeedView:
|
|||||||
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),
|
||||||
on_click=lambda e, s=slug: self.page.run_task(self._filter_category, s),
|
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=12, vertical=6),
|
||||||
side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
border_side=ft.BorderSide(1, d.PRIMARY if is_active else d.DIVIDER),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _filter_category(self, slug: str | None):
|
async def _filter_category(self, slug: str | None):
|
||||||
@@ -174,26 +183,36 @@ class NewsFeedView:
|
|||||||
if article.get("published_at"):
|
if article.get("published_at"):
|
||||||
pub_date = article["published_at"][:10]
|
pub_date = article["published_at"][:10]
|
||||||
|
|
||||||
|
slug = article["slug"]
|
||||||
|
|
||||||
cat = article.get("category")
|
cat = article.get("category")
|
||||||
cat_chip = ft.Container(
|
meta_row_controls = []
|
||||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE),
|
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,
|
bgcolor=d.PRIMARY,
|
||||||
border_radius=4,
|
border_radius=4,
|
||||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||||
visible=bool(cat),
|
))
|
||||||
) if cat else ft.Container(visible=False)
|
if pub_date:
|
||||||
|
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY))
|
||||||
|
|
||||||
cover = ft.Image(
|
cover_section = ft.Container(
|
||||||
|
content=ft.Image(
|
||||||
src=article["cover_url"],
|
src=article["cover_url"],
|
||||||
width=260,
|
width=float("inf"),
|
||||||
height=160,
|
height=200,
|
||||||
fit=ft.ImageFit.COVER,
|
fit=ft.ImageFit.COVER,
|
||||||
border_radius=ft.BorderRadius(0, 8, 8, 0),
|
),
|
||||||
|
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)
|
) if article.get("cover_url") else ft.Container(visible=False)
|
||||||
|
|
||||||
text_part = ft.Column(
|
body = ft.Container(
|
||||||
|
content=ft.Column(
|
||||||
[
|
[
|
||||||
ft.Row([cat_chip, ft.Text(pub_date, size=11, color=d.TEXT_SECONDARY)], spacing=8),
|
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
|
||||||
ft.Text(
|
ft.Text(
|
||||||
article["title"],
|
article["title"],
|
||||||
size=18,
|
size=18,
|
||||||
@@ -208,7 +227,7 @@ class NewsFeedView:
|
|||||||
color=d.TEXT_SECONDARY,
|
color=d.TEXT_SECONDARY,
|
||||||
max_lines=3,
|
max_lines=3,
|
||||||
overflow=ft.TextOverflow.ELLIPSIS,
|
overflow=ft.TextOverflow.ELLIPSIS,
|
||||||
),
|
) if article.get("excerpt") else ft.Container(visible=False),
|
||||||
ft.Row(
|
ft.Row(
|
||||||
[
|
[
|
||||||
ft.Row([
|
ft.Row([
|
||||||
@@ -218,23 +237,28 @@ class NewsFeedView:
|
|||||||
ft.TextButton(
|
ft.TextButton(
|
||||||
"Читать →",
|
"Читать →",
|
||||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||||
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
on_click=lambda e, s=slug: self.page.launch_url(
|
||||||
|
f"/article/{s}", web_window_name="_self"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
spacing=8,
|
spacing=8,
|
||||||
expand=True,
|
),
|
||||||
|
padding=ft.padding.symmetric(horizontal=20, vertical=16),
|
||||||
)
|
)
|
||||||
|
|
||||||
return ft.Container(
|
return ft.Container(
|
||||||
content=ft.Row([ft.Container(content=text_part, expand=True, padding=20), cover], spacing=0),
|
content=ft.Column([cover_section, body], spacing=0),
|
||||||
bgcolor=d.SURFACE,
|
bgcolor=d.SURFACE,
|
||||||
border_radius=12,
|
border_radius=12,
|
||||||
border=ft.border.all(1, d.DIVIDER),
|
border=ft.border.all(1, d.DIVIDER),
|
||||||
shadow=ft.BoxShadow(blur_radius=6, color=ft.colors.with_opacity(0.05, ft.colors.BLACK)),
|
shadow=ft.BoxShadow(blur_radius=8, color=ft.colors.with_opacity(0.07, ft.colors.BLACK)),
|
||||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||||
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
on_click=lambda e, s=slug: self.page.launch_url(
|
||||||
|
f"/article/{s}", web_window_name="_self"
|
||||||
|
),
|
||||||
ink=True,
|
ink=True,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user