@liiift-studio/deploy-vercel-from-sanity 0.1.4 → 0.1.7

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,333 @@ 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
- <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}>
175
+ <Card padding={5} radius={2} shadow={1} tone="default">
176
+ <Flex gap={4} align="stretch">
177
+
178
+ {/* ── Left: info column ──────────────────────────────────── */}
179
+ <Stack space={3} 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) && (
156
- <MenuItem
157
- text="Build logs"
158
- icon={LaunchIcon}
159
- as="a"
160
- href={safeHref(latest?.inspectorUrl)}
161
- target="_blank"
162
- rel="noreferrer"
163
- />
164
- )}
165
- {!target.disableDeleteAction && (
184
+ <MenuButton
185
+ button={<Button mode="ghost" icon={EllipsisVerticalIcon} padding={2} />}
186
+ id={`menu-${target._id}`}
187
+ menu={
188
+ <Menu>
166
189
  <MenuItem
167
- text="Delete"
168
- icon={TrashIcon}
169
- tone="critical"
170
- onClick={() => onDelete(target)}
190
+ text="History"
191
+ icon={ClockIcon}
192
+ onClick={() => setShowHistory(true)}
171
193
  />
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 stretch full card height ── */}
444
+ <Flex
445
+ direction="column"
446
+ gap={2}
447
+ style={{ flexShrink: 0, alignSelf: 'stretch' }}
448
+ >
267
449
  {isActiveState(latest?.state) && (
268
450
  <Button
269
451
  text="Cancel"
@@ -281,9 +463,11 @@ export function DeployItem({ target, token, onDelete }: DeployItemProps) {
281
463
  loading={triggering}
282
464
  disabled={isActive}
283
465
  onClick={deploy}
466
+ style={{ flex: 1 }}
284
467
  />
285
468
  </Flex>
286
- </Stack>
469
+
470
+ </Flex>
287
471
  </Card>
288
472
 
289
473
  {showHistory && (
@@ -2,7 +2,7 @@
2
2
  import { useState, useEffect, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
4
  import {
5
- Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Dialog, useToast,
5
+ Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Badge, Dialog, useToast,
6
6
  } from '@sanity/ui'
7
7
  import { RocketIcon, TokenIcon, TrashIcon, WarningOutlineIcon } from '@sanity/icons'
8
8
  import { DeployItem } from './DeployItem'
@@ -81,11 +81,7 @@ export function DeployTool() {
81
81
  )
82
82
  }
83
83
 
84
- if (!token && !showTokenSetup) {
85
- return <TokenSetup onSaved={() => { setShowTokenSetup(false); load() }} />
86
- }
87
-
88
- if (showTokenSetup) {
84
+ if (!token || showTokenSetup) {
89
85
  return (
90
86
  <TokenSetup
91
87
  onSaved={() => {
@@ -106,13 +102,16 @@ export function DeployTool() {
106
102
  <RocketIcon />
107
103
  <Heading size={2}>Deploy</Heading>
108
104
  </Flex>
109
- <Button
110
- text="API Token"
111
- mode="ghost"
112
- icon={TokenIcon}
113
- fontSize={1}
114
- onClick={() => setShowTokenSetup(true)}
115
- />
105
+ <Flex align="center" gap={3}>
106
+ <Badge tone="positive" mode="outline">Connected</Badge>
107
+ <Button
108
+ text="Change Token"
109
+ mode="ghost"
110
+ icon={TokenIcon}
111
+ fontSize={1}
112
+ onClick={() => setShowTokenSetup(true)}
113
+ />
114
+ </Flex>
116
115
  </Flex>
117
116
 
118
117
  {/* ── No targets ──────────────────────────────────────────── */}
@@ -121,22 +120,30 @@ export function DeployTool() {
121
120
  <Stack space={3} style={{ textAlign: 'center' }}>
122
121
  <Text size={2} weight="semibold">No deploy targets configured</Text>
123
122
  <Text size={1} muted>
124
- Create a <code>vercel_deploy</code> document in the dataset with a Vercel deploy
125
- hook URL, or add one via the Sanity CLI.
123
+ Create a <code>vercel_deploy</code> document in the dataset with a Vercel
124
+ deploy hook URL.
126
125
  </Text>
127
126
  </Stack>
128
127
  </Card>
129
128
  )}
130
129
 
131
- {/* ── Deploy targets ──────────────────────────────────────── */}
132
- {token && targets.map(target => (
133
- <DeployItem
134
- key={target._id}
135
- target={target}
136
- token={token}
137
- onDelete={setPendingDelete}
138
- />
139
- ))}
130
+ {/* ── Deploy targets responsive 2-col grid ──────────────── */}
131
+ {targets.length > 0 && (
132
+ <div style={{
133
+ display: 'grid',
134
+ gridTemplateColumns: 'repeat(auto-fill, minmax(540px, 1fr))',
135
+ gap: '16px',
136
+ }}>
137
+ {targets.map(target => (
138
+ <DeployItem
139
+ key={target._id}
140
+ target={target}
141
+ token={token}
142
+ onDelete={setPendingDelete}
143
+ />
144
+ ))}
145
+ </div>
146
+ )}
140
147
  </Stack>
141
148
  </Box>
142
149
 
@@ -172,8 +179,8 @@ export function DeployTool() {
172
179
  <Text size={2} weight="semibold">{pendingDelete.name}</Text>
173
180
  </Flex>
174
181
  <Text size={1} muted>
175
- This removes the deploy target from the dataset. The Vercel deploy hook itself is
176
- not affected.
182
+ This removes the deploy target from the dataset. The Vercel deploy hook
183
+ itself is not affected.
177
184
  </Text>
178
185
  </Stack>
179
186
  </Box>