mirror of
https://github.com/arc53/DocsGPT.git
synced 2026-02-17 03:30:54 +00:00
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
271
frontend/src/components/SearchableDropdown.tsx
Normal file
271
frontend/src/components/SearchableDropdown.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user