@liiift-studio/deploy-vercel-from-sanity 1.0.10 → 1.1.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.
- package/README.md +15 -3
- package/dist/index.d.mts +38 -5
- package/dist/index.d.ts +38 -5
- package/dist/index.js +568 -393
- package/dist/index.mjs +565 -415
- package/package.json +8 -5
- package/src/components/DeployHistory.tsx +3 -2
- package/src/components/DeployItem.tsx +21 -65
- package/src/components/DeployTargetForm.tsx +6 -5
- package/src/components/DeployTool.tsx +6 -2
- package/src/components/TokenSetup.tsx +5 -2
- package/src/icons.tsx +71 -0
- package/src/index.ts +1 -1
- package/src/schema/vercelDeploy.ts +1 -1
- package/src/ui.tsx +337 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/deploy-vercel-from-sanity",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports v3
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports Studio v3 through v6.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Liiift Studio",
|
|
7
7
|
"keywords": [
|
|
@@ -39,15 +39,18 @@
|
|
|
39
39
|
"prepublishOnly": "npm run build"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
+
"@sanity/icons": ">=3",
|
|
43
|
+
"@sanity/ui": ">=2",
|
|
42
44
|
"react": ">=18",
|
|
43
45
|
"sanity": ">=3"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
|
-
"@sanity/icons": "^
|
|
47
|
-
"@sanity/ui": "^
|
|
48
|
+
"@sanity/icons": "^5",
|
|
49
|
+
"@sanity/ui": "^4",
|
|
48
50
|
"@types/react": "^19",
|
|
51
|
+
"@types/react-dom": "^19.2.4",
|
|
49
52
|
"react": "^19",
|
|
50
|
-
"sanity": "^
|
|
53
|
+
"sanity": "^6",
|
|
51
54
|
"tsup": "^8",
|
|
52
55
|
"typescript": "^5"
|
|
53
56
|
},
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Deployment history modal — shows last 10 deployments for a target
|
|
2
2
|
import { useEffect, useState, useCallback } from 'react'
|
|
3
3
|
import {
|
|
4
|
-
Dialog, Card, Box,
|
|
4
|
+
Dialog, Card, Box, Flex, Text, Badge, Spinner, Button,
|
|
5
5
|
} from '@sanity/ui'
|
|
6
|
-
import {
|
|
6
|
+
import { Stack } from '../ui'
|
|
7
|
+
import { LaunchIcon, CloseIcon } from '../icons'
|
|
7
8
|
import { listDeployments } from '../lib/api'
|
|
8
9
|
import { parseHookUrl, stateLabel, timeAgo, shortSha, safeHref } from '../lib/helpers'
|
|
9
10
|
import type { DeployTarget, VercelDeployment } from '../types'
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
3
3
|
import { flushSync } from 'react-dom'
|
|
4
4
|
import {
|
|
5
|
-
Card, Box,
|
|
6
|
-
MenuButton, Menu, MenuItem, Code, useToast,
|
|
5
|
+
Card, Box, Flex, Text, Button, Badge, Spinner,
|
|
7
6
|
} from '@sanity/ui'
|
|
7
|
+
import { Stack, useToast, Tooltip, ActionMenu, Code } from '../ui'
|
|
8
8
|
import {
|
|
9
9
|
ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
|
|
10
10
|
CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SchemaIcon
|
|
11
|
-
} from '
|
|
11
|
+
} from '../icons'
|
|
12
12
|
import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
|
|
13
13
|
import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers'
|
|
14
14
|
import { StatusBadge } from './StatusBadge'
|
|
@@ -270,52 +270,22 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
270
270
|
</>
|
|
271
271
|
)}
|
|
272
272
|
</Flex>
|
|
273
|
-
<
|
|
274
|
-
button={<Button mode="ghost" icon={EllipsisVerticalIcon} padding={2} />}
|
|
273
|
+
<ActionMenu
|
|
275
274
|
id={`menu-${target._id}`}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
text="Build logs"
|
|
291
|
-
icon={LaunchIcon}
|
|
292
|
-
as="a"
|
|
293
|
-
href={safeHref(latest?.inspectorUrl)}
|
|
294
|
-
target="_blank"
|
|
295
|
-
rel="noreferrer"
|
|
296
|
-
/>
|
|
297
|
-
)}
|
|
298
|
-
{vercelProjectUrl && (
|
|
299
|
-
<MenuItem
|
|
300
|
-
text="Open in Vercel"
|
|
301
|
-
icon={LaunchIcon}
|
|
302
|
-
as="a"
|
|
303
|
-
href={vercelProjectUrl}
|
|
304
|
-
target="_blank"
|
|
305
|
-
rel="noreferrer"
|
|
306
|
-
/>
|
|
307
|
-
)}
|
|
308
|
-
{!target.disableDeleteAction && (
|
|
309
|
-
<MenuItem
|
|
310
|
-
text="Delete"
|
|
311
|
-
icon={TrashIcon}
|
|
312
|
-
tone="critical"
|
|
313
|
-
onClick={() => onDelete(target)}
|
|
314
|
-
/>
|
|
315
|
-
)}
|
|
316
|
-
</Menu>
|
|
317
|
-
}
|
|
318
|
-
popover={{ placement: 'bottom-end' }}
|
|
275
|
+
buttonIcon={EllipsisVerticalIcon}
|
|
276
|
+
items={[
|
|
277
|
+
{ text: 'Edit target', icon: EditIcon, onClick: () => onEdit(target) },
|
|
278
|
+
{ text: 'History', icon: ClockIcon, onClick: () => setShowHistory(true) },
|
|
279
|
+
...(safeHref(latest?.inspectorUrl)
|
|
280
|
+
? [{ text: 'Build logs', icon: LaunchIcon, href: safeHref(latest?.inspectorUrl)! }]
|
|
281
|
+
: []),
|
|
282
|
+
...(vercelProjectUrl
|
|
283
|
+
? [{ text: 'Open in Vercel', icon: LaunchIcon, href: vercelProjectUrl }]
|
|
284
|
+
: []),
|
|
285
|
+
...(!target.disableDeleteAction
|
|
286
|
+
? [{ text: 'Delete', icon: TrashIcon, tone: 'critical' as const, onClick: () => onDelete(target) }]
|
|
287
|
+
: []),
|
|
288
|
+
]}
|
|
319
289
|
/>
|
|
320
290
|
</Flex>
|
|
321
291
|
|
|
@@ -354,14 +324,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
354
324
|
|
|
355
325
|
{/* Commit SHA — links to GitHub if repo info available, tooltip shows full message */}
|
|
356
326
|
{sha && (
|
|
357
|
-
<Tooltip
|
|
358
|
-
content={
|
|
359
|
-
<Box padding={2}>
|
|
360
|
-
<Text size={1}>{commitMsg ?? sha}</Text>
|
|
361
|
-
</Box>
|
|
362
|
-
}
|
|
363
|
-
portal
|
|
364
|
-
>
|
|
327
|
+
<Tooltip text={commitMsg ?? sha}>
|
|
365
328
|
{commitHref ? (
|
|
366
329
|
<a
|
|
367
330
|
href={commitHref}
|
|
@@ -392,14 +355,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
392
355
|
{/* Visit link + copy URL */}
|
|
393
356
|
{latest?.url && latest.state === 'READY' && (
|
|
394
357
|
<>
|
|
395
|
-
<Tooltip
|
|
396
|
-
content={
|
|
397
|
-
<Box padding={2}>
|
|
398
|
-
<Text size={1}>{copied ? 'Copied!' : 'Copy URL'}</Text>
|
|
399
|
-
</Box>
|
|
400
|
-
}
|
|
401
|
-
portal
|
|
402
|
-
>
|
|
358
|
+
<Tooltip text={copied ? 'Copied!' : 'Copy URL'}>
|
|
403
359
|
<Button
|
|
404
360
|
mode="ghost"
|
|
405
361
|
icon={copied ? CheckmarkIcon : CopyIcon}
|
|
@@ -463,7 +419,7 @@ export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps)
|
|
|
463
419
|
<Stack space={2}>
|
|
464
420
|
<Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 13, lineHeight: 1.6 }}>
|
|
465
421
|
{errorLines.map((line, i) => (
|
|
466
|
-
<Code key={i}
|
|
422
|
+
<Code key={i} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
467
423
|
{line}
|
|
468
424
|
</Code>
|
|
469
425
|
))}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { useState, useCallback } from 'react'
|
|
3
3
|
import { useClient } from 'sanity'
|
|
4
4
|
import {
|
|
5
|
-
Dialog, Box,
|
|
5
|
+
Dialog, Box, Flex, Text, TextInput, Button, Switch, Label, Card,
|
|
6
6
|
} from '@sanity/ui'
|
|
7
|
-
import {
|
|
7
|
+
import { Stack } from '../ui'
|
|
8
|
+
import { CheckmarkCircleIcon } from '../icons'
|
|
8
9
|
import type { DeployTarget } from '../types'
|
|
9
10
|
|
|
10
11
|
const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
|
|
@@ -34,10 +35,10 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
|
|
|
34
35
|
if (!canSave) return
|
|
35
36
|
setSaving(true)
|
|
36
37
|
setError(null)
|
|
37
|
-
const fields = {
|
|
38
|
+
const fields: { name: string; url: string; teamId: string | null; disableDeleteAction: boolean } = {
|
|
38
39
|
name: name.trim(),
|
|
39
40
|
url: url.trim(),
|
|
40
|
-
|
|
41
|
+
teamId: teamId.trim() || null,
|
|
41
42
|
disableDeleteAction: disableDelete,
|
|
42
43
|
}
|
|
43
44
|
try {
|
|
@@ -127,7 +128,7 @@ export function DeployTargetForm({ initial, onSaved, onClose }: DeployTargetForm
|
|
|
127
128
|
id="disable-delete"
|
|
128
129
|
/>
|
|
129
130
|
<Stack space={1}>
|
|
130
|
-
<Label size={1} htmlFor="disable-delete">Disable delete action</Label>
|
|
131
|
+
<Label as="label" size={1} htmlFor="disable-delete">Disable delete action</Label>
|
|
131
132
|
<Text size={0} muted>Hides the delete button for this target in the studio.</Text>
|
|
132
133
|
</Stack>
|
|
133
134
|
</Flex>
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { useState, useEffect, useCallback } from 'react'
|
|
3
3
|
import { useClient } from 'sanity'
|
|
4
4
|
import {
|
|
5
|
-
Card, Box,
|
|
5
|
+
Card, Box, Flex, Text, Heading, Spinner, Button, Dialog,
|
|
6
6
|
} from '@sanity/ui'
|
|
7
|
-
import {
|
|
7
|
+
import { Stack, useToast, ToastViewport } from '../ui'
|
|
8
|
+
import { TokenIcon, TrashIcon, WarningOutlineIcon, AddIcon } from '../icons'
|
|
8
9
|
import { DeployItem } from './DeployItem'
|
|
9
10
|
import { TokenSetup } from './TokenSetup'
|
|
10
11
|
import { DeployTargetForm } from './DeployTargetForm'
|
|
@@ -287,6 +288,9 @@ export function DeployTool() {
|
|
|
287
288
|
</Box>
|
|
288
289
|
</Dialog>
|
|
289
290
|
)}
|
|
291
|
+
|
|
292
|
+
{/* Fallback toast surface — renders nothing when @sanity/ui exports its own useToast */}
|
|
293
|
+
<ToastViewport />
|
|
290
294
|
</Card>
|
|
291
295
|
)
|
|
292
296
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Vercel API token form — rendered inside a Dialog by DeployTool
|
|
2
2
|
import { useState, useCallback } from 'react'
|
|
3
3
|
import { useClient } from 'sanity'
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
Text, TextInput, Button, Card, Dialog, Flex,
|
|
6
|
+
} from '@sanity/ui'
|
|
7
|
+
import { Stack } from '../ui'
|
|
8
|
+
import { CheckmarkCircleIcon } from '../icons'
|
|
6
9
|
|
|
7
10
|
interface TokenSetupProps {
|
|
8
11
|
/** Called after the token is successfully saved */
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Version-agnostic access to @sanity/icons — resolves named exports (icons v3/v4) or <Icon symbol> (icons v5+)
|
|
2
|
+
import { forwardRef } from 'react'
|
|
3
|
+
import type { ComponentType, SVGProps } from 'react'
|
|
4
|
+
import * as sanityIcons from '@sanity/icons'
|
|
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
|
+
/**
|
|
13
|
+
* The installed @sanity/icons namespace, read through an index signature.
|
|
14
|
+
*
|
|
15
|
+
* icons v3 and v4 export one named component per glyph (`RocketIcon`). v5.0.0
|
|
16
|
+
* dropped those from the barrel and replaced them with a single `<Icon symbol>`
|
|
17
|
+
* that lazy-loads from an internal map. Its `index.d.ts` still declares the old
|
|
18
|
+
* named exports, so the mismatch is invisible to TypeScript and only surfaces at
|
|
19
|
+
* runtime — reading the namespace dynamically lets one build serve every major.
|
|
20
|
+
*/
|
|
21
|
+
const INSTALLED = sanityIcons as unknown as Record<string, unknown>
|
|
22
|
+
|
|
23
|
+
/** Sizing of a Sanity icon glyph — 1em square on a 25-unit viewBox, matching @sanity/icons. */
|
|
24
|
+
const GLYPH = { width: '1em', height: '1em', viewBox: '0 0 25 25', fill: 'none' } as const
|
|
25
|
+
|
|
26
|
+
/** Last-resort placeholder when the installed @sanity/icons exposes neither shape. Holds layout, draws nothing. */
|
|
27
|
+
const MissingIcon = forwardRef<SVGSVGElement, IconProps>(function MissingIcon(props, ref) {
|
|
28
|
+
return <svg {...GLYPH} xmlns="http://www.w3.org/2000/svg" {...props} ref={ref} />
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** The v5+ `<Icon>` component, absent on icons v3 and v4. */
|
|
32
|
+
type SymbolIcon = ComponentType<IconProps & { symbol: string }>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve one glyph against whichever @sanity/icons the host Studio installed.
|
|
36
|
+
* Always reads from the host package, so new and revised artwork is picked up
|
|
37
|
+
* on the consumer's next `@sanity/icons` update without a release here.
|
|
38
|
+
*
|
|
39
|
+
* @param name Named export used by icons v3 and v4, e.g. `RocketIcon`.
|
|
40
|
+
* @param symbol Kebab-case symbol used by the v5+ `<Icon>` component, e.g. `rocket`.
|
|
41
|
+
*/
|
|
42
|
+
function resolveIcon(name: string, symbol: string): IconComponent {
|
|
43
|
+
const named = INSTALLED[name] as IconComponent | undefined
|
|
44
|
+
if (named) return named
|
|
45
|
+
|
|
46
|
+
const Icon = INSTALLED.Icon as SymbolIcon | undefined
|
|
47
|
+
if (!Icon) return MissingIcon
|
|
48
|
+
|
|
49
|
+
const Resolved = forwardRef<SVGSVGElement, IconProps>(function SanityIcon(props, ref) {
|
|
50
|
+
return <Icon symbol={symbol} {...props} ref={ref as never} />
|
|
51
|
+
})
|
|
52
|
+
Resolved.displayName = name
|
|
53
|
+
return Resolved as IconComponent
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const AddIcon = resolveIcon('AddIcon', 'add')
|
|
57
|
+
export const CheckmarkCircleIcon = resolveIcon('CheckmarkCircleIcon', 'checkmark-circle')
|
|
58
|
+
export const CheckmarkIcon = resolveIcon('CheckmarkIcon', 'checkmark')
|
|
59
|
+
export const ChevronDownIcon = resolveIcon('ChevronDownIcon', 'chevron-down')
|
|
60
|
+
export const ChevronUpIcon = resolveIcon('ChevronUpIcon', 'chevron-up')
|
|
61
|
+
export const ClockIcon = resolveIcon('ClockIcon', 'clock')
|
|
62
|
+
export const CloseIcon = resolveIcon('CloseIcon', 'close')
|
|
63
|
+
export const CopyIcon = resolveIcon('CopyIcon', 'copy')
|
|
64
|
+
export const EditIcon = resolveIcon('EditIcon', 'edit')
|
|
65
|
+
export const EllipsisVerticalIcon = resolveIcon('EllipsisVerticalIcon', 'ellipsis-vertical')
|
|
66
|
+
export const LaunchIcon = resolveIcon('LaunchIcon', 'launch')
|
|
67
|
+
export const RocketIcon = resolveIcon('RocketIcon', 'rocket')
|
|
68
|
+
export const SchemaIcon = resolveIcon('SchemaIcon', 'schema')
|
|
69
|
+
export const TokenIcon = resolveIcon('TokenIcon', 'token')
|
|
70
|
+
export const TrashIcon = resolveIcon('TrashIcon', 'trash')
|
|
71
|
+
export const WarningOutlineIcon = resolveIcon('WarningOutlineIcon', 'warning-outline')
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// deploy-vercel-from-sanity — Sanity Studio v5 plugin for Vercel deployments
|
|
2
2
|
import { definePlugin } from 'sanity'
|
|
3
|
-
import { RocketIcon } from '
|
|
3
|
+
import { RocketIcon } from './icons'
|
|
4
4
|
import { DeployTool } from './components/DeployTool'
|
|
5
5
|
import { vercelDeploySchema } from './schema/vercelDeploy'
|
|
6
6
|
import type { VercelDeployPluginConfig } from './types'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Sanity schema for vercel_deploy documents — stores deploy hook targets
|
|
2
2
|
import { defineField, defineType } from 'sanity'
|
|
3
|
-
import { RocketIcon } from '
|
|
3
|
+
import { RocketIcon } from '../icons'
|
|
4
4
|
|
|
5
5
|
export const vercelDeploySchema = defineType({
|
|
6
6
|
name: 'vercel_deploy',
|
package/src/ui.tsx
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
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
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Package version — keep in sync with package.json
|
|
2
|
-
export const VERSION = '1.0
|
|
2
|
+
export const VERSION = '1.1.0'
|