@cliff-studio/sanity-plugin-bunny-input 1.0.0 → 1.0.2
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 +4 -10
- package/src/api.js +270 -0
- package/src/components/BunnyInput.jsx +276 -0
- package/src/components/BunnyPreview.jsx +101 -0
- package/src/components/index.js +2 -0
- package/src/index.js +63 -0
- package/src/schema.js +98 -0
package/package.json
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cliff-studio/sanity-plugin-bunny-input",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Sanity Studio plugin for uploading videos to Bunny Stream",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Cliff Studio",
|
|
7
|
-
|
|
8
7
|
"type": "module",
|
|
9
8
|
"sideEffects": false,
|
|
10
|
-
|
|
11
9
|
"main": "./dist/index.js",
|
|
12
10
|
"exports": {
|
|
13
11
|
".": {
|
|
@@ -17,25 +15,22 @@
|
|
|
17
15
|
},
|
|
18
16
|
"./package.json": "./package.json"
|
|
19
17
|
},
|
|
20
|
-
|
|
21
18
|
"files": [
|
|
22
19
|
"dist",
|
|
20
|
+
"src",
|
|
23
21
|
"sanity.json",
|
|
24
22
|
"v2-incompatible.js"
|
|
25
23
|
],
|
|
26
|
-
|
|
27
24
|
"scripts": {
|
|
28
25
|
"build": "pkg build --strict --check --clean",
|
|
29
26
|
"lint": "eslint .",
|
|
30
27
|
"format": "prettier --write --cache --ignore-unknown .",
|
|
31
28
|
"prepublishOnly": "npm run build"
|
|
32
29
|
},
|
|
33
|
-
|
|
34
30
|
"peerDependencies": {
|
|
35
|
-
"sanity": "^3.0.0",
|
|
31
|
+
"sanity": "^3.0.0 || ^5.0.0",
|
|
36
32
|
"react": "^18.0.0 || ^19.0.0"
|
|
37
33
|
},
|
|
38
|
-
|
|
39
34
|
"devDependencies": {
|
|
40
35
|
"@sanity/pkg-utils": "^6.0.0",
|
|
41
36
|
"@sanity/plugin-kit": "^4.0.0",
|
|
@@ -44,7 +39,6 @@
|
|
|
44
39
|
"prettier": "^3.0.0",
|
|
45
40
|
"prettier-plugin-packagejson": "^2.0.0"
|
|
46
41
|
},
|
|
47
|
-
|
|
48
42
|
"publishConfig": {
|
|
49
43
|
"access": "public",
|
|
50
44
|
"exports": {
|
|
@@ -52,4 +46,4 @@
|
|
|
52
46
|
"./package.json": "./package.json"
|
|
53
47
|
}
|
|
54
48
|
}
|
|
55
|
-
}
|
|
49
|
+
}
|
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,276 @@
|
|
|
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
|
+
thumbnailUrl: getThumbnailUrl(config.cdnHostname, value.videoId),
|
|
45
|
+
playbackUrl: getPlaybackUrl(config.cdnHostname, value.videoId),
|
|
46
|
+
mp4Url: getMp4Url(config.cdnHostname, value.videoId),
|
|
47
|
+
})
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Error polling video status:', err)
|
|
52
|
+
}
|
|
53
|
+
}, 5000) // Poll every 5 seconds
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return () => {
|
|
57
|
+
if (pollIntervalRef.current) {
|
|
58
|
+
clearInterval(pollIntervalRef.current)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}, [value?.videoId, value?.status, config, onChange, value])
|
|
62
|
+
|
|
63
|
+
const handleFileSelect = useCallback(
|
|
64
|
+
async (file) => {
|
|
65
|
+
if (!file.type.startsWith('video/')) {
|
|
66
|
+
setError('Please select a video file')
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
setError(null)
|
|
71
|
+
setIsUploading(true)
|
|
72
|
+
setUploadProgress(0)
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
// Step 1: Create video object in Bunny
|
|
76
|
+
const title = file.name.replace(/\.[^/.]+$/, '') // Remove extension
|
|
77
|
+
let collectionId = config.collectionId
|
|
78
|
+
let collectionName = config.collectionName
|
|
79
|
+
|
|
80
|
+
if (!collectionId && collectionName) {
|
|
81
|
+
const collection = await getOrCreateCollection(config, collectionName)
|
|
82
|
+
collectionId = collection.guid
|
|
83
|
+
collectionName = collection.name
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const created = await createVideo(config, title, collectionId)
|
|
87
|
+
|
|
88
|
+
// Update Sanity with initial data
|
|
89
|
+
onChange(
|
|
90
|
+
set({
|
|
91
|
+
_type: 'bunnyVideo',
|
|
92
|
+
videoId: created.guid,
|
|
93
|
+
libraryId: String(created.videoLibraryId),
|
|
94
|
+
title,
|
|
95
|
+
status: 'uploading',
|
|
96
|
+
...(collectionId ? {collectionId} : {}),
|
|
97
|
+
...(collectionName ? {collectionName} : {}),
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
// Step 2: Upload the actual file
|
|
102
|
+
await uploadVideo(config, created.guid, file, setUploadProgress)
|
|
103
|
+
|
|
104
|
+
// Step 3: Update status to processing
|
|
105
|
+
onChange(
|
|
106
|
+
set({
|
|
107
|
+
_type: 'bunnyVideo',
|
|
108
|
+
videoId: created.guid,
|
|
109
|
+
libraryId: String(created.videoLibraryId),
|
|
110
|
+
title,
|
|
111
|
+
status: 'processing',
|
|
112
|
+
...(collectionId ? {collectionId} : {}),
|
|
113
|
+
...(collectionName ? {collectionName} : {}),
|
|
114
|
+
})
|
|
115
|
+
)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('Upload error:', err)
|
|
118
|
+
setError(err instanceof Error ? err.message : 'Upload failed')
|
|
119
|
+
onChange(
|
|
120
|
+
set({
|
|
121
|
+
...value,
|
|
122
|
+
status: 'error',
|
|
123
|
+
errorMessage: err instanceof Error ? err.message : 'Upload failed',
|
|
124
|
+
})
|
|
125
|
+
)
|
|
126
|
+
} finally {
|
|
127
|
+
setIsUploading(false)
|
|
128
|
+
setUploadProgress(0)
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
[config, onChange, value]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const handleInputChange = useCallback(
|
|
135
|
+
(e) => {
|
|
136
|
+
const file = e.target.files?.[0]
|
|
137
|
+
if (file) {
|
|
138
|
+
handleFileSelect(file)
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[handleFileSelect]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const handleRemove = useCallback(async () => {
|
|
145
|
+
if (value?.videoId) {
|
|
146
|
+
try {
|
|
147
|
+
await deleteVideo(config, value.videoId)
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// Log but don't block removal from Sanity
|
|
150
|
+
console.error('Error deleting from Bunny:', err)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
onChange(unset())
|
|
154
|
+
}, [config, value, onChange])
|
|
155
|
+
|
|
156
|
+
// Render video preview if we have one
|
|
157
|
+
if (value?.videoId && value?.status === 'ready') {
|
|
158
|
+
return (
|
|
159
|
+
<Card padding={3} radius={2} shadow={1}>
|
|
160
|
+
<Stack space={3}>
|
|
161
|
+
<Box
|
|
162
|
+
style={{
|
|
163
|
+
position: 'relative',
|
|
164
|
+
paddingBottom: '56.25%',
|
|
165
|
+
backgroundColor: '#000',
|
|
166
|
+
borderRadius: '4px',
|
|
167
|
+
overflow: 'hidden',
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
{value.thumbnailUrl && (
|
|
171
|
+
<img
|
|
172
|
+
src={value.thumbnailUrl}
|
|
173
|
+
alt={value.title || 'Video thumbnail'}
|
|
174
|
+
style={{
|
|
175
|
+
position: 'absolute',
|
|
176
|
+
top: 0,
|
|
177
|
+
left: 0,
|
|
178
|
+
width: '100%',
|
|
179
|
+
height: '100%',
|
|
180
|
+
objectFit: 'contain',
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
</Box>
|
|
185
|
+
<Flex justify="space-between" align="center">
|
|
186
|
+
<Stack space={2}>
|
|
187
|
+
<Text size={1} weight="semibold">
|
|
188
|
+
{value.title}
|
|
189
|
+
</Text>
|
|
190
|
+
{value.duration && (
|
|
191
|
+
<Text size={1} muted>
|
|
192
|
+
{Math.floor(value.duration / 60)}:
|
|
193
|
+
{String(Math.floor(value.duration % 60)).padStart(2, '0')}
|
|
194
|
+
</Text>
|
|
195
|
+
)}
|
|
196
|
+
</Stack>
|
|
197
|
+
<Button text="Remove" tone="critical" mode="ghost" onClick={handleRemove} />
|
|
198
|
+
</Flex>
|
|
199
|
+
</Stack>
|
|
200
|
+
</Card>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Render processing state
|
|
205
|
+
if (value?.status === 'processing') {
|
|
206
|
+
return (
|
|
207
|
+
<Card padding={4} radius={2} shadow={1}>
|
|
208
|
+
<Flex direction="column" align="center" justify="center" gap={3}>
|
|
209
|
+
<Spinner />
|
|
210
|
+
<Text size={1}>Processing video...</Text>
|
|
211
|
+
<Text size={0} muted>
|
|
212
|
+
This may take a few minutes
|
|
213
|
+
</Text>
|
|
214
|
+
</Flex>
|
|
215
|
+
</Card>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Render uploading state
|
|
220
|
+
if (isUploading) {
|
|
221
|
+
return (
|
|
222
|
+
<Card padding={4} radius={2} shadow={1}>
|
|
223
|
+
<Stack space={3}>
|
|
224
|
+
<Flex direction="column" align="center" justify="center" gap={2}>
|
|
225
|
+
<Spinner />
|
|
226
|
+
<Text size={1}>Uploading... {uploadProgress}%</Text>
|
|
227
|
+
</Flex>
|
|
228
|
+
<Box
|
|
229
|
+
style={{
|
|
230
|
+
height: '4px',
|
|
231
|
+
backgroundColor: '#e5e5e5',
|
|
232
|
+
borderRadius: '2px',
|
|
233
|
+
overflow: 'hidden',
|
|
234
|
+
}}
|
|
235
|
+
>
|
|
236
|
+
<Box
|
|
237
|
+
style={{
|
|
238
|
+
height: '100%',
|
|
239
|
+
width: `${uploadProgress}%`,
|
|
240
|
+
backgroundColor: '#2563eb',
|
|
241
|
+
transition: 'width 0.3s ease',
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
</Box>
|
|
245
|
+
</Stack>
|
|
246
|
+
</Card>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Render error state
|
|
251
|
+
if (error || value?.status === 'error') {
|
|
252
|
+
return (
|
|
253
|
+
<Stack space={3}>
|
|
254
|
+
<Card padding={3} radius={2} tone="critical">
|
|
255
|
+
<Text size={1}>Error: {error || value?.errorMessage}</Text>
|
|
256
|
+
</Card>
|
|
257
|
+
<input
|
|
258
|
+
ref={fileInputRef}
|
|
259
|
+
type="file"
|
|
260
|
+
accept="video/*"
|
|
261
|
+
onChange={handleInputChange}
|
|
262
|
+
/>
|
|
263
|
+
</Stack>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Render file input
|
|
268
|
+
return (
|
|
269
|
+
<input
|
|
270
|
+
ref={fileInputRef}
|
|
271
|
+
type="file"
|
|
272
|
+
accept="video/*"
|
|
273
|
+
onChange={handleInputChange}
|
|
274
|
+
/>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {definePlugin} from 'sanity'
|
|
2
|
+
import {createBunnyVideoSchema} from './schema'
|
|
3
|
+
|
|
4
|
+
export {getThumbnailUrl, getPlaybackUrl, getMp4Url} from './api'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Sanity plugin for Bunny Stream video uploads
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```js
|
|
11
|
+
* // sanity.config.js
|
|
12
|
+
* import { bunnyInput } from 'sanity-plugin-bunny-input'
|
|
13
|
+
*
|
|
14
|
+
* export default defineConfig({
|
|
15
|
+
* plugins: [
|
|
16
|
+
* bunnyInput({
|
|
17
|
+
* libraryId: 'your-library-id',
|
|
18
|
+
* cdnHostname: 'vz-abc123-xyz.b-cdn.net', // From Bunny dashboard
|
|
19
|
+
* collectionName: 'my-project-videos', // Optional: auto-create/use collection by name
|
|
20
|
+
* // collectionId: 'existing-collection-guid', // Optional: use existing collection directly
|
|
21
|
+
* apiKey: 'your-api-key', // Or use proxyEndpoint for security
|
|
22
|
+
* })
|
|
23
|
+
* ]
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* Then use in your schemas:
|
|
28
|
+
* ```js
|
|
29
|
+
* defineField({
|
|
30
|
+
* name: 'video',
|
|
31
|
+
* title: 'Video',
|
|
32
|
+
* type: 'bunnyVideo'
|
|
33
|
+
* })
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export const bunnyInput = definePlugin((config) => {
|
|
37
|
+
if (!config.libraryId) {
|
|
38
|
+
throw new Error('sanity-plugin-bunny-input: libraryId is required')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!config.cdnHostname) {
|
|
42
|
+
throw new Error('sanity-plugin-bunny-input: cdnHostname is required (e.g., vz-abc123-xyz.b-cdn.net)')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!config.apiKey && !config.proxyEndpoint) {
|
|
46
|
+
throw new Error('sanity-plugin-bunny-input: Either apiKey or proxyEndpoint is required')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const normalizedCollectionName =
|
|
50
|
+
typeof config.collectionName === 'string' ? config.collectionName.trim() : undefined
|
|
51
|
+
|
|
52
|
+
const pluginConfig = {
|
|
53
|
+
...config,
|
|
54
|
+
...(normalizedCollectionName ? {collectionName: normalizedCollectionName} : {}),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
name: 'sanity-plugin-bunny-input',
|
|
59
|
+
schema: {
|
|
60
|
+
types: [createBunnyVideoSchema(pluginConfig)],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
})
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { defineType, defineField } from 'sanity'
|
|
2
|
+
import { BunnyInput } from './components/BunnyInput'
|
|
3
|
+
import { BunnyPreview } from './components/BunnyPreview'
|
|
4
|
+
|
|
5
|
+
export const createBunnyVideoSchema = (config) => {
|
|
6
|
+
return defineType({
|
|
7
|
+
type: 'object',
|
|
8
|
+
title: 'Bunny Video',
|
|
9
|
+
name: 'bunnyVideo',
|
|
10
|
+
fields: [
|
|
11
|
+
defineField({
|
|
12
|
+
type: 'string',
|
|
13
|
+
title: 'Video ID',
|
|
14
|
+
name: 'videoId',
|
|
15
|
+
readOnly: true,
|
|
16
|
+
}),
|
|
17
|
+
defineField({
|
|
18
|
+
type: 'string',
|
|
19
|
+
title: 'Library ID',
|
|
20
|
+
name: 'libraryId',
|
|
21
|
+
readOnly: true,
|
|
22
|
+
}),
|
|
23
|
+
defineField({
|
|
24
|
+
type: 'string',
|
|
25
|
+
title: 'Collection ID',
|
|
26
|
+
name: 'collectionId',
|
|
27
|
+
readOnly: true,
|
|
28
|
+
}),
|
|
29
|
+
defineField({
|
|
30
|
+
type: 'string',
|
|
31
|
+
title: 'Collection Name',
|
|
32
|
+
name: 'collectionName',
|
|
33
|
+
readOnly: true,
|
|
34
|
+
}),
|
|
35
|
+
defineField({
|
|
36
|
+
type: 'string',
|
|
37
|
+
title: 'Title',
|
|
38
|
+
name: 'title',
|
|
39
|
+
}),
|
|
40
|
+
defineField({
|
|
41
|
+
type: 'string',
|
|
42
|
+
title: 'Status',
|
|
43
|
+
name: 'status',
|
|
44
|
+
options: {
|
|
45
|
+
list: [
|
|
46
|
+
{title: 'Uploading', value: 'uploading'},
|
|
47
|
+
{title: 'Processing', value: 'processing'},
|
|
48
|
+
{title: 'Ready', value: 'ready'},
|
|
49
|
+
{title: 'Error', value: 'error'},
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
readOnly: true,
|
|
53
|
+
}),
|
|
54
|
+
defineField({
|
|
55
|
+
type: 'string',
|
|
56
|
+
title: 'Thumbnail URL',
|
|
57
|
+
name: 'thumbnailUrl',
|
|
58
|
+
readOnly: true,
|
|
59
|
+
}),
|
|
60
|
+
defineField({
|
|
61
|
+
type: 'string',
|
|
62
|
+
title: 'Playback URL',
|
|
63
|
+
name: 'playbackUrl',
|
|
64
|
+
readOnly: true,
|
|
65
|
+
}),
|
|
66
|
+
defineField({
|
|
67
|
+
type: 'string',
|
|
68
|
+
title: 'MP4 URL',
|
|
69
|
+
name: 'mp4Url',
|
|
70
|
+
readOnly: true,
|
|
71
|
+
}),
|
|
72
|
+
defineField({
|
|
73
|
+
type: 'number',
|
|
74
|
+
title: 'Duration (seconds)',
|
|
75
|
+
name: 'duration',
|
|
76
|
+
readOnly: true,
|
|
77
|
+
}),
|
|
78
|
+
defineField({
|
|
79
|
+
type: 'string',
|
|
80
|
+
title: 'Error Message',
|
|
81
|
+
name: 'errorMessage',
|
|
82
|
+
readOnly: true,
|
|
83
|
+
hidden: true,
|
|
84
|
+
}),
|
|
85
|
+
],
|
|
86
|
+
components: {
|
|
87
|
+
input: (props) => BunnyInput({...props, config}),
|
|
88
|
+
preview: BunnyPreview,
|
|
89
|
+
},
|
|
90
|
+
preview: {
|
|
91
|
+
select: {
|
|
92
|
+
title: 'title',
|
|
93
|
+
status: 'status',
|
|
94
|
+
thumbnailUrl: 'thumbnailUrl',
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
}
|