Files
llink/js/desktop/src/features/compose/attachment-strip.tsx
T

219 lines
6.6 KiB
TypeScript

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 { domainFromUrl } from '@/lib/link-metadata';
import {
AttachmentLightbox,
getAttachmentHandler,
type AttachmentItem,
} from '@/features/attachments/attachment-lightbox';
import { platform } from '@/lib/platform';
export interface PendingAttachment {
id: string;
file: File;
thumbnailUrl?: string;
status: 'pending' | 'uploading' | 'uploaded' | 'error';
}
interface AttachmentStripProps {
attachments: PendingAttachment[];
onRemove: (id: string) => void;
onAddClick: () => void;
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`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
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(
'bg-muted group relative flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg',
previewable && 'cursor-pointer',
isError && 'ring-1 ring-red-400/50',
)}
>
{isImage && attachment.thumbnailUrl ? (
<img
src={attachment.thumbnailUrl}
alt={attachment.file.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-0.5 px-1">
<FileIcon className="text-muted-foreground size-5" />
<span className="text-muted-foreground max-w-full truncate text-[9px]">
{attachment.file.name}
</span>
<span className="text-muted-foreground text-[9px]">
{formatFileSize(attachment.file.size)}
</span>
</div>
)}
{isUploading && (
<div className="bg-scrim/50 absolute inset-0 flex items-center justify-center">
<Loader2 className="size-4 animate-spin text-white" />
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="bg-scrim/70 absolute right-0.5 top-0.5 hidden rounded-full p-0.5 text-white/80 hover:text-white group-hover:block"
>
<X className="size-3" />
</button>
</div>
);
}
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
if (entry.isLoading) {
return (
<div className="bg-muted flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg p-2">
<Skeleton className="h-2 w-16" />
<Skeleton className="h-3 w-24" />
</div>
);
}
// Metadata fetch can fail; fall back to the bare URL so the link stays usable.
const { metadata } = entry;
const domain = metadata?.domain ?? domainFromUrl(entry.url);
const title = metadata?.title ?? entry.url;
return (
<button
type="button"
onClick={() => platform.link.openExternal(metadata?.url ?? entry.url)}
className="bg-muted hover:bg-accent flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg px-2 py-1.5 text-left transition-colors"
>
<div className="text-muted-foreground flex items-center gap-1 text-[10px]">
{metadata?.favicon ? (
<img
src={metadata.favicon}
alt=""
className="size-3 rounded-sm"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Globe className="size-3" />
)}
<span className="truncate">{domain}</span>
</div>
{title && (
<p className="text-foreground line-clamp-2 text-[11px] font-medium leading-tight">
{title}
</p>
)}
</button>
);
}
export function AttachmentStrip({
attachments,
onRemove,
onAddClick,
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)}
onPreview={() => {
const idx = previewable.indexOf(a);
if (idx >= 0) setOpenIndex(idx);
}}
/>
))}
{linkPreviews?.map((entry) => (
<LinkPreviewThumbnail key={entry.url} entry={entry} />
))}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onAddClick();
}}
className="border-border text-muted-foreground hover:border-foreground/40 hover:text-foreground flex h-16 w-16 shrink-0 items-center justify-center rounded-lg border border-dashed transition-colors"
>
<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)}
/>
)}
</>
);
}