feat: support image lightbox viewer and file download (#151)
* feat: add lightbox overlay with nice mechanics * ui tweak for top bar in stream view * support locally downloading attachments
This commit was merged in pull request #151.
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
import type { Particle } from "@/api/types";
|
||||
import {
|
||||
ImageAttachment,
|
||||
FileAttachment,
|
||||
} from "@/features/particles/particle-attachments";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { VisuallyHidden } from "radix-ui";
|
||||
|
||||
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||
|
||||
interface AttachmentsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
attachments: FileParticle[];
|
||||
}
|
||||
|
||||
export function AttachmentsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
attachments,
|
||||
}: AttachmentsDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle>Attachments</DialogTitle>
|
||||
</VisuallyHidden.Root>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{attachments.map((attachment) => {
|
||||
const isImage = attachment.properties.mime_type.startsWith("image/");
|
||||
return isImage ? (
|
||||
<ImageAttachment key={attachment.id} particle={attachment} />
|
||||
) : (
|
||||
<FileAttachment key={attachment.id} particle={attachment} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Download, ExternalLink, FileIcon, ImageIcon } from "lucide-react";
|
||||
import type { Particle } from "@/api/types";
|
||||
import { useDownloadUrl } from "@/hooks/use-download-url";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AttachmentLightbox,
|
||||
getAttachmentHandler,
|
||||
type AttachmentItem,
|
||||
} from "@/features/attachments/attachment-lightbox";
|
||||
|
||||
type FileParticle = Extract<Particle, { type: "file" }>;
|
||||
|
||||
@@ -18,7 +24,39 @@ function formatFileSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function ImageAttachment({ particle }: { particle: FileParticle }) {
|
||||
function particleToItem(p: FileParticle): AttachmentItem {
|
||||
return {
|
||||
id: p.id,
|
||||
filename: p.properties.filename,
|
||||
mimeType: p.properties.mime_type,
|
||||
sizeBytes: p.properties.size_bytes,
|
||||
source: { kind: "remote", objectId: p.properties.object_id },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the lightbox for previewable types; hand off to the OS for files.
|
||||
*/
|
||||
function openParticle(
|
||||
particle: FileParticle,
|
||||
index: number,
|
||||
url: string | undefined,
|
||||
onPreview: (index: number) => void,
|
||||
) {
|
||||
if (getAttachmentHandler(particle.properties.mime_type) === "lightbox") {
|
||||
onPreview(index);
|
||||
} else if (url) {
|
||||
window.electronLink.openExternal(url);
|
||||
}
|
||||
}
|
||||
|
||||
function ImageAttachment({
|
||||
particle,
|
||||
onPreview,
|
||||
}: {
|
||||
particle: FileParticle;
|
||||
onPreview: () => void;
|
||||
}) {
|
||||
const { data: url, isLoading } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
if (isLoading || !url) {
|
||||
@@ -27,21 +65,16 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) {
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = particle.properties.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.electronAttachment.download(url, particle.properties.filename);
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPreview();
|
||||
}}
|
||||
className="group relative block h-30 w-40 shrink-0 overflow-hidden rounded-lg bg-white/10"
|
||||
>
|
||||
<img
|
||||
@@ -49,34 +82,38 @@ export function ImageAttachment({ particle }: { particle: FileParticle }) {
|
||||
alt={particle.properties.filename}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleDownload}
|
||||
className="absolute bottom-1 right-1 rounded-full bg-black/60 p-1 text-white/70 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
</a>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileAttachment({ particle }: { particle: FileParticle }) {
|
||||
function FileAttachment({
|
||||
particle,
|
||||
index,
|
||||
onPreview,
|
||||
}: {
|
||||
particle: FileParticle;
|
||||
index: number;
|
||||
onPreview: (index: number) => void;
|
||||
}) {
|
||||
const { data: url } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
const handleOpen = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (url) window.electronLink.openExternal(url);
|
||||
openParticle(particle, index, url, onPreview);
|
||||
};
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!url) return;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = particle.properties.filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.electronAttachment.download(url, particle.properties.filename);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -118,19 +155,25 @@ export function FileAttachment({ particle }: { particle: FileParticle }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CompactAttachmentItem({ particle }: { particle: FileParticle }) {
|
||||
function CompactAttachmentItem({
|
||||
particle,
|
||||
index,
|
||||
onPreview,
|
||||
}: {
|
||||
particle: FileParticle;
|
||||
index: number;
|
||||
onPreview: (index: number) => void;
|
||||
}) {
|
||||
const isImage = particle.properties.mime_type.startsWith("image/");
|
||||
const { data: url } = useDownloadUrl(particle.properties.object_id);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (url) window.electronLink.openExternal(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openParticle(particle, index, url, onPreview);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md bg-white/10 px-2.5 py-1.5 text-left transition-colors hover:bg-white/15"
|
||||
>
|
||||
{isImage ? (
|
||||
@@ -157,31 +200,76 @@ function CompactAttachmentItem({ particle }: { particle: FileParticle }) {
|
||||
}
|
||||
|
||||
export function ParticleAttachments({ attachments, variant = "inline" }: ParticleAttachmentsProps) {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
// Only previewable attachments populate the lightbox; the index passed to the
|
||||
// lightbox is the index into this filtered list, not `attachments`.
|
||||
const previewable = useMemo(
|
||||
() => attachments.filter((a) => getAttachmentHandler(a.properties.mime_type) === "lightbox"),
|
||||
[attachments],
|
||||
);
|
||||
const items = useMemo(() => previewable.map(particleToItem), [previewable]);
|
||||
|
||||
const handlePreview = (attachmentIndex: number) => {
|
||||
const particle = attachments[attachmentIndex];
|
||||
if (!particle) return;
|
||||
const previewIdx = previewable.indexOf(particle);
|
||||
if (previewIdx >= 0) setOpenIndex(previewIdx);
|
||||
};
|
||||
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
const lightbox = items.length > 0 && (
|
||||
<AttachmentLightbox
|
||||
items={items}
|
||||
openIndex={openIndex}
|
||||
onOpenChange={setOpenIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<div className="flex max-w-48 flex-col gap-1">
|
||||
{attachments.map((attachment) => (
|
||||
<CompactAttachmentItem key={attachment.id} particle={attachment} />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex max-w-48 flex-col gap-1">
|
||||
{attachments.map((attachment, i) => (
|
||||
<CompactAttachmentItem
|
||||
key={attachment.id}
|
||||
particle={attachment}
|
||||
index={i}
|
||||
onPreview={handlePreview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{lightbox}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{attachments.map((attachment) => {
|
||||
const isImage = attachment.properties.mime_type.startsWith("image/");
|
||||
return isImage ? (
|
||||
<ImageAttachment key={attachment.id} particle={attachment} />
|
||||
) : (
|
||||
<FileAttachment key={attachment.id} particle={attachment} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
{attachments.map((attachment, i) => {
|
||||
const isImage = attachment.properties.mime_type.startsWith("image/");
|
||||
return isImage ? (
|
||||
<ImageAttachment
|
||||
key={attachment.id}
|
||||
particle={attachment}
|
||||
onPreview={() => handlePreview(i)}
|
||||
/>
|
||||
) : (
|
||||
<FileAttachment
|
||||
key={attachment.id}
|
||||
particle={attachment}
|
||||
index={i}
|
||||
onPreview={handlePreview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
{lightbox}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="drag-region flex flex-row px-4 gap-5 items-center">
|
||||
<div className="drag-region flex flex-row px-4 gap-1 items-center">
|
||||
<WindowControls />
|
||||
|
||||
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
|
||||
|
||||
@@ -24,6 +24,7 @@ import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStrea
|
||||
import { ComposingIndicator } from "@/components/composing-indicator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMount } from "react-use";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
|
||||
function getReactions(particle: Particle): Record<string, string[]> | undefined {
|
||||
if (isParticleDeleted(particle)) return undefined;
|
||||
@@ -192,6 +193,8 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
|
||||
const [composeActive, setComposeActive] = useState(false);
|
||||
const [composeStep, setComposeStep] = useState<ComposeStep>("idle");
|
||||
const playbackSuspended = usePlaybackStore((s) => s.suspendCount > 0);
|
||||
const playbackBlocked = composeActive || playbackSuspended;
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [fastPlayback, setFastPlayback] = useState(false);
|
||||
const [showKeybindings, setShowKeybindings] = useState(false);
|
||||
@@ -243,11 +246,11 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
setProgress(0);
|
||||
}, [currentParticle?.id]);
|
||||
|
||||
// Pause/resume playback when compose overlay opens/closes
|
||||
// Pause/resume playback when compose overlay or lightbox is open.
|
||||
useEffect(() => {
|
||||
if (composeActive) pause();
|
||||
if (playbackBlocked) pause();
|
||||
else resume();
|
||||
}, [composeActive, pause, resume]);
|
||||
}, [playbackBlocked, pause, resume]);
|
||||
|
||||
// Playback keyboard: arrows, escape, hold-space-to-pause
|
||||
useEffect(() => {
|
||||
@@ -261,7 +264,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (composeActive) return;
|
||||
if (playbackBlocked) return;
|
||||
if (isInputTarget(e)) return;
|
||||
|
||||
switch (e.key) {
|
||||
@@ -313,7 +316,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (composeActive) return;
|
||||
if (playbackBlocked) return;
|
||||
if (isInputTarget(e)) return;
|
||||
|
||||
if (e.key === " ") {
|
||||
@@ -333,22 +336,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
},
|
||||
[composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode],
|
||||
);
|
||||
|
||||
// Click-to-navigate: left 30% = prev, right 70% = next
|
||||
// Suppressed when the user has selected text (drag-to-select)
|
||||
const handlePlaybackClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().length > 0) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
if (x < 0.3) prev();
|
||||
else if (x > 0.7) next();
|
||||
},
|
||||
[prev, next],
|
||||
[playbackBlocked, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode],
|
||||
);
|
||||
|
||||
if (children.length === 0) {
|
||||
@@ -430,10 +418,7 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
{/* Main playback area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{currentParticle && (
|
||||
<div
|
||||
className="relative flex h-full w-full cursor-pointer items-center justify-center"
|
||||
onClick={handlePlaybackClick}
|
||||
>
|
||||
<div className="relative flex h-full w-full cursor-pointer items-center justify-center">
|
||||
{renderParticle(currentParticle)}
|
||||
|
||||
{fastPlayback && (
|
||||
|
||||
Reference in New Issue
Block a user