219 lines
6.6 KiB
TypeScript
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)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|