diff --git a/.changeset/wild-ghosts-tease.md b/.changeset/wild-ghosts-tease.md new file mode 100644 index 00000000..c931d003 --- /dev/null +++ b/.changeset/wild-ghosts-tease.md @@ -0,0 +1,5 @@ +--- +'@myst-theme/frontmatter': patch +--- + +Add awesomebar support to launch button diff --git a/package-lock.json b/package-lock.json index f1f70f25..8c62c27b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8736,6 +8736,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "dev": true, "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-collection": "1.1.0", @@ -8766,6 +8767,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "dev": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.0", @@ -8792,6 +8794,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -9339,86 +9342,6 @@ } } }, - "node_modules/@radix-ui/react-tabs": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", - "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", - "dependencies": { - "@radix-ui/primitive": "1.1.0", - "@radix-ui/react-context": "1.1.1", - "@radix-ui/react-direction": "1.1.0", - "@radix-ui/react-id": "1.1.0", - "@radix-ui/react-presence": "1.1.1", - "@radix-ui/react-primitive": "2.0.0", - "@radix-ui/react-roving-focus": "1.1.0", - "@radix-ui/react-use-controllable-state": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", - "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", - "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", - "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-use-layout-effect": "1.1.0" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-toggle": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.0.tgz", @@ -41222,8 +41145,7 @@ "@radix-ui/react-form": "^0.1.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-tabs": "^1.1.1", - "@scienceicons/react": "^0.0.6", + "@scienceicons/react": "^0.0.12", "classnames": "^2.3.2", "myst-common": "*", "myst-frontmatter": "*" @@ -41242,9 +41164,9 @@ } }, "packages/frontmatter/node_modules/@scienceicons/react": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@scienceicons/react/-/react-0.0.6.tgz", - "integrity": "sha512-nDzPOhVAOuZrKfuK/GFvV7uI8T0bm8ByWv1DuPi4i8oGPJX2h18DOBwTT4IPbsaDKAU4JPkAMirH5sX8TQvZmQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@scienceicons/react/-/react-0.0.12.tgz", + "integrity": "sha512-8t8vACyfEFQagE49kIJTXSdczuyS7CNo4hx1lmPTX2S+/Ef46xR59W4+K+63F6whasq7i3SabMkGrfx9YQAFWQ==", "license": "MIT", "peerDependencies": { "react": ">= 16" diff --git a/packages/frontmatter/package.json b/packages/frontmatter/package.json index e383f85d..799eb1dd 100644 --- a/packages/frontmatter/package.json +++ b/packages/frontmatter/package.json @@ -25,8 +25,7 @@ "@radix-ui/react-form": "^0.1.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-popover": "^1.1.2", - "@radix-ui/react-tabs": "^1.1.1", - "@scienceicons/react": "^0.0.6", + "@scienceicons/react": "^0.0.12", "classnames": "^2.3.2", "myst-common": "*", "myst-frontmatter": "*" diff --git a/packages/frontmatter/src/LaunchButton.tsx b/packages/frontmatter/src/LaunchButton.tsx index 707f493f..b2d62ed7 100644 --- a/packages/frontmatter/src/LaunchButton.tsx +++ b/packages/frontmatter/src/LaunchButton.tsx @@ -2,8 +2,16 @@ import React, { useRef, useCallback, useState } from 'react'; import classNames from 'classnames'; import * as Popover from '@radix-ui/react-popover'; -import { RocketIcon, Cross2Icon, ClipboardCopyIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; -import * as Tabs from '@radix-ui/react-tabs'; +import { + RocketIcon, + Cross2Icon, + ClipboardCopyIcon, + ExternalLinkIcon, + QuestionMarkCircledIcon, + UpdateIcon, + Link2Icon, +} from '@radix-ui/react-icons'; +import { BinderIcon, JupyterIcon } from '@scienceicons/react/24/solid'; import * as Form from '@radix-ui/react-form'; import type { ExpandedThebeFrontmatter, BinderHubOptions } from 'myst-frontmatter'; @@ -16,32 +24,50 @@ const GIST_USERNAME_REPO_REGEX = type CopyButtonProps = { defaultMessage: string; - alternateMessage?: string; - timeout?: number; + copiedMessage?: string; + invalidLinkFallback?: string; + copiedMessageDuration?: number; buildLink: () => string | undefined; className?: string; }; +/** + * Component to add a copy-to-clipboard button + */ function CopyButton(props: CopyButtonProps) { - const { className, defaultMessage, alternateMessage, buildLink, timeout } = props; + const { + className, + defaultMessage, + copiedMessage, + invalidLinkFallback, + buildLink, + copiedMessageDuration, + } = props; const [message, setMessage] = useState(defaultMessage); const copyLink = useCallback(() => { - // Build the link for the clipboard - const link = props.buildLink(); // In secure links, we can copy it! if (window.isSecureContext) { + // Build the link for the clipboard + const link = props.buildLink(); // Write to clipboard - window.navigator.clipboard.writeText(link ?? ''); + window.navigator.clipboard.writeText(link ?? invalidLinkFallback ?? ''); // Update UI - setMessage(alternateMessage ?? defaultMessage); + setMessage(copiedMessage ?? defaultMessage); // Set callback to restore message setTimeout(() => { setMessage(defaultMessage); - }, timeout ?? 1000); + }, copiedMessageDuration ?? 1000); } - }, [defaultMessage, alternateMessage, buildLink, timeout, setMessage]); + }, [ + defaultMessage, + copiedMessage, + buildLink, + copiedMessageDuration, + invalidLinkFallback, + setMessage, + ]); return ( - - - - - ); + return 'error'; } -function JupyterHubLaunchContent(props: ModalLaunchProps) { - const { onLaunch, location, thebe } = props; +function DetectLaunchContent(props: ModalLaunchProps) { + const { thebe, location, onLaunch } = props; const { binder } = thebe; + const defaultBinderBaseURL = binder?.url ?? 'https://mybinder.org'; - const defaultHubBaseURL = ''; + // Detect the provider type + const [detectedProviderType, setDetectedProviderType] = useState< + ProviderType | 'error' | undefined + >(undefined); + + // Handle URL entry that needs to be debounced + const [url, setURL] = useState(''); + const onUrlChanged = useCallback( + (event: React.ChangeEvent) => { + // Reset the known state of the provider + setDetectedProviderType(undefined); + // Update the recorded state of the URL input + setURL(event.target.value); + }, + [setURL], + ); const formRef = useRef(null); + const buildLink = useCallback(() => { const form = formRef.current; if (!form) { @@ -321,14 +333,27 @@ function JupyterHubLaunchContent(props: ModalLaunchProps) { } const data = Object.fromEntries(new FormData(form) as any); - const rawHubBaseURL = data.url; - if (!rawHubBaseURL) { + const rawBaseUrl = data.url; + if (!rawBaseUrl) { return; } - const gitPullURL = makeNbgitpullerURL(binder ?? {}, location); - const hubURL = ensureBasename(rawHubBaseURL); - return `${hubURL}hub/user-redirect/${gitPullURL}`; - }, [formRef, location, binder]); + const baseUrl = ensureBasename(rawBaseUrl); + + const userProvider = data?.provider as ProviderType | undefined; + const provider = userProvider ?? detectedProviderType; + switch (provider) { + case 'jupyterhub': { + const gitPullURL = makeNbgitpullerURL(binder ?? {}, location); + return `${baseUrl}hub/user-redirect/${gitPullURL}`; + } + case 'binderhub': { + return makeBinderURL({ ...(binder ?? {}), url: baseUrl }, location); + } + case undefined: { + return; + } + } + }, [formRef, location, binder, detectedProviderType]); // FIXME: use ValidityState from radix-ui once passing-by-name is fixed const urlRef = useRef(null); @@ -340,6 +365,44 @@ function JupyterHubLaunchContent(props: ModalLaunchProps) { } }, [buildLink, urlRef]); + // Detect the provider type on debounced text input + const debouncedURL = useDebounce(url, 100); + const [isInterrogating, setIsInterrogating] = useState(false); + React.useEffect(() => { + // Check validity manually to ensure that we don't make requests that aren't sensible + const urlIsValid = !!urlRef.current?.checkValidity?.(); + // Don't detect URL if it isn't valid + if (!urlIsValid) { + return; + } + + // Enter interrogating state + setIsInterrogating(true); + + // Interrogate remote endpoint + let baseName; + try { + baseName = ensureBasename(debouncedURL); + } catch (err) { + return; + } + interrogateProviderType(baseName) + .then((provider: ProviderType | 'error') => { + if (provider !== 'error') { + setDetectedProviderType(provider); + } + // Special case for mybinder.org + else if (/https?:\/\/mybinder.org\//.test(baseName)) { + setDetectedProviderType('binderhub'); + } else { + setDetectedProviderType('error'); + } + }) + .catch(console.error) + // Clear the interrogating state + .finally(() => setIsInterrogating(false)); + }, [debouncedURL, urlRef, setIsInterrogating]); + const handleSubmit = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); @@ -352,46 +415,104 @@ function JupyterHubLaunchContent(props: ModalLaunchProps) { } onLaunch?.(); }, - [defaultHubBaseURL, buildLink, onLaunch], + [defaultBinderBaseURL, buildLink, onLaunch], ); return ( - -

Launch on a JupyterHub

+ -
- JupyterHub URL - - Please provide a URL. - - +
+ + Enter a JupyterHub or BinderHub URL, e.g.{' '} + + https://mybinder.org + + Please provide a valid URL that starts with http(s).
- - - +
+ + {(detectedProviderType === 'binderhub' && ( + + )) || + (detectedProviderType === 'jupyterhub' && ( + + )) || + (detectedProviderType === 'error' && ( + + )) || + (isInterrogating && ( + + )) || ( + + )} + + + + +
-
+
+ + The provider type could not be detected automatically. what kind of provider have you + given? + +
+ + +
+
+ + +
+
+ +
+ -
+ ); } @@ -413,34 +534,10 @@ export function LaunchButton(props: LaunchProps) { - - - - Binder - - - JupyterHub - - - - - - - - - +