diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 375c0d012..d799ef83b 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -1938,6 +1938,7 @@ "timeHintTextInTwelveHour": "01:00 PM", "timeHintTextInTwentyFourHour": "13:00" }, + "creating": "Creating...", "slashMenu": { "board": { @@ -3601,5 +3602,19 @@ "other_users": "Other users ({{count}})", "connecting": "Connecting...", "disconnected": "Disconnected from cloud. Reconnect to sync changes.", - "reconnect": "Reconnect" + "reconnect": "Reconnect", + "versionHistory": { + "versionHistory": "Version History", + "all": "All", + "last7Days": "Last 7 days", + "last30Days": "Last 30 days", + "last60Days": "Last 60 days", + "onlyYours": "Only yours", + "restore": "Restore", + "currentVersion": "Current version", + "restoredFrom": "Restored from {}", + "more": "More...", + "upgrade": "Upgrade", + "forLongerVersionHistory": "for longer version history" + } } diff --git a/src/application/collab-version.type.ts b/src/application/collab-version.type.ts index 8f7c9b4f3..e3ac344b5 100644 --- a/src/application/collab-version.type.ts +++ b/src/application/collab-version.type.ts @@ -11,7 +11,6 @@ export interface CollabVersionRecord { parentId: string | null; name: string | null; createdAt: Date; - snapshot: Uint8Array | null; editors: number[] } @@ -19,4 +18,4 @@ export interface EncodedCollab { stateVector: Uint8Array, docState: Uint8Array, version: string | null -} \ No newline at end of file +} diff --git a/src/application/services/js-services/history.ts b/src/application/services/js-services/history.ts index a3d90d95f..71f0f2739 100644 --- a/src/application/services/js-services/history.ts +++ b/src/application/services/js-services/history.ts @@ -27,5 +27,5 @@ export interface CollabVersion { */ export const collabVersions = async (workspaceId: string, viewId: string) => { //TODO: join editors with user data (preferably cached locally) - await getCollabVersions(workspaceId, viewId) -} \ No newline at end of file + return getCollabVersions(workspaceId, viewId) +} diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index c587b8d5d..04b90229a 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -772,6 +772,7 @@ export async function getCollabVersions(workspaceId: string, objectId: string, s created_at: string, created_by: number | null, is_deleted: boolean + editors: number[], }[]; message: string; }>(url, { @@ -790,13 +791,15 @@ export async function getCollabVersions(workspaceId: string, objectId: string, s parentId: data.parent, name: data.name, createdAt: new Date(data.created_at), - isDeleted: data.is_deleted + isDeleted: data.is_deleted, + editors: data.editors, }; }); } export async function previewCollabVersion(workspaceId: string, objectId: string, version: string, collabType: Types) { const url = `/{workspace_id}/collab/${objectId}/history/${version}?collab_type=${collabType}`; + return await axiosInstance?.get(url, { responseType: 'arraybuffer' }).then((response) => { diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index 7a46800a4..ec392b6c2 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -763,6 +763,10 @@ export class AFClientService implements AFService { return APIService.getCollabVersions(workspaceId, viewId, since); } + async previewCollabVersion(workspaceId: string, viewId: string, versionId: string, collabType: Types) { + return APIService.previewCollabVersion(workspaceId, viewId, versionId, collabType); + } + async createCollabVersion(workspaceId: string, viewId: string, name: string, snapshot: Uint8Array) { return APIService.createCollabVersion(workspaceId, viewId, name, snapshot); } diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 74330ab5d..ccffe656e 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -262,7 +262,8 @@ export interface AIChatService { export interface CollabHistoryService { getCollabHistory: (workspaceId: string, viewId: string, since?: Date) => Promise; + previewCollabVersion: (workspaceId: string, viewId: string, versionId: string, collabType: Types) => Promise; createCollabVersion: (workspaceId: string, viewId: string, name: string, snapshot: Uint8Array) => Promise; deleteCollabVersion: (workspaceId: string, viewId: string, versionId: string) => Promise; revertCollabVersion: (workspaceId: string, viewId: string, collabType: Types, versionId: string) => Promise; -} \ No newline at end of file +} diff --git a/src/assets/icons/crown.svg b/src/assets/icons/crown.svg new file mode 100644 index 000000000..ba41cf6db --- /dev/null +++ b/src/assets/icons/crown.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/icons/info.svg b/src/assets/icons/info.svg new file mode 100644 index 000000000..a161608c4 --- /dev/null +++ b/src/assets/icons/info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/app/app.hooks.tsx b/src/components/app/app.hooks.tsx index d8df7db41..9f9bc3bdd 100644 --- a/src/components/app/app.hooks.tsx +++ b/src/components/app/app.hooks.tsx @@ -3,6 +3,7 @@ import EventEmitter from 'events'; import React, { createContext, useContext } from 'react'; import { Awareness } from 'y-protocols/awareness'; +import { CollabVersionRecord } from '@/application/collab-version.type'; import { AppendBreadcrumb, CreateDatabaseViewPayload, @@ -27,6 +28,7 @@ import { UserWorkspaceInfo, View, ViewIconType, + YDoc, } from '@/application/types'; import LoadingDots from '@/components/_shared/LoadingDots'; import { findView } from '@/components/_shared/outline/utils'; @@ -92,6 +94,9 @@ export interface AppContextType { checkIfRowDocumentExists?: (documentId: string) => Promise; getViewIdFromDatabaseId?: (databaseId: string) => Promise; loadMentionableUsers?: () => Promise; + getCollabHistory?: (viewId: string) => Promise; + previewCollabVersion?: (viewId: string, versionId: string) => Promise; + revertCollabVersion?: (viewId: string, versionId: string) => Promise; } // Main AppContext - same as original @@ -283,6 +288,9 @@ export function useAppHandlers() { updatePageIcon: context.updatePageIcon, updatePageName: context.updatePageName, getViewIdFromDatabaseId: context.getViewIdFromDatabaseId, + getCollabHistory: context.getCollabHistory, + previewCollabVersion: context.previewCollabVersion, + revertCollabVersion: context.revertCollabVersion, loadMentionableUsers: context.loadMentionableUsers, }; } diff --git a/src/components/app/contexts/BusinessInternalContext.ts b/src/components/app/contexts/BusinessInternalContext.ts index 25ccc31e1..69ca5030f 100644 --- a/src/components/app/contexts/BusinessInternalContext.ts +++ b/src/components/app/contexts/BusinessInternalContext.ts @@ -1,5 +1,6 @@ import { createContext, useContext } from 'react'; +import { CollabVersionRecord } from '@/application/collab-version.type'; import { View, TextCount, @@ -23,6 +24,8 @@ import { Subscription, MentionablePerson, UIVariant, + YDoc, + Types, } from '@/application/types'; // Internal context for business layer @@ -101,6 +104,11 @@ export interface BusinessInternalContextType { // Word count wordCount?: Record; setWordCount?: (viewId: string, count: TextCount) => void; + + // Collaboration history + getCollabHistory?: (viewId: string, since?: Date | undefined) => Promise; + previewCollabVersion?: (viewId: string, versionId: string, collabType: Types) => Promise; + revertCollabVersion?: (viewId: string, versionId: string, collabType: Types) => Promise; } export const BusinessInternalContext = createContext(null); diff --git a/src/components/app/contexts/SyncInternalContext.ts b/src/components/app/contexts/SyncInternalContext.ts index 7b71b4f9f..286ec7c54 100644 --- a/src/components/app/contexts/SyncInternalContext.ts +++ b/src/components/app/contexts/SyncInternalContext.ts @@ -14,7 +14,7 @@ export interface SyncInternalContextType { webSocket: AppflowyWebSocketType; // WebSocket connection from useAppflowyWebSocket broadcastChannel: BroadcastChannelType; // BroadcastChannel from useBroadcastChannel registerSyncContext: (params: RegisterSyncContext) => SyncContext; - revertCollabVersion: (viewId: string, version: string) => void; + revertCollabVersion: (viewId: string, version: string) => Promise; eventEmitter: EventEmitter; awarenessMap: Record; lastUpdatedCollab: UpdateCollabInfo | null; @@ -31,4 +31,4 @@ export function useSyncInternal() { } return context; -} \ No newline at end of file +} diff --git a/src/components/app/header/MoreActions.tsx b/src/components/app/header/MoreActions.tsx index 3b76faf26..2b1d6f482 100644 --- a/src/components/app/header/MoreActions.tsx +++ b/src/components/app/header/MoreActions.tsx @@ -8,6 +8,7 @@ import { findViewInShareWithMe } from '@/components/_shared/outline/utils'; import { useAIChatContext } from '@/components/ai-chat/AIChatProvider'; import { useAppOutline, useAppView, useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; import DocumentInfo from '@/components/app/header/DocumentInfo'; +import { DocumentHistoryModal } from '@/components/document/history/DocumentHistoryModal'; import { useService } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { @@ -36,6 +37,7 @@ function MoreActions({ const { selectionMode, onOpenSelectionMode } = useAIChatContext(); const [hasMessages, setHasMessages] = useState(false); const [open, setOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); const outline = useAppOutline(); const view = useAppView(viewId); @@ -93,6 +95,23 @@ function MoreActions({ ) : null; }, [view?.layout, hasMessages, t, onOpenSelectionMode, handleClose]); + const handleOpenHistory = useCallback(() => { + handleClose(); + setHistoryOpen(true); + }, [handleClose]); + + useEffect(() => { + setHistoryOpen(false); + }, [viewId]); + + const showHistory = view?.layout === ViewLayout.Document; + + useEffect(() => { + if (!showHistory && historyOpen) { + setHistoryOpen(false); + } + }, [showHistory, historyOpen]); + const shareWithMeView = useMemo(() => { return findViewInShareWithMe(outline || [], viewId); }, [outline, viewId]); @@ -102,31 +121,37 @@ function MoreActions({ } return ( - - - - - - {ChatOptions} - - {role === Role.Guest || shareWithMeView ? null : ( - <> - { - handleClose(); - }} - onDeleted={onDeleted} - viewId={viewId} - /> - - - )} - - - - + <> + + + + + + {ChatOptions} + + {role === Role.Guest || shareWithMeView ? null : ( + <> + { + handleClose(); + }} + onDeleted={onDeleted} + viewId={viewId} + onOpenHistory={showHistory ? handleOpenHistory : undefined} + /> + + + )} + + + + + {showHistory && ( + + )} + ); } diff --git a/src/components/app/header/MoreActionsContent.tsx b/src/components/app/header/MoreActionsContent.tsx index 43b86e6ce..a5d680125 100644 --- a/src/components/app/header/MoreActionsContent.tsx +++ b/src/components/app/header/MoreActionsContent.tsx @@ -6,24 +6,27 @@ import { ViewLayout } from '@/application/types'; import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; import { ReactComponent as DuplicateIcon } from '@/assets/icons/duplicate.svg'; import { ReactComponent as MoveToIcon } from '@/assets/icons/move_to.svg'; +import { ReactComponent as TimeIcon } from '@/assets/icons/time.svg'; import { findView } from '@/components/_shared/outline/utils'; import { useAppOverlayContext } from '@/components/app/app-overlay/AppOverlayContext'; import { useAppHandlers, useAppOutline, useAppView, useCurrentWorkspaceId } from '@/components/app/app.hooks'; import MovePagePopover from '@/components/app/view-actions/MovePagePopover'; import { useService } from '@/components/main/app.hooks'; -import { DropdownMenuGroup, DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'; import { Progress } from '@/components/ui/progress'; - -function MoreActionsContent ({ itemClicked, viewId }: { +function MoreActionsContent({ + itemClicked, + viewId, + onOpenHistory, +}: { itemClicked?: () => void; onDeleted?: () => void; viewId: string; + onOpenHistory?: () => void; }) { const { t } = useTranslation(); - const { - openDeleteModal, - } = useAppOverlayContext(); + const { openDeleteModal } = useAppOverlayContext(); const service = useService(); const workspaceId = useCurrentWorkspaceId(); const view = useAppView(viewId); @@ -38,9 +41,7 @@ function MoreActionsContent ({ itemClicked, viewId }: { }, [outline, parentViewId]); const [duplicateLoading, setDuplicateLoading] = useState(false); - const { - refreshOutline, - } = useAppHandlers(); + const { refreshOutline } = useAppHandlers(); const handleDuplicateClick = async () => { if (!workspaceId || !service) return; setDuplicateLoading(true); @@ -60,57 +61,74 @@ function MoreActionsContent ({ itemClicked, viewId }: { const [container, setContainer] = useState(null); + const isDocument = layout === ViewLayout.Document; + return ( - -
{ + <> + +
{ + setContainer(el); + }} + /> + + {duplicateLoading ? : } + {t('button.duplicate')} + + {container && ( + + { + e.preventDefault(); + }} + disabled={parentLayout !== ViewLayout.Document} + > + + {t('disclosureAction.moveTo')} + + + )} - setContainer(el); - }} - /> - - {duplicateLoading ? : } - {t('button.duplicate')} - - {container && { - e.preventDefault(); + data-testid='view-action-delete' + variant={'destructive'} + onSelect={() => { + openDeleteModal(viewId); }} - disabled={parentLayout !== ViewLayout.Document} > - - {t('disclosureAction.moveTo')} + + {t('button.delete')} - - } - - { - openDeleteModal(viewId); - }} - > - - {t('button.delete')} - - - + + + {isDocument && ( + + { + event.preventDefault(); + onOpenHistory?.(); + itemClicked?.(); + }} + > + + {t('versionHistory.versionHistory')} + + + )} + ); } -export default MoreActionsContent; \ No newline at end of file +export default MoreActionsContent; diff --git a/src/components/app/hooks/useViewOperations.ts b/src/components/app/hooks/useViewOperations.ts index 39611b9ec..e2ac019a8 100644 --- a/src/components/app/hooks/useViewOperations.ts +++ b/src/components/app/hooks/useViewOperations.ts @@ -1,10 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Awareness } from 'y-protocols/awareness'; +import * as Y from 'yjs'; import { Log } from '@/utils/log'; import { openCollabDB } from '@/application/db'; -import { AccessLevel, DatabaseId, Types, View, ViewId, ViewLayout, YDoc, YjsEditorKey, YSharedRoot } from '@/application/types'; +import { AccessLevel, CollabOrigin, DatabaseId, Types, View, ViewId, ViewLayout, YDoc, YjsEditorKey, YSharedRoot } from '@/application/types'; import { findView, findViewInShareWithMe } from '@/components/_shared/outline/utils'; import { getPlatform } from '@/utils/platform'; @@ -222,7 +223,7 @@ export function useViewOperations() { console.warn('[useViewOperations] View not found in outline, checking Yjs document', { viewId: id }); // Check if the document has a database section (database views) - const sharedRoot = res.getMap(YjsEditorKey.data_section) as YSharedRoot; + const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; const hasDatabase = sharedRoot?.has(YjsEditorKey.database); const hasDocument = sharedRoot?.has(YjsEditorKey.document); @@ -348,6 +349,61 @@ export function useViewOperations() { [currentWorkspaceId, navigate] ); + const getCollabHistory = useCallback( + async (viewId: string, since?: Date) => { + if (!currentWorkspaceId || !service) { + throw new Error('Service not found'); + } + + try { + const versions = await service.getCollabHistory(currentWorkspaceId, viewId, since); + + return versions; + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + + const previewCollabVersion = useCallback( + async (viewId: string, versionId: string, collabType: Types) => { + if (!currentWorkspaceId || !service) { + throw new Error('Service not found'); + } + + try { + const docState = await service.previewCollabVersion(currentWorkspaceId, viewId, versionId, collabType); + + if (!docState) { + return Promise.reject(new Error('No document state returned')); + } + + if (collabType === Types.Document) { + const doc = new Y.Doc(); + + Y.transact( + doc, + () => { + try { + Y.applyUpdate(doc, docState, CollabOrigin.Local); + } catch (e) { + Log.error('Error applying Yjs update for document version preview', e); + throw e; + } + }, + CollabOrigin.Local, + ); + + return doc; + } + } catch (e) { + return Promise.reject(e); + } + }, + [currentWorkspaceId, service] + ); + // Clean up created row documents when view changes useEffect(() => { const rowKeys = createdRowKeys.current; @@ -372,5 +428,7 @@ export function useViewOperations() { awarenessMap, getViewIdFromDatabaseId, getViewReadOnlyStatus, + getCollabHistory, + previewCollabVersion, }; -} \ No newline at end of file +} diff --git a/src/components/app/layers/AppBusinessLayer.tsx b/src/components/app/layers/AppBusinessLayer.tsx index 14ec29aee..2acdcd763 100644 --- a/src/components/app/layers/AppBusinessLayer.tsx +++ b/src/components/app/layers/AppBusinessLayer.tsx @@ -24,7 +24,7 @@ interface AppBusinessLayerProps { // Depends on workspace ID and sync context from previous layers export const AppBusinessLayer: React.FC = ({ children }) => { const { currentWorkspaceId } = useAuthInternal(); - const { lastUpdatedCollab } = useSyncInternal(); + const { lastUpdatedCollab, revertCollabVersion } = useSyncInternal(); const params = useParams(); // UI state @@ -60,7 +60,15 @@ export const AppBusinessLayer: React.FC = ({ children }) } = useWorkspaceData(); // Initialize view operations - const { loadView, createRowDoc, toView, awarenessMap, getViewIdFromDatabaseId } = useViewOperations(); + const { + loadView, + createRowDoc, + toView, + awarenessMap, + getViewIdFromDatabaseId, + getCollabHistory, + previewCollabVersion, + } = useViewOperations(); // Initialize page operations const pageOperations = usePageOperations({ outline, loadOutline }); @@ -256,6 +264,11 @@ export const AppBusinessLayer: React.FC = ({ children }) wordCount: wordCountRef.current, setWordCount, + getCollabHistory, + previewCollabVersion, + revertCollabVersion, + + // Mentionable users loadMentionableUsers, }), [ @@ -288,6 +301,9 @@ export const AppBusinessLayer: React.FC = ({ children }) openPageModal, openModalViewId, setWordCount, + getCollabHistory, + previewCollabVersion, + revertCollabVersion, loadMentionableUsers, ] ); diff --git a/src/components/document/history/DocumentHistoryModal.tsx b/src/components/document/history/DocumentHistoryModal.tsx new file mode 100644 index 000000000..3977d2a0d --- /dev/null +++ b/src/components/document/history/DocumentHistoryModal.tsx @@ -0,0 +1,298 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as Y from 'yjs'; + +import { CollabVersionRecord } from '@/application/collab-version.type'; +import { MentionablePerson, ViewIcon } from '@/application/types'; +import ComponentLoading from '@/components/_shared/progress/ComponentLoading'; +import { useAppHandlers, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { useSubscriptionPlan } from '@/components/app/hooks/useSubscriptionPlan'; +import { Editor } from '@/components/editor'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import { Log } from '@/utils/log'; + +import { VersionList } from './DocumentHistoryVersionList'; + +export function DocumentHistoryModal({ + open, + onOpenChange, + viewId, + view, +}: { + open: boolean; + onOpenChange: (value: boolean) => void; + viewId: string; + view?: { + name: string; + icon: ViewIcon | null; + }; +}) { + const { + loadMentionableUsers, + getSubscriptions, + getCollabHistory, + previewCollabVersion, + revertCollabVersion, + ...props + } = useAppHandlers(); + const workspaceId = useCurrentWorkspaceId(); + const currentUser = useCurrentUser(); + const { isPro } = useSubscriptionPlan(getSubscriptions); + const { t } = useTranslation(); + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedVersionId, setSelectedVersionId] = useState(''); + const [mentionables, setMentionables] = useState([]); + const [dateFilter, setDateFilter] = useState<'all' | 'last7Days' | 'last30Days' | 'last60Days'>('all'); + const [onlyShowMine, setOnlyShowMine] = useState(false); + const previewYDocRef = useRef>(new Map()); + const [activeDoc, setActiveDoc] = useState(null); + const [isRestoring, setIsRestoring] = useState(false); + + const visibleVersions = useMemo(() => { + let filtered = [...versions]; + + if (onlyShowMine && currentUser) { + filtered = filtered.filter((version) => version.editors.some((editor) => editor.toString() === currentUser.uid)); + } + + const now = new Date(); + + filtered = filtered.filter((version) => { + if (dateFilter === 'all') { + return true; + } + + const diffTime = Math.abs(now.getTime() - version.createdAt.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (dateFilter === 'last7Days') { + return diffDays <= 7; + } else if (dateFilter === 'last30Days') { + return diffDays <= 30; + } else if (dateFilter === 'last60Days') { + return diffDays <= 60; + } + + return true; + }); + + if (filtered.length > 0 && !filtered.some((version) => version.versionId === selectedVersionId)) { + setSelectedVersionId(filtered[0].versionId); + } + + return filtered; + }, [versions, onlyShowMine, currentUser, dateFilter, selectedVersionId]); + + const authorMap = useMemo(() => { + const map = new Map(); + + mentionables.forEach((user) => { + map.set(user.person_id, user); + }); + return map; + }, [mentionables]); + + const refreshVersions = useCallback(async () => { + if (!viewId || !getCollabHistory) { + return []; + } + + setLoading(true); + setError(null); + try { + const data = await getCollabHistory(viewId); + + setVersions(data); + return data; + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + return []; + } finally { + setLoading(false); + } + }, [viewId, getCollabHistory]); + + const handleSetDateFilter = useCallback( + (filter: 'all' | 'last7Days' | 'last30Days' | 'last60Days') => { + if (dateFilter === filter) { + return; + } + + setDateFilter(filter); + }, + [dateFilter] + ); + + const refreshAuthors = useCallback(async () => { + if (!open || !loadMentionableUsers) { + return; + } + + try { + const users = await loadMentionableUsers(); + + setMentionables(users ?? []); + } catch (error) { + console.error('Failed to load mentionable users', error); + } + }, [loadMentionableUsers, open]); + + const handleRestore = useCallback(async () => { + if (!viewId || !selectedVersionId || !revertCollabVersion) { + return; + } + + setIsRestoring(true); + setError(null); + try { + void revertCollabVersion(viewId, selectedVersionId); + previewYDocRef.current.clear(); + setActiveDoc(null); + + const updatedVersions = await refreshVersions(); + + if (updatedVersions.length > 0) { + setSelectedVersionId(updatedVersions[0].versionId); + } else { + setSelectedVersionId(''); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + Log.error('Failed to restore document version', err); + setError(message); + } finally { + setIsRestoring(false); + } + }, [viewId, selectedVersionId, revertCollabVersion, refreshVersions]); + + useEffect(() => { + if (!open) { + return; + } + + void refreshVersions(); + }, [open, refreshVersions]); + + useEffect(() => { + if (!open) { + return; + } + + void refreshAuthors(); + }, [open, refreshAuthors]); + + useEffect(() => { + void (async () => { + if (!selectedVersionId) { + Log.warn('No selected version id for previewing version'); + } + + const cachedDoc = previewYDocRef.current.get(selectedVersionId); + + if (cachedDoc) { + setActiveDoc(cachedDoc); + return; + } + + if (!viewId || !previewCollabVersion) { + return; + } + + try { + const doc = await previewCollabVersion(viewId, selectedVersionId); + + if (!doc) { + Log.warn('No doc received for previewing version'); + return; + } + + previewYDocRef.current.set(selectedVersionId, doc); + setActiveDoc(doc); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + // do nothing + } + })(); + }, [previewCollabVersion, selectedVersionId, viewId, workspaceId]); + + useEffect(() => { + if (!open && visibleVersions.length > 0) { + setSelectedVersionId(visibleVersions[0].versionId); + } + }, [open, visibleVersions]); + + const PreviewBody = useMemo(() => { + if (loading) { + return ; + } + + if (error) { + return ; + } + + if (!activeDoc) { + return <>; + } + + return ( +
+ +
+ ); + }, [activeDoc, error, loading, props, viewId, workspaceId]); + + return ( + + +
+ + {view?.name || t('untitled')} + +
{PreviewBody}
+
+
+ onOpenChange(false)} + isPro={isPro} + /> +
+
+
+ ); +} + +function EmptyState({ message }: { message: string }) { + return
{message}
; +} + +export default DocumentHistoryModal; diff --git a/src/components/document/history/DocumentHistoryVersionList.tsx b/src/components/document/history/DocumentHistoryVersionList.tsx new file mode 100644 index 000000000..fca322064 --- /dev/null +++ b/src/components/document/history/DocumentHistoryVersionList.tsx @@ -0,0 +1,225 @@ +import { format } from 'date-fns'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CollabVersionRecord } from '@/application/collab-version.type'; +import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; +import { ReactComponent as CrownIcon } from '@/assets/icons/crown.svg'; +import { ReactComponent as FilterIcon } from '@/assets/icons/filter.svg'; +// import { ReactComponent as InfoIcon } from '@/assets/icons/info.svg'; +import { ReactComponent as TickIcon } from '@/assets/icons/tick.svg'; +import { ReactComponent as TimeIcon } from '@/assets/icons/time.svg'; +import { ReactComponent as UserIcon } from '@/assets/icons/user.svg'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; +import { cn } from '@/lib/utils'; +import { MentionablePerson } from '@/application/types'; + +export function VersionList({ + versions, + selectedVersionId, + onSelect, + isPro, + dateFilter = 'all', + onlyShowMine, + onDateFilterChange, + onOnlyShowMineChange, + onRestoreClicked, + isRestoring = false, + onClose, +}: { + versions: CollabVersionRecord[]; + selectedVersionId: string; + onSelect: (versionId: string) => void; + isPro: boolean; + authorMap: Map; + dateFilter: 'all' | 'last7Days' | 'last30Days' | 'last60Days'; + onlyShowMine: boolean; + onDateFilterChange: (filter: 'all' | 'last7Days' | 'last30Days' | 'last60Days') => void; + onOnlyShowMineChange: (onlyShowMine: boolean) => void; + onRestoreClicked?: () => void; + isRestoring?: boolean; + onClose?: () => void; +}) { + const { t } = useTranslation(); + + return ( +
+
+
+

