@liiift-studio/deploy-vercel-from-sanity 0.1.7 → 0.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.
@@ -0,0 +1,143 @@
1
+ // Dialog form for creating and editing vercel_deploy documents
2
+ import { useState, useCallback } from 'react'
3
+ import { useClient } from 'sanity'
4
+ import {
5
+ Dialog, Box, Stack, Flex, Text, TextInput, Button, Switch, Label, Card,
6
+ } from '@sanity/ui'
7
+ import { CheckmarkCircleIcon } from '@sanity/icons'
8
+ import type { DeployTarget } from '../types'
9
+
10
+ const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
11
+
12
+ interface DeployTargetFormProps {
13
+ /** When provided, form is in edit mode; otherwise create mode */
14
+ initial?: DeployTarget
15
+ onSaved: () => void
16
+ onClose: () => void
17
+ }
18
+
19
+ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetFormProps) {
20
+ const client = useClient({ apiVersion: '2025-01-01' })
21
+ const isEdit = Boolean(initial)
22
+
23
+ const [name, setName] = useState(initial?.name ?? '')
24
+ const [url, setUrl] = useState(initial?.url ?? '')
25
+ const [teamId, setTeamId] = useState(initial?.teamId ?? '')
26
+ const [disableDelete, setDisableDelete] = useState(initial?.disableDeleteAction ?? false)
27
+ const [saving, setSaving] = useState(false)
28
+ const [error, setError] = useState<string | null>(null)
29
+
30
+ const urlValid = !url || VERCEL_HOOK_RE.test(url.trim())
31
+ const canSave = name.trim() && url.trim() && urlValid
32
+
33
+ const save = useCallback(async () => {
34
+ if (!canSave) return
35
+ setSaving(true)
36
+ setError(null)
37
+ const fields = {
38
+ name: name.trim(),
39
+ url: url.trim(),
40
+ ...(teamId.trim() ? { teamId: teamId.trim() } : { teamId: null }),
41
+ disableDeleteAction: disableDelete,
42
+ }
43
+ try {
44
+ if (isEdit && initial) {
45
+ await client.patch(initial._id).set(fields).commit()
46
+ } else {
47
+ await client.create({ _type: 'vercel_deploy', ...fields })
48
+ }
49
+ onSaved()
50
+ } catch (err) {
51
+ setError(err instanceof Error ? err.message : 'Save failed')
52
+ } finally {
53
+ setSaving(false)
54
+ }
55
+ }, [canSave, isEdit, initial, client, name, url, teamId, disableDelete, onSaved])
56
+
57
+ return (
58
+ <Dialog
59
+ header={isEdit ? `Edit "${initial?.name}"` : 'Add deploy target'}
60
+ id="deploy-target-form"
61
+ onClose={onClose}
62
+ width={1}
63
+ footer={
64
+ <Flex padding={3} gap={2} justify="flex-end">
65
+ <Button text="Cancel" mode="ghost" onClick={onClose} style={{ cursor: 'pointer' }} />
66
+ <Button
67
+ text={isEdit ? 'Save changes' : 'Add target'}
68
+ tone="primary"
69
+ icon={CheckmarkCircleIcon}
70
+ loading={saving}
71
+ disabled={!canSave || saving}
72
+ onClick={save}
73
+ style={{ cursor: 'pointer' }}
74
+ />
75
+ </Flex>
76
+ }
77
+ >
78
+ <Box padding={4}>
79
+ <Stack space={4}>
80
+
81
+ {/* Name */}
82
+ <Stack space={2}>
83
+ <Label size={1}>Name <span style={{ color: 'var(--card-fg-color)' }}>*</span></Label>
84
+ <TextInput
85
+ value={name}
86
+ onChange={e => setName((e.target as HTMLInputElement).value)}
87
+ placeholder="Production"
88
+ />
89
+ </Stack>
90
+
91
+ {/* Deploy hook URL */}
92
+ <Stack space={2}>
93
+ <Label size={1}>Deploy hook URL <span style={{ color: 'var(--card-fg-color)' }}>*</span></Label>
94
+ <TextInput
95
+ value={url}
96
+ onChange={e => setUrl((e.target as HTMLInputElement).value)}
97
+ placeholder="https://api.vercel.com/v1/integrations/deploy/…"
98
+ />
99
+ {url && !urlValid && (
100
+ <Text size={1} style={{ color: 'var(--card-critical-fg-color, red)' }}>
101
+ Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)
102
+ </Text>
103
+ )}
104
+ <Text size={0} muted>
105
+ Vercel dashboard → Project → Settings → Git → Deploy Hooks
106
+ </Text>
107
+ </Stack>
108
+
109
+ {/* Team ID */}
110
+ <Stack space={2}>
111
+ <Label size={1}>Team ID <span style={{ opacity: 0.5, fontWeight: 'normal' }}>— optional</span></Label>
112
+ <TextInput
113
+ value={teamId}
114
+ onChange={e => setTeamId((e.target as HTMLInputElement).value)}
115
+ placeholder="team_xxxxxxxx"
116
+ />
117
+ <Text size={0} muted>Required for team-owned Vercel projects.</Text>
118
+ </Stack>
119
+
120
+ {/* Disable delete */}
121
+ <Flex align="center" gap={3}>
122
+ <Switch
123
+ checked={disableDelete}
124
+ onChange={e => setDisableDelete((e.target as HTMLInputElement).checked)}
125
+ id="disable-delete"
126
+ />
127
+ <Stack space={1}>
128
+ <Label size={1} htmlFor="disable-delete">Disable delete action</Label>
129
+ <Text size={0} muted>Hides the delete button for this target in the studio.</Text>
130
+ </Stack>
131
+ </Flex>
132
+
133
+ {error && (
134
+ <Card tone="critical" padding={3} radius={2}>
135
+ <Text size={1}>{error}</Text>
136
+ </Card>
137
+ )}
138
+
139
+ </Stack>
140
+ </Box>
141
+ </Dialog>
142
+ )
143
+ }
@@ -2,26 +2,30 @@
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, Badge, Dialog, useToast,
5
+ Card, Box, Stack, Flex, Text, Heading, Spinner, Button, Dialog, useToast,
6
6
  } from '@sanity/ui'
