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,8 +1,14 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LinkPreviewEntry } from "@/hooks/use-link-metadata";
|
||||
import {
|
||||
AttachmentLightbox,
|
||||
getAttachmentHandler,
|
||||
type AttachmentItem,
|
||||
} from "@/features/attachments/attachment-lightbox";
|
||||
|
||||
export interface PendingAttachment {
|
||||
id: string;
|
||||
@@ -18,6 +24,16 @@ interface AttachmentStripProps {
|
||||
linkPreviews?: LinkPreviewEntry[];
|
||||
}
|
||||
|
||||
function pendingToItem(p: PendingAttachment): AttachmentItem {
|
||||
return {
|
||||
id: p.id,
|
||||
filename: p.file.name,
|
||||
mimeType: p.file.type || "application/octet-stream",
|
||||
sizeBytes: p.file.size,
|
||||
source: { kind: "local", file: p.file },
|
||||
};
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
@@ -27,18 +43,25 @@ function formatFileSize(bytes: number): string {
|
||||
function AttachmentThumbnail({
|
||||
attachment,
|
||||
onRemove,
|
||||
onPreview,
|
||||
}: {
|
||||
attachment: PendingAttachment;
|
||||
onRemove: () => void;
|
||||
onPreview?: () => void;
|
||||
}) {
|
||||
const isImage = attachment.file.type.startsWith("image/");
|
||||
const isUploading = attachment.status === "uploading";
|
||||
const isError = attachment.status === "error";
|
||||
const previewable = getAttachmentHandler(attachment.file.type) === "lightbox";
|
||||
|
||||
return (
|
||||
<div
|
||||
role={previewable ? "button" : undefined}
|
||||
tabIndex={previewable ? 0 : undefined}
|
||||
onClick={previewable && onPreview ? onPreview : undefined}
|
||||
className={cn(
|
||||
"group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/10",
|
||||
previewable && "cursor-pointer",
|
||||
isError && "ring-1 ring-red-400/50",
|
||||
)}
|
||||
>
|
||||
@@ -131,35 +154,59 @@ export function AttachmentStrip({
|
||||
linkPreviews,
|
||||
}: AttachmentStripProps) {
|
||||
const hasLinks = linkPreviews && linkPreviews.length > 0;
|
||||
|
||||
// Lightbox state — only previewable attachments go in.
|
||||
const previewable = useMemo(
|
||||
() => attachments.filter((a) => getAttachmentHandler(a.file.type) === "lightbox"),
|
||||
[attachments],
|
||||
);
|
||||
const items = useMemo(() => previewable.map(pendingToItem), [previewable]);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
if (attachments.length === 0 && !hasLinks) return null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
{attachments.map((a) => (
|
||||
<AttachmentThumbnail
|
||||
key={a.id}
|
||||
attachment={a}
|
||||
onRemove={() => onRemove(a.id)}
|
||||
/>
|
||||
))}
|
||||
<>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
{attachments.map((a) => (
|
||||
<AttachmentThumbnail
|
||||
key={a.id}
|
||||
attachment={a}
|
||||
onRemove={() => onRemove(a.id)}
|
||||
onPreview={() => {
|
||||
const idx = previewable.indexOf(a);
|
||||
if (idx >= 0) setOpenIndex(idx);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{linkPreviews?.map((entry) => (
|
||||
<LinkPreviewThumbnail key={entry.url} entry={entry} />
|
||||
))}
|
||||
{linkPreviews?.map((entry) => (
|
||||
<LinkPreviewThumbnail key={entry.url} entry={entry} />
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddClick();
|
||||
}}
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddClick();
|
||||
}}
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed border-white/20 text-white/40 transition-colors hover:border-white/40 hover:text-white/60"
|
||||
>
|
||||
<Plus className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
||||
{items.length > 0 && (
|
||||
<AttachmentLightbox
|
||||
items={items}
|
||||
openIndex={openIndex}
|
||||
onOpenChange={setOpenIndex}
|
||||
onRemove={(item) => onRemove(item.id)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user