feat(settings): click own avatar in profile preview to view fullscreen

Lightbox was previously a file-private component inside AttachmentImage
(used for enlarging chat image attachments). Extracted to a standalone
components/Lightbox.tsx so other surfaces can reuse the same dialog
without duplicating Esc/backdrop/body-overflow plumbing.

In SettingsPage's profile live-preview, the round avatar overlapping the
banner is now wrapped in a transparent button that opens the Lightbox
with the full-resolution avatar URL on click. Cursor switches to
zoom-in. Disabled when the user only has the initial-letter placeholder
(nothing meaningful to enlarge). Native button chrome (border, padding,
button-face background) is reset to keep the avatar circle's appearance
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-12 22:58:22 +02:00
parent 12c66d676a
commit 0d65a134fd
3 changed files with 89 additions and 45 deletions
@@ -3,7 +3,8 @@ import { useEffect, useState } from 'react';
import { getCachedAttachment, putCachedAttachment } from '../lib/attachmentCache';
import { supabase } from '../lib/supabase';
import { AlertIcon, SpinnerIcon, XIcon } from './icons';
import { AlertIcon, SpinnerIcon } from './icons';
import { Lightbox } from './Lightbox';
interface Props {
handle: AttachmentHandle;
@@ -136,42 +137,5 @@ export function AttachmentImage({ handle }: Props) {
);
}
function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt="attachment full"
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
// Lightbox extracted to ./Lightbox.tsx so the settings avatar preview and
// any future surface can reuse the same dialog without duplication.
+51
View File
@@ -0,0 +1,51 @@
import { useEffect } from 'react';
import { XIcon } from './icons';
// Fullscreen image viewer. Backdrop click + Esc close. Originally lived
// inside AttachmentImage.tsx as a file-private component; extracted here
// so other surfaces (settings avatar preview, future profile popover,
// etc.) can reuse the exact same dialog without duplicating the chrome.
//
// The image itself stops click propagation so a click on the picture
// keeps the lightbox open — only the backdrop or the explicit close
// button dismisses.
export function Lightbox({ url, onClose }: { url: string; onClose: () => void }) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Bildansicht"
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/90 p-6 backdrop-blur-sm animate-fade-in"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute right-4 top-4 flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-white/10 bg-ink-900/80 text-neutral-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40"
>
<XIcon className="h-5 w-5" />
</button>
<img
src={url}
alt=""
onClick={(e) => e.stopPropagation()}
className="max-h-[92vh] max-w-[92vw] rounded-xl object-contain shadow-2xl"
/>
</div>
);
}
+34 -5
View File
@@ -33,6 +33,7 @@ import {
uploadBannerBlob,
} from '../lib/bannerUpload';
import { ImageCropDialog } from '../components/ImageCropDialog';
import { Lightbox } from '../components/Lightbox';
import { devLocalSecretStore } from '../lib/secretStore';
import {
getPttSettings,
@@ -799,6 +800,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
// ratios.
const [cropFile, setCropFile] = useState<File | null>(null);
const [cropKind, setCropKind] = useState<'avatar' | 'banner' | null>(null);
// Lightbox toggle for the avatar live-preview. Clicking the in-page
// avatar opens a fullscreen view; clicking outside / Esc dismisses.
const [avatarPreviewOpen, setAvatarPreviewOpen] = useState(false);
const userId = profile?.userId;
const avatarUrl = profile?.avatarUrl ?? null;
@@ -930,11 +934,33 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
className="relative z-10 flex items-end gap-3 px-4 pb-3"
style={{ marginTop: '-2rem' }}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
<button
type="button"
onClick={() => {
if (avatarUrl) setAvatarPreviewOpen(true);
}}
// Disabled when there's no uploaded avatar — clicking the
// generated-initial placeholder would open an empty lightbox.
disabled={!avatarUrl}
aria-label={
avatarUrl
? t('app:settings.avatar_preview', { defaultValue: 'Profilbild vergrößern' })
: undefined
}
// appearance-none + reset border/bg/padding so the native
// button chrome (outset border, button-face background, 1px
// padding) doesn't draw a box around the avatar circle.
className={
'appearance-none border-0 bg-transparent p-0 rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ' +
(avatarUrl ? 'cursor-zoom-in' : 'cursor-default')
}
>
<Avatar
url={avatarUrl}
displayName={displayName}
className="h-16 w-16 rounded-full text-2xl ring-4 ring-surface-3"
/>
</button>
<div className="min-w-0 flex-1 pb-1">
<div className="truncate text-sm font-semibold text-fg">
{displayName ?? '—'}
@@ -1061,6 +1087,9 @@ function ProfileVisualsControls({ patchProfile, busy }: AvatarControlsProps) {
}}
onClose={closeCropDialog}
/>
{avatarPreviewOpen && avatarUrl && (
<Lightbox url={avatarUrl} onClose={() => setAvatarPreviewOpen(false)} />
)}
</div>
);
}