diff --git a/app/api/calendar/route.js b/app/api/calendar/route.js new file mode 100644 index 0000000..2c65f5c --- /dev/null +++ b/app/api/calendar/route.js @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +// GET /api/calendar?from=&to=&channel_id= +export async function GET(request) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { searchParams } = new URL(request.url); + const params = {}; + if (searchParams.get('from')) params.from = searchParams.get('from'); + if (searchParams.get('to')) params.to = searchParams.get('to'); + if (searchParams.get('channel_id')) params.channel_id = searchParams.get('channel_id'); + + try { + const data = await engine.getCalendar(user.id, params); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} + +// PATCH /api/calendar — reschedule user_post (drag & drop) +export async function PATCH(request) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const { id, scheduled_at } = await request.json(); + if (!id || !scheduled_at) { + return NextResponse.json({ error: 'id and scheduled_at required' }, { status: 400 }); + } + + try { + const post = await engine.updatePost(user.id, id, { scheduled_at, status: 'scheduled' }); + return NextResponse.json(post); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/calendar/page.js b/app/calendar/page.js new file mode 100644 index 0000000..af10414 --- /dev/null +++ b/app/calendar/page.js @@ -0,0 +1,36 @@ +import { redirect } from 'next/navigation'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; +import Header from '@/components/Header'; +import CalendarView from '@/components/CalendarView'; + +export const metadata = { title: 'Календарь публикаций — ZeroPost' }; + +export default async function CalendarPage() { + const user = await requireUser(); + if (!user) redirect('/login'); + + let channels = []; + try { + channels = await engine.listChannels(user.id); + } catch (e) { + console.error('[Calendar] listChannels failed:', e.message); + } + + return ( + <> +
+
+
+
+

Календарь публикаций

+

+ Планируй и отслеживай выход постов по всем каналам +

+
+
+ +
+ + ); +} diff --git a/components/CalendarView.js b/components/CalendarView.js new file mode 100644 index 0000000..65b1d45 --- /dev/null +++ b/components/CalendarView.js @@ -0,0 +1,562 @@ +'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 ( +
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 || ''} + > +
+ + {event.scheduled_at && ( + {formatTime(event.scheduled_at)} + )} + + {event.title || event.preview?.slice(0, 60) || '—'} + + {!compact && ( + + {SOURCE_LABEL[event.source] || ''} + + )} +
+
+ ); +} + +// ── 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 ( +
onDrop(e, date)} + > +
+ {date.getDate()} +
+
+ {visible.map(ev => ( + + ))} + {hidden > 0 && ( +
+{hidden} ещё
+ )} +
+
+ ); +} + +// ── 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 ( +
+
+ {WEEKDAYS.map(w => ( +
{w}
+ ))} +
+
+ {cells.map((d, i) => ( + + ))} +
+
+ ); +} + +// ── WeekGrid ────────────────────────────────────────────────────────────────── + +function WeekGrid({ weekStart, eventsByDay, onDrop, onDragOver, onDragStart }) { + const today = new Date(); + const days = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i)); + + return ( +
+ {days.map((d, i) => { + const dayEvents = eventsByDay[isoDay(d)] || []; + return ( +
onDrop(e, d)} + > +
+ {d.getDate()} +
+
+ {WEEKDAYS[i]} +
+
+ {dayEvents.map(ev => ( + + ))} +
+
+ ); + })} +
+ ); +} + +// ── ListView ────────────────────────────────────────────────────────────────── + +function ListView({ events }) { + if (!events.length) { + return ( +
+ +

Нет событий в выбранном периоде

+
+ ); + } + + // Группируем по дням + const groups = {}; + for (const ev of events) { + const key = isoDay(new Date(ev.date)); + if (!groups[key]) groups[key] = []; + groups[key].push(ev); + } + + return ( +
+ {Object.entries(groups).sort().map(([day, evs]) => { + const d = new Date(day); + const today = new Date(); + const isToday = sameDay(d, today); + return ( +
+
+ {isToday ? 'Сегодня' : d.toLocaleDateString('ru', { weekday: 'long', day: 'numeric', month: 'long' })} +
+
+ {evs.map(ev => { + const meta = STATUS_META[ev.status] || STATUS_META.draft; + return ( +
+
+
+
+ + {ev.title || ev.preview?.slice(0, 80) || '—'} + + + {meta.label} + + + {SOURCE_LABEL[ev.source]} + +
+
+ {ev.channel_name} + {ev.scheduled_at && ( + + + {formatTime(ev.scheduled_at)} + + )} + {ev.error && ( + + + {ev.error.slice(0, 60)} + + )} +
+
+
+ ); + })} +
+
+ ); + })} +
+ ); +} + +// ── Legend ──────────────────────────────────────────────────────────────────── + +function Legend() { + const items = [ + { status: 'draft', Icon: FileText }, + { status: 'scheduled', Icon: Clock }, + { status: 'published', Icon: CheckCircle2 }, + { status: 'failed', Icon: XCircle }, + ]; + return ( +
+ {items.map(({ status, Icon }) => { + const m = STATUS_META[status]; + return ( +
+ + {m.label} +
+ ); + })} +
+ ); +} + +// ── Главный компонент ───────────────────────────────────────────────────────── + +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 ( +
+ + {/* ── Тулбар ── */} +
+ + {/* Навигация */} +
+ + + +
+ + {/* Заголовок периода */} +

{headerLabel()}

+ + {/* Фильтр канала */} + {channels.length > 0 && ( +
+ + +
+ )} + + {/* Переключатель вида */} +
+ {viewBtns.map(({ id, Icon, label }) => ( + + ))} +
+ + {/* Обновить */} + +
+ + {/* Легенда */} +
+ + {events.length > 0 && ( + {events.length} событий + )} +
+ + {/* Ошибка */} + {error && ( +
+ + {error} +
+ )} + + {/* Скелетон загрузки */} + {loading && events.length === 0 && ( +
+ Загружаю события… +
+ )} + + {/* Контент */} + {!loading || events.length > 0 ? ( + <> + {view === 'month' && ( + + )} + {view === 'week' && ( + + )} + {view === 'list' && } + + ) : null} + + {/* Подсказка про drag & drop */} + {(view === 'month' || view === 'week') && ( +

+ Перетащи карточку пользовательского поста на другую дату, чтобы перенести его +

+ )} +
+ ); +} diff --git a/components/Header.js b/components/Header.js index 1c675e9..030a6c3 100644 --- a/components/Header.js +++ b/components/Header.js @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Sparkles, LogOut, Settings2 } from 'lucide-react'; +import { Sparkles, LogOut, Settings2, CalendarDays } from 'lucide-react'; import ThemeToggle from './ThemeToggle'; export default function Header({ user }) { @@ -17,6 +17,12 @@ export default function Header({ user }) { ZeroPost +
{user?.isAdmin && ( call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }), invalidateSettingsCache: () => call('/api/settings/admin/invalidate', { method: 'POST' }), + + // Calendar + getCalendar: (userId, params = {}) => { + const qs = new URLSearchParams(params).toString(); + return call(`/api/calendar${qs ? '?' + qs : ''}`, { userId }); + }, + updateUserPostSchedule: (userId, id, scheduledAt) => + call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: { scheduled_at: scheduledAt } }), };