490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
/**
|
|
* ConstructorToolbar Component
|
|
*
|
|
* Unified toolbar combining page controls and element actions.
|
|
* Glassmorphism styling with draggable positioning.
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect, forwardRef } from 'react';
|
|
import {
|
|
mdiDotsVertical,
|
|
mdiChevronDown,
|
|
mdiDelete,
|
|
mdiImageMultiple,
|
|
mdiViewCarousel,
|
|
mdiSwapHorizontal,
|
|
mdiText,
|
|
mdiPlus,
|
|
mdiExitToApp,
|
|
mdiChevronLeft,
|
|
mdiChevronRight,
|
|
mdiChevronUp,
|
|
mdiContentDuplicate,
|
|
mdiContentPaste,
|
|
mdiMusicNote,
|
|
mdiVideo,
|
|
mdiInformationOutline,
|
|
mdiPanoramaHorizontal,
|
|
} from '@mdi/js';
|
|
import BaseIcon from '../BaseIcon';
|
|
import BaseButton from '../BaseButton';
|
|
import ClickOutside from '../ClickOutside';
|
|
import PageSelector from './PageSelector';
|
|
import InteractionModeToggle from './InteractionModeToggle';
|
|
import MenuActionButton from './MenuActionButton';
|
|
import dataFormatter from '../../helpers/dataFormatter';
|
|
import type { ConstructorToolbarProps } from './types';
|
|
|
|
const ConstructorToolbar = forwardRef<HTMLDivElement, ConstructorToolbarProps>(
|
|
(
|
|
{
|
|
position,
|
|
onDragStart,
|
|
pages,
|
|
activePageId,
|
|
onPageChange,
|
|
onMovePage,
|
|
isReorderingPages = false,
|
|
onDuplicatePage,
|
|
isDuplicatingPage = false,
|
|
onDeletePage,
|
|
canDeletePage = true,
|
|
isDeletingPage = false,
|
|
interactionMode,
|
|
onModeChange,
|
|
onSelectMenuItem,
|
|
allowedNavigationTypes,
|
|
onAddElement,
|
|
onCopyElement,
|
|
onPasteElement,
|
|
canCopyElement = false,
|
|
canPasteElement = false,
|
|
onCreatePage,
|
|
isCreatingPage,
|
|
onSave,
|
|
onSaveToStage,
|
|
isSaving,
|
|
isSavingToStage,
|
|
lastSavedAt,
|
|
lastSavedToStageAt,
|
|
onExit,
|
|
},
|
|
ref,
|
|
) => {
|
|
// Local UI state
|
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
|
const [activeDropdown, setActiveDropdown] = useState<
|
|
'bg' | 'elements' | null
|
|
>(null);
|
|
|
|
// Refs for ClickOutside exclusion (following NavBarItem pattern)
|
|
const bgTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const elementsTriggerRef = useRef<HTMLButtonElement>(null);
|
|
const sortedPages = [...pages].sort((a, b) => {
|
|
const orderA =
|
|
typeof a.sort_order === 'number'
|
|
? a.sort_order
|
|
: Number.MAX_SAFE_INTEGER;
|
|
const orderB =
|
|
typeof b.sort_order === 'number'
|
|
? b.sort_order
|
|
: Number.MAX_SAFE_INTEGER;
|
|
if (orderA !== orderB) return orderA - orderB;
|
|
return (a.name || '').localeCompare(b.name || '');
|
|
});
|
|
const activePageIndex = sortedPages.findIndex(
|
|
(page) => page.id === activePageId,
|
|
);
|
|
const canMovePageUp =
|
|
Boolean(onMovePage) &&
|
|
!isReorderingPages &&
|
|
activePageIndex > 0 &&
|
|
sortedPages.length > 1;
|
|
const canMovePageDown =
|
|
Boolean(onMovePage) &&
|
|
!isReorderingPages &&
|
|
activePageIndex >= 0 &&
|
|
activePageIndex < sortedPages.length - 1;
|
|
const canDuplicatePage =
|
|
Boolean(onDuplicatePage) &&
|
|
!isDuplicatingPage &&
|
|
!isReorderingPages &&
|
|
activePageIndex >= 0;
|
|
const canDeleteCurrentPage =
|
|
Boolean(onDeletePage) &&
|
|
canDeletePage &&
|
|
!isDeletingPage &&
|
|
!isReorderingPages &&
|
|
activePageIndex >= 0;
|
|
const canCopyCurrentElement = Boolean(onCopyElement) && canCopyElement;
|
|
const canPasteCurrentElement = Boolean(onPasteElement) && canPasteElement;
|
|
|
|
// Keyboard handling (Escape closes dropdown)
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && activeDropdown) {
|
|
setActiveDropdown(null);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [activeDropdown]);
|
|
|
|
// Dropdown handlers
|
|
const closeDropdown = () => setActiveDropdown(null);
|
|
const toggleDropdown = (dropdown: 'bg' | 'elements') => {
|
|
setActiveDropdown((prev) => (prev === dropdown ? null : dropdown));
|
|
};
|
|
|
|
// Close dropdown after menu action
|
|
const handleMenuAction = (action: () => void) => {
|
|
action();
|
|
closeDropdown();
|
|
};
|
|
|
|
// Shared button styles
|
|
const triggerBtnClass =
|
|
'flex h-10 items-center gap-1.5 rounded px-3 text-sm font-medium text-white/90 transition-colors hover:bg-white/20';
|
|
const iconBtnClass =
|
|
'flex h-10 w-10 items-center justify-center rounded border border-white/20 bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-35';
|
|
const sectionClass =
|
|
'flex h-[58px] flex-col justify-center gap-1 border-x border-white/15 px-3';
|
|
const sectionLabelClass =
|
|
'text-center text-[9px] font-semibold uppercase leading-none tracking-wide text-white/50';
|
|
const dropdownPanelClass =
|
|
'absolute top-full left-0 mt-1 min-w-[180px] py-1 rounded-lg bg-white/50 backdrop-blur-xl border border-white/30 shadow-lg z-10 flex flex-col items-start';
|
|
|
|
// Collapsed state
|
|
if (isCollapsed) {
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className='fixed z-[1000] flex items-center gap-1 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl'
|
|
style={{ left: position.x, top: position.y }}
|
|
>
|
|
<div
|
|
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60'
|
|
onMouseDown={onDragStart}
|
|
>
|
|
<BaseIcon path={mdiDotsVertical} size={24} />
|
|
</div>
|
|
<button
|
|
type='button'
|
|
onClick={() => setIsCollapsed(false)}
|
|
className='flex items-center justify-center w-10 h-10 rounded text-white/60 hover:text-white/90 hover:bg-white/20 transition-colors'
|
|
title='Expand toolbar'
|
|
>
|
|
<BaseIcon path={mdiChevronRight} size={26} />
|
|
</button>
|
|
<span className='text-base font-medium text-white/80 truncate max-w-[140px] pr-2'>
|
|
{pages.find((p) => p.id === activePageId)?.name || 'Page'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className='fixed z-[1000] flex min-h-[72px] items-center gap-2 px-2 py-1.5 rounded-lg bg-white/10 backdrop-blur-xl border border-white/30 shadow-xl max-w-[95vw]'
|
|
style={{ left: position.x, top: position.y }}
|
|
>
|
|
{/* Drag Handle */}
|
|
<div
|
|
className='cursor-move flex items-center justify-center w-10 h-10 text-white/60 hover:text-white/90'
|
|
onMouseDown={onDragStart}
|
|
>
|
|
<BaseIcon path={mdiDotsVertical} size={24} />
|
|
</div>
|
|
|
|
<div className='flex h-[58px] items-start border-r border-white/15 pt-[15px] pr-3'>
|
|
<InteractionModeToggle
|
|
mode={interactionMode}
|
|
onModeChange={onModeChange}
|
|
compact
|
|
/>
|
|
</div>
|
|
|
|
<div className={sectionClass}>
|
|
<span className={sectionLabelClass}>
|
|
Page actions
|
|
</span>
|
|
<div className='flex h-10 items-center gap-2'>
|
|
<PageSelector
|
|
pages={pages}
|
|
activePageId={activePageId}
|
|
onPageChange={onPageChange}
|
|
disabled={isReorderingPages}
|
|
className='h-10 min-w-[210px]'
|
|
/>
|
|
<div className='flex items-center gap-1'>
|
|
<button
|
|
type='button'
|
|
onClick={() => onMovePage?.('up')}
|
|
disabled={!canMovePageUp}
|
|
className={iconBtnClass}
|
|
title='Move page up'
|
|
aria-label='Move page up'
|
|
>
|
|
<BaseIcon path={mdiChevronUp} size={22} />
|
|
</button>
|
|
<button
|
|
type='button'
|
|
onClick={() => onMovePage?.('down')}
|
|
disabled={!canMovePageDown}
|
|
className={iconBtnClass}
|
|
title='Move page down'
|
|
aria-label='Move page down'
|
|
>
|
|
<BaseIcon path={mdiChevronDown} size={22} />
|
|
</button>
|
|
</div>
|
|
<button
|
|
type='button'
|
|
onClick={onCreatePage}
|
|
disabled={isCreatingPage}
|
|
className={`${triggerBtnClass} ${isCreatingPage ? 'opacity-50 cursor-not-allowed' : ''}`}
|
|
>
|
|
<BaseIcon path={mdiPlus} size={18} />
|
|
<span>{isCreatingPage ? 'Creating...' : 'Page'}</span>
|
|
</button>
|
|
<button
|
|
type='button'
|
|
onClick={onDuplicatePage}
|
|
disabled={!canDuplicatePage}
|
|
className={iconBtnClass}
|
|
title='Duplicate page'
|
|
aria-label='Duplicate page'
|
|
>
|
|
<BaseIcon path={mdiContentDuplicate} size={20} />
|
|
</button>
|
|
<button
|
|
type='button'
|
|
onClick={onDeletePage}
|
|
disabled={!canDeleteCurrentPage}
|
|
className='flex h-10 w-10 items-center justify-center rounded border border-red-300/30 bg-red-500/10 text-red-200 transition-colors hover:bg-red-500/20 hover:text-red-100 disabled:cursor-not-allowed disabled:opacity-35'
|
|
title='Delete page'
|
|
aria-label='Delete page'
|
|
>
|
|
<BaseIcon path={mdiDelete} size={20} />
|
|
</button>
|
|
<div className='relative'>
|
|
<button
|
|
ref={bgTriggerRef}
|
|
type='button'
|
|
onClick={() => toggleDropdown('bg')}
|
|
className={triggerBtnClass}
|
|
>
|
|
<BaseIcon path={mdiImageMultiple} size={18} />
|
|
<span>BG</span>
|
|
<BaseIcon path={mdiChevronDown} size={16} />
|
|
</button>
|
|
{activeDropdown === 'bg' && (
|
|
<ClickOutside
|
|
onClickOutside={closeDropdown}
|
|
excludedElements={[bgTriggerRef]}
|
|
>
|
|
<div className={dropdownPanelClass}>
|
|
<MenuActionButton
|
|
icon={mdiImageMultiple}
|
|
label='Background Image'
|
|
onClick={() =>
|
|
handleMenuAction(() =>
|
|
onSelectMenuItem('background_image'),
|
|
)
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiVideo}
|
|
label='Background Video'
|
|
onClick={() =>
|
|
handleMenuAction(() =>
|
|
onSelectMenuItem('background_video'),
|
|
)
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiPanoramaHorizontal}
|
|
label='Background 360'
|
|
onClick={() =>
|
|
handleMenuAction(() =>
|
|
onSelectMenuItem('background_embed'),
|
|
)
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiMusicNote}
|
|
label='Background Audio'
|
|
onClick={() =>
|
|
handleMenuAction(() =>
|
|
onSelectMenuItem('background_audio'),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
</ClickOutside>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={sectionClass}>
|
|
<span className={sectionLabelClass}>
|
|
Elements actions
|
|
</span>
|
|
<div className='flex h-10 items-center gap-2'>
|
|
<div className='relative'>
|
|
<button
|
|
ref={elementsTriggerRef}
|
|
type='button'
|
|
onClick={() => toggleDropdown('elements')}
|
|
className={triggerBtnClass}
|
|
>
|
|
<BaseIcon path={mdiPlus} size={18} />
|
|
<span>Elements</span>
|
|
<BaseIcon path={mdiChevronDown} size={16} />
|
|
</button>
|
|
{activeDropdown === 'elements' && (
|
|
<ClickOutside
|
|
onClickOutside={closeDropdown}
|
|
excludedElements={[elementsTriggerRef]}
|
|
>
|
|
<div className={dropdownPanelClass}>
|
|
<MenuActionButton
|
|
icon={mdiSwapHorizontal}
|
|
label='Navigation Button'
|
|
onClick={() =>
|
|
handleMenuAction(() =>
|
|
onAddElement(allowedNavigationTypes[0]),
|
|
)
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiImageMultiple}
|
|
label='Gallery'
|
|
onClick={() =>
|
|
handleMenuAction(() => onAddElement('gallery'))
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiViewCarousel}
|
|
label='Carousel'
|
|
onClick={() =>
|
|
handleMenuAction(() => onAddElement('carousel'))
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiText}
|
|
label='Description'
|
|
onClick={() =>
|
|
handleMenuAction(() => onAddElement('description'))
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiVideo}
|
|
label='Video Player'
|
|
onClick={() =>
|
|
handleMenuAction(() => onAddElement('video_player'))
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiMusicNote}
|
|
label='Audio Player'
|
|
onClick={() =>
|
|
handleMenuAction(() => onAddElement('audio_player'))
|
|
}
|
|
/>
|
|
<MenuActionButton
|
|
icon={mdiInformationOutline}
|
|
label='Info Panel'
|
|
onClick={() =>
|
|
handleMenuAction(() => onAddElement('info_panel'))
|
|
}
|
|
/>
|
|
</div>
|
|
</ClickOutside>
|
|
)}
|
|
</div>
|
|
<div className='flex items-center gap-1'>
|
|
<button
|
|
type='button'
|
|
onClick={onCopyElement}
|
|
disabled={!canCopyCurrentElement}
|
|
className={iconBtnClass}
|
|
title='Copy selected element'
|
|
aria-label='Copy selected element'
|
|
>
|
|
<BaseIcon path={mdiContentDuplicate} size={20} />
|
|
</button>
|
|
<button
|
|
type='button'
|
|
onClick={onPasteElement}
|
|
disabled={!canPasteCurrentElement}
|
|
className={iconBtnClass}
|
|
title='Paste copied element'
|
|
aria-label='Paste copied element'
|
|
>
|
|
<BaseIcon path={mdiContentPaste} size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='flex h-[58px] items-start gap-2 border-l border-white/15 pt-4 pl-3'>
|
|
{/* Save Button - reuse BaseButton with subtitle */}
|
|
<BaseButton
|
|
small
|
|
color='info'
|
|
label={isSaving ? 'Saving...' : 'Save'}
|
|
subtitle={
|
|
lastSavedAt
|
|
? dataFormatter.relativeTimestamp(lastSavedAt)
|
|
: undefined
|
|
}
|
|
onClick={onSave}
|
|
disabled={isSaving}
|
|
/>
|
|
|
|
{/* Save to Stage Button */}
|
|
<BaseButton
|
|
small
|
|
color='success'
|
|
label={isSavingToStage ? 'Saving...' : 'Stage'}
|
|
subtitle={
|
|
lastSavedToStageAt
|
|
? dataFormatter.relativeTimestamp(lastSavedToStageAt)
|
|
: undefined
|
|
}
|
|
onClick={onSaveToStage}
|
|
disabled={isSavingToStage}
|
|
/>
|
|
|
|
{/* Exit Button */}
|
|
<button
|
|
type='button'
|
|
onClick={onExit}
|
|
className='flex h-10 w-10 items-center justify-center rounded text-red-400 transition-colors hover:bg-red-500/20 hover:text-red-300'
|
|
title='Exit constructor'
|
|
>
|
|
<BaseIcon path={mdiExitToApp} size={26} />
|
|
</button>
|
|
|
|
{/* Collapse Toggle */}
|
|
<button
|
|
type='button'
|
|
onClick={() => setIsCollapsed(true)}
|
|
className='flex h-10 w-10 items-center justify-center rounded text-white/60 transition-colors hover:bg-white/20 hover:text-white/90'
|
|
title='Collapse toolbar'
|
|
>
|
|
<BaseIcon path={mdiChevronLeft} size={26} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
ConstructorToolbar.displayName = 'ConstructorToolbar';
|
|
|
|
export default ConstructorToolbar;
|