sanity-plugin-iframe-pane 2.3.1 → 2.3.2-canary.2

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.
Files changed (40) hide show
  1. package/README.md +15 -5
  2. package/lib/_chunks/is-valid-secret-57fea7e5.js +35 -0
  3. package/lib/_chunks/is-valid-secret-57fea7e5.js.map +1 -0
  4. package/lib/_chunks/is-valid-secret-7b704c76.cjs +41 -0
  5. package/lib/_chunks/is-valid-secret-7b704c76.cjs.map +1 -0
  6. package/lib/_chunks/types-107599a1.js +34 -0
  7. package/lib/_chunks/types-107599a1.js.map +1 -0
  8. package/lib/_chunks/types-4a29b6ac.cjs +38 -0
  9. package/lib/_chunks/types-4a29b6ac.cjs.map +1 -0
  10. package/lib/index.cjs +462 -154
  11. package/lib/index.cjs.js +3 -1
  12. package/lib/index.cjs.map +1 -1
  13. package/lib/index.d.ts +38 -11
  14. package/lib/index.js +462 -158
  15. package/lib/index.js.map +1 -1
  16. package/lib/is-valid-secret.cjs +8 -0
  17. package/lib/is-valid-secret.cjs.js +4 -0
  18. package/lib/is-valid-secret.cjs.map +1 -0
  19. package/lib/is-valid-secret.d.ts +41 -0
  20. package/lib/is-valid-secret.js +2 -0
  21. package/lib/is-valid-secret.js.map +1 -0
  22. package/lib/preview-url.cjs +77 -0
  23. package/lib/preview-url.cjs.js +4 -0
  24. package/lib/preview-url.cjs.map +1 -0
  25. package/lib/preview-url.d.ts +17 -0
  26. package/lib/preview-url.js +72 -0
  27. package/lib/preview-url.js.map +1 -0
  28. package/package.json +34 -10
  29. package/src/DisplayUrl.tsx +21 -0
  30. package/src/GetUrlSecret.tsx +80 -0
  31. package/src/Iframe.tsx +272 -158
  32. package/src/Toolbar.tsx +153 -0
  33. package/src/defineUrlResolver.tsx +31 -0
  34. package/src/index.ts +3 -5
  35. package/src/is-valid-secret.ts +2 -0
  36. package/src/isValidSecret.tsx +67 -0
  37. package/src/preview-url.ts +2 -0
  38. package/src/previewUrl.ts +62 -0
  39. package/src/types.ts +17 -0
  40. package/src/utils.ts +45 -0
package/src/Iframe.tsx CHANGED
@@ -1,43 +1,28 @@
1
1
  /* eslint-disable react/jsx-no-bind */
2
- import {CopyIcon, LeaveIcon, MobileDeviceIcon, UndoIcon} from '@sanity/icons'
3
- import {Box, Button, Card, Flex, Spinner, Text, ThemeProvider} from '@sanity/ui'
4
- import React, {useEffect, useRef, useState} from 'react'
2
+ import {WarningOutlineIcon} from '@sanity/icons'
3
+ import {Box, Card, Container, Flex, Spinner, Stack, Text, usePrefersReducedMotion} from '@sanity/ui'
4
+ import {AnimatePresence, motion, MotionConfig} from 'framer-motion'
5
+ import React, {forwardRef, useCallback, useDeferredValue, useEffect, useRef, useState} from 'react'
5
6
  import {HTMLAttributeReferrerPolicy} from 'react'
6
7
  import {SanityDocumentLike} from 'sanity'
7
- import {useCopyToClipboard} from 'usehooks-ts'
8
8
 
9
- type Size = 'desktop' | 'mobile'
9
+ import {UrlResolver} from './defineUrlResolver'
10
+ import {GetUrlSecret} from './GetUrlSecret'
11
+ import {UrlSecretId} from './isValidSecret'
12
+ import {DEFAULT_SIZE, sizes, Toolbar} from './Toolbar'
13
+ import {IframeSizeKey, MissingSlug, SetError, type UrlState} from './types'
10
14
 
