@liiift-studio/deploy-vercel-from-sanity 1.2.0 → 1.2.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 +1 -1
- package/dist/index.d.mts +1 -33
- package/dist/index.d.ts +1 -33
- package/dist/index.js +185 -99
- package/dist/index.mjs +195 -108
- package/package.json +12 -8
- package/src/compat/code.tsx +0 -29
- package/src/compat/index.ts +0 -14
- package/src/compat/menu.tsx +0 -225
- package/src/compat/primitives.tsx +0 -93
- package/src/compat/resolve.ts +0 -55
- package/src/compat/toast.tsx +0 -173
- package/src/compat/tooltip.tsx +0 -78
- package/src/components/DeployHistory.tsx +0 -160
- package/src/components/DeployItem.tsx +0 -596
- package/src/components/DeployTargetForm.tsx +0 -172
- package/src/components/DeployTool.tsx +0 -318
- package/src/components/StatusBadge.tsx +0 -23
- package/src/components/TokenSetup.tsx +0 -98
- package/src/icons.tsx +0 -78
- package/src/index.ts +0 -47
- package/src/lib/api.ts +0 -102
- package/src/lib/helpers.ts +0 -131
- package/src/schema/schemaIcon.tsx +0 -38
- package/src/schema/vercelConfig.ts +0 -34
- package/src/schema/vercelDeploy.ts +0 -49
- package/src/types.ts +0 -77
- package/src/version.ts +0 -2
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
// Dialog form for creating and editing vercel_deploy documents
|
|
2
|
-
import { useId, useState, useCallback } from 'react'
|
|
3
|
-
import { useClient } from 'sanity'
|
|
4
|
-
import { CheckmarkCircleIcon } from '../icons'
|
|
5
|
-
import type { DeployTarget } from '../types'
|
|
6
|
-
import { Box, Button, Card, Dialog, Flex, Label, Stack, Switch, Text, TextInput } from '../compat'
|
|
7
|
-
|
|
8
|
-
const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
|
|
9
|
-
|
|
10
|
-
interface DeployTargetFormProps {
|
|
11
|
-
/** When provided, form is in edit mode; otherwise create mode */
|
|
12
|
-
initial?: DeployTarget
|
|
13
|
-
onSaved: () => void
|
|
14
|
-
onClose: () => void
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetFormProps) {
|
|
18
|
-
const client = useClient({ apiVersion: '2025-01-01' })
|
|
19
|
-
const isEdit = Boolean(initial)
|
|
20
|
-
|
|
21
|
-
// Ids for label/description association. useId keeps them unique inside a published
|
|
22
|
-
// plugin, where a hardcoded id can collide with anything the host Studio renders.
|
|
23
|
-
const nameId = useId()
|
|
24
|
-
const urlId = useId()
|
|
25
|
-
const urlErrorId = useId()
|
|
26
|
-
const urlHelpId = useId()
|
|
27
|
-
const teamId_ = useId()
|
|
28
|
-
const teamHelpId = useId()
|
|
29
|
-
const disableId = useId()
|
|
30
|
-
|
|
31
|
-
const [name, setName] = useState(initial?.name ?? '')
|
|
32
|
-
const [url, setUrl] = useState(initial?.url ?? '')
|
|
33
|
-
const [teamId, setTeamId] = useState(initial?.teamId ?? '')
|
|
34
|
-
const [disableDelete, setDisableDelete] = useState(initial?.disableDeleteAction ?? false)
|
|
35
|
-
const [saving, setSaving] = useState(false)
|
|
36
|
-
const [error, setError] = useState<string | null>(null)
|
|
37
|
-
|
|
38
|
-
const urlValid = !url || VERCEL_HOOK_RE.test(url.trim())
|
|
39
|
-
const canSave = name.trim() && url.trim() && urlValid
|
|
40
|
-
|
|
41
|
-
const save = useCallback(async () => {
|
|
42
|
-
if (!canSave) return
|
|
43
|
-
setSaving(true)
|
|
44
|
-
setError(null)
|
|
45
|
-
const fields: { name: string; url: string; teamId: string | null; disableDeleteAction: boolean } = {
|
|
46
|
-
name: name.trim(),
|
|
47
|
-
url: url.trim(),
|
|
48
|
-
teamId: teamId.trim() || null,
|
|
49
|
-
disableDeleteAction: disableDelete,
|
|
50
|
-
}
|
|
51
|
-
try {
|
|
52
|
-
if (isEdit && initial) {
|
|
53
|
-
await client.patch(initial._id).set(fields).commit()
|
|
54
|
-
} else {
|
|
55
|
-
await client.create({ _type: 'vercel_deploy', ...fields })
|
|
56
|
-
}
|
|
57
|
-
onSaved()
|
|
58
|
-
} catch (err) {
|
|
59
|
-
setError(err instanceof Error ? err.message : 'Save failed')
|
|
60
|
-
} finally {
|
|
61
|
-
setSaving(false)
|
|
62
|
-
}
|
|
63
|
-
}, [canSave, isEdit, initial, client, name, url, teamId, disableDelete, onSaved])
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<Dialog
|
|
67
|
-
header={isEdit ? `Edit "${initial?.name}"` : 'Add deploy target'}
|
|
68
|
-
id="deploy-target-form"
|
|
69
|
-
onClose={onClose}
|
|
70
|
-
width={1}
|
|
71
|
-
footer={
|
|
72
|
-
<Flex padding={3} gap={2} justify="flex-end">
|
|
73
|
-
<Button text="Cancel" mode="ghost" onClick={onClose} style={{ cursor: 'pointer' }} />
|
|
74
|
-
<Button
|
|
75
|
-
text={isEdit ? 'Save changes' : 'Add target'}
|
|
76
|
-
tone="primary"
|
|
77
|
-
icon={CheckmarkCircleIcon}
|
|
78
|
-
loading={saving}
|
|
79
|
-
disabled={!canSave || saving}
|
|
80
|
-
onClick={save}
|
|
81
|
-
style={{ cursor: 'pointer' }}
|
|
82
|
-
/>
|
|
83
|
-
</Flex>
|
|
84
|
-
}
|
|
85
|
-
>
|
|
86
|
-
<Box padding={4}>
|
|
87
|
-
<Stack space={4}>
|
|
88
|
-
|
|
89
|
-
{/* Name. Label carries `as="label"` — @sanity/ui's Label renders a div otherwise,
|
|
90
|
-
so the input would have no accessible name. */}
|
|
91
|
-
<Stack space={2}>
|
|
92
|
-
<Label as="label" size={1} htmlFor={nameId}>
|
|
93
|
-
Name <span aria-hidden="true">*</span>
|
|
94
|
-
</Label>
|
|
95
|
-
<TextInput
|
|
96
|
-
id={nameId}
|
|
97
|
-
required
|
|
98
|
-
aria-required="true"
|
|
99
|
-
value={name}
|
|
100
|
-
onChange={e => setName((e.target as HTMLInputElement).value)}
|
|
101
|
-
placeholder="Production"
|
|
102
|
-
/>
|
|
103
|
-
</Stack>
|
|
104
|
-
|
|
105
|
-
{/* Deploy hook URL */}
|
|
106
|
-
<Stack space={2}>
|
|
107
|
-
<Label as="label" size={1} htmlFor={urlId}>
|
|
108
|
-
Deploy hook URL <span aria-hidden="true">*</span>
|
|
109
|
-
</Label>
|
|
110
|
-
<TextInput
|
|
111
|
-
id={urlId}
|
|
112
|
-
required
|
|
113
|
-
aria-required="true"
|
|
114
|
-
aria-invalid={Boolean(url) && !urlValid}
|
|
115
|
-
aria-describedby={`${urlHelpId}${url && !urlValid ? ` ${urlErrorId}` : ''}`}
|
|
116
|
-
value={url}
|
|
117
|
-
onChange={e => setUrl((e.target as HTMLInputElement).value)}
|
|
118
|
-
placeholder="https://api.vercel.com/v1/integrations/deploy/…"
|
|
119
|
-
/>
|
|
120
|
-
{url && !urlValid && (
|
|
121
|
-
<Card tone="critical" padding={2} radius={2} role="alert">
|
|
122
|
-
<Text id={urlErrorId} size={1}>
|
|
123
|
-
Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)
|
|
124
|
-
</Text>
|
|
125
|
-
</Card>
|
|
126
|
-
)}
|
|
127
|
-
<Text id={urlHelpId} size={0} muted>
|
|
128
|
-
Vercel dashboard → Project → Settings → Git → Deploy Hooks
|
|
129
|
-
</Text>
|
|
130
|
-
</Stack>
|
|
131
|
-
|
|
132
|
-
{/* Team ID */}
|
|
133
|
-
<Stack space={2}>
|
|
134
|
-
<Label as="label" size={1} htmlFor={teamId_}>
|
|
135
|
-
Team ID <span style={{ opacity: 0.5, fontWeight: 'normal' }}>— optional</span>
|
|
136
|
-
</Label>
|
|
137
|
-
<TextInput
|
|
138
|
-
id={teamId_}
|
|
139
|
-
aria-describedby={teamHelpId}
|
|
140
|
-
value={teamId}
|
|
141
|
-
onChange={e => setTeamId((e.target as HTMLInputElement).value)}
|
|
142
|
-
placeholder="team_xxxxxxxx"
|
|
143
|
-
/>
|
|
144
|
-
<Text id={teamHelpId} size={0} muted>
|
|
145
|
-
Required for team-owned Vercel projects. Find it at Vercel → Settings → General → Team ID (starts with <code>team_</code>).
|
|
146
|
-
</Text>
|
|
147
|
-
</Stack>
|
|
148
|
-
|
|
149
|
-
{/* Disable delete */}
|
|
150
|
-
<Flex align="center" gap={3}>
|
|
151
|
-
<Switch
|
|
152
|
-
checked={disableDelete}
|
|
153
|
-
onChange={e => setDisableDelete((e.target as HTMLInputElement).checked)}
|
|
154
|
-
id={disableId}
|
|
155
|
-
/>
|
|
156
|
-
<Stack space={1}>
|
|
157
|
-
<Label as="label" size={1} htmlFor={disableId}>Disable delete action</Label>
|
|
158
|
-
<Text size={0} muted>Hides the delete button for this target in the studio.</Text>
|
|
159
|
-
</Stack>
|
|
160
|
-
</Flex>
|
|
161
|
-
|
|
162
|
-
{error && (
|
|
163
|
-
<Card tone="critical" padding={3} radius={2}>
|
|
164
|
-
<Text size={1}>{error}</Text>
|
|
165
|
-
</Card>
|
|
166
|
-
)}
|
|
167
|
-
|
|
168
|
-
</Stack>
|
|
169
|
-
</Box>
|
|
170
|
-
</Dialog>
|
|
171
|
-
)
|
|
172
|
-
}
|
|
@@ -1,318 +0,0 @@
|
|
|
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 { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '../icons'
|
|
5
|
-
import { DeployItem } from './DeployItem'
|
|
6
|
-
import { TokenSetup } from './TokenSetup'
|
|
7
|
-
import { DeployTargetForm } from './DeployTargetForm'
|
|
8
|
-
import { VERSION } from '../version'
|
|
9
|
-
import type { DeployTarget } from '../types'
|
|
10
|
-
import { Box, Button, Card, Dialog, Flex, Heading, Spinner, Stack, Text, ToastViewport, useToast } from '../compat'
|
|
11
|
-
|
|
12
|
-
const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
|
|
13
|
-
// `useClient` hands back a raw-perspective client, so drafts are returned alongside published
|
|
14
|
-
// documents. `vercel_deploy` is a registered type and therefore editable from Structure, which
|
|
15
|
-
// would otherwise render two cards for the same target.
|
|
16
|
-
const TARGETS_QUERY = `*[_type == "vercel_deploy" && !(_id in path("drafts.**"))] | order(_createdAt asc)`
|
|
17
|
-
|
|
18
|
-
export function DeployTool() {
|
|
19
|
-
const client = useClient({ apiVersion: '2025-01-01' })
|
|
20
|
-
const toast = useToast()
|
|
21
|
-
|
|
22
|
-
const [token, setToken] = useState<string | null>(null)
|
|
23
|
-
const [targets, setTargets] = useState<DeployTarget[]>([])
|
|
24
|
-
const [loading, setLoading] = useState(true)
|
|
25
|
-
const [showTokenSetup, setShowTokenSetup] = useState(false)
|
|
26
|
-
const [showCreateForm, setShowCreateForm] = useState(false)
|
|
27
|
-
const [pendingEdit, setPendingEdit] = useState<DeployTarget | null>(null)
|
|
28
|
-
const [pendingDelete, setPendingDelete] = useState<DeployTarget | null>(null)
|
|
29
|
-
const [deleting, setDeleting] = useState(false)
|
|
30
|
-
|
|
31
|
-
// ── Fetch token + targets ─────────────────────────────────────────────────
|
|
32
|
-
const load = useCallback(async () => {
|
|
33
|
-
setLoading(true)
|
|
34
|
-
try {
|
|
35
|
-
const [fetchedToken, fetchedTargets] = await Promise.all([
|
|
36
|
-
client.fetch<string | null>(TOKEN_QUERY),
|
|
37
|
-
client.fetch<DeployTarget[]>(TARGETS_QUERY),
|
|
38
|
-
])
|
|
39
|
-
setToken(fetchedToken ?? null)
|
|
40
|
-
setTargets(fetchedTargets)
|
|
41
|
-
} catch (err) {
|
|
42
|
-
console.error('deploy-vercel-from-sanity: load error', err)
|
|
43
|
-
} finally {
|
|
44
|
-
setLoading(false)
|
|
45
|
-
}
|
|
46
|
-
}, [client])
|
|
47
|
-
|
|
48
|
-
useEffect(() => { load() }, [load])
|
|
49
|
-
|
|
50
|
-
// Inject responsive styles once on mount
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
// Reference-counted: with two tools mounted, the one that skipped creation must not
|
|
53
|
-
// remove the stylesheet the other is still using.
|
|
54
|
-
const existing = document.getElementById('dvfs-styles')
|
|
55
|
-
if (existing) {
|
|
56
|
-
const n = Number(existing.dataset.refs ?? '1') + 1
|
|
57
|
-
existing.dataset.refs = String(n)
|
|
58
|
-
return () => {
|
|
59
|
-
const left = Number(existing.dataset.refs ?? '1') - 1
|
|
60
|
-
existing.dataset.refs = String(left)
|
|
61
|
-
if (left <= 0) existing.remove()
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
const style = document.createElement('style')
|
|
65
|
-
style.id = 'dvfs-styles'
|
|
66
|
-
style.textContent = `
|
|
67
|
-
@media (max-width: 768px) {
|
|
68
|
-
.dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
|
|
69
|
-
.dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
|
|
70
|
-
.dvfs-grid { grid-template-columns: 1fr !important; }
|
|
71
|
-
.dvfs-card-flex { flex-direction: column !important; }
|
|
72
|
-
.dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
|
|
73
|
-
.dvfs-deploy-col button { border-radius: 3px !important; }
|
|
74
|
-
}
|
|
75
|
-
@keyframes dvfs-open {
|
|
76
|
-
from { opacity: 0; transform: translateY(-4px); }
|
|
77
|
-
to { opacity: 1; transform: translateY(0); }
|
|
78
|
-
}
|
|
79
|
-
.dvfs-accordion-content {
|
|
80
|
-
animation: dvfs-open 0.15s ease-out;
|
|
81
|
-
}
|
|
82
|
-
`
|
|
83
|
-
document.head.appendChild(style)
|
|
84
|
-
style.dataset.refs = '1'
|
|
85
|
-
return () => {
|
|
86
|
-
const left = Number(style.dataset.refs ?? '1') - 1
|
|
87
|
-
style.dataset.refs = String(left)
|
|
88
|
-
if (left <= 0) style.remove()
|
|
89
|
-
}
|
|
90
|
-
}, [])
|
|
91
|
-
|
|
92
|
-
// Live subscription — update targets when documents change
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
const sub = client
|
|
95
|
-
.listen<DeployTarget>(TARGETS_QUERY)
|
|
96
|
-
.subscribe(() => {
|
|
97
|
-
client.fetch<DeployTarget[]>(TARGETS_QUERY).then(setTargets).catch(err => {
|
|
98
|
-
console.error('deploy-vercel-from-sanity: subscription refresh error', err)
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
return () => sub.unsubscribe()
|
|
102
|
-
}, [client])
|
|
103
|
-
|
|
104
|
-
// ── Delete target ─────────────────────────────────────────────────────────
|
|
105
|
-
const confirmDelete = useCallback(async () => {
|
|
106
|
-
if (!pendingDelete) return
|
|
107
|
-
setDeleting(true)
|
|
108
|
-
try {
|
|
109
|
-
// Remove the draft counterpart as well; deleting only the published id leaves the
|
|
110
|
-
// target behind and it reappears on the next load.
|
|
111
|
-
await client.delete(pendingDelete._id)
|
|
112
|
-
await client.delete(`drafts.${pendingDelete._id}`).catch(() => {})
|
|
113
|
-
setTargets(prev => prev.filter(t => t._id !== pendingDelete._id))
|
|
114
|
-
toast.push({ status: 'success', title: `Deleted "${pendingDelete.name}"` })
|
|
115
|
-
} catch (err) {
|
|
116
|
-
toast.push({ status: 'error', title: 'Delete failed', description: String(err) })
|
|
117
|
-
} finally {
|
|
118
|
-
setDeleting(false)
|
|
119
|
-
setPendingDelete(null)
|
|
120
|
-
}
|
|
121
|
-
}, [client, pendingDelete, toast])
|
|
122
|
-
|
|
123
|
-
// ── Render ────────────────────────────────────────────────────────────────
|
|
124
|
-
if (loading) {
|
|
125
|
-
return (
|
|
126
|
-
<Card height="fill" tone="transparent">
|
|
127
|
-
<Flex align="center" justify="center" height="fill">
|
|
128
|
-
<Spinner muted />
|
|
129
|
-
</Flex>
|
|
130
|
-
</Card>
|
|
131
|
-
)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
return (
|
|
135
|
-
<Card height="fill" tone="transparent">
|
|
136
|
-
<Box padding={5}>
|
|
137
|
-
<Stack space={5}>
|
|
138
|
-
|
|
139
|
-
{/* ── Header ──────────────────────────────────────────────── */}
|
|
140
|
-
<Flex align="center" justify="space-between" className="dvfs-header">
|
|
141
|
-
<Heading size={2}>Deploy with Vercel</Heading>
|
|
142
|
-
<Flex align="center" gap={3} className="dvfs-header-actions">
|
|
143
|
-
<Button
|
|
144
|
-
text={token ? 'Token connected' : 'Connect API token'}
|
|
145
|
-
mode="ghost"
|
|
146
|
-
icon={TokenIcon}
|
|
147
|
-
fontSize={1}
|
|
148
|
-
tone={token ? 'positive' : 'caution'}
|
|
149
|
-
onClick={() => setShowTokenSetup(true)}
|
|
150
|
-
style={{ cursor: 'pointer' }}
|
|
151
|
-
/>
|
|
152
|
-
<Button
|
|
153
|
-
text="Add target"
|
|
154
|
-
mode="ghost"
|
|
155
|
-
icon={AddIcon}
|
|
156
|
-
fontSize={1}
|
|
157
|
-
onClick={() => setShowCreateForm(true)}
|
|
158
|
-
style={{ cursor: 'pointer' }}
|
|
159
|
-
/>
|
|
160
|
-
</Flex>
|
|
161
|
-
</Flex>
|
|
162
|
-
|
|
163
|
-
{/* ── No-token upgrade banner ──────────────────────────────── */}
|
|
164
|
-
{!token && (
|
|
165
|
-
<Card padding={4} radius={2} tone="caution" shadow={1}>
|
|
166
|
-
<Flex align="center" justify="space-between" gap={4}>
|
|
167
|
-
<Stack space={2}>
|
|
168
|
-
<Text size={1} weight="semibold">Deploy status is not connected</Text>
|
|
169
|
-
<Text size={1} muted>
|
|
170
|
-
You can trigger deploys now. Connect a Vercel API token to also see
|
|
171
|
-
deployment status, build logs, history, and commit metadata.
|
|
172
|
-
</Text>
|
|
173
|
-
</Stack>
|
|
174
|
-
<Button
|
|
175
|
-
text="Connect"
|
|
176
|
-
tone="caution"
|
|
177
|
-
fontSize={1}
|
|
178
|
-
onClick={() => setShowTokenSetup(true)}
|
|
179
|
-
style={{ cursor: 'pointer', flexShrink: 0 }}
|
|
180
|
-
/>
|
|
181
|
-
</Flex>
|
|
182
|
-
</Card>
|
|
183
|
-
)}
|
|
184
|
-
|
|
185
|
-
{/* ── No targets ──────────────────────────────────────────── */}
|
|
186
|
-
{targets.length === 0 && (
|
|
187
|
-
<Card padding={5} radius={2} tone="transparent" shadow={1}>
|
|
188
|
-
<Stack space={4} style={{ textAlign: 'center' }}>
|
|
189
|
-
<Text size={2} weight="semibold">No deploy targets configured</Text>
|
|
190
|
-
<Text size={1} muted>
|
|
191
|
-
Add a deploy target using the button above, or create a{' '}
|
|
192
|
-
<code>vercel_deploy</code> document directly in the dataset.
|
|
193
|
-
</Text>
|
|
194
|
-
<Flex justify="center">
|
|
195
|
-
<Button
|
|
196
|
-
text="Add deploy target"
|
|
197
|
-
tone="primary"
|
|
198
|
-
icon={AddIcon}
|
|
199
|
-
onClick={() => setShowCreateForm(true)}
|
|
200
|
-
style={{ cursor: 'pointer' }}
|
|
201
|
-
/>
|
|
202
|
-
</Flex>
|
|
203
|
-
</Stack>
|
|
204
|
-
</Card>
|
|
205
|
-
)}
|
|
206
|
-
|
|
207
|
-
{/* ── Deploy targets — responsive 2-col grid ──────────────── */}
|
|
208
|
-
{targets.length > 0 && (
|
|
209
|
-
<div className="dvfs-grid" style={{
|
|
210
|
-
display: 'grid',
|
|
211
|
-
gridTemplateColumns: 'repeat(auto-fill, minmax(min(540px, 100%), 1fr))',
|
|
212
|
-
gap: '16px',
|
|
213
|
-
alignItems: 'start',
|
|
214
|
-
}}>
|
|
215
|
-
{targets.map(target => (
|
|
216
|
-
<DeployItem
|
|
217
|
-
key={target._id}
|
|
218
|
-
target={target}
|
|
219
|
-
token={token ?? ''}
|
|
220
|
-
onDelete={setPendingDelete}
|
|
221
|
-
onEdit={setPendingEdit}
|
|
222
|
-
/>
|
|
223
|
-
))}
|
|
224
|
-
</div>
|
|
225
|
-
)}
|
|
226
|
-
|
|
227
|
-
</Stack>
|
|
228
|
-
</Box>
|
|
229
|
-
|
|
230
|
-
{/* ── Version watermark ──────────────────────────────────────────── */}
|
|
231
|
-
<Box
|
|
232
|
-
style={{
|
|
233
|
-
position: 'fixed',
|
|
234
|
-
bottom: 12,
|
|
235
|
-
right: 16,
|
|
236
|
-
opacity: 0.25,
|
|
237
|
-
pointerEvents: 'none',
|
|
238
|
-
userSelect: 'none',
|
|
239
|
-
}}
|
|
240
|
-
>
|
|
241
|
-
<Text size={0} muted>v{VERSION}</Text>
|
|
242
|
-
</Box>
|
|
243
|
-
|
|
244
|
-
{/* ── Token setup dialog ──────────────────────────────────────────── */}
|
|
245
|
-
{/* The token dialog is always dismissable. Withholding Cancel on first run —
|
|
246
|
-
exactly when the user may not have a token to hand — made it a keyboard trap,
|
|
247
|
-
since Sanity's Dialog holds focus and Escape was wired to the absent handler. */}
|
|
248
|
-
{showTokenSetup && (
|
|
249
|
-
<TokenSetup
|
|
250
|
-
onSaved={() => { setShowTokenSetup(false); load() }}
|
|
251
|
-
onCancel={() => setShowTokenSetup(false)}
|
|
252
|
-
/>
|
|
253
|
-
)}
|
|
254
|
-
|
|
255
|
-
{/* ── Create form ─────────────────────────────────────────────────── */}
|
|
256
|
-
{showCreateForm && (
|
|
257
|
-
<DeployTargetForm
|
|
258
|
-
onSaved={() => { setShowCreateForm(false); toast.push({ status: 'success', title: 'Deploy target added' }) }}
|
|
259
|
-
onClose={() => setShowCreateForm(false)}
|
|
260
|
-
/>
|
|
261
|
-
)}
|
|
262
|
-
|
|
263
|
-
{/* ── Edit form ───────────────────────────────────────────────────── */}
|
|
264
|
-
{pendingEdit && (
|
|
265
|
-
<DeployTargetForm
|
|
266
|
-
initial={pendingEdit}
|
|
267
|
-
onSaved={() => { setPendingEdit(null); toast.push({ status: 'success', title: 'Deploy target updated' }) }}
|
|
268
|
-
onClose={() => setPendingEdit(null)}
|
|
269
|
-
/>
|
|
270
|
-
)}
|
|
271
|
-
|
|
272
|
-
{/* ── Delete confirmation ─────────────────────────────────────────── */}
|
|
273
|
-
{pendingDelete && (
|
|
274
|
-
<Dialog
|
|
275
|
-
header="Delete deploy target?"
|
|
276
|
-
id="confirm-delete"
|
|
277
|
-
onClose={() => setPendingDelete(null)}
|
|
278
|
-
width={1}
|
|
279
|
-
footer={
|
|
280
|
-
<Flex padding={3} gap={2} justify="flex-end">
|
|
281
|
-
<Button
|
|
282
|
-
text="Cancel"
|
|
283
|
-
mode="ghost"
|
|
284
|
-
onClick={() => setPendingDelete(null)}
|
|
285
|
-
style={{ cursor: 'pointer' }}
|
|
286
|
-
/>
|
|
287
|
-
<Button
|
|
288
|
-
text="Delete"
|
|
289
|
-
tone="critical"
|
|
290
|
-
icon={TrashIcon}
|
|
291
|
-
loading={deleting}
|
|
292
|
-
disabled={deleting}
|
|
293
|
-
onClick={confirmDelete}
|
|
294
|
-
style={{ cursor: 'pointer' }}
|
|
295
|
-
/>
|
|
296
|
-
</Flex>
|
|
297
|
-
}
|
|
298
|
-
>
|
|
299
|
-
<Box padding={4}>
|
|
300
|
-
<Stack space={3}>
|
|
301
|
-
<Flex align="center" gap={2}>
|
|
302
|
-
<WarningOutlineIcon />
|
|
303
|
-
<Text size={2} weight="semibold">{pendingDelete.name}</Text>
|
|
304
|
-
</Flex>
|
|
305
|
-
<Text size={1} muted>
|
|
306
|
-
This removes the deploy target from the dataset. The Vercel deploy hook
|
|
307
|
-
itself is not affected.
|
|
308
|
-
</Text>
|
|
309
|
-
</Stack>
|
|
310
|
-
</Box>
|
|
311
|
-
</Dialog>
|
|
312
|
-
)}
|
|
313
|
-
|
|
314
|
-
{/* Fallback toast surface — renders nothing when @sanity/ui exports its own useToast */}
|
|
315
|
-
<ToastViewport />
|
|
316
|
-
</Card>
|
|
317
|
-
)
|
|
318
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// Status badge for a Vercel deployment state
|
|
2
|
-
import { stateLabel } from '../lib/helpers'
|
|
3
|
-
import type { VercelDeployState } from '../types'
|
|
4
|
-
import { Badge, Flex, Spinner } from '../compat'
|
|
5
|
-
|
|
6
|
-
interface StatusBadgeProps {
|
|
7
|
-
state: VercelDeployState | undefined
|
|
8
|
-
/** When true, shows a spinner alongside the label */
|
|
9
|
-
showSpinner?: boolean
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function StatusBadge({ state, showSpinner }: StatusBadgeProps) {
|
|
13
|
-
const { label, tone } = stateLabel(state)
|
|
14
|
-
const spinning = showSpinner && (state === 'QUEUED' || state === 'INITIALIZING' || state === 'BUILDING')
|
|
15
|
-
return (
|
|
16
|
-
<Flex align="center" gap={2}>
|
|
17
|
-
{spinning && <Spinner muted />}
|
|
18
|
-
<Badge tone={tone} padding={2}>
|
|
19
|
-
{label}
|
|
20
|
-
</Badge>
|
|
21
|
-
</Flex>
|
|
22
|
-
)
|
|
23
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// Vercel API token form — rendered inside a Dialog by DeployTool
|
|
2
|
-
import { useId, useState, useCallback } from 'react'
|
|
3
|
-
import { useClient } from 'sanity'
|
|
4
|
-
import { CheckmarkCircleIcon } from '../icons'
|
|
5
|
-
import { Button, Card, Dialog, Flex, Label, Stack, Text, TextInput } from '../compat'
|
|
6
|
-
|
|
7
|
-
interface TokenSetupProps {
|
|
8
|
-
/** Called after the token is successfully saved */
|
|
9
|
-
onSaved: () => void
|
|
10
|
-
/** Called when the user dismisses — only available when a token already exists */
|
|
11
|
-
onCancel?: () => void
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const TOKEN_DOC_ID = 'config.vercelDeploy'
|
|
15
|
-
|
|
16
|
-
export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
|
|
17
|
-
const tokenId = useId()
|
|
18
|
-
const client = useClient({ apiVersion: '2025-01-01' })
|
|
19
|
-
const [token, setToken] = useState('')
|
|
20
|
-
const [saving, setSaving] = useState(false)
|
|
21
|
-
const [error, setError] = useState<string | null>(null)
|
|
22
|
-
|
|
23
|
-
const save = useCallback(async () => {
|
|
24
|
-
if (!token.trim()) return
|
|
25
|
-
setSaving(true)
|
|
26
|
-
setError(null)
|
|
27
|
-
try {
|
|
28
|
-
await client.createOrReplace({
|
|
29
|
-
_id: TOKEN_DOC_ID,
|
|
30
|
-
_type: 'vercelDeploy.config',
|
|
31
|
-
accessToken: token.trim(),
|
|
32
|
-
})
|
|
33
|
-
onSaved()
|
|
34
|
-
} catch (err) {
|
|
35
|
-
setError(err instanceof Error ? err.message : 'Failed to save token')
|
|
36
|
-
} finally {
|
|
37
|
-
setSaving(false)
|
|
38
|
-
}
|
|
39
|
-
}, [client, token, onSaved])
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<Dialog
|
|
43
|
-
header="Connect Vercel API token"
|
|
44
|
-
id="token-setup"
|
|
45
|
-
onClose={onCancel}
|
|
46
|
-
width={1}
|
|
47
|
-
footer={
|
|
48
|
-
<Flex padding={3} gap={2} justify="flex-end">
|
|
49
|
-
{onCancel && (
|
|
50
|
-
<Button text="Cancel" mode="ghost" onClick={onCancel} style={{ cursor: 'pointer' }} />
|
|
51
|
-
)}
|
|
52
|
-
<Button
|
|
53
|
-
text="Save and connect"
|
|
54
|
-
tone="primary"
|
|
55
|
-
icon={CheckmarkCircleIcon}
|
|
56
|
-
loading={saving}
|
|
57
|
-
disabled={!token.trim() || saving}
|
|
58
|
-
onClick={save}
|
|
59
|
-
style={{ cursor: 'pointer' }}
|
|
60
|
-
/>
|
|
61
|
-
</Flex>
|
|
62
|
-
}
|
|
63
|
-
>
|
|
64
|
-
<Stack space={4} padding={4}>
|
|
65
|
-
<Stack space={3}>
|
|
66
|
-
<Text size={1} muted>
|
|
67
|
-
A Vercel API token lets this tool read deployment status, history, build logs,
|
|
68
|
-
and branch metadata. Without it you can still trigger deploys — you just won't
|
|
69
|
-
see any feedback.
|
|
70
|
-
</Text>
|
|
71
|
-
<Text size={1} muted>
|
|
72
|
-
Create one at <strong>vercel.com → Settings → Tokens</strong> with{' '}
|
|
73
|
-
<strong>Full Account</strong> scope. The token is stored in your Sanity dataset
|
|
74
|
-
and shared across all authenticated studio users.
|
|
75
|
-
</Text>
|
|
76
|
-
</Stack>
|
|
77
|
-
|
|
78
|
-
<Stack space={2}>
|
|
79
|
-
{/* A plain Text is not a label — @sanity/ui's Label needs `as="label"` to render one. */}
|
|
80
|
-
<Label as="label" size={1} htmlFor={tokenId}>Vercel API Token</Label>
|
|
81
|
-
<TextInput
|
|
82
|
-
id={tokenId}
|
|
83
|
-
value={token}
|
|
84
|
-
onChange={e => setToken((e.target as HTMLInputElement).value)}
|
|
85
|
-
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
|
|
86
|
-
type="password"
|
|
87
|
-
/>
|
|
88
|
-
</Stack>
|
|
89
|
-
|
|
90
|
-
{error && (
|
|
91
|
-
<Card tone="critical" padding={3} radius={2} role="alert">
|
|
92
|
-
<Text size={1}>{error}</Text>
|
|
93
|
-
</Card>
|
|
94
|
-
)}
|
|
95
|
-
</Stack>
|
|
96
|
-
</Dialog>
|
|
97
|
-
)
|
|
98
|
-
}
|
package/src/icons.tsx
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// Version-agnostic access to @sanity/icons — named exports on v2/v3/v4, <Icon symbol> on v5+
|
|
2
|
-
import { forwardRef } from 'react'
|
|
3
|
-
import type { ComponentType, SVGProps } from 'react'
|
|
4
|
-
import { ICONS, resolveExport } from './compat/resolve'
|
|
5
|
-
|
|
6
|
-
/** Props every Sanity icon accepts — it renders a plain sized SVG. */
|
|
7
|
-
export type IconProps = SVGProps<SVGSVGElement>
|
|
8
|
-
|
|
9
|
-
/** An icon component, whichever shape the installed @sanity/icons exposes it in. */
|
|
10
|
-
export type IconComponent = ComponentType<IconProps>
|
|
11
|
-
|
|
12
|
-
/** Sizing of a Sanity icon glyph — 1em square on a 25-unit viewBox, matching @sanity/icons. */
|
|
13
|
-
const GLYPH = { width: '1em', height: '1em', viewBox: '0 0 25 25', fill: 'none' } as const
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Placeholder for when the installed @sanity/icons exposes neither shape. Holds
|
|
17
|
-
* layout, draws nothing, and is hidden from assistive tech since every icon in
|
|
18
|
-
* this plugin is decorative.
|
|
19
|
-
*/
|
|
20
|
-
const MissingIcon = forwardRef<SVGSVGElement, IconProps>(function MissingIcon(props, ref) {
|
|
21
|
-
return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false" {...props} ref={ref} />
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
/** The v5+ `<Icon>` component, absent on icons v2, v3 and v4. */
|
|
25
|
-
type SymbolIcon = ComponentType<IconProps & { symbol: string }>
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* The symbol map v5+ exposes alongside `Icon`. Used to check a symbol exists
|
|
29
|
-
* before relying on it: `<Icon>` renders `null` for an unrecognised symbol, so
|
|
30
|
-
* a renamed glyph would otherwise vanish silently rather than reaching
|
|
31
|
-
* {@link MissingIcon}.
|
|
32
|
-
*/
|
|
33
|
-
const SYMBOL_MAP = resolveExport<Record<string, unknown>>(ICONS, 'icons')
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Resolve one glyph against whichever @sanity/icons the host Studio installed.
|
|
37
|
-
* Always reads from the host package, so new and revised artwork is picked up on
|
|
38
|
-
* the consumer's next @sanity/icons update without a release here.
|
|
39
|
-
*
|
|
40
|
-
* Note the named exports are not merely absent on v5 — they are declared `never`
|
|
41
|
-
* with a deprecation note pointing at per-icon subpaths, so a static named import
|
|
42
|
-
* is a type error as well as a runtime one. Subpaths only exist from v4.1.0,
|
|
43
|
-
* which is why this resolves at runtime instead.
|
|
44
|
-
*
|
|
45
|
-
* @param name Named export used by icons v2, v3 and v4, e.g. `RocketIcon`.
|
|
46
|
-
* @param symbol Kebab-case symbol used by the v5+ `<Icon>` component, e.g. `rocket`.
|
|
47
|
-
*/
|
|
48
|
-
function resolveIcon(name: string, symbol: string): IconComponent {
|
|
49
|
-
const named = resolveExport<IconComponent>(ICONS, name)
|
|
50
|
-
if (named) return named
|
|
51
|
-
|
|
52
|
-
const Icon = resolveExport<SymbolIcon>(ICONS, 'Icon')
|
|
53
|
-
if (!Icon) return MissingIcon
|
|
54
|
-
// An unknown symbol would render nothing at all; fall back to the sized placeholder instead.
|
|
55
|
-
if (SYMBOL_MAP && !(symbol in SYMBOL_MAP)) return MissingIcon
|
|
56
|
-
|
|
57
|
-
const Resolved = forwardRef<SVGSVGElement, IconProps>(function SanityIcon(props, ref) {
|
|
58
|
-
return <Icon symbol={symbol} {...props} ref={ref} />
|
|
59
|
-
})
|
|
60
|
-
Resolved.displayName = name
|
|
61
|
-
return Resolved
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export const AddIcon = resolveIcon('AddIcon', 'add')
|
|
65
|
-
export const CheckmarkCircleIcon = resolveIcon('CheckmarkCircleIcon', 'checkmark-circle')
|
|
66
|
-
export const CheckmarkIcon = resolveIcon('CheckmarkIcon', 'checkmark')
|
|
67
|
-
export const ChevronDownIcon = resolveIcon('ChevronDownIcon', 'chevron-down')
|
|
68
|
-
export const ChevronUpIcon = resolveIcon('ChevronUpIcon', 'chevron-up')
|
|
69
|
-
export const ClockIcon = resolveIcon('ClockIcon', 'clock')
|
|
70
|
-
export const CloseIcon = resolveIcon('CloseIcon', 'close')
|
|
71
|
-
export const CopyIcon = resolveIcon('CopyIcon', 'copy')
|
|
72
|
-
export const EditIcon = resolveIcon('EditIcon', 'edit')
|
|
73
|
-
export const EllipsisVerticalIcon = resolveIcon('EllipsisVerticalIcon', 'ellipsis-vertical')
|
|
74
|
-
export const LaunchIcon = resolveIcon('LaunchIcon', 'launch')
|
|
75
|
-
export const RocketIcon = resolveIcon('RocketIcon', 'rocket')
|
|
76
|
-
export const TokenIcon = resolveIcon('TokenIcon', 'token')
|
|
77
|
-
export const TrashIcon = resolveIcon('TrashIcon', 'trash')
|
|
78
|
-
export const WarningOutlineIcon = resolveIcon('WarningOutlineIcon', 'warning-outline')
|