39948-vm/frontend/src/components/Constructor/ConstructorToolbar.tsx
2026-06-27 13:24:20 +02:00

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;