fixed global and project settings for global contrils buttons

This commit is contained in:
Dmitri 2026-06-29 11:11:55 +02:00
parent cf3deb14f7
commit 5fc54b1894
15 changed files with 1325 additions and 366 deletions

View File

@ -188,7 +188,9 @@ function checkCrudPermissions(name) {
}
// Dynamically determine the permission name (e.g., 'READ_USERS')
const permissionName = `${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
const permissionName =
req.permissionNameOverride ||
`${METHOD_MAP[req.method]}_${name.toUpperCase()}`;
// Call the checkPermissions middleware with the determined permission
return checkPermissions(permissionName)(req, res, next);
};

View File

@ -25,9 +25,17 @@ const allowPublicRead = (req, _res, next) => {
return next();
};
const authenticateWrites = (req, res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
return next();
}
return jwtAuth(req, res, next);
};
// Apply public read first, then CRUD permission checks
router.use(allowPublicRead);
router.use(checkCrudPermissions('global_transition_defaults'));
router.use(authenticateWrites);
router.use(checkCrudPermissions('page_elements'));
/**
* @swagger
@ -120,7 +128,6 @@ router.get(
*/
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
assertRouteIdMatchesBody(req);
await Global_transition_defaultsService.update({

View File

@ -15,8 +15,16 @@ const allowPublicRead = (req, _res, next) => {
return next();
};
const authenticateWrites = (req, res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
return next();
}
return jwtAuth(req, res, next);
};
router.use(allowPublicRead);
router.use(checkCrudPermissions('global_ui_control_defaults'));
router.use(authenticateWrites);
router.use(checkCrudPermissions('page_elements'));
router.get(
'/',
@ -42,7 +50,6 @@ router.get(
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Global_ui_control_defaultsService.update({
id: req.params.id,

View File

@ -26,6 +26,23 @@ const allowAuthenticatedRead = (req, _res, next) => {
return next();
};
const authenticateWrites = (req, res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
return next();
}
return jwtAuth(req, res, next);
};
const useUpdatePermissionForEnvironmentReset = (req, _res, next) => {
if (
req.method === 'DELETE' &&
/^\/project\/[^/]+\/env\/[^/]+\/?$/.test(req.path)
) {
req.permissionNameOverride = 'UPDATE_PAGE_ELEMENTS';
}
return next();
};
/**
* Middleware: Production GET is public, everything else requires JWT.
* Determines public access from URL path, not headers.
@ -105,7 +122,9 @@ const requireProductionOrAuth = async (req, res, next) => {
// Mark reads as public first, then apply CRUD permission checks
router.use(allowAuthenticatedRead);
router.use(checkCrudPermissions('project_transition_settings'));
router.use(authenticateWrites);
router.use(useUpdatePermissionForEnvironmentReset);
router.use(checkCrudPermissions('page_elements'));
/**
* @swagger
@ -214,7 +233,6 @@ router.get(
*/
router.put(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
@ -264,7 +282,6 @@ router.put(
*/
router.delete(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
@ -339,7 +356,6 @@ router.get(
*/
router.post(
'/',
jwtAuth,
wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsService.create({
data: req.body.data,
@ -414,7 +430,6 @@ router.get(
*/
router.put(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
assertRouteIdMatchesBody(req);
await Project_transition_settingsService.update({
@ -448,7 +463,6 @@ router.put(
*/
router.delete(
'/:id',
jwtAuth,
wrapAsync(async (req, res) => {
await Project_transition_settingsService.remove({
id: req.params.id,

View File

@ -17,6 +17,23 @@ const allowAuthenticatedRead = (req, _res, next) => {
return next();
};
const authenticateWrites = (req, res, next) => {
if (['GET', 'OPTIONS'].includes(req.method)) {
return next();
}
return jwtAuth(req, res, next);
};
const useUpdatePermissionForEnvironmentReset = (req, _res, next) => {
if (
req.method === 'DELETE' &&
/^\/project\/[^/]+\/env\/[^/]+\/?$/.test(req.path)
) {
req.permissionNameOverride = 'UPDATE_PAGE_ELEMENTS';
}
return next();
};
const getRuntimeProjectSlug = async (req) => {
if (req.runtimeContext?.headerProjectSlug) {
return req.runtimeContext.headerProjectSlug;
@ -87,7 +104,9 @@ const requireProductionOrAuth = async (req, res, next) => {
};
router.use(allowAuthenticatedRead);
router.use(checkCrudPermissions('project_ui_control_settings'));
router.use(authenticateWrites);
router.use(useUpdatePermissionForEnvironmentReset);
router.use(checkCrudPermissions('page_elements'));
router.get(
'/project/:projectId/env/:environment',
@ -116,7 +135,6 @@ router.get(
router.put(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;
@ -141,7 +159,6 @@ router.put(
router.delete(
'/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => {
const { projectId, environment } = req.params;

View File

@ -0,0 +1,77 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const RolesDBApi = require('../src/db/api/roles');
const AccessPolicy = require('../src/services/access-policy');
const originalFindBy = RolesDBApi.findBy;
RolesDBApi.findBy = async () => ({
id: 'public-role',
name: 'Public',
permissions: [],
});
const { checkCrudPermissions } = require('../src/middlewares/check-permissions');
test.after(() => {
RolesDBApi.findBy = originalFindBy;
});
test('checkCrudPermissions honors explicit permission override', async () => {
const originalHasPermission = AccessPolicy.hasPermission;
const seenPermissions = [];
AccessPolicy.hasPermission = async (_user, permission) => {
seenPermissions.push(permission);
return permission === 'UPDATE_PAGE_ELEMENTS';
};
try {
const req = {
method: 'DELETE',
path: '/project/project-id/env/dev',
currentUser: { id: 'user-1' },
permissionNameOverride: 'UPDATE_PAGE_ELEMENTS',
};
await new Promise((resolve, reject) => {
checkCrudPermissions('page_elements')(req, {}, (error) => {
if (error) reject(error);
else resolve();
});
});
assert.deepEqual(seenPermissions, ['UPDATE_PAGE_ELEMENTS']);
} finally {
AccessPolicy.hasPermission = originalHasPermission;
}
});
test('checkCrudPermissions keeps default method-derived permission without override', async () => {
const originalHasPermission = AccessPolicy.hasPermission;
const seenPermissions = [];
AccessPolicy.hasPermission = async (_user, permission) => {
seenPermissions.push(permission);
return permission === 'DELETE_PAGE_ELEMENTS';
};
try {
const req = {
method: 'DELETE',
path: '/project/project-id/env/dev',
currentUser: { id: 'user-1' },
};
await new Promise((resolve, reject) => {
checkCrudPermissions('page_elements')(req, {}, (error) => {
if (error) reject(error);
else resolve();
});
});
assert.deepEqual(seenPermissions, ['DELETE_PAGE_ELEMENTS']);
} finally {
AccessPolicy.hasPermission = originalHasPermission;
}
});

View File

@ -38,12 +38,6 @@ import {
selectByProjectAndEnv,
selectIsLoading as selectTransitionSettingsLoading,
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
import {
fetchByProjectAndEnv as fetchUiControlsByProjectAndEnv,
upsertByProjectAndEnv as upsertUiControlsByProjectAndEnv,
deleteByProjectAndEnv as deleteUiControlsByProjectAndEnv,
selectByProjectAndEnv as selectUiControlsByProjectAndEnv,
} from '../stores/project_ui_control_settings/projectUiControlSettingsSlice';
import type {
ProjectTransitionSettings,
TransitionType,
@ -142,12 +136,6 @@ const TourFlowManager = () => {
? selectTransitionSettingsLoading(state, selectedProjectId, 'dev')
: false,
);
const projectUiControlsSettingsEntity = useAppSelector((state) =>
selectedProjectId
? selectUiControlsByProjectAndEnv(state, selectedProjectId, 'dev')
: undefined,
);
// Project transition settings state
const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] =
useState(false);
@ -166,9 +154,6 @@ const TourFlowManager = () => {
useState(false);
const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false);
const [isUiControlsExpanded, setIsUiControlsExpanded] = useState(false);
const [uiControlsJson, setUiControlsJson] = useState('');
const [isSavingUiControls, setIsSavingUiControls] = useState(false);
const [uiControlsSaveSuccess, setUiControlsSaveSuccess] = useState(false);
const canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES');
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
@ -269,12 +254,6 @@ const TourFlowManager = () => {
environment: 'dev',
}),
);
dispatch(
fetchUiControlsByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
}),
);
}, [selectedProjectId, dispatch]);
// Sync local form state when store data changes
@ -285,16 +264,6 @@ const TourFlowManager = () => {
setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? '');
}, [projectTransitionSettings]);
useEffect(() => {
setUiControlsJson(
JSON.stringify(
projectUiControlsSettingsEntity?.settings_json || {},
null,
2,
),
);
}, [projectUiControlsSettingsEntity]);
useEffect(() => {
if (!selectedProjectId) return;
if (routeProjectId && selectedProjectId === routeProjectId) return;
@ -672,41 +641,6 @@ const TourFlowManager = () => {
}
};
const handleSaveUiControlsSettings = async () => {
if (!selectedProjectId) return;
setIsSavingUiControls(true);
setUiControlsSaveSuccess(false);
try {
const parsed = JSON.parse(uiControlsJson || '{}');
if (!parsed || Object.keys(parsed).length === 0) {
await dispatch(
deleteUiControlsByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
}),
).unwrap();
} else {
await dispatch(
upsertUiControlsByProjectAndEnv({
projectId: selectedProjectId,
environment: 'dev',
data: { settings_json: parsed },
}),
).unwrap();
}
setUiControlsSaveSuccess(true);
setTimeout(() => setUiControlsSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save project UI controls settings:', error);
toast.error('Failed to save UI controls settings');
} finally {
setIsSavingUiControls(false);
}
};
const handleDelete = async (
event: React.MouseEvent,
id: string,
@ -962,30 +896,14 @@ const TourFlowManager = () => {
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Override fixed-size fullscreen, sound, and offline button
defaults for this project in dev. Positions are
canvas-relative percentages; dimensions are CSS pixels. Empty
JSON reverts to global defaults.
canvas-relative percentages.
</p>
<textarea
className='min-h-[180px] w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs dark:border-dark-600 dark:bg-dark-800'
value={uiControlsJson}
onChange={(event) => setUiControlsJson(event.target.value)}
<BaseButton
href={`/project-ui-control-settings?projectId=${selectedProjectId}&environment=dev`}
label='Open UI Controls Settings'
color='info'
small
/>
<div className='mt-4 flex items-center gap-3'>
<BaseButton
label={
isSavingUiControls ? 'Saving...' : 'Save UI Controls'
}
color='info'
small
onClick={handleSaveUiControlsSettings}
disabled={isSavingUiControls}
/>
{uiControlsSaveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
</div>
</div>
)}
</CardBox>

View File

@ -0,0 +1,338 @@
import React, { useState } from 'react';
import ElementSettingsTabs from '../ElementSettings/ElementSettingsTabs';
import type {
SystemUiControlAnchor,
SystemUiControlSettings,
SystemUiControlType,
UiControlsSettings,
} from '../../types/uiControls';
import { resolveUiControlsSettings } from '../../types/uiControls';
const CONTROL_TYPES: SystemUiControlType[] = ['offline', 'fullscreen', 'sound'];
const CONTROL_LABELS: Record<SystemUiControlType, string> = {
offline: 'Offline',
fullscreen: 'Fullscreen',
sound: 'Sound',
};
const ANCHORS: SystemUiControlAnchor[] = [
'center',
'top-left',
'top-right',
'bottom-left',
'bottom-right',
];
const TABS = [
{ id: 'general', label: 'General Settings' },
{ id: 'css', label: 'CSS Styles' },
{ id: 'effects', label: 'Effects' },
];
type ControlField = keyof SystemUiControlSettings;
type Props = {
value: UiControlsSettings | null | undefined;
fallbackValue?: UiControlsSettings | null;
onChange: (value: UiControlsSettings) => void;
disabled?: boolean;
controlTypes?: SystemUiControlType[];
};
const toEditableSettings = (
value: UiControlsSettings | null | undefined,
fallbackValue?: UiControlsSettings | null,
controlTypes: SystemUiControlType[] = CONTROL_TYPES,
): UiControlsSettings => {
const resolved = resolveUiControlsSettings(fallbackValue, value);
return controlTypes.reduce((acc, type) => {
acc[type] = { ...resolved[type] };
return acc;
}, {} as UiControlsSettings);
};
const toNumber = (value: string) => {
if (value.trim() === '') return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const toInputNumberValue = (value: unknown) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return Number(value.toFixed(4)).toString();
};
const inputClass =
'w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800';
const labelClass = 'mb-1 block text-xs font-medium text-gray-600';
export default function UiControlsSettingsForm({
value,
fallbackValue,
onChange,
disabled = false,
controlTypes = CONTROL_TYPES,
}: Props) {
const [activeTab, setActiveTab] = useState('general');
const settings = toEditableSettings(value, fallbackValue, controlTypes);
const updateControl = (
type: SystemUiControlType,
field: ControlField,
nextValue: SystemUiControlSettings[ControlField],
) => {
onChange({
...(value || {}),
[type]: {
...settings[type],
[field]: nextValue,
},
});
};
const renderNumberInput = (
type: SystemUiControlType,
field: ControlField,
label: string,
options: {
min?: string;
max?: string;
step?: string;
} = {},
) => {
const control = settings[type] || {};
return (
<div>
<label className={labelClass}>{label}</label>
<input
type='number'
className={inputClass}
value={toInputNumberValue(control[field])}
disabled={disabled}
min={options.min}
max={options.max}
step={options.step}
onChange={(event) =>
updateControl(type, field, toNumber(event.target.value))
}
/>
</div>
);
};
const renderTextInput = (
type: SystemUiControlType,
field: ControlField,
label: string,
) => {
const control = settings[type] || {};
return (
<div>
<label className={labelClass}>{label}</label>
<input
type='text'
className={inputClass}
value={(control[field] as string | undefined) || ''}
disabled={disabled}
onChange={(event) => updateControl(type, field, event.target.value)}
/>
</div>
);
};
const renderColorInput = (
type: SystemUiControlType,
field: ControlField,
label: string,
fallback: string,
) => {
const control = settings[type] || {};
return (
<div>
<label className={labelClass}>{label}</label>
<input
type='color'
className='h-10 w-full cursor-pointer rounded border border-gray-300 p-1 dark:border-dark-600'
value={(control[field] as string | undefined) || fallback}
disabled={disabled}
onChange={(event) => updateControl(type, field, event.target.value)}
/>
</div>
);
};
return (
<div>
<ElementSettingsTabs
activeTab={activeTab}
onTabChange={setActiveTab}
tabs={TABS}
/>
<div className='space-y-4'>
{controlTypes.map((type) => {
const control = settings[type] || {};
return (
<div
key={type}
className='rounded border border-gray-200 p-4 dark:border-dark-700'
>
<div className='mb-4 flex flex-wrap items-center justify-between gap-3'>
<h4 className='text-sm font-semibold text-gray-800 dark:text-gray-200'>
{CONTROL_LABELS[type]}
</h4>
{activeTab === 'general' && (
<div className='flex gap-4 text-xs text-gray-600 dark:text-gray-400'>
<label className='inline-flex items-center gap-2'>
<input
type='checkbox'
checked={control.enabled !== false}
disabled={disabled}
onChange={(event) =>
updateControl(type, 'enabled', event.target.checked)
}
/>
Enabled
</label>
<label className='inline-flex items-center gap-2'>
<input
type='checkbox'
checked={control.hidden === true}
disabled={disabled}
onChange={(event) =>
updateControl(type, 'hidden', event.target.checked)
}
/>
Hidden
</label>
</div>
)}
</div>
{activeTab === 'general' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
{renderNumberInput(type, 'xPercent', 'X Position (%)', {
min: '0',
max: '100',
step: 'any',
})}
{renderNumberInput(type, 'yPercent', 'Y Position (%)', {
min: '0',
max: '100',
step: 'any',
})}
<div>
<label className={labelClass}>Anchor</label>
<select
className={inputClass}
value={control.anchor || 'center'}
disabled={disabled}
onChange={(event) =>
updateControl(
type,
'anchor',
event.target.value as SystemUiControlAnchor,
)
}
>
{ANCHORS.map((anchor) => (
<option key={anchor} value={anchor}>
{anchor}
</option>
))}
</select>
</div>
{renderNumberInput(type, 'order', 'Order', { step: '1' })}
<div className='md:col-span-2'>
{renderTextInput(
type,
'defaultIconUrl',
'Default Icon URL',
)}
</div>
<div className='md:col-span-2'>
{renderTextInput(type, 'activeIconUrl', 'Active Icon URL')}
</div>
</div>
)}
{activeTab === 'css' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
{renderNumberInput(
type,
'buttonSizePercent',
'Button Size (%)',
{
min: '0',
step: 'any',
},
)}
{renderNumberInput(type, 'iconSizePercent', 'Icon Size (%)', {
min: '0',
step: 'any',
})}
{renderNumberInput(
type,
'borderRadiusPercent',
'Radius (%)',
{
min: '0',
step: 'any',
},
)}
{renderColorInput(type, 'color', 'Icon Color', '#FFFFFF')}
{renderColorInput(
type,
'defaultBackgroundColor',
'Default Background',
'#2563EB',
)}
{renderColorInput(
type,
'activeBackgroundColor',
'Active Background',
'#2563EB',
)}
{renderColorInput(
type,
'hoverBackgroundColor',
'Hover Background',
'#1D4ED8',
)}
{renderColorInput(
type,
'defaultBorderColor',
'Default Border',
'#2563EB',
)}
{renderColorInput(
type,
'activeBorderColor',
'Active Border',
'#2563EB',
)}
</div>
)}
{activeTab === 'effects' && (
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
{renderNumberInput(type, 'opacity', 'Opacity', {
min: '0',
max: '1',
step: 'any',
})}
{renderNumberInput(type, 'zIndex', 'Z Index', {
step: '1',
})}
{renderTextInput(type, 'boxShadow', 'Box Shadow')}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@ -7,24 +7,12 @@ import CardBox from '../components/CardBox';
import LayoutAuthenticated from '../layouts/Authenticated';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import BaseButton from '../components/BaseButton';
import { getPageTitle } from '../config';
import { logger } from '../lib/logger';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import {
fetch as fetchGlobalTransitionDefaults,
update as updateGlobalTransitionDefaults,
} from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
import {
fetch as fetchGlobalUiControlDefaults,
update as updateGlobalUiControlDefaults,
} from '../stores/global_ui_control_defaults/globalUiControlDefaultsSlice';
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
import type { UiElementDefault } from '../types/constructor';
import type {
GlobalTransitionDefaults,
TransitionType,
EasingFunction,
} from '../types/transition';
import type { SystemUiControlType } from '../types/uiControls';
type ElementTypeDefault = UiElementDefault & {
element_type: string;
@ -38,96 +26,68 @@ const toHumanLabel = (value: string) =>
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const TRANSITION_TYPES: { value: TransitionType; label: string }[] = [
{ value: 'fade', label: 'Fade' },
{ value: 'none', label: 'None (instant)' },
const UI_CONTROL_ITEMS: Array<{
type: SystemUiControlType;
title: string;
description: string;
order: number;
}> = [
{
type: 'offline',
title: 'Offline Button',
description: 'Offline mode runtime control.',
order: 1,
},
{
type: 'fullscreen',
title: 'Fullscreen Button',
description: 'Fullscreen runtime control.',
order: 2,
},
{
type: 'sound',
title: 'Sound Button',
description: 'Mute and sound runtime control.',
order: 3,
},
];
const EASING_OPTIONS: { value: EasingFunction; label: string }[] = [
{ value: 'ease-in-out', label: 'Ease In-Out' },
{ value: 'ease-in', label: 'Ease In' },
{ value: 'ease-out', label: 'Ease Out' },
{ value: 'linear', label: 'Linear' },
];
type SettingsSectionHeaderProps = {
title: string;
description: string;
actions?: React.ReactNode;
};
const SettingsSectionHeader = ({
title,
description,
actions,
}: SettingsSectionHeaderProps) => (
<div className='mb-5 flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 pb-4 dark:border-dark-700'>
<div>
<h3 className='mb-1 text-base font-semibold text-gray-800 dark:text-gray-100'>
{title}
</h3>
<p className='text-sm text-gray-500 dark:text-gray-400'>{description}</p>
</div>
{actions && <div className='flex items-center gap-2'>{actions}</div>}
</div>
);
const ElementTypeDefaultsPage = () => {
const dispatch = useAppDispatch();
const globalDefaults = useAppSelector(
(state) => state.global_transition_defaults.data,
);
const globalLoading = useAppSelector(
(state) => state.global_transition_defaults.loading,
);
const globalUiControlDefaults = useAppSelector(
(state) => state.global_ui_control_defaults.data,
);
// Local state for global transition defaults editing
const [localTransitionType, setLocalTransitionType] =
useState<TransitionType>('fade');
const [localDurationMs, setLocalDurationMs] = useState<number>(700);
const [localEasing, setLocalEasing] = useState<EasingFunction>('ease-in-out');
const [localOverlayColor, setLocalOverlayColor] = useState<string>('#000000');
const [isSavingGlobal, setIsSavingGlobal] = useState(false);
const [globalSaveSuccess, setGlobalSaveSuccess] = useState(false);
const [uiControlsJson, setUiControlsJson] = useState('');
const [isSavingUiControls, setIsSavingUiControls] = useState(false);
const [uiControlsSaveSuccess, setUiControlsSaveSuccess] = useState(false);
const [rows, setRows] = useState<ElementTypeDefault[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
// Load global transition defaults
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
dispatch(fetchGlobalUiControlDefaults());
}, [dispatch]);
// Sync local state when global defaults are loaded
useEffect(() => {
if (globalDefaults) {
setLocalTransitionType(globalDefaults.transition_type);
setLocalDurationMs(globalDefaults.duration_ms);
setLocalEasing(globalDefaults.easing);
setLocalOverlayColor(globalDefaults.overlay_color ?? '#000000');
}
}, [globalDefaults]);
useEffect(() => {
if (globalUiControlDefaults) {
setUiControlsJson(
JSON.stringify(globalUiControlDefaults.settings_json || {}, null, 2),
);
}
}, [globalUiControlDefaults]);
const handleSaveGlobalDefaults = async () => {
if (!globalDefaults?.id) return;
setIsSavingGlobal(true);
setGlobalSaveSuccess(false);
try {
await dispatch(
updateGlobalTransitionDefaults({
id: globalDefaults.id,
data: {
transition_type: localTransitionType,
duration_ms: localDurationMs,
easing: localEasing,
overlay_color: localOverlayColor,
},
}),
).unwrap();
setGlobalSaveSuccess(true);
setTimeout(() => setGlobalSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save global transition defaults:', error);
} finally {
setIsSavingGlobal(false);
}
};
const loadRows = useCallback(async () => {
try {
setIsLoading(true);
@ -160,30 +120,6 @@ const ElementTypeDefaultsPage = () => {
}
}, []);
const handleSaveGlobalUiControls = async () => {
if (!globalUiControlDefaults?.id) return;
setIsSavingUiControls(true);
setUiControlsSaveSuccess(false);
try {
const parsed = JSON.parse(uiControlsJson || '{}');
await dispatch(
updateGlobalUiControlDefaults({
id: globalUiControlDefaults.id,
data: {
settings_json: parsed,
},
}),
).unwrap();
setUiControlsSaveSuccess(true);
setTimeout(() => setUiControlsSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save global UI control defaults:', error);
} finally {
setIsSavingUiControls(false);
}
};
useEffect(() => {
loadRows();
}, [loadRows]);
@ -202,144 +138,73 @@ const ElementTypeDefaultsPage = () => {
{''}
</SectionTitleLineWithButton>
{/* Global Transition Defaults Section */}
<CardBox className='mb-6'>
<h3 className='mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300'>
Global Transition Defaults
</h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
These settings apply to all page transitions unless overridden at
the project or element level.
</p>
{globalLoading && !globalDefaults ? (
<p className='text-sm text-gray-500'>
Loading global transition defaults...
</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'
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) =>
setLocalDurationMs(
Math.max(0, parseInt(e.target.value, 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}
onChange={(e) => setLocalOverlayColor(e.target.value)}
/>
<input
type='text'
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)}
placeholder='#000000'
/>
</div>
</div>
</div>
)}
<div className='mt-4 flex items-center gap-3'>
<BaseButton
label={isSavingGlobal ? 'Saving...' : 'Save Global Defaults'}
color='info'
small
onClick={handleSaveGlobalDefaults}
disabled={isSavingGlobal || !globalDefaults}
/>
{globalSaveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
</div>
</CardBox>
<CardBox className='mb-6'>
<h3 className='mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300'>
Global UI Controls Defaults
</h3>
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
Fixed-size fullscreen, sound, and offline buttons. Positions are
canvas-relative percentages; dimensions are CSS pixels.
</p>
<textarea
className='min-h-[220px] w-full rounded border border-gray-300 px-3 py-2 font-mono text-xs dark:border-dark-600 dark:bg-dark-800'
value={uiControlsJson}
onChange={(event) => setUiControlsJson(event.target.value)}
<SettingsSectionHeader
title='Global UI Controls Defaults'
description='Fullscreen, sound, and offline runtime controls.'
/>
<div className='mt-4 flex items-center gap-3'>
<BaseButton
label={isSavingUiControls ? 'Saving...' : 'Save UI Controls'}
color='info'
small
onClick={handleSaveGlobalUiControls}
disabled={isSavingUiControls || !globalUiControlDefaults}
/>
{uiControlsSaveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
<div className='divide-y divide-gray-100 rounded border border-gray-200 dark:divide-dark-700 dark:border-dark-700'>
{UI_CONTROL_ITEMS.map((item) => (
<Link
key={item.type}
href={`/global-ui-control-defaults/${item.type}`}
className='flex flex-wrap items-center justify-between gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-dark-800'
>
<div>
<p className='text-sm font-semibold text-gray-800 dark:text-gray-100'>
{item.title}
</p>
<p className='text-xs text-gray-500 dark:text-gray-400'>
{item.description}
</p>
</div>
<div className='flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span>Order {item.order}</span>
<span className='rounded border border-gray-200 px-2 py-1 dark:border-dark-700'>
Active
</span>
</div>
</Link>
))}
</div>
</CardBox>
{/* Element Types List */}
<CardBox className='mb-6'>
<SettingsSectionHeader
title='Global Transition Defaults'
description='Default transition behavior for pages unless overridden at project or element level.'
/>
<Link
href='/global-transition-defaults'
className='flex flex-wrap items-center justify-between gap-3 rounded border border-gray-200 px-4 py-3 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-800'
>
<div>
<p className='text-sm font-semibold text-gray-800 dark:text-gray-100'>
Page Transition
</p>
<p className='text-xs text-gray-500 dark:text-gray-400'>
{globalDefaults
? `${toHumanLabel(globalDefaults.transition_type)}${globalDefaults.duration_ms} ms • ${globalDefaults.easing}`
: 'Loading transition defaults...'}
</p>
</div>
<span className='rounded border border-gray-200 px-2 py-1 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-400'>
Active
</span>
</Link>
</CardBox>
<CardBox>
<SettingsSectionHeader
title='Element Type Defaults'
description='Default settings used when new constructor elements are created.'
actions={
<span className='rounded border border-gray-200 px-3 py-1 text-xs text-gray-500 dark:border-dark-700 dark:text-gray-400'>
{rows.length} types
</span>
}
/>
{isLoading ? (
<p className='text-sm text-gray-500'>
Loading element type defaults...
@ -351,21 +216,27 @@ const ElementTypeDefaultsPage = () => {
No element type defaults found.
</p>
) : (
<div className='space-y-2'>
<div className='divide-y divide-gray-100 rounded border border-gray-200 dark:divide-dark-700 dark:border-dark-700'>
{rows.map((item) => (
<Link
key={item.id}
href={`/element-type-defaults/${item.id}`}
className='block rounded border border-gray-200 px-3 py-2 hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-800'
className='flex flex-wrap items-center justify-between gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-dark-800'
>
<p className='text-sm font-semibold'>
{item.name || toHumanLabel(item.element_type)}
</p>
<p className='text-xs text-gray-500'>
{toHumanLabel(item.element_type)} Order{' '}
{Number(item.sort_order || 0)} {' '}
{item.is_active ? 'Active' : 'Inactive'}
</p>
<div>
<p className='text-sm font-semibold text-gray-800 dark:text-gray-100'>
{item.name || toHumanLabel(item.element_type)}
</p>
<p className='text-xs text-gray-500 dark:text-gray-400'>
{toHumanLabel(item.element_type)}
</p>
</div>
<div className='flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span>Order {Number(item.sort_order || 0)}</span>
<span className='rounded border border-gray-200 px-2 py-1 dark:border-dark-700'>
{item.is_active ? 'Active' : 'Inactive'}
</span>
</div>
</Link>
))}
</div>

View File

@ -0,0 +1,221 @@
import { mdiContentSave, mdiSwapHorizontal } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement, useEffect, useState } from 'react';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { logger } from '../lib/logger';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import {
fetch as fetchGlobalTransitionDefaults,
update as updateGlobalTransitionDefaults,
} from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
import type { EasingFunction, TransitionType } from '../types/transition';
const TRANSITION_TYPES: { value: TransitionType; label: string }[] = [
{ value: 'fade', label: 'Fade' },
{ value: 'none', label: 'None (instant)' },
];
const EASING_OPTIONS: { value: EasingFunction; label: string }[] = [
{ 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 GlobalTransitionDefaultsPage = () => {
const dispatch = useAppDispatch();
const defaults = useAppSelector(
(state) => state.global_transition_defaults.data,
);
const isLoading = useAppSelector(
(state) => state.global_transition_defaults.loading,
);
const [transitionType, setTransitionType] = useState<TransitionType>('fade');
const [durationMs, setDurationMs] = useState<number>(700);
const [easing, setEasing] = useState<EasingFunction>('ease-in-out');
const [overlayColor, setOverlayColor] = useState<string>('#000000');
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
useEffect(() => {
if (!defaults) return;
setTransitionType(defaults.transition_type);
setDurationMs(defaults.duration_ms);
setEasing(defaults.easing);
setOverlayColor(defaults.overlay_color ?? '#000000');
}, [defaults]);
const handleSave = async () => {
if (!defaults?.id) return;
setIsSaving(true);
setSaveSuccess(false);
try {
await dispatch(
updateGlobalTransitionDefaults({
id: defaults.id,
data: {
transition_type: transitionType,
duration_ms: durationMs,
easing,
overlay_color: overlayColor,
},
}),
).unwrap();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save global transition defaults:', error);
} finally {
setIsSaving(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Global Transition Defaults')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiSwapHorizontal}
title='Global Transition Defaults'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<Link
href='/element-type-defaults'
className='inline-flex rounded border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50'
>
Back to Element Type Defaults
</Link>
<div className='flex items-center gap-3'>
{saveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
<BaseButton
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
color='info'
onClick={handleSave}
disabled={isSaving || isLoading || !defaults}
/>
</div>
</div>
</CardBox>
<CardBox>
{isLoading && !defaults ? (
<p className='text-sm text-gray-500'>
Loading global transition defaults...
</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={transitionType}
onChange={(event) =>
setTransitionType(event.target.value as TransitionType)
}
>
{TRANSITION_TYPES.map((option) => (
<option key={option.value} value={option.value}>
{option.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'
className='w-full rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={durationMs}
onChange={(event) =>
setDurationMs(
Math.max(0, parseInt(event.target.value, 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={easing}
onChange={(event) =>
setEasing(event.target.value as EasingFunction)
}
>
{EASING_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.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={overlayColor}
onChange={(event) => setOverlayColor(event.target.value)}
/>
<input
type='text'
className='flex-1 rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={overlayColor}
onChange={(event) => setOverlayColor(event.target.value)}
placeholder='#000000'
/>
</div>
</div>
</div>
)}
</CardBox>
</SectionMain>
</>
);
};
GlobalTransitionDefaultsPage.getLayout = function getLayout(
page: ReactElement,
) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default GlobalTransitionDefaultsPage;

View File

@ -0,0 +1,103 @@
import { mdiCog } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import React, { ReactElement } from 'react';
import CardBox from '../components/CardBox';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import type { SystemUiControlType } from '../types/uiControls';
const CONTROL_ITEMS: Array<{
type: SystemUiControlType;
title: string;
description: string;
order: number;
}> = [
{
type: 'offline',
title: 'Offline Button',
description: 'Offline mode runtime control.',
order: 1,
},
{
type: 'fullscreen',
title: 'Fullscreen Button',
description: 'Fullscreen runtime control.',
order: 2,
},
{
type: 'sound',
title: 'Sound Button',
description: 'Mute and sound runtime control.',
order: 3,
},
];
const GlobalUiControlDefaultsListPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Global UI Controls Defaults')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiCog}
title='Global UI Controls Defaults'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6'>
<Link
href='/element-type-defaults'
className='inline-flex rounded border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50'
>
Back to Element Type Defaults
</Link>
</CardBox>
<CardBox>
<div className='divide-y divide-gray-100 rounded border border-gray-200 dark:divide-dark-700 dark:border-dark-700'>
{CONTROL_ITEMS.map((item) => (
<Link
key={item.type}
href={`/global-ui-control-defaults/${item.type}`}
className='flex flex-wrap items-center justify-between gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-dark-800'
>
<div>
<p className='text-sm font-semibold text-gray-800 dark:text-gray-100'>
{item.title}
</p>
<p className='text-xs text-gray-500 dark:text-gray-400'>
{item.description}
</p>
</div>
<div className='flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400'>
<span>Order {item.order}</span>
<span className='rounded border border-gray-200 px-2 py-1 dark:border-dark-700'>
Active
</span>
</div>
</Link>
))}
</div>
</CardBox>
</SectionMain>
</>
);
};
GlobalUiControlDefaultsListPage.getLayout = function getLayout(
page: ReactElement,
) {
return (
<LayoutAuthenticated permission='READ_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default GlobalUiControlDefaultsListPage;

View File

@ -0,0 +1,153 @@
import { mdiCog, mdiContentSave } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import UiControlsSettingsForm from '../../components/UiControls/UiControlsSettingsForm';
import { getPageTitle } from '../../config';
import LayoutAuthenticated from '../../layouts/Authenticated';
import { logger } from '../../lib/logger';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import {
fetch as fetchGlobalUiControlDefaults,
update as updateGlobalUiControlDefaults,
} from '../../stores/global_ui_control_defaults/globalUiControlDefaultsSlice';
import type {
SystemUiControlType,
UiControlsSettings,
} from '../../types/uiControls';
const CONTROL_TYPES: SystemUiControlType[] = ['offline', 'fullscreen', 'sound'];
const CONTROL_LABELS: Record<SystemUiControlType, string> = {
offline: 'Offline Button',
fullscreen: 'Fullscreen Button',
sound: 'Sound Button',
};
const GlobalUiControlDefaultEditPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const defaults = useAppSelector(
(state) => state.global_ui_control_defaults.data,
);
const isLoading = useAppSelector(
(state) => state.global_ui_control_defaults.loading,
);
const controlType = useMemo<SystemUiControlType | null>(() => {
const value = Array.isArray(router.query.controlType)
? router.query.controlType[0]
: router.query.controlType;
return CONTROL_TYPES.includes(value as SystemUiControlType)
? (value as SystemUiControlType)
: null;
}, [router.query.controlType]);
const [settings, setSettings] = useState<UiControlsSettings>({});
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
dispatch(fetchGlobalUiControlDefaults());
}, [dispatch]);
useEffect(() => {
setSettings(defaults?.settings_json || {});
}, [defaults]);
const handleSave = async () => {
if (!defaults?.id || !controlType) return;
setIsSaving(true);
setSaveSuccess(false);
try {
await dispatch(
updateGlobalUiControlDefaults({
id: defaults.id,
data: { settings_json: settings },
}),
).unwrap();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save global UI control defaults:', error);
} finally {
setIsSaving(false);
}
};
const title = controlType
? CONTROL_LABELS[controlType]
: 'Global UI Control Defaults';
return (
<>
<Head>
<title>{getPageTitle(title)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiCog} title={title} main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<Link
href='/element-type-defaults/'
className='inline-flex rounded border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50'
>
Back to Element Type Defaults
</Link>
<div className='flex items-center gap-3'>
{saveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
<BaseButton
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
color='info'
onClick={handleSave}
disabled={isSaving || isLoading || !defaults || !controlType}
/>
</div>
</div>
</CardBox>
<CardBox>
{!controlType ? (
<p className='text-sm text-red-600'>Unknown UI control type.</p>
) : isLoading && !defaults ? (
<p className='text-sm text-gray-500'>
Loading global UI control defaults...
</p>
) : (
<UiControlsSettingsForm
value={settings}
onChange={setSettings}
disabled={isSaving}
controlTypes={[controlType]}
/>
)}
</CardBox>
</SectionMain>
</>
);
};
GlobalUiControlDefaultEditPage.getLayout = function getLayout(
page: ReactElement,
) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default GlobalUiControlDefaultEditPage;

View File

@ -0,0 +1,228 @@
import { mdiCog, mdiContentSave } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import UiControlsSettingsForm from '../components/UiControls/UiControlsSettingsForm';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
import { logger } from '../lib/logger';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { fetch as fetchGlobalUiControlDefaults } from '../stores/global_ui_control_defaults/globalUiControlDefaultsSlice';
import {
deleteByProjectAndEnv,
fetchByProjectAndEnv,
selectByProjectAndEnv,
upsertByProjectAndEnv,
} from '../stores/project_ui_control_settings/projectUiControlSettingsSlice';
import type { UiControlsSettings } from '../types/uiControls';
type Project = {
id: string;
name?: string;
};
type Environment = 'dev' | 'stage' | 'production';
const ENVIRONMENTS: Environment[] = ['dev', 'stage', 'production'];
const ProjectUiControlSettingsPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const projectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const environment = useMemo<Environment>(() => {
const value = Array.isArray(router.query.environment)
? router.query.environment[0]
: router.query.environment;
return ENVIRONMENTS.includes(value as Environment)
? (value as Environment)
: 'dev';
}, [router.query.environment]);
const globalDefaults = useAppSelector(
(state) => state.global_ui_control_defaults.data,
);
const projectSettings = useAppSelector((state) =>
projectId
? selectByProjectAndEnv(state, projectId, environment)
: undefined,
);
const [project, setProject] = useState<Project | null>(null);
const [settings, setSettings] = useState<UiControlsSettings>({});
const [isSaving, setIsSaving] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
useEffect(() => {
dispatch(fetchGlobalUiControlDefaults());
}, [dispatch]);
useEffect(() => {
if (!projectId) return;
dispatch(fetchByProjectAndEnv({ projectId, environment }));
}, [dispatch, projectId, environment]);
useEffect(() => {
if (!projectId) return;
axios
.get<Project>(`/projects/${projectId}`)
.then((response) => setProject(response.data || null))
.catch((error) => {
logger.error('Failed to load project:', error);
setProject(null);
});
}, [projectId]);
useEffect(() => {
setSettings(projectSettings?.settings_json || {});
}, [projectSettings]);
const handleEnvironmentChange = (nextEnvironment: Environment) => {
router.replace({
pathname: '/project-ui-control-settings',
query: { projectId, environment: nextEnvironment },
});
};
const handleSave = async () => {
if (!projectId) return;
setIsSaving(true);
setSaveSuccess(false);
try {
await dispatch(
upsertByProjectAndEnv({
projectId,
environment,
data: { settings_json: settings },
}),
).unwrap();
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to save project UI controls settings:', error);
toast.error('Failed to save UI controls settings');
} finally {
setIsSaving(false);
}
};
const handleUseGlobalDefaults = async () => {
if (!projectId) return;
setIsSaving(true);
setSaveSuccess(false);
try {
await dispatch(
deleteByProjectAndEnv({ projectId, environment }),
).unwrap();
setSettings({});
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 2000);
} catch (error) {
logger.error('Failed to clear project UI controls settings:', error);
toast.error('Failed to clear UI controls settings');
} finally {
setIsSaving(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Project UI Controls Settings')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiCog}
title={`Project UI Controls: ${project?.name || 'Loading...'}`}
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<Link
href={
projectId ? `/projects/${projectId}` : '/projects/projects-list'
}
className='inline-flex rounded border border-blue-600 px-4 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50'
>
Back to Project
</Link>
<div className='flex flex-wrap items-center gap-3'>
<select
className='rounded border border-gray-300 px-3 py-2 text-sm dark:border-dark-600 dark:bg-dark-800'
value={environment}
disabled={isSaving || !projectId}
onChange={(event) =>
handleEnvironmentChange(event.target.value as Environment)
}
>
{ENVIRONMENTS.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
<BaseButton
label='Use Global Defaults'
color='lightDark'
onClick={handleUseGlobalDefaults}
disabled={isSaving || !projectId}
/>
<BaseButton
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
color='info'
onClick={handleSave}
disabled={isSaving || !projectId}
/>
{saveSuccess && (
<span className='text-xs text-green-600'>
Saved successfully!
</span>
)}
</div>
</div>
</CardBox>
<CardBox>
{!projectId ? (
<p className='text-sm text-gray-500'>No project selected.</p>
) : (
<UiControlsSettingsForm
value={settings}
fallbackValue={globalDefaults?.settings_json}
onChange={setSettings}
disabled={isSaving}
/>
)}
</CardBox>
</SectionMain>
</>
);
};
ProjectUiControlSettingsPage.getLayout = function getLayout(
page: ReactElement,
) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default ProjectUiControlSettingsPage;

View File

@ -276,6 +276,20 @@ const ProjectWorkspacePage = () => {
disabled={!projectId}
/>
</CardBox>
<CardBox>
<h3 className='text-lg font-semibold mb-2'>UI Controls</h3>
<p className='text-sm text-gray-500 mb-3'>
Project overrides for fullscreen, sound, and offline controls.
</p>
<BaseButton
href={`/project-ui-control-settings?projectId=${projectId}&environment=dev`}
icon={mdiCog}
color='info'
label='Open UI Controls'
disabled={!projectId}
/>
</CardBox>
</div>
<CardBox className='mt-6'>

View File

@ -161,17 +161,6 @@ const EditTour_pagesPage = () => {
/>
</FormField>
<FormField
label='Global UI Controls Settings JSON'
hasTextareaHeight
>
<Field
name='global_ui_controls_settings_json'
as='textarea'
placeholder='Global UI Controls Settings JSON'
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type='submit' color='info' label='Submit' />