Files
kandev/apps/web/components/task/chat/image-attachment-preview.tsx
T

143 lines
4.2 KiB
TypeScript

"use client";
import { memo } from "react";
import { IconX } from "@tabler/icons-react";
import { cn, generateUUID } from "@/lib/utils";
export type ImageAttachment = {
id: string;
data: string; // Base64-encoded image data (without data: prefix)
mimeType: string; // "image/png", "image/jpeg", "image/gif", "image/webp"
preview: string; // Data URL for preview display
size: number; // File size in bytes
width: number; // Image width in pixels
height: number; // Image height in pixels
};
// Supported image types
export const SUPPORTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
// Size limits
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB per image
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB total
export const MAX_IMAGES = 10;
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
type ImageAttachmentPreviewProps = {
attachments: ImageAttachment[];
onRemove: (id: string) => void;
disabled?: boolean;
};
export const ImageAttachmentPreview = memo(function ImageAttachmentPreview({
attachments,
onRemove,
disabled = false,
}: ImageAttachmentPreviewProps) {
if (attachments.length === 0) return null;
return (
<div className="flex gap-1.5 px-3 pt-2 flex-wrap">
{attachments.map((attachment) => (
<div
key={attachment.id}
className={cn(
"relative group rounded-md overflow-hidden border border-border bg-muted/30",
disabled && "opacity-50",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element -- base64/blob preview URLs are not compatible with next/image */}
<img
src={attachment.preview}
alt="Attachment preview"
className="h-10 w-10 object-cover"
/>
{/* Remove button - inside the image bounds */}
{!disabled && (
<button
type="button"
onClick={() => onRemove(attachment.id)}
className={cn(
"absolute top-0.5 right-0.5 p-0.5 rounded-full",
"bg-black/70 text-white",
"opacity-0 group-hover:opacity-100 transition-opacity",
"hover:bg-black/90 cursor-pointer",
"focus:outline-none focus:ring-1 focus:ring-white/50",
)}
aria-label="Remove image"
>
<IconX className="h-2.5 w-2.5" />
</button>
)}
</div>
))}
</div>
);
});
/**
* Process a pasted or dropped file into an ImageAttachment
* Returns null if the file is invalid (wrong type, too large, etc.)
*/
export function processImageFile(file: File): Promise<ImageAttachment | null> {
return new Promise((resolve) => {
// Validate type
if (!SUPPORTED_IMAGE_TYPES.includes(file.type)) {
console.warn(`Unsupported image type: ${file.type}`);
resolve(null);
return;
}
// Validate size
if (file.size > MAX_IMAGE_SIZE) {
console.warn(
`Image too large: ${formatBytes(file.size)} (max: ${formatBytes(MAX_IMAGE_SIZE)})`,
);
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const dataUrl = event.target?.result as string;
if (!dataUrl) {
resolve(null);
return;
}
// Extract base64 data (remove "data:image/png;base64," prefix)
const base64 = dataUrl.split(",")[1];
const mimeType = dataUrl.split(";")[0].split(":")[1];
// Read image dimensions
const img = new Image();
img.onload = () => {
resolve({
id: generateUUID(),
data: base64,
mimeType,
preview: dataUrl,
size: file.size,
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = () => resolve(null);
img.src = dataUrl;
};
reader.onerror = () => {
console.error("Failed to read image file");
resolve(null);
};
reader.readAsDataURL(file);
});
}