/** * 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 ( <>
{item.mimeType}