Files
postcast-tool/components/CalendarView.js
T

563 lines
21 KiB
JavaScript

'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ChevronLeft, ChevronRight, Calendar, LayoutGrid, List,
RefreshCw, Filter, Clock, CheckCircle2, XCircle,
FileText, AlertCircle } from 'lucide-react';
// ── Константы ─────────────────────────────────────────────────────────────────
const WEEKDAYS = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь',
'Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
const STATUS_META = {
draft: { label: 'Черновик', color: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/30', dot: 'bg-zinc-400' },
scheduled: { label: 'Запланирован',color: 'bg-blue-500/15 text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
published: { label: 'Опубликован', color: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', dot: 'bg-emerald-400' },
failed: { label: 'Ошибка', color: 'bg-red-500/15 text-red-400 border-red-500/30', dot: 'bg-red-400' },
pending: { label: 'Запланирован',color: 'bg-blue-500/15 text-blue-400 border-blue-500/30', dot: 'bg-blue-400' },
};
const SOURCE_LABEL = { user_post: 'Пост', scheduled_post: 'Авто' };
// ── Утилиты ───────────────────────────────────────────────────────────────────
function startOfWeek(date) {
const d = new Date(date);
const day = d.getDay(); // 0=вс
const diff = day === 0 ? -6 : 1 - day; // делаем пн первым
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(date, n) {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
function sameDay(a, b) {
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
}
function formatTime(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleTimeString('ru', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short' });
}
function isoDay(date) {
return date.toISOString().slice(0, 10);
}
// ── EventCard ────────────────────────────────────────────────────────────────
function EventCard({ event, compact = false, draggable = false, onDragStart }) {
const meta = STATUS_META[event.status] || STATUS_META.draft;
return (
<div
draggable={draggable && event.editable}
onDragStart={draggable ? (e) => onDragStart(e, event) : undefined}
className={`
group rounded-md border px-2 py-1 text-xs cursor-default select-none
transition-opacity hover:opacity-90
${meta.color}
${draggable && event.editable ? 'cursor-grab active:cursor-grabbing' : ''}
${compact ? 'truncate' : ''}
`}
title={event.title || event.preview || ''}
>
<div className="flex items-center gap-1 min-w-0">
<span className={`shrink-0 w-1.5 h-1.5 rounded-full ${meta.dot}`} />
{event.scheduled_at && (
<span className="shrink-0 opacity-70">{formatTime(event.scheduled_at)}</span>
)}
<span className="truncate font-medium">
{event.title || event.preview?.slice(0, 60) || '—'}
</span>
{!compact && (
<span className="shrink-0 ml-auto opacity-50 text-[10px]">
{SOURCE_LABEL[event.source] || ''}
</span>
)}
</div>
</div>
);
}
// ── DayCell ───────────────────────────────────────────────────────────────────
function DayCell({ date, events, isToday, isCurrentMonth, onDrop, onDragOver, onDragStart }) {
const MAX_VISIBLE = 3;
const visible = events.slice(0, MAX_VISIBLE);
const hidden = events.length - MAX_VISIBLE;
return (
<div
className={`
min-h-[90px] p-1.5 rounded-lg border transition-colors
${isCurrentMonth ? 'bg-surface border-border' : 'bg-transparent border-transparent opacity-40'}
${isToday ? 'ring-1 ring-accent/60' : ''}
`}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, date)}
>
<div className={`text-xs font-semibold mb-1 w-6 h-6 flex items-center justify-center rounded-full
${isToday ? 'bg-accent text-white' : 'text-text-soft'}`}>
{date.getDate()}
</div>
<div className="flex flex-col gap-0.5">
{visible.map(ev => (
<EventCard key={ev.id} event={ev} compact draggable onDragStart={onDragStart} />
))}
{hidden > 0 && (
<div className="text-[10px] text-text-mute pl-1">+{hidden} ещё</div>
)}
</div>
</div>
);
}
// ── MonthGrid ─────────────────────────────────────────────────────────────────
function MonthGrid({ year, month, eventsByDay, onDrop, onDragOver, onDragStart }) {
const today = new Date();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Заполняем сетку: пн первый день недели
const startPad = ((firstDay.getDay() + 6) % 7); // смещение от пн
const totalCells = Math.ceil((startPad + lastDay.getDate()) / 7) * 7;
const cells = [];
for (let i = 0; i < totalCells; i++) {
const d = new Date(year, month, 1 - startPad + i);
cells.push(d);
}
return (
<div>
<div className="grid grid-cols-7 mb-1">
{WEEKDAYS.map(w => (
<div key={w} className="text-center text-xs font-medium text-text-mute py-1">{w}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((d, i) => (
<DayCell
key={i}
date={d}
events={eventsByDay[isoDay(d)] || []}
isToday={sameDay(d, today)}
isCurrentMonth={d.getMonth() === month}
onDrop={onDrop}
onDragOver={onDragOver}
onDragStart={onDragStart}
/>
))}
</div>
</div>
);
}
// ── WeekGrid ──────────────────────────────────────────────────────────────────
function WeekGrid({ weekStart, eventsByDay, onDrop, onDragOver, onDragStart }) {
const today = new Date();
const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
return (
<div className="grid grid-cols-7 gap-2">
{days.map((d, i) => {
const dayEvents = eventsByDay[isoDay(d)] || [];
return (
<div key={i}
className={`min-h-[200px] rounded-xl border p-2
${sameDay(d, today) ? 'ring-1 ring-accent/60 border-accent/30' : 'border-border'}
bg-surface`}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, d)}
>
<div className={`text-xs font-semibold mb-2 text-center w-7 h-7 mx-auto
flex items-center justify-center rounded-full
${sameDay(d, today) ? 'bg-accent text-white' : 'text-text-soft'}`}>
{d.getDate()}
</div>
<div className="text-[10px] text-text-mute text-center mb-2">
{WEEKDAYS[i]}
</div>
<div className="flex flex-col gap-1">
{dayEvents.map(ev => (
<EventCard key={ev.id} event={ev} draggable onDragStart={onDragStart} />
))}
</div>
</div>
);
})}
</div>
);
}
// ── ListView ──────────────────────────────────────────────────────────────────
function ListView({ events }) {
if (!events.length) {
return (
<div className="card p-12 text-center text-text-mute">
<Calendar className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Нет событий в выбранном периоде</p>
</div>
);
}
// Группируем по дням
const groups = {};
for (const ev of events) {
const key = isoDay(new Date(ev.date));
if (!groups[key]) groups[key] = [];
groups[key].push(ev);
}
return (
<div className="flex flex-col gap-4">
{Object.entries(groups).sort().map(([day, evs]) => {
const d = new Date(day);
const today = new Date();
const isToday = sameDay(d, today);
return (
<div key={day}>
<div className={`text-sm font-semibold mb-2 ${isToday ? 'text-accent' : 'text-text-soft'}`}>
{isToday ? 'Сегодня' : d.toLocaleDateString('ru', { weekday: 'long', day: 'numeric', month: 'long' })}
</div>
<div className="flex flex-col gap-2">
{evs.map(ev => {
const meta = STATUS_META[ev.status] || STATUS_META.draft;
return (
<div key={ev.id} className="card p-3 flex items-start gap-3">
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 ${meta.dot}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm truncate">
{ev.title || ev.preview?.slice(0, 80) || '—'}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full border ${meta.color}`}>
{meta.label}
</span>
<span className="text-xs text-text-mute">
{SOURCE_LABEL[ev.source]}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-text-mute">
<span>{ev.channel_name}</span>
{ev.scheduled_at && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(ev.scheduled_at)}
</span>
)}
{ev.error && (
<span className="text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{ev.error.slice(0, 60)}
</span>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
);
}
// ── Legend ────────────────────────────────────────────────────────────────────
function Legend() {
const items = [
{ status: 'draft', Icon: FileText },
{ status: 'scheduled', Icon: Clock },
{ status: 'published', Icon: CheckCircle2 },
{ status: 'failed', Icon: XCircle },
];
return (
<div className="flex items-center gap-3 flex-wrap">
{items.map(({ status, Icon }) => {
const m = STATUS_META[status];
return (
<div key={status} className="flex items-center gap-1.5 text-xs text-text-soft">
<span className={`w-2 h-2 rounded-full ${m.dot}`} />
{m.label}
</div>
);
})}
</div>
);
}
// ── Главный компонент ─────────────────────────────────────────────────────────
export default function CalendarView({ channels }) {
const today = new Date();
const [view, setView] = useState('month'); // month | week | list
const [currentDate, setCurrentDate] = useState(today);
const [channelFilter, setChannelFilter] = useState('');
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const dragRef = useRef(null); // перетаскиваемое событие
// Вычисляем диапазон для запроса
const getRange = useCallback(() => {
if (view === 'week') {
const ws = startOfWeek(currentDate);
return {
from: isoDay(ws),
to: isoDay(addDays(ws, 6)),
};
}
// month и list — показываем текущий + соседние
return {
from: isoDay(new Date(currentDate.getFullYear(), currentDate.getMonth(), 1)),
to: isoDay(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 0)),
};
}, [view, currentDate]);
const fetchEvents = useCallback(async () => {
setLoading(true);
setError('');
try {
const range = getRange();
const qs = new URLSearchParams(range);
if (channelFilter) qs.set('channel_id', channelFilter);
const res = await fetch(`/api/calendar?${qs}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
setEvents(data.events || []);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, [getRange, channelFilter]);
useEffect(() => { fetchEvents(); }, [fetchEvents]);
// Группировка по дням для grid-видов
const eventsByDay = {};
for (const ev of events) {
const key = isoDay(new Date(ev.date));
if (!eventsByDay[key]) eventsByDay[key] = [];
eventsByDay[key].push(ev);
}
// ── Навигация ──────────────────────────────────────────────────────────────
function navigate(dir) {
const d = new Date(currentDate);
if (view === 'week') {
d.setDate(d.getDate() + dir * 7);
} else {
d.setMonth(d.getMonth() + dir);
}
setCurrentDate(d);
}
function goToday() { setCurrentDate(new Date()); }
function headerLabel() {
if (view === 'week') {
const ws = startOfWeek(currentDate);
const we = addDays(ws, 6);
return `${ws.getDate()}${we.getDate()} ${MONTHS[we.getMonth()]} ${we.getFullYear()}`;
}
return `${MONTHS[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
}
// ── Drag & drop ────────────────────────────────────────────────────────────
function handleDragStart(e, event) {
dragRef.current = event;
e.dataTransfer.effectAllowed = 'move';
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
async function handleDrop(e, targetDate) {
e.preventDefault();
const ev = dragRef.current;
dragRef.current = null;
if (!ev) return;
if (!ev.editable || ev.source !== 'user_post') return;
// Берём время из оригинального scheduled_at, меняем только дату
const orig = ev.scheduled_at ? new Date(ev.scheduled_at) : new Date();
const newDt = new Date(targetDate);
newDt.setHours(orig.getHours(), orig.getMinutes(), 0, 0);
// Оптимистичное обновление
setEvents(prev => prev.map(e2 =>
e2.id === ev.id ? { ...e2, scheduled_at: newDt.toISOString(), date: newDt.toISOString() } : e2
));
try {
const res = await fetch('/api/calendar', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ev.source_id, scheduled_at: newDt.toISOString() }),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error || 'Ошибка переноса');
}
} catch (err) {
alert(`Не удалось перенести: ${err.message}`);
fetchEvents(); // откатываем
}
}
// ── Рендер ────────────────────────────────────────────────────────────────
const viewBtns = [
{ id: 'month', Icon: LayoutGrid, label: 'Месяц' },
{ id: 'week', Icon: Calendar, label: 'Неделя' },
{ id: 'list', Icon: List, label: 'Список' },
];
return (
<div className="flex flex-col gap-4">
{/* ── Тулбар ── */}
<div className="flex flex-wrap items-center gap-3">
{/* Навигация */}
<div className="flex items-center gap-1">
<button onClick={() => navigate(-1)} className="btn-ghost p-2 rounded-lg">
<ChevronLeft className="w-4 h-4" />
</button>
<button onClick={goToday} className="btn-ghost px-3 py-1.5 rounded-lg text-sm font-medium">
Сегодня
</button>
<button onClick={() => navigate(1)} className="btn-ghost p-2 rounded-lg">
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Заголовок периода */}
<h2 className="text-lg font-semibold flex-1 min-w-0">{headerLabel()}</h2>
{/* Фильтр канала */}
{channels.length > 0 && (
<div className="flex items-center gap-1.5">
<Filter className="w-3.5 h-3.5 text-text-mute" />
<select
className="input py-1.5 text-sm w-40"
value={channelFilter}
onChange={e => setChannelFilter(e.target.value)}
>
<option value="">Все каналы</option>
{channels.map(ch => (
<option key={ch.id} value={ch.id}>{ch.name}</option>
))}
</select>
</div>
)}
{/* Переключатель вида */}
<div className="flex items-center gap-0.5 rounded-lg p-0.5 bg-surface2 border border-border">
{viewBtns.map(({ id, Icon, label }) => (
<button
key={id}
onClick={() => setView(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors
${view === id ? 'bg-surface text-text shadow-sm' : 'text-text-soft hover:text-text'}`}
>
<Icon className="w-3.5 h-3.5" />
<span className="hidden sm:inline">{label}</span>
</button>
))}
</div>
{/* Обновить */}
<button onClick={fetchEvents} className="btn-ghost p-2 rounded-lg" title="Обновить">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Легенда */}
<div className="flex items-center justify-between flex-wrap gap-2">
<Legend />
{events.length > 0 && (
<span className="text-xs text-text-mute">{events.length} событий</span>
)}
</div>
{/* Ошибка */}
{error && (
<div className="card p-3 text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
{/* Скелетон загрузки */}
{loading && events.length === 0 && (
<div className="card p-8 text-center text-text-mute animate-pulse">
Загружаю события
</div>
)}
{/* Контент */}
{!loading || events.length > 0 ? (
<>
{view === 'month' && (
<MonthGrid
year={currentDate.getFullYear()}
month={currentDate.getMonth()}
eventsByDay={eventsByDay}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
/>
)}
{view === 'week' && (
<WeekGrid
weekStart={startOfWeek(currentDate)}
eventsByDay={eventsByDay}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragStart={handleDragStart}
/>
)}
{view === 'list' && <ListView events={events} />}
</>
) : null}
{/* Подсказка про drag & drop */}
{(view === 'month' || view === 'week') && (
<p className="text-xs text-text-mute text-center">
Перетащи карточку пользовательского поста на другую дату, чтобы перенести его
</p>
)}
</div>
);
}