1042 lines
34 KiB
TypeScript
1042 lines
34 KiB
TypeScript
import {
|
|
mdiChartTimelineVariant,
|
|
mdiClose,
|
|
mdiFileDocumentPlus,
|
|
mdiSwapHorizontal,
|
|
mdiViewDashboard,
|
|
mdiChevronDown,
|
|
mdiChevronUp,
|
|
mdiPencil,
|
|
} from '@mdi/js';
|
|
import Icon from '@mdi/react';
|
|
import axios from 'axios';
|
|
import Head from 'next/head';
|
|
import { useRouter } from 'next/router';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { toast } from 'react-toastify';
|
|
import BaseButton from './BaseButton';
|
|
import CardBox from './CardBox';
|
|
import CardBoxModal from './CardBoxModal';
|
|
import SectionMain from './SectionMain';
|
|
import SectionTitleLineWithButton from './SectionTitleLineWithButton';
|
|
import { getPageTitle } from '../config';
|
|
import { hasPermission } from '../helpers/userPermissions';
|
|
import { useAppSelector, useAppDispatch } from '../stores/hooks';
|
|
import { logger } from '../lib/logger';
|
|
import { sanitizeSlug, buildUniqueSlug } from '../lib/slugHelpers';
|
|
import {
|
|
toRoutePath,
|
|
compareRoutes,
|
|
getProjectId,
|
|
getRows,
|
|
} from '../lib/tourFlowHelpers';
|
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
|
import {
|
|
fetchByProjectAndEnv,
|
|
upsertByProjectAndEnv,
|
|
deleteByProjectAndEnv,
|
|
selectByProjectAndEnv,
|
|
selectIsLoading as selectTransitionSettingsLoading,
|
|
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
|
import type {
|
|
ProjectTransitionSettings,
|
|
TransitionType,
|
|
EasingFunction,
|
|
} from '../types/transition';
|
|
import { entityToProjectSettings } from '../types/transition';
|
|
|
|
type TourPage = {
|
|
id: string;
|
|
name?: string;
|
|
slug?: string;
|
|
sort_order?: number;
|
|
environment?: string;
|
|
projectId?: string;
|
|
project?: { id?: string } | string;
|
|
};
|
|
|
|
type Transition = {
|
|
id: string;
|
|
name?: string;
|
|
slug?: string;
|
|
environment?: string;
|
|
projectId?: string;
|
|
createdAt?: string;
|
|
project?: { id?: string } | string;
|
|
};
|
|
|
|
type ProjectOption = {
|
|
id: string;
|
|
label: string;
|
|
};
|
|
|
|
type ListEntry = {
|
|
type: 'page' | 'transition';
|
|
id: string;
|
|
title: string;
|
|
routeLabel: string;
|
|
description: string;
|
|
parentPageId: string;
|
|
};
|
|
|
|
const TRANSITION_TYPES: { value: TransitionType | ''; label: string }[] = [
|
|
{ value: '', label: 'Use Global Default' },
|
|
{ value: 'fade', label: 'Fade' },
|
|
{ value: 'none', label: 'None (instant)' },
|
|
];
|
|
|
|
const EASING_OPTIONS: { value: EasingFunction | ''; label: string }[] = [
|
|
{ value: '', label: 'Use Global Default' },
|
|
{ value: 'ease-in-out', label: 'Ease In-Out' },
|
|
{ value: 'ease-in', label: 'Ease In' },
|
|
{ value: 'ease-out', label: 'Ease Out' },
|
|
{ value: 'linear', label: 'Linear' },
|
|
];
|
|
|
|
const TourFlowManager = () => {
|
|
const router = useRouter();
|
|
const dispatch = useAppDispatch();
|
|
const { currentUser } = useAppSelector((state) => state.auth);
|
|
const globalDefaults = useAppSelector(
|
|
(state) => state.global_transition_defaults.data,
|
|
);
|
|
|
|
const routeProjectId = useMemo(() => {
|
|
const value = router.query.projectId;
|
|
if (Array.isArray(value)) return value[0] || '';
|
|
return String(value || '');
|
|
}, [router.query.projectId]);
|
|
|
|
const [pages, setPages] = useState<TourPage[]>([]);
|
|
const [transitions, setTransitions] = useState<Transition[]>([]);
|
|
const [projects, setProjects] = useState<ProjectOption[]>([]);
|
|
const [selectedProjectId, setSelectedProjectId] = useState('');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
|
const [isCreatingTransition, setIsCreatingTransition] = useState(false);
|
|
const [isCreatePageModalActive, setIsCreatePageModalActive] = useState(false);
|
|
const [newPageName, setNewPageName] = useState('');
|
|
const [deletingId, setDeletingId] = useState('');
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
// Edit page modal state
|
|
const [isEditPageModalActive, setIsEditPageModalActive] = useState(false);
|
|
const [editingPageId, setEditingPageId] = useState('');
|
|
const [editPageName, setEditPageName] = useState('');
|
|
const [editPageNameError, setEditPageNameError] = useState('');
|
|
const [isSavingPageName, setIsSavingPageName] = useState(false);
|
|
|
|
// Use selector for current project's dev transition settings
|
|
const projectTransitionSettingsEntity = useAppSelector((state) =>
|
|
selectedProjectId
|
|
? selectByProjectAndEnv(state, selectedProjectId, 'dev')
|
|
: undefined,
|
|
);
|
|
const isTransitionSettingsLoading = useAppSelector((state) =>
|
|
selectedProjectId
|
|
? selectTransitionSettingsLoading(state, selectedProjectId, 'dev')
|
|
: false,
|
|
);
|
|
|
|
// Project transition settings state
|
|
const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] =
|
|
useState(false);
|
|
// Convert entity to camelCase for local form state
|
|
const projectTransitionSettings = useMemo(
|
|
() => entityToProjectSettings(projectTransitionSettingsEntity),
|
|
[projectTransitionSettingsEntity],
|
|
);
|
|
const [localTransitionType, setLocalTransitionType] = useState<
|
|
TransitionType | ''
|
|
>('');
|
|
const [localDurationMs, setLocalDurationMs] = useState<number | ''>('');
|
|
const [localEasing, setLocalEasing] = useState<EasingFunction | ''>('');
|
|
const [localOverlayColor, setLocalOverlayColor] = useState<string>('');
|
|
const [isSavingTransitionSettings, setIsSavingTransitionSettings] =
|
|
useState(false);
|
|
const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false);
|
|
|
|
const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES');
|
|
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
|
const canDeletePage = hasPermission(currentUser, 'DELETE_TOUR_PAGES');
|
|
const canDeleteTransition = hasPermission(currentUser, 'DELETE_TRANSITIONS');
|
|
|
|
const loadProjects = useCallback(async () => {
|
|
const response = await axios.get('/projects/autocomplete?limit=100');
|
|
const projectOptions = Array.isArray(response?.data) ? response.data : [];
|
|
setProjects(projectOptions);
|
|
|
|
if (projectOptions.length === 0) {
|
|
toast('Please create a project first', {
|
|
type: 'info',
|
|
position: 'bottom-center',
|
|
});
|
|
router.replace('/projects/projects-new');
|
|
return '';
|
|
}
|
|
|
|
if (
|
|
routeProjectId &&
|
|
projectOptions.some((item: ProjectOption) => item.id === routeProjectId)
|
|
) {
|
|
setSelectedProjectId(routeProjectId);
|
|
return routeProjectId;
|
|
}
|
|
|
|
if (projectOptions.length > 0) {
|
|
setSelectedProjectId((prev) => prev || projectOptions[0].id);
|
|
return projectOptions[0].id;
|
|
}
|
|
|
|
setSelectedProjectId('');
|
|
return '';
|
|
}, [routeProjectId, router]);
|
|
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
|
|
const projectId = selectedProjectId || (await loadProjects());
|
|
if (!projectId) {
|
|
setPages([]);
|
|
setTransitions([]);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const pagesResponse = await axios.get(
|
|
`/tour_pages?limit=500&sort=asc&field=sort_order&project=${projectId}&environment=dev`,
|
|
);
|
|
|
|
setPages(getRows(pagesResponse));
|
|
setTransitions([]);
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
setErrorMessage(
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to load pages and transitions.',
|
|
);
|
|
logger.error(
|
|
'Failed to load merged pages/transitions list:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [loadProjects, selectedProjectId]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [loadData]);
|
|
|
|
// Fetch global transition defaults
|
|
useEffect(() => {
|
|
dispatch(fetchGlobalTransitionDefaults());
|
|
}, [dispatch]);
|
|
|
|
// Load project transition settings when project changes
|
|
useEffect(() => {
|
|
if (!selectedProjectId) {
|
|
setLocalTransitionType('');
|
|
setLocalDurationMs('');
|
|
setLocalEasing('');
|
|
setLocalOverlayColor('');
|
|
return;
|
|
}
|
|
|
|
// Dispatch fetch for dev environment settings
|
|
dispatch(
|
|
fetchByProjectAndEnv({
|
|
projectId: selectedProjectId,
|
|
environment: 'dev',
|
|
}),
|
|
);
|
|
}, [selectedProjectId, dispatch]);
|
|
|
|
// Sync local form state when store data changes
|
|
useEffect(() => {
|
|
setLocalTransitionType(projectTransitionSettings?.transitionType ?? '');
|
|
setLocalDurationMs(projectTransitionSettings?.durationMs ?? '');
|
|
setLocalEasing(projectTransitionSettings?.easing ?? '');
|
|
setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? '');
|
|
}, [projectTransitionSettings]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedProjectId) return;
|
|
if (routeProjectId && selectedProjectId === routeProjectId) return;
|
|
|
|
if (routeProjectId) return;
|
|
|
|
router.replace(
|
|
{
|
|
pathname: '/tour_pages/tour_pages-list',
|
|
query: { projectId: selectedProjectId },
|
|
},
|
|
undefined,
|
|
{ shallow: true },
|
|
);
|
|
}, [routeProjectId, router, selectedProjectId]);
|
|
|
|
const sortedPages = useMemo(
|
|
() =>
|
|
[...pages].sort((first, second) => {
|
|
const routeCompare = compareRoutes(first.slug, second.slug);
|
|
if (routeCompare !== 0) return routeCompare;
|
|
|
|
const orderCompare = (first.sort_order || 0) - (second.sort_order || 0);
|
|
if (orderCompare !== 0) return orderCompare;
|
|
|
|
return (first.name || '').localeCompare(second.name || '');
|
|
}),
|
|
[pages],
|
|
);
|
|
|
|
const sortedTransitions = useMemo(
|
|
() =>
|
|
[...transitions].sort((first, second) => {
|
|
const dateFirst = first.createdAt
|
|
? new Date(first.createdAt).getTime()
|
|
: 0;
|
|
const dateSecond = second.createdAt
|
|
? new Date(second.createdAt).getTime()
|
|
: 0;
|
|
if (dateFirst !== dateSecond) return dateFirst - dateSecond;
|
|
|
|
return (first.name || first.slug || '').localeCompare(
|
|
second.name || second.slug || '',
|
|
);
|
|
}),
|
|
[transitions],
|
|
);
|
|
|
|
const activeProjectId = useMemo(() => {
|
|
if (selectedProjectId) return selectedProjectId;
|
|
|
|
const fromPages = sortedPages.find((item) => getProjectId(item));
|
|
if (fromPages) return getProjectId(fromPages);
|
|
|
|
const fromTransitions = sortedTransitions.find((item) =>
|
|
getProjectId(item),
|
|
);
|
|
if (fromTransitions) return getProjectId(fromTransitions);
|
|
|
|
return projects[0]?.id || '';
|
|
}, [projects, selectedProjectId, sortedPages, sortedTransitions]);
|
|
|
|
const targetEnvironment = useMemo(
|
|
() =>
|
|
sortedPages[0]?.environment || sortedTransitions[0]?.environment || 'dev',
|
|
[sortedPages, sortedTransitions],
|
|
);
|
|
|
|
const pageSlugsInEnvironment = useMemo(
|
|
() =>
|
|
new Set(
|
|
pages
|
|
.filter((item) => (item.environment || 'dev') === targetEnvironment)
|
|
.map((item) => String(item.slug || '').trim())
|
|
.filter(Boolean),
|
|
),
|
|
[pages, targetEnvironment],
|
|
);
|
|
|
|
const nameValidationError = useMemo(() => {
|
|
const name = newPageName.trim();
|
|
if (!name) return 'Page name is required.';
|
|
if (name.length > 255) return 'Page name must be 255 characters or less.';
|
|
return '';
|
|
}, [newPageName]);
|
|
|
|
// Auto-generate unique slug from name
|
|
const generatedSlug = useMemo(() => {
|
|
const baseSlug = sanitizeSlug(newPageName) || 'page';
|
|
return buildUniqueSlug(baseSlug, pageSlugsInEnvironment);
|
|
}, [newPageName, pageSlugsInEnvironment]);
|
|
|
|
const editNameValidationError = useMemo(() => {
|
|
const name = editPageName.trim();
|
|
if (!name) return 'Page name is required.';
|
|
if (name.length > 255) return 'Page name must be 255 characters or less.';
|
|
return '';
|
|
}, [editPageName]);
|
|
|
|
const nextPageNumber = useMemo(() => pages.length + 1, [pages.length]);
|
|
|
|
const listEntries = useMemo<ListEntry[]>(() => {
|
|
const entries: ListEntry[] = [];
|
|
|
|
sortedPages.forEach((page, index) => {
|
|
const route = toRoutePath(page.slug);
|
|
|
|
entries.push({
|
|
type: 'page',
|
|
id: page.id,
|
|
title: page.name || `Page ${index + 1}`,
|
|
routeLabel: route,
|
|
description: 'Page',
|
|
parentPageId: page.id,
|
|
});
|
|
|
|
const transition = sortedTransitions[index];
|
|
if (!transition) return;
|
|
|
|
entries.push({
|
|
type: 'transition',
|
|
id: transition.id,
|
|
title: transition.name || transition.slug || `Transition ${index + 1}`,
|
|
routeLabel: route,
|
|
description: `Transition after ${route}`,
|
|
parentPageId: page.id,
|
|
});
|
|
});
|
|
|
|
return entries;
|
|
}, [sortedPages, sortedTransitions]);
|
|
|
|
const openConstructor = (
|
|
pageId: string,
|
|
sourceId: string,
|
|
sourceType: 'page' | 'transition',
|
|
) => {
|
|
if (!activeProjectId) {
|
|
router.push('/projects/projects-list');
|
|
return;
|
|
}
|
|
|
|
router.push({
|
|
pathname: '/constructor',
|
|
query: {
|
|
projectId: activeProjectId,
|
|
pageId,
|
|
sourceId,
|
|
sourceType,
|
|
},
|
|
});
|
|
};
|
|
|
|
const openCreatePageModal = () => {
|
|
if (!activeProjectId) {
|
|
setErrorMessage('Project not found. Please create a project first.');
|
|
return;
|
|
}
|
|
|
|
const suggestedName = `Page ${nextPageNumber}`;
|
|
setNewPageName(suggestedName);
|
|
setIsCreatePageModalActive(true);
|
|
};
|
|
|
|
const handleNameChange = (value: string) => {
|
|
setNewPageName(value);
|
|
};
|
|
|
|
const closeCreatePageModal = () => {
|
|
setIsCreatePageModalActive(false);
|
|
setNewPageName('');
|
|
};
|
|
|
|
// Edit page modal handlers
|
|
const openEditPageModal = (event: React.MouseEvent, page: TourPage) => {
|
|
event.stopPropagation();
|
|
setEditingPageId(page.id);
|
|
setEditPageName(page.name || '');
|
|
setEditPageNameError('');
|
|
setIsEditPageModalActive(true);
|
|
};
|
|
|
|
const handleEditNameChange = (value: string) => {
|
|
setEditPageName(value);
|
|
setEditPageNameError('');
|
|
};
|
|
|
|
const closeEditPageModal = () => {
|
|
setIsEditPageModalActive(false);
|
|
setEditingPageId('');
|
|
setEditPageName('');
|
|
setEditPageNameError('');
|
|
};
|
|
|
|
const handleSavePageName = async () => {
|
|
if (editNameValidationError) {
|
|
setEditPageNameError(editNameValidationError);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSavingPageName(true);
|
|
setEditPageNameError('');
|
|
|
|
// Fetch full page data from API to preserve all fields
|
|
// The backend's getFieldMapping converts missing fields to null
|
|
const pageResponse = await axios.get(`/tour_pages/${editingPageId}`);
|
|
const fullPageData = pageResponse.data;
|
|
|
|
if (!fullPageData) {
|
|
setEditPageNameError('Page not found');
|
|
return;
|
|
}
|
|
|
|
// Send all existing fields with only name changed
|
|
await axios.put(`/tour_pages/${editingPageId}`, {
|
|
id: editingPageId,
|
|
data: {
|
|
environment: fullPageData.environment,
|
|
source_key: fullPageData.source_key,
|
|
name: editPageName.trim(),
|
|
slug: fullPageData.slug,
|
|
sort_order: fullPageData.sort_order,
|
|
background_image_url: fullPageData.background_image_url,
|
|
background_video_url: fullPageData.background_video_url,
|
|
background_audio_url: fullPageData.background_audio_url,
|
|
background_loop: fullPageData.background_loop,
|
|
background_video_autoplay: fullPageData.background_video_autoplay,
|
|
background_video_loop: fullPageData.background_video_loop,
|
|
background_video_muted: fullPageData.background_video_muted,
|
|
background_video_start_time: fullPageData.background_video_start_time,
|
|
background_video_end_time: fullPageData.background_video_end_time,
|
|
design_width: fullPageData.design_width,
|
|
design_height: fullPageData.design_height,
|
|
requires_auth: fullPageData.requires_auth,
|
|
ui_schema_json: fullPageData.ui_schema_json,
|
|
},
|
|
});
|
|
|
|
// Update local state
|
|
setPages((prev) =>
|
|
prev.map((page) =>
|
|
page.id === editingPageId
|
|
? { ...page, name: editPageName.trim() }
|
|
: page,
|
|
),
|
|
);
|
|
|
|
closeEditPageModal();
|
|
toast.success('Page name updated');
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
const message =
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to update page name.';
|
|
setEditPageNameError(message);
|
|
logger.error(
|
|
'Failed to update page name:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
} finally {
|
|
setIsSavingPageName(false);
|
|
}
|
|
};
|
|
|
|
const handleCreatePage = async () => {
|
|
if (!activeProjectId) {
|
|
setErrorMessage('Project not found. Please create a project first.');
|
|
return;
|
|
}
|
|
|
|
const name = newPageName.trim();
|
|
|
|
// Validate name
|
|
if (nameValidationError) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsCreatingPage(true);
|
|
setErrorMessage('');
|
|
const payload = {
|
|
project: activeProjectId,
|
|
environment: targetEnvironment,
|
|
source_key: '',
|
|
name,
|
|
slug: generatedSlug,
|
|
sort_order:
|
|
Math.max(...pages.map((item) => Number(item.sort_order || 0)), 0) + 1,
|
|
background_image_url: '',
|
|
background_video_url: '',
|
|
background_audio_url: '',
|
|
background_loop: false,
|
|
requires_auth: false,
|
|
ui_schema_json: '',
|
|
};
|
|
|
|
await axios.post('/tour_pages', { data: payload });
|
|
closeCreatePageModal();
|
|
await loadData();
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
const message =
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to create page.';
|
|
setErrorMessage(message);
|
|
logger.error(
|
|
'Failed to create page:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
} finally {
|
|
setIsCreatingPage(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateTransition = async () => {
|
|
// Transitions are now set directly on navigation elements as transitionVideoUrl
|
|
toast.info('Transitions are configured directly on navigation elements.');
|
|
};
|
|
|
|
const handleSaveTransitionSettings = async () => {
|
|
if (!selectedProjectId) return;
|
|
|
|
setIsSavingTransitionSettings(true);
|
|
setTransitionSaveSuccess(false);
|
|
|
|
try {
|
|
// Check if all values are empty (should delete to use global defaults)
|
|
const hasValues =
|
|
localTransitionType ||
|
|
localDurationMs !== '' ||
|
|
localEasing ||
|
|
localOverlayColor;
|
|
|
|
if (!hasValues) {
|
|
// Delete the settings record to revert to global defaults
|
|
await dispatch(
|
|
deleteByProjectAndEnv({
|
|
projectId: selectedProjectId,
|
|
environment: 'dev',
|
|
}),
|
|
).unwrap();
|
|
} else {
|
|
// Build the settings object with snake_case keys for the backend
|
|
const settingsToSave = {
|
|
transition_type: localTransitionType || 'fade',
|
|
duration_ms:
|
|
localDurationMs !== '' ? (localDurationMs as number) : 700,
|
|
easing: localEasing || 'ease-in-out',
|
|
overlay_color: localOverlayColor || '#000000',
|
|
};
|
|
|
|
await dispatch(
|
|
upsertByProjectAndEnv({
|
|
projectId: selectedProjectId,
|
|
environment: 'dev',
|
|
data: settingsToSave,
|
|
}),
|
|
).unwrap();
|
|
}
|
|
|
|
setTransitionSaveSuccess(true);
|
|
setTimeout(() => setTransitionSaveSuccess(false), 2000);
|
|
} catch (error) {
|
|
logger.error('Failed to save project transition settings:', error);
|
|
toast.error('Failed to save transition settings');
|
|
} finally {
|
|
setIsSavingTransitionSettings(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (
|
|
event: React.MouseEvent,
|
|
id: string,
|
|
type: 'page' | 'transition',
|
|
) => {
|
|
event.stopPropagation();
|
|
|
|
try {
|
|
setDeletingId(id);
|
|
setErrorMessage('');
|
|
|
|
if (type === 'page') {
|
|
await axios.delete(`/tour_pages/${id}`);
|
|
setPages((prev) => prev.filter((item) => item.id !== id));
|
|
}
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { message?: string } };
|
|
};
|
|
setErrorMessage(
|
|
axiosError?.response?.data?.message ||
|
|
(error instanceof Error ? error.message : null) ||
|
|
'Failed to delete item.',
|
|
);
|
|
logger.error(
|
|
'Failed to delete item:',
|
|
error instanceof Error ? error : { error },
|
|
);
|
|
} finally {
|
|
setDeletingId('');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{getPageTitle('Pages & Transitions')}</title>
|
|
</Head>
|
|
<SectionMain>
|
|
<SectionTitleLineWithButton
|
|
icon={mdiChartTimelineVariant}
|
|
title='Pages & Transitions'
|
|
main
|
|
>
|
|
{''}
|
|
</SectionTitleLineWithButton>
|
|
|
|
<p className='mb-6 text-sm font-semibold'>
|
|
{isLoading
|
|
? 'Loading project...'
|
|
: projects.find((project) => project.id === activeProjectId)
|
|
?.label || 'No project selected'}
|
|
</p>
|
|
|
|
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap gap-3'>
|
|
<BaseButton
|
|
color='info'
|
|
icon={mdiFileDocumentPlus}
|
|
label='Create Page'
|
|
onClick={openCreatePageModal}
|
|
disabled={
|
|
!canCreatePage ||
|
|
isCreatingPage ||
|
|
isCreatingTransition ||
|
|
!activeProjectId
|
|
}
|
|
/>
|
|
<BaseButton
|
|
color='info'
|
|
icon={mdiSwapHorizontal}
|
|
label={
|
|
isCreatingTransition
|
|
? 'Creating Transition...'
|
|
: 'Create Transition'
|
|
}
|
|
onClick={handleCreateTransition}
|
|
disabled={
|
|
!canCreateTransition ||
|
|
isCreatingPage ||
|
|
isCreatingTransition ||
|
|
!activeProjectId
|
|
}
|
|
/>
|
|
<BaseButton
|
|
color='lightDark'
|
|
icon={mdiViewDashboard}
|
|
label='To Constructor'
|
|
href={
|
|
activeProjectId
|
|
? `/constructor?projectId=${activeProjectId}`
|
|
: '/projects/projects-list'
|
|
}
|
|
/>
|
|
</CardBox>
|
|
|
|
{/* Project Transition Settings */}
|
|
{selectedProjectId && (
|
|
<CardBox className='mb-6'>
|
|
<button
|
|
type='button'
|
|
className='flex w-full items-center justify-between text-left'
|
|
onClick={() =>
|
|
setIsTransitionSettingsExpanded(!isTransitionSettingsExpanded)
|
|
}
|
|
>
|
|
<h3 className='text-sm font-semibold text-gray-700 dark:text-gray-300'>
|
|
Project Transition Settings
|
|
</h3>
|
|
<Icon
|
|
path={
|
|
isTransitionSettingsExpanded ? mdiChevronUp : mdiChevronDown
|
|
}
|
|
size={0.8}
|
|
className='text-gray-500'
|
|
/>
|
|
</button>
|
|
|
|
{isTransitionSettingsExpanded && (
|
|
<div className='mt-4'>
|
|
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
|
Override global transition defaults for this project (dev
|
|
environment). Changes are copied to Stage when you "Save
|
|
to Stage" and to Production when you "Publish".
|
|
Leave empty to use global defaults.
|
|
{globalDefaults && (
|
|
<span className='ml-1'>
|
|
(Global: {globalDefaults.transition_type},{' '}
|
|
{globalDefaults.duration_ms}ms, {globalDefaults.easing},{' '}
|
|
{globalDefaults.overlay_color ?? '#000000'})
|
|
</span>
|
|
)}
|
|
</p>
|
|
|
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
|
|
<div>
|
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
|
Transition Type
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
|
value={localTransitionType}
|
|
onChange={(e) =>
|
|
setLocalTransitionType(
|
|
e.target.value as TransitionType | '',
|
|
)
|
|
}
|
|
>
|
|
{TRANSITION_TYPES.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
|
Duration (ms)
|
|
</label>
|
|
<input
|
|
type='number'
|
|
min='0'
|
|
placeholder='Use global default'
|
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
|
value={localDurationMs}
|
|
onChange={(e) => {
|
|
const val = e.target.value;
|
|
setLocalDurationMs(
|
|
val === '' ? '' : Math.max(0, parseInt(val, 10) || 0),
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
|
Easing
|
|
</label>
|
|
<select
|
|
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
|
value={localEasing}
|
|
onChange={(e) =>
|
|
setLocalEasing(e.target.value as EasingFunction | '')
|
|
}
|
|
>
|
|
{EASING_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className='mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400'>
|
|
Overlay Color
|
|
</label>
|
|
<div className='flex gap-2'>
|
|
<input
|
|
type='color'
|
|
className='h-9 w-12 cursor-pointer rounded border border-gray-300 p-0.5 dark:border-dark-600'
|
|
value={localOverlayColor || '#000000'}
|
|
onChange={(e) => setLocalOverlayColor(e.target.value)}
|
|
/>
|
|
<input
|
|
type='text'
|
|
placeholder='Use global'
|
|
className='flex-1 rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
|
|
value={localOverlayColor}
|
|
onChange={(e) => setLocalOverlayColor(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='mt-4 flex items-center gap-3'>
|
|
<BaseButton
|
|
label={
|
|
isSavingTransitionSettings ? 'Saving...' : 'Save Settings'
|
|
}
|
|
color='info'
|
|
small
|
|
onClick={handleSaveTransitionSettings}
|
|
disabled={isSavingTransitionSettings}
|
|
/>
|
|
{transitionSaveSuccess && (
|
|
<span className='text-xs text-green-600'>
|
|
Saved successfully!
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardBox>
|
|
)}
|
|
|
|
<CardBoxModal
|
|
title='Create page'
|
|
buttonColor='info'
|
|
buttonLabel={isCreatingPage ? 'Creating...' : 'Create'}
|
|
isConfirmDisabled={Boolean(nameValidationError) || isCreatingPage}
|
|
isActive={isCreatePageModalActive}
|
|
onConfirm={handleCreatePage}
|
|
onCancel={isCreatingPage ? undefined : closeCreatePageModal}
|
|
>
|
|
<div>
|
|
<label
|
|
htmlFor='new-page-name'
|
|
className='block text-sm font-semibold mb-1'
|
|
>
|
|
Page name
|
|
</label>
|
|
<input
|
|
id='new-page-name'
|
|
type='text'
|
|
value={newPageName}
|
|
onChange={(event) => handleNameChange(event.target.value)}
|
|
placeholder='Enter page name'
|
|
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
|
|
autoFocus
|
|
maxLength={255}
|
|
/>
|
|
{nameValidationError && (
|
|
<p className='text-xs text-red-600 mt-1'>{nameValidationError}</p>
|
|
)}
|
|
</div>
|
|
</CardBoxModal>
|
|
|
|
{/* Edit Page Name Modal */}
|
|
<CardBoxModal
|
|
title='Edit page name'
|
|
buttonColor='info'
|
|
buttonLabel={isSavingPageName ? 'Saving...' : 'Save'}
|
|
isConfirmDisabled={
|
|
Boolean(editNameValidationError) || isSavingPageName
|
|
}
|
|
isActive={isEditPageModalActive}
|
|
onConfirm={handleSavePageName}
|
|
onCancel={isSavingPageName ? undefined : closeEditPageModal}
|
|
>
|
|
<div>
|
|
<label
|
|
htmlFor='edit-page-name'
|
|
className='block text-sm font-semibold mb-1'
|
|
>
|
|
Page name
|
|
</label>
|
|
<input
|
|
id='edit-page-name'
|
|
type='text'
|
|
value={editPageName}
|
|
onChange={(event) => handleEditNameChange(event.target.value)}
|
|
placeholder='Enter page name'
|
|
className='w-full border border-gray-300 rounded px-3 py-2 bg-white dark:bg-dark-800'
|
|
autoFocus
|
|
maxLength={255}
|
|
/>
|
|
{(editPageNameError || editNameValidationError) && (
|
|
<p className='text-xs text-red-600 mt-1'>
|
|
{editPageNameError || editNameValidationError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardBoxModal>
|
|
|
|
{errorMessage && (
|
|
<CardBox className='mb-6'>
|
|
<p className='text-sm text-red-600'>{errorMessage}</p>
|
|
</CardBox>
|
|
)}
|
|
|
|
<CardBox>
|
|
{isLoading ? (
|
|
<p className='text-sm text-gray-500'>
|
|
Loading pages and transitions...
|
|
</p>
|
|
) : listEntries.length === 0 ? (
|
|
<p className='text-sm text-gray-500'>
|
|
No pages or transitions yet.
|
|
</p>
|
|
) : (
|
|
<ul className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
|
{listEntries.map((entry) => {
|
|
const canDelete =
|
|
entry.type === 'page' ? canDeletePage : canDeleteTransition;
|
|
const isDeleting = deletingId === entry.id;
|
|
const pageData =
|
|
entry.type === 'page'
|
|
? pages.find((p) => p.id === entry.id)
|
|
: null;
|
|
|
|
return (
|
|
<li key={`${entry.type}-${entry.id}`}>
|
|
<div
|
|
role='button'
|
|
tabIndex={0}
|
|
className='w-full text-left border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors relative cursor-pointer'
|
|
onClick={() =>
|
|
openConstructor(
|
|
entry.parentPageId,
|
|
entry.id,
|
|
entry.type,
|
|
)
|
|
}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
openConstructor(
|
|
entry.parentPageId,
|
|
entry.id,
|
|
entry.type,
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
<div className='pr-20'>
|
|
<p className='text-xs uppercase text-gray-500 mb-1'>
|
|
{entry.description}
|
|
</p>
|
|
<p className='font-semibold'>{entry.title}</p>
|
|
<p className='text-sm text-gray-500 mt-1'>
|
|
Route: {entry.routeLabel}
|
|
</p>
|
|
</div>
|
|
|
|
<div className='absolute top-3 right-3 flex gap-1'>
|
|
{entry.type === 'page' && pageData && (
|
|
<BaseButton
|
|
icon={mdiPencil}
|
|
color='info'
|
|
outline
|
|
small
|
|
onClick={(event) =>
|
|
openEditPageModal(event, pageData)
|
|
}
|
|
/>
|
|
)}
|
|
<BaseButton
|
|
icon={mdiClose}
|
|
color='danger'
|
|
outline
|
|
small
|
|
onClick={(event) =>
|
|
handleDelete(event, entry.id, entry.type)
|
|
}
|
|
disabled={!canDelete || isDeleting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</CardBox>
|
|
</SectionMain>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default TourFlowManager;
|