sanity-plugin-mux-input 2.9.1 → 2.10.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sanity-plugin-mux-input",
3
- "version": "2.9.1",
3
+ "version": "2.10.0",
4
4
  "description": "An input component that integrates Sanity Studio with Mux video encoding/hosting service.",
5
5
  "keywords": [
6
6
  "sanity",
@@ -47,3 +47,25 @@ export function getAsset(client: SanityClient, assetId: string) {
47
47
  method: 'GET',
48
48
  })
49
49
  }
50
+
51
+ export function listAssets(
52
+ client: SanityClient,
53
+ options: {limit?: number; cursor?: string | null}
54
+ ) {
55
+ const {dataset} = client.config()
56
+ const query: {limit?: string; cursor?: string} = {}
57
+
58
+ if (options.limit) {
59
+ query.limit = options.limit.toString()
60
+ }
61
+ if (options.cursor) {
62
+ query.cursor = options.cursor
63
+ }
64
+
65
+ return client.request<{data: MuxAsset[]; next_cursor?: string | null}>({
66
+ url: `/addons/mux/assets/${dataset}/data/list`,
67
+ withCredentials: true,
68
+ method: 'GET',
69
+ query,
70
+ })
71
+ }
@@ -11,25 +11,31 @@ import {
11
11
  Text,
12
12
  TextInput,
13
13
  } from '@sanity/ui'
14
- import React, {memo, useCallback, useEffect, useId, useMemo, useRef} from 'react'
14
+ import {useCallback, useEffect, useId, useMemo, useRef} from 'react'
15
15
  import {clear, preload} from 'suspend-react'
16
16
 
17
17
  import {useClient} from '../hooks/useClient'
18
18
  import type {SetDialogState} from '../hooks/useDialogState'
19
+ import {useDialogState} from '../hooks/useDialogState'
19
20
  import {useSaveSecrets} from '../hooks/useSaveSecrets'
21
+ import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
20
22
  import {useSecretsFormState} from '../hooks/useSecretsFormState'
21
- import {cacheNs} from '../util/constants'
23
+ import {cacheNs, DIALOGS_Z_INDEX} from '../util/constants'
22
24
  import {_id as secretsId} from '../util/readSecrets'
23
25
  import type {Secrets} from '../util/types'
24
26
  import {Header} from './ConfigureApi.styled'
25
27
  import FormField from './FormField'
26
28
 
