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')
|
// 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
|
// Call the checkPermissions middleware with the determined permission
|
||||||
return checkPermissions(permissionName)(req, res, next);
|
return checkPermissions(permissionName)(req, res, next);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -25,9 +25,17 @@ const allowPublicRead = (req, _res, next) => {
|
|||||||
return 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
|
// Apply public read first, then CRUD permission checks
|
||||||
router.use(allowPublicRead);
|
router.use(allowPublicRead);
|
||||||
router.use(checkCrudPermissions('global_transition_defaults'));
|
router.use(authenticateWrites);
|
||||||
|
router.use(checkCrudPermissions('page_elements'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@ -120,7 +128,6 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
assertRouteIdMatchesBody(req);
|
assertRouteIdMatchesBody(req);
|
||||||
await Global_transition_defaultsService.update({
|
await Global_transition_defaultsService.update({
|
||||||
|
|||||||
@ -15,8 +15,16 @@ const allowPublicRead = (req, _res, next) => {
|
|||||||
return 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(allowPublicRead);
|
||||||
router.use(checkCrudPermissions('global_ui_control_defaults'));
|
router.use(authenticateWrites);
|
||||||
|
router.use(checkCrudPermissions('page_elements'));
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
@ -42,7 +50,6 @@ router.get(
|
|||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Global_ui_control_defaultsService.update({
|
await Global_ui_control_defaultsService.update({
|
||||||
id: req.params.id,
|
id: req.params.id,
|
||||||
|
|||||||
@ -26,6 +26,23 @@ const allowAuthenticatedRead = (req, _res, next) => {
|
|||||||
return 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.
|
* Middleware: Production GET is public, everything else requires JWT.
|
||||||
* Determines public access from URL path, not headers.
|
* 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
|
// Mark reads as public first, then apply CRUD permission checks
|
||||||
router.use(allowAuthenticatedRead);
|
router.use(allowAuthenticatedRead);
|
||||||
router.use(checkCrudPermissions('project_transition_settings'));
|
router.use(authenticateWrites);
|
||||||
|
router.use(useUpdatePermissionForEnvironmentReset);
|
||||||
|
router.use(checkCrudPermissions('page_elements'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@ -214,7 +233,6 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
@ -264,7 +282,6 @@ router.put(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
@ -339,7 +356,6 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await Project_transition_settingsService.create({
|
const payload = await Project_transition_settingsService.create({
|
||||||
data: req.body.data,
|
data: req.body.data,
|
||||||
@ -414,7 +430,6 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
assertRouteIdMatchesBody(req);
|
assertRouteIdMatchesBody(req);
|
||||||
await Project_transition_settingsService.update({
|
await Project_transition_settingsService.update({
|
||||||
@ -448,7 +463,6 @@ router.put(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
await Project_transition_settingsService.remove({
|
await Project_transition_settingsService.remove({
|
||||||
id: req.params.id,
|
id: req.params.id,
|
||||||
|
|||||||
@ -17,6 +17,23 @@ const allowAuthenticatedRead = (req, _res, next) => {
|
|||||||
return 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) => {
|
const getRuntimeProjectSlug = async (req) => {
|
||||||
if (req.runtimeContext?.headerProjectSlug) {
|
if (req.runtimeContext?.headerProjectSlug) {
|
||||||
return req.runtimeContext.headerProjectSlug;
|
return req.runtimeContext.headerProjectSlug;
|
||||||
@ -87,7 +104,9 @@ const requireProductionOrAuth = async (req, res, next) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
router.use(allowAuthenticatedRead);
|
router.use(allowAuthenticatedRead);
|
||||||
router.use(checkCrudPermissions('project_ui_control_settings'));
|
router.use(authenticateWrites);
|
||||||
|
router.use(useUpdatePermissionForEnvironmentReset);
|
||||||
|
router.use(checkCrudPermissions('page_elements'));
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
@ -116,7 +135,6 @@ router.get(
|
|||||||
|
|
||||||
router.put(
|
router.put(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
const { projectId, environment } = req.params;
|
||||||
|
|
||||||
@ -141,7 +159,6 @@ router.put(
|
|||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
jwtAuth,
|
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
const { projectId, environment } = req.params;
|
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,
|
selectByProjectAndEnv,
|
||||||
selectIsLoading as selectTransitionSettingsLoading,
|
selectIsLoading as selectTransitionSettingsLoading,
|
||||||
} from '../stores/project_transition_settings/projectTransitionSettingsSlice';
|
} 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 {
|
import type {
|
||||||
ProjectTransitionSettings,
|
ProjectTransitionSettings,
|
||||||
TransitionType,
|
TransitionType,
|
||||||
@ -142,12 +136,6 @@ const TourFlowManager = () => {
|
|||||||
? selectTransitionSettingsLoading(state, selectedProjectId, 'dev')
|
? selectTransitionSettingsLoading(state, selectedProjectId, 'dev')
|
||||||
: false,
|
: false,
|
||||||
);
|
);
|
||||||
const projectUiControlsSettingsEntity = useAppSelector((state) =>
|
|
||||||
selectedProjectId
|
|
||||||
? selectUiControlsByProjectAndEnv(state, selectedProjectId, 'dev')
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Project transition settings state
|
// Project transition settings state
|
||||||
const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] =
|
const [isTransitionSettingsExpanded, setIsTransitionSettingsExpanded] =
|
||||||
useState(false);
|
useState(false);
|
||||||
@ -166,9 +154,6 @@ const TourFlowManager = () => {
|
|||||||
useState(false);
|
useState(false);
|
||||||
const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false);
|
const [transitionSaveSuccess, setTransitionSaveSuccess] = useState(false);
|
||||||
const [isUiControlsExpanded, setIsUiControlsExpanded] = 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 canCreatePage = hasPermission(currentUser, 'CREATE_TOUR_PAGES');
|
||||||
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
const canCreateTransition = hasPermission(currentUser, 'CREATE_TRANSITIONS');
|
||||||
@ -269,12 +254,6 @@ const TourFlowManager = () => {
|
|||||||
environment: 'dev',
|
environment: 'dev',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
dispatch(
|
|
||||||
fetchUiControlsByProjectAndEnv({
|
|
||||||
projectId: selectedProjectId,
|
|
||||||
environment: 'dev',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, [selectedProjectId, dispatch]);
|
}, [selectedProjectId, dispatch]);
|
||||||
|
|
||||||
// Sync local form state when store data changes
|
// Sync local form state when store data changes
|
||||||
@ -285,16 +264,6 @@ const TourFlowManager = () => {
|
|||||||
setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? '');
|
setLocalOverlayColor(projectTransitionSettings?.overlayColor ?? '');
|
||||||
}, [projectTransitionSettings]);
|
}, [projectTransitionSettings]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setUiControlsJson(
|
|
||||||
JSON.stringify(
|
|
||||||
projectUiControlsSettingsEntity?.settings_json || {},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, [projectUiControlsSettingsEntity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedProjectId) return;
|
if (!selectedProjectId) return;
|
||||||
if (routeProjectId && selectedProjectId === routeProjectId) 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 (
|
const handleDelete = async (
|
||||||
event: React.MouseEvent,
|
event: React.MouseEvent,
|
||||||
id: string,
|
id: string,
|
||||||
@ -962,30 +896,14 @@ const TourFlowManager = () => {
|
|||||||
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mb-4 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
Override fixed-size fullscreen, sound, and offline button
|
Override fixed-size fullscreen, sound, and offline button
|
||||||
defaults for this project in dev. Positions are
|
defaults for this project in dev. Positions are
|
||||||
canvas-relative percentages; dimensions are CSS pixels. Empty
|
canvas-relative percentages.
|
||||||
JSON reverts to global defaults.
|
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<BaseButton
|
||||||
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'
|
href={`/project-ui-control-settings?projectId=${selectedProjectId}&environment=dev`}
|
||||||
value={uiControlsJson}
|
label='Open UI Controls Settings'
|
||||||
onChange={(event) => setUiControlsJson(event.target.value)}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBox>
|
</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 LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
import SectionMain from '../components/SectionMain';
|
import SectionMain from '../components/SectionMain';
|
||||||
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
|
||||||
import BaseButton from '../components/BaseButton';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
import {
|
import { fetch as fetchGlobalTransitionDefaults } from '../stores/global_transition_defaults/globalTransitionDefaultsSlice';
|
||||||
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 type { UiElementDefault } from '../types/constructor';
|
import type { UiElementDefault } from '../types/constructor';
|
||||||
import type {
|
import type { SystemUiControlType } from '../types/uiControls';
|
||||||
GlobalTransitionDefaults,
|
|
||||||
TransitionType,
|
|
||||||
EasingFunction,
|
|
||||||
} from '../types/transition';
|
|
||||||
|
|
||||||
type ElementTypeDefault = UiElementDefault & {
|
type ElementTypeDefault = UiElementDefault & {
|
||||||
element_type: string;
|
element_type: string;
|
||||||
@ -38,96 +26,68 @@ const toHumanLabel = (value: string) =>
|
|||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
const TRANSITION_TYPES: { value: TransitionType; label: string }[] = [
|
const UI_CONTROL_ITEMS: Array<{
|
||||||
{ value: 'fade', label: 'Fade' },
|
type: SystemUiControlType;
|
||||||
{ value: 'none', label: 'None (instant)' },
|
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 }[] = [
|
type SettingsSectionHeaderProps = {
|
||||||
{ value: 'ease-in-out', label: 'Ease In-Out' },
|
title: string;
|
||||||
{ value: 'ease-in', label: 'Ease In' },
|
description: string;
|
||||||
{ value: 'ease-out', label: 'Ease Out' },
|
actions?: React.ReactNode;
|
||||||
{ value: 'linear', label: 'Linear' },
|
};
|
||||||
];
|
|
||||||
|
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 ElementTypeDefaultsPage = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const globalDefaults = useAppSelector(
|
const globalDefaults = useAppSelector(
|
||||||
(state) => state.global_transition_defaults.data,
|
(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 [rows, setRows] = useState<ElementTypeDefault[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
// Load global transition defaults
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchGlobalTransitionDefaults());
|
dispatch(fetchGlobalTransitionDefaults());
|
||||||
dispatch(fetchGlobalUiControlDefaults());
|
|
||||||
}, [dispatch]);
|
}, [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 () => {
|
const loadRows = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
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(() => {
|
useEffect(() => {
|
||||||
loadRows();
|
loadRows();
|
||||||
}, [loadRows]);
|
}, [loadRows]);
|
||||||
@ -202,144 +138,73 @@ const ElementTypeDefaultsPage = () => {
|
|||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
{/* Global Transition Defaults Section */}
|
|
||||||
<CardBox className='mb-6'>
|
<CardBox className='mb-6'>
|
||||||
<h3 className='mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300'>
|
<SettingsSectionHeader
|
||||||
Global Transition Defaults
|
title='Global UI Controls Defaults'
|
||||||
</h3>
|
description='Fullscreen, sound, and offline runtime controls.'
|
||||||
<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)}
|
|
||||||
/>
|
/>
|
||||||
<div className='mt-4 flex items-center gap-3'>
|
<div className='divide-y divide-gray-100 rounded border border-gray-200 dark:divide-dark-700 dark:border-dark-700'>
|
||||||
<BaseButton
|
{UI_CONTROL_ITEMS.map((item) => (
|
||||||
label={isSavingUiControls ? 'Saving...' : 'Save UI Controls'}
|
<Link
|
||||||
color='info'
|
key={item.type}
|
||||||
small
|
href={`/global-ui-control-defaults/${item.type}`}
|
||||||
onClick={handleSaveGlobalUiControls}
|
className='flex flex-wrap items-center justify-between gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-dark-800'
|
||||||
disabled={isSavingUiControls || !globalUiControlDefaults}
|
>
|
||||||
/>
|
<div>
|
||||||
{uiControlsSaveSuccess && (
|
<p className='text-sm font-semibold text-gray-800 dark:text-gray-100'>
|
||||||
<span className='text-xs text-green-600'>
|
{item.title}
|
||||||
Saved successfully!
|
</p>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
</CardBox>
|
</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>
|
<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 ? (
|
{isLoading ? (
|
||||||
<p className='text-sm text-gray-500'>
|
<p className='text-sm text-gray-500'>
|
||||||
Loading element type defaults...
|
Loading element type defaults...
|
||||||
@ -351,21 +216,27 @@ const ElementTypeDefaultsPage = () => {
|
|||||||
No element type defaults found.
|
No element type defaults found.
|
||||||
</p>
|
</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) => (
|
{rows.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
href={`/element-type-defaults/${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'>
|
<div>
|
||||||
{item.name || toHumanLabel(item.element_type)}
|
<p className='text-sm font-semibold text-gray-800 dark:text-gray-100'>
|
||||||
</p>
|
{item.name || toHumanLabel(item.element_type)}
|
||||||
<p className='text-xs text-gray-500'>
|
</p>
|
||||||
{toHumanLabel(item.element_type)} • Order{' '}
|
<p className='text-xs text-gray-500 dark:text-gray-400'>
|
||||||
{Number(item.sort_order || 0)} •{' '}
|
{toHumanLabel(item.element_type)}
|
||||||
{item.is_active ? 'Active' : 'Inactive'}
|
</p>
|
||||||
</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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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}
|
disabled={!projectId}
|
||||||
/>
|
/>
|
||||||
</CardBox>
|
</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>
|
</div>
|
||||||
|
|
||||||
<CardBox className='mt-6'>
|
<CardBox className='mt-6'>
|
||||||
|
|||||||
@ -161,17 +161,6 @@ const EditTour_pagesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</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 />
|
<BaseDivider />
|
||||||
<BaseButtons>
|
<BaseButtons>
|
||||||
<BaseButton type='submit' color='info' label='Submit' />
|
<BaseButton type='submit' color='info' label='Submit' />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user