Skip to content

Commit 5f17fdf

Browse files
authored
feat: Resync video metadata + fix CORS (#436)
* Added configure plugin in video section. Changed pagination to cursor based. Added warning and skipped in import videos if assets without playback is detected * improve get assets and warning in one iteration * Added resync metadata feature for existing Sanity assets * Removed fetch from Mux API
1 parent ea6179c commit 5f17fdf

File tree

10 files changed

+535
-72
lines changed

10 files changed

+535
-72
lines changed

src/actions/assets.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,25 @@ export function getAsset(client: SanityClient, assetId: string) {
4747
method: 'GET',
4848
})
4949
}
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+
}

src/components/ConfigureApi.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,31 @@ import {
1111
Text,
1212
TextInput,
1313
} from '@sanity/ui'
14-
import React, {memo, useCallback, useEffect, useId, useMemo, useRef} from 'react'
14+
import {useCallback, useEffect, useId, useMemo, useRef} from 'react'
1515
import {clear, preload} from 'suspend-react'
1616

1717
import {useClient} from '../hooks/useClient'
1818
import type {SetDialogState} from '../hooks/useDialogState'
19+
import {useDialogState} from '../hooks/useDialogState'
1920
import {useSaveSecrets} from '../hooks/useSaveSecrets'
21+
import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
2022
import {useSecretsFormState} from '../hooks/useSecretsFormState'
21-
import {cacheNs} from '../util/constants'
23+
import {cacheNs, DIALOGS_Z_INDEX} from '../util/constants'
2224
import {_id as secretsId} from '../util/readSecrets'
2325
import type {Secrets} from '../util/types'
2426
import {Header} from './ConfigureApi.styled'
2527
import FormField from './FormField'
2628

27-
export interface Props {
29+
// Props for the dialog component when used with external state management
30+
export interface ConfigureApiDialogProps {
2831
setDialogState: SetDialogState
2932
secrets: Secrets
3033
}
34+
3135
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) {
3339
const client = useClient()
3440
const [state, dispatch] = useSecretsFormState(secrets)
3541
const hasSecretsInitially = useMemo(() => secrets.token && secrets.secretKey, [secrets])
@@ -112,13 +118,13 @@ function ConfigureApi({secrets, setDialogState}: Props) {
112118
animate
113119
id={id}
114120
onClose={handleClose}
121+
onClickOutside={handleClose}
115122
header={<Header />}
123+
zOffset={DIALOGS_Z_INDEX}
124+
position="fixed"
116125
width={1}
117-
style={{
118-
maxWidth: '550px',
119-
}}
120126
>
121-
<Box padding={4} style={{position: 'relative'}}>
127+
<Box padding={3}>
122128
<form onSubmit={handleSubmit} noValidate>
123129
<Stack space={4}>
124130
{!hasSecretsInitially && (
@@ -224,4 +230,21 @@ function ConfigureApi({secrets, setDialogState}: Props) {
224230
)
225231
}
226232

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+
}

src/components/ImportVideosFromMux.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
28
import {
39
Box,
410
Button,
@@ -120,7 +126,7 @@ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
120126
<Button
121127
fontSize={2}
122128
padding={3}
123-
mode="bleed"
129+
mode="ghost"
124130
text="Cancel"
125131
tone="critical"
126132
onClick={props.closeDialog}
@@ -149,6 +155,24 @@ function ImportVideosDialog(props: ReturnType<typeof useImportMuxAssets>) {
149155
}
150156
>
151157
<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+
152176
{/* LOADING ASSETS STATE */}
153177
{(props.muxAssets.loading || props.assetsInSanityLoading) && (
154178
<Card tone="primary" marginBottom={5} padding={3} border>

src/components/Input.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import {Card} from '@sanity/ui'
22
import {memo, Suspense} from 'react'
33

4+
import {useAccessControl} from '../hooks/useAccessControl'
45
import {useAssetDocumentValues} from '../hooks/useAssetDocumentValues'
56
import {useClient} from '../hooks/useClient'
67
import {useDialogState} from '../hooks/useDialogState'
78
import {useMuxPolling} from '../hooks/useMuxPolling'
89
import {useSecretsDocumentValues} from '../hooks/useSecretsDocumentValues'
910
import type {MuxInputProps, PluginConfig} from '../util/types'
10-
import ConfigureApi from './ConfigureApi'
11+
import {ConfigureApiDialog} from './ConfigureApi'
1112
import ErrorBoundaryCard from './ErrorBoundaryCard'
1213
import {InputFallback} from './Input.styled'
1314
import Onboard from './Onboard'
1415
import Uploader from './Uploader'
15-
import {useAccessControl} from '../hooks/useAccessControl'
1616

1717
export interface InputProps extends MuxInputProps {
1818
config: PluginConfig
@@ -62,7 +62,7 @@ const Input = (props: InputProps) => {
6262
)}
6363

6464
{dialogState === 'secrets' && hasConfigAccess && (
65-
<ConfigureApi
65+
<ConfigureApiDialog
6666
setDialogState={setDialogState}
6767
secrets={secretDocumentValues.value.secrets}
6868
/>

src/components/ResyncMetadata.tsx

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)