@cliff-studio/sanity-plugin-bunny-input 1.0.1 → 1.0.3

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.1",
3
+ "version": "1.0.3",
4
4
  "description": "Sanity Studio plugin for uploading videos to Bunny Stream",
5
5
  "license": "MIT",
6
6
  "author": "Cliff Studio",
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "files": [
19
19
  "dist",
20
+ "src",
20
21
  "sanity.json",
21
22
  "v2-incompatible.js"
22
23
  ],
package/src/api.js ADDED
@@ -0,0 +1,270 @@
1
+ const BUNNY_API_BASE = 'https://video.bunnycdn.com'
2
+
3
+ /**
4
+ * Maps Bunny's numeric status to our string status
5
+ * @param {number} status
6
+ * @returns {'uploading' | 'processing' | 'ready' | 'error'}
7
+ */
8
+ export function mapBunnyStatus(status) {
9
+ switch (status) {
10
+ case 0: // created
11
+ case 1: // uploaded
12
+ return 'uploading'
13
+ case 2: // processing
14
+ case 3: // transcoding
15
+ return 'processing'
16
+ case 4: // finished
17
+ return 'ready'
18
+ case 5: // error
19
+ default:
20
+ return 'error'
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Get the thumbnail URL for a video
26
+ * @param {string} cdnHostname - The CDN hostname (e.g., 'vz-fbd37838-a6a.b-cdn.net')
27
+ * @param {string} videoId
28
+ * @returns {string}
29
+ */
30
+ export function getThumbnailUrl(cdnHostname, videoId) {
31
+ return `https://${cdnHostname}/${videoId}/thumbnail.jpg`
32
+ }
33
+
34
+ /**
35
+ * Get the HLS playback URL for a video
36
+ * @param {string} cdnHostname - The CDN hostname (e.g., 'vz-fbd37838-a6a.b-cdn.net')
37
+ * @param {string} videoId
38
+ * @returns {string}
39
+ */
40
+ export function getPlaybackUrl(cdnHostname, videoId) {
41
+ return `https://${cdnHostname}/${videoId}/playlist.m3u8`
42
+ }
43
+
44
+ /**
45
+ * Get the direct MP4 URL (if available)
46
+ * @param {string} cdnHostname - The CDN hostname (e.g., 'vz-fbd37838-a6a.b-cdn.net')
47
+ * @param {string} videoId
48
+ * @param {string} resolution
49
+ * @returns {string}
50
+ */
51
+ export function getMp4Url(cdnHostname, videoId, resolution = '720p') {
52
+ return `https://${cdnHostname}/${videoId}/play_${resolution}.mp4`
53
+ }
54
+
55
+ function createHeaders(config, includeJsonContentType = false, useDirectApi = false) {
56
+ const headers = {
57
+ Accept: 'application/json',
58
+ }
59
+
60
+ if (includeJsonContentType) {
61
+ headers['Content-Type'] = 'application/json'
62
+ }
63
+
64
+ if (config.apiKey && (useDirectApi || !config.proxyEndpoint)) {
65
+ headers.AccessKey = config.apiKey
66
+ }
67
+
68
+ return headers
69
+ }
70
+
71
+ function getCollectionListUrl(config, searchTerm) {
72
+ if (config.proxyEndpoint && !config.apiKey) {
73
+ return `${config.proxyEndpoint}/collections?search=${encodeURIComponent(searchTerm)}`
74
+ }
75
+
76
+ return `${BUNNY_API_BASE}/library/${config.libraryId}/collections?search=${encodeURIComponent(
77
+ searchTerm
78
+ )}&page=1&itemsPerPage=100`
79
+ }
80
+
81
+ function getCreateCollectionUrl(config) {
82
+ if (config.proxyEndpoint && !config.apiKey) {
83
+ return `${config.proxyEndpoint}/collections`
84
+ }
85
+
86
+ return `${BUNNY_API_BASE}/library/${config.libraryId}/collections`
87
+ }
88
+
89
+ async function getCollectionList(config, searchTerm) {
90
+ const url = getCollectionListUrl(config, searchTerm)
91
+
92
+ const response = await fetch(url, {
93
+ headers: createHeaders(config, false, !config.proxyEndpoint || Boolean(config.apiKey)),
94
+ })
95
+
96
+ if (!response.ok) {
97
+ const error = await response.text()
98
+ throw new Error(`Failed to list collections: ${error}`)
99
+ }
100
+
101
+ const payload = await response.json()
102
+ return payload.items || []
103
+ }
104
+
105
+ /**
106
+ * Get or create a Bunny Stream collection by name
107
+ * @param {Object} config
108
+ * @param {string} collectionName
109
+ * @returns {Promise<{guid: string, name: string}>}
110
+ */
111
+ export async function getOrCreateCollection(config, collectionName) {
112
+ const normalizedCollectionName = collectionName?.trim()
113
+
114
+ if (!normalizedCollectionName) {
115
+ throw new Error('Collection name cannot be empty')
116
+ }
117
+
118
+ const collections = await getCollectionList(config, normalizedCollectionName)
119
+ const existingCollection = collections.find(
120
+ (collection) => collection.name?.toLowerCase() === normalizedCollectionName.toLowerCase()
121
+ )
122
+
123
+ if (existingCollection) {
124
+ return existingCollection
125
+ }
126
+
127
+ const response = await fetch(getCreateCollectionUrl(config), {
128
+ method: 'POST',
129
+ headers: createHeaders(config, true, !config.proxyEndpoint || Boolean(config.apiKey)),
130
+ body: JSON.stringify({name: normalizedCollectionName}),
131
+ })
132
+
133
+ if (!response.ok) {
134
+ const error = await response.text()
135
+ throw new Error(`Failed to create collection: ${error}`)
136
+ }
137
+
138
+ return response.json()
139
+ }
140
+
141
+ /**
142
+ * Create a new video object in Bunny Stream
143
+ * @param {Object} config
144
+ * @param {string} title
145
+ * @param {string} [collectionId]
146
+ * @returns {Promise<Object>}
147
+ */
148
+ export async function createVideo(config, title, collectionId) {
149
+ const url = config.proxyEndpoint
150
+ ? `${config.proxyEndpoint}/create`
151
+ : `${BUNNY_API_BASE}/library/${config.libraryId}/videos`
152
+
153
+ const headers = createHeaders(config, true)
154
+
155
+ const response = await fetch(url, {
156
+ method: 'POST',
157
+ headers,
158
+ body: JSON.stringify({
159
+ title,
160
+ ...(collectionId ? {collectionId} : {}),
161
+ }),
162
+ })
163
+
164
+ if (!response.ok) {
165
+ const error = await response.text()
166
+ throw new Error(`Failed to create video: ${error}`)
167
+ }
168
+
169
+ return response.json()
170
+ }
171
+
172
+ /**
173
+ * Upload video file to Bunny Stream
174
+ * @param {Object} config
175
+ * @param {string} videoId
176
+ * @param {File} file
177
+ * @param {Function} onProgress
178
+ * @returns {Promise<Object>}
179
+ */
180
+ export async function uploadVideo(config, videoId, file, onProgress) {
181
+ const url = config.proxyEndpoint
182
+ ? `${config.proxyEndpoint}/upload/${videoId}`
183
+ : `${BUNNY_API_BASE}/library/${config.libraryId}/videos/${videoId}`
184
+
185
+ return new Promise((resolve, reject) => {
186
+ const xhr = new XMLHttpRequest()
187
+
188
+ xhr.upload.addEventListener('progress', (event) => {
189
+ if (event.lengthComputable && onProgress) {
190
+ const progress = Math.round((event.loaded / event.total) * 100)
191
+ onProgress(progress)
192
+ }
193
+ })
194
+
195
+ xhr.addEventListener('load', () => {
196
+ if (xhr.status >= 200 && xhr.status < 300) {
197
+ try {
198
+ const response = JSON.parse(xhr.responseText)
199
+ resolve(response)
200
+ } catch {
201
+ resolve({success: true, message: 'OK'})
202
+ }
203
+ } else {
204
+ reject(new Error(`Upload failed: ${xhr.statusText}`))
205
+ }
206
+ })
207
+
208
+ xhr.addEventListener('error', () => {
209
+ reject(new Error('Upload failed: Network error'))
210
+ })
211
+
212
+ xhr.open('PUT', url)
213
+ xhr.setRequestHeader('Accept', 'application/json')
214
+
215
+ if (!config.proxyEndpoint && config.apiKey) {
216
+ xhr.setRequestHeader('AccessKey', config.apiKey)
217
+ }
218
+
219
+ xhr.send(file)
220
+ })
221
+ }
222
+
223
+ /**
224
+ * Get video details from Bunny Stream
225
+ * @param {Object} config
226
+ * @param {string} videoId
227
+ * @returns {Promise<Object>}
228
+ */
229
+ export async function getVideo(config, videoId) {
230
+ const url = config.proxyEndpoint
231
+ ? `${config.proxyEndpoint}/video/${videoId}`
232
+ : `${BUNNY_API_BASE}/library/${config.libraryId}/videos/${videoId}`
233
+
234
+ const headers = createHeaders(config)
235
+
236
+ const response = await fetch(url, {headers})
237
+
238
+ if (!response.ok) {
239
+ const error = await response.text()
240
+ throw new Error(`Failed to get video: ${error}`)
241
+ }
242
+
243
+ return response.json()
244
+ }
245
+
246
+ /**
247
+ * Delete a video from Bunny Stream
248
+ * @param {Object} config
249
+ * @param {string} videoId
250
+ * @returns {Promise<Object>}
251
+ */
252
+ export async function deleteVideo(config, videoId) {
253
+ const url = config.proxyEndpoint
254
+ ? `${config.proxyEndpoint}/video/${videoId}`
255
+ : `${BUNNY_API_BASE}/library/${config.libraryId}/videos/${videoId}`
256
+
257
+ const headers = createHeaders(config)
258
+
259
+ const response = await fetch(url, {
260
+ method: 'DELETE',
261
+ headers,
262
+ })
263
+
264
+ if (!response.ok) {
265
+ const error = await response.text()
266
+ throw new Error(`Failed to delete video: ${error}`)
267
+ }
268
+
269
+ return response.json()
270
+ }
@@ -0,0 +1,278 @@
1
+ import {useCallback, useEffect, useRef, useState} from 'react'
2
+ import {set, unset} from 'sanity'
3
+ import {Box, Button, Card, Flex, Spinner, Stack, Text} from '@sanity/ui'
4
+ import {
5
+ createVideo,
6
+ uploadVideo,
7
+ getVideo,
8
+ deleteVideo,
9
+ getOrCreateCollection,
10
+ mapBunnyStatus,
11
+ getThumbnailUrl,
12
+ getPlaybackUrl,
13
+ getMp4Url,
14
+ } from '../api'
15
+
16
+ export function BunnyInput(props) {
17
+ const {value, onChange, config} = props
18
+ const [uploadProgress, setUploadProgress] = useState(0)
19
+ const [isUploading, setIsUploading] = useState(false)
20
+ const [error, setError] = useState(null)
21
+ const fileInputRef = useRef(null)
22
+ const pollIntervalRef = useRef(null)
23
+
24
+ // Poll for video status when processing
25
+ useEffect(() => {
26
+ if (value?.videoId && value?.status === 'processing') {
27
+ pollIntervalRef.current = window.setInterval(async () => {
28
+ try {
29
+ const videoData = await getVideo(config, value.videoId)
30
+ const status = mapBunnyStatus(videoData.status)
31
+
32
+ if (status === 'ready' || status === 'error') {
33
+ // Clear interval when done
34
+ if (pollIntervalRef.current) {
35
+ clearInterval(pollIntervalRef.current)
36
+ pollIntervalRef.current = null
37
+ }
38
+
39
+ onChange(
40
+ set({
41
+ ...value,
42
+ status,
43
+ duration: videoData.length,
44
+ height: videoData.height ?? null,
45
+ width: videoData.width ?? null,
46
+ thumbnailUrl: getThumbnailUrl(config.cdnHostname, value.videoId),
47
+ playbackUrl: getPlaybackUrl(config.cdnHostname, value.videoId),
48
+ mp4Url: getMp4Url(config.cdnHostname, value.videoId),
49
+ })
50
+ )
51
+ }
52
+ } catch (err) {
53
+ console.error('Error polling video status:', err)
54
+ }
55
+ }, 5000) // Poll every 5 seconds
56
+ }
57
+
58
+ return () => {
59
+ if (pollIntervalRef.current) {
60
+ clearInterval(pollIntervalRef.current)
61
+ }
62
+ }
63
+ }, [value?.videoId, value?.status, config, onChange, value])
64
+
65
+ const handleFileSelect = useCallback(
66
+ async (file) => {
67
+ if (!file.type.startsWith('video/')) {
68
+ setError('Please select a video file')
69
+ return
70
+ }
71
+
72
+ setError(null)
73
+ setIsUploading(true)
74
+ setUploadProgress(0)
75
+
76
+ try {
77
+ // Step 1: Create video object in Bunny
78
+ const title = file.name.replace(/\.[^/.]+$/, '') // Remove extension
79
+ let collectionId = config.collectionId
80
+ let collectionName = config.collectionName
81
+
82
+ if (!collectionId && collectionName) {
83
+ const collection = await getOrCreateCollection(config, collectionName)
84
+ collectionId = collection.guid
85
+ collectionName = collection.name
86
+ }
87
+
88
+ const created = await createVideo(config, title, collectionId)
89
+
90
+ // Update Sanity with initial data
91
+ onChange(
92
+ set({
93
+ _type: 'bunnyVideo',
94
+ videoId: created.guid,
95
+ libraryId: String(created.videoLibraryId),
96
+ title,
97
+ status: 'uploading',
98
+ ...(collectionId ? {collectionId} : {}),
99
+ ...(collectionName ? {collectionName} : {}),
100
+ })
101
+ )
102
+
103
+ // Step 2: Upload the actual file
104
+ await uploadVideo(config, created.guid, file, setUploadProgress)
105
+
106
+ // Step 3: Update status to processing
107
+ onChange(
108
+ set({
109
+ _type: 'bunnyVideo',
110
+ videoId: created.guid,
111
+ libraryId: String(created.videoLibraryId),
112
+ title,
113
+ status: 'processing',
114
+ ...(collectionId ? {collectionId} : {}),
115
+ ...(collectionName ? {collectionName} : {}),
116
+ })
117
+ )
118
+ } catch (err) {
119
+ console.error('Upload error:', err)
120
+ setError(err instanceof Error ? err.message : 'Upload failed')
121
+ onChange(
122
+ set({
123
+ ...value,
124
+ status: 'error',
125
+ errorMessage: err instanceof Error ? err.message : 'Upload failed',
126
+ })
127
+ )
128
+ } finally {
129
+ setIsUploading(false)
130
+ setUploadProgress(0)
131
+ }
132
+ },
133
+ [config, onChange, value]
134
+ )
135
+
136
+ const handleInputChange = useCallback(
137
+ (e) => {
138
+ const file = e.target.files?.[0]
139
+ if (file) {
140
+ handleFileSelect(file)
141
+ }
142
+ },
143
+ [handleFileSelect]
144
+ )
145
+
146
+ const handleRemove = useCallback(async () => {
147
+ if (value?.videoId) {
148
+ try {
149
+ await deleteVideo(config, value.videoId)
150
+ } catch (err) {
151
+ // Log but don't block removal from Sanity
152
+ console.error('Error deleting from Bunny:', err)
153
+ }
154
+ }
155
+ onChange(unset())
156
+ }, [config, value, onChange])
157
+
158
+ // Render video preview if we have one
159
+ if (value?.videoId && value?.status === 'ready') {
160
+ return (
161
+ <Card padding={3} radius={2} shadow={1}>
162
+ <Stack space={3}>
163
+ <Box
164
+ style={{
165
+ position: 'relative',
166
+ paddingBottom: '56.25%',
167
+ backgroundColor: '#000',
168
+ borderRadius: '4px',
169
+ overflow: 'hidden',
170
+ }}
171
+ >
172
+ {value.thumbnailUrl && (
173
+ <img
174
+ src={value.thumbnailUrl}
175
+ alt={value.title || 'Video thumbnail'}
176
+ style={{
177
+ position: 'absolute',
178
+ top: 0,
179
+ left: 0,
180
+ width: '100%',
181
+ height: '100%',
182
+ objectFit: 'contain',
183
+ }}
184
+ />
185
+ )}
186
+ </Box>
187
+ <Flex justify="space-between" align="center">
188
+ <Stack space={2}>
189
+ <Text size={1} weight="semibold">
190
+ {value.title}
191
+ </Text>
192
+ {value.duration && (
193
+ <Text size={1} muted>
194
+ {Math.floor(value.duration / 60)}:
195
+ {String(Math.floor(value.duration % 60)).padStart(2, '0')}
196
+ </Text>
197
+ )}
198
+ </Stack>
199
+ <Button text="Remove" tone="critical" mode="ghost" onClick={handleRemove} />
200
+ </Flex>
201
+ </Stack>
202
+ </Card>
203
+ )
204
+ }
205
+
206
+ // Render processing state
207
+ if (value?.status === 'processing') {
208
+ return (
209
+ <Card padding={4} radius={2} shadow={1}>
210
+ <Flex direction="column" align="center" justify="center" gap={3}>
211
+ <Spinner />
212
+ <Text size={1}>Processing video...</Text>
213
+ <Text size={0} muted>
214
+ This may take a few minutes
215
+ </Text>
216
+ </Flex>
217
+ </Card>
218
+ )
219
+ }
220
+
221
+ // Render uploading state
222
+ if (isUploading) {
223
+ return (
224
+ <Card padding={4} radius={2} shadow={1}>
225
+ <Stack space={3}>
226
+ <Flex direction="column" align="center" justify="center" gap={2}>
227
+ <Spinner />
228
+ <Text size={1}>Uploading... {uploadProgress}%</Text>
229
+ </Flex>
230
+ <Box
231
+ style={{
232
+ height: '4px',
233
+ backgroundColor: '#e5e5e5',
234
+ borderRadius: '2px',
235
+ overflow: 'hidden',
236
+ }}
237
+ >
238
+ <Box
239
+ style={{
240
+ height: '100%',
241
+ width: `${uploadProgress}%`,
242
+ backgroundColor: '#2563eb',
243
+ transition: 'width 0.3s ease',
244
+ }}
245
+ />
246
+ </Box>
247
+ </Stack>
248
+ </Card>
249
+ )
250
+ }
251
+
252
+ // Render error state
253
+ if (error || value?.status === 'error') {
254
+ return (
255
+ <Stack space={3}>
256
+ <Card padding={3} radius={2} tone="critical">
257
+ <Text size={1}>Error: {error || value?.errorMessage}</Text>
258
+ </Card>
259
+ <input
260
+ ref={fileInputRef}
261
+ type="file"
262
+ accept="video/*"
263
+ onChange={handleInputChange}
264
+ />
265
+ </Stack>
266
+ )
267
+ }
268
+
269
+ // Render file input
270
+ return (
271
+ <input
272
+ ref={fileInputRef}
273
+ type="file"
274
+ accept="video/*"
275
+ onChange={handleInputChange}
276
+ />
277
+ )
278
+ }
@@ -0,0 +1,101 @@
1
+ import {Box, Flex, Text} from '@sanity/ui'
2
+
3
+ export function BunnyPreview(props) {
4
+ const {title, status, thumbnailUrl} = props
5
+
6
+ const statusColors = {
7
+ uploading: '#f59e0b',
8
+ processing: '#3b82f6',
9
+ ready: '#22c55e',
10
+ error: '#ef4444',
11
+ }
12
+
13
+ const statusLabels = {
14
+ uploading: 'Uploading',
15
+ processing: 'Processing',
16
+ ready: 'Ready',
17
+ error: 'Error',
18
+ }
19
+
20
+ return (
21
+ <Flex align="center" gap={3} padding={2}>
22
+ {thumbnailUrl ? (
23
+ <Box
24
+ style={{
25
+ width: 80,
26
+ height: 45,
27
+ borderRadius: 4,
28
+ overflow: 'hidden',
29
+ backgroundColor: '#000',
30
+ flexShrink: 0,
31
+ }}
32
+ >
33
+ <img
34
+ src={thumbnailUrl}
35
+ alt={title || 'Video thumbnail'}
36
+ style={{
37
+ width: '100%',
38
+ height: '100%',
39
+ objectFit: 'cover',
40
+ }}
41
+ />
42
+ </Box>
43
+ ) : (
44
+ <Box
45
+ style={{
46
+ width: 80,
47
+ height: 45,
48
+ borderRadius: 4,
49
+ backgroundColor: '#e5e5e5',
50
+ display: 'flex',
51
+ alignItems: 'center',
52
+ justifyContent: 'center',
53
+ flexShrink: 0,
54
+ }}
55
+ >
56
+ <svg
57
+ width="24"
58
+ height="24"
59
+ viewBox="0 0 24 24"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ strokeWidth="1.5"
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ style={{opacity: 0.4}}
66
+ >
67
+ <polygon points="5 3 19 12 5 21 5 3" />
68
+ </svg>
69
+ </Box>
70
+ )}
71
+ <Flex direction="column" gap={1} style={{minWidth: 0}}>
72
+ <Text
73
+ size={1}
74
+ weight="semibold"
75
+ style={{
76
+ overflow: 'hidden',
77
+ textOverflow: 'ellipsis',
78
+ whiteSpace: 'nowrap',
79
+ }}
80
+ >
81
+ {title || 'Untitled video'}
82
+ </Text>
83
+ {status && (
84
+ <Flex align="center" gap={2}>
85
+ <Box
86
+ style={{
87
+ width: 8,
88
+ height: 8,
89
+ borderRadius: '50%',
90
+ backgroundColor: statusColors[status] || '#9ca3af',
91
+ }}
92
+ />
93
+ <Text size={0} muted>
94
+ {statusLabels[status] || status}
95
+ </Text>
96
+ </Flex>
97
+ )}
98
+ </Flex>
99
+ </Flex>
100
+ )
101
+ }
@@ -0,0 +1,2 @@
1
+ export {BunnyInput} from './BunnyInput'
2
+ export {BunnyPreview} from './BunnyPreview'