@liiift-studio/deploy-vercel-from-sanity 0.1.3 → 0.1.6

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.
@@ -1,13 +1,15 @@
1
- // Per-deploy-target card — shows status, build timer, history, cancel, and deploy
1
+ // Per-deploy-target card — shows status, build timer, history, cancel, deploy, copy URL, and error logs
2
2
  import { useState, useEffect, useCallback, useRef } from 'react'
3
3
  import {
4
- Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner, MenuButton, Menu, MenuItem,
4
+ Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner,
5
+ MenuButton, Menu, MenuItem, Code,
5
6
  } from '@sanity/ui'
6
7
  import {
7
8
  RocketIcon, ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
9
+ CopyIcon, CheckmarkIcon, WarningOutlineIcon,
8
10
  } from '@sanity/icons'
9
- import { listDeployments, cancelDeployment, triggerDeploy } from '../lib/api'
10
- import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref } from '../lib/helpers'
11
+ import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
12
+ import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref } from '../lib/helpers'
11
13
  import { StatusBadge } from './StatusBadge'
12
14
  import { DeployHistory } from './DeployHistory'
13
15
  import type { DeployTarget, VercelDeployment } from '../types'
@@ -23,15 +25,24 @@ interface DeployItemProps {
23
25
  export function DeployItem({ target, token, onDelete }: DeployItemProps) {
24
26
  const { projectId, hookId } = parseHookUrl(target.url)
25
27
 
26
- const [deployments, setDeployments] = useState<VercelDeployment[]>([])
28
+ const [deployments, setDeployments] = useState<VercelDeployment[]>([])
27
29
  const [loadingInitial, setLoadingInitial] = useState(true)
28
- const [triggering, setTriggering] = useState(false) // hook fired, waiting for deploy to appear
29
- const [canceling, setCanceling] = useState(false)
30
- const [deployError, setDeployError] = useState<string | null>(null)
31
- const [showHistory, setShowHistory] = useState(false)
32
- const [elapsed, setElapsed] = useState(0) // build timer in seconds
30
+ const [triggering, setTriggering] = useState(false)
31
+ const [canceling, setCanceling] = useState(false)
32
+ const [deployError, setDeployError] = useState<string | null>(null)
33
+ const [showHistory, setShowHistory] = useState(false)
34
+ const [elapsed, setElapsed] = useState(0)
33
35
 
34
- const latest = deployments[0]
36
+ // Copy URL state
37
+ const [copied, setCopied] = useState(false)
38
+
39
+ // Error log expansion state
40
+ const [showErrorLogs, setShowErrorLogs] = useState(false)
41
+ const [errorLines, setErrorLines] = useState<string[]>([])
42
+ const [loadingLogs, setLoadingLogs] = useState(false)
43
+ const [logError, setLogError] = useState<string | null>(null)
44
+
45
+ const latest = deployments[0]
35
46
  const isActive = triggering || isActiveState(latest?.state)
36
47
 
37
48
  // ── Fetch deployments ──────────────────────────────────────────────────────
@@ -45,25 +56,27 @@ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
45
56
  }
46
57
  }, [projectId, hookId, token, target.teamId])
47
58
 
48
- // Initial fetch
49
59
  useEffect(() => {
50
60
  fetchDeployments().finally(() => setLoadingInitial(false))
51
61
  }, [fetchDeployments])
52
62
 
53
- // Polling while active
54
63
  useEffect(() => {
55
64
  if (!isActive) return
56
65
  const id = setInterval(fetchDeployments, POLL_INTERVAL_MS)
57
66
  return () => clearInterval(id)
58
67
  }, [isActive, fetchDeployments])
59
68
 
60
- // Stop triggering once a deployment appears in an active state
61
69
  useEffect(() => {
62
- if (triggering && latest && latest.state !== undefined) {
63
- setTriggering(false)
64
- }
70
+ if (triggering && latest && latest.state !== undefined) setTriggering(false)
65
71
  }, [triggering, latest])
66
72
 
73
+ // Reset error logs when the deployment changes
74
+ useEffect(() => {
75
+ setShowErrorLogs(false)
76
+ setErrorLines([])
77
+ setLogError(null)
78
+ }, [latest?.uid])
79
+
67
80
  // ── Build timer ───────────────────────────────────────────────────────────
68
81
  const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
