@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 CHANGED
@@ -1,13 +1,11 @@
1
1
  {
2
2
  "name": "@cliff-studio/sanity-plugin-bunny-input",
3
- "version": "1.0.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
+ }
@@ -0,0 +1,2 @@
1
+ export {BunnyInput} from './BunnyInput'
2
+ export {BunnyPreview} from './BunnyPreview'
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
+ }