@fils/sanity-components 0.0.9 → 0.1.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.
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Fil's Hosted Deploy Button
3
+ * Dependencies: React, @sanity/ui, @sanity/icons, @sanity/studio-secrets
4
+ * Recommended to use inside the dashboard
5
+ * Uses studio secrets to store Github token
6
+ */
7
+
8
+ import * as React from 'react';
9
+ import { useState, useEffect, useRef } from 'react';
10
+ import { Button, Card, Stack, Text, Spinner, Flex } from '@sanity/ui';
11
+ import { PlayIcon, CheckmarkIcon, CloseIcon } from '@sanity/icons';
12
+ import { useSecrets, SettingsView } from '@sanity/studio-secrets';
13
+
14
+ interface DeployButtonConfig {
15
+ owner: string;
16
+ repo: string;
17
+ token?: string;
18
+ branch?: string;
19
+ eventType?: string;
20
+ }
21
+
22
+ interface DeployButtonProps {
23
+ config: DeployButtonConfig;
24
+ title?: string;
25
+ deploymentUrl?: string;
26
+ environment?: string;
27
+ secretsNamespace?: string;
28
+ secretKey?: string;
29
+ }
30
+
31
+ type DeployState = 'idle' | 'triggering' | 'deploying' | 'success' | 'error';
32
+
33
+ interface WorkflowRun {
34
+ id: number;
35
+ status: string;
36
+ conclusion: string | null;
37
+ created_at: string;
38
+ run_started_at?: string;
39
+ event: string;
40
+ }
41
+
42
+ interface WorkflowRunsResponse {
43
+ workflow_runs: WorkflowRun[];
44
+ }
45
+
46
+ const DeployButton = ({
47
+ config,
48
+ title = "🚀 Site Deployment",
49
+ deploymentUrl,
50
+ environment = "production",
51
+ secretsNamespace = "deployButton",
52
+ secretKey = "github_token"
53
+ }: DeployButtonProps) => {
54
+ // ... rest of code
55
+ // ALL HOOKS MUST BE CALLED FIRST - NEVER CONDITIONALLY
56
+ const [deployState, setDeployState] = useState<DeployState>('idle');
57
+ const [message, setMessage] = useState<string>('');
58
+ const [showSettings, setShowSettings] = useState<boolean>(false);
59
+ const intervalRef = useRef<NodeJS.Timeout | null>(null);
60
+ const monitoringRef = useRef<boolean>(false);
61
+
62
+ // Use Sanity secrets - ALWAYS call this hook
63
+ const { secrets } = useSecrets(secretsNamespace);
64
+ const token = (secrets as Record<string, string | undefined>)?.[secretKey];
65
+
66
+ // Get the effective token (secrets take priority over config)
67
+ const effectiveToken = token || config?.token;
68
+
69
+ // Only show settings if we don't have a token AND we have valid config
70
+ useEffect(() => {
71
+ if (!effectiveToken && config?.owner && config?.repo && !showSettings) {
72
+ setShowSettings(true);
73
+ } else if (effectiveToken && showSettings) {
74
+ // Close settings if we now have a token
75
+ setShowSettings(false);
76
+ }
77
+ }, [effectiveToken, config?.owner, config?.repo, showSettings]);
78
+
79
+ // Clean up on unmount
80
+ useEffect(() => {
81
+ return () => {
82
+ if (intervalRef.current) {
83
+ clearInterval(intervalRef.current);
84
+ intervalRef.current = null;
85
+ }
86
+ };
87
+ }, []);
88
+
89
+ // Plugin config for secrets
90
+ const pluginConfigKeys = [
91
+ {
92
+ key: secretKey,
93
+ title: `GitHub Token for ${environment}`,
94
+ description: `Personal access token for ${config?.owner}/${config?.repo} deployment`,
95
+ type: 'string' as const,
96
+ inputType: 'password' as const
97
+ }
98
+ ];
99
+
100
+ // Poll workflow status
101
+ const pollWorkflowStatus = async (runId: number): Promise<void> => {
102
+ try {
103
+ console.log('Polling workflow:', runId);
104
+
105
+ const response = await fetch(
106
+ `https://api.github.com/repos/${config.owner}/${config.repo}/actions/runs/${runId}`,
107
+ {
108
+ headers: {
109
+ 'Authorization': `token ${effectiveToken}`,
110
+ 'Accept': 'application/vnd.github.v3+json'
111
+ }
112
+ }
113
+ );
114
+
115
+ if (!response.ok) {
116
+ throw new Error(`HTTP ${response.status}`);
117
+ }
118
+
119
+ const run: WorkflowRun = await response.json();
120
+ console.log('Run status:', run.status, 'conclusion:', run.conclusion);
121
+
122
+ if (run.status === 'completed') {
123
+ // Stop polling
124
+ if (intervalRef.current) {
125
+ clearInterval(intervalRef.current);
126
+ intervalRef.current = null;
127
+ }
128
+ monitoringRef.current = false;
129
+
130
+ if (run.conclusion === 'success') {
131
+ setDeployState('success');
132
+ setMessage('🎉 Deployment completed successfully!');
133
+ } else {
134
+ setDeployState('error');
135
+ setMessage(`❌ Deployment failed: ${run.conclusion}`);
136
+ }
137
+ } else if (run.status === 'in_progress' || run.status === 'queued') {
138
+ const startTime = new Date(run.run_started_at || run.created_at);
139
+ const elapsed = Math.floor((Date.now() - startTime.getTime()) / 1000);
140
+ setDeployState('deploying');
141
+ setMessage(`🔄 Deployment running... (${elapsed}s)`);
142
+ }
143
+ } catch (error) {
144
+ console.error('Polling error:', error);
145
+ }
146
+ };
147
+
148
+ // Find the workflow run
149
+ const findWorkflowRun = async (): Promise<void> => {
150
+ try {
151
+ console.log('Looking for workflow run...');
152
+
153
+ const response = await fetch(
154
+ `https://api.github.com/repos/${config.owner}/${config.repo}/actions/runs?per_page=10`,
155
+ {
156
+ headers: {
157
+ 'Authorization': `token ${effectiveToken}`,
158
+ 'Accept': 'application/vnd.github.v3+json'
159
+ }
160
+ }
161
+ );
162
+
163
+ if (!response.ok) {
164
+ throw new Error(`HTTP ${response.status}`);
165
+ }
166
+
167
+ const data: WorkflowRunsResponse = await response.json();
168
+ console.log('Found runs:', data.workflow_runs.length);
169
+
170
+ // Find the most recent run (within last 90 seconds)
171
+ const recentRun = data.workflow_runs.find(run => {
172
+ const ageInSeconds = (Date.now() - new Date(run.created_at).getTime()) / 1000;
173
+ console.log(`Run ${run.id}: age ${ageInSeconds}s, event: ${run.event}, status: ${run.status}`);
174
+ return ageInSeconds < 90;
175
+ });
176
+
177
+ if (recentRun) {
178
+ console.log('Found recent run:', recentRun.id, 'Event:', recentRun.event);
179
+ setDeployState('deploying');
180
+ setMessage('🚀 Deployment started...');
181
+
182
+ // Start polling every 5 seconds
183
+ intervalRef.current = setInterval(() => {
184
+ pollWorkflowStatus(recentRun.id);
185
+ }, 5000);
186
+
187
+ // Initial poll
188
+ pollWorkflowStatus(recentRun.id);
189
+ } else {
190
+ console.log('No recent workflow found');
191
+ setDeployState('error');
192
+ setMessage('❌ Could not find workflow run - check if workflow exists');
193
+ }
194
+ } catch (error) {
195
+ console.error('Error finding workflow:', error);
196
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
197
+ setDeployState('error');
198
+ setMessage(`❌ Error finding workflow: ${errorMessage}`);
199
+ }
200
+ };
201
+
202
+ const triggerDeploy = async (): Promise<void> => {
203
+ // Prevent multiple deployments
204
+ if (deployState !== 'idle' && deployState !== 'success' && deployState !== 'error') return;
205
+
206
+ setDeployState('triggering');
207
+ setMessage('⚡ Triggering deployment...');
208
+
209
+ try {
210
+ console.log('Triggering deployment...');
211
+
212
+ const response = await fetch(
213
+ `https://api.github.com/repos/${config.owner}/${config.repo}/dispatches`,
214
+ {
215
+ method: 'POST',
216
+ headers: {
217
+ 'Authorization': `token ${effectiveToken}`,
218
+ 'Accept': 'application/vnd.github.v3+json',
219
+ 'Content-Type': 'application/json'
220
+ },
221
+ body: JSON.stringify({
222
+ event_type: config.eventType || 'deploy-site',
223
+ client_payload: {
224
+ environment: environment,
225
+ triggered_by: 'sanity_studio',
226
+ timestamp: new Date().toISOString()
227
+ }
228
+ })
229
+ }
230
+ );
231
+
232
+ if (response.ok) {
233
+ console.log('Deployment triggered successfully');
234
+ setMessage('✅ Deployment triggered! Looking for workflow...');
235
+
236
+ // Wait 5 seconds then look for the workflow
237
+ setTimeout(() => {
238
+ if (!monitoringRef.current) {
239
+ monitoringRef.current = true;
240
+ findWorkflowRun();
241
+ }
242
+ }, 5000);
243
+
244
+ } else {
245
+ const error = await response.json();
246
+ throw new Error(error.message || `HTTP ${response.status}`);
247
+ }
248
+
249
+ } catch (error) {
250
+ console.error('Deploy trigger failed:', error);
251
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
252
+ setDeployState('error');
253
+ setMessage(`❌ Failed to trigger: ${errorMessage}`);
254
+ }
255
+ };
256
+
257
+ type IconComponent = React.ComponentType<any>;
258
+
259
+ const getButtonProps = (): {
260
+ tone: 'primary' | 'positive' | 'critical';
261
+ disabled: boolean;
262
+ icon: IconComponent;
263
+ text: string;
264
+ } => {
265
+ switch (deployState) {
266
+ case 'triggering':
267
+ return { tone: 'primary', disabled: true, icon: Spinner, text: 'Triggering...' };
268
+ case 'deploying':
269
+ return { tone: 'primary', disabled: true, icon: Spinner, text: 'Deploying...' };
270
+ case 'success':
271
+ return { tone: 'positive', disabled: false, icon: CheckmarkIcon, text: 'Deploy Again' };
272
+ case 'error':
273
+ return { tone: 'critical', disabled: false, icon: CloseIcon, text: 'Try Again' };
274
+ default:
275
+ return { tone: 'primary', disabled: false, icon: PlayIcon, text: 'Deploy Site' };
276
+ }
277
+ };
278
+
279
+ // NOW WE CAN HANDLE CONDITIONAL RENDERING AFTER ALL HOOKS
280
+
281
+ // Check for missing config
282
+ if (!config || !config.owner || !config.repo) {
283
+ return (
284
+ <Card padding={4} radius={2} shadow={1} tone="critical">
285
+ <Stack space={3}>
286
+ <Text size={2} weight="semibold">
287
+ ❌ Deploy Button Configuration Error
288
+ </Text>
289
+ <Text size={1}>
290
+ Missing required config: owner and repo are required
291
+ </Text>
292
+ </Stack>
293
+ </Card>
294
+ );
295
+ }
296
+
297
+ // If showing settings, render the settings view
298
+ if (showSettings && !effectiveToken) {
299
+ return (
300
+ <Card padding={4} radius={2} shadow={1}>
301
+ <Stack space={3}>
302
+ <Text size={2} weight="semibold">
303
+ 🔐 {title} - Setup Required
304
+ </Text>
305
+ <Text size={1} muted>
306
+ Please configure your GitHub token to enable deployments.
307
+ </Text>
308
+ <SettingsView
309
+ title={`${title} Configuration`}
310
+ namespace={secretsNamespace}
311
+ keys={pluginConfigKeys}
312
+ onClose={() => {
313
+ setShowSettings(false);
314
+ }}
315
+ />
316
+ </Stack>
317
+ </Card>
318
+ );
319
+ }
320
+
321
+ // Check for missing token after settings
322
+ if (!effectiveToken) {
323
+ return (
324
+ <Card padding={4} radius={2} shadow={1} tone="critical">
325
+ <Stack space={3}>
326
+ <Text size={2} weight="semibold">
327
+ ❌ Authentication Required
328
+ </Text>
329
+ <Text size={1}>
330
+ No GitHub token configured.
331
+ </Text>
332
+ <Button
333
+ tone="primary"
334
+ mode="ghost"
335
+ size={1}
336
+ onClick={() => setShowSettings(true)}
337
+ >
338
+ Configure Token
339
+ </Button>
340
+ </Stack>
341
+ </Card>
342
+ );
343
+ }
344
+
345
+ const buttonProps = getButtonProps();
346
+
347
+ return (
348
+ <Card padding={4} radius={2} shadow={1}>
349
+ <Stack space={3}>
350
+ <Text size={2} weight="semibold">
351
+ {title}
352
+ </Text>
353
+
354
+ <Flex justify="flex-start">
355
+ <Button
356
+ tone={buttonProps.tone}
357
+ disabled={buttonProps.disabled}
358
+ onClick={triggerDeploy}
359
+ size={3}
360
+ style={{
361
+ width: '100%',
362
+ maxWidth: '360px',
363
+ }}
364
+ >
365
+ <Flex
366
+ align="center"
367
+ justify="center"
368
+ style={{
369
+ gap: '6px',
370
+ padding: '16px 0px 16px 0px'
371
+ }}
372
+ >
373
+ {buttonProps.icon && (
374
+ <span style={{
375
+ display: 'flex',
376
+ alignItems: 'center',
377
+ fontSize: '16px',
378
+ lineHeight: '1'
379
+ }}>
380
+ <buttonProps.icon />
381
+ </span>
382
+ )}
383
+ <Text size={2} weight="medium" style={{ lineHeight: '1' }}>
384
+ {buttonProps.text}
385
+ </Text>
386
+ </Flex>
387
+ </Button>
388
+ </Flex>
389
+
390
+ {message && (
391
+ <Card
392
+ padding={3}
393
+ radius={1}
394
+ tone={deployState === 'error' ? 'critical' : deployState === 'success' ? 'positive' : 'primary'}
395
+ >
396
+ <Text size={1}>
397
+ {message}
398
+ </Text>
399
+ </Card>
400
+ )}
401
+
402
+ {deploymentUrl && deployState === 'success' && (
403
+ <Button
404
+ as="a"
405
+ href={deploymentUrl}
406
+ target="_blank"
407
+ rel="noopener noreferrer"
408
+ tone="primary"
409
+ mode="ghost"
410
+ size={3}
411
+ >
412
+ 🌐 View Live Site
413
+ </Button>
414
+ )}
415
+
416
+ <Text size={1} muted>
417
+ Deploys from {config.branch || 'main'} branch to {environment}
418
+ </Text>
419
+ </Stack>
420
+ </Card>
421
+ );
422
+ };
423
+
424
+ export default DeployButton;
@@ -0,0 +1,26 @@
1
+ import { FileInputProps, ObjectInputProps } from 'sanity';
2
+ import { VideoAndThumb } from './VideoAndThumb';
3
+
4
+ export interface VideoInputOptions {
5
+ /** Enable thumbnail generation feature */
6
+ enableThumbnailGeneration?: boolean;
7
+ /** Support URL input instead of file upload */
8
+ inputType?: 'file' | 'url';
9
+ }
10
+
11
+ export function createVideoInput(options: VideoInputOptions = {}) {
12
+ const {
13
+ enableThumbnailGeneration = true,
14
+ inputType = 'file'
15
+ } = options;
16
+
17
+ return function VideoInput(props: FileInputProps | ObjectInputProps) {
18
+ return (
19
+ <VideoAndThumb
20
+ props={props as ObjectInputProps}
21
+ isURL={inputType === 'url'}
22
+ enableThumbnailGeneration={enableThumbnailGeneration}
23
+ />
24
+ );
25
+ };
26
+ }
@@ -0,0 +1,187 @@
1
+ import { ChevronDownIcon, ChevronRightIcon, DropIcon, WarningOutlineIcon } from '@sanity/icons';
2
+ import { Button, Card, Flex, Stack, Text } from '@sanity/ui';
3
+ import { useRef, useState } from 'react';
4
+ import { ObjectInputProps, set, useClient } from 'sanity';
5
+ import { buildFileUrl, getFile } from '@sanity/asset-utils';
6
+
7
+ export interface VideoAndThumbProperties {
8
+ props: ObjectInputProps;
9
+ isURL: boolean;
10
+ enableThumbnailGeneration?: boolean;
11
+ }
12
+
13
+ export function VideoAndThumb(params: VideoAndThumbProperties) {
14
+ const { props, isURL, enableThumbnailGeneration = true } = params;
15
+ const client = useClient({ apiVersion: '2024-01-01' });
16
+
17
+ // Get config from the client
18
+ const sanityConfig = {
19
+ projectId: client.config().projectId!,
20
+ dataset: client.config().dataset!
21
+ };
22
+
23
+ const [isOpen, setIsOpen] = useState(false);
24
+ const videoRef = useRef<HTMLVideoElement>(null);
25
+ const [warning, setWarning] = useState<string | null>(null);
26
+
27
+ let url;
28
+ const videoMsg = isURL ? 'video URL' : 'video file';
29
+
30
+ if (isURL) {
31
+ //@ts-ignore
32
+ url = props.value ? props.value.video : "";
33
+ } else {
34
+ // is file
35
+ const file = props.value && props.value.video && props.value.video.asset
36
+ ? getFile(props.value.video.asset, sanityConfig)
37
+ : null;
38
+ url = file ? buildFileUrl(file.asset, sanityConfig) : "";
39
+ }
40
+
41
+ // Get all field members
42
+ const fieldMembers = props.members.filter(member => member.kind === 'field');
43
+
44
+ // URL/File Field
45
+ const videoField = fieldMembers.filter(member => member.name === 'video');
46
+
47
+ // Thumbnail Image Field
48
+ const thumb = fieldMembers.filter(member => member.name === 'image');
49
+
50
+ const generateFrame = async (video: HTMLVideoElement) => {
51
+ // Clear any existing warnings
52
+ setWarning(null);
53
+
54
+ if (video.videoWidth && video.videoHeight) {
55
+ try {
56
+ const can = document.createElement('canvas');
57
+ can.width = video.videoWidth;
58
+ can.height = video.videoHeight;
59
+ const ctx = can.getContext('2d');
60
+ ctx?.drawImage(video, 0, 0);
61
+
62
+ can.toBlob(async blob => {
63
+ if (!blob) {
64
+ setWarning('Failed to generate image from video frame');
65
+ return;
66
+ }
67
+ try {
68
+ // Use the studio's authenticated client
69
+ const asset = await client.assets.upload('image', blob, {
70
+ filename: `video-frame-${Date.now()}.png`,
71
+ title: 'Generated from video frame'
72
+ });
73
+
74
+ // Create image reference
75
+ const imageReference = {
76
+ _type: 'image',
77
+ asset: {
78
+ _type: 'reference',
79
+ _ref: asset._id
80
+ }
81
+ };
82
+
83
+ // Update your object with the new image
84
+ props.onChange([
85
+ set(imageReference, ['image'])
86
+ ]);
87
+
88
+ } catch (uploadError) {
89
+ console.error('Failed to upload image:', uploadError);
90
+ setWarning('Failed to upload generated frame to Sanity');
91
+ }
92
+ }, "image/png");
93
+ } catch (error) {
94
+ console.error('Error generating frame:', error);
95
+ setWarning('Failed to generate frame from video');
96
+ }
97
+ } else {
98
+ setWarning(`Video has no image data. Please make sure a ${videoMsg} is properly set and preview image is visible.`);
99
+ }
100
+ };
101
+
102
+ const handleGenerateFromFrame = () => {
103
+ if (videoRef.current) {
104
+ generateFrame(videoRef.current);
105
+ } else {
106
+ setWarning(`Video element not found. Please make sure a ${videoMsg} is set and video preview unfolded.`);
107
+ }
108
+ };
109
+
110
+ return (
111
+ <Stack space={1}>
112
+ <style>{` video { width: 100%; } `}</style>
113
+
114
+ {/* Video field */}
115
+ <>{props.renderDefault({
116
+ ...props,
117
+ members: videoField
118
+ })}</>
119
+
120
+ {/* Video preview */}
121
+ {url && (
122
+ <Card>
123
+ <Stack space={2}>
124
+ <Button
125
+ mode="bleed"
126
+ justify="flex-start"
127
+ onClick={() => setIsOpen(!isOpen)}
128
+ padding={2}
129
+ >
130
+ <Flex align="center" gap={2}>
131
+ {isOpen ? <ChevronDownIcon /> : <ChevronRightIcon />}
132
+ <Text size={1} weight="medium">
133
+ {isOpen ? 'Hide Preview' : 'Show Preview'}
134
+ </Text>
135
+ </Flex>
136
+ </Button>
137
+
138
+ {isOpen && (
139
+ <Card padding={2} border>
140
+ <video
141
+ ref={videoRef}
142
+ src={url}
143
+ muted
144
+ loop
145
+ autoPlay
146
+ controls
147
+ crossOrigin="anonymous"
148
+ />
149
+ </Card>
150
+ )}
151
+ </Stack>
152
+ </Card>
153
+ )}
154
+
155
+ {/* Thumbnail field */}
156
+ <>{props.renderDefault({
157
+ ...props,
158
+ members: thumb
159
+ })}</>
160
+
161
+ {/* Generate button */}
162
+ {enableThumbnailGeneration && (
163
+ <Button
164
+ fontSize={[2, 2, 3]}
165
+ icon={DropIcon}
166
+ mode="ghost"
167
+ tone="positive"
168
+ text="Generate from Video Frame"
169
+ onClick={handleGenerateFromFrame}
170
+ />
171
+ )}
172
+
173
+ {/* Warning display */}
174
+ {warning && (
175
+ <Card padding={3} tone="caution" border>
176
+ <Stack space={2}>
177
+ <Text size={1} weight="medium">
178
+ <WarningOutlineIcon style={{ marginRight: '8px' }} />
179
+ Warning
180
+ </Text>
181
+ <Text size={1}>{warning}</Text>
182
+ </Stack>
183
+ </Card>
184
+ )}
185
+ </Stack>
186
+ );
187
+ }