diff --git a/apps/outreach/package.json b/apps/outreach/package.json index 4eb6371a1..2e45bad0a 100644 --- a/apps/outreach/package.json +++ b/apps/outreach/package.json @@ -13,6 +13,7 @@ "dependencies": { "@douglasneuroinformatics/libui": "catalog:", "@opendatacapture/licenses": "workspace:*", + "@opendatacapture/runtime-v1": "workspace:*", "@opendatacapture/schemas": "workspace:*", "clsx": "^2.1.1", "lodash-es": "workspace:lodash-es__4.x@*", diff --git a/apps/playground/package.json b/apps/playground/package.json index a5133a021..5125538d0 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -17,6 +17,7 @@ "@douglasneuroinformatics/libui": "catalog:", "@monaco-editor/react": "^4.7.0", "@opendatacapture/instrument-bundler": "workspace:*", + "@opendatacapture/playground-url": "workspace:*", "@opendatacapture/react-core": "workspace:*", "@opendatacapture/runtime-core": "workspace:*", "@opendatacapture/runtime-v1": "workspace:*", @@ -31,7 +32,6 @@ "jwt-decode": "^4.0.0", "lodash-es": "workspace:lodash-es__4.x@*", "lucide-react": "^0.503.0", - "lz-string": "^1.5.0", "monaco-editor": "^0.52.2", "motion": "catalog:", "neverthrow": "catalog:", diff --git a/apps/playground/src/components/Header/ShareButton/ShareButton.tsx b/apps/playground/src/components/Header/ShareButton/ShareButton.tsx index 1e21f3731..7f2430539 100644 --- a/apps/playground/src/components/Header/ShareButton/ShareButton.tsx +++ b/apps/playground/src/components/Header/ShareButton/ShareButton.tsx @@ -3,18 +3,20 @@ import { useEffect, useState } from 'react'; import { formatByteSize } from '@douglasneuroinformatics/libjs'; import { Heading, Input, Label, Popover, Tooltip } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { encodeShareURL } from '@opendatacapture/playground-url'; import { CopyButton } from '@opendatacapture/react-core'; import { CircleHelpIcon, Share2Icon } from 'lucide-react'; import { useFilesRef } from '@/hooks/useFilesRef'; import { useAppStore } from '@/store'; -import { encodeShareURL } from '@/utils/encode'; export const ShareButton = () => { const label = useAppStore((store) => store.selectedInstrument.label); const editorFilesRef = useFilesRef(); const [isFullscreen, setIsFullscreen] = useState(false); - const [shareURL, setShareURL] = useState(encodeShareURL({ files: editorFilesRef.current, label })); + const [shareURL, setShareURL] = useState( + encodeShareURL({ baseURL: window.location.origin, files: editorFilesRef.current, label }) + ); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isTooltipOpen, setIsTooltipOpen] = useState(false); const { t } = useTranslation(); @@ -22,7 +24,14 @@ export const ShareButton = () => { // The user cannot modify the editor without closing the popover useEffect(() => { if (isPopoverOpen) { - setShareURL(encodeShareURL({ files: editorFilesRef.current, fullscreen: isFullscreen, label })); + setShareURL( + encodeShareURL({ + baseURL: window.location.origin, + files: editorFilesRef.current, + fullscreen: isFullscreen, + label + }) + ); } }, [isFullscreen, isPopoverOpen, label]); diff --git a/apps/playground/src/pages/IndexPage.tsx b/apps/playground/src/pages/IndexPage.tsx index c8428ea64..c0fe61ace 100644 --- a/apps/playground/src/pages/IndexPage.tsx +++ b/apps/playground/src/pages/IndexPage.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { LanguageToggle, Separator, ThemeToggle } from '@douglasneuroinformatics/libui/components'; +import { decodeShareURL, isFullscreenShareURL } from '@opendatacapture/playground-url'; import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm?url'; import { Header } from '@/components/Header'; @@ -8,7 +9,6 @@ import { MainContent } from '@/components/MainContent'; import { Viewer } from '@/components/Viewer'; import type { InstrumentRepository } from '@/models/instrument-repository.model'; import { useAppStore } from '@/store'; -import { decodeShareURL, isFullscreenShareURL } from '@/utils/encode'; const { initialize } = await import('esbuild-wasm'); await initialize({ diff --git a/apps/playground/src/utils/encode.ts b/apps/playground/src/utils/encode.ts deleted file mode 100644 index 4705155c5..000000000 --- a/apps/playground/src/utils/encode.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; -import { z } from 'zod/v4'; - -import { $EditorFile } from '@/models/editor-file.model'; -import type { EditorFile } from '@/models/editor-file.model'; -import type { InstrumentRepository } from '@/models/instrument-repository.model'; - -type ShareURL = URL & { size: number }; - -const $EditorFiles = z.array($EditorFile); - -function decodeFiles(encodedFiles: string): EditorFile[] { - return $EditorFiles.parse(JSON.parse(decompressFromEncodedURIComponent(encodedFiles))); -} - -function encodeFiles(files: EditorFile[]): string { - return compressToEncodedURIComponent(JSON.stringify($EditorFiles.parse(files))); -} - -export function encodeShareURL({ - files, - fullscreen, - label -}: Pick & { fullscreen?: boolean }): ShareURL { - const url = new URL(location.origin) as ShareURL; - url.searchParams.append('files', encodeFiles(files)); - url.searchParams.append('label', compressToEncodedURIComponent(label)); - if (fullscreen) { - url.searchParams.append('fullscreen', '1'); - } - url.size = new TextEncoder().encode(url.href).length; - return url; -} - -export function isFullscreenShareURL(url: URL): boolean { - return url.searchParams.get('fullscreen') === '1'; -} - -export function decodeShareURL(url: URL): null | Pick { - const encodedFiles = url.searchParams.get('files'); - const encodedLabel = url.searchParams.get('label'); - if (!(encodedFiles && encodedLabel)) { - return null; - } - return { files: decodeFiles(encodedFiles), label: decompressFromEncodedURIComponent(encodedLabel) }; -} diff --git a/packages/playground-url/README.md b/packages/playground-url/README.md new file mode 100644 index 000000000..c9a232d56 --- /dev/null +++ b/packages/playground-url/README.md @@ -0,0 +1,46 @@ +# @opendatacapture/playground-url + +Generate shareable [Open Data Capture playground](https://playground.opendatacapture.org) links from instrument source files. + +A playground link embeds a snapshot of an instrument's source files directly in the URL (lz-string compressed). Anyone who opens the link gets that instrument loaded into the playground — no server or account required. + +## Library + +```ts +import { generatePlaygroundURL } from '@opendatacapture/playground-url'; + +const url = generatePlaygroundURL({ + files: [{ name: 'index.ts', content: 'export default { /* ... */ };' }], + label: 'My Instrument' +}); +// => https://playground.opendatacapture.org/?files=...&label=... +``` + +| Export | Description | +| -------------------------------- | ----------------------------------------------------------- | +| `generatePlaygroundURL(options)` | Returns the share link as a string. | +| `encodeShareURL(options)` | Returns a `URL` annotated with the encoded `size` in bytes. | +| `decodeShareURL(url)` | Decodes an instrument from a share URL (or `null`). | +| `isFullscreenShareURL(url)` | Whether the link opens the read-only fullscreen preview. | +| `DEFAULT_PLAYGROUND_URL` | Origin of the hosted playground. | + +Options: `files`, `label`, optional `fullscreen` (read-only preview) and `baseURL` (defaults to the hosted playground). + +## CLI + +Point it at a directory of instrument source files: + +```sh +npx @opendatacapture/playground-url ./my-instrument +``` + +The status line is written to stderr and the link to stdout, so it pipes cleanly. + +| Option | Description | +| ---------------------- | ----------------------------------------------------------------- | +| `-l, --label