@liiift-studio/deploy-vercel-from-sanity 1.2.0 → 1.3.0

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,173 +0,0 @@
1
- // Toast delivery — Studio's own toast system where available, a local live-region viewport otherwise
2
- import { useCallback, useEffect, useState } from 'react'
3
- import type { ReactNode } from 'react'
4
- import { UI, resolveExport } from './resolve'
5
- import { Box, Button, Card, Flex, Stack, Text } from './primitives'
6
- import { CloseIcon } from '../icons'
7
-
8
- /** A toast request — the subset of @sanity/ui's ToastParams this plugin uses. */
9
- export type ToastParams = {
10
- status?: 'success' | 'error' | 'warning' | 'info'
11
- title?: ReactNode
12
- description?: ReactNode
13
- }
14
-
15
- /** The object returned by useToast. Only `push` is used here. */
16
- export type Toaster = { push: (params: ToastParams) => void }
17
-
18
- /** A queued fallback toast. `id` is a monotonic counter, unique for the session. */
19
- type LocalToast = ToastParams & { id: number }
20
-
21
- /** How long a non-error fallback toast stays on screen, in milliseconds. */
22
- const LOCAL_TOAST_MS = 6000
23
-
24
- /** Most toasts kept on screen at once; older ones are dropped so the column cannot grow past the viewport. */
25
- const MAX_VISIBLE_TOASTS = 4
26
-
27
- /** Stacking index for the fallback viewport. High enough to clear Studio chrome, since it is not in Sanity's Layer stack. */
28
- const TOAST_Z_INDEX = 1000000
29
-
30
- /** Card tone per toast status, used only by the fallback viewport. */
31
- const LOCAL_TOAST_TONE: Record<string, 'positive' | 'critical' | 'caution' | 'primary'> = {
32
- success: 'positive',
33
- error: 'critical',
34
- warning: 'caution',
35
- info: 'primary',
36
- }
37
-
38
- /** Statuses that stay until dismissed — they carry text the user is expected to act on. */
39
- const PERSISTENT_STATUSES = new Set(['error', 'warning'])
40
-
41
- let nextToastId = 0
42
- let localToasts: LocalToast[] = []
43
- const toastListeners = new Set<(toasts: LocalToast[]) => void>()
44
- const dismissTimers = new Map<number, ReturnType<typeof setTimeout>>()
45
-
46
- /** Publish the current fallback queue to every mounted viewport. */
47
- function emitToasts(): void {
48
- for (const listener of toastListeners) listener(localToasts)
49
- }
50
-
51
- /** Remove one fallback toast and cancel its pending auto-dismiss. */
52
- function removeLocalToast(id: number): void {
53
- const timer = dismissTimers.get(id)
54
- if (timer) {
55
- clearTimeout(timer)
56
- dismissTimers.delete(id)
57
- }
58
- localToasts = localToasts.filter(t => t.id !== id)
59
- emitToasts()
60
- }
61
-
62
- /** Start (or restart) the auto-dismiss countdown for one toast. Persistent statuses are left alone. */
63
- function scheduleDismiss(id: number, status: ToastParams['status']): void {
64
- if (PERSISTENT_STATUSES.has(status ?? 'info')) return
65
- const existing = dismissTimers.get(id)
66
- if (existing) clearTimeout(existing)
67
- dismissTimers.set(id, setTimeout(() => removeLocalToast(id), LOCAL_TOAST_MS))
68
- }
69
-
70
- /** Queue a fallback toast, trimming the oldest if the column is already full. */
71
- function pushLocalToast(params: ToastParams): void {
72
- const toast: LocalToast = { ...params, id: nextToastId++ }
73
- localToasts = [...localToasts, toast].slice(-MAX_VISIBLE_TOASTS)
74
- emitToasts()
75
- scheduleDismiss(toast.id, toast.status)
76
- }
77
-
78
- /** The real hook when the installed @sanity/ui still exports it, otherwise undefined. */
79
- const installedUseToast = resolveExport<() => Toaster>(UI, 'useToast')
80
-
81
- /** Stable fallback toaster — identity never changes, so it is safe in dependency arrays. */
82
- const localToaster: Toaster = { push: pushLocalToast }
83
-
84
- /**
85
- * Push toasts through Studio's toast system where available, or through
86
- * {@link ToastViewport} on @sanity/ui v4+.
87
- *
88
- * The branch is decided once at module load, so the hook-call order of any
89
- * component using this is constant across renders.
90
- */
91
- export function useToast(): Toaster {
92
- const real = installedUseToast
93
- if (real) return real()
94
- return localToaster
95
- }
96
-
97
- /**
98
- * Live region for fallback toasts.
99
- *
100
- * The wrapper stays mounted and empty when there is nothing to show: assistive
101
- * tech has to observe a live region *before* content lands in it, so a region
102
- * created and populated in the same commit is routinely missed. Renders nothing
103
- * at all when the installed @sanity/ui provides its own toast system.
104
- */
105
- export function ToastViewport(): React.JSX.Element | null {
106
- const [toasts, setToasts] = useState<LocalToast[]>(localToasts)
107
-
108
- useEffect(() => {
109
- if (installedUseToast) return
110
- toastListeners.add(setToasts)
111
- // Re-read after subscribing: a toast pushed between render and commit would otherwise be missed.
112
- setToasts(localToasts)
113
- return () => { toastListeners.delete(setToasts) }
114
- }, [])
115
-
116
- const hold = useCallback((id: number) => {
117
- const timer = dismissTimers.get(id)
118
- if (timer) {
119
- clearTimeout(timer)
120
- dismissTimers.delete(id)
121
- }
122
- }, [])
123
-
124
- if (installedUseToast) return null
125
-
126
- return (
127
- <Box
128
- role="status"
129
- aria-live="polite"
130
- aria-atomic={false}
131
- // pointer-events is released so the fixed column cannot swallow clicks on the tool beneath it.
132
- style={{
133
- position: 'fixed',
134
- bottom: 16,
135
- right: 16,
136
- zIndex: TOAST_Z_INDEX,
137
- width: 'min(360px, calc(100vw - 32px))',
138
- pointerEvents: 'none',
139
- }}
140
- >
141
- <Stack space={2}>
142
- {toasts.map(toast => (
143
- <Card
144
- key={toast.id}
145
- padding={3}
146
- radius={2}
147
- shadow={3}
148
- tone={LOCAL_TOAST_TONE[toast.status ?? 'info'] ?? 'primary'}
149
- style={{ pointerEvents: 'auto' }}
150
- onMouseEnter={() => hold(toast.id)}
151
- onFocusCapture={() => hold(toast.id)}
152
- onMouseLeave={() => scheduleDismiss(toast.id, toast.status)}
153
- >
154
- <Flex align="flex-start" gap={3}>
155
- <Stack space={2} flex={1}>
156
- {toast.title && <Text size={1} weight="semibold">{toast.title}</Text>}
157
- {toast.description && <Text size={1} muted>{toast.description}</Text>}
158
- </Stack>
159
- <Button
160
- mode="bleed"
161
- padding={2}
162
- icon={CloseIcon}
163
- text=""
164
- aria-label="Dismiss notification"
165
- onClick={() => removeLocalToast(toast.id)}
166
- />
167
- </Flex>
168
- </Card>
169
- ))}
170
- </Stack>
171
- </Box>
172
- )
173
- }
@@ -1,78 +0,0 @@
1
- // Hover/focus hint — Studio's Tooltip where available, an accessible local tooltip otherwise
2
- import { useId, useState } from 'react'
3
- import type { ReactNode } from 'react'
4
- import { UI, resolveExport } from './resolve'
5
- import { Box, Card, Text } from './primitives'
6
- import type { ComponentType } from 'react'
7
-
8
- /** Props for the compat Tooltip — plain text rather than @sanity/ui's ReactNode `content`. */
9
- export type TooltipProps = {
10
- /** Hint text. Also used as the accessible description of the wrapped control. */
11
- text: string
12
- children: ReactNode
13
- }
14
-
15
- /** The real Tooltip when the installed @sanity/ui still exports it, otherwise undefined. */
16
- const InstalledTooltip = resolveExport<ComponentType<{
17
- content: ReactNode
18
- portal?: boolean
19
- children: ReactNode
20
- }>>(UI, 'Tooltip')
21
-
22
- /**
23
- * Hint shown on hover and on keyboard focus.
24
- *
25
- * The fallback deliberately does not use the native `title` attribute: `title`
26
- * never appears on keyboard focus, never appears on touch, cannot be dismissed,
27
- * and browsers will not re-read it while the pointer is stationary — which
28
- * silently broke the copy button's "Copied!" confirmation. This renders a real
29
- * element instead and wires it to the wrapped control via `aria-describedby`,
30
- * so the text is announced and updates when it changes.
31
- */
32
- export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
33
- const id = useId()
34
- const [visible, setVisible] = useState(false)
35
-
36
- if (InstalledTooltip) {
37
- return (
38
- <InstalledTooltip content={<Box padding={2}><Text size={1}>{text}</Text></Box>} portal>
39
- {children}
40
- </InstalledTooltip>
41
- )
42
- }
43
-
44
- return (
45
- <span
46
- style={{ position: 'relative', display: 'inline-flex' }}
47
- aria-describedby={id}
48
- onMouseEnter={() => setVisible(true)}
49
- onMouseLeave={() => setVisible(false)}
50
- onFocusCapture={() => setVisible(true)}
51
- onBlurCapture={() => setVisible(false)}
52
- onKeyDown={e => { if (e.key === 'Escape') setVisible(false) }}
53
- >
54
- {children}
55
- <Card
56
- id={id}
57
- role="tooltip"
58
- radius={2}
59
- shadow={2}
60
- padding={2}
61
- style={{
62
- position: 'absolute',
63
- bottom: '100%',
64
- left: '50%',
65
- transform: 'translateX(-50%)',
66
- marginBottom: 4,
67
- whiteSpace: 'nowrap',
68
- pointerEvents: 'none',
69
- zIndex: 1000,
70
- // Kept in the accessibility tree when hidden so `aria-describedby` still resolves.
71
- visibility: visible ? 'visible' : 'hidden',
72
- }}
73
- >
74
- <Text size={1}>{text}</Text>
75
- </Card>
76
- </span>
77
- )
78
- }
@@ -1,160 +0,0 @@
1
- // Deployment history modal — shows last 10 deployments for a target
2
- import { useEffect, useState, useCallback } from 'react'
3
- import { LaunchIcon, CloseIcon } from '../icons'
4
- import { listDeployments } from '../lib/api'
5
- import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref, deploymentHref } from '../lib/helpers'
6
- import type { DeployTarget, VercelDeployment } from '../types'
7
- import { Badge, Box, Button, Card, Dialog, Flex, Spinner, Stack, Text } from '../compat'
8
-
9
- interface DeployHistoryProps {
10
- target: DeployTarget
11
- token: string
12
- onClose: () => void
13
- }
14
-
15
- export function DeployHistory({ target, token, onClose }: DeployHistoryProps) {
16
- const [deployments, setDeployments] = useState<VercelDeployment[]>([])
17
- const [loading, setLoading] = useState(true)
18
- const [error, setError] = useState<string | null>(null)
19
-
20
- const { projectId, hookId } = parseHookUrl(target.url)
21
-
22
- const load = useCallback(async () => {
23
- setLoading(true)
24
- setError(null)
25
- try {
26
- const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId, limit: 10 })
27
- setDeployments(data)
28
- } catch (err) {
29
- setError(err instanceof Error ? err.message : 'Failed to load history')
30
- } finally {
31
- setLoading(false)
32
- }
33
- }, [projectId, hookId, token, target.teamId])
34
-
35
- useEffect(() => { load() }, [load])
36
-
37
- return (
38
- <Dialog
39
- header={`${target.name} — Deployment History`}
40
- id="deploy-history"
41
- onClose={onClose}
42
- width={2}
43
- footer={
44
- <Box padding={3}>
45
- <Button text="Close" icon={CloseIcon} mode="ghost" onClick={onClose} />
46
- </Box>
47
- }
48
- >
49
- <Box padding={4}>
50
- {loading && (
51
- <Flex justify="center" padding={6}>
52
- <Spinner muted />
53
- </Flex>
54
- )}
55
-
56
- {error && (
57
- <Card tone="critical" padding={4} radius={2}>
58
- <Text size={1}>{error}</Text>
59
- </Card>
60
- )}
61
-
62
- {!loading && !error && deployments.length === 0 && (
63
- <Card tone="transparent" padding={4}>
64
- <Text size={1} muted align="center">No deployments found for this hook.</Text>
65
- </Card>
66
- )}
67
-
68
- {!loading && deployments.length > 0 && (
69
- <Stack space={2}>
70
- {/* Column headers */}
71
- <Card padding={3} radius={2} tone="transparent">
72
- <Flex gap={3}>
73
- <Box flex={2}><Text size={0} weight="semibold" muted>Preview URL</Text></Box>
74
- <Box flex={1}><Text size={0} weight="semibold" muted>Status</Text></Box>
75
- <Box flex={2}><Text size={0} weight="semibold" muted>Branch · Commit</Text></Box>
76
- <Box flex={1}><Text size={0} weight="semibold" muted>Deployed</Text></Box>
77
- <Box style={{ width: 64 }}><Text size={0} weight="semibold" muted>Logs</Text></Box>
78
- </Flex>
79
- </Card>
80
-
81
- {deployments.map(d => {
82
- const { label, tone } = stateLabel(d.state)
83
- const branch = d.meta?.githubCommitRef ?? '—'
84
- const sha = shortSha(d.meta?.githubCommitSha)
85
- const message = d.meta?.githubCommitMessage?.split('\n')[0] ?? ''
86
-
87
- return (
88
- <Card key={d.uid} padding={3} radius={2} shadow={1} tone="default">
89
- <Flex gap={3} align="center">
90
- {/* Preview URL */}
91
- <Box flex={2} style={{ overflow: 'hidden' }}>
92
- {deploymentHref(d.url) ? (
93
- <a
94
- href={deploymentHref(d.url)}
95
- target="_blank"
96
- rel="noreferrer"
97
- style={{ color: 'inherit' }}
98
- >
99
- <Text size={1} style={{ textDecoration: 'underline' }}>
100
- {d.url.length > 36 ? `${d.url.slice(0, 36)}…` : d.url}
101
- </Text>
102
- </a>
103
- ) : (
104
- <Text size={1} muted>—</Text>
105
- )}
106
- </Box>
107
-
108
- {/* Status */}
109
- <Box flex={1}>
110
- <Badge tone={tone}>{label}</Badge>
111
- </Box>
112
-
113
- {/* Branch + commit */}
114
- <Box flex={2} style={{ overflow: 'hidden' }}>
115
- <Stack space={1}>
116
- <Text size={1}>{branch}</Text>
117
- {sha && (
118
- <Text size={0} muted>
119
- {sha}{message ? ` · ${message.slice(0, 40)}${message.length > 40 ? '…' : ''}` : ''}
120
- </Text>
121
- )}
122
- </Stack>
123
- </Box>
124
-
125
- {/* Time */}
126
- <Box flex={1}>
127
- <Text size={1} muted>{timeAgo(d.created)}</Text>
128
- </Box>
129
-
130
- {/* Build logs link. Rendered as a link, not a Button inside an anchor:
131
- nesting interactive content in <a> gives two focus stops and Enter
132
- activates the button, which has no handler, so the link never opens
133
- for keyboard users. */}
134
- <Box style={{ width: 64 }}>
135
- {safeHref(d.inspectorUrl) ? (
136
- <Button
137
- as="a"
138
- href={safeHref(d.inspectorUrl)}
139
- target="_blank"
140
- rel="noreferrer"
141
- text="Logs"
142
- mode="ghost"
143
- tone="default"
144
- icon={LaunchIcon}
145
- style={{ fontSize: '12px' }}
146
- />
147
- ) : (
148
- <Text size={1} muted>—</Text>
149
- )}
150
- </Box>
151
- </Flex>
152
- </Card>
153
- )
154
- })}
155
- </Stack>
156
- )}
157
- </Box>
158
- </Dialog>
159
- )
160
- }