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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user