feat(drafts): чтение черновика целиком — модалка с полным текстом и большой обложкой
- GET /api/admin/drafts/:id проксирует полный content из движка - кнопка «Читать целиком» + клик по превью-обложке открывают модалку - модалка: большая обложка 16:9, заголовок, markdown-контент (prose) - добавлены подписи категорий digest/opinions/tutorials/comparisons
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { CheckCircle, RefreshCw, Pencil, Clock, ImageIcon, ChevronDown, ChevronUp, X, Save } from 'lucide-react';
|
||||
import { CheckCircle, RefreshCw, Pencil, Clock, ImageIcon, ChevronDown, ChevronUp, X, Save, BookOpen, Maximize2 } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
|
||||
const COVER_STYLES = [
|
||||
{ id: 'robot-workspace', name: '🤖 Робот за работой' },
|
||||
@@ -12,10 +13,14 @@ const COVER_STYLES = [
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS = {
|
||||
'ai-tools': '🤖 AI Tools',
|
||||
'cybersec': '🔒 Cybersec',
|
||||
'automation': '⚡ Automation',
|
||||
'ai-dev': '💻 AI Dev',
|
||||
'ai-tools': '🤖 AI Tools',
|
||||
'cybersec': '🔒 Cybersec',
|
||||
'automation': '⚡ Automation',
|
||||
'ai-dev': '💻 AI Dev',
|
||||
'digest': '🗞 Digest',
|
||||
'opinions': '💬 Opinions',
|
||||
'tutorials': '📘 Tutorials',
|
||||
'comparisons': '⚖️ Comparisons',
|
||||
};
|
||||
|
||||
function DraftCard({ draft, onApproved, onCoverRegenerated }) {
|
||||
@@ -29,6 +34,22 @@ function DraftCard({ draft, onApproved, onCoverRegenerated }) {
|
||||
const [selectedStyle, setSelectedStyle] = useState('');
|
||||
const [coverUrl, setCoverUrl] = useState(draft.cover_url);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [reading, setReading] = useState(false);
|
||||
const [full, setFull] = useState(null);
|
||||
const [readLoading, setReadLoading] = useState(false);
|
||||
|
||||
async function openReader() {
|
||||
setReading(true);
|
||||
if (full) return;
|
||||
setReadLoading(true);
|
||||
try {
|
||||
const r = await fetch(`/api/admin/drafts/${draft.id}`);
|
||||
const d = await r.json();
|
||||
if (d && d.content) setFull(d);
|
||||
else setMsg('❌ ' + (d.error || 'Не удалось загрузить текст'));
|
||||
} catch { setMsg('❌ Ошибка сети'); }
|
||||
setReadLoading(false);
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
setLoading(true); setMsg('');
|
||||
@@ -86,12 +107,16 @@ function DraftCard({ draft, onApproved, onCoverRegenerated }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden">
|
||||
<div className="flex gap-4 p-4">
|
||||
<div className="w-32 h-20 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 shrink-0">
|
||||
<button onClick={openReader} title="Читать целиком"
|
||||
className="relative w-32 h-20 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 shrink-0 group">
|
||||
{coverSrc
|
||||
? <img src={coverSrc} alt="" className="w-full h-full object-cover" />
|
||||
: <div className="w-full h-full flex items-center justify-center text-neutral-400"><ImageIcon className="w-6 h-6" /></div>
|
||||
}
|
||||
</div>
|
||||
<span className="absolute inset-0 bg-black/0 group-hover:bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition">
|
||||
<Maximize2 className="w-5 h-5 text-white" />
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||
@@ -161,12 +186,57 @@ function DraftCard({ draft, onApproved, onCoverRegenerated }) {
|
||||
<Pencil className="w-3.5 h-3.5" /> Редактировать
|
||||
</button>
|
||||
)}
|
||||
{!editing && (
|
||||
<button onClick={openReader}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-neutral-100 dark:bg-neutral-800 text-xs text-neutral-600 dark:text-neutral-300 hover:opacity-80 transition">
|
||||
<BookOpen className="w-3.5 h-3.5" /> Читать целиком
|
||||
</button>
|
||||
)}
|
||||
<button onClick={approve} disabled={loading}
|
||||
className="ml-auto flex items-center gap-1.5 px-4 py-1.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-semibold disabled:opacity-50 transition">
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
{loading ? 'Одобряю...' : 'Одобрить и запланировать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reading && (
|
||||
<div onClick={() => setReading(false)}
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start sm:items-center justify-center p-4 overflow-y-auto">
|
||||
<div onClick={e => e.stopPropagation()}
|
||||
className="bg-white dark:bg-neutral-900 rounded-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto shadow-2xl my-auto">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between px-5 py-3 border-b border-neutral-100 dark:border-neutral-800 bg-white/90 dark:bg-neutral-900/90 backdrop-blur">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 font-medium">
|
||||
{CATEGORY_LABELS[draft.category] || draft.category}
|
||||
</span>
|
||||
<button onClick={() => setReading(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition">
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
{coverSrc && (
|
||||
<img src={coverSrc} alt="" className="w-full aspect-[16/9] object-cover" />
|
||||
)}
|
||||
<div className="p-5 sm:p-7">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-400 mb-2">
|
||||
<Clock className="w-3.5 h-3.5" /> {draft.reading_time} мин чтения
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 mb-5 leading-tight">
|
||||
{full?.title || editTitle}
|
||||
</h1>
|
||||
{readLoading ? (
|
||||
<div className="py-16 text-center text-neutral-400">Загружаю текст…</div>
|
||||
) : full ? (
|
||||
<div
|
||||
className="prose prose-neutral dark:prose-invert max-w-none prose-headings:font-semibold prose-img:rounded-lg"
|
||||
dangerouslySetInnerHTML={{ __html: marked.parse(full.content || '') }}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-16 text-center text-neutral-400">Нет текста</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ import { NextResponse } from 'next/server';
|
||||
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||
import { engineFetch } from '@/lib/engine';
|
||||
|
||||
export async function GET(req, context) {
|
||||
await requireAdminAuth();
|
||||
try {
|
||||
const data = await engineFetch(`/api/drafts/${(await context.params).id}`);
|
||||
return NextResponse.json(data);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req, context) {
|
||||
await requireAdminAuth();
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user