From 1462e9c5d50dadbf697c03e779627d128eb23560 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:09:19 +0100 Subject: [PATCH] feat(client): add compare selection mode to page history list --- .../public/locales/en-US/translation.json | 6 +- .../components/css/history.module.css | 26 ++- .../page-history/components/history-item.tsx | 188 +++++++++++++----- .../page-history/components/history-list.tsx | 100 +++++++++- 4 files changed, 260 insertions(+), 60 deletions(-) diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 18c981434..12d8bffed 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1289,5 +1289,9 @@ "{{count}} rows deleted_one": "1 row deleted", "{{count}} rows deleted_other": "{{count}} rows deleted", "{{count}} selected_one": "1 selected", - "{{count}} selected_other": "{{count}} selected" + "{{count}} selected_other": "{{count}} selected", + "Compare": "Compare", + "Compare versions": "Compare versions", + "Select version from {{date}}": "Select version from {{date}}", + "Version actions for {{date}}": "Version actions for {{date}}" } diff --git a/apps/client/src/features/page-history/components/css/history.module.css b/apps/client/src/features/page-history/components/css/history.module.css index a4be38194..b69a643d3 100644 --- a/apps/client/src/features/page-history/components/css/history.module.css +++ b/apps/client/src/features/page-history/components/css/history.module.css @@ -1,7 +1,7 @@ .history { - display: block; + display: flex; + align-items: center; width: 100%; - padding: var(--mantine-spacing-md); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); @mixin hover { @@ -12,6 +12,28 @@ } } +.historyButton { + flex: 1; + min-width: 0; + color: inherit; +} + +.compareCheckbox { + padding-left: var(--mantine-spacing-xs); +} + +.itemMenu { + opacity: 0; + margin-right: var(--mantine-spacing-xs); +} + +.history:hover .itemMenu, +.history:focus-within .itemMenu, +.history.active .itemMenu, +.itemMenu[aria-expanded="true"] { + opacity: 1; +} + .historyEditor { :global(.ProseMirror) { padding: 0 !important; diff --git a/apps/client/src/features/page-history/components/history-item.tsx b/apps/client/src/features/page-history/components/history-item.tsx index cc56b1911..2143fc305 100644 --- a/apps/client/src/features/page-history/components/history-item.tsx +++ b/apps/client/src/features/page-history/components/history-item.tsx @@ -1,10 +1,21 @@ -import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core"; +import { + Text, + Group, + UnstyledButton, + Avatar, + Tooltip, + ActionIcon, + Checkbox, + Menu, +} from "@mantine/core"; +import { IconDots } from "@tabler/icons-react"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { formattedDate } from "@/lib/time"; import classes from "./css/history.module.css"; import clsx from "clsx"; import { IPageHistory } from "@/features/page-history/types/page.types"; import { memo, useCallback } from "react"; +import { useTranslation } from "react-i18next"; const MAX_VISIBLE_AVATARS = 5; @@ -15,6 +26,13 @@ interface HistoryItemProps { onHover?: (id: string, index: number) => void; onHoverEnd?: () => void; isActive: boolean; + compareMode: boolean; + isChecked: boolean; + isCheckboxDisabled: boolean; + canCompare: boolean; + onToggleCompare: (id: string) => void; + onStartCompare: (id: string) => void; + onRestore?: (id: string, index: number) => void; } const HistoryItem = memo(function HistoryItem({ @@ -24,10 +42,24 @@ const HistoryItem = memo(function HistoryItem({ onHover, onHoverEnd, isActive, + compareMode, + isChecked, + isCheckboxDisabled, + canCompare, + onToggleCompare, + onStartCompare, + onRestore, }: HistoryItemProps) { + const { t } = useTranslation(); + const date = formattedDate(new Date(historyItem.createdAt)); + const handleClick = useCallback(() => { - onSelect(historyItem.id, index); - }, [onSelect, historyItem.id, index]); + if (compareMode) { + onToggleCompare(historyItem.id); + } else { + onSelect(historyItem.id, index); + } + }, [compareMode, onToggleCompare, onSelect, historyItem.id, index]); const handleMouseEnter = useCallback(() => { onHover?.(historyItem.id, index); @@ -37,63 +69,115 @@ const HistoryItem = memo(function HistoryItem({ const hasContributors = contributors && contributors.length > 0; return ( - - {formattedDate(new Date(historyItem.createdAt))} + {compareMode && ( + onToggleCompare(historyItem.id)} + aria-label={t("Select version from {{date}}", { date })} + /> + )} - - {hasContributors ? ( - <> - - - {contributors.slice(0, MAX_VISIBLE_AVATARS).map((contributor) => ( - - - - ))} - {contributors.length > MAX_VISIBLE_AVATARS && ( - ( -
{c.name}
+ + {date} + + + {hasContributors ? ( + <> + + + {contributors + .slice(0, MAX_VISIBLE_AVATARS) + .map((contributor) => ( + + + ))} - > - - +{contributors.length - MAX_VISIBLE_AVATARS} - -
- )} -
-
- {contributors.length === 1 && ( + {contributors.length > MAX_VISIBLE_AVATARS && ( + ( +
{c.name}
+ ))} + > + + +{contributors.length - MAX_VISIBLE_AVATARS} + +
+ )} + + + {contributors.length === 1 && ( + + {contributors[0].name} + + )} + + ) : ( + <> + - {contributors[0].name} + {historyItem.lastUpdatedBy?.name} + + )} +
+
+ + {!compareMode && ( + + + e.stopPropagation()} + > + + + + + onStartCompare(historyItem.id)} + > + {t("Compare")} + + {onRestore && ( + onRestore(historyItem.id, index)}> + {t("Restore")} + )} - - ) : ( - <> - - - {historyItem.lastUpdatedBy?.name} - - - )} - - + + + )} + ); }); diff --git a/apps/client/src/features/page-history/components/history-list.tsx b/apps/client/src/features/page-history/components/history-list.tsx index 3b6d45dd8..6720dd27a 100644 --- a/apps/client/src/features/page-history/components/history-list.tsx +++ b/apps/client/src/features/page-history/components/history-list.tsx @@ -6,8 +6,12 @@ import HistoryItem from "@/features/page-history/components/history-item"; import { activeHistoryIdAtom, activeHistoryPrevIdAtom, + compareModeAtom, + comparePairAtom, + compareSelectionAtom, historyAtoms, } from "@/features/page-history/atoms/history-atoms"; +import { resolveComparePair } from "@/features/page-history/utils/resolve-compare-pair"; import { useAtom, useSetAtom } from "jotai"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { @@ -32,6 +36,10 @@ function HistoryList({ pageId }: Props) { const [activeHistoryId, setActiveHistoryId] = useAtom(activeHistoryIdAtom); const setActiveHistoryPrevId = useSetAtom(activeHistoryPrevIdAtom); const setHistoryModalOpen = useSetAtom(historyAtoms); + const [compareMode, setCompareMode] = useAtom(compareModeAtom); + const [compareSelection, setCompareSelection] = useAtom(compareSelectionAtom); + // @ts-ignore + const setComparePair = useSetAtom(comparePairAtom); const { data: pageHistoryData, @@ -79,10 +87,60 @@ function HistoryList({ pageId }: Props) { const handleSelect = useCallback( (id: string, index: number) => { + // @ts-ignore + setComparePair(null); setActiveHistoryId(id); setActiveHistoryPrevId(historyItems[index + 1]?.id ?? ""); }, - [historyItems, setActiveHistoryId, setActiveHistoryPrevId], + [historyItems, setActiveHistoryId, setActiveHistoryPrevId, setComparePair], + ); + + const handleToggleCompare = useCallback( + (id: string) => { + setCompareSelection((prev) => { + if (prev.includes(id)) return prev.filter((item) => item !== id); + if (prev.length >= 2) return prev; + return [...prev, id]; + }); + }, + [setCompareSelection], + ); + + const handleStartCompare = useCallback( + (id: string) => { + // @ts-ignore + setComparePair(null); + setCompareMode(true); + setCompareSelection([id]); + }, + [setComparePair, setCompareMode, setCompareSelection], + ); + + const handleCancelCompare = useCallback(() => { + setCompareMode(false); + setCompareSelection([]); + }, [setCompareMode, setCompareSelection]); + + const handleConfirmCompare = useCallback(() => { + const pair = resolveComparePair(historyItems, compareSelection); + if (!pair) return; + setComparePair(pair); + setCompareMode(false); + setCompareSelection([]); + }, [ + historyItems, + compareSelection, + setComparePair, + setCompareMode, + setCompareSelection, + ]); + + const handleRestoreItem = useCallback( + (id: string, index: number) => { + handleSelect(id, index); + confirmRestore(id); + }, + [handleSelect, confirmRestore], ); useEffect(() => { @@ -138,6 +196,16 @@ function HistoryList({ pageId }: Props) { onHover={handleHover} onHoverEnd={clearPrefetchTimeout} isActive={historyItem.id === activeHistoryId} + compareMode={compareMode} + isChecked={compareSelection.includes(historyItem.id)} + isCheckboxDisabled={ + !compareSelection.includes(historyItem.id) && + compareSelection.length >= 2 + } + canCompare={historyItems.length >= 2} + onToggleCompare={handleToggleCompare} + onStartCompare={handleStartCompare} + onRestore={canRestore ? handleRestoreItem : undefined} /> ))} {hasNextPage &&
} @@ -148,22 +216,44 @@ function HistoryList({ pageId }: Props) { )} - {canRestore && ( + {compareMode ? ( <> - + ) : ( + canRestore && ( + <> + + + + + + + ) )}
);