Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/components/rule-preview/RulesPreviewActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@ import { ExternalLink } from 'lucide-react';
import React from 'react';
import { aiEnvironmentConfig } from '../../data/ai-environments.ts';
import { useProjectStore } from '../../store/projectStore';
import Tooltip from '../ui/Tooltip.tsx';

export const RulesPreviewActions: React.FC<unknown> = () => {
const { selectedEnvironment } = useProjectStore();

return (
<a
href={aiEnvironmentConfig[selectedEnvironment].docsUrl}
target="_blank"
className="px-3 py-1 bg-purple-700 text-white rounded-md hover:bg-purple-600 flex items-center text-sm opacity-40 hover:opacity-100 cursor-pointer"
aria-label={`Open documentation for ${selectedEnvironment}`}
<Tooltip
content={`Open documentation for ${selectedEnvironment.charAt(0).toUpperCase() + selectedEnvironment.slice(1)}`}
position="bottom"
>
<ExternalLink className="h-4 w-4" />
</a>
<a
href={aiEnvironmentConfig[selectedEnvironment].docsUrl}
target="_blank"
className="px-3 py-1 bg-purple-700 text-white rounded-md hover:bg-purple-600 flex items-center text-sm opacity-40 hover:opacity-100 cursor-pointer"
aria-label={`Open documentation for ${selectedEnvironment}`}
>
<ExternalLink className="h-4 w-4" />
</a>
</Tooltip>
);
};

Expand Down
93 changes: 53 additions & 40 deletions src/components/rule-preview/RulesPreviewCopyDownloadActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { Fragment, useState } from 'react';
import { aiEnvironmentConfig } from '../../data/ai-environments.ts';
import type { RulesContent } from '../../services/rules-builder/RulesBuilderTypes.ts';
import { useProjectStore } from '../../store/projectStore';
import { Tooltip } from '../ui/Tooltip.tsx';

interface RulesPreviewCopyDownloadActionsProps {
rulesContent: RulesContent[];
Expand Down Expand Up @@ -94,53 +95,65 @@ export const RulesPreviewCopyDownloadActions: React.FC<RulesPreviewCopyDownloadA

return (
<Fragment>
<button
onClick={handleCopy}
className={`px-3 py-1 ${
showCopiedMessage
? 'bg-green-700 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
} rounded-md flex items-center transition-colors duration-200 text-sm opacity-40 hover:opacity-100 cursor-pointer`}
aria-label="Copy"
<Tooltip
content={
showCopiedMessage ? 'Copied!' : singleRuleContent ? 'Copy to clipboard' : 'Copy all rules'
}
position="bottom"
>
<button
onClick={handleCopy}
className={`px-3 py-1 ${
showCopiedMessage
? 'bg-green-700 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
} rounded-md flex items-center transition-colors duration-200 text-sm opacity-40 hover:opacity-100 cursor-pointer`}
aria-label="Copy"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
{showCopiedMessage ? (
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
) : (
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
)}
{!showCopiedMessage && (
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
)}
</svg>
</button>
</Tooltip>
<Tooltip
content={singleRuleContent ? 'Download file' : 'Download all rules'}
position="bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
<button
onClick={handleDownload}
className="px-3 py-1 bg-indigo-700 text-white rounded-md hover:bg-indigo-600 flex items-center text-sm opacity-40 hover:opacity-100 cursor-pointer"
aria-label="Download"
>
{showCopiedMessage ? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
) : (
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
)}
{!showCopiedMessage && (
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
)}
</svg>
</button>
<button
onClick={handleDownload}
className="px-3 py-1 bg-indigo-700 text-white rounded-md hover:bg-indigo-600 flex items-center text-sm opacity-40 hover:opacity-100 cursor-pointer"
aria-label="Download"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</svg>
</button>
</Tooltip>
</Fragment>
);
};
Expand Down
154 changes: 154 additions & 0 deletions src/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React, { useState, useRef, useLayoutEffect, type ReactNode } from 'react';

