feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s

- 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.
This commit is contained in:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View File

@@ -0,0 +1,298 @@
/**
* 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,
);
}