/** * PageHistoryModal — редизайн окна «Page history». * Mantine v7. Light/dark. Полноразмерное модальное окно. * * ───────────────────────────────────────────────────────────────────────── * ЧТО ЭТО * ───────────────────────────────────────────────────────────────────────── * Окно истории версий страницы. Слева — панель навигации: мини-календарь * (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный * список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная * версия страницы с подсветкой изменений. Все контролы — в одну строку шапки. * * Ключевые решения: * - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]). * Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave. * - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо * круглого аватара пользователя + «via <кто запустил>». * - Календарь объединён со списком в одной панели (мини-календарь = date jumper). * - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку. * - Текущая версия помечена; Restore для неё недоступен. * * ───────────────────────────────────────────────────────────────────────── * УСТАНОВКА / ИСПОЛЬЗОВАНИЕ * ───────────────────────────────────────────────────────────────────────── * import { PageHistoryModal, Revision } from './PageHistoryModal'; * * setOpen(false)} * revisions={revisions} // Revision[] * selectedId={selId} * onSelect={setSelId} // клик по ревизии → рендер справа * renderVersion={(rev) => } // ваш рендер страницы * highlightChanges={hl} * onToggleHighlight={setHl} * changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу * onlySaved={onlySaved} * onToggleOnlySaved={setOnlySaved} * onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled * /> * * Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем). * * ───────────────────────────────────────────────────────────────────────── * ФОРМАТ ДАННЫХ * ───────────────────────────────────────────────────────────────────────── * interface Revision { * id: string; * at: Date | string; // время ревизии; форматируется вызывающим/util-ом * dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы * saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа) * author: { name: string } & ( * | { kind: 'human' } * | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил * ); * isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен * } * * Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте. * Никаких изменений API не требуется: агентское авторство берётся из author.kind. * * ───────────────────────────────────────────────────────────────────────── * СОСТОЯНИЯ (нарисованы/поддержаны) * ───────────────────────────────────────────────────────────────────────── * - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая. * - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика). * - Highlight changes: on/off; при off навигация по изменениям неактивна. * - Пустая история: одна версия / нет ревизий → EmptyState. * - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)). */ import { useMemo, useState, useCallback } from 'react'; import { Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon, ScrollArea, useMantineTheme, useMantineColorScheme, } from '@mantine/core'; /* ─────────────────────────── Типы ─────────────────────────── */ export type Author = | { name: string; kind: 'human' } | { name: string; kind: 'agent'; role: string; triggeredBy: string }; export interface Revision { id: string; at: Date | string; atLabel: string; // предформатированное «5:35AM» dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul» saved: boolean; author: Author; isCurrent?: boolean; } export interface CalendarDay { label: string; // «12» inMonth: boolean; count: number; // число ревизий за день (для heatmap) selected?: boolean; date: Date | string; } export interface PageHistoryModalProps { opened: boolean; onClose: () => void; revisions: Revision[]; selectedId: string | null; onSelect: (id: string) => void; renderVersion: (rev: Revision | null) => React.ReactNode; highlightChanges: boolean; onToggleHighlight: (v: boolean) => void; changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void }; onlySaved: boolean; onToggleOnlySaved: (v: boolean) => void; onRestore: (rev: Revision) => Promise; /** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */ calendar?: { monthLabel: string; // «July 2025» weekdays: string[]; // ['Mon',…,'Sun'] days: CalendarDay[]; // обычно 35/42 ячейки onPrevMonth: () => void; onNextMonth: () => void; onToday: () => void; onPickDay: (d: CalendarDay) => void; }; } /* ─────────────────────────── Утилиты представления ─────────────────────────── */ const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678']; function userColor(name: string) { let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; return USER_PALETTE[h % USER_PALETTE.length]; } const AGENT_GRAD: Record = { 'Корректор': 'linear-gradient(135deg,#20c997,#12b886)', 'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)', }; function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; } /** heatmap: число ревизий → фон/текст ячейки календаря. */ function heat(n: number, dark: boolean) { if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' }; if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' }; if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' }; return { bg: '#4c8dff', fg: '#ffffff' }; } /* ─────────────────────────── Строка ревизии ─────────────────────────── */ function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) { const theme = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); const dark = colorScheme === 'dark'; const a = rev.author; const isAgent = a.kind === 'agent'; return ( {/* аватар/глиф */} {isAgent ? ( {a.role[0]} ) : ( {a.name[0].toUpperCase()} )} {/* время */} {rev.atLabel} {/* автор + via */} {isAgent ? a.role : a.name} {isAgent && · via {a.triggeredBy}} {/* бейдж — только SAVED */} {rev.saved && SAVED} ); } /* ─────────────────────────── Мини-календарь ─────────────────────────── */ function MiniCalendar({ cal }: { cal: NonNullable }) { const { colorScheme } = useMantineColorScheme(); const dark = colorScheme === 'dark'; return ( ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> {cal.monthLabel} {cal.weekdays.map((w) => ( {w} ))} {cal.days.map((d, i) => { const h = heat(d.count, dark); return ( cal.onPickDay(d)} style={{ position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', background: d.inMonth ? h.bg : 'transparent', boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined, }} > {d.label} ); })} {/* легенда heatmap */} fewer {['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => ( ))} more revisions ); } /* ─────────────────────────── Окно ─────────────────────────── */ export function PageHistoryModal(props: PageHistoryModalProps) { const { opened, onClose, revisions, selectedId, onSelect, renderVersion, highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved, onRestore, calendar, } = props; const [restoring, setRestoring] = useState(false); const selected = revisions.find((r) => r.id === selectedId) ?? null; const isEmpty = revisions.length <= 1; // группировка по dayGroup, с фильтром Only saved const groups = useMemo(() => { const list = onlySaved ? revisions.filter((r) => r.saved) : revisions; const map: { head: string; items: Revision[] }[] = []; for (const r of list) { let g = map.find((x) => x.head === r.dayGroup); if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); } g.items.push(r); } return map; }, [revisions, onlySaved]); const restore = useCallback(async () => { if (!selected || selected.isCurrent) return; setRestoring(true); try { await onRestore(selected); } finally { setRestoring(false); } }, [selected, onRestore]); return ( {/* ── single-row toolbar ── */} ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> Page history ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} /> {selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'} selected version {/* diff cluster */} ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}> {changeNav && ( <> {changeNav.index} / {changeNav.total} )} ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} /> {/* ── body ── */} {/* left: nav panel */} ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}> ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}> onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} /> {calendar && } {groups.map((g) => ( {g.head} {g.items.map((r) => ( onSelect(r.id)} /> ))} ))} {/* right: rendered version */} {isEmpty ? ( No earlier versions У страницы пока одна версия — сравнивать не с чем. ) : ( {renderVersion(selected)} )} ); } export default PageHistoryModal;