69
82
  useEffect(() => {
@@ -77,9 +90,7 @@ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
77
90
  setElapsed(0)
78
91
  if (timerRef.current) clearInterval(timerRef.current)
79
92
  }
80
- return () => {
81
- if (timerRef.current) clearInterval(timerRef.current)
82
- }
93
+ return () => { if (timerRef.current) clearInterval(timerRef.current) }
83
94
  }, [isActive, latest?.created])
84
95
 
85
96
  // ── Actions ───────────────────────────────────────────────────────────────
@@ -88,7 +99,6 @@ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
88
99
  setTriggering(true)
89
100
  try {
90
101
  await triggerDeploy(target.url)
91
- // Poll immediately to pick up the new deployment faster
92
102
  setTimeout(fetchDeployments, 2000)
93
103
  } catch (err) {
94
104
  setTriggering(false)
@@ -109,161 +119,329 @@ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
109
119
  }
110
120
  }, [latest?.uid, token, target.teamId, fetchDeployments])
111
121
 
122
+ const copyUrl = useCallback(() => {
123
+ if (!latest?.url) return
124
+ navigator.clipboard.writeText(`https://${latest.url}`).then(() => {
125
+ setCopied(true)
126
+ setTimeout(() => setCopied(false), 2000)
127
+ }).catch(err => {
128
+ console.error('deploy-vercel-from-sanity: clipboard error', err)
129
+ })
130
+ }, [latest?.url])
131
+
132
+ const fetchErrorLogs = useCallback(async () => {
133
+ if (!latest?.uid) return
134
+ setLoadingLogs(true)
135
+ setLogError(null)
136
+ try {
137
+ const events = await getDeploymentEvents({
138
+ deploymentId: latest.uid,
139
+ token,
140
+ teamId: target.teamId,
141
+ })
142
+ // Keep stderr lines and stdout lines that look like errors, in forward order
143
+ const lines = events
144
+ .filter(e => e.type === 'stderr' || e.type === 'stdout')
145
+ .map(e => e.text ?? '')
146
+ .filter(Boolean)
147
+ .reverse() // API returns backward — flip to chronological
148
+ .slice(-30) // last 30 lines
149
+ setErrorLines(lines.length > 0 ? lines : ['No log output captured.'])
150
+ } catch (err) {
151
+ setLogError(err instanceof Error ? err.message : 'Failed to load build logs')
152
+ } finally {
153
+ setLoadingLogs(false)
154
+ }
155
+ }, [latest?.uid, token, target.teamId])
156
+
157
+ const toggleErrorLogs = useCallback(() => {
158
+ if (!showErrorLogs && errorLines.length === 0 && !logError) {
159
+ fetchErrorLogs()
160
+ }
161
+ setShowErrorLogs(v => !v)
162
+ }, [showErrorLogs, errorLines.length, logError, fetchErrorLogs])
163
+
112
164
  // ── Derived display values ────────────────────────────────────────────────
113
- const branch = latest?.meta?.githubCommitRef
165
+ const branch = latest?.meta?.githubCommitRef
114
166
  const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
115
- const sha = shortSha(latest?.meta?.githubCommitSha)
116
- const creator = latest?.creator?.username
167
+ const sha = shortSha(latest?.meta?.githubCommitSha)
168
+ const creator = latest?.creator?.username
117
169
  const deployedAt = latest?.created ? timeAgo(latest.created) : null
170
+ const vercelProjectUrl = projectHref(latest?.inspectorUrl)
171
+ const isError = latest?.state === 'ERROR'
118
172
 
