feat(drafts): чтение черновика целиком — модалка с полным текстом и большой обложкой

- GET /api/admin/drafts/:id проксирует полный content из движка
- кнопка «Читать целиком» + клик по превью-обложке открывают модалку
- модалка: большая обложка 16:9, заголовок, markdown-контент (prose)
- добавлены подписи категорий digest/opinions/tutorials/comparisons
This commit is contained in:
admin
2026-06-30 01:41:15 +03:00
parent d5a0fb2e19
commit 02124d8ef0
2 changed files with 87 additions and 7 deletions
+73 -3
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; 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 = [ const COVER_STYLES = [
{ id: 'robot-workspace', name: '🤖 Робот за работой' }, { id: 'robot-workspace', name: '🤖 Робот за работой' },
@@ -16,6 +17,10 @@ const CATEGORY_LABELS = {
'cybersec': '🔒 Cybersec', 'cybersec': '🔒 Cybersec',
'automation': '⚡ Automation', 'automation': '⚡ Automation',
'ai-dev': '💻 AI Dev', 'ai-dev': '💻 AI Dev',
'digest': '🗞 Digest',
'opinions': '💬 Opinions',
'tutorials': '📘 Tutorials',
'comparisons': '⚖️ Comparisons',
}; };
function DraftCard({ draft, onApproved, onCoverRegenerated }) { function DraftCard({ draft, onApproved, onCoverRegenerated }) {
@@ -29,6 +34,22 @@ function DraftCard({ draft, onApproved, onCoverRegenerated }) {
const [selectedStyle, setSelectedStyle] = useState(''); const [selectedStyle, setSelectedStyle] = useState('');
const [coverUrl, setCoverUrl] = useState(draft.cover_url); const [coverUrl, setCoverUrl] = useState(draft.cover_url);
const [msg, setMsg] = useState(''); 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() { async function approve() {
setLoading(true); setMsg(''); setLoading(true); setMsg('');
@@ -86,12 +107,16 @@ function DraftCard({ draft, onApproved, onCoverRegenerated }) {
return ( return (
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden"> <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="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 {coverSrc
? <img src={coverSrc} alt="" className="w-full h-full object-cover" /> ? <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 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-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <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"> <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" /> Редактировать <Pencil className="w-3.5 h-3.5" /> Редактировать
</button> </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} <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"> 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" /> <CheckCircle className="w-3.5 h-3.5" />
{loading ? 'Одобряю...' : 'Одобрить и запланировать'} {loading ? 'Одобряю...' : 'Одобрить и запланировать'}
</button> </button>
</div> </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> </div>
); );
} }
+10
View File
@@ -2,6 +2,16 @@ import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/adminAuth'; import { requireAdminAuth } from '@/lib/adminAuth';
import { engineFetch } from '@/lib/engine'; 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) { export async function PATCH(req, context) {
await requireAdminAuth(); await requireAdminAuth();
try { try {