Files
gitmost/NewDesign/PageHistoryModal.tsx
T
agent_vscode e4af672c2c feat(ui): add UI components for comments, history, and time
Introduce redesigned UI elements for the new design system:
- **CommentsPanel** – displays agent‑driven edits with diff view, batch actions, and filtering.
- **PageHistoryModal** – full‑screen modal for browsing page revisions, includes a mini‑calendar and version rendering.
- **TimeWorkedModal** – visualizes work and agent sessions per day with grid or phase axes.

These components replace legacy panels, use Mantine v7, support dark/light themes, and provide richer interactions.
2026-07-12 19:09:03 +03:00

363 lines
19 KiB
TypeScript

/**
* PageHistoryModal — редизайн окна «Page history».
* Mantine v7. Light/dark. Полноразмерное модальное окно.
*
* ─────────────────────────────────────────────────────────────────────────
* ЧТО ЭТО
* ─────────────────────────────────────────────────────────────────────────
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
*
* Ключевые решения:
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
* круглого аватара пользователя + «via <кто запустил>».
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
* - Текущая версия помечена; Restore для неё недоступен.
*
* ─────────────────────────────────────────────────────────────────────────
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
* ─────────────────────────────────────────────────────────────────────────
* import { PageHistoryModal, Revision } from './PageHistoryModal';
*
* <PageHistoryModal
* opened={open}
* onClose={() => setOpen(false)}
* revisions={revisions} // Revision[]
* selectedId={selId}
* onSelect={setSelId} // клик по ревизии → рендер справа
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
* 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<void>;
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
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<string, string> = {
'Корректор': '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 (
<Group
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
onClick={onSelect}
style={{
cursor: 'pointer', borderRadius: 7,
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
}}
>
{/* аватар/глиф */}
{isAgent ? (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.role[0]}
</Box>
) : (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.name[0].toUpperCase()}
</Box>
)}
{/* время */}
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
{rev.atLabel}
</Text>
{/* автор + via */}
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
{isAgent ? a.role : a.name}
</Text>
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
</Group>
{/* бейдж — только SAVED */}
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
</Group>
);
}
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
return (
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Group gap={6} mb={6}>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}></ActionIcon>
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}></ActionIcon>
<Box style={{ flex: 1 }} />
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
</Group>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
{cal.weekdays.map((w) => (
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
))}
</Box>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
{cal.days.map((d, i) => {
const h = heat(d.count, dark);
return (
<Box
key={i} onClick={() => 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,
}}
>
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
{d.label}
</Text>
</Box>
);
})}
</Box>
{/* легенда heatmap */}
<Group gap={6} mt={8} align="center">
<Text fz={9.5} c="dimmed">fewer</Text>
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
))}
<Text fz={9.5} c="dimmed">more revisions</Text>
</Group>
</Box>
);
}
/* ─────────────────────────── Окно ─────────────────────────── */
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 (
<Modal
opened={opened} onClose={onClose} withCloseButton={false}
size="80rem" radius="lg" padding={0}
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
>
{/* ── single-row toolbar ── */}
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Text fz={16} fw={600}>Page history</Text>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Stack gap={1} style={{ minWidth: 0 }}>
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
<Text fz={10.5} c="dimmed">selected version</Text>
</Stack>
<Box style={{ flex: 1 }} />
{/* diff cluster */}
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
Highlight changes
</Button>
{changeNav && (
<>
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
<Box style={{ display: 'flex' }}>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}></ActionIcon>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}></ActionIcon>
</Box>
</>
)}
</Group>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
Restore this version
</Button>
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}></ActionIcon>
</Group>
{/* ── body ── */}
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* left: nav panel */}
<Stack gap={0} style={(t) => ({ 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' })}>
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
</Group>
{calendar && <MiniCalendar cal={calendar} />}
<ScrollArea style={{ flex: 1 }}>
{groups.map((g) => (
<Box key={g.head}>
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
{g.head}
</Text>
{g.items.map((r) => (
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
))}
</Box>
))}
</ScrollArea>
</Stack>
{/* right: rendered version */}
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
{isEmpty ? (
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
<Text fw={600} fz={14}>No earlier versions</Text>
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия сравнивать не с чем.</Text>
</Stack>
) : (
<Box p="26px 0" maw={660} mx="auto">
{renderVersion(selected)}
</Box>
)}
</ScrollArea>
</Box>
</Modal>
);
}
export default PageHistoryModal;