346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
import { useCallback, useEffect } from 'react';
|
|
import { Dialog as DialogPrimitive } from 'radix-ui';
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Download,
|
|
FileIcon,
|
|
Loader2,
|
|
Trash2,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useDownloadUrl } from '@/hooks/use-download-url';
|
|
import { useObjectUrl } from '@/hooks/use-object-url';
|
|
import { Button } from '@/components/ui/button';
|
|
import { KeyHint } from '@/components/key-hint';
|
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
|
import { platform } from '@/lib/platform';
|
|
|
|
export interface AttachmentItem {
|
|
id: string;
|
|
filename: string;
|
|
mimeType: string;
|
|
sizeBytes?: number;
|
|
source: { kind: 'remote'; objectId: string } | { kind: 'local'; file: File };
|
|
}
|
|
|
|
interface AttachmentLightboxProps {
|
|
items: AttachmentItem[];
|
|
openIndex: number | null;
|
|
onOpenChange: (index: number | null) => void;
|
|
/** When provided, enables the trash button + Backspace/Delete to remove. */
|
|
onRemove?: (item: AttachmentItem) => void;
|
|
}
|
|
|
|
/**
|
|
* Return `"lightbox"` for mime types that preview in-app, `"external"` otherwise.
|
|
* Callers use this to decide whether to open the lightbox or hand off to the OS.
|
|
*/
|
|
export function getAttachmentHandler(
|
|
mimeType: string,
|
|
): 'lightbox' | 'external' {
|
|
if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) {
|
|
return 'lightbox';
|
|
}
|
|
return 'external';
|
|
}
|
|
|
|
function formatSize(bytes?: number): string | null {
|
|
if (bytes == null) return null;
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
export function AttachmentLightbox({
|
|
items,
|
|
openIndex,
|
|
onOpenChange,
|
|
onRemove,
|
|
}: AttachmentLightboxProps) {
|
|
const current =
|
|
openIndex !== null && openIndex >= 0 && openIndex < items.length
|
|
? items[openIndex]
|
|
: null;
|
|
const isOpen = current !== null;
|
|
const hasMultiple = items.length > 1;
|
|
const positionIndicator = openIndex !== null ? openIndex + 1 : null;
|
|
|
|
// Remote items resolve through the signed-URL cache; disabled when not remote.
|
|
const remoteObjectId =
|
|
current?.source.kind === 'remote' ? current.source.objectId : undefined;
|
|
const { data: remoteUrl, isLoading: isRemoteLoading } =
|
|
useDownloadUrl(remoteObjectId);
|
|
|
|
// Local items resolve to a blob URL; remote items use the signed-URL cache.
|
|
const localFile =
|
|
current?.source.kind === 'local' ? current.source.file : null;
|
|
const localUrl = useObjectUrl(localFile);
|
|
|
|
const url =
|
|
current?.source.kind === 'remote' ? (remoteUrl ?? null) : localUrl;
|
|
|
|
const canDownload = current?.source.kind === 'remote' && !!url;
|
|
|
|
const goTo = useCallback(
|
|
(delta: number) => {
|
|
if (openIndex === null || items.length === 0) return;
|
|
const next = (openIndex + delta + items.length) % items.length;
|
|
onOpenChange(next);
|
|
},
|
|
[openIndex, items.length, onOpenChange],
|
|
);
|
|
|
|
const handleDownload = useCallback(() => {
|
|
if (!current || !url || current.source.kind !== 'remote') return;
|
|
platform.attachment.download(url, current.filename);
|
|
}, [current, url]);
|
|
|
|
const handleRemove = useCallback(() => {
|
|
if (!current || !onRemove) return;
|
|
const wasLast = items.length <= 1;
|
|
const wasAtEnd = openIndex === items.length - 1;
|
|
onRemove(current);
|
|
if (wasLast) {
|
|
onOpenChange(null);
|
|
} else if (wasAtEnd) {
|
|
onOpenChange(items.length - 2);
|
|
}
|
|
// Otherwise openIndex stays — the next item shifts into its place.
|
|
}, [current, onRemove, items.length, openIndex, onOpenChange]);
|
|
|
|
useSuspendPlayback(isOpen, 'attachment-lightbox');
|
|
|
|
// Keyboard handling — only listens while open. Registered in the capture
|
|
// phase with stopImmediatePropagation so we consume keys (arrows, D, ⌫)
|
|
// before global listeners like stream-view's particle-navigation.
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const handle = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement | null;
|
|
if (
|
|
target &&
|
|
(target.tagName === 'INPUT' ||
|
|
target.tagName === 'TEXTAREA' ||
|
|
target.isContentEditable)
|
|
) {
|
|
return;
|
|
}
|
|
const consume = () => {
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
};
|
|
if (e.key === 'Escape') {
|
|
consume();
|
|
onOpenChange(null);
|
|
} else if (e.key === 'ArrowLeft' && hasMultiple) {
|
|
consume();
|
|
goTo(-1);
|
|
} else if (e.key === 'ArrowRight' && hasMultiple) {
|
|
consume();
|
|
goTo(1);
|
|
} else if ((e.key === 'd' || e.key === 'D') && canDownload) {
|
|
consume();
|
|
handleDownload();
|
|
} else if ((e.key === 'Backspace' || e.key === 'Delete') && onRemove) {
|
|
consume();
|
|
handleRemove();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handle, true);
|
|
return () => window.removeEventListener('keydown', handle, true);
|
|
}, [
|
|
isOpen,
|
|
onOpenChange,
|
|
onRemove,
|
|
hasMultiple,
|
|
canDownload,
|
|
goTo,
|
|
handleDownload,
|
|
handleRemove,
|
|
]);
|
|
|
|
const isImage = current?.mimeType.startsWith('image/');
|
|
const isVideo = current?.mimeType.startsWith('video/');
|
|
const sizeLabel = formatSize(current?.sizeBytes);
|
|
|
|
return (
|
|
<DialogPrimitive.Root
|
|
open={isOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) onOpenChange(null);
|
|
}}
|
|
>
|
|
<DialogPrimitive.Portal>
|
|
<DialogPrimitive.Overlay className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 bg-black/90 backdrop-blur-sm duration-100" />
|
|
<DialogPrimitive.Content
|
|
aria-describedby={undefined}
|
|
className="data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 fixed inset-0 z-50 flex items-center justify-center p-16 outline-none duration-100"
|
|
>
|
|
{current && (
|
|
<>
|
|
<DialogPrimitive.Title className="sr-only">
|
|
{current.filename}
|
|
</DialogPrimitive.Title>
|
|
|
|
{/* Top-left: filename chip */}
|
|
<div className="absolute left-4 top-4 flex items-center gap-2 rounded-2xl border border-white/10 bg-white/5 px-3 py-1.5 backdrop-blur-xl">
|
|
<span className="max-w-[40vw] truncate text-sm text-white/80">
|
|
{current.filename}
|
|
</span>
|
|
{sizeLabel && (
|
|
<span className="text-xs text-white/40">{sizeLabel}</span>
|
|
)}
|
|
{hasMultiple && (
|
|
<span className="border-l border-white/10 pl-2 text-xs text-white/40">
|
|
{positionIndicator} / {items.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top-right: actions */}
|
|
<div className="absolute right-4 top-4 flex items-center gap-1 no-drag">
|
|
{canDownload && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={handleDownload}
|
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
|
title="Download"
|
|
>
|
|
<Download />
|
|
</Button>
|
|
)}
|
|
{onRemove && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={handleRemove}
|
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
|
title="Remove"
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
)}
|
|
<DialogPrimitive.Close asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-white/70 hover:bg-white/10 hover:text-white"
|
|
title="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
</DialogPrimitive.Close>
|
|
</div>
|
|
|
|
{/* Side navigation */}
|
|
{hasMultiple && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => goTo(-1)}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:bg-white/10 hover:text-white"
|
|
aria-label="Previous"
|
|
>
|
|
<ChevronLeft />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => goTo(1)}
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:bg-white/10 hover:text-white"
|
|
aria-label="Next"
|
|
>
|
|
<ChevronRight />
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* Media body */}
|
|
<div className="flex max-h-full max-w-full items-center justify-center">
|
|
{!url && isRemoteLoading && (
|
|
<Loader2 className="size-8 animate-spin text-white/50" />
|
|
)}
|
|
{url && isImage && (
|
|
<img
|
|
key={current.id}
|
|
src={url}
|
|
alt={current.filename}
|
|
onError={() => onOpenChange(null)}
|
|
className="max-h-[85vh] max-w-[85vw] rounded-lg object-contain shadow-2xl"
|
|
/>
|
|
)}
|
|
{url && isVideo && (
|
|
<video
|
|
key={current.id}
|
|
src={url}
|
|
controls
|
|
autoPlay
|
|
className="max-h-[85vh] max-w-[85vw] rounded-lg shadow-2xl"
|
|
/>
|
|
)}
|
|
{url && !isImage && !isVideo && (
|
|
<div className="flex flex-col items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-8 py-6 backdrop-blur-xl">
|
|
<FileIcon className="size-12 text-white/50" />
|
|
<span className="text-sm text-white/80">
|
|
{current.filename}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer kbd hints */}
|
|
<div className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-4 text-xs text-white/50">
|
|
{hasMultiple && (
|
|
<span className="flex items-center gap-1.5">
|
|
<KeyHint
|
|
keys="←"
|
|
onClick={() => goTo(-1)}
|
|
title="Previous (or press ←)"
|
|
aria-label="Previous attachment"
|
|
/>
|
|
<KeyHint
|
|
keys="→"
|
|
onClick={() => goTo(1)}
|
|
title="Next (or press →)"
|
|
aria-label="Next attachment"
|
|
/>
|
|
navigate
|
|
</span>
|
|
)}
|
|
{canDownload && (
|
|
<KeyHint
|
|
keys="D"
|
|
onClick={handleDownload}
|
|
title="Download (or press D)"
|
|
>
|
|
download
|
|
</KeyHint>
|
|
)}
|
|
{onRemove && (
|
|
<KeyHint
|
|
keys="⌫"
|
|
onClick={handleRemove}
|
|
title="Remove (or press Backspace)"
|
|
>
|
|
remove
|
|
</KeyHint>
|
|
)}
|
|
<KeyHint
|
|
keys="Esc"
|
|
onClick={() => onOpenChange(null)}
|
|
title="Close (or press Esc)"
|
|
>
|
|
close
|
|
</KeyHint>
|
|
</div>
|
|
</>
|
|
)}
|
|
</DialogPrimitive.Content>
|
|
</DialogPrimitive.Portal>
|
|
</DialogPrimitive.Root>
|
|
);
|
|
}
|