@liiift-studio/deploy-vercel-from-sanity 1.1.1 → 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 +33 -19
- package/dist/index.d.mts +6 -8
- package/dist/index.d.ts +6 -8
- package/dist/index.js +872 -431
- package/dist/index.mjs +863 -465
- package/package.json +21 -11
- package/src/components/DeployHistory.tsx +0 -158
- package/src/components/DeployItem.tsx +0 -571
- package/src/components/DeployTargetForm.tsx +0 -146
- package/src/components/DeployTool.tsx +0 -296
- package/src/components/StatusBadge.tsx +0 -23
- package/src/components/TokenSetup.tsx +0 -98
- package/src/icons.tsx +0 -71
- package/src/index.ts +0 -42
- package/src/lib/api.ts +0 -96
- package/src/lib/helpers.ts +0 -114
- package/src/schema/vercelDeploy.ts +0 -49
- package/src/types.ts +0 -77
- package/src/ui.tsx +0 -337
- package/src/version.ts +0 -2
package/src/lib/api.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// Vercel REST API helpers — all calls require a bearer token
|
|
2
|
-
import type { VercelDeployment, DeploymentEvent } from '../types'
|
|
3
|
-
|
|
4
|
-
const BASE = 'https://api.vercel.com'
|
|
5
|
-
|
|
6
|
-
/** Only allow genuine Vercel deploy hook URLs through triggerDeploy */
|
|
7
|
-
const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
|
|
8
|
-
|
|
9
|
-
async function vercelFetch<T>(path: string, token: string, init?: RequestInit): Promise<T> {
|
|
10
|
-
const res = await fetch(`${BASE}${path}`, {
|
|
11
|
-
...init,
|
|
12
|
-
headers: {
|
|
13
|
-
Authorization: `Bearer ${token}`,
|
|
14
|
-
'Content-Type': 'application/json',
|
|
15
|
-
...init?.headers,
|
|
16
|
-
},
|
|
17
|
-
})
|
|
18
|
-
if (!res.ok) {
|
|
19
|
-
const hint =
|
|
20
|
-
res.status === 401 ? ' — token is invalid or expired. Reconnect your API token.' :
|
|
21
|
-
res.status === 403 ? ' — token lacks the required permissions. Ensure it has Full Account scope.' :
|
|
22
|
-
res.status === 404 ? ' — resource not found. Check the deploy hook URL and team ID.' :
|
|
23
|
-
res.status === 429 ? ' — rate limit reached. Wait a moment and try again.' :
|
|
24
|
-
res.status >= 500 ? ' — Vercel is experiencing issues. Try again shortly.' :
|
|
25
|
-
''
|
|
26
|
-
throw new Error(`Vercel API ${res.status}${hint}`)
|
|
27
|
-
}
|
|
28
|
-
return res.json() as Promise<T>
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Fetch the last N deployments triggered by a specific deploy hook */
|
|
32
|
-
export async function listDeployments(opts: {
|
|
33
|
-
projectId: string
|
|
34
|
-
hookId: string
|
|
35
|
-
token: string
|
|
36
|
-
teamId?: string
|
|
37
|
-
limit?: number
|
|
38
|
-
}): Promise<VercelDeployment[]> {
|
|
39
|
-
const params = new URLSearchParams({
|
|
40
|
-
projectId: opts.projectId,
|
|
41
|
-
'meta-deployHookId': opts.hookId,
|
|
42
|
-
limit: String(opts.limit ?? 10),
|
|
43
|
-
})
|
|
44
|
-
if (opts.teamId) params.set('teamId', opts.teamId)
|
|
45
|
-
const data = await vercelFetch<{ deployments: VercelDeployment[] }>(
|
|
46
|
-
`/v6/deployments?${params}`,
|
|
47
|
-
opts.token,
|
|
48
|
-
)
|
|
49
|
-
return data.deployments ?? []
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Cancel an in-progress deployment */
|
|
53
|
-
export async function cancelDeployment(opts: {
|
|
54
|
-
deploymentId: string
|
|
55
|
-
token: string
|
|
56
|
-
teamId?: string
|
|
57
|
-
}): Promise<void> {
|
|
58
|
-
const params = opts.teamId ? `?teamId=${opts.teamId}` : ''
|
|
59
|
-
await vercelFetch(`/v12/deployments/${opts.deploymentId}/cancel${params}`, opts.token, {
|
|
60
|
-
method: 'PATCH',
|
|
61
|
-
})
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Trigger a deploy by POSTing to the hook URL.
|
|
66
|
-
* Validates the URL is a genuine Vercel hook before calling to prevent
|
|
67
|
-
* SSRF if a document is tampered with outside the Studio schema.
|
|
68
|
-
*/
|
|
69
|
-
export async function triggerDeploy(hookUrl: string): Promise<void> {
|
|
70
|
-
if (!VERCEL_HOOK_RE.test(hookUrl)) {
|
|
71
|
-
throw new Error('Invalid deploy hook URL — must be a Vercel hook (api.vercel.com/v1/integrations/deploy/…)')
|
|
72
|
-
}
|
|
73
|
-
const res = await fetch(hookUrl, { method: 'POST' })
|
|
74
|
-
if (!res.ok) throw new Error(`Deploy hook returned ${res.status}`)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Fetch build events for a deployment.
|
|
79
|
-
* Returns up to 100 events in reverse chronological order,
|
|
80
|
-
* filtered to lines with actual text content.
|
|
81
|
-
*/
|
|
82
|
-
export async function getDeploymentEvents(opts: {
|
|
83
|
-
deploymentId: string
|
|
84
|
-
token: string
|
|
85
|
-
teamId?: string
|
|
86
|
-
}): Promise<DeploymentEvent[]> {
|
|
87
|
-
const params = new URLSearchParams({ limit: '100', direction: 'backward' })
|
|
88
|
-
if (opts.teamId) params.set('teamId', opts.teamId)
|
|
89
|
-
// API returns either a plain array or a wrapped object depending on version
|
|
90
|
-
const raw = await vercelFetch<DeploymentEvent[] | { events?: DeploymentEvent[] }>(
|
|
91
|
-
`/v2/deployments/${opts.deploymentId}/events?${params}`,
|
|
92
|
-
opts.token,
|
|
93
|
-
)
|
|
94
|
-
const events: DeploymentEvent[] = Array.isArray(raw) ? raw : (raw.events ?? [])
|
|
95
|
-
return events.filter(e => e.text?.trim())
|
|
96
|
-
}
|
package/src/lib/helpers.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
// URL parsing and time formatting utilities
|
|
2
|
-
import type { VercelDeployment, VercelDeployState } from '../types'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Extracts projectId and hookId from a Vercel deploy hook URL.
|
|
6
|
-
* Format: https://api.vercel.com/v1/integrations/deploy/{projectId}/{hookId}
|
|
7
|
-
*/
|
|
8
|
-
export function parseHookUrl(url: string): { projectId: string; hookId: string } {
|
|
9
|
-
try {
|
|
10
|
-
const path = new URL(url).pathname
|
|
11
|
-
const parts = path.split('/').filter(Boolean)
|
|
12
|
-
// parts: ['v1', 'integrations', 'deploy', '{projectId}', '{hookId}']
|
|
13
|
-
return {
|
|
14
|
-
projectId: parts[3] ?? '',
|
|
15
|
-
hookId: parts[4] ?? '',
|
|
16
|
-
}
|
|
17
|
-
} catch {
|
|
18
|
-
return { projectId: '', hookId: '' }
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Active states — deployment is in progress and should be polled */
|
|
23
|
-
const ACTIVE_STATES: ReadonlySet<VercelDeployState> = new Set([
|
|
24
|
-
'QUEUED',
|
|
25
|
-
'INITIALIZING',
|
|
26
|
-
'BUILDING',
|
|
27
|
-
])
|
|
28
|
-
|
|
29
|
-
export function isActiveState(state: VercelDeployState | undefined): boolean {
|
|
30
|
-
return !!state && ACTIVE_STATES.has(state)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Human-readable elapsed duration from a number of seconds */
|
|
34
|
-
export function formatDuration(seconds: number): string {
|
|
35
|
-
if (seconds < 60) return `${seconds}s`
|
|
36
|
-
const m = Math.floor(seconds / 60)
|
|
37
|
-
const s = seconds % 60
|
|
38
|
-
return `${m}m ${s}s`
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Human-readable relative time from a Unix ms timestamp */
|
|
42
|
-
export function timeAgo(ms: number): string {
|
|
43
|
-
const diff = Math.floor((Date.now() - ms) / 1000)
|
|
44
|
-
if (diff < 60) return `${diff}s ago`
|
|
45
|
-
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
46
|
-
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
47
|
-
return `${Math.floor(diff / 86400)}d ago`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Validates a URL is safe to use as an href.
|
|
52
|
-
* Rejects anything that isn't http/https to block javascript: injection
|
|
53
|
-
* from a compromised API response.
|
|
54
|
-
*/
|
|
55
|
-
export function safeHref(url: string | undefined): string | undefined {
|
|
56
|
-
if (!url) return undefined
|
|
57
|
-
try {
|
|
58
|
-
const parsed = new URL(url)
|
|
59
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return undefined
|
|
60
|
-
return url
|
|
61
|
-
} catch {
|
|
62
|
-
return undefined
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Truncates a commit SHA to 7 chars */
|
|
67
|
-
export function shortSha(sha: string | undefined): string {
|
|
68
|
-
return sha ? sha.slice(0, 7) : ''
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Returns a label and tone for a Vercel deployment state */
|
|
72
|
-
export function stateLabel(state: VercelDeployState | undefined): {
|
|
73
|
-
label: string
|
|
74
|
-
tone: 'positive' | 'caution' | 'critical' | 'default'
|
|
75
|
-
} {
|
|
76
|
-
switch (state) {
|
|
77
|
-
case 'READY': return { label: 'Ready', tone: 'positive' }
|
|
78
|
-
case 'BUILDING': return { label: 'Building', tone: 'caution' }
|
|
79
|
-
case 'QUEUED': return { label: 'Queued', tone: 'caution' }
|
|
80
|
-
case 'INITIALIZING': return { label: 'Initializing', tone: 'caution' }
|
|
81
|
-
case 'ERROR': return { label: 'Error', tone: 'critical' }
|
|
82
|
-
case 'CANCELED': return { label: 'Canceled', tone: 'default' }
|
|
83
|
-
case 'LOADING': return { label: 'Loading…', tone: 'default' }
|
|
84
|
-
default: return { label: 'Unknown', tone: 'default' }
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Constructs a GitHub commit URL from deployment meta fields.
|
|
90
|
-
* Returns null if the required repo or SHA info is not present.
|
|
91
|
-
*/
|
|
92
|
-
export function githubCommitHref(meta: VercelDeployment['meta']): string | null {
|
|
93
|
-
if (!meta?.githubCommitSha) return null
|
|
94
|
-
const repo = meta.githubRepo ?? null
|
|
95
|
-
if (!repo) return null
|
|
96
|
-
return `https://github.com/${repo}/commit/${meta.githubCommitSha}`
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Extracts the Vercel project dashboard URL from a deployment's inspectorUrl.
|
|
101
|
-
* inspectorUrl format: https://vercel.com/{team}/{project}/{deploymentId}
|
|
102
|
-
* Returns https://vercel.com/{team}/{project} or null if unparseable.
|
|
103
|
-
*/
|
|
104
|
-
export function projectHref(inspectorUrl: string | undefined): string | null {
|
|
105
|
-
if (!inspectorUrl) return null
|
|
106
|
-
try {
|
|
107
|
-
const { origin, pathname } = new URL(inspectorUrl)
|
|
108
|
-
const parts = pathname.split('/').filter(Boolean)
|
|
109
|
-
if (parts.length < 2) return null
|
|
110
|
-
return `${origin}/${parts[0]}/${parts[1]}`
|
|
111
|
-
} catch {
|
|
112
|
-
return null
|
|
113
|
-
}
|
|
114
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
// Sanity schema for vercel_deploy documents — stores deploy hook targets
|
|
2
|
-
import { defineField, defineType } from 'sanity'
|
|
3
|
-
import { RocketIcon } from '../icons'
|
|
4
|
-
|
|
5
|
-
export const vercelDeploySchema = defineType({
|
|
6
|
-
name: 'vercel_deploy',
|
|
7
|
-
title: 'Deploy Target',
|
|
8
|
-
type: 'document',
|
|
9
|
-
icon: RocketIcon,
|
|
10
|
-
fields: [
|
|
11
|
-
defineField({
|
|
12
|
-
name: 'name',
|
|
13
|
-
title: 'Name',
|
|
14
|
-
type: 'string',
|
|
15
|
-
description: 'Display label shown in the Deploy tool (e.g. "Production", "Staging")',
|
|
16
|
-
validation: Rule => Rule.required(),
|
|
17
|
-
}),
|
|
18
|
-
defineField({
|
|
19
|
-
name: 'url',
|
|
20
|
-
title: 'Deploy Hook URL',
|
|
21
|
-
type: 'url',
|
|
22
|
-
description: 'From Vercel → Project Settings → Git → Deploy Hooks',
|
|
23
|
-
validation: Rule =>
|
|
24
|
-
Rule.required().uri({ scheme: ['https'] }).custom(url => {
|
|
25
|
-
if (typeof url !== 'string') return true
|
|
26
|
-
if (!url.includes('api.vercel.com/v1/integrations/deploy/')) {
|
|
27
|
-
return 'Must be a Vercel deploy hook URL (api.vercel.com/v1/integrations/deploy/…)'
|
|
28
|
-
}
|
|
29
|
-
return true
|
|
30
|
-
}),
|
|
31
|
-
}),
|
|
32
|
-
defineField({
|
|
33
|
-
name: 'teamId',
|
|
34
|
-
title: 'Vercel Team ID',
|
|
35
|
-
type: 'string',
|
|
36
|
-
description: 'Required for team-owned projects — find it in Vercel Team Settings',
|
|
37
|
-
}),
|
|
38
|
-
defineField({
|
|
39
|
-
name: 'disableDeleteAction',
|
|
40
|
-
title: 'Prevent deletion',
|
|
41
|
-
type: 'boolean',
|
|
42
|
-
description: 'Lock this target so it cannot be deleted from the Studio',
|
|
43
|
-
initialValue: false,
|
|
44
|
-
}),
|
|
45
|
-
],
|
|
46
|
-
preview: {
|
|
47
|
-
select: { title: 'name', subtitle: 'url' },
|
|
48
|
-
},
|
|
49
|
-
})
|
package/src/types.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
// TypeScript types for deploy-vercel-from-sanity
|
|
2
|
-
|
|
3
|
-
export type VercelDeployState =
|
|
4
|
-
| 'QUEUED'
|
|
5
|
-
| 'INITIALIZING'
|
|
6
|
-
| 'BUILDING'
|
|
7
|
-
| 'READY'
|
|
8
|
-
| 'ERROR'
|
|
9
|
-
| 'CANCELED'
|
|
10
|
-
| 'LOADING' // internal — before first API response
|
|
11
|
-
|
|
12
|
-
/** A vercel_deploy document stored in the Sanity dataset */
|
|
13
|
-
export interface DeployTarget {
|
|
14
|
-
_id: string
|
|
15
|
-
_type: 'vercel_deploy'
|
|
16
|
-
name: string
|
|
17
|
-
/** Full Vercel deploy hook URL */
|
|
18
|
-
url: string
|
|
19
|
-
/** Vercel team ID — optional, only needed for team projects */
|
|
20
|
-
teamId?: string
|
|
21
|
-
/** Prevent editors from deleting this target */
|
|
22
|
-
disableDeleteAction?: boolean
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** A single deployment returned by GET /v6/deployments */
|
|
26
|
-
export interface VercelDeployment {
|
|
27
|
-
uid: string
|
|
28
|
-
/** Preview hostname, e.g. my-project-abc123.vercel.app */
|
|
29
|
-
url: string
|
|
30
|
-
state: VercelDeployState
|
|
31
|
-
/** Unix ms timestamp — when the deployment was created */
|
|
32
|
-
created: number
|
|
33
|
-
/** Unix ms timestamp — when the deployment became ready */
|
|
34
|
-
ready?: number
|
|
35
|
-
/** Link to the Vercel dashboard page for this deployment */
|
|
36
|
-
inspectorUrl?: string
|
|
37
|
-
creator?: {
|
|
38
|
-
uid: string
|
|
39
|
-
username: string
|
|
40
|
-
avatar?: string
|
|
41
|
-
}
|
|
42
|
-
meta?: {
|
|
43
|
-
githubCommitMessage?: string
|
|
44
|
-
githubCommitRef?: string
|
|
45
|
-
githubCommitSha?: string
|
|
46
|
-
githubCommitAuthorName?: string
|
|
47
|
-
/** GitHub repo in "org/repo" format — used to construct commit links */
|
|
48
|
-
githubRepo?: string
|
|
49
|
-
/** GitHub org slug — fallback when githubRepo is absent */
|
|
50
|
-
githubCommitOrg?: string
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** A single build event returned by GET /v2/deployments/{id}/events */
|
|
55
|
-
export interface DeploymentEvent {
|
|
56
|
-
type: 'command' | 'stdout' | 'stderr' | 'exit' | 'deployment-state'
|
|
57
|
-
text?: string
|
|
58
|
-
created: number
|
|
59
|
-
payload?: Record<string, unknown>
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Vercel config document stored at _id: 'config.vercelDeploy' — readable by all authenticated users */
|
|
63
|
-
export interface VercelConfig {
|
|
64
|
-
_id: 'config.vercelDeploy'
|
|
65
|
-
_type: 'vercelDeploy.config'
|
|
66
|
-
accessToken: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Plugin configuration options */
|
|
70
|
-
export interface VercelDeployPluginConfig {
|
|
71
|
-
/** Tool name slug shown in Studio sidebar (default: 'vercel-deploy') */
|
|
72
|
-
name?: string
|
|
73
|
-
/** Tool label shown in Studio sidebar (default: 'Deploy') */
|
|
74
|
-
title?: string
|
|
75
|
-
/** Custom icon component */
|
|
76
|
-
icon?: React.ComponentType
|
|
77
|
-
}
|
package/src/ui.tsx
DELETED
|
@@ -1,337 +0,0 @@
|
|
|
1
|
-
// Version-agnostic access to the @sanity/ui components that v4 moved out of the barrel, with local fallbacks
|
|
2
|
-
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
3
|
-
import type { CSSProperties, ReactNode } from 'react'
|
|
4
|
-
import * as sanityUi from '@sanity/ui'
|
|
5
|
-
import { Box, Button, Card, Flex, Stack as SanityStack, Text } from '@sanity/ui'
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The installed @sanity/ui namespace, read through an index signature.
|
|
9
|
-
*
|
|
10
|
-
* @sanity/ui v4 relocated Tooltip, Menu, MenuButton, MenuItem, Code, Popover and
|
|
11
|
-
* useToast into subpath entry points (`@sanity/ui/tooltip`, `/menu`, …) and dropped
|
|
12
|
-
* them from the root barrel. Those subpaths do not exist on v2 or v3, so a static
|
|
13
|
-
* import of either shape breaks half the supported Studio range. Reading the barrel
|
|
14
|
-
* dynamically lets one build prefer the real components wherever they are still
|
|
15
|
-
* exported and fall back to the local equivalents below when they are not.
|
|
16
|
-
*/
|
|
17
|
-
const INSTALLED = sanityUi as unknown as Record<string, unknown>
|
|
18
|
-
|
|
19
|
-
/* ------------------------------------------------------------------ stack -- */
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whether the installed @sanity/ui is v4 or newer.
|
|
23
|
-
*
|
|
24
|
-
* v4 both emptied the barrel of Tooltip/Menu/Code/useToast and renamed Stack's
|
|
25
|
-
* `space` prop to `gap` (the old name is typed `never` and ignored at runtime, so
|
|
26
|
-
* passing it silently collapses all vertical spacing). Those landed in the same
|
|
27
|
-
* major, so the absence of `useToast` from the barrel identifies v4+ reliably.
|
|
28
|
-
*/
|
|
29
|
-
const IS_UI_V4_PLUS = !('useToast' in INSTALLED)
|
|
30
|
-
|
|
31
|
-
/** Props for the compat Stack — keeps the pre-v4 `space` name at every call site. */
|
|
32
|
-
export type StackProps = {
|
|
33
|
-
space?: number
|
|
34
|
-
children: ReactNode
|
|
35
|
-
[key: string]: unknown
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Vertical stack. Forwards `space` on @sanity/ui v2 and v3 and `gap` on v4+, so one
|
|
40
|
-
* call site spells spacing correctly on either major.
|
|
41
|
-
*
|
|
42
|
-
* @param space Spacing step on Sanity's scale, forwarded under whichever name applies.
|
|
43
|
-
*/
|
|
44
|
-
export function Stack({ space, children, ...rest }: StackProps): React.JSX.Element {
|
|
45
|
-
const spacing = space === undefined ? {} : IS_UI_V4_PLUS ? { gap: space } : { space }
|
|
46
|
-
const Component = SanityStack as unknown as React.ComponentType<Record<string, unknown>>
|
|
47
|
-
return <Component {...rest} {...spacing}>{children}</Component>
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/* ------------------------------------------------------------------ toast -- */
|
|
51
|
-
|
|
52
|
-
/** A toast request, matching the subset of @sanity/ui's ToastParams this plugin uses. */
|
|
53
|
-
export type ToastParams = {
|
|
54
|
-
status?: 'success' | 'error' | 'warning' | 'info'
|
|
55
|
-
title?: ReactNode
|
|
56
|
-
description?: ReactNode
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** The object returned by useToast — only `push` is used here. */
|
|
60
|
-
export type Toaster = { push: (params: ToastParams) => void }
|
|
61
|
-
|
|
62
|
-
/** A queued fallback toast. `id` is a monotonic counter, unique for the session. */
|
|
63
|
-
type LocalToast = ToastParams & { id: number }
|
|
64
|
-
|
|
65
|
-
/** How long a fallback toast stays on screen, in milliseconds. */
|
|
66
|
-
const LOCAL_TOAST_MS = 5000
|
|
67
|
-
|
|
68
|
-
/** Border tone per toast status, used only by the fallback viewport. */
|
|
69
|
-
const LOCAL_TOAST_TONE: Record<string, 'positive' | 'critical' | 'caution' | 'primary'> = {
|
|
70
|
-
success: 'positive',
|
|
71
|
-
error: 'critical',
|
|
72
|
-
warning: 'caution',
|
|
73
|
-
info: 'primary',
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
let nextToastId = 0
|
|
77
|
-
let localToasts: LocalToast[] = []
|
|
78
|
-
const toastListeners = new Set<(toasts: LocalToast[]) => void>()
|
|
79
|
-
|
|
80
|
-
/** Publish the current fallback queue to every mounted viewport. */
|
|
81
|
-
function emitToasts(): void {
|
|
82
|
-
for (const listener of toastListeners) listener(localToasts)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Queue a fallback toast and schedule its removal. */
|
|
86
|
-
function pushLocalToast(params: ToastParams): void {
|
|
87
|
-
const toast: LocalToast = { ...params, id: nextToastId++ }
|
|
88
|
-
localToasts = [...localToasts, toast]
|
|
89
|
-
emitToasts()
|
|
90
|
-
setTimeout(() => {
|
|
91
|
-
localToasts = localToasts.filter(t => t.id !== toast.id)
|
|
92
|
-
emitToasts()
|
|
93
|
-
}, LOCAL_TOAST_MS)
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The real hook when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
97
|
-
const installedUseToast = INSTALLED.useToast as (() => Toaster) | undefined
|
|
98
|
-
|
|
99
|
-
/** Stable fallback toaster — identity never changes, so it is safe in dependency arrays. */
|
|
100
|
-
const localToaster: Toaster = { push: pushLocalToast }
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Push toasts through Studio's toast system where available, or through the local
|
|
104
|
-
* viewport below on @sanity/ui v4+. Mount `<ToastViewport />` once for the fallback
|
|
105
|
-
* to be visible; it renders nothing when the real hook is present.
|
|
106
|
-
*/
|
|
107
|
-
export function useToast(): Toaster {
|
|
108
|
-
const real = installedUseToast
|
|
109
|
-
// Hook order is stable across renders because `installedUseToast` is module-level.
|
|
110
|
-
if (real) return real()
|
|
111
|
-
return localToaster
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Renders queued fallback toasts. No-op when the installed @sanity/ui exports its
|
|
116
|
-
* own useToast, because Studio's ToastProvider is already handling them.
|
|
117
|
-
*/
|
|
118
|
-
export function ToastViewport(): React.JSX.Element | null {
|
|
119
|
-
const [toasts, setToasts] = useState<LocalToast[]>(localToasts)
|
|
120
|
-
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
if (installedUseToast) return
|
|
123
|
-
toastListeners.add(setToasts)
|
|
124
|
-
return () => { toastListeners.delete(setToasts) }
|
|
125
|
-
}, [])
|
|
126
|
-
|
|
127
|
-
if (installedUseToast || toasts.length === 0) return null
|
|
128
|
-
|
|
129
|
-
return (
|
|
130
|
-
<Box style={{ position: 'fixed', bottom: 16, right: 16, zIndex: 1000000, maxWidth: 360 }}>
|
|
131
|
-
<Stack space={2}>
|
|
132
|
-
{toasts.map(toast => (
|
|
133
|
-
<Card
|
|
134
|
-
key={toast.id}
|
|
135
|
-
padding={3}
|
|
136
|
-
radius={2}
|
|
137
|
-
shadow={3}
|
|
138
|
-
tone={LOCAL_TOAST_TONE[toast.status ?? 'info'] ?? 'primary'}
|
|
139
|
-
>
|
|
140
|
-
<Stack space={2}>
|
|
141
|
-
{toast.title && <Text size={1} weight="semibold">{toast.title}</Text>}
|
|
142
|
-
{toast.description && <Text size={1} muted>{toast.description}</Text>}
|
|
143
|
-
</Stack>
|
|
144
|
-
</Card>
|
|
145
|
-
))}
|
|
146
|
-
</Stack>
|
|
147
|
-
</Box>
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/* ---------------------------------------------------------------- tooltip -- */
|
|
152
|
-
|
|
153
|
-
/** Props for the compat Tooltip — plain text rather than @sanity/ui's ReactNode `content`. */
|
|
154
|
-
export type TooltipProps = { text: string; children: ReactNode }
|
|
155
|
-
|
|
156
|
-
/** The real Tooltip when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
157
|
-
const InstalledTooltip = INSTALLED.Tooltip as
|
|
158
|
-
| React.ComponentType<{ content: ReactNode; portal?: boolean; children: ReactNode }>
|
|
159
|
-
| undefined
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Hover hint over `children`. Uses Studio's Tooltip where available and degrades to
|
|
163
|
-
* the native `title` attribute on @sanity/ui v4+.
|
|
164
|
-
*/
|
|
165
|
-
export function Tooltip({ text, children }: TooltipProps): React.JSX.Element {
|
|
166
|
-
if (InstalledTooltip) {
|
|
167
|
-
return (
|
|
168
|
-
<InstalledTooltip content={<Box padding={2}><Text size={1}>{text}</Text></Box>} portal>
|
|
169
|
-
{children}
|
|
170
|
-
</InstalledTooltip>
|
|
171
|
-
)
|
|
172
|
-
}
|
|
173
|
-
return <span title={text} style={{ display: 'inline-flex' }}>{children}</span>
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/* ------------------------------------------------------------------- menu -- */
|
|
177
|
-
|
|
178
|
-
/** One entry in an ActionMenu. Either `onClick` or `href` drives the behaviour. */
|
|
179
|
-
export type MenuAction = {
|
|
180
|
-
text: string
|
|
181
|
-
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>
|
|
182
|
-
onClick?: () => void
|
|
183
|
-
href?: string
|
|
184
|
-
tone?: 'critical' | 'default'
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Props for the compat ActionMenu — a declarative item list rather than nested JSX. */
|
|
188
|
-
export type ActionMenuProps = { id: string; items: MenuAction[]; buttonIcon: MenuAction['icon'] }
|
|
189
|
-
|
|
190
|
-
const InstalledMenuButton = INSTALLED.MenuButton as React.ComponentType<Record<string, unknown>> | undefined
|
|
191
|
-
const InstalledMenu = INSTALLED.Menu as React.ComponentType<Record<string, unknown>> | undefined
|
|
192
|
-
const InstalledMenuItem = INSTALLED.MenuItem as React.ComponentType<Record<string, unknown>> | undefined
|
|
193
|
-
|
|
194
|
-
/** Whether the installed @sanity/ui still exports the full menu trio. */
|
|
195
|
-
const HAS_INSTALLED_MENU = Boolean(InstalledMenuButton && InstalledMenu && InstalledMenuItem)
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Overflow menu for a deploy target. Uses Studio's MenuButton where available and
|
|
199
|
-
* falls back to a locally positioned card on @sanity/ui v4+.
|
|
200
|
-
*
|
|
201
|
-
* @param id Stable DOM id, required by @sanity/ui's MenuButton.
|
|
202
|
-
* @param items Actions in display order.
|
|
203
|
-
* @param buttonIcon Icon for the trigger button.
|
|
204
|
-
*/
|
|
205
|
-
export function ActionMenu({ id, items, buttonIcon }: ActionMenuProps): React.JSX.Element {
|
|
206
|
-
const [open, setOpen] = useState(false)
|
|
207
|
-
const wrapRef = useRef<HTMLDivElement>(null)
|
|
208
|
-
|
|
209
|
-
// Fallback only — dismiss on outside click or Escape.
|
|
210
|
-
useEffect(() => {
|
|
211
|
-
if (HAS_INSTALLED_MENU || !open) return
|
|
212
|
-
const onPointerDown = (e: MouseEvent) => {
|
|
213
|
-
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false)
|
|
214
|
-
}
|
|
215
|
-
const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
|
216
|
-
document.addEventListener('mousedown', onPointerDown)
|
|
217
|
-
document.addEventListener('keydown', onKeyDown)
|
|
218
|
-
return () => {
|
|
219
|
-
document.removeEventListener('mousedown', onPointerDown)
|
|
220
|
-
document.removeEventListener('keydown', onKeyDown)
|
|
221
|
-
}
|
|
222
|
-
}, [open])
|
|
223
|
-
|
|
224
|
-
const runAction = useCallback((item: MenuAction) => {
|
|
225
|
-
setOpen(false)
|
|
226
|
-
item.onClick?.()
|
|
227
|
-
}, [])
|
|
228
|
-
|
|
229
|
-
if (HAS_INSTALLED_MENU && InstalledMenuButton && InstalledMenu && InstalledMenuItem) {
|
|
230
|
-
const MenuButton = InstalledMenuButton
|
|
231
|
-
const Menu = InstalledMenu
|
|
232
|
-
const MenuItem = InstalledMenuItem
|
|
233
|
-
return (
|
|
234
|
-
<MenuButton
|
|
235
|
-
id={id}
|
|
236
|
-
button={<Button mode="ghost" icon={buttonIcon} padding={2} />}
|
|
237
|
-
popover={{ placement: 'bottom-end' }}
|
|
238
|
-
menu={
|
|
239
|
-
<Menu>
|
|
240
|
-
{items.map(item => (
|
|
241
|
-
<MenuItem
|
|
242
|
-
key={item.text}
|
|
243
|
-
text={item.text}
|
|
244
|
-
icon={item.icon}
|
|
245
|
-
tone={item.tone}
|
|
246
|
-
{...(item.href
|
|
247
|
-
? { as: 'a', href: item.href, target: '_blank', rel: 'noreferrer' }
|
|
248
|
-
: { onClick: () => item.onClick?.() })}
|
|
249
|
-
/>
|
|
250
|
-
))}
|
|
251
|
-
</Menu>
|
|
252
|
-
}
|
|
253
|
-
/>
|
|
254
|
-
)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return (
|
|
258
|
-
<div ref={wrapRef} style={{ position: 'relative' }}>
|
|
259
|
-
<Button
|
|
260
|
-
mode="ghost"
|
|
261
|
-
icon={buttonIcon}
|
|
262
|
-
padding={2}
|
|
263
|
-
id={id}
|
|
264
|
-
aria-haspopup="menu"
|
|
265
|
-
aria-expanded={open}
|
|
266
|
-
onClick={() => setOpen(o => !o)}
|
|
267
|
-
/>
|
|
268
|
-
{open && (
|
|
269
|
-
<Card
|
|
270
|
-
radius={2}
|
|
271
|
-
shadow={3}
|
|
272
|
-
padding={1}
|
|
273
|
-
role="menu"
|
|
274
|
-
style={{ position: 'absolute', top: '100%', right: 0, marginTop: 4, zIndex: 1000, minWidth: 180 }}
|
|
275
|
-
>
|
|
276
|
-
<Stack space={1}>
|
|
277
|
-
{items.map(item => {
|
|
278
|
-
const Icon = item.icon
|
|
279
|
-
const label = (
|
|
280
|
-
<Flex align="center" gap={2} paddingX={2} paddingY={2}>
|
|
281
|
-
<Icon width="1em" height="1em" />
|
|
282
|
-
<Text size={1}>{item.text}</Text>
|
|
283
|
-
</Flex>
|
|
284
|
-
)
|
|
285
|
-
return item.href ? (
|
|
286
|
-
<a
|
|
287
|
-
key={item.text}
|
|
288
|
-
role="menuitem"
|
|
289
|
-
href={item.href}
|
|
290
|
-
target="_blank"
|
|
291
|
-
rel="noreferrer"
|
|
292
|
-
onClick={() => setOpen(false)}
|
|
293
|
-
style={{ color: 'inherit', textDecoration: 'none', display: 'block' }}
|
|
294
|
-
>
|
|
295
|
-
{label}
|
|
296
|
-
</a>
|
|
297
|
-
) : (
|
|
298
|
-
<Card
|
|
299
|
-
key={item.text}
|
|
300
|
-
as="button"
|
|
301
|
-
role="menuitem"
|
|
302
|
-
tone={item.tone === 'critical' ? 'critical' : 'default'}
|
|
303
|
-
radius={1}
|
|
304
|
-
onClick={() => runAction(item)}
|
|
305
|
-
style={{ display: 'block', width: '100%', textAlign: 'left', cursor: 'pointer', border: 0, background: 'none' }}
|
|
306
|
-
>
|
|
307
|
-
{label}
|
|
308
|
-
</Card>
|
|
309
|
-
)
|
|
310
|
-
})}
|
|
311
|
-
</Stack>
|
|
312
|
-
</Card>
|
|
313
|
-
)}
|
|
314
|
-
</div>
|
|
315
|
-
)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/* ------------------------------------------------------------------- code -- */
|
|
319
|
-
|
|
320
|
-
/** The real Code when the installed @sanity/ui still exports it, otherwise undefined. */
|
|
321
|
-
const InstalledCode = INSTALLED.Code as
|
|
322
|
-
| React.ComponentType<{ size?: number; style?: CSSProperties; children: ReactNode }>
|
|
323
|
-
| undefined
|
|
324
|
-
|
|
325
|
-
/** Monospace styling for the fallback Code element, approximating @sanity/ui's `size={1}`. */
|
|
326
|
-
const FALLBACK_CODE_STYLE: CSSProperties = {
|
|
327
|
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
328
|
-
fontSize: '0.8125rem',
|
|
329
|
-
lineHeight: 1.4,
|
|
330
|
-
margin: 0,
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
/** Monospace block. Uses Studio's Code where available, a plain `<code>` on @sanity/ui v4+. */
|
|
334
|
-
export function Code({ style, children }: { style?: CSSProperties; children: ReactNode }): React.JSX.Element {
|
|
335
|
-
if (InstalledCode) return <InstalledCode size={1} style={style}>{children}</InstalledCode>
|
|
336
|
-
return <code style={{ ...FALLBACK_CODE_STYLE, ...style }}>{children}</code>
|
|
337
|
-
}
|
package/src/version.ts
DELETED