first commit
This commit is contained in:
673
packages/admin/src/components/MediaLibrary.tsx
Normal file
673
packages/admin/src/components/MediaLibrary.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user