Files
llink/js/desktop/src/features/compose/attachment-strip.tsx
T
Arjun PatelandGitHub a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00

217 lines
6.4 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 {
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(
'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',
)}
>
{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="size-5 text-white/60" />
<span className="max-w-full truncate text-[9px] text-white/50">
{attachment.file.name}
</span>
<span className="text-[9px] text-white/40">
{formatFileSize(attachment.file.size)}
</span>
</div>
)}
{isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<Loader2 className="size-4 animate-spin text-white/70" />
</div>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="absolute right-0.5 top-0.5 hidden rounded-full bg-black/70 p-0.5 text-white/70 hover:text-white group-hover:block"
>
<X className="size-3" />
</button>
</div>
);
}
function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) {
if (entry.isLoading) {
return (
<div className="flex h-16 w-28 shrink-0 flex-col gap-1.5 rounded-lg bg-white/10 p-2">
<Skeleton className="h-2 w-16 bg-white/10" />
<Skeleton className="h-3 w-24 bg-white/10" />
</div>
);
}
if (!entry.metadata) return null;
const { metadata } = entry;
return (
<button
type="button"
onClick={() => platform.link.openExternal(metadata.url)}
className="flex h-16 w-28 shrink-0 flex-col justify-center gap-1 overflow-hidden rounded-lg bg-white/10 px-2 py-1.5 text-left transition-colors hover:bg-white/15"
>
<div className="flex items-center gap-1 text-[10px] text-white/40">
{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">{metadata.domain}</span>
</div>
{metadata.title && (
<p className="line-clamp-2 text-[11px] font-medium leading-tight text-white/80">
{metadata.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="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)}
/>
)}
</>
);
}