Files
DiscordClone/packages/shared/src/components/channel/ImageLightbox.tsx
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

299 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ImageLightbox — full-screen image viewer opened by clicking an
* image attachment in the chat. Shows a file info card (filename +
* dimensions) on the left, an action card (zoom toggle, download,
* open-external) on the right, and a standalone close card. Clicking
* the image toggles between fit-to-viewport and native-size modes —
* when zoomed, the backdrop scrolls so the user can pan through the
* overflow.
*
* Layout mirrors the Brycord/Fluxer reference 1:1, with Matrix-
* specific bits (useMxcUrl, SavedMediaStore, MobileImageActionsSheet)
* stripped out. The `src` prop is a blob URL already decrypted by
* EncryptedAttachment — no async resolution happens here.
*
* Keyboard: Escape closes, `+` / `-` toggle zoom, `0` resets.
*/
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useMutation, useQuery } from 'convex/react';
import {
Download,
ArrowSquareOut,
Star,
X,
MagnifyingGlassPlus,
MagnifyingGlassMinus,
} from '@phosphor-icons/react';
import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel';
import type { AttachmentMetadata } from './EncryptedAttachment';
import styles from './ImageLightbox.module.css';
interface ImageLightboxProps {
isOpen: boolean;
onClose: () => void;
/** Blob URL of the decrypted image bytes. */
src: string;
/** Filename to show in the info card and to use for the download
* attribute. Falls back to `alt` then to a generic "image". */
filename?: string;
/** Legacy alias for `filename` kept so existing MessageGroup callers
* that only pass `alt` still render a label. */
alt?: string;
/** Pixel dimensions used by the info card's `WxH` readout. */
width?: number;
height?: number;
/** Byte size of the attachment — rendered alongside the dimensions
* when present. */
size?: number;
mimeType?: string;
/**
* Original encrypted attachment metadata. When present, the
* lightbox shows a star button so the user can save the image to
* their personal media library — the saved entry stores the same
* url + per-file key + iv so it can be re-posted later without
* re-uploading the bytes.
*/
attachment?: AttachmentMetadata;
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
export function ImageLightbox({
isOpen,
onClose,
src,
filename,
alt,
width,
height,
size,
attachment,
}: ImageLightboxProps) {
const [zoomed, setZoomed] = useState(false);
const label = filename ?? alt ?? 'image';
// Saved-media wiring — fetches the user's library once so we can
// flip the star button between "save" and "unsave" states. The
// list query is cheap (per-user, indexed) and stays cached.
const myUserId =
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
const savedList = useQuery(
api.savedMedia.list,
myUserId && isOpen ? { userId: myUserId as Id<'userProfiles'> } : 'skip',
);
const isSaved = !!(
attachment &&
savedList?.some((m) => m.url === attachment.url)
);
const saveMutation = useMutation(api.savedMedia.save);
const removeMutation = useMutation(api.savedMedia.remove);
const handleToggleSaved = async () => {
if (!attachment || !myUserId) return;
try {
if (isSaved) {
await removeMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
});
} else {
const kind = attachment.mimeType.split('/')[0]; // image | video | audio
await saveMutation({
userId: myUserId as Id<'userProfiles'>,
url: attachment.url,
kind,
filename: attachment.filename,
mimeType: attachment.mimeType,
width: attachment.width,
height: attachment.height,
size: attachment.size,
encryptionKey: attachment.key,
encryptionIv: attachment.iv,
});
}
} catch (err) {
console.warn('Failed to toggle saved media:', err);
}
};
// Close on Escape, toggle zoom via keyboard shortcuts. Also lock
// body scroll while the lightbox is open so the background chat
// doesn't jitter when the backdrop consumes the viewport.
useEffect(() => {
if (!isOpen) return;
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === '+' || e.key === '=') setZoomed(true);
else if (e.key === '-' || e.key === '_') setZoomed(false);
else if (e.key === '0') setZoomed(false);
};
document.addEventListener('keydown', handleKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKey);
document.body.style.overflow = prevOverflow;
};
}, [isOpen, onClose]);
// Reset transient state whenever the modal closes so a previously
// zoomed session doesn't bleed into the next attachment clicked.
useEffect(() => {
if (!isOpen) setZoomed(false);
}, [isOpen]);
const dimensionsLabel = width && height ? `${width}×${height}` : '';
const sizeLabel = size ? formatBytes(size) : '';
const metaLabel = [dimensionsLabel, sizeLabel].filter(Boolean).join(' · ');
const handleDownload = () => {
if (!src) return;
// Trigger a download via a temporary <a download>. Blob URLs
// honour the `download` attribute in all modern browsers.
const a = document.createElement('a');
a.href = src;
a.download = label;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const handleOpenExternal = () => {
if (!src) return;
window.open(src, '_blank', 'noopener,noreferrer');
};
const handleToggleZoom = () => setZoomed((z) => !z);
// Wrap every action button click so it doesn't bubble up to the
// backdrop (which would close the lightbox).
const stop =
(fn: () => void) =>
(e: React.MouseEvent) => {
e.stopPropagation();
fn();
};
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
className={styles.backdrop}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
onClick={onClose}
>
{/* Desktop header — file info card on the left, action
card + close card on the right. Each sits in its
own `--background-textarea` pill. */}
<div className={styles.header} onClick={(e) => e.stopPropagation()}>
<div className={styles.fileInfoCard}>
<div className={styles.filename} title={label}>
{label}
</div>
{metaLabel && <div className={styles.dimensions}>{metaLabel}</div>}
</div>
<div className={styles.headerRight}>
<div className={styles.actionCard}>
<button
type="button"
className={styles.actionButton}
onClick={stop(handleToggleZoom)}
aria-label={zoomed ? 'Zoom out' : 'Zoom in'}
title={zoomed ? 'Zoom out' : 'Zoom in'}
>
{zoomed ? (
<MagnifyingGlassMinus size={18} weight="regular" />
) : (
<MagnifyingGlassPlus size={18} weight="regular" />
)}
</button>
{attachment && (
<button
type="button"
className={styles.actionButton}
onClick={stop(() => void handleToggleSaved())}
aria-label={isSaved ? 'Unfavorite' : 'Favorite'}
title={isSaved ? 'Unfavorite' : 'Favorite'}
style={
isSaved
? { color: 'var(--brand-primary, #5865f2)' }
: undefined
}
>
<Star
size={18}
weight={isSaved ? 'fill' : 'regular'}
/>
</button>
)}
<button
type="button"
className={styles.actionButton}
onClick={stop(handleDownload)}
aria-label="Download"
title="Download"
disabled={!src}
>
<Download size={18} weight="regular" />
</button>
<button
type="button"
className={styles.actionButton}
onClick={stop(handleOpenExternal)}
aria-label="Open in new tab"
title="Open in new tab"
disabled={!src}
>
<ArrowSquareOut size={18} weight="regular" />
</button>
</div>
<div className={styles.closeCard}>
<button
type="button"
className={styles.actionButton}
onClick={stop(onClose)}
aria-label="Close"
title="Close (Esc)"
>
<X size={20} weight="bold" />
</button>
</div>
</div>
</div>
{/* Centered image. Clicking it toggles zoom — event is
stopped so the click doesn't propagate to the
backdrop's close handler. */}
<motion.img
key={src || 'loading'}
src={src || undefined}
alt={label}
className={`${styles.image} ${zoomed ? styles.imageZoomed : ''}`}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
onClick={stop(handleToggleZoom)}
draggable={false}
/>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}