27
- export interface Props {
29
+ // Props for the dialog component when used with external state management
30
+ export interface ConfigureApiDialogProps {
28
31
  setDialogState: SetDialogState
29
32
  secrets: Secrets
30
33
  }
34
+
31
35
  const fieldNames = ['token', 'secretKey', 'enableSignedUrls'] as const
32
- function ConfigureApi({secrets, setDialogState}: Props) {
36
+
37
+ // Internal dialog component that can be used with external state
38
+ export function ConfigureApiDialog({secrets, setDialogState}: ConfigureApiDialogProps) {
33
39
  const client = useClient()
34
40
  const [state, dispatch] = useSecretsFormState(secrets)
35
41
  const hasSecretsInitially = useMemo(() => secrets.token && secrets.secretKey, [secrets])
@@ -112,13 +118,13 @@ function ConfigureApi({secrets, setDialogState}: Props) {
112
118
  animate
113
119
  id={id}
114
120
  onClose={handleClose}
121
+ onClickOutside={handleClose}
115
122
  header={<Header />}
123
+ zOffset={DIALOGS_Z_INDEX}
124
+ position="fixed"
116
125
  width={1}
117
- style={{
118
- maxWidth: '550px',
119
- }}
120
126
  >
121
- <Box padding={4} style={{position: 'relative'}}>
127
+ <Box padding={3}>
122
128
  <form onSubmit={handleSubmit} noValidate>
123
129
  <Stack space={4}>
124
130
  {!hasSecretsInitially && (
@@ -224,4 +230,21 @@ function ConfigureApi({secrets, setDialogState}: Props) {
224
230
  )
225
231
  }
226
232
 
227
- export default memo(ConfigureApi)
233
+ // Wrapper component that manages its own dialog state (used in VideosBrowser)
234
+ export default function ConfigureApi() {
235
+ const [dialogOpen, setDialogOpen] = useDialogState()
236
+ const secretDocumentValues = useSecretsDocumentValues()
237
+
238
+ const openDialog = useCallback(() => setDialogOpen('secrets'), [setDialogOpen])
239
+
240
+ if (dialogOpen === 'secrets') {
241
+ return (
242
+ <ConfigureApiDialog
243
+ secrets={secretDocumentValues.value.secrets}
244
+ setDialogState={setDialogOpen}
245
+ />
246
+ )
247
+ }
248
+
249
+ return <Button mode="bleed" text="Configure plugin" onClick={openDialog} />
250
+ }
@@ -1,4 +1,10 @@
1
- import {CheckmarkCircleIcon, ErrorOutlineIcon, RetrieveIcon, RetryIcon} from '@sanity/icons'
1
+ import {
2
+ CheckmarkCircleIcon,
3
+ ErrorOutlineIcon,
4
+ InfoOutlineIcon,
5
+ RetrieveIcon,
6
+ RetryIcon,
7
+ } from '@sanity/icons'
2
8
  import {
3
9
  Box,
4
10
  Button,
@@ -120,7 +126,7 @@ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
120
126
  <Button
121
127
  fontSize={2}
122
128
  padding={3}
123
- mode="bleed"
129
+ mode="ghost"
124
130
  text="Cancel"
125
131
  tone="critical"
126
132
  onClick={props.closeDialog}
@@ -149,6 +155,24 @@ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
149
155
  }
150
156
  >
151
157
  <Box padding={3}>
158
+ {/* WARNING: SKIPPED ASSETS WITHOUT PLAYBACK */}
159
+ {props.muxAssets.hasSkippedAssetsWithoutPlayback && (
160
+ <Card tone="caution" marginBottom={5} padding={3} border>
161
+ <Flex align="center" gap={2}>
162
+ <InfoOutlineIcon fontSize={36} />
163
+ <Stack space={2}>
164
+ <Text size={2} weight="semibold">
165
+ Some videos were skipped
166
+ </Text>
167
+ <Text size={1}>
168
+ Videos without playback IDs cannot be imported and have been excluded from the
169
+ list.
170
+ </Text>
171
+ </Stack>
172
+ </Flex>
173
+ </Card>
174
+ )}
175
+
152
176
  {/* LOADING ASSETS STATE */}
153
177
  {(props.muxAssets.loading || props.assetsInSanityLoading) && (
154
178
  <Card tone="primary" marginBottom={5} padding={3} border>
@@ -1,18 +1,18 @@
1
1
  import {Card} from '@sanity/ui'
2
2
  import {memo, Suspense} from 'react'
3
3
 
4
+ import {useAccessControl} from '../hooks/useAccessControl'
4
5
  import {useAssetDocumentValues} from '../hooks/useAssetDocumentValues'
5
6
  import {useClient} from '../hooks/useClient'
6
7
  import {useDialogState} from '../hooks/useDialogState'
7
8
  import {useMuxPolling} from '../hooks/useMuxPolling'
8
9
  import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
9
10
  import type {MuxInputProps, PluginConfig} from '../util/types'
10
- import ConfigureApi from './ConfigureApi'
11
+ import {ConfigureApiDialog} from './ConfigureApi'
11
12
  import ErrorBoundaryCard from './ErrorBoundaryCard'
12
13
  import {InputFallback} from './Input.styled'
13
14
  import Onboard from './Onboard'
14
15
  import Uploader from './Uploader'
15
- import {useAccessControl} from '../hooks/useAccessControl'
16
16
 
17
17
  export interface InputProps extends MuxInputProps {
18
18
  config: PluginConfig
@@ -62,7 +62,7 @@ const Input = (props: InputProps) => {
62
62
  )}
63
63
 
64
64
  {dialogState === 'secrets' && hasConfigAccess && (
65
- <ConfigureApi
65
+ <ConfigureApiDialog
66
66
  setDialogState={setDialogState}
67
67
  secrets={secretDocumentValues.value.secrets}
68
68
  />
@@ -0,0 +1,201 @@
1
+ import {CheckmarkCircleIcon, ErrorOutlineIcon, SyncIcon} from '@sanity/icons'
2
+ import {Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text} from '@sanity/ui'
3
+
4
+ import useResyncMuxMetadata from '../hooks/useResyncMuxMetadata'
5
+ import {isEmptyOrPlaceholderTitle} from '../util/assetTitlePlaceholder'
6
+ import {DIALOGS_Z_INDEX} from '../util/constants'
7
+
8
+ // eslint-disable-next-line complexity
9
+ function ResyncMetadataDialog(props: ReturnType<typeof useResyncMuxMetadata>) {
10
+ const {resyncState} = props
11
+
12
+ const canTriggerResync = resyncState === 'idle' || resyncState === 'error'
13
+ const isResyncing = resyncState === 'syncing'
14
+ const isDone = resyncState === 'done'
15
+
16
+ const videosToUpdate = props.matchedAssets?.filter((m) => m.muxAsset).length || 0
17
+ const videosWithEmptyOrPlaceholder =
18
+ props.matchedAssets?.filter(
19
+ (m) => m.muxAsset && m.muxTitle && isEmptyOrPlaceholderTitle(m.currentTitle, m.muxAsset.id)
20
+ ).length || 0
21
+
22
+ return (
23
+ <Dialog
24
+ animate
25
+ header={'Resync Metadata from Mux'}
26
+ zOffset={DIALOGS_Z_INDEX}
27
+ id="resync-metadata-dialog"
28
+ onClose={props.closeDialog}
29
+ onClickOutside={props.closeDialog}
30
+ width={1}
31
+ position="fixed"
32
+ footer={
33
+ !isDone && (
34
+ <Card padding={3}>
35
+ <Flex justify="space-between" align="center">
36
+ <Button
37
+ fontSize={2}
38
+ padding={3}
39
+ mode="ghost"
40
+ text="Cancel"
41
+ tone="critical"
42
+ onClick={props.closeDialog}
43
+ disabled={isResyncing}
44
+ />
45
+ <Flex gap={2}>
46
+ {videosWithEmptyOrPlaceholder > 0 && (
47
+ <Button
48
+ fontSize={2}
49
+ padding={3}
50
+ mode="ghost"
51
+ text={`Update empty (${videosWithEmptyOrPlaceholder})`}
52
+ tone="caution"
53
+ onClick={props.syncOnlyEmpty}
54
+ disabled={isResyncing || !canTriggerResync}
55
+ />
56
+ )}
57
+ <Button
58
+ icon={SyncIcon}
59
+ fontSize={2}
60
+ padding={3}
61
+ mode="ghost"
62
+ text={`Update all (${videosToUpdate})`}
63
+ tone="positive"
64
+ onClick={props.syncAllVideos}
65
+ iconRight={isResyncing && Spinner}
66
+ disabled={!canTriggerResync}
67
+ />
68
+ </Flex>
69
+ </Flex>
70
+ </Card>
71
+ )
72
+ }
73
+ >
74
+ <Box padding={4}>
75
+ {/* LOADING ASSETS STATE */}
76
+ {(props.muxAssets.loading || props.sanityAssetsLoading) && (
77
+ <Card tone="primary" marginBottom={5} padding={3} border>
78
+ <Flex align="center" gap={4}>
79
+ <Spinner muted size={4} />
80
+ <Stack space={2}>
81
+ <Text size={2} weight="semibold">
82
+ Loading assets from Mux
83
+ </Text>
84
+ <Text size={1}>This may take a while.</Text>
85
+ </Stack>
86
+ </Flex>
87
+ </Card>
88
+ )}
89
+
90
+ {/* ERROR LOADING MUX */}
91
+ {props.muxAssets.error && (
92
+ <Card tone="critical" marginBottom={5} padding={3} border>
93
+ <Flex align="center" gap={2}>
94
+ <ErrorOutlineIcon fontSize={36} />
95
+ <Stack space={2}>
96
+ <Text size={2} weight="semibold">
97
+ There was an error getting data from Mux
98
+ </Text>
99
+ <Text size={1}>Please try again or contact a developer for help.</Text>
100
+ </Stack>
101
+ </Flex>
102
+ </Card>
103
+ )}
104
+
105
+ {/* SYNCING STATE */}
106
+ {resyncState === 'syncing' && (
107
+ <Card tone="primary" marginBottom={5} padding={3} border>
108
+ <Flex align="center" gap={4}>
109
+ <Spinner muted size={4} />
110
+ <Stack space={2}>
111
+ <Text size={2} weight="semibold">
112
+ Updating video metadata
113
+ </Text>
114
+ <Text size={1}>Syncing titles from Mux...</Text>
115
+ </Stack>
116
+ </Flex>
117
+ </Card>
118
+ )}
119
+
120
+ {/* ERROR SYNCING */}
121
+ {resyncState === 'error' && (
122
+ <Card tone="critical" marginBottom={5} padding={3} border>
123
+ <Flex align="center" gap={2}>
124
+ <ErrorOutlineIcon fontSize={36} />
125
+ <Stack space={2}>
126
+ <Text size={2} weight="semibold">
127
+ There was an error syncing metadata
128
+ </Text>
129
+ <Text size={1}>
130
+ {props.resyncError
131
+ ? `Error: ${props.resyncError}`
132
+ : 'Please try again or contact a developer for help.'}
133
+ </Text>
134
+ </Stack>
135
+ </Flex>
136
+ </Card>
137
+ )}
138
+
139
+ {/* SUCCESS STATE */}
140
+ {resyncState === 'done' && (
141
+ <Stack paddingY={5} marginBottom={4} space={3} style={{textAlign: 'center'}}>
142
+ <Box>
143
+ <CheckmarkCircleIcon fontSize={48} />
144
+ </Box>
145
+ <Heading size={2}>Metadata synced successfully</Heading>
146
+ <Text size={2}>All video titles have been updated from Mux.</Text>
147
+ </Stack>
148
+ )}
149
+
150
+ {/* CONFIRMATION MESSAGE */}
151
+ {resyncState === 'idle' && !props.muxAssets.loading && !props.sanityAssetsLoading && (
152
+ <Stack space={4}>
153
+ <Heading size={1}>
154
+ There {videosToUpdate === 1 ? 'is' : 'are'} {videosToUpdate} video
155
+ {videosToUpdate === 1 ? '' : 's'} with Mux metadata
156
+ </Heading>
157
+ <Text size={2}>
158
+ This will update video titles in Sanity to match those in Mux. No new videos will be
159
+ created.
160
+ </Text>
161
+ {videosWithEmptyOrPlaceholder > 0 && (
162
+ <Card padding={3} tone="caution" border>
163
+ <Flex align="flex-start" gap={2}>
164
+ <Box>
165
+ <ErrorOutlineIcon />
166
+ </Box>
167
+ <Stack space={2}>
168
+ <Text size={2} weight="semibold">
169
+ Videos with empty or placeholder titles
170
+ </Text>
171
+ <Text size={1} muted>
172
+ {videosWithEmptyOrPlaceholder} video
173
+ {videosWithEmptyOrPlaceholder === 1 ? '' : 's'} without titles or with
174
+ placeholder titles (e.g., &quot;Asset #123&quot;) can be updated selectively.
175
+ </Text>
176
+ </Stack>
177
+ </Flex>
178
+ </Card>
179
+ )}
180
+ </Stack>
181
+ )}
182
+ </Box>
183
+ </Dialog>
184
+ )
185
+ }
186
+
187
+ export default function ResyncMetadata() {
188
+ const resyncMetadata = useResyncMuxMetadata()
189
+
190
+ if (!resyncMetadata.hasSecrets) {
191
+ return
192
+ }
193
+
194
+ if (resyncMetadata.dialogOpen) {
195
+ // eslint-disable-next-line consistent-return
196
+ return <ResyncMetadataDialog {...resyncMetadata} />
197
+ }
198
+
199
+ // eslint-disable-next-line consistent-return
200
+ return <Button mode="bleed" text="Resync Metadata" onClick={resyncMetadata.openDialog} />
201
+ }
@@ -1,10 +1,12 @@
1
1
  import {SearchIcon} from '@sanity/icons'
2
- import {Card, Flex, Grid, Label, Stack, Text, TextInput} from '@sanity/ui'
2
+ import {Card, Flex, Grid, Inline, Label, Stack, Text, TextInput} from '@sanity/ui'
3
3
  import {useMemo, useState} from 'react'
4
4
 
5
5
  import useAssets from '../hooks/useAssets'
6
6
  import type {VideoAssetDocument} from '../util/types'
7
+ import ConfigureApi from './ConfigureApi'
7
8
  import ImportVideosFromMux from './ImportVideosFromMux'
9
+ import ResyncMetadata from './ResyncMetadata'
8
10
  import {SelectSortOptions} from './SelectSortOptions'
9
11
  import SpinnerBox from './SpinnerBox'
10
12
  import type {VideoDetailsProps} from './VideoDetails/useVideoDetails'
@@ -39,7 +41,13 @@ export default function VideosBrowser({onSelect}: VideosBrowserProps) {
39
41
  />
40
42
  <SelectSortOptions setSort={setSort} sort={sort} />
41
43
  </Flex>
42
- {placement === 'tool' && <ImportVideosFromMux />}
44
+ {placement === 'tool' && (
45
+ <Inline space={2}>
46
+ <ImportVideosFromMux />
47
+ <ResyncMetadata />
48
+ <ConfigureApi />
49
+ </Inline>
50
+ )}
43
51
  </Flex>
44
52
  <Stack space={3}>
45
53
  {assets?.length > 0 && (
@@ -3,11 +3,11 @@ import {useMemo, useState} from 'react'
3
3
  import {
4
4
  createHookFromObservableFactory,
5
5
  type DocumentStore,
6
- truncateString,
7
6
  useClient,
8
7
  useDocumentStore,
9
8
  } from 'sanity'
10
9
 
10
+ import {generateAssetPlaceholder} from '../util/assetTitlePlaceholder'
11
11
  import {parseMuxDate} from '../util/parsers'
12
12
  import type {MuxAsset, VideoAssetDocument} from '../util/types'
13
13
  import {SANITY_API_VERSION} from './useClient'
@@ -37,7 +37,7 @@ export default function useImportMuxAssets() {
37
37
  const dialogOpen = importState !== 'closed'
38
38
 
39
39
  const muxAssets = useMuxAssets({
40
- secrets: secretDocumentValues.value.secrets,
40
+ client,
41
41
  enabled: hasSecrets && dialogOpen,
42
42
  })
43
43
 
@@ -101,7 +101,7 @@ function muxAssetToSanityDocument(asset: MuxAsset): VideoAssetDocument | undefin
101
101
  _createdAt: parseMuxDate(asset.created_at).toISOString(),
102
102
  assetId: asset.id,
103
103
  playbackId,
104
- filename: asset.meta?.title ?? `Asset #${truncateString(asset.id, 15)}`,
104
+ filename: asset.meta?.title ?? generateAssetPlaceholder(asset.id),
105
105
  status: asset.status,
106
106
  data: asset,
107
107
  }
@@ -1,17 +1,19 @@
1
1
  import {useEffect, useState} from 'react'
2
2
  import {defer, of, timer} from 'rxjs'
3
3
  import {concatMap, expand, tap} from 'rxjs/operators'
4
+ import type {SanityClient} from 'sanity'
4
5
 
5
- import type {MuxAsset, Secrets} from '../util/types'
6
+ import {listAssets} from '../actions/assets'
7
+ import type {MuxAsset} from '../util/types'
6
8
 
7
- const FIRST_PAGE = 1
8
9
  const ASSETS_PER_PAGE = 100
9
10
 
10
11
  type MuxAssetsState = {
11
- pageNum: number
12
+ cursor: string | null
12
13
  loading: boolean
13
14
  data?: MuxAsset[]
14
15
  error?: FetchError
16
+ hasSkippedAssetsWithoutPlayback?: boolean
15
17
  }
16
18
 
17
19
  type FetchError =
@@ -23,49 +25,36 @@ type FetchError =
23
25
  type PageResult = (
24
26
  | {
25
27
  data: MuxAsset[]
28
+ next_cursor: string | null
26
29
  }
27
30
  | {
28
31
  error: FetchError
29
32
  }
30
33
  ) & {
31
- pageNum: number
34
+ cursor: string | null
32
35
  }
33
36
 
34
37
  /**
35
38
  * @docs {@link https://docs.mux.com/api-reference#video/operation/list-assets}
36
39
  */
37
40
  async function fetchMuxAssetsPage(
38
- {secretKey, token}: Secrets,
39
- pageNum: number
41
+ client: SanityClient,
42
+ cursor: string | null
40
43
  ): Promise<PageResult> {
41
44
  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
- }
45
+ const response = await listAssets(client, {
46
+ limit: ASSETS_PER_PAGE,
47
+ cursor,
48
+ })
61
49
 
62
50
  return {
63
- pageNum,
64
- data: json.data as MuxAsset[],
51
+ cursor,
52
+ data: response.data as MuxAsset[],
53
+ next_cursor: response.next_cursor || null,
65
54
  }
66
55
  } catch (error) {
67
56
  return {
68
- pageNum,
57
+ cursor,
69
58
  error: {_tag: 'FetchError'},
70
59
  }
71
60
  }
@@ -76,66 +65,88 @@ function accumulateIntermediateState(
76
65
  pageResult: PageResult
77
66
  ): MuxAssetsState {
78
67
  const currentData = ('data' in currentState && currentState.data) || []
68
+ const newAssets = ('data' in pageResult && pageResult.data) || []
69
+
70
+ // Filter assets and check for skipped items
71
+ const {validAssets, skippedInThisPage} = newAssets.reduce<{
72
+ validAssets: MuxAsset[]
73
+ skippedInThisPage: boolean
74
+ }>(
75
+ (acc, asset) => {
76
+ const hasPlaybackIds = asset.playback_ids && asset.playback_ids.length > 0
77
+ const isDuplicate = currentData.some((a) => a.id === asset.id)
78
+
79
+ if (!hasPlaybackIds) {
80
+ acc.skippedInThisPage = true
81
+ }
82
+
83
+ if (hasPlaybackIds && !isDuplicate) {
84
+ acc.validAssets.push(asset)
85
+ }
86
+
87
+ return acc
88
+ },
89
+ {validAssets: [], skippedInThisPage: false}
90
+ )
91
+
79
92
  return {
80
93
  ...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
- ],
94
+ data: [...currentData, ...validAssets],
88
95
  error:
89
96
  'error' in pageResult
90
97
  ? pageResult.error
91
98
  : // Reset error if current page is successful
92
99
  undefined,
93
- pageNum: pageResult.pageNum,
100
+ cursor: 'next_cursor' in pageResult ? pageResult.next_cursor : pageResult.cursor,
94
101
  loading: true,
102
+ hasSkippedAssetsWithoutPlayback:
103
+ currentState.hasSkippedAssetsWithoutPlayback || skippedInThisPage,
95
104
  }
96
105
  }
97
106
 
98
107
  function hasMorePages(pageResult: PageResult) {
99
108
  return (
100
- typeof pageResult === 'object' &&
101
- 'data' in pageResult &&
102
- Array.isArray(pageResult.data) &&
103
- pageResult.data.length > 0
109
+ typeof pageResult === 'object' && 'next_cursor' in pageResult && pageResult.next_cursor !== null
104
110
  )
105
111
  }
106
112
 
107
113
  /**
108
114
  * Fetches all assets from a Mux environment. Rules:
109
115
  * - One page at a time
110
- * - Mux has no information on pagination
111
- * - We've finished fetching if a page returns `data.length === 0`
116
+ * - Uses cursor-based pagination
117
+ * - We've finished fetching when `next_cursor` is null
112
118
  * - Rate limiting to one request per 2 seconds
113
119
  * - Update state while still fetching to give feedback to users
114
120
  */
115
- export default function useMuxAssets({secrets, enabled}: {enabled: boolean; secrets: Secrets}) {
116
- const [state, setState] = useState<MuxAssetsState>({loading: true, pageNum: FIRST_PAGE})
121
+ export default function useMuxAssets({client, enabled}: {client: SanityClient; enabled: boolean}) {
122
+ const [state, setState] = useState<MuxAssetsState>({loading: true, cursor: null})
117
123
 
118
124
  useEffect(() => {
119
125
  if (!enabled) return
120
126
 
121
127
  const subscription = defer(() =>
122
128
  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
129
+ client,
130
+ // When we've already successfully loaded before (fully or partially), we start from the next cursor to avoid re-fetching
131
+ 'data' in state && state.data && state.data.length > 0 && !state.error ? state.cursor : null
128
132
  )
129
133
  )
130
134
  .pipe(
131
- // Here we replace "concatMap" with "expand" to recursively fetch next pages
135
+ // Here we use "expand" to recursively fetch next pages
132
136
  expand((pageResult) => {
133
- // if fetched page has data, we continue emitting, requesting the next page
137
+ // if fetched page has next_cursor, we continue emitting, requesting the next page
134
138
  // after 2s to avoid rate limiting
135
139
  if (hasMorePages(pageResult)) {
136
140
  return timer(2000).pipe(
137
- // eslint-disable-next-line max-nested-callbacks
138
- concatMap(() => defer(() => fetchMuxAssetsPage(secrets, pageResult.pageNum + 1)))
141
+ concatMap(() =>
142
+ // eslint-disable-next-line max-nested-callbacks
143
+ defer(() =>
144
+ fetchMuxAssetsPage(
145
+ client,
146
+ 'next_cursor' in pageResult ? pageResult.next_cursor : null
147
+ )
148
+ )
149
+ )
139
150
  )
140
151
  }
141
152