refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import { useEffect, useState } 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 { Button } from "@/components/ui/button";
|
||||
import { useSuspendPlayback } from "@/hooks/use-suspend-playback";
|
||||
|
||||
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;
|
||||
|
||||
// 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 get a fresh blob URL per item, revoked on change/close.
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (current?.source.kind !== "local") {
|
||||
setLocalUrl(null);
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(current.source.file);
|
||||
setLocalUrl(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [current?.id, current?.source.kind]);
|
||||
|
||||
const url =
|
||||
current?.source.kind === "remote"
|
||||
? remoteUrl ?? null
|
||||
: localUrl;
|
||||
|
||||
const canDownload = current?.source.kind === "remote" && !!url;
|
||||
|
||||
const goTo = (delta: number) => {
|
||||
if (openIndex === null || items.length === 0) return;
|
||||
const next = (openIndex + delta + items.length) % items.length;
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!current || !url || current.source.kind !== "remote") return;
|
||||
window.electronAttachment.download(url, current.filename);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
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(openIndex! - 1);
|
||||
}
|
||||
// Otherwise openIndex stays — the next item shifts into its place.
|
||||
};
|
||||
|
||||
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, openIndex, items, url, onOpenChange, onRemove, hasMultiple, canDownload]);
|
||||
|
||||
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">
|
||||
{openIndex! + 1} / {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">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
←
|
||||
</kbd>
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
→
|
||||
</kbd>
|
||||
navigate
|
||||
</span>
|
||||
)}
|
||||
{canDownload && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
D
|
||||
</kbd>
|
||||
download
|
||||
</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
⌫
|
||||
</kbd>
|
||||
remove
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||
Esc
|
||||
</kbd>
|
||||
close
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user