From f5f9d3d6fcd4ea15a4218d07f36b682f1a576709 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 30 Apr 2026 12:47:22 +0200 Subject: [PATCH] added publishing and saving events notes to appropriate buttons --- backend/src/db/api/publish_events.js | 4 + frontend/src/components/BaseButton.tsx | 13 +- .../Constructor/ConstructorMenu.tsx | 51 +++++--- frontend/src/helpers/dataFormatter.js | 36 ++++++ frontend/src/hooks/usePublishStatus.ts | 112 ++++++++++++++++++ frontend/src/pages/constructor.tsx | 16 ++- frontend/src/pages/projects/[projectsId].tsx | 16 ++- 7 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 frontend/src/hooks/usePublishStatus.ts diff --git a/backend/src/db/api/publish_events.js b/backend/src/db/api/publish_events.js index be57143..f01e4ea 100644 --- a/backend/src/db/api/publish_events.js +++ b/backend/src/db/api/publish_events.js @@ -28,6 +28,10 @@ class Publish_eventsDBApi extends GenericDBApi { return ['from_environment', 'to_environment', 'status']; } + static get UUID_FIELDS() { + return ['projectId']; + } + static get CSV_FIELDS() { return [ 'id', diff --git a/frontend/src/components/BaseButton.tsx b/frontend/src/components/BaseButton.tsx index d44ea02..427dec0 100644 --- a/frontend/src/components/BaseButton.tsx +++ b/frontend/src/components/BaseButton.tsx @@ -7,6 +7,8 @@ import { useAppSelector } from '../stores/hooks'; type Props = { label?: string; + /** Optional subtitle displayed below the label in smaller text */ + subtitle?: string; icon?: string; iconSize?: string | number; href?: string; @@ -26,6 +28,7 @@ type Props = { export default function BaseButton({ label, + subtitle, icon, iconSize, href, @@ -78,9 +81,17 @@ export default function BaseButton({ {icon && ( )} - {label && ( + {label && !subtitle && ( {label} )} + {label && subtitle && ( + + {label} + {subtitle} + + )} ); diff --git a/frontend/src/components/Constructor/ConstructorMenu.tsx b/frontend/src/components/Constructor/ConstructorMenu.tsx index 12c7b5d..f093932 100644 --- a/frontend/src/components/Constructor/ConstructorMenu.tsx +++ b/frontend/src/components/Constructor/ConstructorMenu.tsx @@ -15,10 +15,10 @@ import { mdiSwapHorizontal, mdiText, mdiPlus, - mdiContentSave, mdiExitToApp, } from '@mdi/js'; import MenuActionButton from './MenuActionButton'; +import dataFormatter from '../../helpers/dataFormatter'; import type { Position, CanvasElementType, @@ -41,6 +41,10 @@ interface ConstructorMenuProps { onSave: () => void; onSaveToStage: () => void; onExit: () => void; + /** Page's last saved timestamp (updatedAt from tour_pages) */ + lastSavedAt?: string | null; + /** Last save-to-stage timestamp */ + lastSavedToStageAt?: string | null; } const ConstructorMenu = forwardRef( @@ -60,6 +64,8 @@ const ConstructorMenu = forwardRef( onSave, onSaveToStage, onExit, + lastSavedAt, + lastSavedToStageAt, }, ref, ) => { @@ -144,23 +150,32 @@ const ConstructorMenu = forwardRef( />
-
- - -
+ + 1 ? 's' : ''} ago`; + } + + // Format as date + time + const isToday = d.isSame(now, 'day'); + const isYesterday = d.isSame(now.subtract(1, 'day'), 'day'); + + const timeStr = d.format('HH:mm'); + + if (isToday) return `Today at ${timeStr}`; + if (isYesterday) return `Yesterday at ${timeStr}`; + + return `${d.format('MMM D')} at ${timeStr}`; + }, }; export default dataFormatter; diff --git a/frontend/src/hooks/usePublishStatus.ts b/frontend/src/hooks/usePublishStatus.ts new file mode 100644 index 0000000..6518654 --- /dev/null +++ b/frontend/src/hooks/usePublishStatus.ts @@ -0,0 +1,112 @@ +/** + * usePublishStatus Hook + * + * Fetches the last publish/save events for a project to display timestamps. + * Follows useDashboardCounts pattern with mountedRef and robust error handling. + */ +import { useState, useEffect, useCallback, useRef } from 'react'; +import axios from 'axios'; +import { logger } from '../lib/logger'; + +interface UsePublishStatusOptions { + /** Project ID to fetch status for */ + projectId: string | null; +} + +interface UsePublishStatusResult { + /** Last successful dev -> stage save timestamp */ + lastSavedToStage: string | null; + /** Last successful stage -> production publish timestamp */ + lastPublishedToProduction: string | null; + /** Whether data is loading */ + isLoading: boolean; + /** Refresh the status */ + refresh: () => Promise; +} + +export function usePublishStatus({ + projectId, +}: UsePublishStatusOptions): UsePublishStatusResult { + const [lastSavedToStage, setLastSavedToStage] = useState(null); + const [lastPublishedToProduction, setLastPublishedToProduction] = useState< + string | null + >(null); + const [isLoading, setIsLoading] = useState(false); + + // Track mounted state to prevent updates after unmount + const mountedRef = useRef(true); + + const fetchStatus = useCallback(async () => { + if (!projectId) { + setLastSavedToStage(null); + setLastPublishedToProduction(null); + return; + } + + setIsLoading(true); + + try { + // Fetch both in parallel for performance + const [stageResponse, prodResponse] = await Promise.all([ + // Last successful dev -> stage event + axios.get('/publish_events', { + params: { + projectId, + from_environment: 'dev', + to_environment: 'stage', + status: 'success', + limit: 1, + field: 'finished_at', + sort: 'desc', + }, + }), + // Last successful stage -> production event + axios.get('/publish_events', { + params: { + projectId, + from_environment: 'stage', + to_environment: 'production', + status: 'success', + limit: 1, + field: 'finished_at', + sort: 'desc', + }, + }), + ]); + + if (!mountedRef.current) return; + + setLastSavedToStage(stageResponse?.data?.rows?.[0]?.finished_at ?? null); + setLastPublishedToProduction( + prodResponse?.data?.rows?.[0]?.finished_at ?? null, + ); + } catch (error) { + if (!mountedRef.current) return; + logger.error( + 'Failed to fetch publish status:', + error instanceof Error ? error : { error }, + ); + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [projectId]); + + useEffect(() => { + mountedRef.current = true; + fetchStatus(); + return () => { + mountedRef.current = false; + }; + }, [fetchStatus]); + + return { + lastSavedToStage, + lastPublishedToProduction, + isLoading, + refresh: fetchStatus, + }; +} + +export default usePublishStatus; diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index be2b709..e1286d7 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -82,6 +82,7 @@ import { usePageBackground } from '../hooks/usePageBackground'; import { useConstructorData } from '../hooks/useConstructorData'; import { useAssetOptions } from '../hooks/useAssetOptions'; import { useTransitionCreation } from '../hooks/useTransitionCreation'; +import { usePublishStatus } from '../hooks/usePublishStatus'; import { ConstructorProvider, type ConstructorContextValue, @@ -692,6 +693,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { onSuccess: setSuccessMessage, }); + // Publish status for timestamp display + const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({ + projectId, + }); + + // Wrap saveToStage to refresh publish status after save + const handleSaveToStage = useCallback(async () => { + await saveToStage(); + await refreshPublishStatus(); + }, [saveToStage, refreshPublishStatus]); + useEffect(() => { if (!router.isReady || typeof window === 'undefined') return; @@ -1703,7 +1715,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { onAddElement={addElement} onCreatePage={createPage} onSave={saveConstructor} - onSaveToStage={saveToStage} + onSaveToStage={handleSaveToStage} onExit={() => router.push( projectId @@ -1711,6 +1723,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { : '/projects/projects-list', ) } + lastSavedAt={activePage?.updatedAt} + lastSavedToStageAt={lastSavedToStage} /> )}
diff --git a/frontend/src/pages/projects/[projectsId].tsx b/frontend/src/pages/projects/[projectsId].tsx index 8529c95..a9d7827 100644 --- a/frontend/src/pages/projects/[projectsId].tsx +++ b/frontend/src/pages/projects/[projectsId].tsx @@ -4,7 +4,6 @@ import { mdiFileDocumentMultiple, mdiFolderMultipleImage, mdiPencil, - mdiPublish, mdiViewDashboard, mdiWidgets, } from '@mdi/js'; @@ -23,6 +22,8 @@ import SectionMain from '../../components/SectionMain'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import { getPageTitle } from '../../config'; import { logger } from '../../lib/logger'; +import { usePublishStatus } from '../../hooks/usePublishStatus'; +import dataFormatter from '../../helpers/dataFormatter'; type ProjectData = { id: string; @@ -49,6 +50,12 @@ const ProjectWorkspacePage = () => { const [publishDescription, setPublishDescription] = useState(''); const [errorMessage, setErrorMessage] = useState(''); + // Publish status for timestamp display + const { lastPublishedToProduction, refresh: refreshPublishStatus } = + usePublishStatus({ + projectId, + }); + useEffect(() => { if (!projectId) return; @@ -116,6 +123,7 @@ const ProjectWorkspacePage = () => { setIsPublishModalActive(false); setPublishTitle(''); setPublishDescription(''); + await refreshPublishStatus(); } catch (error: unknown) { logger.error( 'Publish failed:', @@ -277,8 +285,12 @@ const ProjectWorkspacePage = () => {

setIsPublishModalActive(true)} disabled={isPublishing || !projectId}