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 && (
+
+ )}
+
+ {/* Скелетон загрузки */}
+ {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 } }),
};