674 lines
20 KiB
TypeScript
674 lines
20 KiB
TypeScript
import { Button, Input, Loader } from "@cloudflare/kumo";
|
|
import { Upload, Image, SquaresFour, List, MagnifyingGlass, Check, X } from "@phosphor-icons/react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import * as React from "react";
|
|
|
|
import {
|
|
type MediaItem,
|
|
type MediaProviderInfo,
|
|
type MediaProviderItem,
|
|
fetchMediaProviders,
|
|
fetchProviderMedia,
|
|
uploadToProvider,
|
|
} from "../lib/api";
|
|
import { providerItemToMediaItem, getFileIcon, formatFileSize } from "../lib/media-utils";
|
|
import { cn } from "../lib/utils";
|
|
import { MediaDetailPanel } from "./MediaDetailPanel";
|
|
|
|
export interface MediaLibraryProps {
|
|
items?: MediaItem[];
|
|
isLoading?: boolean;
|
|
onUpload?: (file: File) => Promise<void> | void;
|
|
onSelect?: (item: MediaItem) => void;
|
|
onDelete?: (id: string) => void;
|
|
onItemUpdated?: () => void;
|
|
}
|
|
|
|
/**
|
|
* Media library component with upload, provider tabs, and grid view
|
|
*/
|
|
export function MediaLibrary({
|
|
items = [],
|
|
isLoading,
|
|
onUpload,
|
|
onDelete,
|
|
onItemUpdated,
|
|
}: MediaLibraryProps) {
|
|
const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid");
|
|
const [selectedItem, setSelectedItem] = React.useState<MediaItem | null>(null);
|
|
const [activeProvider, setActiveProvider] = React.useState<string>("local");
|
|
const [searchQuery, setSearchQuery] = React.useState("");
|
|
const [uploadState, setUploadState] = React.useState<{
|
|
status: "idle" | "uploading" | "success" | "error";
|
|
message?: string;
|
|
progress?: { current: number; total: number };
|
|
}>({ status: "idle" });
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
// Track loaded image dimensions for providers that don't return them (e.g., CF Images)
|
|
const [loadedDimensions, setLoadedDimensions] = React.useState<
|
|
Record<string, { width: number; height: number }>
|
|
>({});
|
|
|
|
// Fetch available providers
|
|
const { data: providers } = useQuery({
|
|
queryKey: ["media-providers"],
|
|
queryFn: fetchMediaProviders,
|
|
placeholderData: [],
|
|
});
|
|
|
|
// Fetch provider media when a non-local provider is selected
|
|
const {
|
|
data: providerData,
|
|
isLoading: providerLoading,
|
|
refetch: refetchProviderMedia,
|
|
} = useQuery({
|
|
queryKey: ["provider-media", activeProvider, searchQuery],
|
|
queryFn: () =>
|
|
fetchProviderMedia(activeProvider, {
|
|
limit: 50,
|
|
query: searchQuery || undefined,
|
|
}),
|
|
enabled: activeProvider !== "local",
|
|
});
|
|
|
|
// Get active provider info
|
|
const activeProviderInfo = React.useMemo(() => {
|
|
if (activeProvider === "local") {
|
|
return {
|
|
id: "local",
|
|
name: "Library",
|
|
capabilities: { browse: true, search: false, upload: true, delete: true },
|
|
} as MediaProviderInfo;
|
|
}
|
|
return providers?.find((p) => p.id === activeProvider);
|
|
}, [activeProvider, providers]);
|
|
|
|
// Update selected item when items change (e.g., after metadata update)
|
|
React.useEffect(() => {
|
|
if (selectedItem && activeProvider === "local") {
|
|
const updated = items.find((i) => i.id === selectedItem.id);
|
|
if (updated) {
|
|
setSelectedItem(updated);
|
|
} else {
|
|
// Item was deleted
|
|
setSelectedItem(null);
|
|
}
|
|
}
|
|
}, [items, selectedItem?.id, activeProvider]);
|
|
|
|
// Clear success/error message after a delay
|
|
React.useEffect(() => {
|
|
if (uploadState.status === "success" || uploadState.status === "error") {
|
|
const timer = setTimeout(() => {
|
|
setUploadState({ status: "idle" });
|
|
}, 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [uploadState.status]);
|
|
|
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.target.files;
|
|
if (files && files.length > 0) {
|
|
const fileArray = [...files];
|
|
const total = fileArray.length;
|
|
|
|
if (activeProvider === "local") {
|
|
setUploadState({ status: "uploading", progress: { current: 0, total } });
|
|
let uploaded = 0;
|
|
let failed = 0;
|
|
|
|
for (const file of fileArray) {
|
|
try {
|
|
await onUpload?.(file);
|
|
uploaded++;
|
|
} catch (error) {
|
|
console.error("Upload failed:", error);
|
|
failed++;
|
|
}
|
|
setUploadState({
|
|
status: "uploading",
|
|
progress: { current: uploaded + failed, total },
|
|
});
|
|
}
|
|
|
|
if (failed === 0) {
|
|
setUploadState({
|
|
status: "success",
|
|
message: total === 1 ? "File uploaded" : `${total} files uploaded`,
|
|
});
|
|
} else if (uploaded === 0) {
|
|
setUploadState({
|
|
status: "error",
|
|
message: total === 1 ? "Upload failed" : `All ${total} uploads failed`,
|
|
});
|
|
} else {
|
|
setUploadState({
|
|
status: "error",
|
|
message: `${uploaded} uploaded, ${failed} failed`,
|
|
});
|
|
}
|
|
} else if (activeProviderInfo?.capabilities.upload) {
|
|
// Upload to external provider
|
|
setUploadState({ status: "uploading", progress: { current: 0, total } });
|
|
let uploaded = 0;
|
|
let failed = 0;
|
|
|
|
for (const file of fileArray) {
|
|
try {
|
|
await uploadToProvider(activeProvider, file);
|
|
uploaded++;
|
|
} catch (error) {
|
|
console.error("Upload failed:", error);
|
|
failed++;
|
|
}
|
|
setUploadState({
|
|
status: "uploading",
|
|
progress: { current: uploaded + failed, total },
|
|
});
|
|
}
|
|
|
|
if (failed === 0) {
|
|
setUploadState({
|
|
status: "success",
|
|
message: total === 1 ? "File uploaded" : `${total} files uploaded`,
|
|
});
|
|
} else if (uploaded === 0) {
|
|
setUploadState({
|
|
status: "error",
|
|
message: total === 1 ? "Upload failed" : `All ${total} uploads failed`,
|
|
});
|
|
} else {
|
|
setUploadState({
|
|
status: "error",
|
|
message: `${uploaded} uploaded, ${failed} failed`,
|
|
});
|
|
}
|
|
|
|
void refetchProviderMedia();
|
|
}
|
|
}
|
|
// Reset input
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
// Build provider tabs
|
|
const providerTabs = React.useMemo(() => {
|
|
const tabs: Array<{ id: string; name: string; icon?: string }> = [
|
|
{ id: "local", name: "Library", icon: undefined },
|
|
];
|
|
if (providers) {
|
|
for (const p of providers) {
|
|
if (p.id !== "local") {
|
|
tabs.push({ id: p.id, name: p.name, icon: p.icon });
|
|
}
|
|
}
|
|
}
|
|
return tabs;
|
|
}, [providers]);
|
|
|
|
// Get current items based on active provider
|
|
const currentItems = activeProvider === "local" ? items : [];
|
|
const currentProviderItems = activeProvider !== "local" ? providerData?.items || [] : [];
|
|
const currentLoading = activeProvider === "local" ? isLoading : providerLoading;
|
|
|
|
const canUpload = activeProviderInfo?.capabilities.upload ?? false;
|
|
const canSearch = activeProviderInfo?.capabilities.search ?? false;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold">Media Library</h1>
|
|
<div className="flex rounded-md border" role="group" aria-label="View mode">
|
|
<Button
|
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
|
shape="square"
|
|
onClick={() => setViewMode("grid")}
|
|
aria-label="Grid view"
|
|
aria-pressed={viewMode === "grid"}
|
|
>
|
|
<SquaresFour className="h-4 w-4" aria-hidden="true" />
|
|
</Button>
|
|
<Button
|
|
variant={viewMode === "list" ? "secondary" : "ghost"}
|
|
shape="square"
|
|
onClick={() => setViewMode("list")}
|
|
aria-label="List view"
|
|
aria-pressed={viewMode === "list"}
|
|
>
|
|
<List className="h-4 w-4" aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider Tabs + Upload */}
|
|
<div className="flex items-center justify-between gap-4 border-b pb-3">
|
|
{providerTabs.length > 1 && (
|
|
<div className="flex gap-2 overflow-x-auto">
|
|
{providerTabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setActiveProvider(tab.id);
|
|
setSelectedItem(null);
|
|
setSearchQuery("");
|
|
}}
|
|
className={cn(
|
|
"flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
|
|
activeProvider === tab.id
|
|
? "bg-kumo-brand text-white"
|
|
: "bg-kumo-tint hover:bg-kumo-tint/80 text-kumo-subtle",
|
|
)}
|
|
>
|
|
{tab.icon &&
|
|
(tab.icon.startsWith("data:") ? (
|
|
<img src={tab.icon} alt="" className="h-4 w-4" aria-hidden="true" />
|
|
) : (
|
|
<span aria-hidden="true">{tab.icon}</span>
|
|
))}
|
|
{tab.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload button + status */}
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
{/* Upload status feedback */}
|
|
{uploadState.status === "uploading" && (
|
|
<div className="flex items-center gap-2 text-sm text-kumo-subtle">
|
|
<Loader size="sm" />
|
|
<span>
|
|
Uploading
|
|
{uploadState.progress &&
|
|
uploadState.progress.total > 1 &&
|
|
` ${uploadState.progress.current}/${uploadState.progress.total}`}
|
|
...
|
|
</span>
|
|
</div>
|
|
)}
|
|
{uploadState.status === "success" && (
|
|
<div className="flex items-center gap-2 text-sm text-green-600">
|
|
<Check className="h-4 w-4" />
|
|
<span>{uploadState.message}</span>
|
|
</div>
|
|
)}
|
|
{uploadState.status === "error" && (
|
|
<div className="flex items-center gap-2 text-sm text-kumo-danger">
|
|
<X className="h-4 w-4" />
|
|
<span>{uploadState.message}</span>
|
|
</div>
|
|
)}
|
|
|
|
{canUpload && (
|
|
<>
|
|
<Button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadState.status === "uploading"}
|
|
icon={uploadState.status === "uploading" ? <Loader size="sm" /> : <Upload />}
|
|
>
|
|
Upload to {activeProviderInfo?.name || "Library"}
|
|
</Button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx"
|
|
className="sr-only"
|
|
onChange={handleFileSelect}
|
|
aria-label="Upload files"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search (for providers that support it) */}
|
|
{canSearch && (
|
|
<div className="relative max-w-sm">
|
|
<MagnifyingGlass className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-kumo-subtle" />
|
|
<Input
|
|
type="search"
|
|
placeholder="Search..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content */}
|
|
{currentLoading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader />
|
|
</div>
|
|
) : activeProvider === "local" && currentItems.length === 0 ? (
|
|
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
|
<Image className="mx-auto h-12 w-12 text-kumo-subtle" aria-hidden="true" />
|
|
<h2 className="mt-4 text-lg font-medium">No media yet</h2>
|
|
<p className="mt-2 text-sm text-kumo-subtle">
|
|
Upload images, videos, and documents to get started.
|
|
</p>
|
|
<Button className="mt-4" onClick={() => fileInputRef.current?.click()} icon={<Upload />}>
|
|
Upload Files
|
|
</Button>
|
|
</div>
|
|
) : activeProvider !== "local" && currentProviderItems.length === 0 ? (
|
|
<div className="rounded-lg border bg-kumo-base p-12 text-center">
|
|
<Image className="mx-auto h-12 w-12 text-kumo-subtle" aria-hidden="true" />
|
|
<h2 className="mt-4 text-lg font-medium">No media found</h2>
|
|
<p className="mt-2 text-sm text-kumo-subtle">
|
|
{canSearch && searchQuery
|
|
? "Try a different search term"
|
|
: canUpload
|
|
? "Upload media to get started"
|
|
: "No media available from this provider"}
|
|
</p>
|
|
</div>
|
|
) : viewMode === "grid" ? (
|
|
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(160px,1fr))]">
|
|
{activeProvider === "local"
|
|
? currentItems.map((item) => (
|
|
<MediaGridItem
|
|
key={item.id}
|
|
item={item}
|
|
selected={selectedItem?.id === item.id}
|
|
onClick={() => setSelectedItem(item)}
|
|
onDelete={() => onDelete?.(item.id)}
|
|
/>
|
|
))
|
|
: currentProviderItems.map((item) => (
|
|
<ProviderGridItem
|
|
key={item.id}
|
|
item={item}
|
|
selected={selectedItem?.id === item.id}
|
|
onClick={() => {
|
|
// Merge loaded dimensions if provider didn't return them
|
|
const dims = loadedDimensions[item.id];
|
|
const itemWithDims = dims
|
|
? {
|
|
...item,
|
|
width: item.width ?? dims.width,
|
|
height: item.height ?? dims.height,
|
|
}
|
|
: item;
|
|
setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims));
|
|
}}
|
|
onDimensionsLoaded={(width, height) => {
|
|
setLoadedDimensions((prev) => ({
|
|
...prev,
|
|
[item.id]: { width, height },
|
|
}));
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-md border overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b bg-kumo-tint/50">
|
|
<th className="px-4 py-3 text-left text-sm font-medium">Preview</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">Filename</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">Type</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium">Size</th>
|
|
<th className="px-4 py-3 text-right text-sm font-medium">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activeProvider === "local"
|
|
? currentItems.map((item) => (
|
|
<MediaListItem
|
|
key={item.id}
|
|
item={item}
|
|
selected={selectedItem?.id === item.id}
|
|
onClick={() => setSelectedItem(item)}
|
|
onDelete={() => onDelete?.(item.id)}
|
|
/>
|
|
))
|
|
: currentProviderItems.map((item) => (
|
|
<ProviderListItem
|
|
key={item.id}
|
|
item={item}
|
|
selected={selectedItem?.id === item.id}
|
|
onClick={() => {
|
|
const dims = loadedDimensions[item.id];
|
|
const itemWithDims = dims
|
|
? {
|
|
...item,
|
|
width: item.width ?? dims.width,
|
|
height: item.height ?? dims.height,
|
|
}
|
|
: item;
|
|
setSelectedItem(providerItemToMediaItem(activeProvider, itemWithDims));
|
|
}}
|
|
onDimensionsLoaded={(width, height) => {
|
|
setLoadedDimensions((prev) => ({
|
|
...prev,
|
|
[item.id]: { width, height },
|
|
}));
|
|
}}
|
|
/>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail Panel */}
|
|
{selectedItem && (
|
|
<MediaDetailPanel
|
|
item={selectedItem}
|
|
onClose={() => setSelectedItem(null)}
|
|
onDeleted={() => {
|
|
if (activeProvider === "local") {
|
|
onDelete?.(selectedItem.id);
|
|
onItemUpdated?.();
|
|
} else {
|
|
void refetchProviderMedia();
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface MediaGridItemProps {
|
|
item: MediaItem;
|
|
selected?: boolean;
|
|
onClick?: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
function MediaGridItem({ item, selected, onClick }: MediaGridItemProps) {
|
|
const isImage = item.mimeType.startsWith("image/");
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
"group relative overflow-hidden rounded-lg border bg-kumo-base text-left transition-all max-w-[200px]",
|
|
selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50",
|
|
)}
|
|
>
|
|
<div className="aspect-square">
|
|
{isImage ? (
|
|
<img
|
|
src={item.url}
|
|
alt={item.alt || item.filename}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
|
|
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
|
<div className="w-full p-3">
|
|
<p className="truncate text-sm font-medium text-white">{item.filename}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface ProviderGridItemProps {
|
|
item: MediaProviderItem;
|
|
selected?: boolean;
|
|
onClick?: () => void;
|
|
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
|
|
onDimensionsLoaded?: (width: number, height: number) => void;
|
|
}
|
|
|
|
function ProviderGridItem({ item, selected, onClick, onDimensionsLoaded }: ProviderGridItemProps) {
|
|
const isImage = item.mimeType.startsWith("image/");
|
|
|
|
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
const img = e.currentTarget;
|
|
// Only report if we don't already have dimensions
|
|
if (onDimensionsLoaded && (!item.width || !item.height)) {
|
|
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={cn(
|
|
"group relative overflow-hidden rounded-lg border bg-kumo-base text-left transition-all max-w-[200px]",
|
|
selected ? "ring-2 ring-kumo-brand border-kumo-brand" : "hover:border-kumo-brand/50",
|
|
)}
|
|
>
|
|
<div className="aspect-square">
|
|
{isImage && item.previewUrl ? (
|
|
<img
|
|
src={item.previewUrl}
|
|
alt={item.alt || item.filename}
|
|
className="h-full w-full object-cover"
|
|
onLoad={handleImageLoad}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-kumo-tint">
|
|
<span className="text-4xl">{getFileIcon(item.mimeType)}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 to-transparent opacity-0 transition-opacity group-hover:opacity-100">
|
|
<div className="w-full p-3">
|
|
<p className="truncate text-sm font-medium text-white">{item.filename}</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
interface MediaListItemProps {
|
|
item: MediaItem;
|
|
selected?: boolean;
|
|
onClick?: () => void;
|
|
onDelete: () => void;
|
|
}
|
|
|
|
function MediaListItem({ item, selected, onClick }: MediaListItemProps) {
|
|
const isImage = item.mimeType.startsWith("image/");
|
|
|
|
return (
|
|
<tr
|
|
className={cn(
|
|
"border-b cursor-pointer transition-colors",
|
|
selected ? "bg-kumo-brand/10" : "hover:bg-kumo-tint/25",
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="h-10 w-10 overflow-hidden rounded">
|
|
{isImage ? (
|
|
<img
|
|
src={item.url}
|
|
alt={item.alt || item.filename}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
|
|
{getFileIcon(item.mimeType)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium">{item.filename}</td>
|
|
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
|
|
<td className="px-4 py-3 text-sm text-kumo-subtle">{formatFileSize(item.size)}</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<span className="text-sm text-kumo-subtle">
|
|
{item.alt ? "Alt text set" : "No alt text"}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
interface ProviderListItemProps {
|
|
item: MediaProviderItem;
|
|
selected?: boolean;
|
|
onClick?: () => void;
|
|
/** Callback when image dimensions are loaded (for providers that don't return dimensions) */
|
|
onDimensionsLoaded?: (width: number, height: number) => void;
|
|
}
|
|
|
|
function ProviderListItem({ item, selected, onClick, onDimensionsLoaded }: ProviderListItemProps) {
|
|
const isImage = item.mimeType.startsWith("image/");
|
|
|
|
const handleImageLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
const img = e.currentTarget;
|
|
if (onDimensionsLoaded && (!item.width || !item.height)) {
|
|
onDimensionsLoaded(img.naturalWidth, img.naturalHeight);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<tr
|
|
className={cn(
|
|
"border-b cursor-pointer transition-colors",
|
|
selected ? "bg-kumo-brand/10" : "hover:bg-kumo-tint/25",
|
|
)}
|
|
onClick={onClick}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="h-10 w-10 overflow-hidden rounded">
|
|
{isImage && item.previewUrl ? (
|
|
<img
|
|
src={item.previewUrl}
|
|
alt={item.alt || item.filename}
|
|
className="h-full w-full object-cover"
|
|
onLoad={handleImageLoad}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-kumo-tint text-xl">
|
|
{getFileIcon(item.mimeType)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium">{item.filename}</td>
|
|
<td className="px-4 py-3 text-sm text-kumo-subtle">{item.mimeType}</td>
|
|
<td className="px-4 py-3 text-sm text-kumo-subtle">
|
|
{item.size ? formatFileSize(item.size) : "—"}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<span className="text-sm text-kumo-subtle">
|
|
{item.alt ? "Alt text set" : "No alt text"}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export default MediaLibrary;
|