11
- type SizeProps = {
12
- // eslint-disable-next-line no-unused-vars
13
- [key in Size]: {
14
- width: string | number
15
- height: string | number
16
- maxHeight: string | number
17
- }
18
- }
19
-
20
- const sizes: SizeProps = {
21
- desktop: {
22
- width: `100%`,
23
- height: `100%`,
24
- maxHeight: `100%`,
25
- },
26
- mobile: {
27
- width: 414,
28
- height: `100%`,
29
- maxHeight: 736,
30
- },
31
- }
15
+ export type {UrlResolver, UrlSecretId}
32
16
 
33
17
  export type IframeOptions = {
34
- url: string | ((document: SanityDocumentLike) => unknown)
35
- defaultSize?: 'desktop' | 'mobile'
36
- loader?: boolean | string
18
+ urlSecretId?: UrlSecretId
19
+ url: UrlState | UrlResolver
20
+ defaultSize?: IframeSizeKey
21
+ loader?: string | boolean
37
22
  showDisplayUrl?: boolean
38
- reload: {
39
- revision: boolean | number
40
- button: boolean
23
+ reload?: {
24
+ revision?: boolean | number
25
+ button?: boolean
41
26
  }
42
27
  attributes?: Partial<{
43
28
  allow: string
@@ -47,187 +32,316 @@ export type IframeOptions = {
47
32
  }>
48
33
  }
49
34
 
50
- export type IframeProps = {
35
+ const MotionFlex = motion(Flex)
36
+
37
+ export interface IframeProps {
51
38
  document: {
52
39
  displayed: SanityDocumentLike
53
40
  }
54
41
  options: IframeOptions
55
42
  }
56
43
 
57
- const DEFAULT_SIZE = `desktop`
44
+ export function Iframe(props: IframeProps) {
45
+ const [error, setError] = useState<unknown>(null)
46
+ if (error) {
47
+ throw error
48
+ }
58
49
 
59
- function Iframe(props: IframeProps) {
60
50
  const {document: sanityDocument, options} = props
61
51
  const {
62
52
  url,
53
+ urlSecretId,
63
54
  defaultSize = DEFAULT_SIZE,
64
55
  reload,
65
- loader,
56
+ loader = 'Loading…',
66
57
  attributes = {},
67
58
  showDisplayUrl = true,
68
59
  } = options
69
- const [displayUrl, setDisplayUrl] = useState(url && typeof url === 'string' ? url : ``)
70
60
  const [iframeSize, setIframeSize] = useState(sizes?.[defaultSize] ? defaultSize : DEFAULT_SIZE)
71
- const [loading, setLoading] = useState(false)
72
- const input = useRef<HTMLTextAreaElement>(null)
73
- const iframe = useRef<HTMLIFrameElement>(null)
74
- const {displayed} = sanityDocument
75
- const [, copy] = useCopyToClipboard()
76
61
 
77
- function handleCopy() {
78
- if (!input?.current?.value) return
62
+ // Workaround documents that initially appears to be an empty new document but just hasen't loaded yet
63
+ const [workaroundEmptyDocument, setWorkaroundEmptyDocument] = useState(true)
64
+ useEffect(() => {
65
+ const timeout = setTimeout(() => setWorkaroundEmptyDocument(false), 1000)
66
+ return () => clearTimeout(timeout)
67
+ }, [])
68
+
69
+ const prefersReducedMotion = usePrefersReducedMotion()
70
+ const [urlState, setUrlState] = useState<UrlState>(() => (typeof url === 'function' ? '' : url))
79
71
 
80
- copy(input.current.value)
81
- }
72
+ const [loading, setLoading] = useState(true)
73
+ const [reloading, setReloading] = useState(false)
74
+
75
+ const iframe = useRef<HTMLIFrameElement>(null)
76
+ const {displayed} = sanityDocument
82
77
 
83
- function handleReload() {
78
+ const handleReload = useCallback(() => {
84
79
  if (!iframe?.current) {
85
80
  return
86
81
  }
87
82
 
88
- // Funky way to reload an iframe without CORS issuies
83
+ // Funky way to reload an iframe without CORS issues
89
84
  // eslint-disable-next-line no-self-assign
90
85
  iframe.current.src = iframe.current.src
91
86
 
92
- setLoading(true)
93
- }
87
+ setReloading(true)
88
+ }, [])
89
+
90
+ const deferredRevision = useDeferredValue(displayed._rev)
91
+ const displayUrl = typeof urlState === 'string' ? urlState : ''
92
+
93
+ return (
94
+ <MotionConfig transition={prefersReducedMotion ? {duration: 0} : undefined}>
95
+ <Flex direction="column" style={{height: `100%`}}>
96
+ <Toolbar
97
+ displayUrl={displayUrl}
98
+ iframeSize={iframeSize}
99
+ reloading={reloading}
100
+ setIframeSize={setIframeSize}
101
+ showDisplayUrl={showDisplayUrl}
102
+ reloadButton={!!reload?.button}
103
+ handleReload={handleReload}
104
+ />
105
+ {urlState === MissingSlug && !workaroundEmptyDocument ? (
106
+ <MissingSlugScreen />
107
+ ) : (
108
+ <Card tone="transparent" style={{height: `100%`}}>
109
+ <Frame
110
+ ref={iframe}
111
+ loader={loader}
112
+ loading={loading}
113
+ reloading={reloading}
114
+ iframeSize={iframeSize}
115
+ setReloading={setReloading}
116
+ setLoading={setLoading}
117
+ displayUrl={displayUrl}
118
+ attributes={attributes}
119
+ />
120
+ </Card>
121
+ )}
122
+ {typeof url === 'function' && (
123
+ <AsyncUrl
124
+ // We use the revision as a key, to force a re-render when the revision changes
125
+ // This allows us to respond to changed props (maybe the url function itself changes)
126
+ // But avoid calling async logic on every render accidentally
127
+ key={deferredRevision}
128
+ url={url}
129
+ displayed={displayed}
130
+ urlSecretId={urlSecretId}
131
+ setDisplayUrl={setUrlState}
132
+ setError={setError}
133
+ />
134
+ )}
135
+ {displayUrl && (reload?.revision || reload?.revision === 0) && (
136
+ <ReloadOnRevision
137
+ revision={reload.revision}
138
+ _rev={deferredRevision}
139
+ handleReload={handleReload}
140
+ />
141
+ )}
142
+ </Flex>
143
+ </MotionConfig>
144
+ )
145
+ }
146
+
147
+ interface FrameProps extends Required<Pick<IframeOptions, 'loader' | 'attributes'>> {
148
+ loader: string | boolean
149
+ loading: boolean
150
+ reloading: boolean
151
+ setLoading: (loading: boolean) => void
152
+ setReloading: (reloading: boolean) => void
153
+ iframeSize: IframeSizeKey
154
+ displayUrl: string
155
+ }
156
+ const Frame = forwardRef(function Frame(
157
+ props: FrameProps,
158
+ iframe: React.ForwardedRef<HTMLIFrameElement>,
159
+ ) {
160
+ const {loader, loading, setLoading, iframeSize, attributes, reloading, displayUrl, setReloading} =
161
+ props
94
162
 
95
163
  function handleIframeLoad() {
96
164
  setLoading(false)
165
+ setReloading(false)
97
166
  // Run onLoad from attributes
98
167
  if (attributes.onLoad && typeof attributes.onLoad === 'function') {
99
168
  attributes.onLoad()
100
169
  }
101
170
  }
102
171
 
172
+ return (
173
+ <Flex align="center" justify="center" style={{height: `100%`, position: `relative`}}>
174
+ <AnimatePresence>
175
+ {loader && loading && (
176
+ <MotionFlex
177
+ initial="initial"
178
+ animate="animate"
179
+ exit="exit"
180
+ variants={spinnerVariants}
181
+ justify="center"
182
+ align="center"
183
+ style={{inset: `0`, position: `absolute`}}
184
+ >
185
+ <Flex
186
+ style={{...sizes[iframeSize]}}
187
+ justify="center"
188
+ align="center"
189
+ direction="column"
190
+ gap={4}
191
+ >
192
+ <Spinner muted />
193
+ {loader && typeof loader === 'string' && (
194
+ <Text muted size={1}>
195
+ {loader}
196
+ </Text>
197
+ )}
198
+ </Flex>
199
+ </MotionFlex>
200
+ )}
201
+ </AnimatePresence>
202
+ <motion.iframe
203
+ ref={iframe}
204
+ title="preview"
205
+ frameBorder="0"
206
+ style={{maxHeight: '100%'}}
207
+ src={displayUrl}
208
+ initial={['background', iframeSize]}
209
+ variants={iframeVariants}
210
+ animate={[
211
+ loader && loading ? 'background' : 'active',
212
+ reloading ? 'reloading' : 'idle',
213
+ iframeSize,
214
+ ]}
215
+ {...attributes}
216
+ onLoad={handleIframeLoad}
217
+ />
218
+ </Flex>
219
+ )
220
+ })
221
+
222
+ const spinnerVariants = {
223
+ initial: {opacity: 1},
224
+ animate: {opacity: [0, 0, 1]},
225
+ exit: {opacity: [1, 0, 0]},
226
+ }
227
+
228
+ const iframeVariants = {
229
+ ...sizes,
230
+ desktop: {
231
+ ...sizes.desktop,
232
+ boxShadow: '0 0 0 0px var(--card-shadow-outline-color)',
233
+ },
234
+ mobile: {
235
+ ...sizes.mobile,
236
+ boxShadow: '0 0 0 1px var(--card-shadow-outline-color)',
237
+ },
238
+ background: {
239
+ opacity: 0,
240
+ scale: 1,
241
+ },
242
+ idle: {
243
+ scale: 1,
244
+ },
245
+ reloading: {
246
+ scale: [1, 1, 1, 0.98],
247
+ },
248
+ active: {
249
+ opacity: [0, 0, 1],
250
+ scale: 1,
251
+ },
252
+ }
253
+
254
+ interface ReloadOnRevisionProps {
255
+ _rev?: string
256
+ revision: number | boolean
257
+ handleReload: () => void
258
+ }
259
+ function ReloadOnRevision(props: ReloadOnRevisionProps) {
260
+ const {revision, handleReload, _rev} = props
261
+ const [initialRev] = useState(_rev)
103
262
  // Reload on new revisions
263
+ // eslint-disable-next-line consistent-return
104
264
  useEffect(() => {
105
- if (reload?.revision || reload?.revision == 0) {
106
- setTimeout(() => {
107
- handleReload()
108
- }, Number(reload?.revision))
265
+ if (_rev !== initialRev) {
266
+ const timeout = setTimeout(handleReload, Number(revision === true ? 300 : revision))
267
+ return () => clearTimeout(timeout)
109
268
  }
110
- }, [displayed._rev, reload?.revision])
269
+ }, [_rev, revision, handleReload, initialRev])
270
+
271
+ return null
272
+ }
273
+
274
+ interface AsyncUrlProps {
275
+ displayed: SanityDocumentLike
276
+ url: UrlResolver
277
+ urlSecretId?: UrlSecretId
278
+ setDisplayUrl: (url: UrlState) => void
279
+ setError: SetError
280
+ }
281
+ function AsyncUrl(props: AsyncUrlProps) {
282
+ const {urlSecretId, setDisplayUrl, setError} = props
283
+ // Snapshot values we only care about when the revision changes, done by changing the `key` prop
284
+ const [displayed] = useState(props.displayed)
285
+ const [url] = useState(() => props.url)
286
+ const [urlSecret, setUrlSecret] = useState<null | string>(null)
111
287
 
112
288
  // Set initial URL and refresh on new revisions
113
289
  useEffect(() => {
114
- const getUrl = async () => {
115
- setLoading(true)
116
- const resolveUrl = typeof url === 'function' ? await url(displayed) : ``
290
+ if (urlSecretId && !urlSecret) return
291
+
292
+ const getUrl = async (signal: AbortSignal) => {
293
+ const resolveUrl = await url(displayed, urlSecret, abort.signal)
117
294
 
118
295
  // Only update state if URL has changed
119
- if (resolveUrl !== displayUrl && resolveUrl && typeof resolveUrl === 'string') {
296
+ if (!signal.aborted && resolveUrl) {
120
297
  setDisplayUrl(resolveUrl)
121
298
  }
122
299
  }
123
300
 
124
- if (typeof url === 'function') {
125
- getUrl()
126
- }
127
- // eslint-disable-next-line react-hooks/exhaustive-deps
128
- }, [displayed._rev])
301
+ const abort = new AbortController()
302
+ getUrl(abort.signal).catch((error) => error.name !== 'AbortError' && setError(error))
303
+ // eslint-disable-next-line consistent-return
304
+ return () => abort.abort()
305
+ }, [displayed, setDisplayUrl, setError, url, urlSecret, urlSecretId])
129
306
 
130
- if (!displayUrl || typeof displayUrl !== 'string') {
307
+ if (urlSecretId) {
131
308
  return (
132
- <ThemeProvider>
133
- <Flex padding={5} align="center" justify="center">
134
- <Spinner />
135
- </Flex>
136
- </ThemeProvider>
309
+ <GetUrlSecret
310
+ urlSecretId={urlSecretId}
311
+ urlSecret={urlSecret}
312
+ setUrlSecret={setUrlSecret}
313
+ setError={setError}
314
+ />
137
315
  )
138
316
  }
139
317
 
318
+ return null
319
+ }
320
+
321
+ export function MissingSlugScreen() {
140
322
  return (
141
- <ThemeProvider>
142
- <textarea
143
- style={{position: `absolute`, pointerEvents: `none`, opacity: 0}}
144
- ref={input}
145
- value={displayUrl}
146
- readOnly
147
- tabIndex={-1}
148
- />
149
- <Flex direction="column" style={{height: `100%`}}>
150
- <Card padding={2} borderBottom>
151
- <Flex align="center" gap={2}>
152
- <Flex align="center" gap={1}>
153
- <Button
154
- fontSize={[1]}
155
- padding={2}
156
- tone="primary"
157
- mode={iframeSize === 'mobile' ? 'default' : 'ghost'}
158
- icon={MobileDeviceIcon}
159
- onClick={() => setIframeSize(iframeSize === 'mobile' ? 'desktop' : 'mobile')}
160
- />
161
- </Flex>
162
- <Box flex={1}>
163
- {showDisplayUrl && (
164
- <Text size={0} textOverflow="ellipsis">
165
- {displayUrl}
323
+ <Card height="fill">
324
+ <Flex align="center" height="fill" justify="center" padding={4} sizing="border">
325
+ <Container width={0}>
326
+ <Card padding={4} radius={2} shadow={1} tone="caution">
327
+ <Flex>
328
+ <Box>
329
+ <Text size={1}>
330
+ <WarningOutlineIcon />
166
331
  </Text>
167
- )}
168
- </Box>
169
- <Flex align="center" gap={1}>
170
- {reload?.button ? (
171
- <Button
172
- fontSize={[1]}
173
- padding={2}
174
- icon={UndoIcon}
175
- title="Reload"
176
- aria-label="Reload"
177
- onClick={() => handleReload()}
178
- />
179
- ) : null}
180
- <Button
181
- fontSize={[1]}
182
- icon={CopyIcon}
183
- padding={[2]}
184
- title="Copy"
185
- aria-label="Copy"
186
- onClick={() => handleCopy()}
187
- />
188
- <Button
189
- fontSize={[1]}
190
- icon={LeaveIcon}
191
- padding={[2]}
192
- text="Open"
193
- tone="primary"
194
- onClick={() => window.open(displayUrl)}
195
- />
332
+ </Box>
333
+ <Stack flex={1} marginLeft={3} space={3}>
334
+ <Text as="h1" size={1} weight="bold">
335
+ Missing slug
336
+ </Text>
337
+ <Text as="p" muted size={1}>
338
+ Add a slug to see the preview.
339
+ </Text>
340
+ </Stack>
196
341
  </Flex>
197
- </Flex>
198
- </Card>
199
- <Card tone="transparent" padding={iframeSize === 'mobile' ? 2 : 0} style={{height: `100%`}}>
200
- <Flex align="center" justify="center" style={{height: `100%`, position: `relative`}}>
201
- {loader && loading && (
202
- <Flex justify="center" align="center" style={{inset: `0`, position: `absolute`}}>
203
- <Flex
204
- style={{...sizes[iframeSize], backgroundColor: `rgba(0,0,0,0.2)`}}
205
- justify="center"
206
- align="center"
207
- >
208
- <Card padding={4} radius={2} shadow={1}>
209
- <Flex align="center" direction="column" gap={3} height="fill" justify="center">
210
- <Spinner />
211
- {loader && typeof loader === 'string' && <Text size={1}>{loader}</Text>}
212
- </Flex>
213
- </Card>
214
- </Flex>
215
- </Flex>
216
- )}
217
- <iframe
218
- ref={iframe}
219
- title="preview"
220
- style={sizes[iframeSize]}
221
- frameBorder="0"
222
- src={displayUrl}
223
- {...attributes}
224
- onLoad={handleIframeLoad}
225
- />
226
- </Flex>
227
- </Card>
342
+ </Card>
343
+ </Container>
228
344
  </Flex>
229
- </ThemeProvider>
345
+ </Card>
230
346
  )
231
347
  }
232
-
233
- export default Iframe
@@ -0,0 +1,153 @@
1
+ /* eslint-disable react/jsx-no-bind */
2
+ import {ClipboardIcon, LaunchIcon, MobileDeviceIcon, UndoIcon} from '@sanity/icons'
3
+ import {Box, Button, Card, Flex, Text, Tooltip, useToast} from '@sanity/ui'
4
+ import React, {useRef} from 'react'
5
+ import {useCopyToClipboard} from 'usehooks-ts'
6
+
7
+ import {DisplayUrl} from './DisplayUrl'
8
+ import {IframeSizeKey, type SizeProps} from './types'
9
+
10
+ export const sizes: SizeProps = {
11
+ desktop: {
12
+ width: '100%',
13
+ height: '100%',
14
+ },
15
+ mobile: {
16
+ width: 414,
17
+ height: 746,
18
+ },
19
+ }
20
+
21
+ export const DEFAULT_SIZE = `desktop`
22
+
23
+ export interface ToolbarProps {
24
+ displayUrl: string
25
+ iframeSize: IframeSizeKey
26
+ setIframeSize: (size: IframeSizeKey) => void
27
+ showDisplayUrl: boolean
28
+ reloading: boolean
29
+ reloadButton: boolean
30
+ handleReload: () => void
31
+ }
32
+ export function Toolbar(props: ToolbarProps) {
33
+ const {
34
+ displayUrl,
35
+ iframeSize,
36
+ setIframeSize,
37
+ reloading,
38
+ showDisplayUrl,
39
+ reloadButton,
40
+ handleReload,
41
+ } = props
42
+
43
+ const input = useRef<HTMLTextAreaElement>(null)
44
+ const {push: pushToast} = useToast()
45
+ const [, copy] = useCopyToClipboard()
46
+
47
+ return (
48
+ <>
49
+ <textarea
50
+ style={{position: `absolute`, pointerEvents: `none`, opacity: 0}}
51
+ ref={input}
52
+ value={displayUrl}
53
+ readOnly
54
+ tabIndex={-1}
55
+ />
56
+ <Card padding={2} borderBottom>
57
+ <Flex align="center" gap={2}>
58
+ <Flex align="center" gap={1}>
59
+ <Tooltip
60
+ content={
61
+ <Text size={1} style={{whiteSpace: 'nowrap'}}>
62
+ {iframeSize === 'mobile' ? 'Exit mobile preview' : 'Preview mobile viewport'}
63
+ </Text>
64
+ }
65
+ padding={2}
66
+ >
67
+ <Button
68
+ disabled={!displayUrl}
69
+ fontSize={[1]}
70
+ padding={2}
71
+ mode={iframeSize === 'mobile' ? 'default' : 'ghost'}
72
+ icon={MobileDeviceIcon}
73
+ onClick={() => setIframeSize(iframeSize === 'mobile' ? 'desktop' : 'mobile')}
74
+ />
75
+ </Tooltip>
76
+ </Flex>
77
+ <Box flex={1}>
78
+ {showDisplayUrl && displayUrl && <DisplayUrl displayUrl={displayUrl} />}
79
+ </Box>
80
+ <Flex align="center" gap={1}>
81
+ {reloadButton ? (
82
+ <Tooltip
83
+ content={
84
+ <Text size={1} style={{whiteSpace: 'nowrap'}}>
85
+ {reloading ? 'Reloading…' : 'Reload'}
86
+ </Text>
87
+ }
88
+ padding={2}
89
+ >
90
+ <Button
91
+ disabled={!displayUrl}
92
+ mode="bleed"
93
+ fontSize={[1]}
94
+ padding={2}
95
+ icon={<UndoIcon style={{transform: 'rotate(90deg) scaleY(-1)'}} />}
96
+ loading={reloading}
97
+ aria-label="Reload"
98
+ onClick={() => handleReload()}
99
+ />
100
+ </Tooltip>
101
+ ) : null}
102
+ <Tooltip
103
+ content={
104
+ <Text size={1} style={{whiteSpace: 'nowrap'}}>
105
+ Copy URL
106
+ </Text>
107
+ }
108
+ padding={2}
109
+ >
110
+ <Button
111
+ mode="bleed"
112
+ disabled={!displayUrl}
113
+ fontSize={[1]}
114
+ icon={ClipboardIcon}
115
+ padding={[2]}
116
+ aria-label="Copy URL"
117
+ onClick={() => {
118
+ if (!input?.current?.value) return
119
+
120
+ copy(input.current.value)
121
+ pushToast({
122
+ closable: true,
123
+ status: 'success',
124
+ title: 'The URL is copied to the clipboard',
125
+ })
126
+ }}
127
+ />
128
+ </Tooltip>
129
+ <Tooltip
130
+ content={
131
+ <Text size={1} style={{whiteSpace: 'nowrap'}}>
132
+ Open URL in a new tab
133
+ </Text>
134
+ }
135
+ padding={2}
136
+ >
137
+ <Button
138
+ disabled={!displayUrl}
139
+ fontSize={[1]}
140
+ icon={LaunchIcon}
141
+ mode="ghost"
142
+ paddingY={[2]}
143
+ text="Open"
144
+ aria-label="Open URL in a new tab"
145
+ onClick={() => window.open(displayUrl)}
146
+ />
147
+ </Tooltip>
148
+ </Flex>
149
+ </Flex>
150
+ </Card>
151
+ </>
152
+ )
153
+ }
@@ -0,0 +1,31 @@
1
+ import type {SanityDocumentLike} from 'sanity'
2
+
3
+ import {MissingSlug, UrlState} from './types'
4
+
5
+ export type UrlResolver = (
6
+ document: SanityDocumentLike,
7
+ urlSecret: string | null | undefined,
8
+ signal?: AbortSignal,
9
+ ) => UrlState | Promise<UrlState>
10
+
11
+ export interface DefineUrlResolverOptions {
12
+ base: string | URL
13
+ requiresSlug?: string[]
14
+ }
15
+ export function defineUrlResolver(options: DefineUrlResolverOptions): UrlResolver {
16
+ const {base, requiresSlug = []} = options
17
+ return (document, urlSecret) => {
18
+ const url = new URL(base, location.origin)
19
+ url.searchParams.set('type', document._type)
20
+ const slug = (document?.slug as any)?.current
21
+ if (slug) {
22
+ url.searchParams.set('slug', slug)
23
+ } else if (requiresSlug.includes(document._type)) {
24
+ return MissingSlug
25
+ }
26
+ if (urlSecret) {
27
+ url.searchParams.set('secret', urlSecret)
28
+ }
29
+ return url.toString()
30
+ }
31
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- import IframeComponent, {IframeOptions as IframeOptionsType} from './Iframe'
2
-
3
- export default IframeComponent
4
-
5
- export type IframeOptions = IframeOptionsType
1
+ export type * from './Iframe'
2
+ export {Iframe as default, Iframe} from './Iframe'
3
+ export type * from './types'