sanity-plugin-mux-input 2.2.4 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +148 -16
  2. package/lib/index.cjs +3996 -3677
  3. package/lib/index.cjs.map +1 -1
  4. package/lib/index.d.cts +210 -0
  5. package/lib/index.d.ts +109 -25
  6. package/lib/index.esm.js +4390 -0
  7. package/lib/index.esm.js.map +1 -0
  8. package/lib/index.js +3964 -3626
  9. package/lib/index.js.map +1 -1
  10. package/package.json +48 -52
  11. package/src/_exports/index.ts +32 -0
  12. package/src/actions/upload.ts +35 -40
  13. package/src/clients/upChunkObservable.ts +5 -1
  14. package/src/components/ConfigureApi.tsx +0 -1
  15. package/src/components/FileInputArea.tsx +92 -0
  16. package/src/components/FileInputButton.tsx +3 -2
  17. package/src/components/FileInputMenuItem.styled.tsx +2 -2
  18. package/src/components/FileInputMenuItem.tsx +2 -10
  19. package/src/components/ImportVideosFromMux.tsx +317 -0
  20. package/src/components/Input.tsx +3 -3
  21. package/src/components/PlayerActionsMenu.tsx +14 -12
  22. package/src/components/SelectAsset.tsx +1 -1
  23. package/src/components/StudioTool.tsx +11 -6
  24. package/src/components/TextTracksEditor.tsx +214 -0
  25. package/src/components/UploadConfiguration.tsx +390 -0
  26. package/src/components/UploadPlaceholder.tsx +41 -55
  27. package/src/components/Uploader.styled.tsx +0 -1
  28. package/src/components/Uploader.tsx +384 -0
  29. package/src/components/VideoDetails/DeleteDialog.tsx +20 -24
  30. package/src/components/VideoPlayer.tsx +33 -5
  31. package/src/components/VideoThumbnail.tsx +21 -7
  32. package/src/components/VideosBrowser.tsx +6 -3
  33. package/src/components/withFocusRing/withFocusRing.ts +20 -22
  34. package/src/hooks/useClient.ts +1 -1
  35. package/src/hooks/useImportMuxAssets.ts +127 -0
  36. package/src/hooks/useMuxAssets.ts +168 -0
  37. package/src/plugin.tsx +5 -5
  38. package/src/util/asserters.ts +9 -0
  39. package/src/util/createSearchFilter.ts +1 -1
  40. package/src/util/formatBytes.ts +32 -0
  41. package/src/util/generateJwt.ts +1 -0
  42. package/src/util/getAnimatedPosterSrc.ts +1 -1
  43. package/src/util/getPlaybackId.ts +1 -1
  44. package/src/util/getPlaybackPolicy.ts +1 -1
  45. package/src/util/parsers.ts +5 -0
  46. package/src/util/types.ts +195 -12
  47. package/lib/index.cjs.js +0 -5
  48. package/src/components/__legacy__Uploader.tsx +0 -280
  49. package/src/index.ts +0 -29
@@ -1,32 +1,30 @@
1
- import {rem, Theme} from '@sanity/ui'
1
+ import {rem} from '@sanity/ui'
2
2
  import {type ComponentType} from 'react'
3
3
  import styled, {css} from 'styled-components'
4
4
 
5
5
  import {focusRingBorderStyle, focusRingStyle} from './helpers'
6
6
 
