Files
readest/apps/readest-app/src/components/UserAvatar.tsx
T
Huang Xin f5657fb3a0 fix(share): correct recipient import flow and assorted UI polish (#4043)
- ensureSharedBookLocal helper makes sure the local library has both the
  Book entry and the bytes on disk after /import succeeds; navigating
  into the reader before this lands on "Book not found"
- ShareLanding navigates via navigateToReader (path form on web) so the
  reader actually renders instead of hitting the App Router stub and
  going blank
- Loading + progress UI on the landing page while bytes stream in;
  Open-in-app disabled mid-import to avoid races
- UserInfo header: vertically center avatar with name/email, tighter
  mobile gap, and a fillContainer prop on UserAvatar so a parent can
  size the box via classes without the inline style fighting back
- Rename "Share current page" -> "Share reading progress" (and matching
  post-generation hint) and shrink the dialog from 480 to 460px
- Drop the unused Reload Page menu item from SettingsMenu
- Translate the two new i18n keys across 31 locales

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:16:46 +02:00

105 lines
3.0 KiB
TypeScript

import clsx from 'clsx';
import Image from 'next/image';
import { useEffect, useState } from 'react';
import { IconType } from 'react-icons';
interface UserAvatarProps {
url: string;
size: number;
DefaultIcon: IconType;
className?: string;
borderClassName?: string;
/**
* When true, the root box stretches via h-full/w-full instead of getting
* the explicit `size` pinned via inline style. Use this when a parent
* constrains the avatar with its own (responsive) classes — otherwise the
* inline style overrides the parent box and the avatar can overflow on
* smaller breakpoints. `size` is still used as the intrinsic dimension
* for next/image and the fallback icon.
*/
fillContainer?: boolean;
}
const getStorageKey = (url: string) => {
return `avatar_${btoa(url).replace(/[^a-zA-Z0-9]/g, '')}`;
};
const UserAvatar: React.FC<UserAvatarProps> = ({
url,
size,
className,
borderClassName,
DefaultIcon,
fillContainer,
}) => {
const [cachedImageUrl, setCachedImageUrl] = useState<string | null>(() => {
if (!url) return null;
return localStorage.getItem(getStorageKey(url));
});
useEffect(() => {
if (!url) return;
const storageKey = getStorageKey(url);
const cached = localStorage.getItem(storageKey);
if (cached && cached === cachedImageUrl) {
return;
}
const cacheImage = async () => {
try {
const response = await fetch(url, { referrerPolicy: 'no-referrer' });
const blob = await response.blob();
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result as string;
try {
localStorage.setItem(storageKey, base64data);
setCachedImageUrl(base64data);
} catch (e) {
console.warn('Failed to cache avatar in localStorage:', e);
}
};
reader.readAsDataURL(blob);
} catch (error) {
console.error('Failed to cache avatar:', error);
}
};
cacheImage();
}, [url, cachedImageUrl]);
return (
<div
className='relative flex h-full w-full items-center justify-center rounded-full'
style={fillContainer ? undefined : { width: size, height: size }}
>
{url ? (
<div>
<Image
src={cachedImageUrl || url}
alt='User Avatar'
className={clsx('rounded-full', className, borderClassName)}
referrerPolicy='no-referrer'
width={size}
height={size}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
(e.target as HTMLImageElement).nextElementSibling?.classList.remove('invisible');
}}
/>
<div className='invisible absolute inset-0 flex items-center justify-center'>
<DefaultIcon className={clsx('text-neutral-content', className)} />
</div>
</div>
) : (
<DefaultIcon className='text-neutral-content' size={size} />
)}
</div>
);
};
export default UserAvatar;