Fixes: re-blink in converstaion, (refactor) prompts and validate LocalStorage prompts (#2181)

* chore(dependabot): add react-widget npm dependency updates

* refactor(prompts): init on load, mv to pref slice

* (refactor): searchable dropdowns are separate

* (fix/ui) prompts adjust

* feat(changelog): dancing stars

* (fix)conversation: re-blink bubble past stream

* (fix)endless GET sources, esling err

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
This commit is contained in:
Manish Madan
2025-12-11 03:23:40 +05:30
committed by GitHub
parent 4adffe762a
commit 09e7c1b97f
14 changed files with 613 additions and 129 deletions

View File

@@ -1,7 +1,5 @@
import { useTranslation } from 'react-i18next';
import close from '../assets/cross.svg';
import rightArrow from '../assets/arrow-full-right.svg';
import bg from '../assets/notification-bg.jpg';
interface NotificationProps {
notificationText: string;
@@ -9,6 +7,16 @@ interface NotificationProps {
handleCloseNotification: () => void;
}
const stars = Array.from({ length: 12 }, (_, i) => ({
id: i,
size: Math.random() * 2 + 1, // 1-3px
left: Math.random() * 100, // 0-100%
top: Math.random() * 100, // 0-100%
animationDuration: Math.random() * 3 + 2, // 2-5s
animationDelay: Math.random() * 2, // 0-2s
opacity: Math.random() * 0.5 + 0.3, // 0.3-0.8
}));
export default function Notification({
notificationText,
notificationLink,
@@ -16,32 +24,112 @@ export default function Notification({
}: NotificationProps) {
const { t } = useTranslation();
return (
<a
className="absolute right-2 bottom-6 z-20 flex w-3/4 items-center justify-center gap-2 rounded-lg bg-cover bg-center bg-no-repeat px-2 py-4 sm:right-4 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
style={{ backgroundImage: `url(${bg})` }}
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
rel="noreferrer"
>
<p className="text-white-3000 text-xs leading-6 font-semibold xl:text-sm xl:leading-7">
{notificationText}
</p>
<span>
<img className="w-full" src={rightArrow} alt="" />
</span>
<>
<style>{`
@keyframes twinkle {
0%, 100% {
opacity: 0.3;
transform: scale(1) translateY(0);
}
50% {
opacity: 1;
transform: scale(1.2) translateY(-2px);
}
}
<button
className="absolute top-2 right-2 z-30 h-4 w-4 hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
.star {
animation: twinkle var(--duration) ease-in-out infinite;
animation-delay: var(--delay);
}
`}</style>
<a
className="group absolute right-2 bottom-6 z-20 flex w-3/4 items-center justify-center gap-2 overflow-hidden rounded-lg px-2 py-4 sm:right-4 md:w-2/5 lg:w-1/3 xl:w-1/4 2xl:w-1/5"
style={{
background:
'linear-gradient(90deg, #390086 0%, #6222B7 100%), linear-gradient(90deg, rgba(57, 0, 134, 0) 0%, #6222B7 53.02%, rgba(57, 0, 134, 0) 100%)',
}}
href={notificationLink}
target="_blank"
aria-label={t('notification.ariaLabel')}
rel="noreferrer"
>
<img className="w-full" src={close} alt="Close notification" />
</button>
</a>
{/* Animated stars background */}
<div className="pointer-events-none absolute inset-0">
{stars.map((star) => (
<svg
key={star.id}
className="star absolute"
style={
{
width: `${star.size * 4}px`,
height: `${star.size * 4}px`,
left: `${star.left}%`,
top: `${star.top}%`,
opacity: star.opacity,
filter: `drop-shadow(0 0 ${star.size}px rgba(255, 255, 255, 0.5))`,
'--duration': `${star.animationDuration}s`,
'--delay': `${star.animationDelay}s`,
} as React.CSSProperties & {
'--duration': string;
'--delay': string;
}
}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* 4-pointed Christmas star */}
<path
d="M12 0L13.5 10.5L24 12L13.5 13.5L12 24L10.5 13.5L0 12L10.5 10.5L12 0Z"
fill="white"
/>
</svg>
))}
</div>
<p className="text-white-3000 relative z-10 text-xs leading-6 font-semibold xl:text-sm xl:leading-7">
{notificationText}
</p>
<span className="relative z-10 flex items-center">
<svg
width="18"
height="13"
viewBox="0 0 18 13"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="overflow-visible"
>
{/* Arrow tail - grows leftward from arrow head's back point on hover */}
<rect
x="4"
y="5.75"
width="8"
height="1.5"
fill="white"
className="scale-x-0 transition-transform duration-300 ease-out group-hover:scale-x-100"
style={{ transformOrigin: '12px 6.5px' }}
/>
{/* Arrow head - pushed forward by the tail on hover */}
<path
d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033Z"
fill="white"
className="transition-transform duration-300 ease-out group-hover:translate-x-1"
/>
</svg>
</span>
<button
className="absolute top-2 right-2 z-30 h-4 w-4 hover:opacity-70"
aria-label={t('notification.closeAriaLabel')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCloseNotification();
}}
>
<img className="w-full" src={close} alt="Close notification" />
</button>
</a>
</>
);
}

View File

@@ -0,0 +1,271 @@
import React from 'react';
import Arrow2 from '../assets/dropdown-arrow.svg';
import Edit from '../assets/edit.svg';
import Search from '../assets/search.svg';
import Trash from '../assets/trash.svg';
/**
* SearchableDropdown - A standalone dropdown component with built-in search functionality
*/
type SearchableDropdownOptionBase = {
id?: string;
type?: string;
};
type NameIdOption = { name: string; id: string } & SearchableDropdownOptionBase;
export type SearchableDropdownOption =
| string
| NameIdOption
| ({ label: string; value: string } & SearchableDropdownOptionBase)
| ({ value: number; description: string } & SearchableDropdownOptionBase);
export type SearchableDropdownSelectedValue = SearchableDropdownOption | null;
export interface SearchableDropdownProps<
T extends SearchableDropdownOption = SearchableDropdownOption,
> {
options: T[];
selectedValue: SearchableDropdownSelectedValue;
onSelect: (value: T) => void;
size?: string;
/** Controls border radius for both button and dropdown menu */
rounded?: 'xl' | '3xl';
border?: 'border' | 'border-2';
showEdit?: boolean;
onEdit?: (value: NameIdOption) => void;
showDelete?: boolean | ((option: T) => boolean);
onDelete?: (id: string) => void;
placeholder?: string;
}
function SearchableDropdown<T extends SearchableDropdownOption>({
options,
selectedValue,
onSelect,
size = 'w-32',
rounded = 'xl',
border = 'border-2',
showEdit,
onEdit,
showDelete,
onDelete,
placeholder,
}: SearchableDropdownProps<T>) {
const dropdownRef = React.useRef<HTMLDivElement>(null);
const searchInputRef = React.useRef<HTMLInputElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const borderRadius = rounded === 'xl' ? 'rounded-xl' : 'rounded-3xl';
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
React.useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
const getOptionText = (option: SearchableDropdownOption): string => {
if (typeof option === 'string') return option;
if ('name' in option) return option.name;
if ('label' in option) return option.label;
if ('description' in option) return option.description;
return '';
};
const filteredOptions = React.useMemo(() => {
if (!searchQuery.trim()) return options;
const query = searchQuery.toLowerCase();
return options.filter((option) =>
getOptionText(option).toLowerCase().includes(query),
);
}, [options, searchQuery]);
const getDisplayValue = (): string => {
if (!selectedValue) return placeholder ?? 'From URL';
if (typeof selectedValue === 'string') return selectedValue;
if ('label' in selectedValue) return selectedValue.label;
if ('name' in selectedValue) return selectedValue.name;
if ('description' in selectedValue) {
return selectedValue.value < 1e9
? `${selectedValue.value} (${selectedValue.description})`
: selectedValue.description;
}
return placeholder ?? 'From URL';
};
const isOptionSelected = (option: T): boolean => {
if (!selectedValue) return false;
if (typeof selectedValue === 'string')
return selectedValue === (option as unknown as string);
if (typeof option === 'string') return false;
const optionObj = option as Record<string, unknown>;
const selectedObj = selectedValue as Record<string, unknown>;
if ('name' in optionObj && 'name' in selectedObj)
return selectedObj.name === optionObj.name;
if ('label' in optionObj && 'label' in selectedObj)
return selectedObj.label === optionObj.label;
if ('value' in optionObj && 'value' in selectedObj)
return selectedObj.value === optionObj.value;
return false;
};
return (
<div
className={`relative ${typeof selectedValue === 'string' ? '' : 'align-middle'} ${size}`}
ref={dropdownRef}
>
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex w-full cursor-pointer items-center justify-between ${border} border-silver dark:border-dim-gray bg-white px-5 py-3 dark:bg-transparent ${borderRadius}`}
>
<span
className={`dark:text-bright-gray truncate ${!selectedValue ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{getDisplayValue()}
</span>
<img
src={Arrow2}
alt="arrow"
className={`h-3 w-3 transform transition-transform ${isOpen ? 'rotate-180' : 'rotate-0'}`}
/>
</button>
{isOpen && (
<div
className={`absolute right-0 left-0 z-20 mt-2 ${borderRadius} dark:bg-dark-charcoal bg-[#FBFBFB] shadow-[0px_24px_48px_0px_#00000029]`}
>
<div
className={`border-silver dark:border-dim-gray dark:bg-dark-charcoal sticky top-0 z-10 border-b bg-[#FBFBFB] px-3 py-2 ${rounded === 'xl' ? 'rounded-t-xl' : 'rounded-t-3xl'}`}
>
<div className="relative flex items-center">
<img
src={Search}
alt="search"
width={14}
height={14}
className="absolute left-3"
/>
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search..."
className="dark:text-bright-gray w-full rounded-lg border-0 bg-transparent py-2 pr-3 pl-10 font-['Inter'] text-[14px] leading-[16.5px] font-normal focus:ring-0 focus:outline-none"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
<div className="max-h-40 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="px-5 py-3 text-center text-sm text-gray-500 dark:text-gray-400">
No results found
</div>
) : (
filteredOptions.map((option, index) => {
const selected = isOptionSelected(option);
const optionObj =
typeof option !== 'string'
? (option as Record<string, unknown>)
: null;
const optionType = optionObj?.type as string | undefined;
const optionId = optionObj?.id as string | undefined;
const optionName = optionObj?.name as string | undefined;
return (
<div
key={index}
className={`flex cursor-pointer items-center justify-between hover:bg-[#ECECEC] dark:hover:bg-[#545561] ${selected ? 'bg-[#ECECEC] dark:bg-[#545561]' : ''}`}
>
<span
onClick={() => {
onSelect(option);
setIsOpen(false);
setSearchQuery('');
}}
className="dark:text-light-gray ml-5 flex-1 overflow-hidden py-3 font-['Inter'] text-[14px] leading-[16.5px] font-normal text-ellipsis whitespace-nowrap"
>
{getOptionText(option)}
</span>
{showEdit &&
onEdit &&
optionObj &&
optionType !== 'public' && (
<img
src={Edit}
alt="Edit"
className="mr-4 h-4 w-4 cursor-pointer hover:opacity-50"
onClick={() => {
if (optionName && optionId) {
onEdit({
id: optionId,
name: optionName,
type: optionType,
});
}
setIsOpen(false);
setSearchQuery('');
}}
/>
)}
{showDelete && onDelete && (
<button
onClick={(e) => {
e.stopPropagation();
const id =
typeof option === 'string'
? option
: (optionId ?? '');
onDelete(id);
}}
className={`mr-2 h-4 w-4 cursor-pointer hover:opacity-50 ${
typeof showDelete === 'function' &&
!showDelete(option)
? 'hidden'
: ''
}`}
>
<img
src={Trash}
alt="Delete"
className={`mr-2 h-4 w-4 cursor-pointer hover:opacity-50 ${
optionType === 'public'
? 'cursor-not-allowed opacity-50'
: ''
}`}
/>
</button>
)}
</div>
);
})
)}
</div>
</div>
)}
</div>
);
}
export default SearchableDropdown;