added publishing and saving events notes to appropriate buttons

This commit is contained in:
Dmitri 2026-04-30 12:47:22 +02:00
parent 166dafc217
commit f5f9d3d6fc
7 changed files with 226 additions and 22 deletions

View File

@ -28,6 +28,10 @@ class Publish_eventsDBApi extends GenericDBApi {
return ['from_environment', 'to_environment', 'status']; return ['from_environment', 'to_environment', 'status'];
} }
static get UUID_FIELDS() {
return ['projectId'];
}
static get CSV_FIELDS() { static get CSV_FIELDS() {
return [ return [
'id', 'id',

View File

@ -7,6 +7,8 @@ import { useAppSelector } from '../stores/hooks';
type Props = { type Props = {
label?: string; label?: string;
/** Optional subtitle displayed below the label in smaller text */
subtitle?: string;
icon?: string; icon?: string;
iconSize?: string | number; iconSize?: string | number;
href?: string; href?: string;
@ -26,6 +28,7 @@ type Props = {
export default function BaseButton({ export default function BaseButton({
label, label,
subtitle,
icon, icon,
iconSize, iconSize,
href, href,
@ -78,9 +81,17 @@ export default function BaseButton({
{icon && ( {icon && (
<BaseIcon path={icon} size={iconSize} className={iconClassName} /> <BaseIcon path={icon} size={iconSize} className={iconClassName} />
)} )}
{label && ( {label && !subtitle && (
<span className={small && icon ? 'px-1' : 'px-2'}>{label}</span> <span className={small && icon ? 'px-1' : 'px-2'}>{label}</span>
)} )}
{label && subtitle && (
<span
className={`flex flex-col items-center leading-tight ${small && icon ? 'px-1' : 'px-2'}`}
>
<span>{label}</span>
<span className='text-[10px] opacity-80'>{subtitle}</span>
</span>
)}
</> </>
); );

View File

@ -15,10 +15,10 @@ import {
mdiSwapHorizontal, mdiSwapHorizontal,
mdiText, mdiText,
mdiPlus, mdiPlus,
mdiContentSave,
mdiExitToApp, mdiExitToApp,
} from '@mdi/js'; } from '@mdi/js';
import MenuActionButton from './MenuActionButton'; import MenuActionButton from './MenuActionButton';
import dataFormatter from '../../helpers/dataFormatter';
import type { import type {
Position, Position,
CanvasElementType, CanvasElementType,
@ -41,6 +41,10 @@ interface ConstructorMenuProps {
onSave: () => void; onSave: () => void;
onSaveToStage: () => void; onSaveToStage: () => void;
onExit: () => 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<HTMLDivElement, ConstructorMenuProps>( const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
@ -60,6 +64,8 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
onSave, onSave,
onSaveToStage, onSaveToStage,
onExit, onExit,
lastSavedAt,
lastSavedToStageAt,
}, },
ref, ref,
) => { ) => {
@ -144,23 +150,32 @@ const ConstructorMenu = forwardRef<HTMLDivElement, ConstructorMenuProps>(
/> />
<div className='pt-2 border-t border-gray-200 space-y-1'> <div className='pt-2 border-t border-gray-200 space-y-1'>
<div className='flex gap-1'> <BaseButton
<BaseButton small
small color='info'
color='info' label={isSaving ? 'Saving...' : 'Save'}
label={isSaving ? 'Saving...' : 'Save'} subtitle={
icon={mdiContentSave} lastSavedAt
onClick={onSave} ? dataFormatter.relativeTimestamp(lastSavedAt)
disabled={isSaving} : undefined
/> }
<BaseButton onClick={onSave}
small disabled={isSaving}
color='success' className='w-full'
label={isSavingToStage ? 'Saving...' : 'Save to Stage'} />
onClick={onSaveToStage} <BaseButton
disabled={isSavingToStage} small
/> color='success'
</div> label={isSavingToStage ? 'Saving...' : 'Save to Stage'}
subtitle={
lastSavedToStageAt
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
: undefined
}
onClick={onSaveToStage}
disabled={isSavingToStage}
className='w-full'
/>
<MenuActionButton <MenuActionButton
icon={mdiExitToApp} icon={mdiExitToApp}
label='Exit' label='Exit'

View File

@ -1,6 +1,9 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import _ from 'lodash'; import _ from 'lodash';
dayjs.extend(relativeTime);
const dataFormatter = { const dataFormatter = {
filesFormatter(arr) { filesFormatter(arr) {
if (!arr || !arr.length) return []; if (!arr || !arr.length) return [];
@ -171,6 +174,39 @@ const dataFormatter = {
if (!val) return ''; if (!val) return '';
return { label: val.name, id: val.id }; return { label: val.name, id: val.id };
}, },
/**
* Format date as relative or absolute timestamp.
* Examples: "Just now", "5 min ago", "2 hours ago", "Today at 14:30", "Apr 28 at 16:45"
*/
relativeTimestamp(date) {
if (!date) return '';
const d = dayjs(date);
const now = dayjs();
const diffMinutes = now.diff(d, 'minute');
const diffHours = now.diff(d, 'hour');
// Within last hour
if (diffMinutes < 60) {
return diffMinutes <= 1 ? 'Just now' : `${diffMinutes} min ago`;
}
// Within last 24 hours
if (diffHours < 24) {
return `${diffHours} hour${diffHours > 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; export default dataFormatter;

View File

@ -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<void>;
}
export function usePublishStatus({
projectId,
}: UsePublishStatusOptions): UsePublishStatusResult {
const [lastSavedToStage, setLastSavedToStage] = useState<string | null>(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;

View File

@ -82,6 +82,7 @@ import { usePageBackground } from '../hooks/usePageBackground';
import { useConstructorData } from '../hooks/useConstructorData'; import { useConstructorData } from '../hooks/useConstructorData';
import { useAssetOptions } from '../hooks/useAssetOptions'; import { useAssetOptions } from '../hooks/useAssetOptions';
import { useTransitionCreation } from '../hooks/useTransitionCreation'; import { useTransitionCreation } from '../hooks/useTransitionCreation';
import { usePublishStatus } from '../hooks/usePublishStatus';
import { import {
ConstructorProvider, ConstructorProvider,
type ConstructorContextValue, type ConstructorContextValue,
@ -692,6 +693,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onSuccess: setSuccessMessage, 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(() => { useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return; if (!router.isReady || typeof window === 'undefined') return;
@ -1703,7 +1715,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onAddElement={addElement} onAddElement={addElement}
onCreatePage={createPage} onCreatePage={createPage}
onSave={saveConstructor} onSave={saveConstructor}
onSaveToStage={saveToStage} onSaveToStage={handleSaveToStage}
onExit={() => onExit={() =>
router.push( router.push(
projectId projectId
@ -1711,6 +1723,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: '/projects/projects-list', : '/projects/projects-list',
) )
} }
lastSavedAt={activePage?.updatedAt}
lastSavedToStageAt={lastSavedToStage}
/> />
)} )}
</div> </div>

View File

@ -4,7 +4,6 @@ import {
mdiFileDocumentMultiple, mdiFileDocumentMultiple,
mdiFolderMultipleImage, mdiFolderMultipleImage,
mdiPencil, mdiPencil,
mdiPublish,
mdiViewDashboard, mdiViewDashboard,
mdiWidgets, mdiWidgets,
} from '@mdi/js'; } from '@mdi/js';
@ -23,6 +22,8 @@ import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { usePublishStatus } from '../../hooks/usePublishStatus';
import dataFormatter from '../../helpers/dataFormatter';
type ProjectData = { type ProjectData = {
id: string; id: string;
@ -49,6 +50,12 @@ const ProjectWorkspacePage = () => {
const [publishDescription, setPublishDescription] = useState(''); const [publishDescription, setPublishDescription] = useState('');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
// Publish status for timestamp display
const { lastPublishedToProduction, refresh: refreshPublishStatus } =
usePublishStatus({
projectId,
});
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!projectId) return;
@ -116,6 +123,7 @@ const ProjectWorkspacePage = () => {
setIsPublishModalActive(false); setIsPublishModalActive(false);
setPublishTitle(''); setPublishTitle('');
setPublishDescription(''); setPublishDescription('');
await refreshPublishStatus();
} catch (error: unknown) { } catch (error: unknown) {
logger.error( logger.error(
'Publish failed:', 'Publish failed:',
@ -277,8 +285,12 @@ const ProjectWorkspacePage = () => {
</p> </p>
<BaseButtons> <BaseButtons>
<BaseButton <BaseButton
icon={mdiPublish}
label={isPublishing ? 'Publishing...' : 'Publish to Production'} label={isPublishing ? 'Publishing...' : 'Publish to Production'}
subtitle={
lastPublishedToProduction
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
: undefined
}
color='success' color='success'
onClick={() => setIsPublishModalActive(true)} onClick={() => setIsPublishModalActive(true)}
disabled={isPublishing || !projectId} disabled={isPublishing || !projectId}