7
7
  export function withFocusRing<Props>(component: ComponentType<Props>) {
8
- return styled(component as unknown as any)<Props & {$border?: boolean}>(
9
- (props: {theme: Theme; $border?: boolean}) => {
10
- const border = {
11
- width: props.$border ? 1 : 0,
12
- color: 'var(--card-border-color)',
13
- }
8
+ return styled(component as unknown as any)<Props & {$border?: boolean}>((props) => {
9
+ const border = {
10
+ width: props.$border ? 1 : 0,
11
+ color: 'var(--card-border-color)',
12
+ }
14
13
 
15
- return css`
16
- --card-focus-box-shadow: ${focusRingBorderStyle(border)};
14
+ return css`
15
+ --card-focus-box-shadow: ${focusRingBorderStyle(border)};
17
16
 
18
- border-radius: ${rem(props.theme.sanity.radius[1])};
19
- outline: none;
20
- box-shadow: var(--card-focus-box-shadow);
17
+ border-radius: ${rem(props.theme.sanity.radius[1])};
18
+ outline: none;
19
+ box-shadow: var(--card-focus-box-shadow);
21
20
 
22
- &:focus {
23
- --card-focus-box-shadow: ${focusRingStyle({
24
- base: props.theme.sanity.color.base,
25
- border,
26
- focusRing: props.theme.sanity.focusRing,
27
- })};
28
- }
29
- `
30
- }
31
- )
21
+ &:focus {
22
+ --card-focus-box-shadow: ${focusRingStyle({
23
+ base: props.theme.sanity.color.base,
24
+ border,
25
+ focusRing: props.theme.sanity.focusRing,
26
+ })};
27
+ }
28
+ `
29
+ })
32
30
  }
@@ -1,7 +1,7 @@
1
1
  // As it's required to specify the API Version this custom hook ensures it's all using the same version
2
2
  import {useClient as useSanityClient} from 'sanity'
3
3
 
4
- export const SANITY_API_VERSION = '2022-09-14'
4
+ export const SANITY_API_VERSION = '2024-03-05'
5
5
 
6
6
  export function useClient() {
7
7
  return useSanityClient({apiVersion: SANITY_API_VERSION})
@@ -0,0 +1,127 @@
1
+ import {uuid} from '@sanity/uuid'
2
+ import {useMemo, useState} from 'react'
3
+ import {
4
+ createHookFromObservableFactory,
5
+ truncateString,
6
+ useClient,
7
+ useDocumentStore,
8
+ type DocumentStore,
9
+ } from 'sanity'
10
+ import {parseMuxDate} from '../util/parsers'
11
+ import type {MuxAsset, VideoAssetDocument} from '../util/types'
12
+ import {SANITY_API_VERSION} from './useClient'
13
+ import useMuxAssets from './useMuxAssets'
14
+ import {useSecretsDocumentValues} from './useSecretsDocumentValues'
15
+
16
+ type ImportState = 'closed' | 'idle' | 'importing' | 'done' | 'error'
17
+
18
+ export type AssetInSanity = {
19
+ uploadId: string
20
+ assetId: string
21
+ }
22
+
23
+ export default function useImportMuxAssets() {
24
+ const documentStore = useDocumentStore()
25
+ const client = useClient({
26
+ apiVersion: SANITY_API_VERSION,
27
+ })
28
+
29
+ const [assetsInSanity, assetsInSanityLoading] = useAssetsInSanity(documentStore)
30
+
31
+ const secretDocumentValues = useSecretsDocumentValues()
32
+ const hasSecrets = !!secretDocumentValues.value.secrets?.secretKey
33
+
34
+ const [importError, setImportError] = useState<unknown>()
35
+ const [importState, setImportState] = useState<ImportState>('closed')
36
+ const dialogOpen = importState !== 'closed'
37
+
38
+ const muxAssets = useMuxAssets({
39
+ secrets: secretDocumentValues.value.secrets,
40
+ enabled: hasSecrets && dialogOpen,
41
+ })
42
+
43
+ const missingAssets = useMemo(() => {
44
+ return assetsInSanity && muxAssets.data
45
+ ? muxAssets.data.filter((a) => !assetExistsInSanity(a, assetsInSanity))
46
+ : undefined
47
+ }, [assetsInSanity, muxAssets.data])
48
+
49
+ const [selectedAssets, setSelectedAssets] = useState<MuxAsset[]>([])
50
+
51
+ const closeDialog = () => {
52
+ if (importState !== 'importing') setImportState('closed')
53
+ }
54
+ const openDialog = () => {
55
+ if (importState === 'closed') setImportState('idle')
56
+ }
57
+
58
+ async function importAssets() {
59
+ setImportState('importing')
60
+ const documents = selectedAssets.map(muxAssetToSanityDocument)
61
+
62
+ const tx = client.transaction()
63
+ documents.forEach((doc) => tx.create(doc))
64
+
65
+ try {
66
+ await tx.commit({returnDocuments: false})
67
+ setSelectedAssets([])
68
+ setImportState('done')
69
+ } catch (error) {
70
+ setImportState('error')
71
+ setImportError(error)
72
+ }
73
+ }
74
+
75
+ return {
76
+ assetsInSanityLoading,
77
+ closeDialog,
78
+ dialogOpen,
79
+ importState,
80
+ importError,
81
+ hasSecrets,
82
+ importAssets,
83
+ missingAssets,
84
+ muxAssets,
85
+ openDialog,
86
+ selectedAssets,
87
+ setSelectedAssets,
88
+ }
89
+ }
90
+
91
+ function muxAssetToSanityDocument(asset: MuxAsset): VideoAssetDocument {
92
+ return {
93
+ _id: uuid(),
94
+ _type: 'mux.videoAsset',
95
+ _updatedAt: new Date().toISOString(),
96
+ _createdAt: parseMuxDate(asset.created_at).toISOString(),
97
+ assetId: asset.id,
98
+ playbackId: asset.playback_ids.find((p) => p.id)?.id,
99
+ filename: `Asset #${truncateString(asset.id, 15)}`,
100
+ status: asset.status,
101
+ data: asset,
102
+ }
103
+ }
104
+
105
+ const useAssetsInSanity = createHookFromObservableFactory<AssetInSanity[], DocumentStore>(
106
+ (documentStore) => {
107
+ return documentStore.listenQuery(
108
+ /* groq */ `*[_type == "mux.videoAsset"] {
109
+ "uploadId": coalesce(uploadId, data.upload_id),
110
+ "assetId": coalesce(assetId, data.id),
111
+ }`,
112
+ {},
113
+ {
114
+ apiVersion: SANITY_API_VERSION,
115
+ }
116
+ )
117
+ }
118
+ )
119
+
120
+ function assetExistsInSanity(asset: MuxAsset, existingAssets: AssetInSanity[]) {
121
+ // Don't allow importing assets that are not ready
122
+ if (asset.status !== 'ready') return false
123
+
124
+ return existingAssets.some(
125
+ (existing) => existing.assetId === asset.id || existing.uploadId === asset.upload_id
126
+ )
127
+ }
@@ -0,0 +1,168 @@
1
+ import {useEffect, useState} from 'react'
2
+ import {defer, of, timer} from 'rxjs'
3
+ import {concatMap, expand, tap} from 'rxjs/operators'
4
+
5
+ import type {MuxAsset, Secrets} from '../util/types'
6
+
7
+ const FIRST_PAGE = 1
8
+ const ASSETS_PER_PAGE = 100
9
+
10
+ type MuxAssetsState = {
11
+ pageNum: number
12
+ loading: boolean
13
+ data?: MuxAsset[]
14
+ error?: FetchError
15
+ }
16
+
17
+ type FetchError =
18
+ | {
19
+ _tag: 'FetchError'
20
+ }
21
+ | {_tag: 'MuxError'; error: unknown}
22
+
23
+ type PageResult = (
24
+ | {
25
+ data: MuxAsset[]
26
+ }
27
+ | {
28
+ error: FetchError
29
+ }
30
+ ) & {
31
+ pageNum: number
32
+ }
33
+
34
+ /**
35
+ * @docs {@link https://docs.mux.com/api-reference#video/operation/list-assets}
36
+ */
37
+ async function fetchMuxAssetsPage(
38
+ {secretKey, token}: Secrets,
39
+ pageNum: number
40
+ ): Promise<PageResult> {
41
+ try {
42
+ const res = await fetch(
43
+ `https://api.mux.com/video/v1/assets?limit=${ASSETS_PER_PAGE}&page=${pageNum}`,
44
+ {
45
+ headers: {
46
+ Authorization: `Basic ${btoa(`${token}:${secretKey}`)}`,
47
+ },
48
+ }
49
+ )
50
+ const json = await res.json()
51
+
52
+ if (json.error) {
53
+ return {
54
+ pageNum,
55
+ error: {
56
+ _tag: 'MuxError',
57
+ error: json.error,
58
+ },
59
+ }
60
+ }
61
+
62
+ return {
63
+ pageNum,
64
+ data: json.data as MuxAsset[],
65
+ }
66
+ } catch (error) {
67
+ return {
68
+ pageNum,
69
+ error: {_tag: 'FetchError'},
70
+ }
71
+ }
72
+ }
73
+
74
+ function accumulateIntermediateState(
75
+ currentState: MuxAssetsState,
76
+ pageResult: PageResult
77
+ ): MuxAssetsState {
78
+ const currentData = ('data' in currentState && currentState.data) || []
79
+ return {
80
+ ...currentState,
81
+ data: [
82
+ ...currentData,
83
+ ...(('data' in pageResult && pageResult.data) || []).filter(
84
+ // De-duplicate assets for safety
85
+ (asset) => !currentData.some((a) => a.id === asset.id)
86
+ ),
87
+ ],
88
+ error:
89
+ 'error' in pageResult
90
+ ? pageResult.error
91
+ : // Reset error if current page is successful
92
+ undefined,
93
+ pageNum: pageResult.pageNum,
94
+ loading: true,
95
+ }
96
+ }
97
+
98
+ function hasMorePages(pageResult: PageResult) {
99
+ return (
100
+ typeof pageResult === 'object' &&
101
+ 'data' in pageResult &&
102
+ Array.isArray(pageResult.data) &&
103
+ pageResult.data.length > 0
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Fetches all assets from a Mux environment. Rules:
109
+ * - One page at a time
110
+ * - Mux has no information on pagination
111
+ * - We've finished fetching if a page returns `data.length === 0`
112
+ * - Rate limiting to one request per 2 seconds
113
+ * - Update state while still fetching to give feedback to users
114
+ */
115
+ export default function useMuxAssets({secrets, enabled}: {enabled: boolean; secrets: Secrets}) {
116
+ const [state, setState] = useState<MuxAssetsState>({loading: true, pageNum: FIRST_PAGE})
117
+
118
+ useEffect(() => {
119
+ if (!enabled) return
120
+
121
+ const subscription = defer(() =>
122
+ fetchMuxAssetsPage(
123
+ secrets,
124
+ // When we've already successfully loaded before (fully or partially), we start from the following page to avoid re-fetching
125
+ 'data' in state && state.data && state.data.length > 0 && !state.error
126
+ ? state.pageNum + 1
127
+ : state.pageNum
128
+ )
129
+ )
130
+ .pipe(
131
+ // Here we replace "concatMap" with "expand" to recursively fetch next pages
132
+ expand((pageResult) => {
133
+ // if fetched page has data, we continue emitting, requesting the next page
134
+ // after 2s to avoid rate limiting
135
+ if (hasMorePages(pageResult)) {
136
+ return timer(2000).pipe(
137
+ // eslint-disable-next-line max-nested-callbacks
138
+ concatMap(() => defer(() => fetchMuxAssetsPage(secrets, pageResult.pageNum + 1)))
139
+ )
140
+ }
141
+
142
+ // Else, we stop emitting
143
+ return of()
144
+ }),
145
+
146
+ // On each iteration, persist intermediate states to give feedback to users
147
+ tap((pageResult) =>
148
+ setState((prevState) => accumulateIntermediateState(prevState, pageResult))
149
+ )
150
+ )
151
+ .subscribe({
152
+ // Once done, let the user know we've stopped loading
153
+ complete: () => {
154
+ setState((prev) => ({
155
+ ...prev,
156
+ loading: false,
157
+ }))
158
+ },
159
+ })
160
+
161
+ // Unsubscribe on component unmount to prevent memory leaks or fetching unnecessarily
162
+ // eslint-disable-next-line consistent-return
163
+ return () => subscription.unsubscribe()
164
+ // eslint-disable-next-line react-hooks/exhaustive-deps
165
+ }, [enabled])
166
+
167
+ return state
168
+ }
package/src/plugin.tsx CHANGED
@@ -1,13 +1,13 @@
1
- import React from 'react'
2
-
3
1
  import Input from './components/Input'
4
2
  import VideoThumbnail from './components/VideoThumbnail'
5
- import type {Config, MuxInputProps, VideoAssetDocument} from './util/types'
3
+ import type {MuxInputProps, PluginConfig, VideoAssetDocument} from './util/types'
6
4
 
7
- export function muxVideoCustomRendering(config: Config) {
5
+ export function muxVideoCustomRendering(config: PluginConfig) {
8
6
  return {
9
7
  components: {
10
- input: (props: MuxInputProps) => <Input config={config} {...props} />,
8
+ input: (props: MuxInputProps) => (
9
+ <Input config={{...config, ...props.schemaType.options}} {...props} />
10
+ ),
11
11
  },
12
12
  preview: {
13
13
  select: {
@@ -11,3 +11,12 @@ export function isMuxInputPreviewProps(
11
11
  ): props is MuxInputPreviewProps {
12
12
  return props.schemaType?.type?.name === 'mux.video'
13
13
  }
14
+
15
+ export function isValidUrl(url: string): boolean {
16
+ try {
17
+ const parsed = new URL(url)
18
+ return parsed && !!parsed.protocol.match(/http:|https:/)
19
+ } catch {
20
+ return false
21
+ }
22
+ }
@@ -55,7 +55,7 @@ function extractTermsFromQuery(query: string): string[] {
55
55
  * Create GROQ constraints, given search terms and the full spec of available document types and fields.
56
56
  * Essentially a large list of all possible fields (joined by logical OR) to match our search terms against.
57
57
  */
58
- function createConstraints(terms: string[], includeAssetId: Boolean) {
58
+ function createConstraints(terms: string[], includeAssetId: boolean) {
59
59
  const searchPaths = includeAssetId ? ['filename', 'assetId'] : ['filename']
60
60
  const constraints = terms
61
61
  .map((_term, i) => searchPaths.map((joinedPath) => `${joinedPath} match $t${i}`))
@@ -0,0 +1,32 @@
1
+ /* eslint-disable */
2
+ // From: https://stackoverflow.com/questions/10420352/converting-file-size-in-bytes-to-human-readable-string
3
+ /**
4
+ * Format bytes as human-readable text.
5
+ *
6
+ * @param bytes Number of bytes.
7
+ * @param si True to use metric (SI) units, aka powers of 1000. False to use
8
+ * binary (IEC), aka powers of 1024.
9
+ * @param dp Number of decimal places to display.
10
+ *
11
+ * @return Formatted string.
12
+ */
13
+ export default function formatBytes(bytes: number, si = false, dp = 1) {
14
+ const thresh = si ? 1000 : 1024
15
+
16
+ if (Math.abs(bytes) < thresh) {
17
+ return bytes + ' B'
18
+ }
19
+
20
+ const units = si
21
+ ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
22
+ : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
23
+ let u = -1
24
+ const r = 10 ** dp
25
+
26
+ do {
27
+ bytes /= thresh
28
+ ++u
29
+ } while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1)
30
+
31
+ return bytes.toFixed(dp) + ' ' + units[u]
32
+ }
@@ -30,6 +30,7 @@ export function generateJwt<T extends Audience>(
30
30
  throw new TypeError('Missing signingKeyPrivate')
31
31
  }
32
32
 
33
+ // @ts-expect-error - handle missing typings for this package
33
34
  const {default: sign} = suspend(() => import('jsonwebtoken-esm/sign'), ['jsonwebtoken-esm/sign'])
34
35
 
35
36
  return sign(
@@ -6,7 +6,7 @@ import {getPlaybackPolicy} from './getPlaybackPolicy'
6
6
  import type {AnimatedThumbnailOptions, MuxAnimatedThumbnailUrl, VideoAssetDocument} from './types'
7
7
 
8
8
  export interface AnimatedPosterSrcOptions extends AnimatedThumbnailOptions {
9
- asset: Partial<VideoAssetDocument>
9
+ asset: Pick<VideoAssetDocument, 'playbackId' | 'data' | 'thumbTime'>
10
10
  client: SanityClient
11
11
  }
12
12
 
@@ -1,6 +1,6 @@
1
1
  import type {VideoAssetDocument} from './types'
2
2
 
3
- export function getPlaybackId(asset: Partial<VideoAssetDocument>): string {
3
+ export function getPlaybackId(asset: Pick<VideoAssetDocument, 'playbackId'>): string {
4
4
  if (!asset?.playbackId) {
5
5
  console.error('Asset is missing a playbackId', {asset})
6
6
  throw new TypeError(`Missing playbackId`)
@@ -1,5 +1,5 @@
1
1
  import type {PlaybackPolicy, VideoAssetDocument} from './types'
2
2
 
3
- export function getPlaybackPolicy(asset: Partial<VideoAssetDocument>): PlaybackPolicy {
3
+ export function getPlaybackPolicy(asset: Pick<VideoAssetDocument, 'data'>): PlaybackPolicy {
4
4
  return asset.data?.playback_ids?.[0]?.policy ?? 'public'
5
5
  }
@@ -0,0 +1,5 @@
1
+ import type {MuxAsset} from './types'
2
+
3
+ export function parseMuxDate(date: MuxAsset['created_at']): Date {
4
+ return new Date(Number(date) * 1000)
5
+ }