UNPKG

sanity-plugin-mux-input

Version:

An input component that integrates Sanity Studio with Mux video encoding/hosting service.

1,208 lines (1,207 loc) 152 kB
import { useClient as useClient$1, createHookFromObservableFactory, useDocumentStore, collate, useDocumentValues, truncateString, useFormattedDuration, SanityDefaultPreview, useTimeAgo, TextWithTone, isRecord, getPreviewStateObservable, getPreviewValueWithFallback, DocumentPreviewPresence, useDocumentPreviewStore, useSchema, useDocumentPresence, PreviewCard, isReference, useProjectId, useDataset, useCurrentUser, PatchEvent, unset, setIfMissing, set, LinearProgress, FormField as FormField$2, definePlugin } from "sanity"; import { jsx, jsxs, Fragment } from "react/jsx-runtime"; import { ErrorOutlineIcon, RetryIcon, CheckmarkCircleIcon, RetrieveIcon, SortIcon, WarningOutlineIcon, EditIcon, PublishIcon, DocumentIcon, TrashIcon, RevertIcon, SearchIcon, ClockIcon, CropIcon, CalendarIcon, TagIcon, CheckmarkIcon, LockIcon, PlayIcon, PlugIcon, EllipsisHorizontalIcon, UploadIcon, ImageIcon, ResetIcon, TranslateIcon, WarningFilledIcon, DocumentVideoIcon } from "@sanity/icons"; import { Card, Box, Spinner, Stack, Text, Checkbox, Button, Dialog, Flex, Heading, Code, MenuButton, Menu, MenuItem, TextInput, Tooltip, Inline, useToast, TabList, Tab, TabPanel, Label as Label$1, Grid, useTheme_v2, useClickOutsideEvent, Popover, MenuDivider, Autocomplete, Radio, rem } from "@sanity/ui"; import React, { useState, useMemo, useEffect, useRef, useId, memo, createContext, useContext, isValidElement, useCallback, useReducer, PureComponent, createElement, forwardRef, Suspense } from "react"; import compact from "lodash/compact.js"; import toLower from "lodash/toLower.js"; import trim from "lodash/trim.js"; import uniq from "lodash/uniq.js"; import words from "lodash/words.js"; import { styled, css } from "styled-components"; import { uuid } from "@sanity/uuid"; import { defer, timer, of, Observable, concat, throwError, from, Subject } from "rxjs"; import { expand, concatMap, tap, switchMap, mergeMap, catchError, mergeMapTo, takeUntil } from "rxjs/operators"; import { suspend, clear, preload } from "suspend-react"; import MuxPlayer from "@mux/mux-player-react"; import { IntentLink } from "sanity/router"; import isNumber from "lodash/isNumber.js"; import isString from "lodash/isString.js"; import { useObservable } from "react-rx"; import useSWR from "swr"; import scrollIntoView from "scroll-into-view-if-needed"; import { UpChunk } from "@mux/upchunk"; import { isValidElementType } from "react-is"; import LanguagesList from "iso-639-1"; const ToolIcon = () => /* @__PURE__ */ jsx( "svg", { stroke: "currentColor", fill: "currentColor", strokeWidth: "0", viewBox: "0 0 24 24", height: "1em", width: "1em", xmlns: "http://www.w3.org/2000/svg", children: /* @__PURE__ */ jsx("path", { d: "M21 3H3c-1.11 0-2 .89-2 2v12c0 1.1.89 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.11-.9-2-2-2zm0 14H3V5h18v12zm-5-6l-7 4V7z" }) } ), SANITY_API_VERSION = "2024-03-05"; function useClient() { return useClient$1({ apiVersion: SANITY_API_VERSION }); } const SPECIAL_CHARS = /([^!@#$%^&*(),\\/?";:{}|[\]+<>\s-])+/g, STRIP_EDGE_CHARS = /(^[.]+)|([.]+$)/; function tokenize(string) { return (string.match(SPECIAL_CHARS) || []).map((token) => token.replace(STRIP_EDGE_CHARS, "")); } function toGroqParams(terms) { const params = {}; return terms.reduce((acc, term, i) => (acc[`t${i}`] = `*${term}*`, acc), params); } function extractTermsFromQuery(query) { const quotedQueries = [], unquotedQuery = query.replace(/("[^"]*")/g, (match) => words(match).length > 1 ? (quotedQueries.push(match), "") : match), quotedTerms = quotedQueries.map((str) => trim(toLower(str))), remainingTerms = uniq(compact(tokenize(toLower(unquotedQuery)))); return [...quotedTerms, ...remainingTerms]; } function createConstraints(terms, includeAssetId) { const searchPaths = includeAssetId ? ["filename", "assetId"] : ["filename"]; return terms.map((_term, i) => searchPaths.map((joinedPath) => `${joinedPath} match $t${i}`)).filter((constraint) => constraint.length > 0).map((constraint) => `(${constraint.join(" || ")})`); } function createSearchFilter(query) { const terms = extractTermsFromQuery(query); return { filter: createConstraints(terms, query.length >= 8), // if the search is big enough, include the assetId (mux id) in the results params: { ...toGroqParams(terms) } }; } const ASSET_SORT_OPTIONS = { createdDesc: { groq: "_createdAt desc", label: "Newest first" }, createdAsc: { groq: "_createdAt asc", label: "First created (oldest)" }, filenameAsc: { groq: "filename asc", label: "By filename (A-Z)" }, filenameDesc: { groq: "filename desc", label: "By filename (Z-A)" } }, useAssetDocuments = createHookFromObservableFactory(({ documentStore, sort, searchQuery }) => { const search = createSearchFilter(searchQuery), filter = ['_type == "mux.videoAsset"', ...search.filter].filter(Boolean).join(" && "), sortFragment = ASSET_SORT_OPTIONS[sort].groq; return documentStore.listenQuery( /* groq */ `*[${filter}] | order(${sortFragment})`, search.params, { apiVersion: SANITY_API_VERSION } ); }); function useAssets() { const documentStore = useDocumentStore(), [sort, setSort] = useState("createdDesc"), [searchQuery, setSearchQuery] = useState(""), [assetDocuments = [], isLoading] = useAssetDocuments( useMemo(() => ({ documentStore, sort, searchQuery }), [documentStore, sort, searchQuery]) ); return { assets: useMemo( () => ( // Avoid displaying both drafts & published assets by collating them together and giving preference to drafts collate(assetDocuments).map( (collated) => ({ ...collated.draft || collated.published || {}, _id: collated.id }) ) ), [assetDocuments] ), isLoading, sort, searchQuery, setSort, setSearchQuery }; } function parseMuxDate(date) { return new Date(Number(date) * 1e3); } const FIRST_PAGE = 1, ASSETS_PER_PAGE = 100; async function fetchMuxAssetsPage({ secretKey, token }, pageNum) { try { const json = await (await fetch( `https://api.mux.com/video/v1/assets?limit=${ASSETS_PER_PAGE}&page=${pageNum}`, { headers: { Authorization: `Basic ${btoa(`${token}:${secretKey}`)}` } } )).json(); return json.error ? { pageNum, error: { _tag: "MuxError", error: json.error } } : { pageNum, data: json.data }; } catch { return { pageNum, error: { _tag: "FetchError" } }; } } function accumulateIntermediateState(currentState, pageResult) { const currentData = "data" in currentState && currentState.data || []; return { ...currentState, data: [ ...currentData, ...("data" in pageResult && pageResult.data || []).filter( // De-duplicate assets for safety (asset) => !currentData.some((a2) => a2.id === asset.id) ) ], error: "error" in pageResult ? pageResult.error : ( // Reset error if current page is successful void 0 ), pageNum: pageResult.pageNum, loading: !0 }; } function hasMorePages(pageResult) { return typeof pageResult == "object" && "data" in pageResult && Array.isArray(pageResult.data) && pageResult.data.length > 0; } function useMuxAssets({ secrets, enabled }) { const [state, setState] = useState({ loading: !0, pageNum: FIRST_PAGE }); return useEffect(() => { if (!enabled) return; const subscription = defer( () => fetchMuxAssetsPage( secrets, // When we've already successfully loaded before (fully or partially), we start from the following page to avoid re-fetching "data" in state && state.data && state.data.length > 0 && !state.error ? state.pageNum + 1 : state.pageNum ) ).pipe( // Here we replace "concatMap" with "expand" to recursively fetch next pages expand((pageResult) => hasMorePages(pageResult) ? timer(2e3).pipe( // eslint-disable-next-line max-nested-callbacks concatMap(() => defer(() => fetchMuxAssetsPage(secrets, pageResult.pageNum + 1))) ) : of()), // On each iteration, persist intermediate states to give feedback to users tap( (pageResult) => setState((prevState) => accumulateIntermediateState(prevState, pageResult)) ) ).subscribe({ // Once done, let the user know we've stopped loading complete: () => { setState((prev) => ({ ...prev, loading: !1 })); } }); return () => subscription.unsubscribe(); }, [enabled]), state; } const name = "mux-input", cacheNs = "sanity-plugin-mux-input", muxSecretsDocumentId = "secrets.mux", DIALOGS_Z_INDEX = 6e4, THUMBNAIL_ASPECT_RATIO = 1.7777777777777777, MIN_ASPECT_RATIO = 5 / 4, AUDIO_ASPECT_RATIO = 5 / 1, path$1 = ["token", "secretKey", "enableSignedUrls", "signingKeyId", "signingKeyPrivate"], useSecretsDocumentValues = () => { const { error, isLoading, value } = useDocumentValues( muxSecretsDocumentId, path$1 ), cache = useMemo(() => { const exists = !!value, secrets = { token: value?.token || null, secretKey: value?.secretKey || null, enableSignedUrls: value?.enableSignedUrls || !1, signingKeyId: value?.signingKeyId || null, signingKeyPrivate: value?.signingKeyPrivate || null }; return { isInitialSetup: !exists, needsSetup: !secrets?.token || !secrets?.secretKey, secrets }; }, [value]); return { error, isLoading, value: cache }; }; function useImportMuxAssets() { const documentStore = useDocumentStore(), client = useClient$1({ apiVersion: SANITY_API_VERSION }), [assetsInSanity, assetsInSanityLoading] = useAssetsInSanity(documentStore), secretDocumentValues = useSecretsDocumentValues(), hasSecrets = !!secretDocumentValues.value.secrets?.secretKey, [importError, setImportError] = useState(), [importState, setImportState] = useState("closed"), dialogOpen = importState !== "closed", muxAssets = useMuxAssets({ secrets: secretDocumentValues.value.secrets, enabled: hasSecrets && dialogOpen }), missingAssets = useMemo(() => assetsInSanity && muxAssets.data ? muxAssets.data.filter((a2) => !assetExistsInSanity(a2, assetsInSanity)) : void 0, [assetsInSanity, muxAssets.data]), [selectedAssets, setSelectedAssets] = useState([]), closeDialog = () => { importState !== "importing" && setImportState("closed"); }, openDialog = () => { importState === "closed" && setImportState("idle"); }; async function importAssets() { setImportState("importing"); const documents = selectedAssets.flatMap((asset) => muxAssetToSanityDocument(asset) || []), tx = client.transaction(); documents.forEach((doc) => tx.create(doc)); try { await tx.commit({ returnDocuments: !1 }), setSelectedAssets([]), setImportState("done"); } catch (error) { setImportState("error"), setImportError(error); } } return { assetsInSanityLoading, closeDialog, dialogOpen, importState, importError, hasSecrets, importAssets, missingAssets, muxAssets, openDialog, selectedAssets, setSelectedAssets }; } function muxAssetToSanityDocument(asset) { const playbackId = (asset.playback_ids || []).find((p) => p.id)?.id; if (playbackId) return { _id: uuid(), _type: "mux.videoAsset", _updatedAt: (/* @__PURE__ */ new Date()).toISOString(), _createdAt: parseMuxDate(asset.created_at).toISOString(), assetId: asset.id, playbackId, filename: asset.meta?.title ?? `Asset #${truncateString(asset.id, 15)}`, status: asset.status, data: asset }; } const useAssetsInSanity = createHookFromObservableFactory( (documentStore) => documentStore.listenQuery( /* groq */ `*[_type == "mux.videoAsset"] { "uploadId": coalesce(uploadId, data.upload_id), "assetId": coalesce(assetId, data.id), }`, {}, { apiVersion: SANITY_API_VERSION } ) ); function assetExistsInSanity(asset, existingAssets) { return asset.status !== "ready" ? !1 : existingAssets.some( (existing) => existing.assetId === asset.id || existing.uploadId === asset.upload_id ); } function useInView(ref, options = {}) { const [inView, setInView] = useState(!1); return useEffect(() => { if (!ref.current) return; const observer = new IntersectionObserver(([entry], obs) => { const nowInView = entry.isIntersecting && obs.thresholds.some((threshold) => entry.intersectionRatio >= threshold); setInView(nowInView), options?.onChange?.(nowInView); }, options), toObserve = ref.current; return observer.observe(toObserve), () => { toObserve && observer.unobserve(toObserve); }; }, [options, ref]), inView; } const _id = "secrets.mux"; function readSecrets(client) { const { projectId, dataset } = client.config(); return suspend(async () => { const data = await client.fetch( /* groq */ `*[_id == $_id][0]{ token, secretKey, enableSignedUrls, signingKeyId, signingKeyPrivate }`, { _id } ); return { token: data?.token || null, secretKey: data?.secretKey || null, enableSignedUrls: !!data?.enableSignedUrls || !1, signingKeyId: data?.signingKeyId || null, signingKeyPrivate: data?.signingKeyPrivate || null }; }, [cacheNs, _id, projectId, dataset]); } function generateJwt(client, playbackId, aud, payload) { const { signingKeyId, signingKeyPrivate } = readSecrets(client); if (!signingKeyId) throw new TypeError("Missing `signingKeyId`.\n Check your plugin's configuration"); if (!signingKeyPrivate) throw new TypeError("Missing `signingKeyPrivate`.\n Check your plugin's configuration"); const { default: sign } = suspend(() => import("jsonwebtoken-esm/sign"), ["jsonwebtoken-esm/sign"]); return sign( payload ? JSON.parse(JSON.stringify(payload, (_, v) => v ?? void 0)) : {}, atob(signingKeyPrivate), { algorithm: "RS256", keyid: signingKeyId, audience: aud, subject: playbackId, noTimestamp: !0, expiresIn: "12h" } ); } function getPlaybackId(asset) { if (!asset?.playbackId) throw console.error("Asset is missing a playbackId", { asset }), new TypeError("Missing playbackId"); return asset.playbackId; } function getPlaybackPolicy(asset) { return asset.data?.playback_ids?.find((playbackId) => asset.playbackId === playbackId.id)?.policy ?? "public"; } function createUrlParamsObject(client, asset, params, audience) { const playbackId = getPlaybackId(asset); let searchParams = new URLSearchParams( JSON.parse(JSON.stringify(params, (_, v) => v ?? void 0)) ); if (getPlaybackPolicy(asset) === "signed") { const token = generateJwt(client, playbackId, audience, params); searchParams = new URLSearchParams({ token }); } return { playbackId, searchParams }; } function getAnimatedPosterSrc({ asset, client, height, width, start = asset.thumbTime ? Math.max(0, asset.thumbTime - 2.5) : 0, end = start + 5, fps = 15 }) { const params = { height, width, start, end, fps }, { playbackId, searchParams } = createUrlParamsObject(client, asset, params, "g"); return `https://image.mux.com/${playbackId}/animated.gif?${searchParams}`; } function getPosterSrc({ asset, client, fit_mode, height, time = asset.thumbTime ?? void 0, width }) { const params = { fit_mode, height, width }; time && (params.time = time); const { playbackId, searchParams } = createUrlParamsObject(client, asset, params, "t"); return `https://image.mux.com/${playbackId}/thumbnail.png?${searchParams}`; } const Image = styled.img` transition: opacity 0.175s ease-out 0s; display: block; width: 100%; height: 100%; object-fit: contain; object-position: center center; `, STATUS_TO_TONE = { loading: "transparent", error: "critical", loaded: "default" }; function VideoThumbnail({ asset, width, staticImage = !1 }) { const ref = useRef(null), inView = useInView(ref), posterWidth = width || 250, [status, setStatus] = useState("loading"), client = useClient(), src = useMemo(() => { try { let thumbnail; return staticImage ? thumbnail = getPosterSrc({ asset, client, width: posterWidth }) : thumbnail = getAnimatedPosterSrc({ asset, client, width: posterWidth }), thumbnail; } catch { status !== "error" && setStatus("error"); return; } }, [asset, client, posterWidth, status, staticImage]); function handleLoad() { setStatus("loaded"); } function handleError() { setStatus("error"); } return /* @__PURE__ */ jsx( Card, { style: { aspectRatio: THUMBNAIL_ASPECT_RATIO, position: "relative", maxWidth: width ? `${width}px` : void 0, width: "100%", flex: 1 }, border: !0, radius: 2, ref, tone: STATUS_TO_TONE[status], children: inView ? /* @__PURE__ */ jsxs(Fragment, { children: [ status === "loading" && /* @__PURE__ */ jsx( Box, { style: { position: "absolute", left: "50%", top: "50%", transform: "translate(-50%, -50%)" }, children: /* @__PURE__ */ jsx(Spinner, {}) } ), status === "error" && /* @__PURE__ */ jsxs( Stack, { space: 4, style: { position: "absolute", width: "100%", left: 0, top: "50%", transform: "translateY(-50%)", justifyItems: "center" }, children: [ /* @__PURE__ */ jsx(Text, { size: 4, muted: !0, children: /* @__PURE__ */ jsx(ErrorOutlineIcon, { style: { fontSize: "1.75em" } }) }), /* @__PURE__ */ jsx(Text, { muted: !0, align: "center", children: "Failed loading thumbnail" }) ] } ), /* @__PURE__ */ jsx( Image, { src, alt: `Preview for ${staticImage ? "image" : "video"} ${asset.filename || asset.assetId}`, onLoad: handleLoad, onError: handleError, style: { opacity: status === "loaded" ? 1 : 0 } } ) ] }) : null } ); } const MissingAssetCheckbox = styled(Checkbox)` position: static !important; input::after { content: ''; position: absolute; inset: 0; display: block; cursor: pointer; z-index: 1000; } `; function MissingAsset({ asset, selectAsset, selected }) { const duration = useFormattedDuration(asset.duration * 1e3); return /* @__PURE__ */ jsx( Card, { tone: selected ? "positive" : void 0, border: !0, paddingX: 2, paddingY: 3, style: { position: "relative" }, radius: 1, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [ /* @__PURE__ */ jsx( MissingAssetCheckbox, { checked: selected, onChange: (e) => { selectAsset(e.currentTarget.checked); }, "aria-label": selected ? `Import video ${asset.id}` : `Skip import of video ${asset.id}` } ), /* @__PURE__ */ jsx( VideoThumbnail, { asset: { assetId: asset.id, data: asset, filename: asset.id, playbackId: asset.playback_ids.find((p) => p.id)?.id }, width: 150 } ), /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 1, children: [ /* @__PURE__ */ jsx(Code, { size: 2, children: truncateString(asset.id, 15) }), " ", /* @__PURE__ */ jsxs(Text, { muted: !0, size: 2, children: [ "(", duration.formatted, ")" ] }) ] }), /* @__PURE__ */ jsxs(Text, { size: 1, children: [ "Uploaded at", " ", new Date(Number(asset.created_at) * 1e3).toLocaleDateString("en", { year: "numeric", day: "2-digit", month: "2-digit" }) ] }) ] }) ] }) }, asset.id ); } function ImportVideosDialog(props) { const { importState } = props, canTriggerImport = (importState === "idle" || importState === "error") && props.selectedAssets.length > 0, isImporting = importState === "importing", noAssetsToImport = props.missingAssets?.length === 0 && !props.muxAssets.loading && !props.assetsInSanityLoading; return /* @__PURE__ */ jsx( Dialog, { animate: !0, header: "Import videos from Mux", zOffset: DIALOGS_Z_INDEX, id: "video-details-dialog", onClose: props.closeDialog, onClickOutside: props.closeDialog, width: 1, position: "fixed", footer: importState !== "done" && !noAssetsToImport && /* @__PURE__ */ jsx(Card, { padding: 3, children: /* @__PURE__ */ jsxs(Flex, { justify: "space-between", align: "center", children: [ /* @__PURE__ */ jsx( Button, { fontSize: 2, padding: 3, mode: "bleed", text: "Cancel", tone: "critical", onClick: props.closeDialog, disabled: isImporting } ), props.missingAssets && /* @__PURE__ */ jsx( Button, { icon: RetrieveIcon, fontSize: 2, padding: 3, mode: "ghost", text: props.selectedAssets?.length > 0 ? `Import ${props.selectedAssets.length} video(s)` : "No video(s) selected", tone: "positive", onClick: props.importAssets, iconRight: isImporting && Spinner, disabled: !canTriggerImport } ) ] }) }), children: /* @__PURE__ */ jsxs(Box, { padding: 3, children: [ (props.muxAssets.loading || props.assetsInSanityLoading) && /* @__PURE__ */ jsx(Card, { tone: "primary", marginBottom: 5, padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 4, children: [ /* @__PURE__ */ jsx(Spinner, { muted: !0, size: 4 }), /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { size: 2, weight: "semibold", children: "Loading assets from Mux" }), /* @__PURE__ */ jsxs(Text, { size: 1, children: [ "This may take a while.", props.missingAssets && props.missingAssets.length > 0 && ` There are at least ${props.missingAssets.length} video${props.missingAssets.length > 1 ? "s" : ""} currently not in Sanity...` ] }) ] }) ] }) }), props.muxAssets.error && /* @__PURE__ */ jsx(Card, { tone: "critical", marginBottom: 5, padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [ /* @__PURE__ */ jsx(ErrorOutlineIcon, { fontSize: 36 }), /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { size: 2, weight: "semibold", children: "There was an error getting all data from Mux" }), /* @__PURE__ */ jsx(Text, { size: 1, children: props.missingAssets ? `But we've found ${props.missingAssets.length} video${props.missingAssets.length > 1 ? "s" : ""} not in Sanity, which you can start importing now.` : "Please try again or contact a developer for help." }) ] }) ] }) }), importState === "importing" && /* @__PURE__ */ jsx(Card, { tone: "primary", marginBottom: 5, padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 4, children: [ /* @__PURE__ */ jsx(Spinner, { muted: !0, size: 4 }), /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsxs(Text, { size: 2, weight: "semibold", children: [ "Importing ", props.selectedAssets.length, " video", props.selectedAssets.length > 1 && "s", " from Mux" ] }) }) ] }) }), importState === "error" && /* @__PURE__ */ jsx(Card, { tone: "critical", marginBottom: 5, padding: 3, border: !0, children: /* @__PURE__ */ jsxs(Flex, { align: "center", gap: 2, children: [ /* @__PURE__ */ jsx(ErrorOutlineIcon, { fontSize: 36 }), /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { size: 2, weight: "semibold", children: "There was an error importing videos" }), /* @__PURE__ */ jsx(Text, { size: 1, children: props.importError ? `Error: ${props.importError}` : "Please try again or contact a developer for help." }), /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx( Button, { icon: RetryIcon, text: "Retry", tone: "primary", onClick: props.importAssets } ) }) ] }) ] }) }), (noAssetsToImport || importState === "done") && /* @__PURE__ */ jsxs(Stack, { paddingY: 5, marginBottom: 4, space: 3, style: { textAlign: "center" }, children: [ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(CheckmarkCircleIcon, { fontSize: 48 }) }), /* @__PURE__ */ jsx(Heading, { size: 2, children: importState === "done" ? "Videos imported successfully" : "There are no Mux videos to import" }), /* @__PURE__ */ jsx(Text, { size: 2, children: importState === "done" ? "You can now use them in your Sanity content." : "They're all in Sanity and ready to be used in your content." }) ] }), props.missingAssets && props.missingAssets.length > 0 && (importState === "idle" || importState === "error") && /* @__PURE__ */ jsxs(Stack, { space: 4, children: [ /* @__PURE__ */ jsxs(Heading, { size: 1, children: [ "There are ", props.missingAssets.length, props.muxAssets.loading && "+", " Mux video", props.missingAssets.length > 1 && "s", " ", "not in Sanity" ] }), !props.muxAssets.loading && /* @__PURE__ */ jsxs(Flex, { align: "center", paddingX: 2, children: [ /* @__PURE__ */ jsx( Checkbox, { id: "import-all", style: { display: "block" }, onClick: (e) => { e.currentTarget.checked ? props.missingAssets && props.setSelectedAssets(props.missingAssets) : props.setSelectedAssets([]); }, checked: props.selectedAssets.length === props.missingAssets.length } ), /* @__PURE__ */ jsx(Box, { flex: 1, paddingLeft: 3, as: "label", htmlFor: "import-all", children: /* @__PURE__ */ jsx(Text, { children: "Import all" }) }) ] }), props.missingAssets.map((asset) => /* @__PURE__ */ jsx( MissingAsset, { asset, selectAsset: (selected) => { selected ? props.setSelectedAssets([...props.selectedAssets, asset]) : props.setSelectedAssets(props.selectedAssets.filter((a2) => a2.id !== asset.id)); }, selected: props.selectedAssets.some((a2) => a2.id === asset.id) }, asset.id )) ] }) ] }) } ); } function ImportVideosFromMux() { const importAssets = useImportMuxAssets(); if (importAssets.hasSecrets) return importAssets.dialogOpen ? /* @__PURE__ */ jsx(ImportVideosDialog, { ...importAssets }) : /* @__PURE__ */ jsx(Button, { mode: "bleed", text: "Import from Mux", onClick: importAssets.openDialog }); } const CONTEXT_MENU_POPOVER_PROPS = { constrainSize: !0, placement: "bottom", portal: !0, width: 0 }; function SelectSortOptions(props) { const id = useId(); return /* @__PURE__ */ jsx( MenuButton, { button: /* @__PURE__ */ jsx(Button, { text: "Sort", icon: SortIcon, mode: "bleed", padding: 3, style: { cursor: "pointer" } }), id, menu: /* @__PURE__ */ jsx(Menu, { children: Object.entries(ASSET_SORT_OPTIONS).map(([type, { label }]) => /* @__PURE__ */ jsx( MenuItem, { "data-as": "button", onClick: () => props.setSort(type), padding: 3, tone: "default", text: label, pressed: type === props.sort }, type )) }), popover: CONTEXT_MENU_POPOVER_PROPS } ); } const SpinnerBox = () => /* @__PURE__ */ jsx( Box, { style: { display: "flex", alignItems: "center", justifyContent: "center", minHeight: "150px" }, children: /* @__PURE__ */ jsx(Spinner, {}) } ); function FormField(props) { const { children, title, description, inputId } = props; return /* @__PURE__ */ jsxs(Stack, { space: 1, children: [ /* @__PURE__ */ jsx(Flex, { align: "flex-end", children: /* @__PURE__ */ jsx(Box, { flex: 1, paddingY: 2, children: /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { as: "label", htmlFor: inputId, weight: "semibold", size: 1, children: title || /* @__PURE__ */ jsx("em", { children: "Untitled" }) }), description && /* @__PURE__ */ jsx(Text, { muted: !0, size: 1, children: description }) ] }) }) }), /* @__PURE__ */ jsx("div", { children }) ] }); } var FormField$1 = memo(FormField); const IconInfo = (props) => { const Icon = props.icon; return /* @__PURE__ */ jsxs(Flex, { gap: 2, align: "center", padding: 1, children: [ /* @__PURE__ */ jsx(Text, { size: (props.size || 1) + 1, muted: !0, children: /* @__PURE__ */ jsx(Icon, {}) }), /* @__PURE__ */ jsx(Text, { size: props.size || 1, muted: props.muted, children: props.text }) ] }); }; function ResolutionIcon(props) { return /* @__PURE__ */ jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "1em", height: "1em", viewBox: "0 0 24 24", ...props, children: /* @__PURE__ */ jsx( "path", { fill: "currentColor", d: "M20 9V6h-3V4h5v5h-2ZM2 9V4h5v2H4v3H2Zm15 11v-2h3v-3h2v5h-5ZM2 20v-5h2v3h3v2H2Zm4-4V8h12v8H6Zm2-2h8v-4H8v4Zm0 0v-4v4Z" } ) }); } function StopWatchIcon(props) { return /* @__PURE__ */ jsxs( "svg", { xmlns: "http://www.w3.org/2000/svg", width: "1em", height: "1em", viewBox: "0 0 512 512", ...props, children: [ /* @__PURE__ */ jsx("path", { d: "M232 306.667h48V176h-48v130.667z", fill: "currentColor" }), /* @__PURE__ */ jsx( "path", { d: "M407.67 170.271l30.786-30.786-33.942-33.941-30.785 30.786C341.217 111.057 300.369 96 256 96 149.961 96 64 181.961 64 288s85.961 192 192 192 192-85.961 192-192c0-44.369-15.057-85.217-40.33-117.729zm-45.604 223.795C333.734 422.398 296.066 438 256 438s-77.735-15.602-106.066-43.934C121.602 365.735 106 328.066 106 288s15.602-77.735 43.934-106.066C178.265 153.602 215.934 138 256 138s77.734 15.602 106.066 43.934C390.398 210.265 406 247.934 406 288s-15.602 77.735-43.934 106.066z", fill: "currentColor" } ), /* @__PURE__ */ jsx("path", { d: "M192 32h128v48H192z", fill: "currentColor" }) ] } ); } const DialogStateContext = createContext({ dialogState: !1, setDialogState: () => null }), DialogStateProvider = ({ dialogState, setDialogState, children }) => /* @__PURE__ */ jsx(DialogStateContext.Provider, { value: { dialogState, setDialogState }, children }), useDialogStateContext = () => useContext(DialogStateContext); function getVideoSrc({ asset, client }) { const playbackId = getPlaybackId(asset), searchParams = new URLSearchParams(); if (getPlaybackPolicy(asset) === "signed") { const token = generateJwt(client, playbackId, "v"); searchParams.set("token", token); } return `https://stream.mux.com/${playbackId}.m3u8?${searchParams}`; } function getDevicePixelRatio(options) { const { defaultDpr = 1, maxDpr = 3, round = !0 } = options || {}, dpr = typeof window < "u" && typeof window.devicePixelRatio == "number" ? window.devicePixelRatio : defaultDpr; return Math.min(Math.max(1, round ? Math.floor(dpr) : dpr), maxDpr); } function formatSeconds(seconds) { if (typeof seconds != "number" || Number.isNaN(seconds)) return ""; const hrs = ~~(seconds / 3600), mins = ~~(seconds % 3600 / 60), secs = ~~seconds % 60; let ret = ""; return hrs > 0 && (ret += "" + hrs + ":" + (mins < 10 ? "0" : "")), ret += "" + mins + ":" + (secs < 10 ? "0" : ""), ret += "" + secs, ret; } function formatSecondsToHHMMSS(seconds) { const hrs = Math.floor(seconds / 3600).toString().padStart(2, "0"), mins = Math.floor(seconds % 3600 / 60).toString().padStart(2, "0"), secs = Math.floor(seconds % 60).toString().padStart(2, "0"); return `${hrs}:${mins}:${secs}`; } function isValidTimeFormat(time) { return /^([0-1]?[0-9]|2[0-3]):([0-5]?[0-9]):([0-5]?[0-9])$/.test(time) || time === ""; } function getSecondsFromTimeFormat(time) { const [hh = 0, mm = 0, ss = 0] = time.split(":").map(Number); return hh * 3600 + mm * 60 + ss; } function EditThumbnailDialog({ asset, currentTime = 0 }) { const client = useClient(), { setDialogState } = useDialogStateContext(), dialogId = `EditThumbnailDialog${useId()}`, [timeFormatted, setTimeFormatted] = useState( () => formatSecondsToHHMMSS(currentTime) ), [nextTime, setNextTime] = useState(currentTime), [inputError, setInputError] = useState(""), assetWithNewThumbnail = useMemo(() => ({ ...asset, thumbTime: nextTime }), [asset, nextTime]), [saving, setSaving] = useState(!1), [saveThumbnailError, setSaveThumbnailError] = useState(null), handleSave = () => { setSaving(!0), client.patch(asset._id).set({ thumbTime: nextTime }).commit({ returnDocuments: !1 }).then(() => void setDialogState(!1)).catch(setSaveThumbnailError).finally(() => void setSaving(!1)); }, width = 300 * getDevicePixelRatio({ maxDpr: 2 }); if (saveThumbnailError) throw saveThumbnailError; return /* @__PURE__ */ jsx( Dialog, { id: dialogId, header: "Edit thumbnail", onClose: () => setDialogState(!1), footer: /* @__PURE__ */ jsx(Stack, { padding: 3, children: /* @__PURE__ */ jsx( Button, { disabled: inputError !== "", mode: "ghost", tone: "primary", loading: saving, onClick: handleSave, text: "Set new thumbnail" }, "thumbnail" ) }), children: /* @__PURE__ */ jsxs(Stack, { space: 3, padding: 3, children: [ /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "Current:" }), /* @__PURE__ */ jsx(VideoThumbnail, { asset, width, staticImage: !0 }) ] }), /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "New:" }), /* @__PURE__ */ jsx(VideoThumbnail, { asset: assetWithNewThumbnail, width, staticImage: !0 }) ] }), /* @__PURE__ */ jsx(Stack, { space: 2, children: /* @__PURE__ */ jsx(Flex, { align: "center", justify: "center", children: /* @__PURE__ */ jsx(Text, { size: 5, weight: "semibold", children: "Or" }) }) }), /* @__PURE__ */ jsxs(Stack, { space: 2, children: [ /* @__PURE__ */ jsx(Text, { size: 1, weight: "semibold", children: "Selected time for thumbnail (hh:mm:ss):" }), /* @__PURE__ */ jsx( TextInput, { size: 1, value: timeFormatted, placeholder: "hh:mm:ss", onChange: (event) => { const value = event.currentTarget.value; if (setTimeFormatted(value), isValidTimeFormat(value)) { setInputError(""); const totalSeconds = getSecondsFromTimeFormat(value); setNextTime(totalSeconds); } else setInputError("Invalid time format"); }, customValidity: inputError } ) ] }) ] }) } ); } function VideoPlayer({ asset, thumbnailWidth = 250, children, ...props }) { const client = useClient(), { dialogState } = useDialogStateContext(), isAudio = assetIsAudio(asset), muxPlayer = useRef(null), thumbnail = getPosterSrc({ asset, client, width: thumbnailWidth }), { src: videoSrc, error } = useMemo(() => { try { const src = asset?.playbackId && getVideoSrc({ client, asset }); return src ? { src } : { error: new TypeError("Asset has no playback ID") }; } catch (error2) { return { error: error2 }; } }, [asset, client]), signedToken = useMemo(() => { try { return new URL(videoSrc).searchParams.get("token"); } catch { return !1; } }, [videoSrc]), [width, height] = (asset?.data?.aspect_ratio ?? "16:9").split(":").map(Number), targetAspectRatio = props.forceAspectRatio || (Number.isNaN(width) ? 16 / 9 : width / height); let aspectRatio = Math.max(MIN_ASPECT_RATIO, targetAspectRatio); return isAudio && (aspectRatio = props.forceAspectRatio ? ( // Make it wider when forcing aspect ratio to balance with videos' rendering height (audio players overflow a bit) props.forceAspectRatio * 1.2 ) : AUDIO_ASPECT_RATIO), /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsxs(Card, { tone: "transparent", style: { aspectRatio, position: "relative" }, children: [ videoSrc && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx( MuxPlayer, { poster: thumbnail, ref: muxPlayer, ...props, playsInline: !0, playbackId: asset.playbackId, tokens: signedToken ? { playback: signedToken, thumbnail: signedToken, storyboard: signedToken } : void 0, preload: "metadata", crossOrigin: "anonymous", metadata: { player_name: "Sanity Admin Dashboard", player_version: "2.9.0", page_type: "Preview Player" }, audio: isAudio, style: { height: "100%", width: "100%", display: "block", objectFit: "contain" } } ), children ] }), error ? /* @__PURE__ */ jsx( "div", { style: { position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)" }, children: /* @__PURE__ */ jsxs(Text, { muted: !0, children: [ /* @__PURE__ */ jsx(ErrorOutlineIcon, { style: { marginRight: "0.15em" } }), typeof error == "object" && "message" in error && typeof error.message == "string" ? error.message : "Error loading video" ] }) } ) : null, children ] }), dialogState === "edit-thumbnail" && /* @__PURE__ */ jsx(EditThumbnailDialog, { asset, currentTime: muxPlayer?.current?.currentTime }) ] }); } function assetIsAudio(asset) { return asset.data?.max_stored_resolution === "Audio only"; } function deleteAssetOnMux(client, assetId) { const { dataset } = client.config(); return client.request({ url: `/addons/mux/assets/${dataset}/${assetId}`, withCredentials: !0, method: "DELETE" }); } async function deleteAsset({ client, asset, deleteOnMux }) { if (!asset?._id) return !0; try { await client.delete(asset._id); } catch { return "failed-sanity"; } if (deleteOnMux && asset?.assetId) try { await deleteAssetOnMux(client, asset.assetId); } catch { return "failed-mux"; } return !0; } function getAsset(client, assetId) { const { dataset } = client.config(); return client.request({ url: `/addons/mux/assets/${dataset}/data/${assetId}`, withCredentials: !0, method: "GET" }); } const getUnknownTypeFallback = (id, typeName) => ({ title: /* @__PURE__ */ jsxs("em", { children: [ "No schema found for type ", /* @__PURE__ */ jsx("code", { children: typeName }) ] }), subtitle: /* @__PURE__ */ jsxs("em", { children: [ "Document: ", /* @__PURE__ */ jsx("code", { children: id }) ] }), media: () => /* @__PURE__ */ jsx(WarningOutlineIcon, {}) }); function MissingSchemaType(props) { const { layout, value } = props; return /* @__PURE__ */ jsx(SanityDefaultPreview, { ...getUnknownTypeFallback(value._id, value._type), layout }); } function TimeAgo({ time }) { const timeAgo = useTimeAgo(time); return /* @__PURE__ */ jsxs("span", { title: timeAgo, children: [ timeAgo, " ago" ] }); } function DraftStatus(props) { const { document: document2 } = props, updatedAt = document2 && "_updatedAt" in document2 && document2._updatedAt; return /* @__PURE__ */ jsx( Tooltip, { animate: !0, portal: !0, content: /* @__PURE__ */ jsx(Box, { padding: 2, children: /* @__PURE__ */ jsx(Text, { size: 1, children: document2 ? /* @__PURE__ */ jsxs(Fragment, { children: [ "Edited ", updatedAt && /* @__PURE__ */ jsx(TimeAgo, { time: updatedAt }) ] }) : /* @__PURE__ */ jsx(Fragment, { children: "No unpublished edits" }) }) }), children: /* @__PURE__ */ jsx(TextWithTone, { tone: "caution", dimmed: !document2, muted: !document2, size: 1, children: /* @__PURE__ */ jsx(EditIcon, {}) }) } ); } function PublishedStatus(props) { const { document: document2 } = props, updatedAt = document2 && "_updatedAt" in document2 && document2._updatedAt; return /* @__PURE__ */ jsx( Tooltip, { animate: !0, portal: !0, content: /* @__PURE__ */ jsx(Box, { padding: 2, children: /* @__PURE__ */ jsx(Text, { size: 1, children: document2 ? /* @__PURE__ */ jsxs(Fragment, { children: [ "Published ", updatedAt && /* @__PURE__ */ jsx(TimeAgo, { time: updatedAt }) ] }) : /* @__PURE__ */ jsx(Fragment, { children: "Not published" }) }) }), children: /* @__PURE__ */ jsx(TextWithTone, { tone: "positive", dimmed: !document2, muted: !document2, size: 1, children: /* @__PURE__ */ jsx(PublishIcon, {}) }) } ); } function PaneItemPreview(props) { const { icon, layout, presence, schemaType, value } = props, title = isRecord(value.title) && isValidElement(value.title) || isString(value.title) || isNumber(value.title) ? value.title : null, observable = useMemo( () => getPreviewStateObservable(props.documentPreviewStore, schemaType, value._id, title), [props.documentPreviewStore, schemaType, title, value._id] ), { draft, published, isLoading } = useObservable(observable, { draft: null, published: null, isLoading: !0 }), status = isLoading ? null : /* @__PURE__ */ jsxs(Inline, { space: 4, children: [ presence && presence.length > 0 && /* @__PURE__ */ jsx(DocumentPreviewPresence, { presence }), /* @__PURE__ */ jsx(PublishedStatus, { document: published }), /* @__PURE__ */ jsx(DraftStatus, { document: draft }) ] }); return /* @__PURE__ */ jsx( SanityDefaultPreview, { ...getPreviewValueWithFallback({ value, draft, published }), isPlaceholder: isLoading, icon, layout, status } ); } function getIconWithFallback(icon, schemaType, defaultIcon) { return icon === !1 ? !1 : icon || schemaType && schemaType.icon || defaultIcon || !1; } function DocumentPreviewLink(props) { return (linkProps) => /* @__PURE__ */ jsx(IntentLink, { intent: "edit", params: { id: props.documentPair.id }, children: linkProps.children }); } function DocumentPreview(props) { const { schemaType, documentPair } = props, doc = documentPair?.draft || documentPair?.published, id = documentPair.id || "", documentPreviewStore = useDocumentPreviewStore(), schema = useSchema(), documentPresence = useDocumentPresence(id), hasSchemaType = !!(schemaType && schemaType.name && schema.get(schemaType.name)), PreviewComponent = useMemo(() => doc ? !schemaType || !hasSchemaType ? /* @__PURE__ */ jsx(MissingSchemaType, { value: doc }) : /* @__PURE__ */ jsx( PaneItemPreview, { documentPreviewStore, icon: getIconWithFallback(void 0, schemaType, DocumentIcon), schemaType, layout: "default", value: doc, presence: documentPresence } ) : null, [hasSchemaType, schemaType, documentPresence, doc, documentPreviewStore]); return /* @__PURE__ */ jsx( PreviewCard, { __unstable_focusRing: !0, as: DocumentPreviewLink(props), "data-as": "a", "data-ui": "PaneItem", padding: 2, radius: 2, tone: "inherit", children: PreviewComponent } ); } const Container = styled(Box)` * { color: ${(props) => props.theme.sanity.color.base.fg}; } a { text-decoration: none; } h2 { font-size: ${(props) => props.theme.sanity.fonts.text.sizes[1]}; } `, VideoReferences = (props) => { const schema = useSchema(); if (!props.isLoaded) return /* @__PURE__ */ jsx(SpinnerBox, {}); if (!props.references?.length) return /* @__PURE__ */ jsx(Card, { border: !0, radius: 3, padding: 3, children: /* @__PURE__ */ jsx(Text, { size: 2, children: "No documents are using this video" }) }); const documentPairs = collate(props.references || []); return /* @__PURE__ */ jsx(Container, { children: documentPairs?.map((documentPair) => { const schemaType = schema.get(documentPair.type); return /* @__PURE__ */ jsx( Card, { marginBottom: 2, padding: 2, radius: 2, shadow: 1, style: { overflow: "hidden" }, children: /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(DocumentPreview, { documentPair, schemaType }) }) }, documentPair.id ); }) }); }; function DeleteDialog({ asset, references, referencesLoading, cancelDelete, succeededDeleting }) { const client = useClient(), [state, setState] = useState("checkingReferences"), [deleteOnMux, setDeleteOnMux] = useState(!0), toast = useToast(); useEffect(() => { state !== "checkingReferences" || referencesLoading || setState(references?.length ? "cantDelete" : "confirm"); }, [state, references, referencesLoading]); async function confirmDelete() { if (state !== "confirm") return; setState("processing_deletion"); const worked = await deleteAsset({ client, asset, deleteOnMux }); worked === !0 ? (toast.push({ title: "Successfully deleted video", status: "success" }), succeededDeleting()) : worked === "failed-mux" ? (toast.push({ title: "Deleted video in Sanity", description: "But it wasn't deleted in Mux", status: "warning" }), succeededDeleting()) : (toast.push({ title: "Failed deleting video", status: "error" }), setState("error_deleting")); } return /* @__PURE__ */ jsx( Dialog, { animate: !0, header: "Delete video", zOffset: DIALOGS_Z_INDEX, id: "deleting-video-details-dialog", onClose: cancelDelete, onClickOutside: cancelDelete, width: 1, position: "fixed", children: /* @__PURE__ */ jsx( Card, { padding: 3, style: { minHeight: "150px", display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsxs(Stack, { space: 3, children: [ state === "checkingReferences" && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Heading, { size: 2, children: "Checking if video can be deleted" }), /* @__PURE__ */ jsx(SpinnerBox, {}) ] }), state === "cantDelete" && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Heading, { size: 2, children: "Video can't be deleted" }), /* @__PURE__ */ jsxs(Text, { size: 2, style: { marginBottom: "2rem" }, children: [ "There are ", references?.length, " document", references && references.length > 0 && "s", " ", "pointing to this video. Remove their references to this file or delete them before proceeding." ] }), /* @__PURE__ */ jsx(VideoReferences, { references, isLoaded: !referencesLoading }) ] }), state === "confirm" && /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx(Heading, { size: 2, children: "Are you sure you want to delete this video?" }), /* @__PURE__ */ jsx(Text, { size: 2, children: "This action is irreversible" }), /* @__PURE__ */ jsxs(Stack, { space: 4, marginY: 4, children: [ /* @__PURE__ */ jsxs(Flex, { align: "center", as: "label", children: [ /* @__PURE__ */ jsx( Checkbox, { checked: deleteOnMux, onChange: () => setDeleteOnMux((prev) => !prev) } ), /* @__PURE__ */ jsx(Text, { style: { margin: "0 10px" }, children: "Delete asset on Mux" }) ] }), /* @__PURE__ */ jsxs(Flex, { align: "center", as: "label", children: [ /* @__PURE__ */ jsx(Checkbox, { disabled: !0, checked: !0 }), /* @__PURE__ */ jsx(Text, { style: { margin: "0 10px" }, children: "Delete video from dataset" }) ] }), /* @__PURE__ */ jsx(Box, { children: /