fixed global and project settings for global contrils buttons
This commit is contained in:
parent
cf3deb14f7
commit
5fc54b1894
@ -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);
|
||||
};
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
77
backend/tests/check-permissions.test.js
Normal file
77
backend/tests/check-permissions.test.js
Normal 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;
|
||||
}
|
||||
});
|
||||
@ -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>
|
||||
|
||||
338
frontend/src/components/UiControls/UiControlsSettingsForm.tsx
Normal file
338
frontend/src/components/UiControls/UiControlsSettingsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
221
frontend/src/pages/global-transition-defaults.tsx
Normal file
221
frontend/src/pages/global-transition-defaults.tsx
Normal 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;
|
||||
103
frontend/src/pages/global-ui-control-defaults.tsx
Normal file
103
frontend/src/pages/global-ui-control-defaults.tsx
Normal 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;
|
||||
153
frontend/src/pages/global-ui-control-defaults/[controlType].tsx
Normal file
153
frontend/src/pages/global-ui-control-defaults/[controlType].tsx
Normal 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;
|
||||
228
frontend/src/pages/project-ui-control-settings.tsx
Normal file
228
frontend/src/pages/project-ui-control-settings.tsx
Normal 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;
|
||||
@ -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'>
|
||||
|
||||
@ -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' />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user