@cliff-studio/sanity-plugin-bunny-input 1.0.9 → 1.2.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": "@cliff-studio/sanity-plugin-bunny-input",
3
- "version": "1.0.9",
3
+ "version": "1.2.0",
4
4
  "description": "Sanity Studio plugin for uploading videos to Bunny Stream",
5
5
  "license": "MIT",
6
6
  "author": "Cliff Studio",
@@ -28,7 +28,7 @@
28
28
  "prepublishOnly": "npm run build"
29
29
  },
30
30
  "peerDependencies": {
31
- "sanity": "^3.0.0 || ^5.0.0",
31
+ "sanity": "^3.0.0 || ^5.0.0 || ^6.0.0",
32
32
  "react": "^18.0.0 || ^19.0.0"
33
33
  },
34
34
  "devDependencies": {
package/src/api.js CHANGED
@@ -250,6 +250,48 @@ export async function getVideo(config, videoId) {
250
250
  return response.json()
251
251
  }
252
252
 
253
+ /**
254
+ * List videos in a collection (or entire library if no collectionId)
255
+ * @param {Object} config
256
+ * @param {Object} options
257
+ * @param {string} [options.collectionId]
258
+ * @param {string} [options.search]
259
+ * @param {number} [options.page]
260
+ * @param {number} [options.itemsPerPage]
261
+ * @returns {Promise<{items: Array, totalItems: number, currentPage: number, itemsPerPage: number}>}
262
+ */
263
+ export async function listVideos(config, {collectionId, search = '', page = 1, itemsPerPage = 50} = {}) {
264
+ const params = new URLSearchParams({
265
+ page: String(page),
266
+ itemsPerPage: String(itemsPerPage),
267
+ orderBy: 'date',
268
+ })
269
+
270
+ if (collectionId) params.set('collection', collectionId)
271
+ if (search) params.set('search', search)
272
+
273
+ const url = config.proxyEndpoint
274
+ ? `${config.proxyEndpoint}/videos?${params}`
275
+ : `${BUNNY_API_BASE}/library/${config.libraryId}/videos?${params}`
276
+
277
+ const response = await fetch(url, {
278
+ headers: createHeaders(config, false, !config.proxyEndpoint || Boolean(config.apiKey)),
279
+ })
280
+
281
+ if (!response.ok) {
282
+ const error = await response.text()
283
+ throw new Error(`Failed to list videos: ${error}`)
284
+ }
285
+
286
+ const payload = await response.json()
287
+ return {
288
+ items: payload.items || [],
289
+ totalItems: payload.totalItems ?? 0,
290
+ currentPage: payload.currentPage ?? page,
291
+ itemsPerPage: payload.itemsPerPage ?? itemsPerPage,
292
+ }
293
+ }
294
+
253
295
  /**
254
296
  * Delete a video from Bunny Stream
255
297
  * @param {Object} config
@@ -0,0 +1,254 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
+ import {Box, Button, Card, Dialog, Flex, Grid, Spinner, Stack, Text, TextInput} from '@sanity/ui'
3
+ import {SearchIcon} from '@sanity/icons'
4
+ import {listVideos, getThumbnailUrl, getPlaybackUrl, getMp4Url, mapBunnyStatus, getOrCreateCollection} from '../api'
5
+
6
+ const ITEMS_PER_PAGE = 48
7
+
8
+ export function BunnyBrowserModal({config, collectionId: collectionIdProp, onSelect, onClose}) {
9
+ const [videos, setVideos] = useState([])
10
+ const [search, setSearch] = useState('')
11
+ const [page, setPage] = useState(1)
12
+ const [totalItems, setTotalItems] = useState(0)
13
+ const [isLoading, setIsLoading] = useState(false)
14
+ const [error, setError] = useState(null)
15
+ // Resolved collection ID (may come from prop or be looked up from collectionName)
16
+ const [resolvedCollectionId, setResolvedCollectionId] = useState(collectionIdProp || null)
17
+ const searchTimeoutRef = useRef(null)
18
+
19
+ const fetchVideos = useCallback(
20
+ async (searchTerm, pageNum, collId) => {
21
+ setIsLoading(true)
22
+ setError(null)
23
+ try {
24
+ const result = await listVideos(config, {
25
+ collectionId: collId,
26
+ search: searchTerm,
27
+ page: pageNum,
28
+ itemsPerPage: ITEMS_PER_PAGE,
29
+ })
30
+ setVideos(result.items)
31
+ setTotalItems(result.totalItems)
32
+ } catch (err) {
33
+ setError(err instanceof Error ? err.message : 'Failed to load videos')
34
+ } finally {
35
+ setIsLoading(false)
36
+ }
37
+ },
38
+ [config]
39
+ )
40
+
41
+ // Resolve collectionId from collectionName if needed, then fetch
42
+ useEffect(() => {
43
+ async function init() {
44
+ let collId = collectionIdProp || null
45
+
46
+ if (!collId && config.collectionName) {
47
+ try {
48
+ const collection = await getOrCreateCollection(config, config.collectionName)
49
+ collId = collection.guid
50
+ setResolvedCollectionId(collId)
51
+ } catch (err) {
52
+ // Fall back to browsing entire library if collection lookup fails
53
+ console.warn('Could not resolve collection, browsing full library:', err)
54
+ }
55
+ }
56
+
57
+ fetchVideos('', 1, collId)
58
+ }
59
+ init()
60
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
61
+
62
+ const handleSearchChange = useCallback(
63
+ (e) => {
64
+ const value = e.target.value
65
+ setSearch(value)
66
+ setPage(1)
67
+ if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
68
+ searchTimeoutRef.current = setTimeout(() => {
69
+ fetchVideos(value, 1, resolvedCollectionId)
70
+ }, 400)
71
+ },
72
+ [fetchVideos, resolvedCollectionId]
73
+ )
74
+
75
+ const handlePageChange = useCallback(
76
+ (newPage) => {
77
+ setPage(newPage)
78
+ fetchVideos(search, newPage, resolvedCollectionId)
79
+ },
80
+ [fetchVideos, search, resolvedCollectionId]
81
+ )
82
+
83
+ const handleSelect = useCallback(
84
+ (video) => {
85
+ const videoStatus = mapBunnyStatus(video.status)
86
+ if (videoStatus !== 'ready') return
87
+
88
+ const width = Number(video.width)
89
+ const height = Number(video.height)
90
+ const hasDims = Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0
91
+ const ratio = hasDims ? Number((width / height).toFixed(6)) : null
92
+ const orientation = hasDims
93
+ ? width > height
94
+ ? 'landscape'
95
+ : width < height
96
+ ? 'portrait'
97
+ : 'square'
98
+ : null
99
+
100
+ onSelect({
101
+ _type: 'bunnyVideo',
102
+ videoId: video.guid,
103
+ libraryId: String(video.videoLibraryId),
104
+ title: video.title,
105
+ status: 'ready',
106
+ duration: video.length,
107
+ width: hasDims ? width : null,
108
+ height: hasDims ? height : null,
109
+ ratio,
110
+ orientation,
111
+ thumbnailUrl: getThumbnailUrl(config.cdnHostname, video.guid),
112
+ playbackUrl: getPlaybackUrl(config.cdnHostname, video.guid),
113
+ mp4Url: getMp4Url(config.cdnHostname, video.guid, video.availableResolutions),
114
+ ...(resolvedCollectionId ? {collectionId: resolvedCollectionId} : {}),
115
+ ...(video.collectionId ? {collectionId: video.collectionId} : {}),
116
+ })
117
+ },
118
+ [config, resolvedCollectionId, onSelect]
119
+ )
120
+
121
+ const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE)
122
+
123
+ return (
124
+ <Dialog
125
+ header="Browse videos"
126
+ id="bunny-browser-modal"
127
+ onClose={onClose}
128
+ width={3}
129
+ >
130
+ <Box padding={4}>
131
+ <Box marginBottom={4}>
132
+ <TextInput
133
+ icon={SearchIcon}
134
+ placeholder="Search videos…"
135
+ value={search}
136
+ onChange={handleSearchChange}
137
+ />
138
+ </Box>
139
+
140
+ {error && (
141
+ <Card padding={3} radius={2} tone="critical" marginBottom={4}>
142
+ <Text size={1}>{error}</Text>
143
+ </Card>
144
+ )}
145
+
146
+ {isLoading ? (
147
+ <Flex align="center" justify="center" padding={6}>
148
+ <Spinner />
149
+ </Flex>
150
+ ) : videos.length === 0 ? (
151
+ <Flex align="center" justify="center" padding={6}>
152
+ <Text muted size={1}>
153
+ {search ? 'No videos found matching your search' : 'No videos in this collection'}
154
+ </Text>
155
+ </Flex>
156
+ ) : (
157
+ <>
158
+ <Grid columns={[2, 2, 3, 4]} gap={3}>
159
+ {videos.map((video) => {
160
+ const status = mapBunnyStatus(video.status)
161
+ const isReady = status === 'ready'
162
+ const thumbUrl = getThumbnailUrl(config.cdnHostname, video.guid)
163
+
164
+ return (
165
+ <Card
166
+ key={video.guid}
167
+ radius={2}
168
+ shadow={1}
169
+ style={{
170
+ cursor: isReady ? 'pointer' : 'not-allowed',
171
+ opacity: isReady ? 1 : 0.5,
172
+ }}
173
+ onClick={() => isReady && handleSelect(video)}
174
+ >
175
+ <Box
176
+ style={{
177
+ position: 'relative',
178
+ paddingBottom: '56.25%',
179
+ backgroundColor: '#111',
180
+ borderRadius: '2px',
181
+ overflow: 'hidden',
182
+ }}
183
+ >
184
+ <img
185
+ src={thumbUrl}
186
+ alt={video.title || 'Video thumbnail'}
187
+ style={{
188
+ position: 'absolute',
189
+ top: 0,
190
+ left: 0,
191
+ width: '100%',
192
+ height: '100%',
193
+ objectFit: 'cover',
194
+ }}
195
+ />
196
+ {!isReady && (
197
+ <Flex
198
+ align="center"
199
+ justify="center"
200
+ style={{
201
+ position: 'absolute',
202
+ inset: 0,
203
+ backgroundColor: 'rgba(0,0,0,0.5)',
204
+ }}
205
+ >
206
+ <Text size={0} style={{color: '#fff'}}>
207
+ {status === 'processing' ? 'Processing…' : status}
208
+ </Text>
209
+ </Flex>
210
+ )}
211
+ </Box>
212
+ <Box padding={3}>
213
+ <Stack space={2}>
214
+ <Text size={1} weight="semibold">
215
+ {video.title || 'Untitled'}
216
+ </Text>
217
+ {video.length > 0 && (
218
+ <Text size={1} muted>
219
+ {Math.floor(video.length / 60)}:
220
+ {String(Math.floor(video.length % 60)).padStart(2, '0')}
221
+ </Text>
222
+ )}
223
+ </Stack>
224
+ </Box>
225
+ </Card>
226
+ )
227
+ })}
228
+ </Grid>
229
+
230
+ {totalPages > 1 && (
231
+ <Flex align="center" justify="center" gap={2} marginTop={4}>
232
+ <Button
233
+ text="Previous"
234
+ mode="ghost"
235
+ disabled={page <= 1}
236
+ onClick={() => handlePageChange(page - 1)}
237
+ />
238
+ <Text size={1} muted>
239
+ Page {page} of {totalPages}
240
+ </Text>
241
+ <Button
242
+ text="Next"
243
+ mode="ghost"
244
+ disabled={page >= totalPages}
245
+ onClick={() => handlePageChange(page + 1)}
246
+ />
247
+ </Flex>
248
+ )}
249
+ </>
250
+ )}
251
+ </Box>
252
+ </Dialog>
253
+ )
254
+ }
@@ -1,6 +1,7 @@
1
1
  import {useCallback, useEffect, useRef, useState} from 'react'
2
2
  import {set, unset} from 'sanity'
3
3
  import {Box, Button, Card, Flex, Spinner, Stack, Text} from '@sanity/ui'
4
+ import {UploadIcon, SearchIcon, VideoIcon} from '@sanity/icons'
4
5
  import {
5
6
  createVideo,
6
7
  uploadVideo,
@@ -12,12 +13,14 @@ import {
12
13
  getPlaybackUrl,
13
14
  getMp4Url,
14
15
  } from '../api'
16
+ import {BunnyBrowserModal} from './BunnyBrowserModal'
15
17
 
16
18
  export function BunnyInput(props) {
17
19
  const {value, onChange, config} = props
18
20
  const [uploadProgress, setUploadProgress] = useState(0)
19
21
  const [isUploading, setIsUploading] = useState(false)
20
22
  const [error, setError] = useState(null)
23
+ const [isBrowserOpen, setIsBrowserOpen] = useState(false)
21
24
  const fileInputRef = useRef(null)
22
25
  const pollIntervalRef = useRef(null)
23
26
 
@@ -162,6 +165,14 @@ export function BunnyInput(props) {
162
165
  [handleFileSelect]
163
166
  )
164
167
 
168
+ const handleBrowseSelect = useCallback(
169
+ (videoData) => {
170
+ setIsBrowserOpen(false)
171
+ onChange(set(videoData))
172
+ },
173
+ [onChange]
174
+ )
175
+
165
176
  const handleRemove = useCallback(async () => {
166
177
  if (value?.videoId) {
167
178
  try {
@@ -177,48 +188,65 @@ export function BunnyInput(props) {
177
188
  // Render video preview if we have one
178
189
  if (value?.videoId && value?.status === 'ready') {
179
190
  return (
180
- <Card padding={3} radius={2} shadow={1}>
181
- <Stack space={3}>
182
- <Box
183
- style={{
184
- position: 'relative',
185
- paddingBottom: '56.25%',
186
- backgroundColor: '#000',
187
- borderRadius: '4px',
188
- overflow: 'hidden',
189
- }}
190
- >
191
- {value.thumbnailUrl && (
192
- <img
193
- src={value.thumbnailUrl}
194
- alt={value.title || 'Video thumbnail'}
195
- style={{
196
- position: 'absolute',
197
- top: 0,
198
- left: 0,
199
- width: '100%',
200
- height: '100%',
201
- objectFit: 'contain',
202
- }}
203
- />
204
- )}
205
- </Box>
206
- <Flex justify="space-between" align="center">
207
- <Stack space={2}>
208
- <Text size={1} weight="semibold">
209
- {value.title}
210
- </Text>
211
- {value.duration && (
212
- <Text size={1} muted>
213
- {Math.floor(value.duration / 60)}:
214
- {String(Math.floor(value.duration % 60)).padStart(2, '0')}
215
- </Text>
191
+ <>
192
+ <Card padding={3} radius={2} shadow={1}>
193
+ <Stack space={3}>
194
+ <Box
195
+ style={{
196
+ position: 'relative',
197
+ paddingBottom: '56.25%',
198
+ backgroundColor: '#000',
199
+ borderRadius: '4px',
200
+ overflow: 'hidden',
201
+ }}
202
+ >
203
+ {value.thumbnailUrl && (
204
+ <img
205
+ src={value.thumbnailUrl}
206
+ alt={value.title || 'Video thumbnail'}
207
+ style={{
208
+ position: 'absolute',
209
+ top: 0,
210
+ left: 0,
211
+ width: '100%',
212
+ height: '100%',
213
+ objectFit: 'contain',
214
+ }}
215
+ />
216
216
  )}
217
- </Stack>
218
- <Button text="Remove" tone="critical" mode="ghost" onClick={handleRemove} />
219
- </Flex>
220
- </Stack>
221
- </Card>
217
+ </Box>
218
+ <Flex justify="space-between" align="center">
219
+ <Stack space={2}>
220
+ <Text size={1} weight="semibold">
221
+ {value.title}
222
+ </Text>
223
+ {value.duration && (
224
+ <Text size={1} muted>
225
+ {Math.floor(value.duration / 60)}:
226
+ {String(Math.floor(value.duration % 60)).padStart(2, '0')}
227
+ </Text>
228
+ )}
229
+ </Stack>
230
+ <Flex gap={2}>
231
+ <Button
232
+ text="Browse"
233
+ mode="ghost"
234
+ onClick={() => setIsBrowserOpen(true)}
235
+ />
236
+ <Button text="Remove" tone="critical" mode="ghost" onClick={handleRemove} />
237
+ </Flex>
238
+ </Flex>
239
+ </Stack>
240
+ </Card>
241
+ {isBrowserOpen && (
242
+ <BunnyBrowserModal
243
+ config={config}
244
+ collectionId={value.collectionId || config.collectionId}
245
+ onSelect={handleBrowseSelect}
246
+ onClose={() => setIsBrowserOpen(false)}
247
+ />
248
+ )}
249
+ </>
222
250
  )
223
251
  }
224
252
 
@@ -285,13 +313,59 @@ export function BunnyInput(props) {
285
313
  )
286
314
  }
287
315
 
288
- // Render file input
316
+ // Render file input + browse option
289
317
  return (
290
- <input
291
- ref={fileInputRef}
292
- type="file"
293
- accept="video/*"
294
- onChange={handleInputChange}
295
- />
318
+ <>
319
+ <div style={{padding: 1}}>
320
+ <Card tone="inherit" border paddingX={3} paddingY={2} radius={2}>
321
+ <Flex align="center" gap={4} justify="space-between">
322
+ <Flex flex={1} align="center" gap={3}>
323
+ <Text size={1} muted><VideoIcon /></Text>
324
+ <Text size={1} muted>Drag or paste video here</Text>
325
+ </Flex>
326
+ <Flex align="center" gap={1}>
327
+ <label style={{display: 'contents'}}>
328
+ <Button
329
+ as="span"
330
+ icon={UploadIcon}
331
+ text="Upload"
332
+ mode="bleed"
333
+ padding={2}
334
+ style={{cursor: 'pointer'}}
335
+ />
336
+ <input
337
+ ref={fileInputRef}
338
+ type="file"
339
+ accept="video/*"
340
+ onChange={handleInputChange}
341
+ style={{
342
+ position: 'absolute',
343
+ width: '1px',
344
+ height: '1px',
345
+ overflow: 'hidden',
346
+ opacity: 0,
347
+ }}
348
+ />
349
+ </label>
350
+ <Button
351
+ icon={SearchIcon}
352
+ text="Select"
353
+ mode="bleed"
354
+ padding={2}
355
+ onClick={() => setIsBrowserOpen(true)}
356
+ />
357
+ </Flex>
358
+ </Flex>
359
+ </Card>
360
+ </div>
361
+ {isBrowserOpen && (
362
+ <BunnyBrowserModal
363
+ config={config}
364
+ collectionId={config.collectionId}
365
+ onSelect={handleBrowseSelect}
366
+ onClose={() => setIsBrowserOpen(false)}
367
+ />
368
+ )}
369
+ </>
296
370
  )
297
371
  }
@@ -1,2 +1,3 @@
1
1
  export {BunnyInput} from './BunnyInput'
2
2
  export {BunnyPreview} from './BunnyPreview'
3
+ export {BunnyBrowserModal} from './BunnyBrowserModal'
package/src/schema.js CHANGED
@@ -69,6 +69,19 @@ export const createBunnyVideoSchema = (config) => {
69
69
  name: 'ratio',
70
70
  readOnly: true,
71
71
  }),
72
+ defineField({
73
+ type: 'string',
74
+ title: 'Orientation',
75
+ name: 'orientation',
76
+ options: {
77
+ list: [
78
+ {title: 'Landscape', value: 'landscape'},
79
+ {title: 'Portrait', value: 'portrait'},
80
+ {title: 'Square', value: 'square'},
81
+ ],
82
+ },
83
+ readOnly: true,
84
+ }),
72
85
  defineField({
73
86
  type: 'string',
74
87
  title: 'Thumbnail URL',