119
173
  return (
120
174
  <>
121
175
  <Card padding={4} radius={2} shadow={1} tone="default">
122
- <Stack space={4}>
123
- {/* ── Header ──────────────────────────────────────────────── */}
124
- <Flex align="flex-start" justify="space-between" gap={3}>
125
- <Stack space={2} flex={1}>
176
+ <Flex gap={4} align="center">
177
+
178
+ {/* ── Left: info column ──────────────────────────────────── */}
179
+ <Stack space={2} flex={1} style={{ minWidth: 0 }}>
180
+
181
+ {/* Name + menu */}
182
+ <Flex align="center" justify="space-between" gap={2}>
126
183
  <Text size={2} weight="semibold">{target.name}</Text>
127
- <Flex gap={2} wrap="wrap">
128
- <Text size={0} muted>
129
- {projectId ? `${projectId.slice(0, 18)}…` : '—'}
130
- </Text>
131
- <Text size={0} muted>·</Text>
132
- <Text size={0} muted>Hook: {hookId || '—'}</Text>
133
- {target.teamId && (
134
- <>
135
- <Text size={0} muted>·</Text>
136
- <Text size={0} muted>Team: {target.teamId}</Text>
137
- </>
138
- )}
139
- </Flex>
140
- </Stack>
141
-
142
- {/* Action menu */}
143
- <MenuButton
144
- button={
145
- <Button mode="ghost" icon={EllipsisVerticalIcon} />
146
- }
147
- id={`menu-${target._id}`}
148
- menu={
149
- <Menu>
150
- <MenuItem
151
- text="History"
152
- icon={ClockIcon}
153
- onClick={() => setShowHistory(true)}
154
- />
155
- {safeHref(latest?.inspectorUrl) && (
184
+ <MenuButton
185
+ button={<Button mode="ghost" icon={EllipsisVerticalIcon} padding={2} />}
186
+ id={`menu-${target._id}`}
187
+ menu={
188
+ <Menu>
156
189
  <MenuItem
157
- text="Build logs"
158
- icon={LaunchIcon}
159
- as="a"
160
- href={safeHref(latest?.inspectorUrl)}
161
- target="_blank"
162
- rel="noreferrer"
190
+ text="History"
191
+ icon={ClockIcon}
192
+ onClick={() => setShowHistory(true)}
163
193
  />
164
- )}
165
- {!target.disableDeleteAction && (
166
- <MenuItem
167
- text="Delete"
168
- icon={TrashIcon}
169
- tone="critical"
170
- onClick={() => onDelete(target)}
171
- />
172
- )}
173
- </Menu>
174
- }
175
- popover={{ placement: 'bottom-end' }}
176
- />
177
- </Flex>
194
+ {safeHref(latest?.inspectorUrl) && (
195
+ <MenuItem
196
+ text="Build logs"
197
+ icon={LaunchIcon}
198
+ as="a"
199
+ href={safeHref(latest?.inspectorUrl)}
200
+ target="_blank"
201
+ rel="noreferrer"
202
+ />
203
+ )}
204
+ {vercelProjectUrl && (
205
+ <MenuItem
206
+ text="Open in Vercel"
207
+ icon={LaunchIcon}
208
+ as="a"
209
+ href={vercelProjectUrl}
210
+ target="_blank"
211
+ rel="noreferrer"
212
+ />
213
+ )}
214
+ {!target.disableDeleteAction && (
215
+ <MenuItem
216
+ text="Delete"
217
+ icon={TrashIcon}
218
+ tone="critical"
219
+ onClick={() => onDelete(target)}
220
+ />
221
+ )}
222
+ </Menu>
223
+ }
224
+ popover={{ placement: 'bottom-end' }}
225
+ />
226
+ </Flex>
178
227
 
179
- {/* ── Status row ──────────────────────────────────────────── */}
180
- {loadingInitial ? (
181
- <Flex align="center" gap={2}>
182
- <Spinner muted />
183
- <Text size={1} muted>Loading…</Text>
228
+ {/* Subtitle: project + hook IDs */}
229
+ <Flex gap={2} wrap="wrap">
230
+ <Text size={0} muted>
231
+ {projectId ? `${projectId.slice(0, 18)}…` : '—'}
232
+ </Text>
233
+ <Text size={0} muted>·</Text>
234
+ <Text size={0} muted>Hook: {hookId || '—'}</Text>
235
+ {target.teamId && (
236
+ <>
237
+ <Text size={0} muted>·</Text>
238
+ <Text size={0} muted>Team: {target.teamId}</Text>
239
+ </>
240
+ )}
184
241
  </Flex>
185
- ) : (
186
- <Stack space={3}>
187
- <Flex align="center" gap={3} wrap="wrap">
188
- {/* Status badge */}
189
- {triggering ? (
190
- <Flex align="center" gap={2}>
191
- <Spinner muted />
192
- <Badge tone="caution" mode="outline">Triggering…</Badge>
193
- </Flex>
194
- ) : (
195
- <StatusBadge state={latest?.state} showSpinner />
196
- )}
197
242
 
198
- {/* Build timer */}
199
- {isActive && elapsed > 0 && (
200
- <Flex align="center" gap={1}>
201
- <ClockIcon />
202
- <Text size={1} muted>{formatDuration(elapsed)}</Text>
203
- </Flex>
204
- )}
243
+ {/* Status + metadata */}
244
+ {loadingInitial ? (
245
+ <Flex align="center" gap={2}>
246
+ <Spinner muted />
247
+ <Text size={1} muted>Loading…</Text>
248
+ </Flex>
249
+ ) : (
250
+ <Stack space={2}>
251
+ <Flex align="center" gap={2} wrap="wrap">
252
+ {/* Status */}
253
+ {triggering ? (
254
+ <Flex align="center" gap={2}>
255
+ <Spinner muted />
256
+ <Badge tone="caution" mode="outline">Triggering…</Badge>
257
+ </Flex>
258
+ ) : (
259
+ <StatusBadge state={latest?.state} showSpinner />
260
+ )}
205
261
 
206
- {/* Time since deploy */}
207
- {!isActive && deployedAt && (
208
- <Text size={1} muted>{deployedAt}</Text>
209
- )}
262
+ {/* Build timer (active) or time since deploy (idle) */}
263
+ {isActive && elapsed > 0 ? (
264
+ <Flex align="center" gap={1}>
265
+ <ClockIcon />
266
+ <Text size={1} muted>{formatDuration(elapsed)}</Text>
267
+ </Flex>
268
+ ) : (!isActive && deployedAt) ? (
269
+ <Text size={1} muted>{deployedAt}</Text>
270
+ ) : null}
210
271
 
211
- {/* Branch */}
212
- {branch && (
213
- <>
214
- <Text size={1} muted>·</Text>
215
- <Badge tone="default" mode="outline">{branch}</Badge>
216
- </>
217
- )}
272
+ {/* Branch */}
273
+ {branch && (
274
+ <>
275
+ <Text size={1} muted>·</Text>
276
+ <Badge tone="default" mode="outline">{branch}</Badge>
277
+ </>
278
+ )}
279
+
280
+ {/* Commit SHA — tooltip shows full message */}
281
+ {sha && (
282
+ <Tooltip
283
+ content={
284
+ <Box padding={2}>
285
+ <Text size={1}>{commitMsg ?? sha}</Text>
286
+ </Box>
287
+ }
288
+ portal
289
+ >
290
+ <Text
291
+ size={1}
292
+ muted
293
+ style={{ cursor: 'default', fontFamily: 'monospace' }}
294
+ >
295
+ {sha}
296
+ </Text>
297
+ </Tooltip>
298
+ )}
218
299
 
219
- {/* Commit */}
220
- {sha && (
221
- <Tooltip
222
- content={
223
- <Box padding={2}>
224
- <Text size={1}>{commitMsg ?? sha}</Text>
225
- </Box>
226
- }
227
- portal
300
+ {/* Creator */}
301
+ {creator && <Text size={1} muted>by {creator}</Text>}
302
+
303
+ {/* Preview link + copy URL */}
304
+ {latest?.url && latest.state === 'READY' && (
305
+ <>
306
+ <a
307
+ href={`https://${latest.url}`}
308
+ target="_blank"
309
+ rel="noreferrer"
310
+ style={{ color: 'inherit' }}
311
+ >
312
+ <Flex align="center" gap={1}>
313
+ <LaunchIcon />
314
+ <Text size={1}>Preview</Text>
315
+ </Flex>
316
+ </a>
317
+ <Tooltip
318
+ content={
319
+ <Box padding={2}>
320
+ <Text size={1}>{copied ? 'Copied!' : 'Copy URL'}</Text>
321
+ </Box>
322
+ }
323
+ portal
324
+ >
325
+ <Button
326
+ mode="ghost"
327
+ icon={copied ? CheckmarkIcon : CopyIcon}
328
+ padding={1}
329
+ tone={copied ? 'positive' : 'default'}
330
+ onClick={copyUrl}
331
+ />
332
+ </Tooltip>
333
+ </>
334
+ )}
335
+ </Flex>
336
+
337
+ {/* Commit message */}
338
+ {commitMsg && (
339
+ <Text
340
+ size={0}
341
+ muted
342
+ style={{
343
+ overflow: 'hidden',
344
+ textOverflow: 'ellipsis',
345
+ whiteSpace: 'nowrap',
346
+ }}
228
347
  >
229
- <Text size={1} muted style={{ cursor: 'default' }}>
230
- {sha}
231
- </Text>
232
- </Tooltip>
348
+ {commitMsg}
349
+ </Text>
233
350
  )}
234
351
 
235
- {/* Creator */}
236
- {creator && (
237
- <Text size={1} muted>by {creator}</Text>
238
- )}
352
+ {/* Error expansion */}
353
+ {isError && (
354
+ <Stack space={2}>
355
+ <Button
356
+ text={showErrorLogs ? 'Hide error details' : 'Show error details'}
357
+ mode="ghost"
358
+ tone="critical"
359
+ icon={WarningOutlineIcon}
360
+ fontSize={1}
361
+ padding={2}
362
+ onClick={toggleErrorLogs}
363
+ style={{ alignSelf: 'flex-start' }}
364
+ />
239
365
 
240
- {/* Preview link */}
241
- {latest?.url && latest.state === 'READY' && (
242
- <a
243
- href={`https://${latest.url}`}
244
- target="_blank"
245
- rel="noreferrer"
246
- style={{ color: 'inherit' }}
247
- >
248
- <Flex align="center" gap={1}>
249
- <LaunchIcon />
250
- <Text size={1}>Preview</Text>
251
- </Flex>
252
- </a>
366
+ {showErrorLogs && (
367
+ <Card tone="critical" radius={2} padding={3}>
368
+ {loadingLogs && (
369
+ <Flex align="center" gap={2}>
370
+ <Spinner muted />
371
+ <Text size={1} muted>Loading logs…</Text>
372
+ </Flex>
373
+ )}
374
+ {logError && (
375
+ <Stack space={2}>
376
+ <Text size={1}>{logError}</Text>
377
+ {safeHref(latest?.inspectorUrl) && (
378
+ <a
379
+ href={safeHref(latest?.inspectorUrl)}
380
+ target="_blank"
381
+ rel="noreferrer"
382
+ style={{ color: 'inherit' }}
383
+ >
384
+ <Flex align="center" gap={1}>
385
+ <LaunchIcon />
386
+ <Text size={1}>View full logs in Vercel</Text>
387
+ </Flex>
388
+ </a>
389
+ )}
390
+ </Stack>
391
+ )}
392
+ {!loadingLogs && !logError && errorLines.length > 0 && (
393
+ <Stack space={2}>
394
+ <Box
395
+ style={{
396
+ maxHeight: 240,
397
+ overflowY: 'auto',
398
+ fontFamily: 'monospace',
399
+ fontSize: 11,
400
+ lineHeight: 1.6,
401
+ }}
402
+ >
403
+ {errorLines.map((line, i) => (
404
+ <Code
405
+ key={i}
406
+ size={1}
407
+ style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
408
+ >
409
+ {line}
410
+ </Code>
411
+ ))}
412
+ </Box>
413
+ {safeHref(latest?.inspectorUrl) && (
414
+ <a
415
+ href={safeHref(latest?.inspectorUrl)}
416
+ target="_blank"
417
+ rel="noreferrer"
418
+ style={{ color: 'inherit' }}
419
+ >
420
+ <Flex align="center" gap={1}>
421
+ <LaunchIcon />
422
+ <Text size={1}>View full logs in Vercel</Text>
423
+ </Flex>
424
+ </a>
425
+ )}
426
+ </Stack>
427
+ )}
428
+ </Card>
429
+ )}
430
+ </Stack>
253
431
  )}
254
- </Flex>
255
432
 
256
- {/* Error message from commit meta */}
257
- {deployError && (
258
- <Card tone="critical" padding={2} radius={2}>
259
- <Text size={1}>{deployError}</Text>
260
- </Card>
261
- )}
262
- </Stack>
263
- )}
433
+ {/* Trigger error */}
434
+ {deployError && (
435
+ <Card tone="critical" padding={2} radius={2}>
436
+ <Text size={1}>{deployError}</Text>
437
+ </Card>
438
+ )}
439
+ </Stack>
440
+ )}
441
+ </Stack>
264
442
 
265
- {/* ── Action buttons ──────────────────────────────────────── */}
266
- <Flex align="center" justify="flex-end" gap={2}>
443
+ {/* ── Right: action buttons ──────────────────────────────── */}
444
+ <Stack space={2} style={{ flexShrink: 0 }}>
267
445
  {isActiveState(latest?.state) && (
268
446
  <Button
269
447
  text="Cancel"
@@ -282,8 +460,9 @@ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
282
460
  disabled={isActive}
283
461
  onClick={deploy}
284
462
  />
285
- </Flex>
286
- </Stack>
463
+ </Stack>
464
+
465
+ </Flex>
287
466
  </Card>
288
467
 
289
468
  {showHistory && (