7
- import { RocketIcon, TokenIcon, TrashIcon, WarningOutlineIcon } from '@sanity/icons'
7
+ import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '@sanity/icons'
8
8
  import { DeployItem } from './DeployItem'
9
9
  import { TokenSetup } from './TokenSetup'
10
+ import { DeployTargetForm } from './DeployTargetForm'
11
+ import { VERSION } from '../version'
10
12
  import type { DeployTarget } from '../types'
11
13
 
12
- const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
14
+ const TOKEN_QUERY = `*[_id == "config.vercelDeploy"][0].accessToken`
13
15
  const TARGETS_QUERY = `*[_type == "vercel_deploy"] | order(_createdAt asc)`
14
16
 
15
17
  export function DeployTool() {
16
18
  const client = useClient({ apiVersion: '2025-01-01' })
17
- const toast = useToast()
19
+ const toast = useToast()
18
20
 
19
- const [token, setToken] = useState<string | null>(null)
20
- const [targets, setTargets] = useState<DeployTarget[]>([])
21
- const [loading, setLoading] = useState(true)
21
+ const [token, setToken] = useState<string | null>(null)
22
+ const [targets, setTargets] = useState<DeployTarget[]>([])
23
+ const [loading, setLoading] = useState(true)
22
24
  const [showTokenSetup, setShowTokenSetup] = useState(false)
23
- const [pendingDelete, setPendingDelete] = useState<DeployTarget | null>(null)
24
- const [deleting, setDeleting] = useState(false)
25
+ const [showCreateForm, setShowCreateForm] = useState(false)
26
+ const [pendingEdit, setPendingEdit] = useState<DeployTarget | null>(null)
27
+ const [pendingDelete, setPendingDelete] = useState<DeployTarget | null>(null)
28
+ const [deleting, setDeleting] = useState(false)
25
29
 
26
30
  // ── Fetch token + targets ─────────────────────────────────────────────────
27
31
  const load = useCallback(async () => {
@@ -42,6 +46,25 @@ export function DeployTool() {
42
46
 
43
47
  useEffect(() => { load() }, [load])
44
48
 
49
+ // Inject responsive styles once on mount
50
+ useEffect(() => {
51
+ if (document.getElementById('dvfs-styles')) return
52
+ const style = document.createElement('style')
53
+ style.id = 'dvfs-styles'
54
+ style.textContent = `
55
+ @media (max-width: 600px) {
56
+ .dvfs-header { flex-wrap: wrap !important; row-gap: 8px !important; }
57
+ .dvfs-header-actions { width: 100% !important; flex-wrap: wrap !important; justify-content: flex-start !important; }
58
+ .dvfs-grid { grid-template-columns: 1fr !important; }
59
+ .dvfs-card-flex { flex-direction: column !important; }
60
+ .dvfs-deploy-col { width: 100% !important; align-self: auto !important; }
61
+ .dvfs-deploy-col button { border-radius: 3px !important; }
62
+ }
63
+ `
64
+ document.head.appendChild(style)
65
+ return () => { document.getElementById('dvfs-styles')?.remove() }
66
+ }, [])
67
+
45
68
  // Live subscription — update targets when documents change
46
69
  useEffect(() => {
47
70
  const sub = client
@@ -81,72 +104,141 @@ export function DeployTool() {
81
104
  )
82
105
  }
83
106
 
84
- if (!token || showTokenSetup) {
85
- return (
86
- <TokenSetup
87
- onSaved={() => {
88
- setShowTokenSetup(false)
89
- load()
90
- }}
91
- />
92
- )
93
- }
94
-
95
107
  return (
96
108
  <Card height="fill" tone="transparent">
97
109
  <Box padding={5}>
98
110
  <Stack space={5}>
111
+
99
112
  {/* ── Header ──────────────────────────────────────────────── */}
100
- <Flex align="center" justify="space-between">
101
- <Flex align="center" gap={3}>
102
- <RocketIcon />
103
- <Heading size={2}>Deploy</Heading>
104
- </Flex>
105
- <Flex align="center" gap={3}>
106
- <Badge tone="positive" mode="outline">Connected</Badge>
113
+ <Flex align="center" justify="space-between" className="dvfs-header">
114
+ <Heading size={2}>Deploy with Vercel</Heading>
115
+ <Flex align="center" gap={3} className="dvfs-header-actions">
107
116
  <Button
108
- text="Change Token"
117
+ text={token ? 'Token connected' : 'Connect API token'}
109
118
  mode="ghost"
110
119
  icon={TokenIcon}
111
120
  fontSize={1}
121
+ tone={token ? 'positive' : 'caution'}
112
122
  onClick={() => setShowTokenSetup(true)}
123
+ style={{ cursor: 'pointer' }}
124
+ />
125
+ <Button
126
+ text="Add target"
127
+ mode="ghost"
128
+ icon={AddIcon}
129
+ fontSize={1}
130
+ onClick={() => setShowCreateForm(true)}
131
+ style={{ cursor: 'pointer' }}
113
132
  />
114
133
  </Flex>
115
134
  </Flex>
116
135
 
136
+ {/* ── No-token upgrade banner ──────────────────────────────── */}
137
+ {!token && (
138
+ <Card padding={4} radius={2} tone="caution" shadow={1}>
139
+ <Flex align="center" justify="space-between" gap={4}>
140
+ <Stack space={2}>
141
+ <Text size={1} weight="semibold">Deploy status is not connected</Text>
142
+ <Text size={1} muted>
143
+ You can trigger deploys now. Connect a Vercel API token to also see
144
+ deployment status, build logs, history, and commit metadata.
145
+ </Text>
146
+ </Stack>
147
+ <Button
148
+ text="Connect"
149
+ tone="caution"
150
+ fontSize={1}
151
+ onClick={() => setShowTokenSetup(true)}
152
+ style={{ cursor: 'pointer', flexShrink: 0 }}
153
+ />
154
+ </Flex>
155
+ </Card>
156
+ )}
157
+
117
158
  {/* ── No targets ──────────────────────────────────────────── */}
118
159
  {targets.length === 0 && (
119
160
  <Card padding={5} radius={2} tone="transparent" shadow={1}>
120
- <Stack space={3} style={{ textAlign: 'center' }}>
161
+ <Stack space={4} style={{ textAlign: 'center' }}>
121
162
  <Text size={2} weight="semibold">No deploy targets configured</Text>
122
163
  <Text size={1} muted>
123
- Create a <code>vercel_deploy</code> document in the dataset with a Vercel
124
- deploy hook URL.
164
+ Add a deploy target using the button above, or create a{' '}
165
+ <code>vercel_deploy</code> document directly in the dataset.
125
166
  </Text>
167
+ <Flex justify="center">
168
+ <Button
169
+ text="Add deploy target"
170
+ tone="primary"
171
+ icon={AddIcon}
172
+ onClick={() => setShowCreateForm(true)}
173
+ style={{ cursor: 'pointer' }}
174
+ />
175
+ </Flex>
126
176
  </Stack>
127
177
  </Card>
128
178
  )}
129
179
 
130
180
  {/* ── Deploy targets — responsive 2-col grid ──────────────── */}
131
181
  {targets.length > 0 && (
132
- <div style={{
182
+ <div className="dvfs-grid" style={{
133
183
  display: 'grid',
134
184
  gridTemplateColumns: 'repeat(auto-fill, minmax(540px, 1fr))',
135
185
  gap: '16px',
186
+ alignItems: 'start',
136
187
  }}>
137
188
  {targets.map(target => (
138
189
  <DeployItem
139
190
  key={target._id}
140
191
  target={target}
141
- token={token}
192
+ token={token ?? ''}
142
193
  onDelete={setPendingDelete}
194
+ onEdit={setPendingEdit}
143
195
  />
144
196
  ))}
145
197
  </div>
146
198
  )}
199
+
147
200
  </Stack>
148
201
  </Box>
149
202
 
203
+ {/* ── Version watermark ──────────────────────────────────────────── */}
204
+ <Box
205
+ style={{
206
+ position: 'fixed',
207
+ bottom: 12,
208
+ right: 16,
209
+ opacity: 0.25,
210
+ pointerEvents: 'none',
211
+ userSelect: 'none',
212
+ }}
213
+ >
214
+ <Text size={0} muted>v{VERSION}</Text>
215
+ </Box>
216
+
217
+ {/* ── Token setup dialog ──────────────────────────────────────────── */}
218
+ {showTokenSetup && (
219
+ <TokenSetup
220
+ onSaved={() => { setShowTokenSetup(false); load() }}
221
+ onCancel={token ? () => setShowTokenSetup(false) : undefined}
222
+ />
223
+ )}
224
+
225
+ {/* ── Create form ─────────────────────────────────────────────────── */}
226
+ {showCreateForm && (
227
+ <DeployTargetForm
228
+ onSaved={() => { setShowCreateForm(false); toast.push({ status: 'success', title: 'Deploy target added' }) }}
229
+ onClose={() => setShowCreateForm(false)}
230
+ />
231
+ )}
232
+
233
+ {/* ── Edit form ───────────────────────────────────────────────────── */}
234
+ {pendingEdit && (
235
+ <DeployTargetForm
236
+ initial={pendingEdit}
237
+ onSaved={() => { setPendingEdit(null); toast.push({ status: 'success', title: 'Deploy target updated' }) }}
238
+ onClose={() => setPendingEdit(null)}
239
+ />
240
+ )}
241
+
150
242
  {/* ── Delete confirmation ─────────────────────────────────────────── */}
151
243
  {pendingDelete && (
152
244
  <Dialog
@@ -160,6 +252,7 @@ export function DeployTool() {
160
252
  text="Cancel"
161
253
  mode="ghost"
162
254
  onClick={() => setPendingDelete(null)}
255
+ style={{ cursor: 'pointer' }}
163
256
  />
164
257
  <Button
165
258
  text="Delete"
@@ -168,6 +261,7 @@ export function DeployTool() {
168
261
  loading={deleting}
169
262
  disabled={deleting}
170
263
  onClick={confirmDelete}
264
+ style={{ cursor: 'pointer' }}
171
265
  />
172
266
  </Flex>
173
267
  }
@@ -15,7 +15,7 @@ export function StatusBadge({ state, showSpinner }: StatusBadgeProps) {
15
15
  return (
16
16
  <Flex align="center" gap={2}>
17
17
  {spinning && <Spinner muted />}
18
- <Badge tone={tone} mode="outline">
18
+ <Badge tone={tone} padding={2}>
19
19
  {label}
20
20
  </Badge>
21
21
  </Flex>
@@ -1,19 +1,19 @@
1
- // Vercel API token setupstores token in secrets.vercelDeploy Sanity document
1
+ // Vercel API token formrendered inside a Dialog by DeployTool
2
2
  import { useState, useCallback } from 'react'
3
3
  import { useClient } from 'sanity'
4
- import {
5
- Card, Box, Stack, Text, Heading, TextInput, Button, Flex,
6
- } from '@sanity/ui'
7
- import { TokenIcon, CheckmarkCircleIcon } from '@sanity/icons'
4
+ import { Stack, Text, TextInput, Button, Card, Dialog, Flex } from '@sanity/ui'
5
+ import { CheckmarkCircleIcon } from '@sanity/icons'
8
6
 
9
7
  interface TokenSetupProps {
10
8
  /** Called after the token is successfully saved */
11
9
  onSaved: () => void
10
+ /** Called when the user dismisses — only available when a token already exists */
11
+ onCancel?: () => void
12
12
  }
13
13
 
14
14
  const TOKEN_DOC_ID = 'config.vercelDeploy'
15
15
 
16
- export function TokenSetup({ onSaved }: TokenSetupProps) {
16
+ export function TokenSetup({ onSaved, onCancel }: TokenSetupProps) {
17
17
  const client = useClient({ apiVersion: '2025-01-01' })
18
18
  const [token, setToken] = useState('')
19
19
  const [saving, setSaving] = useState(false)
@@ -38,55 +38,58 @@ export function TokenSetup({ onSaved }: TokenSetupProps) {
38
38
  }, [client, token, onSaved])
39
39
 
40
40
  return (
41
- <Card height="fill" tone="transparent">
42
- <Flex align="center" justify="center" height="fill" padding={6}>
43
- <Card padding={5} radius={3} shadow={1} style={{ maxWidth: 480, width: '100%' }}>
44
- <Stack space={5}>
45
- <Flex align="center" gap={3}>
46
- <Text size={3}><TokenIcon /></Text>
47
- <Heading size={2}>Connect to Vercel</Heading>
48
- </Flex>
41
+ <Dialog
42
+ header="Connect Vercel API token"
43
+ id="token-setup"
44
+ onClose={onCancel}
45
+ width={1}
46
+ footer={
47
+ <Flex padding={3} gap={2} justify="flex-end">
48
+ {onCancel && (
49
+ <Button text="Cancel" mode="ghost" onClick={onCancel} style={{ cursor: 'pointer' }} />
50
+ )}
51
+ <Button
52
+ text="Save and connect"
53
+ tone="primary"
54
+ icon={CheckmarkCircleIcon}
55
+ loading={saving}
56
+ disabled={!token.trim() || saving}
57
+ onClick={save}
58
+ style={{ cursor: 'pointer' }}
59
+ />
60
+ </Flex>
61
+ }
62
+ >
63
+ <Stack space={4} padding={4}>
64
+ <Stack space={3}>
65
+ <Text size={1} muted>
66
+ A Vercel API token lets this tool read deployment status, history, build logs,
67
+ and branch metadata. Without it you can still trigger deploys — you just won't
68
+ see any feedback.
69
+ </Text>
70
+ <Text size={1} muted>
71
+ Create one at <strong>vercel.com → Settings → Tokens</strong> with{' '}
72
+ <strong>Full Account</strong> scope. The token is stored in your Sanity dataset
73
+ and shared across all authenticated studio users.
74
+ </Text>
75
+ </Stack>
49
76
 
50
- <Stack space={3}>
51
- <Text size={1} muted>
52
- A Vercel API token is required to read deployment status, history, and build logs.
53
- Your token is stored securely in the Sanity dataset under a{' '}
54
- <code>secrets.*</code> document ID that is not publicly readable.
55
- </Text>
56
- <Text size={1} muted>
57
- Create a token at{' '}
58
- <strong>vercel.com → Settings → Tokens</strong>.
59
- Choose <strong>Full Account</strong> scope.
60
- </Text>
61
- </Stack>
77
+ <Stack space={2}>
78
+ <Text size={1} weight="semibold">Vercel API Token</Text>
79
+ <TextInput
80
+ value={token}
81
+ onChange={e => setToken((e.target as HTMLInputElement).value)}
82
+ placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
83
+ type="password"
84
+ />
85
+ </Stack>
62
86
 
63
- <Stack space={3}>
64
- <Text size={1} weight="semibold">Vercel API Token</Text>
65
- <TextInput
66
- value={token}
67
- onChange={e => setToken((e.target as HTMLInputElement).value)}
68
- placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
69
- type="password"
70
- />
71
- </Stack>
72
-
73
- {error && (
74
- <Card tone="critical" padding={3} radius={2}>
75
- <Text size={1}>{error}</Text>
76
- </Card>
77
- )}
78
-
79
- <Button
80
- text="Save and connect"
81
- tone="primary"
82
- icon={CheckmarkCircleIcon}
83
- loading={saving}
84
- disabled={!token.trim() || saving}
85
- onClick={save}
86
- />
87
- </Stack>
88
- </Card>
89
- </Flex>
90
- </Card>
87
+ {error && (
88
+ <Card tone="critical" padding={3} radius={2}>
89
+ <Text size={1}>{error}</Text>
90
+ </Card>
91
+ )}
92
+ </Stack>
93
+ </Dialog>
91
94
  )
92
95
  }
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Package version — keep in sync with package.json
2
+ export const VERSION = '0.2.0'