@liiift-studio/deploy-vercel-from-sanity 0.1.1
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/README.md +139 -0
- package/dist/index.d.mts +75 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.js +768 -0
- package/dist/index.mjs +791 -0
- package/package.json +42 -0
- package/src/components/DeployHistory.tsx +157 -0
- package/src/components/DeployItem.tsx +298 -0
- package/src/components/DeployTool.tsx +184 -0
- package/src/components/StatusBadge.tsx +23 -0
- package/src/components/TokenSetup.tsx +92 -0
- package/src/index.ts +42 -0
- package/src/lib/api.ts +59 -0
- package/src/lib/helpers.ts +86 -0
- package/src/schema/vercelDeploy.ts +49 -0
- package/src/types.ts +63 -0
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liiift-studio/deploy-vercel-from-sanity",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Sanity Studio v5 plugin — trigger and monitor Vercel deployments with full status, history, and build logs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Liiift Studio",
|
|
7
|
+
"keywords": ["sanity", "sanity-plugin", "vercel", "deploy", "deployment"],
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"source": "./src/index.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"require": "./dist/index.cjs",
|
|
16
|
+
"default": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": ["dist", "src"],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"dev": "tsup --watch",
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=18",
|
|
27
|
+
"sanity": ">=5"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@sanity/icons": "^3",
|
|
31
|
+
"@sanity/ui": "^3",
|
|
32
|
+
"@types/react": "^19",
|
|
33
|
+
"react": "^19",
|
|
34
|
+
"sanity": "^5",
|
|
35
|
+
"tsup": "^8",
|
|
36
|
+
"typescript": "^5"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/Liiift-Studio/Deploy-Vercel-from-Sanity"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// Deployment history modal — shows last 10 deployments for a target
|
|
2
|
+
import { useEffect, useState, useCallback } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Dialog, Card, Box, Stack, Flex, Text, Badge, Spinner, Button,
|
|
5
|
+
} from '@sanity/ui'
|
|
6
|
+
import { LaunchIcon, CloseIcon } from '@sanity/icons'
|
|
7
|
+
import { listDeployments } from '../lib/api'
|
|
8
|
+
import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref } from '../lib/helpers'
|
|
9
|
+
import type { DeployTarget, VercelDeployment } from '../types'
|
|
10
|
+
|
|
11
|
+
interface DeployHistoryProps {
|
|
12
|
+
target: DeployTarget
|
|
13
|
+
token: string
|
|
14
|
+
onClose: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
|
|
18
|
+
const [deployments, setDeployments] = useState<VercelDeployment[]>([])
|
|
19
|
+
const [loading, setLoading] = useState(true)
|
|
20
|
+
const [error, setError] = useState<string | null>(null)
|
|
21
|
+
|
|
22
|
+
const { projectId, hookId } = parseHookUrl(target.url)
|
|
23
|
+
|
|
24
|
+
const load = useCallback(async () => {
|
|
25
|
+
setLoading(true)
|
|
26
|
+
setError(null)
|
|
27
|
+
try {
|
|
28
|
+
const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId, limit: 10 })
|
|
29
|
+
setDeployments(data)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
setError(err instanceof Error ? err.message : 'Failed to load history')
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false)
|
|
34
|
+
}
|
|
35
|
+
}, [projectId, hookId, token, target.teamId])
|
|
36
|
+
|
|
37
|
+
useEffect(() => { load() }, [load])
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Dialog
|
|
41
|
+
header={`${target.name} — Deployment History`}
|
|
42
|
+
id="deploy-history"
|
|
43
|
+
onClose={onClose}
|
|
44
|
+
width={2}
|
|
45
|
+
footer={
|
|
46
|
+
<Box padding={3}>
|
|
47
|
+
<Button text="Close" icon={CloseIcon} mode="ghost" onClick={onClose} />
|
|
48
|
+
</Box>
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
<Box padding={4}>
|
|
52
|
+
{loading && (
|
|
53
|
+
<Flex justify="center" padding={6}>
|
|
54
|
+
<Spinner muted />
|
|
55
|
+
</Flex>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
{error && (
|
|
59
|
+
<Card tone="critical" padding={4} radius={2}>
|
|
60
|
+
<Text size={1}>{error}</Text>
|
|
61
|
+
</Card>
|
|
62
|
+
)}
|
|
63
|
+
|
|
64
|
+
{!loading && !error && deployments.length === 0 && (
|
|
65
|
+
<Card tone="transparent" padding={4}>
|
|
66
|
+
<Text size={1} muted align="center">No deployments found for this hook.</Text>
|
|
67
|
+
</Card>
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
{!loading && deployments.length > 0 && (
|
|
71
|
+
<Stack space={2}>
|
|
72
|
+
{/* Column headers */}
|
|
73
|
+
<Card padding={3} radius={2} tone="transparent">
|
|
74
|
+
<Flex gap={3}>
|
|
75
|
+
<Box flex={2}><Text size={0} weight="semibold" muted>Preview URL</Text></Box>
|
|
76
|
+
<Box flex={1}><Text size={0} weight="semibold" muted>Status</Text></Box>
|
|
77
|
+
<Box flex={2}><Text size={0} weight="semibold" muted>Branch · Commit</Text></Box>
|
|
78
|
+
<Box flex={1}><Text size={0} weight="semibold" muted>Deployed</Text></Box>
|
|
79
|
+
<Box style={{ width: 64 }}><Text size={0} weight="semibold" muted>Logs</Text></Box>
|
|
80
|
+
</Flex>
|
|
81
|
+
</Card>
|
|
82
|
+
|
|
83
|
+
{deployments.map(d => {
|
|
84
|
+
const { label, tone } = stateLabel(d.state)
|
|
85
|
+
const branch = d.meta?.githubCommitRef ?? '—'
|
|
86
|
+
const sha = shortSha(d.meta?.githubCommitSha)
|
|
87
|
+
const message = d.meta?.githubCommitMessage?.split('\n')[0] ?? ''
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Card key={d.uid} padding={3} radius={2} shadow={1} tone="default">
|
|
91
|
+
<Flex gap={3} align="center">
|
|
92
|
+
{/* Preview URL */}
|
|
93
|
+
<Box flex={2} style={{ overflow: 'hidden' }}>
|
|
94
|
+
{d.url ? (
|
|
95
|
+
<a
|
|
96
|
+
href={`https://${d.url}`}
|
|
97
|
+
target="_blank"
|
|
98
|
+
rel="noreferrer"
|
|
99
|
+
style={{ color: 'inherit' }}
|
|
100
|
+
>
|
|
101
|
+
<Text size={1} style={{ textDecoration: 'underline' }}>
|
|
102
|
+
{d.url.length > 36 ? `${d.url.slice(0, 36)}…` : d.url}
|
|
103
|
+
</Text>
|
|
104
|
+
</a>
|
|
105
|
+
) : (
|
|
106
|
+
<Text size={1} muted>—</Text>
|
|
107
|
+
)}
|
|
108
|
+
</Box>
|
|
109
|
+
|
|
110
|
+
{/* Status */}
|
|
111
|
+
<Box flex={1}>
|
|
112
|
+
<Badge tone={tone} mode="outline">{label}</Badge>
|
|
113
|
+
</Box>
|
|
114
|
+
|
|
115
|
+
{/* Branch + commit */}
|
|
116
|
+
<Box flex={2} style={{ overflow: 'hidden' }}>
|
|
117
|
+
<Stack space={1}>
|
|
118
|
+
<Text size={1}>{branch}</Text>
|
|
119
|
+
{sha && (
|
|
120
|
+
<Text size={0} muted>
|
|
121
|
+
{sha}{message ? ` · ${message.slice(0, 40)}${message.length > 40 ? '…' : ''}` : ''}
|
|
122
|
+
</Text>
|
|
123
|
+
)}
|
|
124
|
+
</Stack>
|
|
125
|
+
</Box>
|
|
126
|
+
|
|
127
|
+
{/* Time */}
|
|
128
|
+
<Box flex={1}>
|
|
129
|
+
<Text size={1} muted>{timeAgo(d.created)}</Text>
|
|
130
|
+
</Box>
|
|
131
|
+
|
|
132
|
+
{/* Build logs link */}
|
|
133
|
+
<Box style={{ width: 64 }}>
|
|
134
|
+
{safeHref(d.inspectorUrl) ? (
|
|
135
|
+
<a href={safeHref(d.inspectorUrl)} target="_blank" rel="noreferrer">
|
|
136
|
+
<Button
|
|
137
|
+
text="Logs"
|
|
138
|
+
mode="ghost"
|
|
139
|
+
tone="default"
|
|
140
|
+
icon={LaunchIcon}
|
|
141
|
+
style={{ fontSize: '12px' }}
|
|
142
|
+
/>
|
|
143
|
+
</a>
|
|
144
|
+
) : (
|
|
145
|
+
<Text size={1} muted>—</Text>
|
|
146
|
+
)}
|
|
147
|
+
</Box>
|
|
148
|
+
</Flex>
|
|
149
|
+
</Card>
|
|
150
|
+
)
|
|
151
|
+
})}
|
|
152
|
+
</Stack>
|
|
153
|
+
)}
|
|
154
|
+
</Box>
|
|
155
|
+
</Dialog>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// Per-deploy-target card — shows status, build timer, history, cancel, and deploy
|
|
2
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
3
|
+
import {
|
|
4
|
+
Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner, MenuButton, Menu, MenuItem,
|
|
5
|
+
} from '@sanity/ui'
|
|
6
|
+
import {
|
|
7
|
+
RocketIcon, ClockIcon, HistoryIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
|
|
8
|
+
} from '@sanity/icons'
|
|
9
|
+
import { listDeployments, cancelDeployment, triggerDeploy } from '../lib/api'
|
|
10
|
+
import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref } from '../lib/helpers'
|
|
11
|
+
import { StatusBadge } from './StatusBadge'
|
|
12
|
+
import { DeployHistory } from './DeployHistory'
|
|
13
|
+
import type { DeployTarget, VercelDeployment } from '../types'
|
|
14
|
+
|
|
15
|
+
const POLL_INTERVAL_MS = 5_000
|
|
16
|
+
|
|
17
|
+
interface DeployItemProps {
|
|
18
|
+
target: DeployTarget
|
|
19
|
+
token: string
|
|
20
|
+
onDelete: (target: DeployTarget) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function DeployItem({ target, token, onDelete }: DeployItemProps) {
|
|
24
|
+
const { projectId, hookId } = parseHookUrl(target.url)
|
|
25
|
+
|
|
26
|
+
const [deployments, setDeployments] = useState<VercelDeployment[]>([])
|
|
27
|
+
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
|
|
33
|
+
|
|
34
|
+
const latest = deployments[0]
|
|
35
|
+
const isActive = triggering || isActiveState(latest?.state)
|
|
36
|
+
|
|
37
|
+
// ── Fetch deployments ──────────────────────────────────────────────────────
|
|
38
|
+
const fetchDeployments = useCallback(async () => {
|
|
39
|
+
if (!projectId || !hookId || !token) return
|
|
40
|
+
try {
|
|
41
|
+
const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId })
|
|
42
|
+
setDeployments(data)
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('deploy-vercel-from-sanity: fetch error', err)
|
|
45
|
+
}
|
|
46
|
+
}, [projectId, hookId, token, target.teamId])
|
|
47
|
+
|
|
48
|
+
// Initial fetch
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
fetchDeployments().finally(() => setLoadingInitial(false))
|
|
51
|
+
}, [fetchDeployments])
|
|
52
|
+
|
|
53
|
+
// Polling while active
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!isActive) return
|
|
56
|
+
const id = setInterval(fetchDeployments, POLL_INTERVAL_MS)
|
|
57
|
+
return () => clearInterval(id)
|
|
58
|
+
}, [isActive, fetchDeployments])
|
|
59
|
+
|
|
60
|
+
// Stop triggering once a deployment appears in an active state
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (triggering && latest && latest.state !== undefined) {
|
|
63
|
+
setTriggering(false)
|
|
64
|
+
}
|
|
65
|
+
}, [triggering, latest])
|
|
66
|
+
|
|
67
|
+
// ── Build timer ───────────────────────────────────────────────────────────
|
|
68
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (isActive) {
|
|
71
|
+
const start = latest?.created ?? Date.now()
|
|
72
|
+
setElapsed(Math.floor((Date.now() - start) / 1000))
|
|
73
|
+
timerRef.current = setInterval(() => {
|
|
74
|
+
setElapsed(Math.floor((Date.now() - start) / 1000))
|
|
75
|
+
}, 1000)
|
|
76
|
+
} else {
|
|
77
|
+
setElapsed(0)
|
|
78
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
79
|
+
}
|
|
80
|
+
return () => {
|
|
81
|
+
if (timerRef.current) clearInterval(timerRef.current)
|
|
82
|
+
}
|
|
83
|
+
}, [isActive, latest?.created])
|
|
84
|
+
|
|
85
|
+
// ── Actions ───────────────────────────────────────────────────────────────
|
|
86
|
+
const deploy = useCallback(async () => {
|
|
87
|
+
setDeployError(null)
|
|
88
|
+
setTriggering(true)
|
|
89
|
+
try {
|
|
90
|
+
await triggerDeploy(target.url)
|
|
91
|
+
// Poll immediately to pick up the new deployment faster
|
|
92
|
+
setTimeout(fetchDeployments, 2000)
|
|
93
|
+
} catch (err) {
|
|
94
|
+
setTriggering(false)
|
|
95
|
+
setDeployError(err instanceof Error ? err.message : 'Deploy failed')
|
|
96
|
+
}
|
|
97
|
+
}, [target.url, fetchDeployments])
|
|
98
|
+
|
|
99
|
+
const cancel = useCallback(async () => {
|
|
100
|
+
if (!latest?.uid) return
|
|
101
|
+
setCanceling(true)
|
|
102
|
+
try {
|
|
103
|
+
await cancelDeployment({ deploymentId: latest.uid, token, teamId: target.teamId })
|
|
104
|
+
await fetchDeployments()
|
|
105
|
+
} catch (err) {
|
|
106
|
+
console.error('deploy-vercel-from-sanity: cancel error', err)
|
|
107
|
+
} finally {
|
|
108
|
+
setCanceling(false)
|
|
109
|
+
}
|
|
110
|
+
}, [latest?.uid, token, target.teamId, fetchDeployments])
|
|
111
|
+
|
|
112
|
+
// ── Derived display values ────────────────────────────────────────────────
|
|
113
|
+
const branch = latest?.meta?.githubCommitRef
|
|
114
|
+
const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
|
|
115
|
+
const sha = shortSha(latest?.meta?.githubCommitSha)
|
|
116
|
+
const creator = latest?.creator?.username
|
|
117
|
+
const deployedAt = latest?.created ? timeAgo(latest.created) : null
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<>
|
|
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}>
|
|
126
|
+
<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={HistoryIcon}
|
|
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 && (
|
|
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>
|
|
178
|
+
|
|
179
|
+
{/* ── Status row ──────────────────────────────────────────── */}
|
|
180
|
+
{loadingInitial ? (
|
|
181
|
+
<Flex align="center" gap={2}>
|
|
182
|
+
<Spinner muted />
|
|
183
|
+
<Text size={1} muted>Loading…</Text>
|
|
184
|
+
</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
|
+
|
|
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
|
+
)}
|
|
205
|
+
|
|
206
|
+
{/* Time since deploy */}
|
|
207
|
+
{!isActive && deployedAt && (
|
|
208
|
+
<Text size={1} muted>{deployedAt}</Text>
|
|
209
|
+
)}
|
|
210
|
+
|
|
211
|
+
{/* Branch */}
|
|
212
|
+
{branch && (
|
|
213
|
+
<>
|
|
214
|
+
<Text size={1} muted>·</Text>
|
|
215
|
+
<Badge tone="default" mode="outline">{branch}</Badge>
|
|
216
|
+
</>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{/* Commit */}
|
|
220
|
+
{sha && (
|
|
221
|
+
<Tooltip
|
|
222
|
+
content={
|
|
223
|
+
<Box padding={2}>
|
|
224
|
+
<Text size={1}>{commitMsg ?? sha}</Text>
|
|
225
|
+
</Box>
|
|
226
|
+
}
|
|
227
|
+
portal
|
|
228
|
+
>
|
|
229
|
+
<Text size={1} muted style={{ cursor: 'default' }}>
|
|
230
|
+
{sha}
|
|
231
|
+
</Text>
|
|
232
|
+
</Tooltip>
|
|
233
|
+
)}
|
|
234
|
+
|
|
235
|
+
{/* Creator */}
|
|
236
|
+
{creator && (
|
|
237
|
+
<Text size={1} muted>by {creator}</Text>
|
|
238
|
+
)}
|
|
239
|
+
|
|
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>
|
|
253
|
+
)}
|
|
254
|
+
</Flex>
|
|
255
|
+
|
|
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
|
+
)}
|
|
264
|
+
|
|
265
|
+
{/* ── Action buttons ──────────────────────────────────────── */}
|
|
266
|
+
<Flex align="center" justify="flex-end" gap={2}>
|
|
267
|
+
{isActiveState(latest?.state) && (
|
|
268
|
+
<Button
|
|
269
|
+
text="Cancel"
|
|
270
|
+
mode="ghost"
|
|
271
|
+
tone="critical"
|
|
272
|
+
loading={canceling}
|
|
273
|
+
disabled={canceling}
|
|
274
|
+
onClick={cancel}
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
<Button
|
|
278
|
+
text="Deploy"
|
|
279
|
+
tone="primary"
|
|
280
|
+
icon={RocketIcon}
|
|
281
|
+
loading={triggering}
|
|
282
|
+
disabled={isActive}
|
|
283
|
+
onClick={deploy}
|
|
284
|
+
/>
|
|
285
|
+
</Flex>
|
|
286
|
+
</Stack>
|
|
287
|
+
</Card>
|
|
288
|
+
|
|
289
|
+
{showHistory && (
|
|
290
|
+
<DeployHistory
|
|
291
|
+
target={target}
|
|
292
|
+
token={token}
|
|
293
|
+
onClose={() => setShowHistory(false)}
|
|
294
|
+
/>
|
|
295
|
+
)}
|
|
296
|
+
</>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Main deploy tool — fetches targets + token, renders per-project cards
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
3
|
+
import { useClient } from 'sanity'
|
|
4
|
+
import {
|
|
5
|
+
Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Dialog, useToast,
|
|
6
|
+
} from '@sanity/ui'
|
|
7
|
+
import { RocketIcon, KeyIcon, TrashIcon, WarningOutlineIcon } from '@sanity/icons'
|
|
8
|
+
import { DeployItem } from './DeployItem'
|
|
9
|
+
import { TokenSetup } from './TokenSetup'
|
|
10
|
+
import type { DeployTarget } from '../types'
|
|
11
|
+
|
|
12
|
+
const TOKEN_QUERY = `*[_id == "secrets.vercelDeploy"][0].accessToken`
|
|
13
|
+
const TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`
|
|
14
|
+
|
|
15
|
+
export function DeployTool() {
|
|
16
|
+
const client = useClient({ apiVersion: '2025-01-01' })
|
|
17
|
+
const toast = useToast()
|
|
18
|
+
|
|
19
|
+
const [token, setToken] = useState<string | null>(null)
|
|
20
|
+
const [targets, setTargets] = useState<DeployTarget[]>([])
|
|
21
|
+
const [loading, setLoading] = useState(true)
|
|
22
|
+
const [showTokenSetup, setShowTokenSetup] = useState(false)
|
|
23
|
+
const [pendingDelete, setPendingDelete] = useState<DeployTarget | null>(null)
|
|
24
|
+
const [deleting, setDeleting] = useState(false)
|
|
25
|
+
|
|
26
|
+
// ── Fetch token + targets ─────────────────────────────────────────────────
|
|
27
|
+
const load = useCallback(async () => {
|
|
28
|
+
setLoading(true)
|
|
29
|
+
try {
|
|
30
|
+
const [fetchedToken, fetchedTargets] = await Promise.all([
|
|
31
|
+
client.fetch<string | null>(TOKEN_QUERY),
|
|
32
|
+
client.fetch<DeployTarget[]>(TARGETS_QUERY),
|
|
33
|
+
])
|
|
34
|
+
setToken(fetchedToken ?? null)
|
|
35
|
+
setTargets(fetchedTargets)
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('deploy-vercel-from-sanity: load error', err)
|
|
38
|
+
} finally {
|
|
39
|
+
setLoading(false)
|
|
40
|
+
}
|
|
41
|
+
}, [client])
|
|
42
|
+
|
|
43
|
+
useEffect(() => { load() }, [load])
|
|
44
|
+
|
|
45
|
+
// Live subscription — update targets when documents change
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const sub = client
|
|
48
|
+
.listen<DeployTarget>(TARGETS_QUERY)
|
|
49
|
+
.subscribe(() => {
|
|
50
|
+
client.fetch<DeployTarget[]>(TARGETS_QUERY).then(setTargets).catch(err => {
|
|
51
|
+
console.error('deploy-vercel-from-sanity: subscription refresh error', err)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
return () => sub.unsubscribe()
|
|
55
|
+
}, [client])
|
|
56
|
+
|
|
57
|
+
// ── Delete target ─────────────────────────────────────────────────────────
|
|
58
|
+
const confirmDelete = useCallback(async () => {
|
|
59
|
+
if (!pendingDelete) return
|
|
60
|
+
setDeleting(true)
|
|
61
|
+
try {
|
|
62
|
+
await client.delete(pendingDelete._id)
|
|
63
|
+
setTargets(prev => prev.filter(t => t._id !== pendingDelete._id))
|
|
64
|
+
toast.push({ status: 'success', title: `Deleted "${pendingDelete.name}"` })
|
|
65
|
+
} catch (err) {
|
|
66
|
+
toast.push({ status: 'error', title: 'Delete failed', description: String(err) })
|
|
67
|
+
} finally {
|
|
68
|
+
setDeleting(false)
|
|
69
|
+
setPendingDelete(null)
|
|
70
|
+
}
|
|
71
|
+
}, [client, pendingDelete, toast])
|
|
72
|
+
|
|
73
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
74
|
+
if (loading) {
|
|
75
|
+
return (
|
|
76
|
+
<Card height="fill" tone="transparent">
|
|
77
|
+
<Flex align="center" justify="center" height="fill">
|
|
78
|
+
<Spinner muted />
|
|
79
|
+
</Flex>
|
|
80
|
+
</Card>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!token && !showTokenSetup) {
|
|
85
|
+
return <TokenSetup onSaved={() => { setShowTokenSetup(false); load() }} />
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (showTokenSetup) {
|
|
89
|
+
return (
|
|
90
|
+
<TokenSetup
|
|
91
|
+
onSaved={() => {
|
|
92
|
+
setShowTokenSetup(false)
|
|
93
|
+
load()
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Card height="fill" tone="transparent">
|
|
101
|
+
<Box padding={5}>
|
|
102
|
+
<Stack space={5}>
|
|
103
|
+
{/* ── Header ──────────────────────────────────────────────── */}
|
|
104
|
+
<Flex align="center" justify="space-between">
|
|
105
|
+
<Flex align="center" gap={3}>
|
|
106
|
+
<RocketIcon />
|
|
107
|
+
<Heading size={2}>Deploy</Heading>
|
|
108
|
+
</Flex>
|
|
109
|
+
<Button
|
|
110
|
+
text="API Token"
|
|
111
|
+
mode="ghost"
|
|
112
|
+
icon={KeyIcon}
|
|
113
|
+
fontSize={1}
|
|
114
|
+
onClick={() => setShowTokenSetup(true)}
|
|
115
|
+
/>
|
|
116
|
+
</Flex>
|
|
117
|
+
|
|
118
|
+
{/* ── No targets ──────────────────────────────────────────── */}
|
|
119
|
+
{targets.length === 0 && (
|
|
120
|
+
<Card padding={5} radius={2} tone="transparent" shadow={1}>
|
|
121
|
+
<Stack space={3} style={{ textAlign: 'center' }}>
|
|
122
|
+
<Text size={2} weight="semibold">No deploy targets configured</Text>
|
|
123
|
+
<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.
|
|
126
|
+
</Text>
|
|
127
|
+
</Stack>
|
|
128
|
+
</Card>
|
|
129
|
+
)}
|
|
130
|
+
|
|
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
|
+
))}
|
|
140
|
+
</Stack>
|
|
141
|
+
</Box>
|
|
142
|
+
|
|
143
|
+
{/* ── Delete confirmation ─────────────────────────────────────────── */}
|
|
144
|
+
{pendingDelete && (
|
|
145
|
+
<Dialog
|
|
146
|
+
header="Delete deploy target?"
|
|
147
|
+
id="confirm-delete"
|
|
148
|
+
onClose={() => setPendingDelete(null)}
|
|
149
|
+
width={1}
|
|
150
|
+
footer={
|
|
151
|
+
<Flex padding={3} gap={2} justify="flex-end">
|
|
152
|
+
<Button
|
|
153
|
+
text="Cancel"
|
|
154
|
+
mode="ghost"
|
|
155
|
+
onClick={() => setPendingDelete(null)}
|
|
156
|
+
/>
|
|
157
|
+
<Button
|
|
158
|
+
text="Delete"
|
|
159
|
+
tone="critical"
|
|
160
|
+
icon={TrashIcon}
|
|
161
|
+
loading={deleting}
|
|
162
|
+
disabled={deleting}
|
|
163
|
+
onClick={confirmDelete}
|
|
164
|
+
/>
|
|
165
|
+
</Flex>
|
|
166
|
+
}
|
|
167
|
+
>
|
|
168
|
+
<Box padding={4}>
|
|
169
|
+
<Stack space={3}>
|
|
170
|
+
<Flex align="center" gap={2}>
|
|
171
|
+
<WarningOutlineIcon />
|
|
172
|
+
<Text size={2} weight="semibold">{pendingDelete.name}</Text>
|
|
173
|
+
</Flex>
|
|
174
|
+
<Text size={1} muted>
|
|
175
|
+
This removes the deploy target from the dataset. The Vercel deploy hook itself is
|
|
176
|
+
not affected.
|
|
177
|
+
</Text>
|
|
178
|
+
</Stack>
|
|
179
|
+
</Box>
|
|
180
|
+
</Dialog>
|
|
181
|
+
)}
|
|
182
|
+
</Card>
|
|
183
|
+
)
|
|
184
|
+
}
|