/** * Media Detail Panel * * A slide-out panel for viewing and editing media item metadata. * Opens when clicking an item in the MediaLibrary. */ import { Button, ClipboardText, Input, InputArea } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import { X, Trash, Calendar, HardDrive, LinkSimple, Ruler } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { updateMedia, deleteMedia, type MediaItem } from "../lib/api"; import { useStableCallback } from "../lib/hooks"; import { getFileIcon, formatFileSize } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { ConfirmDialog } from "./ConfirmDialog"; export interface MediaDetailPanelProps { item: MediaItem | null; onClose: () => void; onDeleted?: () => void; } /** * Slide-out panel for viewing and editing media metadata */ export function MediaDetailPanel({ item, onClose, onDeleted }: MediaDetailPanelProps) { const { t } = useLingui(); const queryClient = useQueryClient(); // Form state - controlled inputs const [filename, setFilename] = React.useState(item?.filename ?? ""); const [alt, setAlt] = React.useState(item?.alt ?? ""); const [caption, setCaption] = React.useState(item?.caption ?? ""); // Reset form when item changes React.useEffect(() => { if (item) { setFilename(item.filename); setAlt(item.alt ?? ""); setCaption(item.caption ?? ""); } }, [item]); // Public file URL — absolute so it can be pasted anywhere (relative API // paths from local storage are resolved against the current origin). const fileUrl = item ? new URL(item.url, window.location.origin).href : ""; // Track if form has unsaved changes const hasChanges = React.useMemo(() => { if (!item) return false; return ( filename !== item.filename || alt !== (item.alt ?? "") || caption !== (item.caption ?? "") ); }, [item, filename, alt, caption]); // Update mutation const updateMutation = useMutation({ mutationFn: (data: { alt?: string; caption?: string }) => { if (!item) throw new Error("No item selected"); return updateMedia(item.id, data); }, onSuccess: () => { // Invalidate to refresh the list void queryClient.invalidateQueries({ queryKey: ["media"] }); }, }); // Delete mutation const deleteMutation = useMutation({ mutationFn: () => { if (!item) throw new Error("No item selected"); return deleteMedia(item.id); }, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["media"] }); onDeleted?.(); onClose(); }, }); const handleSave = () => { if (!item || !hasChanges) return; updateMutation.mutate({ alt: alt || undefined, caption: caption || undefined, }); }; const [showDeleteConfirm, setShowDeleteConfirm] = React.useState(false); const handleDelete = () => { if (!item) return; setShowDeleteConfirm(true); }; const stableOnClose = useStableCallback(onClose); const stableHandleSave = useStableCallback(handleSave); // Handle keyboard shortcuts React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { stableOnClose(); } if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); stableHandleSave(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [stableOnClose, stableHandleSave]); if (!item) return null; const isImage = item.mimeType.startsWith("image/"); const isVideo = item.mimeType.startsWith("video/"); const isAudio = item.mimeType.startsWith("audio/"); return ( <>
{/* Header */}

{t`Media Details`}

{/* Content */}
{/* Preview */}
{isImage ? ( {item.alt ) : isVideo ? (
{/* File Info */}
{t`Size:`} {formatFileSize(item.size)}
{item.width && item.height && (
{t`Dimensions:`} {item.width} × {item.height}
)}
{t`Uploaded:`} {formatDate(item.createdAt)}
{t`URL:`}
{/* Editable Fields */}
setFilename(e.target.value)} disabled // Filename editing needs backend support description={t`Filename cannot be changed after upload`} /> {isImage && ( <> setAlt(e.target.value)} placeholder={t`Describe this image for accessibility`} description={t`Used by screen readers and when image fails to load`} /> setCaption(e.target.value)} placeholder={t`Optional caption for display`} rows={2} /> )}
{/* Footer */}
{ setShowDeleteConfirm(false); deleteMutation.reset(); }} title={t`Delete Media?`} description={t`Delete "${item.filename}"? This cannot be undone.`} confirmLabel={t`Delete`} pendingLabel={t`Deleting...`} isPending={deleteMutation.isPending} error={deleteMutation.error} onConfirm={() => deleteMutation.mutate()} /> ); } function formatDate(isoString: string): string { return new Date(isoString).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }); } export default MediaDetailPanel;