interface TooltipProps {
content: string;
children: ReactNode;
position?: 'top' | 'bottom' | 'left' | 'right';
delay?: number;
className?: string;
}

export const Tooltip: React.FC<TooltipProps> = ({
content,
children,
position = 'top',
delay = 300,
className = '',
}) => {
const [isVisible, setIsVisible] = useState(false);
const [actualPosition, setActualPosition] = useState(position);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);

useLayoutEffect(() => {
if (tooltipRef.current && containerRef.current) {
const tooltip = tooltipRef.current;
const container = containerRef.current;
const tooltipRect = tooltip.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;

let newPosition = position;

// Check if tooltip is out of the right edge of the screen
if (containerRect.left + tooltipRect.width / 2 > viewportWidth - 64) {
if (position === 'top' || position === 'bottom') {
newPosition = 'left';
}
}

// Check if tooltip is out of the left edge of the screen
if (containerRect.left - tooltipRect.width / 2 < 64) {
if (position === 'top' || position === 'bottom') {
newPosition = 'right';
}
}

// Check if tooltip is out of the top edge of the screen
if (containerRect.top - tooltipRect.height < 64) {
if (position === 'top') {
newPosition = 'bottom';
}
}

// Check if tooltip is out of the bottom edge of the screen
if (containerRect.bottom + tooltipRect.height > viewportHeight - 64) {
if (position === 'bottom') {
newPosition = 'top';
}
}

setActualPosition(newPosition);
}
}, [position]);

const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}

timeoutRef.current = setTimeout(() => {
setIsVisible(true);

// Auto-hide after 1.5 second of showing
hideTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
}, 1500);
}, delay);
};

const handleMouseLeave = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
setIsVisible(false);
};

const getTooltipPosition = () => {
const baseClasses =
'absolute z-50 px-2 py-1 text-xs text-white bg-gray-900 border border-gray-600 rounded shadow-lg whitespace-nowrap pointer-events-none';

switch (actualPosition) {
case 'top':
return `${baseClasses} bottom-full left-1/2 transform -translate-x-1/2 mb-2`;
case 'bottom':
return `${baseClasses} top-full left-1/2 transform -translate-x-1/2 mt-2`;
case 'left':
return `${baseClasses} right-full top-1/2 transform -translate-y-1/2 mr-2`;
case 'right':
return `${baseClasses} left-full top-1/2 transform -translate-y-1/2 ml-2`;
default:
return `${baseClasses} bottom-full left-1/2 transform -translate-x-1/2 mb-2`;
}
};

const getArrowClasses = () => {
const arrowBase = 'absolute w-2 h-2 bg-gray-900 border border-gray-600 transform -rotate-45';

switch (actualPosition) {
case 'top':
return `${arrowBase} border-t-0 border-r-0 top-full left-1/2 -translate-x-1/2 -translate-y-1/2`;
case 'bottom':
return `${arrowBase} border-b-0 border-l-0 bottom-full left-1/2 -translate-x-1/2 translate-y-1/2`;
case 'left':
return `${arrowBase} border-l-0 border-t-0 left-full top-1/2 -translate-x-1/2 -translate-y-1/2`;
case 'right':
return `${arrowBase} border-r-0 border-b-0 right-full top-1/2 translate-x-1/2 -translate-y-1/2`;
default:
return `${arrowBase} border-t-0 border-r-0 top-full left-1/2 -translate-x-1/2 -translate-y-1/2`;
}
};

return (
<div
ref={containerRef}
className={`relative inline-block ${className}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
<div
ref={tooltipRef}
className={`${getTooltipPosition()} transition-opacity duration-200 ${
isVisible ? 'opacity-100' : 'opacity-0'
}`}
role="tooltip"
style={{ visibility: isVisible ? 'visible' : 'hidden' }}
>
{content}
<div className={getArrowClasses()} />
</div>
</div>
);
};

export default Tooltip;
Loading