- Deleted redundant components (`AddActionButton`, `AlertBox`, `AlertStack`, `BackButton`, `CancelButton`, and `CollapsableArea`) and related files. - Removed unused models (`Book`, `BookSerie`, `BookTables`, `Character`, and `Chapter`) to reduce codebase clutter. - Updated project structure and references to reflect these removals.
400 lines
14 KiB
TypeScript
400 lines
14 KiB
TypeScript
import React, {JSX, useEffect, useRef, useState} from 'react';
|
|
import {createPortal} from 'react-dom';
|
|
import {X} from 'lucide-react';
|
|
import Button from '@/components/ui/Button';
|
|
import IconButton from '@/components/ui/IconButton';
|
|
|
|
export type GuidePosition =
|
|
'top'
|
|
| 'bottom'
|
|
| 'left'
|
|
| 'right'
|
|
| 'auto'
|
|
| 'top-left'
|
|
| 'top-right'
|
|
| 'bottom-left'
|
|
| 'bottom-right';
|
|
|
|
export interface GuideStep {
|
|
id: number;
|
|
x?: number;
|
|
y?: number;
|
|
title: string;
|
|
content: React.ReactNode;
|
|
targetSelector?: string;
|
|
highlightRadius?: number;
|
|
position?: GuidePosition;
|
|
}
|
|
|
|
interface GuideTourProps {
|
|
stepId: number;
|
|
steps: GuideStep[];
|
|
onClose: () => void;
|
|
onComplete: () => void;
|
|
}
|
|
|
|
/**
|
|
* Generates the spotlight background style for a given guide step.
|
|
*
|
|
* @param {GuideStep} step - The guide step containing information about the target element,
|
|
* position, and properties for spotlight rendering.
|
|
* @return {string} The CSS background string representing the spotlight effect.
|
|
*/
|
|
function getOverlayColor(opacity: number): string {
|
|
const style: CSSStyleDeclaration = getComputedStyle(document.documentElement);
|
|
const darkest: string = style.getPropertyValue('--theme-darkest-background').trim() || '#1A1A1A';
|
|
const hex: string = darkest.replace('#', '');
|
|
const r: number = parseInt(hex.substring(0, 2), 16);
|
|
const g: number = parseInt(hex.substring(2, 4), 16);
|
|
const b: number = parseInt(hex.substring(4, 6), 16);
|
|
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
}
|
|
|
|
function getSpotlightBackground(step: GuideStep): string {
|
|
if (step.x !== undefined && step.y !== undefined) {
|
|
return getOverlayColor(0.5);
|
|
}
|
|
if (!step.targetSelector) {
|
|
return getOverlayColor(0.5);
|
|
}
|
|
const element: HTMLElement | null = document.querySelector<HTMLElement>(step.targetSelector);
|
|
if (!element) {
|
|
return getOverlayColor(0.5);
|
|
}
|
|
const rect: DOMRect = element.getBoundingClientRect();
|
|
const centerX: number = rect.left + rect.width / 2;
|
|
const centerY: number = rect.top + rect.height / 2;
|
|
const radius: number = Math.max(rect.width, rect.height) / 2 + (step.highlightRadius || 10);
|
|
|
|
return `radial-gradient(circle at ${centerX}px ${centerY}px, transparent ${radius}px, ${getOverlayColor(0.65)} ${radius + 20}px)`;
|
|
}
|
|
|
|
/**
|
|
* Determines the position of a popover element based on the provided guide step properties.
|
|
*
|
|
* @param {GuideStep} step - An object containing the configuration for positioning the popover, including its x and y coordinates, target selector, and preferred position.
|
|
* @return {React.CSSProperties} An object representing the CSS properties to position the popover, including `left`, `top`, and optionally `transform` values.
|
|
*/
|
|
interface PopoverPosition {
|
|
left: string;
|
|
top: string;
|
|
transform?: string;
|
|
}
|
|
|
|
function getPopoverPosition(step: GuideStep): PopoverPosition {
|
|
if (step.x !== undefined && step.y !== undefined) {
|
|
return {
|
|
left: `${step.x}%`,
|
|
top: `${step.y}%`,
|
|
transform: 'translate(-50%, -50%)'
|
|
};
|
|
}
|
|
|
|
if (!step.targetSelector) {
|
|
return {
|
|
left: '50%',
|
|
top: '50%',
|
|
transform: 'translate(-50%, -50%)'
|
|
};
|
|
}
|
|
|
|
const element: HTMLElement | null = document.querySelector<HTMLElement>(step.targetSelector);
|
|
if (!element) {
|
|
return {
|
|
left: '50%',
|
|
top: '50%',
|
|
transform: 'translate(-50%, -50%)'
|
|
};
|
|
}
|
|
|
|
const rect: DOMRect = element.getBoundingClientRect();
|
|
const {left, top, width, height} = rect;
|
|
const popoverWidth: number = 420;
|
|
const popoverHeight: number = 300;
|
|
const margin: number = 20;
|
|
const position: GuidePosition = step.position || 'auto';
|
|
|
|
switch (position) {
|
|
case 'top':
|
|
return {
|
|
left: `${Math.max(margin, Math.min(left + width / 2 - popoverWidth / 2, window.innerWidth - popoverWidth - margin))}px`,
|
|
top: `${Math.max(margin, top - popoverHeight - margin)}px`,
|
|
};
|
|
|
|
case 'bottom':
|
|
return {
|
|
left: `${Math.max(margin, Math.min(left + width / 2 - popoverWidth / 2, window.innerWidth - popoverWidth - margin))}px`,
|
|
top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`,
|
|
};
|
|
|
|
case 'left':
|
|
return {
|
|
left: `${Math.max(margin, left - popoverWidth - margin)}px`,
|
|
top: `${Math.max(margin, Math.min(top + height / 2 - popoverHeight / 2, window.innerHeight - popoverHeight - margin))}px`,
|
|
};
|
|
|
|
case 'right':
|
|
return {
|
|
left: `${Math.min(left + width + margin, window.innerWidth - popoverWidth - margin)}px`,
|
|
top: `${Math.max(margin, Math.min(top + height / 2 - popoverHeight / 2, window.innerHeight - popoverHeight - margin))}px`,
|
|
};
|
|
|
|
case 'top-left':
|
|
return {
|
|
left: `${Math.max(margin, left)}px`,
|
|
top: `${Math.max(margin, top - popoverHeight - margin)}px`,
|
|
};
|
|
|
|
case 'top-right':
|
|
return {
|
|
left: `${Math.max(margin, Math.min(left + width - popoverWidth, window.innerWidth - popoverWidth - margin))}px`,
|
|
top: `${Math.max(margin, top - popoverHeight - margin)}px`,
|
|
};
|
|
|
|
case 'bottom-left':
|
|
return {
|
|
left: `${Math.max(margin, left)}px`,
|
|
top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`,
|
|
};
|
|
|
|
case 'bottom-right':
|
|
return {
|
|
left: `${Math.max(margin, Math.min(left + width - popoverWidth, window.innerWidth - popoverWidth - margin))}px`,
|
|
top: `${Math.min(top + height + margin, window.innerHeight - popoverHeight - margin)}px`,
|
|
};
|
|
|
|
case 'auto':
|
|
default:
|
|
let x: number = left + width + margin;
|
|
let y: number = top + height / 2 - popoverHeight / 2;
|
|
|
|
if (x + popoverWidth > window.innerWidth - margin) {
|
|
x = left - popoverWidth - margin;
|
|
}
|
|
|
|
if (x < margin) {
|
|
x = left + width / 2 - popoverWidth / 2;
|
|
y = top + height + margin;
|
|
}
|
|
|
|
x = Math.max(margin, Math.min(x, window.innerWidth - popoverWidth - margin));
|
|
y = Math.max(margin, Math.min(y, window.innerHeight - popoverHeight - margin));
|
|
|
|
return {
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A component that guides the user through a series of steps.
|
|
* Displays a sequence of instructional overlay elements based on the provided steps.
|
|
* Handles navigation between steps and supports custom actions upon completion or closure.
|
|
*
|
|
* @param {Object} props - The properties object.
|
|
* @param {number} props.stepId - The initial step ID to start the guide.
|
|
* @param {Array} props.steps - An array of objects representing each step of the guide.
|
|
* Each step should include necessary details such as its ID and other metadata.
|
|
* @param {Function} props.onClose - Callback function executed when the guide is closed manually.
|
|
* @param {Function} props.onComplete - Callback function executed when the guide is completed after the last step.
|
|
*
|
|
* @return {JSX.Element|null} The guide tour component that renders the step-by-step instructions,
|
|
* or null if no steps are available or the initial conditions aren't met.
|
|
*/
|
|
export default function GuideTour({stepId, steps, onClose, onComplete}: GuideTourProps): JSX.Element | null {
|
|
const [currentStep, setCurrentStep] = useState<number>(0);
|
|
const [isVisible, setIsVisible] = useState<boolean>(false);
|
|
const [rendered, setRendered] = useState<boolean>(false);
|
|
const overlayRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
|
|
|
const filteredSteps: GuideStep[] = React.useMemo((): GuideStep[] => {
|
|
return steps.filter((step: GuideStep): boolean => step.id >= stepId);
|
|
}, [steps, stepId]);
|
|
|
|
const currentStepData: GuideStep = filteredSteps[currentStep];
|
|
|
|
const timeoutRef: React.RefObject<NodeJS.Timeout | null> = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
function showStep(index: number): void {
|
|
setIsVisible(false);
|
|
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
|
|
timeoutRef.current = setTimeout((): void => {
|
|
setCurrentStep(index);
|
|
setRendered(false);
|
|
|
|
const step: GuideStep = filteredSteps[index];
|
|
if (step?.targetSelector) {
|
|
const element: HTMLElement | null = document.querySelector<HTMLElement>(step.targetSelector);
|
|
if (element) {
|
|
element.scrollIntoView({behavior: 'smooth', block: 'center'});
|
|
}
|
|
}
|
|
|
|
timeoutRef.current = setTimeout((): void => {
|
|
setRendered(true);
|
|
|
|
timeoutRef.current = setTimeout((): void => {
|
|
setIsVisible(true);
|
|
}, 50);
|
|
}, 600);
|
|
}, 200);
|
|
}
|
|
|
|
useEffect((): () => void => {
|
|
showStep(0);
|
|
|
|
return (): void => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
useEffect((): void => {
|
|
if (overlayRef.current) {
|
|
overlayRef.current.style.background = rendered ? getSpotlightBackground(currentStepData) : getOverlayColor(0.5);
|
|
overlayRef.current.style.opacity = isVisible ? '1' : '0';
|
|
}
|
|
}, [rendered, isVisible, currentStepData]);
|
|
|
|
function handleNext(): void {
|
|
if (currentStep < filteredSteps.length - 1) {
|
|
showStep(currentStep + 1);
|
|
} else {
|
|
onComplete();
|
|
}
|
|
}
|
|
|
|
function handlePrevious(): void {
|
|
if (currentStep > 0) {
|
|
showStep(currentStep - 1);
|
|
}
|
|
}
|
|
if (!filteredSteps.length || !currentStepData) {
|
|
return null;
|
|
}
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-[60] font-['Lora']">
|
|
<div
|
|
ref={overlayRef}
|
|
className="absolute inset-0 transition-opacity duration-500"
|
|
onClick={onClose}
|
|
/>
|
|
{rendered && (
|
|
<GuidePopup
|
|
step={currentStepData}
|
|
isVisible={isVisible}
|
|
currentStep={currentStep}
|
|
totalSteps={filteredSteps.length}
|
|
onPrevious={handlePrevious}
|
|
onNext={handleNext}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Functional component that displays a guide popup. This popup includes step-based navigation,
|
|
* title, content, and control buttons for navigating between steps or closing the popup.
|
|
*
|
|
* @param {object} params - The parameters for the GuidePopup component.
|
|
* @param {GuideStep} params.step - The current guide step data, containing title and content.
|
|
* @param {boolean} params.isVisible - Determines whether the popup is visible.
|
|
* @param {number} params.currentStep - The index of the current step in the guide.
|
|
* @param {number} params.totalSteps - Total number of steps in the guide.
|
|
* @param {function} params.onPrevious - Callback invoked when navigating to the previous step.
|
|
* @param {function} params.onNext - Callback invoked when navigating to the next step.
|
|
* @param {function} params.onClose - Callback invoked when closing the popup.
|
|
* @return {JSX.Element} The rendered GuidePopup component.
|
|
*/
|
|
function GuidePopup(
|
|
{
|
|
step,
|
|
isVisible,
|
|
currentStep,
|
|
totalSteps,
|
|
onPrevious,
|
|
onNext,
|
|
onClose
|
|
}: {
|
|
step: GuideStep;
|
|
isVisible: boolean;
|
|
currentStep: number;
|
|
totalSteps: number;
|
|
onPrevious: () => void;
|
|
onNext: () => void;
|
|
onClose: () => void;
|
|
}): JSX.Element {
|
|
const popupRef: React.RefObject<HTMLDivElement | null> = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect((): void => {
|
|
if (popupRef.current) {
|
|
const pos: PopoverPosition = getPopoverPosition(step);
|
|
popupRef.current.style.left = pos.left;
|
|
popupRef.current.style.top = pos.top;
|
|
popupRef.current.style.transform = pos.transform || '';
|
|
}
|
|
}, [step]);
|
|
|
|
return (
|
|
<div
|
|
ref={popupRef}
|
|
className={`absolute bg-tertiary border border-primary/30 rounded-xl w-96 transition-opacity duration-300 ${
|
|
isVisible ? 'opacity-100' : 'opacity-0'
|
|
}`}
|
|
>
|
|
<div className="px-8 py-6 border-b border-secondary">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 mr-6">
|
|
<h3 className="text-text-primary font-semibold text-xl mb-3">
|
|
{step.title}
|
|
</h3>
|
|
<div className="flex items-center space-x-4">
|
|
<span className="text-primary text-sm font-medium bg-primary/10 px-3 py-1 rounded-full">
|
|
Étape {currentStep + 1} sur {totalSteps}
|
|
</span>
|
|
<div className="flex items-center space-x-2">
|
|
{Array.from({length: totalSteps}).map((_: unknown, index: number) => (
|
|
<div
|
|
key={index}
|
|
className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
|
|
index <= currentStep ? 'bg-primary' : 'bg-secondary'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<IconButton icon={X} variant="ghost" shape="square" onClick={onClose}/>
|
|
</div>
|
|
</div>
|
|
<div className="px-8 py-8">
|
|
<div className="text-text-secondary text-base leading-relaxed space-y-4">
|
|
{step.content}
|
|
</div>
|
|
</div>
|
|
<div className="px-8 py-6 bg-tertiary border-t border-secondary rounded-b-xl">
|
|
<div className="flex items-center justify-between">
|
|
{currentStep > 0 ? (
|
|
<Button variant="ghost" size="sm" onClick={onPrevious}>
|
|
← Précédent
|
|
</Button>
|
|
) : (
|
|
<div></div>
|
|
)}
|
|
<Button variant="primary" onClick={onNext}>
|
|
{currentStep === totalSteps - 1 ? '🎉 Terminer' : 'Continuer →'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |