Autosave: 20260319-073852
This commit is contained in:
parent
d21a76e602
commit
afabb0cce1
235
backend/src/db/api/ui_elements.js
Normal file
235
backend/src/db/api/ui_elements.js
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
const GenericDBApi = require('./base.api');
|
||||||
|
const db = require('../models');
|
||||||
|
|
||||||
|
class Ui_elementsDBApi extends GenericDBApi {
|
||||||
|
static get MODEL() {
|
||||||
|
return db.ui_elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get TABLE_NAME() {
|
||||||
|
return 'ui_elements';
|
||||||
|
}
|
||||||
|
|
||||||
|
static get SEARCHABLE_FIELDS() {
|
||||||
|
return ['name', 'element_type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get RANGE_FIELDS() {
|
||||||
|
return ['sort_order'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get ENUM_FIELDS() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get CSV_FIELDS() {
|
||||||
|
return ['id', 'element_type', 'name', 'sort_order', 'is_active', 'createdAt'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static get AUTOCOMPLETE_FIELD() {
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
|
|
||||||
|
static getFieldMapping(data) {
|
||||||
|
return {
|
||||||
|
id: data.id || undefined,
|
||||||
|
element_type: data.element_type ?? null,
|
||||||
|
name: data.name ?? null,
|
||||||
|
sort_order: data.sort_order ?? 0,
|
||||||
|
default_settings_json:
|
||||||
|
data.default_settings_json === null || data.default_settings_json === undefined
|
||||||
|
? null
|
||||||
|
: typeof data.default_settings_json === 'string'
|
||||||
|
? data.default_settings_json
|
||||||
|
: JSON.stringify(data.default_settings_json),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get DEFAULT_ROWS() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
element_type: 'navigation_next',
|
||||||
|
name: 'Navigation Forward Button',
|
||||||
|
sort_order: 1,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Navigation: Forward',
|
||||||
|
navLabel: 'Forward',
|
||||||
|
navType: 'forward',
|
||||||
|
transitionReverseMode: 'auto_reverse',
|
||||||
|
transitionDurationSec: 0.7,
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'navigation_prev',
|
||||||
|
name: 'Navigation Back Button',
|
||||||
|
sort_order: 2,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Navigation: Back',
|
||||||
|
navLabel: 'Back',
|
||||||
|
navType: 'back',
|
||||||
|
transitionReverseMode: 'auto_reverse',
|
||||||
|
transitionDurationSec: 0.7,
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'tooltip',
|
||||||
|
name: 'Tooltip',
|
||||||
|
sort_order: 3,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Tooltip',
|
||||||
|
tooltipTitle: 'Tooltip title',
|
||||||
|
tooltipText: 'Tooltip text',
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'description',
|
||||||
|
name: 'Description',
|
||||||
|
sort_order: 4,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Description',
|
||||||
|
descriptionTitle: 'Description title',
|
||||||
|
descriptionText: 'Description text',
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'gallery',
|
||||||
|
name: 'Gallery',
|
||||||
|
sort_order: 5,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Gallery',
|
||||||
|
galleryCards: [{ imageUrl: '', title: 'Card 1', description: '' }],
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'carousel',
|
||||||
|
name: 'Carousel',
|
||||||
|
sort_order: 6,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Carousel',
|
||||||
|
carouselSlides: [{ imageUrl: '', caption: 'Slide 1' }],
|
||||||
|
carouselPrevIconUrl: '',
|
||||||
|
carouselNextIconUrl: '',
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'video_player',
|
||||||
|
name: 'Video Player',
|
||||||
|
sort_order: 7,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Video Player',
|
||||||
|
mediaUrl: '',
|
||||||
|
mediaAutoplay: true,
|
||||||
|
mediaLoop: true,
|
||||||
|
mediaMuted: true,
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element_type: 'audio_player',
|
||||||
|
name: 'Audio Player',
|
||||||
|
sort_order: 8,
|
||||||
|
default_settings_json: {
|
||||||
|
label: 'Audio Player',
|
||||||
|
mediaUrl: '',
|
||||||
|
mediaAutoplay: true,
|
||||||
|
mediaLoop: true,
|
||||||
|
mediaMuted: false,
|
||||||
|
appearDelaySec: 0,
|
||||||
|
appearDurationSec: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
static async ensureInitialized() {
|
||||||
|
if (!this.initializationPromise) {
|
||||||
|
this.initializationPromise = (async () => {
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
count = await this.MODEL.count();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.original?.code !== '42P01') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.MODEL.sync();
|
||||||
|
count = await this.MODEL.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) return;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await this.MODEL.bulkCreate(
|
||||||
|
this.DEFAULT_ROWS.map((item) => ({
|
||||||
|
...item,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
})().catch((error) => {
|
||||||
|
this.initializationPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.initializationPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(data, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.create(data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async bulkImport(data, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.bulkImport(data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async update(id, data, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.update(id, data, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteByIds(ids, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.deleteByIds(ids, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async remove(id, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.remove(id, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findBy(where, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.findBy(where, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAll(filter = {}, options = {}) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.findAll(filter, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async findAllAutocomplete(query, limit, offset) {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
return super.findAllAutocomplete(query, limit, offset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ui_elementsDBApi.initializationPromise = null;
|
||||||
|
|
||||||
|
module.exports = Ui_elementsDBApi;
|
||||||
72
backend/src/db/models/ui_elements.js
Normal file
72
backend/src/db/models/ui_elements.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
module.exports = function (sequelize, DataTypes) {
|
||||||
|
const ui_elements = sequelize.define(
|
||||||
|
'ui_elements',
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true,
|
||||||
|
},
|
||||||
|
element_type: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
unique: true,
|
||||||
|
validate: {
|
||||||
|
notEmpty: { msg: 'Element type is required' },
|
||||||
|
len: { args: [1, 100], msg: 'Element type must be between 1 and 100 characters' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
notEmpty: { msg: 'Name is required' },
|
||||||
|
len: { args: [1, 255], msg: 'Name must be between 1 and 255 characters' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sort_order: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
is_active: {
|
||||||
|
type: DataTypes.VIRTUAL,
|
||||||
|
get() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default_settings_json: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
field: 'settings_json',
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
importHash: {
|
||||||
|
type: DataTypes.STRING(255),
|
||||||
|
allowNull: true,
|
||||||
|
unique: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
freezeTableName: true,
|
||||||
|
indexes: [
|
||||||
|
{ fields: ['element_type'] },
|
||||||
|
{ fields: ['sort_order'] },
|
||||||
|
{ fields: ['deletedAt'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ui_elements.associate = (db) => {
|
||||||
|
db.ui_elements.belongsTo(db.users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
});
|
||||||
|
|
||||||
|
db.ui_elements.belongsTo(db.users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return ui_elements;
|
||||||
|
};
|
||||||
@ -52,6 +52,7 @@ const publish_eventsRoutes = require('./routes/publish_events');
|
|||||||
const pwa_cachesRoutes = require('./routes/pwa_caches');
|
const pwa_cachesRoutes = require('./routes/pwa_caches');
|
||||||
|
|
||||||
const access_logsRoutes = require('./routes/access_logs');
|
const access_logsRoutes = require('./routes/access_logs');
|
||||||
|
const ui_elementsRoutes = require('./routes/ui_elements');
|
||||||
|
|
||||||
const publishRoutes = require('./routes/publish');
|
const publishRoutes = require('./routes/publish');
|
||||||
const runtimeContextRoutes = require('./routes/runtime-context');
|
const runtimeContextRoutes = require('./routes/runtime-context');
|
||||||
@ -208,6 +209,7 @@ app.use('/api/publish_events', jwtAuth, publish_eventsRoutes);
|
|||||||
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes);
|
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes);
|
||||||
|
|
||||||
app.use('/api/access_logs', jwtAuth, access_logsRoutes);
|
app.use('/api/access_logs', jwtAuth, access_logsRoutes);
|
||||||
|
app.use('/api/ui-elements', jwtAuth, ui_elementsRoutes);
|
||||||
|
|
||||||
app.use('/api/publish', jwtAuth, publishRoutes);
|
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||||
|
|
||||||
|
|||||||
7
backend/src/routes/ui_elements.js
Normal file
7
backend/src/routes/ui_elements.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
const Ui_elementsService = require('../services/ui_elements');
|
||||||
|
const Ui_elementsDBApi = require('../db/api/ui_elements');
|
||||||
|
const { createEntityRouter } = require('../factories/router.factory');
|
||||||
|
|
||||||
|
module.exports = createEntityRouter('ui_elements', Ui_elementsService, Ui_elementsDBApi, {
|
||||||
|
permissionEntity: 'page_elements',
|
||||||
|
});
|
||||||
6
backend/src/services/ui_elements.js
Normal file
6
backend/src/services/ui_elements.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const Ui_elementsDBApi = require('../db/api/ui_elements');
|
||||||
|
const { createEntityService } = require('../factories/service.factory');
|
||||||
|
|
||||||
|
module.exports = createEntityService(Ui_elementsDBApi, {
|
||||||
|
entityName: 'ui_elements',
|
||||||
|
});
|
||||||
@ -22,6 +22,7 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
href: '/ui-elements',
|
href: '/ui-elements',
|
||||||
label: 'UI Elements',
|
label: 'UI Elements',
|
||||||
icon: icon.mdiPaletteSwatch,
|
icon: icon.mdiPaletteSwatch,
|
||||||
|
permissions: 'READ_PAGE_ELEMENTS',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
@ -125,6 +125,13 @@ type ConstructorSchema = {
|
|||||||
elements?: CanvasElement[];
|
elements?: CanvasElement[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UiElementDefault = {
|
||||||
|
id: string;
|
||||||
|
element_type?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
default_settings_json?: Partial<CanvasElement> | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type DragElementState = {
|
type DragElementState = {
|
||||||
id: string;
|
id: string;
|
||||||
pointerOffsetX: number;
|
pointerOffsetX: number;
|
||||||
@ -413,6 +420,20 @@ const labelByType: Record<CanvasElementType, string> = {
|
|||||||
audio_player: 'Audio Player',
|
audio_player: 'Audio Player',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canvasElementTypes: CanvasElementType[] = [
|
||||||
|
'navigation_next',
|
||||||
|
'navigation_prev',
|
||||||
|
'gallery',
|
||||||
|
'carousel',
|
||||||
|
'tooltip',
|
||||||
|
'description',
|
||||||
|
'video_player',
|
||||||
|
'audio_player',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isCanvasElementType = (value: string): value is CanvasElementType =>
|
||||||
|
canvasElementTypes.includes(value as CanvasElementType);
|
||||||
|
|
||||||
const isNavigationElementType = (
|
const isNavigationElementType = (
|
||||||
type: CanvasElementType,
|
type: CanvasElementType,
|
||||||
): type is NavigationElementType =>
|
): type is NavigationElementType =>
|
||||||
@ -505,6 +526,50 @@ const createDefaultElement = (
|
|||||||
return base;
|
return base;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mergeElementWithDefaults = (
|
||||||
|
element: CanvasElement,
|
||||||
|
defaults?: Partial<CanvasElement>,
|
||||||
|
): CanvasElement => {
|
||||||
|
if (!defaults) return element;
|
||||||
|
|
||||||
|
const merged: CanvasElement = {
|
||||||
|
...element,
|
||||||
|
...defaults,
|
||||||
|
id: element.id,
|
||||||
|
type: element.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
merged.xPercent = clamp(Number(merged.xPercent ?? element.xPercent), 0, 100);
|
||||||
|
merged.yPercent = clamp(Number(merged.yPercent ?? element.yPercent), 0, 100);
|
||||||
|
merged.appearDelaySec = normalizeAppearDelaySec(merged.appearDelaySec);
|
||||||
|
merged.appearDurationSec = normalizeAppearDurationSec(merged.appearDurationSec);
|
||||||
|
|
||||||
|
if (merged.type === 'gallery') {
|
||||||
|
const cards = Array.isArray(defaults.galleryCards)
|
||||||
|
? defaults.galleryCards
|
||||||
|
: element.galleryCards || [];
|
||||||
|
merged.galleryCards = cards.map((card, cardIndex) => ({
|
||||||
|
id: String(card?.id || createLocalId()),
|
||||||
|
imageUrl: String(card?.imageUrl || ''),
|
||||||
|
title: String(card?.title || `Card ${cardIndex + 1}`),
|
||||||
|
description: String(card?.description || ''),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merged.type === 'carousel') {
|
||||||
|
const slides = Array.isArray(defaults.carouselSlides)
|
||||||
|
? defaults.carouselSlides
|
||||||
|
: element.carouselSlides || [];
|
||||||
|
merged.carouselSlides = slides.map((slide, slideIndex) => ({
|
||||||
|
id: String(slide?.id || createLocalId()),
|
||||||
|
imageUrl: String(slide?.imageUrl || ''),
|
||||||
|
caption: String(slide?.caption || `Slide ${slideIndex + 1}`),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
const getElementButtonTitle = (element: CanvasElement) => {
|
const getElementButtonTitle = (element: CanvasElement) => {
|
||||||
if (element.type === 'gallery') {
|
if (element.type === 'gallery') {
|
||||||
return `${element.label} (${element.galleryCards?.length || 0})`;
|
return `${element.label} (${element.galleryCards?.length || 0})`;
|
||||||
@ -558,6 +623,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
|
|
||||||
const [pages, setPages] = useState<TourPage[]>([]);
|
const [pages, setPages] = useState<TourPage[]>([]);
|
||||||
const [assets, setAssets] = useState<ProjectAsset[]>([]);
|
const [assets, setAssets] = useState<ProjectAsset[]>([]);
|
||||||
|
const [uiElementDefaultsByType, setUiElementDefaultsByType] = useState<
|
||||||
|
Partial<Record<CanvasElementType, Partial<CanvasElement>>>
|
||||||
|
>({});
|
||||||
const [activePageId, setActivePageId] = useState('');
|
const [activePageId, setActivePageId] = useState('');
|
||||||
const [projectName, setProjectName] = useState('');
|
const [projectName, setProjectName] = useState('');
|
||||||
|
|
||||||
@ -866,7 +934,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
|
|
||||||
const [projectResponse, pagesResponse, assetsResponse] =
|
const [projectResponse, pagesResponse, assetsResponse, uiElementsResponse] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
axios.get(`/projects/${projectId}`),
|
axios.get(`/projects/${projectId}`),
|
||||||
axios.get(
|
axios.get(
|
||||||
@ -875,6 +943,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
axios.get(
|
axios.get(
|
||||||
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
|
`/assets?limit=500&page=0&sort=desc&field=createdAt&project=${projectId}`,
|
||||||
),
|
),
|
||||||
|
axios.get('/ui-elements?limit=200&page=0&sort=asc&field=sort_order&is_active=true'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
|
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
|
||||||
@ -889,6 +958,25 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setPages(pageRows);
|
setPages(pageRows);
|
||||||
setAssets(assetRows);
|
setAssets(assetRows);
|
||||||
|
|
||||||
|
const uiElementRows: UiElementDefault[] = Array.isArray(
|
||||||
|
uiElementsResponse?.data?.rows,
|
||||||
|
)
|
||||||
|
? uiElementsResponse.data.rows
|
||||||
|
: [];
|
||||||
|
const defaultsByType: Partial<
|
||||||
|
Record<CanvasElementType, Partial<CanvasElement>>
|
||||||
|
> = {};
|
||||||
|
uiElementRows.forEach((row) => {
|
||||||
|
const elementType = String(row.element_type || '').trim();
|
||||||
|
if (!isCanvasElementType(elementType)) return;
|
||||||
|
const rawDefaults = parseJsonObject<Partial<CanvasElement>>(
|
||||||
|
row.default_settings_json,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
defaultsByType[elementType] = rawDefaults;
|
||||||
|
});
|
||||||
|
setUiElementDefaultsByType(defaultsByType);
|
||||||
|
|
||||||
const defaultPageId = pageIdFromRoute || pageRows[0]?.id || '';
|
const defaultPageId = pageIdFromRoute || pageRows[0]?.id || '';
|
||||||
setActivePageId(defaultPageId);
|
setActivePageId(defaultPageId);
|
||||||
setIsMenuOpen(false);
|
setIsMenuOpen(false);
|
||||||
@ -911,6 +999,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
setPages([]);
|
setPages([]);
|
||||||
setAssets([]);
|
setAssets([]);
|
||||||
|
setUiElementDefaultsByType({});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -1239,7 +1328,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
? type
|
? type
|
||||||
: allowedNavigationTypes[0]
|
: allowedNavigationTypes[0]
|
||||||
: type;
|
: type;
|
||||||
const nextElement = createDefaultElement(nextElementType, elements.length);
|
const baseElement = createDefaultElement(nextElementType, elements.length);
|
||||||
|
const nextElement = mergeElementWithDefaults(
|
||||||
|
baseElement,
|
||||||
|
uiElementDefaultsByType[nextElementType],
|
||||||
|
);
|
||||||
setElements((prev) => [...prev, nextElement]);
|
setElements((prev) => [...prev, nextElement]);
|
||||||
selectElementForEdit(nextElement.id);
|
selectElementForEdit(nextElement.id);
|
||||||
setSuccessMessage('Element added. Drag it to set position.');
|
setSuccessMessage('Element added. Drag it to set position.');
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { mdiArrowLeft, mdiContentSave, mdiPencil } from '@mdi/js';
|
import { mdiArrowLeft, mdiContentSave, mdiPencil, mdiPlus, mdiTrashCan } from '@mdi/js';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
@ -6,6 +6,7 @@ import { useRouter } from 'next/router';
|
|||||||
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import BaseButton from '../../components/BaseButton';
|
import BaseButton from '../../components/BaseButton';
|
||||||
import CardBox from '../../components/CardBox';
|
import CardBox from '../../components/CardBox';
|
||||||
|
import FormField from '../../components/FormField';
|
||||||
import SectionMain from '../../components/SectionMain';
|
import SectionMain from '../../components/SectionMain';
|
||||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||||
import { getPageTitle } from '../../config';
|
import { getPageTitle } from '../../config';
|
||||||
@ -27,6 +28,19 @@ type TourPage = {
|
|||||||
background_loop?: boolean;
|
background_loop?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GalleryCard = {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CarouselSlide = {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
caption: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ConstructorElement = {
|
type ConstructorElement = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@ -35,8 +49,14 @@ type ConstructorElement = {
|
|||||||
yPercent?: number;
|
yPercent?: number;
|
||||||
appearDelaySec?: number;
|
appearDelaySec?: number;
|
||||||
appearDurationSec?: number | null;
|
appearDurationSec?: number | null;
|
||||||
|
iconUrl?: string;
|
||||||
navLabel?: string;
|
navLabel?: string;
|
||||||
|
navType?: 'forward' | 'back';
|
||||||
targetPageId?: string;
|
targetPageId?: string;
|
||||||
|
transitionVideoUrl?: string;
|
||||||
|
transitionReverseMode?: 'auto_reverse' | 'separate_video';
|
||||||
|
reverseVideoUrl?: string;
|
||||||
|
transitionDurationSec?: number;
|
||||||
tooltipTitle?: string;
|
tooltipTitle?: string;
|
||||||
tooltipText?: string;
|
tooltipText?: string;
|
||||||
descriptionTitle?: string;
|
descriptionTitle?: string;
|
||||||
@ -45,8 +65,10 @@ type ConstructorElement = {
|
|||||||
mediaAutoplay?: boolean;
|
mediaAutoplay?: boolean;
|
||||||
mediaLoop?: boolean;
|
mediaLoop?: boolean;
|
||||||
mediaMuted?: boolean;
|
mediaMuted?: boolean;
|
||||||
galleryCards?: any[];
|
carouselPrevIconUrl?: string;
|
||||||
carouselSlides?: any[];
|
carouselNextIconUrl?: string;
|
||||||
|
galleryCards?: GalleryCard[];
|
||||||
|
carouselSlides?: CarouselSlide[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ConstructorSchema = {
|
type ConstructorSchema = {
|
||||||
@ -91,17 +113,12 @@ const parseNullableNumber = (value: string) => {
|
|||||||
return parsed;
|
return parsed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseArrayJson = (value: string, fieldName: string) => {
|
const createLocalId = () => {
|
||||||
try {
|
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
|
||||||
const parsed = JSON.parse(value);
|
return window.crypto.randomUUID();
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
throw new Error(`${fieldName} must be a JSON array.`);
|
|
||||||
}
|
|
||||||
return parsed;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to parse ${fieldName}:`, error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return `element_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PageElementsProjectEditPage = () => {
|
const PageElementsProjectEditPage = () => {
|
||||||
@ -126,8 +143,14 @@ const PageElementsProjectEditPage = () => {
|
|||||||
const [yPercent, setYPercent] = useState('0');
|
const [yPercent, setYPercent] = useState('0');
|
||||||
const [appearDelaySec, setAppearDelaySec] = useState('0');
|
const [appearDelaySec, setAppearDelaySec] = useState('0');
|
||||||
const [appearDurationSec, setAppearDurationSec] = useState('');
|
const [appearDurationSec, setAppearDurationSec] = useState('');
|
||||||
|
const [iconUrl, setIconUrl] = useState('');
|
||||||
const [navLabel, setNavLabel] = useState('');
|
const [navLabel, setNavLabel] = useState('');
|
||||||
|
const [navType, setNavType] = useState<'forward' | 'back'>('forward');
|
||||||
const [targetPageId, setTargetPageId] = useState('');
|
const [targetPageId, setTargetPageId] = useState('');
|
||||||
|
const [transitionVideoUrl, setTransitionVideoUrl] = useState('');
|
||||||
|
const [transitionReverseMode, setTransitionReverseMode] = useState<'auto_reverse' | 'separate_video'>('auto_reverse');
|
||||||
|
const [reverseVideoUrl, setReverseVideoUrl] = useState('');
|
||||||
|
const [transitionDurationSec, setTransitionDurationSec] = useState('0.7');
|
||||||
const [tooltipTitle, setTooltipTitle] = useState('');
|
const [tooltipTitle, setTooltipTitle] = useState('');
|
||||||
const [tooltipText, setTooltipText] = useState('');
|
const [tooltipText, setTooltipText] = useState('');
|
||||||
const [descriptionTitle, setDescriptionTitle] = useState('');
|
const [descriptionTitle, setDescriptionTitle] = useState('');
|
||||||
@ -136,8 +159,10 @@ const PageElementsProjectEditPage = () => {
|
|||||||
const [mediaAutoplay, setMediaAutoplay] = useState(false);
|
const [mediaAutoplay, setMediaAutoplay] = useState(false);
|
||||||
const [mediaLoop, setMediaLoop] = useState(false);
|
const [mediaLoop, setMediaLoop] = useState(false);
|
||||||
const [mediaMuted, setMediaMuted] = useState(false);
|
const [mediaMuted, setMediaMuted] = useState(false);
|
||||||
const [galleryCardsJson, setGalleryCardsJson] = useState('[]');
|
const [carouselPrevIconUrl, setCarouselPrevIconUrl] = useState('');
|
||||||
const [carouselSlidesJson, setCarouselSlidesJson] = useState('[]');
|
const [carouselNextIconUrl, setCarouselNextIconUrl] = useState('');
|
||||||
|
const [galleryCards, setGalleryCards] = useState<GalleryCard[]>([]);
|
||||||
|
const [carouselSlides, setCarouselSlides] = useState<CarouselSlide[]>([]);
|
||||||
|
|
||||||
const applyElementToForm = useCallback((item: ConstructorElement) => {
|
const applyElementToForm = useCallback((item: ConstructorElement) => {
|
||||||
setLabel(String(item.label || ''));
|
setLabel(String(item.label || ''));
|
||||||
@ -145,8 +170,14 @@ const PageElementsProjectEditPage = () => {
|
|||||||
setYPercent(String(item.yPercent ?? 0));
|
setYPercent(String(item.yPercent ?? 0));
|
||||||
setAppearDelaySec(String(item.appearDelaySec ?? 0));
|
setAppearDelaySec(String(item.appearDelaySec ?? 0));
|
||||||
setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec));
|
setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec));
|
||||||
|
setIconUrl(String(item.iconUrl || ''));
|
||||||
setNavLabel(String(item.navLabel || ''));
|
setNavLabel(String(item.navLabel || ''));
|
||||||
|
setNavType(item.navType === 'back' ? 'back' : 'forward');
|
||||||
setTargetPageId(String(item.targetPageId || ''));
|
setTargetPageId(String(item.targetPageId || ''));
|
||||||
|
setTransitionVideoUrl(String(item.transitionVideoUrl || ''));
|
||||||
|
setTransitionReverseMode(item.transitionReverseMode === 'separate_video' ? 'separate_video' : 'auto_reverse');
|
||||||
|
setReverseVideoUrl(String(item.reverseVideoUrl || ''));
|
||||||
|
setTransitionDurationSec(String(item.transitionDurationSec ?? 0.7));
|
||||||
setTooltipTitle(String(item.tooltipTitle || ''));
|
setTooltipTitle(String(item.tooltipTitle || ''));
|
||||||
setTooltipText(String(item.tooltipText || ''));
|
setTooltipText(String(item.tooltipText || ''));
|
||||||
setDescriptionTitle(String(item.descriptionTitle || ''));
|
setDescriptionTitle(String(item.descriptionTitle || ''));
|
||||||
@ -155,8 +186,27 @@ const PageElementsProjectEditPage = () => {
|
|||||||
setMediaAutoplay(Boolean(item.mediaAutoplay));
|
setMediaAutoplay(Boolean(item.mediaAutoplay));
|
||||||
setMediaLoop(Boolean(item.mediaLoop));
|
setMediaLoop(Boolean(item.mediaLoop));
|
||||||
setMediaMuted(Boolean(item.mediaMuted));
|
setMediaMuted(Boolean(item.mediaMuted));
|
||||||
setGalleryCardsJson(JSON.stringify(item.galleryCards || [], null, 2));
|
setCarouselPrevIconUrl(String(item.carouselPrevIconUrl || ''));
|
||||||
setCarouselSlidesJson(JSON.stringify(item.carouselSlides || [], null, 2));
|
setCarouselNextIconUrl(String(item.carouselNextIconUrl || ''));
|
||||||
|
setGalleryCards(
|
||||||
|
Array.isArray(item.galleryCards)
|
||||||
|
? item.galleryCards.map((card, index) => ({
|
||||||
|
id: String(card?.id || createLocalId()),
|
||||||
|
imageUrl: String(card?.imageUrl || ''),
|
||||||
|
title: String(card?.title || `Card ${index + 1}`),
|
||||||
|
description: String(card?.description || ''),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
setCarouselSlides(
|
||||||
|
Array.isArray(item.carouselSlides)
|
||||||
|
? item.carouselSlides.map((slide, index) => ({
|
||||||
|
id: String(slide?.id || createLocalId()),
|
||||||
|
imageUrl: String(slide?.imageUrl || ''),
|
||||||
|
caption: String(slide?.caption || `Slide ${index + 1}`),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
@ -208,6 +258,56 @@ const PageElementsProjectEditPage = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
const isNavigationType = element?.type === 'navigation_next' || element?.type === 'navigation_prev';
|
||||||
|
const isTooltipType = element?.type === 'tooltip';
|
||||||
|
const isDescriptionType = element?.type === 'description';
|
||||||
|
const isGalleryType = element?.type === 'gallery';
|
||||||
|
const isCarouselType = element?.type === 'carousel';
|
||||||
|
const isMediaType = element?.type === 'video_player' || element?.type === 'audio_player';
|
||||||
|
|
||||||
|
const updateGalleryCard = (cardId: string, field: keyof GalleryCard, value: string) => {
|
||||||
|
setGalleryCards((previous) =>
|
||||||
|
previous.map((card) => (card.id === cardId ? { ...card, [field]: value } : card)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addGalleryCard = () => {
|
||||||
|
setGalleryCards((previous) => [
|
||||||
|
...previous,
|
||||||
|
{
|
||||||
|
id: createLocalId(),
|
||||||
|
imageUrl: '',
|
||||||
|
title: `Card ${previous.length + 1}`,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeGalleryCard = (cardId: string) => {
|
||||||
|
setGalleryCards((previous) => previous.filter((card) => card.id !== cardId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCarouselSlide = (slideId: string, field: keyof CarouselSlide, value: string) => {
|
||||||
|
setCarouselSlides((previous) =>
|
||||||
|
previous.map((slide) => (slide.id === slideId ? { ...slide, [field]: value } : slide)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCarouselSlide = () => {
|
||||||
|
setCarouselSlides((previous) => [
|
||||||
|
...previous,
|
||||||
|
{
|
||||||
|
id: createLocalId(),
|
||||||
|
imageUrl: '',
|
||||||
|
caption: `Slide ${previous.length + 1}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCarouselSlide = (slideId: string) => {
|
||||||
|
setCarouselSlides((previous) => previous.filter((slide) => slide.id !== slideId));
|
||||||
|
};
|
||||||
|
|
||||||
const saveElement = async () => {
|
const saveElement = async () => {
|
||||||
if (!currentPage || !element || !hasUpdatePermission) return;
|
if (!currentPage || !element || !hasUpdatePermission) return;
|
||||||
|
|
||||||
@ -223,24 +323,55 @@ const PageElementsProjectEditPage = () => {
|
|||||||
yPercent: clampPercent(yPercent),
|
yPercent: clampPercent(yPercent),
|
||||||
appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0,
|
appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0,
|
||||||
appearDurationSec: parseNullableNumber(appearDurationSec),
|
appearDurationSec: parseNullableNumber(appearDurationSec),
|
||||||
navLabel: navLabel.trim(),
|
|
||||||
targetPageId: targetPageId.trim(),
|
|
||||||
tooltipTitle: tooltipTitle.trim(),
|
|
||||||
tooltipText: tooltipText,
|
|
||||||
descriptionTitle: descriptionTitle.trim(),
|
|
||||||
descriptionText: descriptionText,
|
|
||||||
mediaUrl: mediaUrl.trim(),
|
|
||||||
mediaAutoplay,
|
|
||||||
mediaLoop,
|
|
||||||
mediaMuted,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (element.type === 'gallery') {
|
if (isNavigationType) {
|
||||||
nextElement.galleryCards = parseArrayJson(galleryCardsJson, 'Gallery cards');
|
nextElement.iconUrl = iconUrl.trim();
|
||||||
|
nextElement.navLabel = navLabel.trim();
|
||||||
|
nextElement.navType = navType;
|
||||||
|
nextElement.targetPageId = targetPageId.trim();
|
||||||
|
nextElement.transitionVideoUrl = transitionVideoUrl.trim();
|
||||||
|
nextElement.transitionReverseMode = transitionReverseMode;
|
||||||
|
nextElement.reverseVideoUrl = reverseVideoUrl.trim();
|
||||||
|
nextElement.transitionDurationSec = Number(transitionDurationSec) > 0 ? Number(transitionDurationSec) : 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (element.type === 'carousel') {
|
if (isTooltipType) {
|
||||||
nextElement.carouselSlides = parseArrayJson(carouselSlidesJson, 'Carousel slides');
|
nextElement.iconUrl = iconUrl.trim();
|
||||||
|
nextElement.tooltipTitle = tooltipTitle.trim();
|
||||||
|
nextElement.tooltipText = tooltipText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDescriptionType) {
|
||||||
|
nextElement.iconUrl = iconUrl.trim();
|
||||||
|
nextElement.descriptionTitle = descriptionTitle.trim();
|
||||||
|
nextElement.descriptionText = descriptionText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGalleryType) {
|
||||||
|
nextElement.galleryCards = galleryCards.map((card, index) => ({
|
||||||
|
id: String(card.id || createLocalId()),
|
||||||
|
imageUrl: card.imageUrl.trim(),
|
||||||
|
title: card.title.trim() || `Card ${index + 1}`,
|
||||||
|
description: card.description,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCarouselType) {
|
||||||
|
nextElement.carouselSlides = carouselSlides.map((slide, index) => ({
|
||||||
|
id: String(slide.id || createLocalId()),
|
||||||
|
imageUrl: slide.imageUrl.trim(),
|
||||||
|
caption: slide.caption.trim() || `Slide ${index + 1}`,
|
||||||
|
}));
|
||||||
|
nextElement.carouselPrevIconUrl = carouselPrevIconUrl.trim();
|
||||||
|
nextElement.carouselNextIconUrl = carouselNextIconUrl.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMediaType) {
|
||||||
|
nextElement.mediaUrl = mediaUrl.trim();
|
||||||
|
nextElement.mediaAutoplay = mediaAutoplay;
|
||||||
|
nextElement.mediaLoop = mediaLoop;
|
||||||
|
nextElement.mediaMuted = mediaMuted;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSchema = parseJsonObject<ConstructorSchema>(currentPage.ui_schema_json, {});
|
const existingSchema = parseJsonObject<ConstructorSchema>(currentPage.ui_schema_json, {});
|
||||||
@ -345,19 +476,75 @@ const PageElementsProjectEditPage = () => {
|
|||||||
<CardBox className='mb-4'>
|
<CardBox className='mb-4'>
|
||||||
<h3 className='mb-3 text-sm font-semibold'>General</h3>
|
<h3 className='mb-3 text-sm font-semibold'>General</h3>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<div>
|
<FormField label='Label'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Label</label>
|
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(event) => setLabel(event.target.value)}
|
onChange={(event) => setLabel(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='X percent'>
|
||||||
|
<input
|
||||||
|
value={xPercent}
|
||||||
|
onChange={(event) => setXPercent(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Y percent'>
|
||||||
|
<input
|
||||||
|
value={yPercent}
|
||||||
|
onChange={(event) => setYPercent(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Appear delay (sec)'>
|
||||||
|
<input
|
||||||
|
value={appearDelaySec}
|
||||||
|
onChange={(event) => setAppearDelaySec(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Appear duration (sec)'>
|
||||||
|
<input
|
||||||
|
value={appearDurationSec}
|
||||||
|
onChange={(event) => setAppearDurationSec(event.target.value)}
|
||||||
|
placeholder='Leave empty for none'
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</CardBox>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Target page</label>
|
|
||||||
|
{isNavigationType ? (
|
||||||
|
<CardBox className='mb-4'>
|
||||||
|
<h3 className='mb-3 text-sm font-semibold'>Navigation</h3>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormField label='Icon URL'>
|
||||||
|
<input
|
||||||
|
value={iconUrl}
|
||||||
|
onChange={(event) => setIconUrl(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Navigation label'>
|
||||||
|
<input
|
||||||
|
value={navLabel}
|
||||||
|
onChange={(event) => setNavLabel(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Navigation type'>
|
||||||
|
<select
|
||||||
|
value={navType}
|
||||||
|
onChange={(event) => setNavType(event.target.value === 'back' ? 'back' : 'forward')}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
>
|
||||||
|
<option value='forward'>Forward</option>
|
||||||
|
<option value='back'>Back</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Target page'>
|
||||||
<select
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={targetPageId}
|
value={targetPageId}
|
||||||
onChange={(event) => setTargetPageId(event.target.value)}
|
onChange={(event) => setTargetPageId(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
@ -369,110 +556,127 @@ const PageElementsProjectEditPage = () => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</FormField>
|
||||||
<div>
|
<FormField label='Transition video URL'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>X percent</label>
|
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
value={transitionVideoUrl}
|
||||||
value={xPercent}
|
onChange={(event) => setTransitionVideoUrl(event.target.value)}
|
||||||
onChange={(event) => setXPercent(event.target.value)}
|
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div>
|
<FormField label='Reverse mode'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Y percent</label>
|
<select
|
||||||
|
value={transitionReverseMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setTransitionReverseMode(
|
||||||
|
event.target.value === 'separate_video' ? 'separate_video' : 'auto_reverse',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
>
|
||||||
|
<option value='auto_reverse'>Auto reverse</option>
|
||||||
|
<option value='separate_video'>Separate reverse video</option>
|
||||||
|
</select>
|
||||||
|
</FormField>
|
||||||
|
{transitionReverseMode === 'separate_video' ? (
|
||||||
|
<FormField label='Reverse video URL'>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
value={reverseVideoUrl}
|
||||||
value={yPercent}
|
onChange={(event) => setReverseVideoUrl(event.target.value)}
|
||||||
onChange={(event) => setYPercent(event.target.value)}
|
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div>
|
) : null}
|
||||||
<label className='mb-1 block text-sm font-semibold'>Appear delay (sec)</label>
|
<FormField label='Transition duration (sec)'>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
value={transitionDurationSec}
|
||||||
value={appearDelaySec}
|
onChange={(event) => setTransitionDurationSec(event.target.value)}
|
||||||
onChange={(event) => setAppearDelaySec(event.target.value)}
|
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div>
|
|
||||||
<label className='mb-1 block text-sm font-semibold'>Appear duration (sec)</label>
|
|
||||||
<input
|
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={appearDurationSec}
|
|
||||||
onChange={(event) => setAppearDurationSec(event.target.value)}
|
|
||||||
placeholder='Leave empty for none'
|
|
||||||
disabled={!hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isTooltipType ? (
|
||||||
<CardBox className='mb-4'>
|
<CardBox className='mb-4'>
|
||||||
<h3 className='mb-3 text-sm font-semibold'>Navigation / Text Settings</h3>
|
<h3 className='mb-3 text-sm font-semibold'>Tooltip</h3>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<div>
|
<FormField label='Icon URL'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Navigation label</label>
|
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
value={iconUrl}
|
||||||
value={navLabel}
|
onChange={(event) => setIconUrl(event.target.value)}
|
||||||
onChange={(event) => setNavLabel(event.target.value)}
|
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div>
|
<FormField label='Tooltip title'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Tooltip title</label>
|
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={tooltipTitle}
|
value={tooltipTitle}
|
||||||
onChange={(event) => setTooltipTitle(event.target.value)}
|
onChange={(event) => setTooltipTitle(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Tooltip text</label>
|
<FormField label='Tooltip text' hasTextareaHeight>
|
||||||
<textarea
|
<textarea
|
||||||
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
||||||
value={tooltipText}
|
value={tooltipText}
|
||||||
onChange={(event) => setTooltipText(event.target.value)}
|
onChange={(event) => setTooltipText(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Description title</label>
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isDescriptionType ? (
|
||||||
|
<CardBox className='mb-4'>
|
||||||
|
<h3 className='mb-3 text-sm font-semibold'>Description</h3>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormField label='Icon URL'>
|
||||||
|
<input
|
||||||
|
value={iconUrl}
|
||||||
|
onChange={(event) => setIconUrl(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Description title'>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={descriptionTitle}
|
value={descriptionTitle}
|
||||||
onChange={(event) => setDescriptionTitle(event.target.value)}
|
onChange={(event) => setDescriptionTitle(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormField>
|
||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Description text</label>
|
<FormField label='Description text' hasTextareaHeight>
|
||||||
<textarea
|
<textarea
|
||||||
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
||||||
value={descriptionText}
|
value={descriptionText}
|
||||||
onChange={(event) => setDescriptionText(event.target.value)}
|
onChange={(event) => setDescriptionText(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isMediaType ? (
|
||||||
<CardBox className='mb-4'>
|
<CardBox className='mb-4'>
|
||||||
<h3 className='mb-3 text-sm font-semibold'>Media</h3>
|
<h3 className='mb-3 text-sm font-semibold'>Media</h3>
|
||||||
<div className='grid gap-4 md:grid-cols-2'>
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
<div className='md:col-span-2'>
|
<div className='md:col-span-2'>
|
||||||
<label className='mb-1 block text-sm font-semibold'>Media URL</label>
|
<FormField label='Media URL'>
|
||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={mediaUrl}
|
value={mediaUrl}
|
||||||
onChange={(event) => setMediaUrl(event.target.value)}
|
onChange={(event) => setMediaUrl(event.target.value)}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
|
<FormField label='Playback options'>
|
||||||
|
<div className='flex flex-wrap gap-4 pt-2'>
|
||||||
<label className='flex items-center gap-2 text-sm'>
|
<label className='flex items-center gap-2 text-sm'>
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
@ -501,29 +705,144 @@ const PageElementsProjectEditPage = () => {
|
|||||||
Muted
|
Muted
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</CardBox>
|
</FormField>
|
||||||
|
</div>
|
||||||
{element.type === 'gallery' ? (
|
|
||||||
<CardBox className='mb-4'>
|
|
||||||
<h3 className='mb-3 text-sm font-semibold'>Gallery cards JSON</h3>
|
|
||||||
<textarea
|
|
||||||
className='h-56 w-full rounded border border-gray-300 px-2 py-2 font-mono text-xs dark:border-dark-700 dark:bg-dark-800'
|
|
||||||
value={galleryCardsJson}
|
|
||||||
onChange={(event) => setGalleryCardsJson(event.target.value)}
|
|
||||||
disabled={!hasUpdatePermission}
|
|
||||||
/>
|
|
||||||
</CardBox>
|
</CardBox>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{element.type === 'carousel' ? (
|
{isGalleryType ? (
|
||||||
<CardBox className='mb-4'>
|
<CardBox className='mb-4'>
|
||||||
<h3 className='mb-3 text-sm font-semibold'>Carousel slides JSON</h3>
|
<div className='mb-3 flex items-center justify-between'>
|
||||||
<textarea
|
<h3 className='text-sm font-semibold'>Gallery cards</h3>
|
||||||
className='h-56 w-full rounded border border-gray-300 px-2 py-2 font-mono text-xs dark:border-dark-700 dark:bg-dark-800'
|
<BaseButton
|
||||||
value={carouselSlidesJson}
|
color='info'
|
||||||
onChange={(event) => setCarouselSlidesJson(event.target.value)}
|
icon={mdiPlus}
|
||||||
|
small
|
||||||
|
label='Add card'
|
||||||
|
onClick={addGalleryCard}
|
||||||
disabled={!hasUpdatePermission}
|
disabled={!hasUpdatePermission}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{galleryCards.length === 0 ? (
|
||||||
|
<p className='text-sm text-gray-500'>No cards yet.</p>
|
||||||
|
) : (
|
||||||
|
galleryCards.map((card, index) => (
|
||||||
|
<CardBox key={card.id} className='border border-gray-200 dark:border-dark-700'>
|
||||||
|
<div className='mb-3 flex items-center justify-between'>
|
||||||
|
<h4 className='text-sm font-semibold'>Card {index + 1}</h4>
|
||||||
|
<BaseButton
|
||||||
|
color='danger'
|
||||||
|
icon={mdiTrashCan}
|
||||||
|
small
|
||||||
|
outline
|
||||||
|
onClick={() => removeGalleryCard(card.id)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
<FormField label='Image URL'>
|
||||||
|
<input
|
||||||
|
value={card.imageUrl}
|
||||||
|
onChange={(event) => updateGalleryCard(card.id, 'imageUrl', event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Title'>
|
||||||
|
<input
|
||||||
|
value={card.title}
|
||||||
|
onChange={(event) => updateGalleryCard(card.id, 'title', event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div className='md:col-span-2'>
|
||||||
|
<FormField label='Description' hasTextareaHeight>
|
||||||
|
<textarea
|
||||||
|
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
|
||||||
|
value={card.description}
|
||||||
|
onChange={(event) => updateGalleryCard(card.id, 'description', event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isCarouselType ? (
|
||||||
|
<CardBox className='mb-4'>
|
||||||
|
<h3 className='mb-3 text-sm font-semibold'>Carousel settings</h3>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<FormField label='Previous icon URL'>
|
||||||
|
<input
|
||||||
|
value={carouselPrevIconUrl}
|
||||||
|
onChange={(event) => setCarouselPrevIconUrl(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Next icon URL'>
|
||||||
|
<input
|
||||||
|
value={carouselNextIconUrl}
|
||||||
|
onChange={(event) => setCarouselNextIconUrl(event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='mb-3 mt-2 flex items-center justify-between'>
|
||||||
|
<h3 className='text-sm font-semibold'>Carousel slides</h3>
|
||||||
|
<BaseButton
|
||||||
|
color='info'
|
||||||
|
icon={mdiPlus}
|
||||||
|
small
|
||||||
|
label='Add slide'
|
||||||
|
onClick={addCarouselSlide}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{carouselSlides.length === 0 ? (
|
||||||
|
<p className='text-sm text-gray-500'>No slides yet.</p>
|
||||||
|
) : (
|
||||||
|
carouselSlides.map((slide, index) => (
|
||||||
|
<CardBox key={slide.id} className='border border-gray-200 dark:border-dark-700'>
|
||||||
|
<div className='mb-3 flex items-center justify-between'>
|
||||||
|
<h4 className='text-sm font-semibold'>Slide {index + 1}</h4>
|
||||||
|
<BaseButton
|
||||||
|
color='danger'
|
||||||
|
icon={mdiTrashCan}
|
||||||
|
small
|
||||||
|
outline
|
||||||
|
onClick={() => removeCarouselSlide(slide.id)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
|
<FormField label='Image URL'>
|
||||||
|
<input
|
||||||
|
value={slide.imageUrl}
|
||||||
|
onChange={(event) => updateCarouselSlide(slide.id, 'imageUrl', event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label='Caption'>
|
||||||
|
<input
|
||||||
|
value={slide.caption}
|
||||||
|
onChange={(event) => updateCarouselSlide(slide.id, 'caption', event.target.value)}
|
||||||
|
disabled={!hasUpdatePermission}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</CardBox>
|
</CardBox>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,131 +1,62 @@
|
|||||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
import { mdiPaletteSwatch } from '@mdi/js';
|
||||||
import Head from 'next/head';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import React, {
|
|
||||||
ReactElement,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||||
import CardBox from '../components/CardBox';
|
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 { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import ElementPreview from '../components/UiElements/ElementPreview';
|
|
||||||
import {
|
type UiElementType = {
|
||||||
getDefaultSettings,
|
id: string;
|
||||||
normalizeUiElement,
|
element_type: string;
|
||||||
toElementLabel,
|
name?: string;
|
||||||
UI_ELEMENT_TYPES,
|
sort_order?: number;
|
||||||
} from '../components/UiElements/defaults';
|
is_active?: boolean;
|
||||||
import { UiElementItem, UiElementRow } from '../components/UiElements/types';
|
};
|
||||||
|
|
||||||
|
const toHumanLabel = (value: string) =>
|
||||||
|
String(value || '')
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
const UiElementsPage = () => {
|
const UiElementsPage = () => {
|
||||||
const router = useRouter();
|
const [rows, setRows] = useState<UiElementType[]>([]);
|
||||||
|
|
||||||
const [elements, setElements] = useState<UiElementItem[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
const fetchRows = useCallback(async () => {
|
const loadRows = useCallback(async () => {
|
||||||
const response = await axios.get(
|
try {
|
||||||
'/ui-elements?limit=1000&page=0&sort=asc&field=sort_order',
|
|
||||||
);
|
|
||||||
return Array.isArray(response?.data?.rows) ? response.data.rows : [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const bootstrapMissingElements = useCallback(async (rows: UiElementRow[]) => {
|
|
||||||
const existingTypes = new Set(rows.map((row) => row.element_type));
|
|
||||||
const missingTypes = UI_ELEMENT_TYPES.filter(
|
|
||||||
(type) => !existingTypes.has(type),
|
|
||||||
);
|
|
||||||
|
|
||||||
const safeStartOrder = Math.max(
|
|
||||||
1,
|
|
||||||
...rows
|
|
||||||
.map((row) => Number(row.sort_order))
|
|
||||||
.filter((value) => Number.isFinite(value))
|
|
||||||
.map((value) => Math.trunc(value) + 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!missingTypes.length) return;
|
|
||||||
|
|
||||||
const createResults = await Promise.allSettled(
|
|
||||||
missingTypes.map((elementType, index) =>
|
|
||||||
axios.post('/ui-elements', {
|
|
||||||
data: {
|
|
||||||
element_type: elementType,
|
|
||||||
name: toElementLabel(elementType),
|
|
||||||
sort_order: safeStartOrder + index,
|
|
||||||
settings_json: JSON.stringify(getDefaultSettings(elementType)),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rejected = createResults.filter(
|
|
||||||
(result) => result.status === 'rejected',
|
|
||||||
) as PromiseRejectedResult[];
|
|
||||||
|
|
||||||
if (rejected.length) {
|
|
||||||
const reasons = rejected
|
|
||||||
.map((result) => {
|
|
||||||
const err = result.reason;
|
|
||||||
return (
|
|
||||||
err?.response?.data?.message ||
|
|
||||||
err?.message ||
|
|
||||||
'Unknown create error'
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join('; ');
|
|
||||||
|
|
||||||
throw new Error(`Failed to bootstrap some UI elements: ${reasons}`);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadElements = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
|
|
||||||
try {
|
const response = await axios.get(
|
||||||
const initialRows = (await fetchRows()) as UiElementRow[];
|
'/ui-elements?limit=1000&page=0&sort=asc&field=sort_order',
|
||||||
await bootstrapMissingElements(initialRows);
|
|
||||||
|
|
||||||
const rowsAfterBootstrap = (await fetchRows()) as UiElementRow[];
|
|
||||||
const normalized = rowsAfterBootstrap
|
|
||||||
.map((row) => normalizeUiElement(row))
|
|
||||||
.sort(
|
|
||||||
(a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setElements(normalized);
|
const nextRows: UiElementType[] = Array.isArray(response?.data?.rows)
|
||||||
|
? response.data.rows
|
||||||
|
: [];
|
||||||
|
setRows(nextRows);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message =
|
||||||
error?.response?.data?.message ||
|
error?.response?.data?.message ||
|
||||||
error?.message ||
|
error?.message ||
|
||||||
'Failed to load UI elements.';
|
'Failed to load UI element types.';
|
||||||
|
console.error('Failed to load UI element defaults:', error);
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
setElements([]);
|
setRows([]);
|
||||||
console.error('Failed to load UI elements:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [bootstrapMissingElements, fetchRows]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadElements();
|
loadRows();
|
||||||
}, [loadElements]);
|
}, [loadRows]);
|
||||||
|
|
||||||
const sortedElements = useMemo(
|
|
||||||
() =>
|
|
||||||
[...elements].sort(
|
|
||||||
(a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name),
|
|
||||||
),
|
|
||||||
[elements],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -134,54 +65,38 @@ const UiElementsPage = () => {
|
|||||||
</Head>
|
</Head>
|
||||||
<SectionMain>
|
<SectionMain>
|
||||||
<SectionTitleLineWithButton
|
<SectionTitleLineWithButton
|
||||||
icon={mdiChartTimelineVariant}
|
icon={mdiPaletteSwatch}
|
||||||
title='UI Elements'
|
title='UI Elements Defaults'
|
||||||
main
|
main
|
||||||
>
|
>
|
||||||
{''}
|
{''}
|
||||||
</SectionTitleLineWithButton>
|
</SectionTitleLineWithButton>
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<CardBox className='mb-4 text-sm text-red-600'>
|
|
||||||
{errorMessage}
|
|
||||||
</CardBox>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<CardBox>
|
<CardBox>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className='text-sm text-gray-500'>
|
<p className='text-sm text-gray-500'>Loading UI element types...</p>
|
||||||
Loading platform elements...
|
) : errorMessage ? (
|
||||||
</p>
|
<p className='text-sm text-red-600'>{errorMessage}</p>
|
||||||
) : !sortedElements.length ? (
|
) : rows.length === 0 ? (
|
||||||
<p className='text-sm text-gray-500'>No UI elements found.</p>
|
<p className='text-sm text-gray-500'>No UI element types found.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className='overflow-x-auto'>
|
<div className='space-y-2'>
|
||||||
<table className='w-full min-w-[680px] text-sm'>
|
{rows.map((item) => (
|
||||||
<thead>
|
<Link
|
||||||
<tr className='border-b border-gray-200 text-left dark:border-dark-700'>
|
|
||||||
<th className='py-2 pr-3 font-semibold'>Name</th>
|
|
||||||
<th className='py-2 pr-3 font-semibold'>Type</th>
|
|
||||||
<th className='py-2 pr-3 font-semibold'>Preview</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedElements.map((item) => (
|
|
||||||
<tr
|
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className='cursor-pointer border-b border-gray-100 hover:bg-gray-50 dark:border-dark-800 dark:hover:bg-dark-800'
|
href={`/ui-elements/${item.id}`}
|
||||||
onClick={() => router.push(`/ui-elements/${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'
|
||||||
>
|
>
|
||||||
<td className='py-3 pr-3 font-semibold'>{item.name}</td>
|
<p className='text-sm font-semibold'>
|
||||||
<td className='py-3 pr-3 text-xs text-gray-500'>
|
{item.name || toHumanLabel(item.element_type)}
|
||||||
{toElementLabel(item.elementType)}
|
</p>
|
||||||
</td>
|
<p className='text-xs text-gray-500'>
|
||||||
<td className='py-3 pr-3'>
|
{toHumanLabel(item.element_type)} • Order{' '}
|
||||||
<ElementPreview item={item} />
|
{Number(item.sort_order || 0)} •{' '}
|
||||||
</td>
|
{item.is_active ? 'Active' : 'Inactive'}
|
||||||
</tr>
|
</p>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBox>
|
</CardBox>
|
||||||
@ -191,7 +106,11 @@ const UiElementsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
UiElementsPage.getLayout = function getLayout(page: ReactElement) {
|
UiElementsPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
return (
|
||||||
|
<LayoutAuthenticated permission='READ_PAGE_ELEMENTS'>
|
||||||
|
{page}
|
||||||
|
</LayoutAuthenticated>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UiElementsPage;
|
export default UiElementsPage;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user