{t('versionHistory.versionHistory')}

+ {/**/} +
+ + + + + + + { + e.preventDefault(); + onDateFilterChange('all'); + }} + > + + {t('versionHistory.all')} + {dateFilter === 'all' && } + + {isPro && ( + <> + { + e.preventDefault(); + onDateFilterChange('last7Days'); + }} + > + + {t('versionHistory.last7Days')} + {dateFilter === 'last7Days' && } + + { + e.preventDefault(); + onDateFilterChange('last30Days'); + }} + > + + {t('versionHistory.last30Days')} + {dateFilter === 'last30Days' && } + + { + e.preventDefault(); + onDateFilterChange('last60Days'); + }} + > + + {t('versionHistory.last60Days')} + {dateFilter === 'last60Days' && } + + + )} + + + + { + e.preventDefault(); + onOnlyShowMineChange(!onlyShowMine); + }} + > + + {t('versionHistory.onlyYours')} + + + + + + +
+
+ {versions.map((version, index) => { + const createdAt = version.createdAt; + const title = version.name || format(createdAt, 'PPpp'); + + return ( + onSelect(version.versionId)} + /> + ); + })} +
+ {!isPro && ( +
+ + + {t('versionHistory.upgrade')} + + {t('versionHistory.forLongerVersionHistory')} + +
+ )} + +
+ +
+
+ ); +} + +function VersionListItem({ + id, + title, + selected, + isFirst = false, + isLast = false, + onSelect, +}: { + id: string; + title: string; + selected: boolean; + isFirst: boolean; + isLast: boolean; + onSelect: (id: string) => void; +}) { + const [isHovered, setIsHovered] = useState(false); + + return ( + + ); +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 976d9f2d2..f1d72af29 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -22,10 +22,10 @@ const buttonVariants = cva( loading: 'opacity-50 cursor-not-allowed', }, size: { - sm: 'h-7 text-sm px-3 rounded-300 gap-2 font-normal', - default: 'h-8 text-sm px-3 rounded-300 gap-2 font-normal', - lg: 'h-10 rounded-400 text-sm px-4 gap-2 font-medium', - xl: 'h-14 rounded-500 px-4 text-xl gap-2 font-medium', + sm: 'text-sm px-3 py-1 rounded-300 gap-2 font-normal', + default: 'text-sm px-3 py-1.5 rounded-300 gap-2 font-normal', + lg: 'rounded-400 text-sm px-4 py-[10px] gap-2 font-medium', + xl: 'rounded-500 px-4 text-xl py-[14px] gap-2 font-medium', 'icon-sm': 'w-6 h-6 rounded-300 p-0.5 text-icon-primary disabled:text-icon-tertiary', icon: 'w-7 h-7 rounded-300 p-1 text-icon-primary disabled:text-icon-tertiary', 'icon-lg': 'w-8 h-8 rounded-300 p-2 text-icon-primary disabled:text-icon-tertiary', @@ -55,19 +55,7 @@ const Button = React.forwardRef< VariantProps & { asChild?: boolean; } ->(( - { - className, - variant, - size, - loading, - asChild = false, - children, - danger, - ...props - }, - ref, -) => { +>(({ className, variant, size, loading, asChild = false, children, danger, ...props }, ref) => { const Comp = asChild ? Slot : 'button'; return ( diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 1dd20b882..d676ebfa5 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -35,7 +35,7 @@ function DropdownMenuContent({ className, sideOffset = 4, container, forceMount, avoidCollisions className={cn( // Base colors and appearance - 'bg-background-primary text-text-primary', + 'border border-border-primary bg-background-primary text-text-primary', 'z-50 min-w-[240px] rounded-400 p-2 shadow-menu', // Size constraints and overflow behavior @@ -69,7 +69,7 @@ const DropdownMenuGroup = ({ ...props }: React.ComponentProps