test ract
This commit is contained in:
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
|
||||
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
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.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
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):
|
||||
route = page.route or "/"
|
||||
page.window.on_focus = None
|
||||
|
||||
if _is_admin(route):
|
||||
token = page.session.get("token")
|
||||
|
||||
@@ -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
|
||||
self.page.update()
|
||||
except Exception:
|
||||
self.page.run_javascript(js)
|
||||
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()
|
||||
|
||||
# ─── 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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
*([
|
||||
|
||||
@@ -68,18 +68,27 @@ class NewsFeedView:
|
||||
main_content = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
self._list_col,
|
||||
self._loader,
|
||||
ft.Container(
|
||||
content=self._load_more_btn,
|
||||
alignment=ft.alignment.center,
|
||||
padding=16,
|
||||
content=ft.Column(
|
||||
[
|
||||
self._list_col,
|
||||
self._loader,
|
||||
ft.Container(
|
||||
content=self._load_more_btn,
|
||||
alignment=ft.alignment.center,
|
||||
padding=16,
|
||||
),
|
||||
],
|
||||
spacing=16,
|
||||
),
|
||||
width=680,
|
||||
),
|
||||
],
|
||||
spacing=0,
|
||||
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||
),
|
||||
padding=ft.padding.symmetric(horizontal=24, vertical=20),
|
||||
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,67 +183,82 @@ class NewsFeedView:
|
||||
if article.get("published_at"):
|
||||
pub_date = article["published_at"][:10]
|
||||
|
||||
cat = article.get("category")
|
||||
cat_chip = ft.Container(
|
||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE),
|
||||
bgcolor=d.PRIMARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
visible=bool(cat),
|
||||
) if cat else ft.Container(visible=False)
|
||||
slug = article["slug"]
|
||||
|
||||
cover = ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=260,
|
||||
height=160,
|
||||
fit=ft.ImageFit.COVER,
|
||||
border_radius=ft.BorderRadius(0, 8, 8, 0),
|
||||
cat = article.get("category")
|
||||
meta_row_controls = []
|
||||
if cat:
|
||||
meta_row_controls.append(ft.Container(
|
||||
content=ft.Text(cat["name"], size=11, color=ft.colors.WHITE, weight=ft.FontWeight.W_600),
|
||||
bgcolor=d.PRIMARY,
|
||||
border_radius=4,
|
||||
padding=ft.padding.symmetric(horizontal=8, vertical=3),
|
||||
))
|
||||
if pub_date:
|
||||
meta_row_controls.append(ft.Text(pub_date, size=12, color=d.TEXT_SECONDARY))
|
||||
|
||||
cover_section = ft.Container(
|
||||
content=ft.Image(
|
||||
src=article["cover_url"],
|
||||
width=float("inf"),
|
||||
height=200,
|
||||
fit=ft.ImageFit.COVER,
|
||||
),
|
||||
height=200,
|
||||
clip_behavior=ft.ClipBehavior.HARD_EDGE,
|
||||
border_radius=ft.BorderRadius(12, 12, 0, 0),
|
||||
) if article.get("cover_url") else ft.Container(visible=False)
|
||||
|
||||
text_part = ft.Column(
|
||||
[
|
||||
ft.Row([cat_chip, ft.Text(pub_date, size=11, color=d.TEXT_SECONDARY)], spacing=8),
|
||||
ft.Text(
|
||||
article["title"],
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(
|
||||
article.get("excerpt", ""),
|
||||
size=13,
|
||||
color=d.TEXT_SECONDARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
ft.TextButton(
|
||||
"Читать →",
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, s=article["slug"]: self.page.go(f"/news/{s}"),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
expand=True,
|
||||
body = ft.Container(
|
||||
content=ft.Column(
|
||||
[
|
||||
ft.Row(meta_row_controls, spacing=8) if meta_row_controls else ft.Container(visible=False),
|
||||
ft.Text(
|
||||
article["title"],
|
||||
size=18,
|
||||
weight=ft.FontWeight.BOLD,
|
||||
color=d.TEXT_PRIMARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
),
|
||||
ft.Text(
|
||||
article.get("excerpt", ""),
|
||||
size=13,
|
||||
color=d.TEXT_SECONDARY,
|
||||
max_lines=3,
|
||||
overflow=ft.TextOverflow.ELLIPSIS,
|
||||
) if article.get("excerpt") else ft.Container(visible=False),
|
||||
ft.Row(
|
||||
[
|
||||
ft.Row([
|
||||
ft.Icon(ft.icons.REMOVE_RED_EYE_OUTLINED, size=14, color=d.TEXT_SECONDARY),
|
||||
ft.Text(str(article.get("view_count", 0)), size=12, color=d.TEXT_SECONDARY),
|
||||
], spacing=4),
|
||||
ft.TextButton(
|
||||
"Читать →",
|
||||
style=ft.ButtonStyle(color=d.PRIMARY),
|
||||
on_click=lambda e, s=slug: self.page.launch_url(
|
||||
f"/article/{s}", web_window_name="_self"
|
||||
),
|
||||
),
|
||||
],
|
||||
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||
),
|
||||
],
|
||||
spacing=8,
|
||||
),
|
||||
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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user