test ract

This commit is contained in:
jze9
2026-05-14 16:47:58 +05:00
parent 0ac1c8a862
commit de78624495
36 changed files with 5877 additions and 254 deletions

14
.env.example Normal file
View 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
View File

@@ -1,4 +1,33 @@
.claude/
data/
# ── Secrets ────────────────────────────────────────────────────────────────
.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
View File

@@ -0,0 +1 @@
3.13

0
README.md Normal file
View File

View File

@@ -70,6 +70,11 @@ async def openapi(username: str = Depends(_verify_docs)):
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(auth_router, prefix="/admin", tags=["admin-auth"])
app.include_router(articles_router, prefix="/admin/articles", tags=["admin-articles"])

View File

@@ -1,6 +1,8 @@
version: "3.9"
services:
# ── FastAPI backend ────────────────────────────────────────────────────────
api:
build:
context: ./api
@@ -22,9 +24,16 @@ services:
depends_on:
minio:
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:
- news_net
# ── Flet SPA + React editor (multi-stage build) ────────────────────────────
web:
build:
context: ./web
@@ -35,10 +44,18 @@ services:
environment:
API_URL: http://api:8000
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:
- news_net
# ── MinIO object storage ───────────────────────────────────────────────────
minio:
image: minio/minio:latest
restart: unless-stopped

View File

@@ -11,7 +11,6 @@ services:
environment:
POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD:-PgRootNews2026Rz}
POSTGRES_USER: postgres
PGDATA: /var/lib/postgresql/data/pgdata
volumes:
- ./data/postgres:/var/lib/postgresql/data
ports:

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from news-flet!")
if __name__ == "__main__":
main()

11
pyproject.toml Normal file
View 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
View 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

View File

@@ -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
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
COPY --from=editor-build /build/dist ./editor-ui/dist
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

15
web/editor-ui/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

260
web/editor-ui/src/App.jsx Normal file
View 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
View 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()
},
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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(),
}
},
})

View 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
View 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;
}

View 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>
)

View 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: [],
}

View 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
View 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"))

View File

@@ -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.fastapi as flet_fastapi
import designer as d
import theme_manager
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):
@@ -38,4 +47,19 @@ async def main(page: ft.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
View 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()">&#8592; Назад</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">&#128197; %%PUB_DATE%%</span>
<span class="meta-sep">&middot;</span>
<span class="meta-text">&#128065; %%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>&#128444;</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">&middot;</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()))

View File

@@ -52,6 +52,7 @@ def resolve_view(page: ft.Page, route: str):
async def handle_route(page: ft.Page):
route = page.route or "/"
page.window.on_focus = None
if _is_admin(route):
token = page.session.get("token")

View File

@@ -1,112 +1,11 @@
import os
import asyncio
import flet as ft
import designer as d
import api_client as api
from views.dashboard import _admin_appbar, _sidebar
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;
}})();
"""
_UPLOAD_DIR = os.getenv("FLET_UPLOAD_DIR", "/tmp/flet_uploads")
class ArticleEditorView:
@@ -140,16 +39,6 @@ class ArticleEditorView:
border_radius=8,
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
self._content_field = ft.TextField(
@@ -181,10 +70,15 @@ class ArticleEditorView:
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
self._img_picker = ft.FilePicker(on_result=self._on_image_picked)
self._vid_picker = ft.FilePicker(on_result=self._on_video_picked)
self._cover_picker = ft.FilePicker(on_result=self._on_cover_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, on_upload=self._on_vid_upload)
self._cover_picker = ft.FilePicker(on_result=self._on_cover_picked, on_upload=self._on_cover_upload)
# Save button
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 tb_btn(icon, tooltip: str, js_code: str) -> ft.IconButton:
def tb_btn(icon, tooltip: str, on_click) -> ft.IconButton:
return ft.IconButton(
icon=icon,
tooltip=tooltip,
icon_size=18,
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(
shape=ft.RoundedRectangleBorder(radius=4),
padding=ft.padding.all(6),
),
)
def sep():
return ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4))
buttons = [
tb_btn(ft.icons.FORMAT_BOLD, "Жирный (Ctrl+B)", _js_insert("**", "**", "жирный текст")),
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив (Ctrl+I)", _js_insert("*", "*", "курсив")),
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", _js_insert("~~", "~~", "текст")),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.TITLE, "Заголовок H1", _js_prepend_line("# ")),
tb_btn(ft.icons.FORMAT_BOLD, "Жирный", lambda e: self._fmt("**", "**", "жирный текст")),
tb_btn(ft.icons.FORMAT_ITALIC, "Курсив", lambda e: self._fmt("*", "*", "курсив")),
tb_btn(ft.icons.FORMAT_STRIKETHROUGH, "Зачёркнутый", lambda e: self._fmt("~~", "~~", "текст")),
sep(),
tb_btn(ft.icons.TITLE, "Заголовок H1", lambda e: self._fmt("# ", "", "Заголовок")),
ft.Container(
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("## ")),
ink=True,
border_radius=4,
on_click=lambda e: self._fmt("## ", "", "Заголовок"),
ink=True, border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=6),
tooltip="Заголовок H2",
),
ft.Container(
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("### ")),
ink=True,
border_radius=4,
on_click=lambda e: self._fmt("### ", "", "Заголовок"),
ink=True, border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=6),
tooltip="Заголовок H3",
),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", _js_prepend_line("- ")),
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", _js_prepend_line("1. ")),
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", _js_prepend_line("> ")),
tb_btn(ft.icons.CODE, "Код", _js_insert("`", "`", "код")),
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", _js_insert("```\n", "\n```", "код")),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.LINK, "Ссылка", _js_insert("[", "](https://)", "текст ссылки")),
sep(),
tb_btn(ft.icons.FORMAT_LIST_BULLETED, "Список", lambda e: self._fmt("- ", "", "пункт")),
tb_btn(ft.icons.FORMAT_LIST_NUMBERED, "Нумерованный список", lambda e: self._fmt("1. ", "", "пункт")),
tb_btn(ft.icons.FORMAT_QUOTE, "Цитата", lambda e: self._fmt("> ", "", "цитата")),
tb_btn(ft.icons.CODE, "Инлайн-код", lambda e: self._fmt("`", "`", "код")),
tb_btn(ft.icons.DATA_OBJECT, "Блок кода", lambda e: self._fmt("```\n", "\n```", "код")),
sep(),
tb_btn(ft.icons.LINK, "Ссылка", lambda e: self._fmt("[", "](https://)", "текст ссылки")),
ft.IconButton(
icon=ft.icons.IMAGE,
tooltip="Вставить картинку",
@@ -387,10 +282,8 @@ class ArticleEditorView:
dialog_title="Выберите видео",
),
),
ft.Container(width=1, height=24, bgcolor=d.DIVIDER, margin=ft.margin.symmetric(horizontal=4)),
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", _js_append("\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),
sep(),
tb_btn(ft.icons.HORIZONTAL_RULE, "Горизонтальная линия", lambda e: self._fmt("", "", "\n---\n")),
]
return ft.Container(
@@ -401,17 +294,16 @@ class ArticleEditorView:
padding=ft.padding.symmetric(horizontal=8, vertical=4),
)
# ─── Events ──────────────────────────────────────────────────────────────
# ─── Formatting ──────────────────────────────────────────────────────────
async def _exec_js(self, js: str):
try:
new_val = self.page.run_javascript(js, wait_for_result=True)
if isinstance(new_val, str):
self._content_field.value = new_val
self._preview.value = new_val
def _fmt(self, prefix: str, suffix: str = "", placeholder: str = "текст"):
cur = self._content_field.value or ""
sep = "\n" if cur and not cur.endswith("\n") else ""
self._content_field.value = cur + sep + prefix + placeholder + suffix
self._preview.value = self._content_field.value
self.page.update()
except Exception:
self.page.run_javascript(js)
# ─── Events ──────────────────────────────────────────────────────────────
def _on_article_font_change(self, e):
font = e.control.value or "Merriweather"
@@ -419,13 +311,6 @@ class ArticleEditorView:
self._preview.value = self._content_field.value or ""
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):
if not self.article_id:
self._slug.value = _slugify(e.control.value)
@@ -445,23 +330,84 @@ class ArticleEditorView:
if not e.files:
return
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):
if not e.files:
return
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):
if not e.files:
return
f = e.files[0]
token = self.page.session.get("token")
file_bytes = await _read_file(f)
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
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()
ct = _content_type(ext, "image")
result = await api.admin_upload_media(token, file_bytes, f.name, ct)
@@ -474,13 +420,8 @@ class ArticleEditorView:
else:
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")
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()
ct = _content_type(ext, media_kind)
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'</video>'
)
self.page.run_javascript(_js_append(md))
self._content_field.value = (self._content_field.value or "") + "\n" + md
cur = self._content_field.value or ""
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.page.update()
d.snack(self.page, f"Файл загружен: {f.name}")
@@ -516,7 +458,6 @@ class ArticleEditorView:
if self._article:
self._fill_form(self._article)
self.page.run_javascript(_TRACK_JS)
self.page.update()
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:
try:
if hasattr(f, "path") and f.path:
with open(f.path, "rb") as fh:
return fh.read()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: open(f.path, "rb").read())
except Exception:
pass
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

View File

@@ -37,12 +37,15 @@ class ArticlesListView:
d.headline("Статьи", size=22),
ft.Row(
[
d.btn_icon(ft.icons.REFRESH, "Обновить список",
on_click=lambda e: self.page.run_task(self._load_articles)),
d.btn_primary(
"Новая статья",
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,
@@ -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):
self.page.window.on_focus = lambda e: self.page.run_task(self._load_articles)
await self._load_articles()
async def _on_filter(self, e):
@@ -147,7 +156,7 @@ class ArticlesListView:
d.btn_icon(
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,
),
*([

View File

@@ -66,6 +66,9 @@ class NewsFeedView:
)
main_content = ft.Container(
content=ft.Column(
[
ft.Container(
content=ft.Column(
[
self._list_col,
@@ -76,10 +79,16 @@ class NewsFeedView:
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,
expand=True,
)
return ft.View(
@@ -157,7 +166,7 @@ class NewsFeedView:
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),
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):
@@ -174,26 +183,36 @@ class NewsFeedView:
if article.get("published_at"):
pub_date = article["published_at"][:10]
slug = article["slug"]
cat = article.get("category")
cat_chip = ft.Container(
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE),
meta_row_controls = []
if cat:
meta_row_controls.append(ft.Container(
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600),
bgcolor=d.PRIMARY,
border_radius=4,
padding=ft.padding.symmetric(horizontal=8, vertical=3),
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"],
width=260,
height=160,
width=float("inf"),
height=200,
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)
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(
article["title"],
size=18,
@@ -208,7 +227,7 @@ class NewsFeedView:
color=d.TEXT_SECONDARY,
max_lines=3,
overflow=ft.TextOverflow.ELLIPSIS,
),
) if article.get("excerpt") else ft.Container(visible=False),
ft.Row(
[
ft.Row([
@@ -218,23 +237,28 @@ class NewsFeedView:
ft.TextButton(
"Читать →",
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,
),
],
spacing=8,
expand=True,
),
padding=ft.padding.symmetric(horizontal=20, vertical=16),
)
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,
border_radius=12,
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,
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,
)