diff --git a/docs/ViewersConfiguration.md b/docs/ViewersConfiguration.md index 52542957..714b15e0 100644 --- a/docs/ViewersConfiguration.md +++ b/docs/ViewersConfiguration.md @@ -114,6 +114,12 @@ viewers: ``` +#### Per-user override of the Neuroglancer URL + +When a deployment overrides Neuroglancer's URL with `instance_template_url` (for example, to point at an internal Neuroglancer instance), individual users can still switch their own links back to the manifest default (the external Neuroglancer at `neuroglancer-demo.appspot.com`) from **Preferences → Neuroglancer**. + +This choice is a per-user preference (`viewerUrlSources`); it does not change the deployment configuration and only affects the user who sets it. The option only appears when the deployment actually overrides the Neuroglancer URL — if the configured URL already equals the manifest default, there is nothing to switch between and no control is shown. + ### Add a custom viewer To add a new viewer, create a capability manifest YAML file, host it at a URL, and reference it in the config: diff --git a/frontend/src/__tests__/unitTests/viewerUrl.test.ts b/frontend/src/__tests__/unitTests/viewerUrl.test.ts new file mode 100644 index 00000000..e566e8ff --- /dev/null +++ b/frontend/src/__tests__/unitTests/viewerUrl.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest'; + +import { resolveViewerTemplate } from '@/utils/viewerUrl'; +import type { ValidViewer } from '@/contexts/ViewersContext'; + +const viewer = { + key: 'neuroglancer', + displayName: 'Neuroglancer', + urlTemplate: 'https://ng.internal.example.org/#!', + manifestTemplateUrl: 'https://neuroglancer-demo.appspot.com/#!', + logoPath: '', + label: 'View in Neuroglancer', + manifest: {} as ValidViewer['manifest'] +} satisfies ValidViewer; + +describe('resolveViewerTemplate', () => { + test('defaults to the configured template when no source is set', () => { + expect(resolveViewerTemplate(viewer, undefined)).toBe(viewer.urlTemplate); + expect(resolveViewerTemplate(viewer, 'configured')).toBe( + viewer.urlTemplate + ); + }); + + test('uses the manifest default when source is "manifest"', () => { + expect(resolveViewerTemplate(viewer, 'manifest')).toBe( + viewer.manifestTemplateUrl + ); + }); + + test('falls back to the configured template when manifest default is empty', () => { + const noManifest = { ...viewer, manifestTemplateUrl: '' }; + expect(resolveViewerTemplate(noManifest, 'manifest')).toBe( + noManifest.urlTemplate + ); + }); + + test('uses the custom URL when provided', () => { + expect( + resolveViewerTemplate(viewer, { custom: 'https://custom.example/#!' }) + ).toBe('https://custom.example/#!'); + }); + + test('falls back to the configured template for an empty custom URL', () => { + expect(resolveViewerTemplate(viewer, { custom: '' })).toBe( + viewer.urlTemplate + ); + }); +}); diff --git a/frontend/src/components/NGLinks.tsx b/frontend/src/components/NGLinks.tsx index e9080244..cdf2dbf4 100644 --- a/frontend/src/components/NGLinks.tsx +++ b/frontend/src/components/NGLinks.tsx @@ -10,6 +10,7 @@ import FgDialog from '@/components/ui/Dialogs/FgDialog'; import { useNGLinkContext } from '@/contexts/NGLinkContext'; import type { CreateNGLinkPayload, NGLink } from '@/queries/ngLinkQueries'; import { copyToClipboard } from '@/utils/copyText'; +import { useDefaultNeuroglancerBaseUrl } from '@/hooks/useDefaultNeuroglancerBaseUrl'; import FgButton from './designSystem/atoms/FgButton'; export default function NGLinks() { @@ -22,6 +23,7 @@ export default function NGLinks() { const [showDialog, setShowDialog] = useState(false); const [editItem, setEditItem] = useState(undefined); const [deleteItem, setDeleteItem] = useState(undefined); + const defaultBaseUrl = useDefaultNeuroglancerBaseUrl(); const handleOpenCreate = () => { setEditItem(undefined); @@ -134,6 +136,7 @@ export default function NGLinks() { {showDialog ? ( Data links -
+
+
+
+ +
+ + Neuroglancer links + +
diff --git a/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx index 63936d8c..d51af805 100644 --- a/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx +++ b/frontend/src/components/ui/Dialogs/NGLinkDialog.tsx @@ -13,6 +13,7 @@ import { validateJsonState, constructNeuroglancerUrl } from '@/utils'; +import { DEFAULT_NEUROGLANCER_BASE_URL } from '@/hooks/useDefaultNeuroglancerBaseUrl'; import FgButton from '@/components/designSystem/atoms/FgButton'; import FgFormField from '@/components/designSystem/molecules/FgFormField'; import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; @@ -27,17 +28,22 @@ type NGLinkDialogProps = { readonly onCreate?: (payload: CreateNGLinkPayload) => Promise; readonly onUpdate?: (payload: UpdateNGLinkPayload) => Promise; readonly editItem?: NGLink; + /** + * Base URL a new short link defaults to, resolved from the deployment config + * and the user's Neuroglancer URL preference by the parent. Falls back to the + * external default when not provided. + */ + readonly defaultBaseUrl?: string; }; -const DEFAULT_BASE_URL = 'https://neuroglancer-demo.appspot.com/'; - export default function NGLinkDialog({ open, pending, onClose, onCreate, onUpdate, - editItem + editItem, + defaultBaseUrl = DEFAULT_NEUROGLANCER_BASE_URL }: NGLinkDialogProps) { const isEditMode = !!editItem; const urlInputRef = useRef(null); @@ -46,7 +52,7 @@ export default function NGLinkDialog({ const [inputMode, setInputMode] = useState<'url' | 'state'>('url'); const [neuroglancerUrl, setNeuroglancerUrl] = useState(''); const [stateJson, setStateJson] = useState(''); - const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL); + const [baseUrl, setBaseUrl] = useState(defaultBaseUrl); const [shortName, setShortName] = useState(''); const [title, setTitle] = useState(''); const [error, setError] = useState(null); @@ -78,12 +84,22 @@ export default function NGLinkDialog({ setShortName(''); setTitle(''); setStateJson(''); - setBaseUrl(DEFAULT_BASE_URL); setUrlValidationError(null); setStateValidationError(null); } }, [editItem]); + // When creating a new link, keep the base URL aligned with the resolved + // default as it becomes available (viewers finishing loading) or changes + // (preference update). Owns the create-mode base URL so the reset above + // stays keyed on editItem alone and never clears a half-filled form. Does + // not run in edit mode, so existing links keep their stored base URL. + useEffect(() => { + if (!editItem) { + setBaseUrl(defaultBaseUrl); + } + }, [defaultBaseUrl, editItem]); + // Auto-select the URL text once after it's populated in edit mode useEffect(() => { if (shouldAutoSelectUrl.current && urlInputRef.current) { @@ -131,7 +147,7 @@ export default function NGLinkDialog({ if (!isEditMode) { setNeuroglancerUrl(''); setStateJson(''); - setBaseUrl(DEFAULT_BASE_URL); + setBaseUrl(defaultBaseUrl); } setUrlValidationError(null); setStateValidationError(null); @@ -172,7 +188,7 @@ export default function NGLinkDialog({ setInputMode('url'); setNeuroglancerUrl(''); setStateJson(''); - setBaseUrl(DEFAULT_BASE_URL); + setBaseUrl(defaultBaseUrl); setShortName(''); setTitle(''); onClose(); @@ -294,7 +310,7 @@ export default function NGLinkDialog({ autoFocus id="neuroglancer-url" onChange={handleUrlChange} - placeholder="https://neuroglancer-demo.appspot.com/#!{...}" + placeholder={`${defaultBaseUrl}#!{...}`} ref={urlInputRef} size="lg" type="text" @@ -328,7 +344,7 @@ export default function NGLinkDialog({ onChange={(e: ChangeEvent) => setBaseUrl(e.target.value) } - placeholder="https://neuroglancer-demo.appspot.com/" + placeholder={defaultBaseUrl} size="lg" type="text" value={baseUrl} diff --git a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx index d188b443..fba60ddd 100644 --- a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx @@ -74,7 +74,7 @@ export default function DataLinkOptions({ }; return ( - <> +
{hideSubpathMode ? null : } - +
); } diff --git a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx index 903ad75b..cd182d5f 100644 --- a/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/NeuroglancerOptions.tsx @@ -1,8 +1,20 @@ +import { Typography } from '@material-tailwind/react'; import toast from 'react-hot-toast'; import FgSwitch from '@/components/designSystem/atoms/formElements/FgSwitch'; +import FgRadio from '@/components/designSystem/atoms/formElements/FgRadio'; import FgFieldSet from '@/components/designSystem/molecules/FgFieldSet'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; + +/** Best-effort hostname for display; falls back to the raw template. */ +function hostLabel(template: string): string { + try { + return new URL(template).hostname; + } catch { + return template; + } +} export default function NeuroglancerOptions() { const { @@ -11,65 +23,114 @@ export default function NeuroglancerOptions() { disableNeuroglancerStateGeneration, toggleDisableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, - toggleDisableHeuristicalLayerTypeDetection + toggleDisableHeuristicalLayerTypeDetection, + viewerUrlSources, + setViewerUrlSource } = usePreferencesContext(); + const { validViewers } = useViewersContext(); + + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + + // Only offer the URL choice when the deployment overrides the Neuroglancer URL + // (i.e. the configured template differs from the manifest default). Otherwise + // both options resolve to the same URL and the control is meaningless. + const showUrlSourceChoice = + !!neuroglancer && + !!neuroglancer.manifestTemplateUrl && + neuroglancer.manifestTemplateUrl !== neuroglancer.urlTemplate; + + const currentSource = + viewerUrlSources['neuroglancer'] === 'manifest' ? 'manifest' : 'configured'; + + const handleUrlSourceChange = async (source: 'configured' | 'manifest') => { + const result = await setViewerUrlSource('neuroglancer', source); + if (result.success) { + toast.success('Neuroglancer URL preference updated'); + } else { + toast.error(result.error); + } + }; return ( - - { - const result = await toggleUseLegacyMultichannelApproach(); - if (result.success) { - toast.success( - useLegacyMultichannelApproach - ? 'Disabled multichannel state generation for Neuroglancer' - : 'Enabled multichannel state generation for Neuroglancer' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - { - const result = await toggleDisableNeuroglancerStateGeneration(); - if (result.success) { - toast.success( - disableNeuroglancerStateGeneration - ? 'Neuroglancer state generation is now enabled' - : 'Neuroglancer state generation is now disabled' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - { - const result = await toggleDisableHeuristicalLayerTypeDetection(); - if (result.success) { - toast.success( - disableHeuristicalLayerTypeDetection - ? 'Heuristical layer type determination is now enabled' - : 'Heuristical layer type determination is now disabled' - ); - } else { - toast.error(result.error); - } - }} - showState - /> - +
+ {showUrlSourceChoice ? ( + + handleUrlSourceChange('configured')} + value="configured" + /> + handleUrlSourceChange('manifest')} + value="manifest" + /> + + ) : null} + + { + const result = await toggleUseLegacyMultichannelApproach(); + if (result.success) { + toast.success( + useLegacyMultichannelApproach + ? 'Disabled multichannel state generation for Neuroglancer' + : 'Enabled multichannel state generation for Neuroglancer' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + { + const result = await toggleDisableNeuroglancerStateGeneration(); + if (result.success) { + toast.success( + disableNeuroglancerStateGeneration + ? 'Neuroglancer state generation is now enabled' + : 'Neuroglancer state generation is now disabled' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + { + const result = await toggleDisableHeuristicalLayerTypeDetection(); + if (result.success) { + toast.success( + disableHeuristicalLayerTypeDetection + ? 'Heuristical layer type determination is now enabled' + : 'Heuristical layer type determination is now disabled' + ); + } else { + toast.error(result.error); + } + }} + showState + /> + +
); } diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index d08c680b..b868a7f6 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -39,6 +39,18 @@ export type FolderPreference = { fspName: string; }; +/** + * Which URL to use for a given viewer. Defaults to 'configured' when a viewer + * is absent from the map. + * - 'configured': the deployment default (viewers.config instance_template_url + * override, else the manifest default) + * - 'manifest': the capability-manifest library's default (e.g. the external + * Neuroglancer at appspot) + * - { custom }: a user-supplied template URL (reserved for future UI) + */ +export type ViewerUrlSource = 'configured' | 'manifest' | { custom: string }; +export type ViewerUrlSources = Record; + type PreferencesContextType = { //Full query for accessing loading/error states preferenceQuery: ReturnType; @@ -52,6 +64,7 @@ type PreferencesContextType = { disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; + viewerUrlSources: ViewerUrlSources; isFilteredByGroups: boolean; showTutorial: boolean; defaultExtraArgs: string; @@ -84,6 +97,10 @@ type PreferencesContextType = { toggleDisableNeuroglancerStateGeneration: () => Promise>; toggleDisableHeuristicalLayerTypeDetection: () => Promise>; toggleUseLegacyMultichannelApproach: () => Promise>; + setViewerUrlSource: ( + viewerKey: string, + source: ViewerUrlSource + ) => Promise>; toggleFilterByGroups: () => Promise>; toggleShowTutorial: () => Promise>; updateDefaultExtraArgs: (args: string) => Promise>; @@ -257,6 +274,25 @@ export const PreferencesProvider = ({ ); }; + const setViewerUrlSource = async ( + viewerKey: string, + source: ViewerUrlSource + ): Promise> => { + try { + const updated: ViewerUrlSources = { + ...(preferencesQuery.data?.viewerUrlSources || {}), + [viewerKey]: source + }; + await updatePreferenceMutation.mutateAsync({ + key: 'viewerUrlSources', + value: updated + }); + return createSuccess(undefined); + } catch (error) { + return handleError(error); + } + }; + const toggleShowTutorial = async (): Promise> => { return togglePreference( 'showTutorial', @@ -550,6 +586,7 @@ export const PreferencesProvider = ({ preferencesQuery.data?.disableHeuristicalLayerTypeDetection || false, useLegacyMultichannelApproach: preferencesQuery.data?.useLegacyMultichannelApproach || false, + viewerUrlSources: preferencesQuery.data?.viewerUrlSources || {}, isFilteredByGroups: preferencesQuery.data?.isFilteredByGroups ?? true, showTutorial: preferencesQuery.data?.showTutorial ?? true, defaultExtraArgs: preferencesQuery.data?.defaultExtraArgs || '', @@ -579,6 +616,7 @@ export const PreferencesProvider = ({ toggleDisableNeuroglancerStateGeneration, toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, + setViewerUrlSource, toggleFilterByGroups, toggleShowTutorial, updateDefaultExtraArgs, diff --git a/frontend/src/contexts/ViewersContext.tsx b/frontend/src/contexts/ViewersContext.tsx index bff4bc00..4b018b67 100644 --- a/frontend/src/contexts/ViewersContext.tsx +++ b/frontend/src/contexts/ViewersContext.tsx @@ -26,6 +26,13 @@ export interface ValidViewer { displayName: string; /** URL template (may contain {dataLink} placeholder) */ urlTemplate: string; + /** + * The capability-manifest library's default template, before any instance + * override from viewers.config. Empty string if the manifest has no + * template_url. Used to let users opt back to the manifest default (e.g. the + * external Neuroglancer at appspot) when a deployment overrides the URL. + */ + manifestTemplateUrl: string; /** Logo path */ logoPath: string; /** Tooltip/alt text label */ @@ -124,10 +131,15 @@ export function ViewersProvider({ } // Replace {DATA_URL} with {dataLink} for consistency with existing code - const normalizedUrlTemplate = urlTemplate.replace( - /{DATA_URL}/g, - '{dataLink}' - ); + const normalizeTemplate = (template: string) => + template.replace(/{DATA_URL}/g, '{dataLink}'); + const normalizedUrlTemplate = normalizeTemplate(urlTemplate); + + // The manifest's own default template (before any instance override), + // normalized the same way. Empty string if the manifest has none. + const manifestTemplateUrl = manifest.viewer.template_url + ? normalizeTemplate(manifest.viewer.template_url) + : ''; // Create valid viewer entry const key = normalizeViewerName(manifest.viewer.name); @@ -139,6 +151,7 @@ export function ViewersProvider({ key, displayName, urlTemplate: normalizedUrlTemplate, + manifestTemplateUrl, logoPath, label, manifest diff --git a/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts new file mode 100644 index 00000000..efeb1055 --- /dev/null +++ b/frontend/src/hooks/useDefaultNeuroglancerBaseUrl.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; + +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; + +// Used only when no Neuroglancer viewer is configured and no custom URL is set. +export const DEFAULT_NEUROGLANCER_BASE_URL = + 'https://neuroglancer-demo.appspot.com/'; + +/** + * The Neuroglancer base URL (the part before the '#!' state fragment) that new + * short links should default to, honoring the deployment's configured URL and + * the user's per-viewer URL preference. + */ +export function useDefaultNeuroglancerBaseUrl(): string { + const { validViewers } = useViewersContext(); + const { viewerUrlSources } = usePreferencesContext(); + + return useMemo(() => { + const source = viewerUrlSources['neuroglancer']; + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + if (neuroglancer) { + const template = resolveViewerTemplate(neuroglancer, source); + // Strip the '#!' state fragment to get the bare base URL. + return template.split('#!')[0] || DEFAULT_NEUROGLANCER_BASE_URL; + } + // No Neuroglancer viewer is configured, so 'configured'/'manifest' have no + // template to resolve. A user-supplied custom URL is the only preference + // value that carries a URL on its own; otherwise fall back to the external + // default. + if (typeof source === 'object' && source.custom) { + return source.custom.split('#!')[0] || DEFAULT_NEUROGLANCER_BASE_URL; + } + return DEFAULT_NEUROGLANCER_BASE_URL; + }, [validViewers, viewerUrlSources]); +} diff --git a/frontend/src/hooks/useN5Metadata.ts b/frontend/src/hooks/useN5Metadata.ts index aa665368..a1f61875 100644 --- a/frontend/src/hooks/useN5Metadata.ts +++ b/frontend/src/hooks/useN5Metadata.ts @@ -2,11 +2,18 @@ import { useMemo } from 'react'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; import { useProxiedPathContext } from '@/contexts/ProxiedPathContext'; import { useExternalBucketContext } from '@/contexts/ExternalBucketContext'; +import { usePreferencesContext } from '@/contexts/PreferencesContext'; +import { useViewersContext } from '@/contexts/ViewersContext'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; import { useN5MetadataQuery } from '@/queries/n5Queries'; import type { N5Metadata, N5OpenWithToolUrls } from '@/queries/n5Queries'; export type { N5Metadata, N5OpenWithToolUrls }; +// Fallback used only when no Neuroglancer viewer is configured. +const FALLBACK_NEUROGLANCER_BASE_URL = + 'https://neuroglancer-demo.appspot.com/#!'; + /** * Get the Neuroglancer source URL for N5 format */ @@ -51,6 +58,8 @@ export default function useN5Metadata() { const { fileQuery } = useFileBrowserContext(); const { currentDirProxiedPathQuery } = useProxiedPathContext(); const { externalDataUrlQuery } = useExternalBucketContext(); + const { viewerUrlSources } = usePreferencesContext(); + const { validViewers } = useViewersContext(); // Fetch N5 metadata const n5MetadataQuery = useN5MetadataQuery({ @@ -66,7 +75,15 @@ export default function useN5Metadata() { return null; } - const neuroglancerBaseUrl = 'https://neuroglancer-demo.appspot.com/#!'; + // Resolve the Neuroglancer base URL from the deployment config and the + // user's per-viewer URL preference, matching the OME-Zarr/Zarr path. + const neuroglancer = validViewers.find(v => v.key === 'neuroglancer'); + const neuroglancerBaseUrl = neuroglancer + ? resolveViewerTemplate( + neuroglancer, + viewerUrlSources['neuroglancer'] + ).split('#!')[0] + '#!' + : FALLBACK_NEUROGLANCER_BASE_URL; const url = externalDataUrlQuery.data || currentDirProxiedPathQuery.data?.url; @@ -86,7 +103,9 @@ export default function useN5Metadata() { }, [ metadata, currentDirProxiedPathQuery.data?.url, - externalDataUrlQuery.data + externalDataUrlQuery.data, + validViewers, + viewerUrlSources ]); return { diff --git a/frontend/src/hooks/useZarrMetadata.ts b/frontend/src/hooks/useZarrMetadata.ts index 86190f92..9e1d3b7c 100644 --- a/frontend/src/hooks/useZarrMetadata.ts +++ b/frontend/src/hooks/useZarrMetadata.ts @@ -18,6 +18,7 @@ import { determineLayerType } from '@/omezarr-helper'; import { buildUrl } from '@/utils'; +import { resolveViewerTemplate } from '@/utils/viewerUrl'; import * as zarr from 'zarrita'; export type { OpenWithToolUrls, ZarrMetadata }; @@ -31,7 +32,8 @@ export default function useZarrMetadata() { const { disableNeuroglancerStateGeneration, disableHeuristicalLayerTypeDetection, - useLegacyMultichannelApproach + useLegacyMultichannelApproach, + viewerUrlSources } = usePreferencesContext(); const { validViewers, @@ -135,13 +137,19 @@ export default function useZarrMetadata() { continue; } + // Resolve the template based on the user's per-viewer URL preference + const effectiveTemplate = resolveViewerTemplate( + viewer, + viewerUrlSources[viewer.key] + ); + // Generate the viewer URL - let viewerUrl = viewer.urlTemplate; + let viewerUrl = effectiveTemplate; // Special handling for Neuroglancer to maintain existing state generation logic if (viewer.key === 'neuroglancer') { // Extract base URL from template (everything before #!) - const neuroglancerBaseUrl = viewer.urlTemplate.split('#!')[0] + '#!'; + const neuroglancerBaseUrl = effectiveTemplate.split('#!')[0] + '#!'; if (disableNeuroglancerStateGeneration) { viewerUrl = neuroglancerBaseUrl + @@ -194,9 +202,13 @@ export default function useZarrMetadata() { } else { // Neuroglancer if (url) { + // Resolve the template based on the user's per-viewer URL preference + const effectiveTemplate = resolveViewerTemplate( + viewer, + viewerUrlSources[viewer.key] + ); // Extract base URL from template (everything before #!) - const neuroglancerBaseUrl = - viewer.urlTemplate.split('#!')[0] + '#!'; + const neuroglancerBaseUrl = effectiveTemplate.split('#!')[0] + '#!'; if (disableNeuroglancerStateGeneration) { openWithToolUrls.neuroglancer = neuroglancerBaseUrl + @@ -229,6 +241,7 @@ export default function useZarrMetadata() { externalDataUrlQuery.data, disableNeuroglancerStateGeneration, useLegacyMultichannelApproach, + viewerUrlSources, layerType, effectiveZarrVersion, validViewers, diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index 4e43dbec..e1372c75 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -17,7 +17,8 @@ import type { ZonePreference, FileSharePathPreference, FolderPreference, - FolderFavorite + FolderFavorite, + ViewerUrlSources } from '@/contexts/PreferencesContext'; import { getResponseJsonOrError, @@ -37,6 +38,7 @@ type PreferencesApiResponse = { disableNeuroglancerStateGeneration?: { value: boolean }; disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; + viewerUrlSources?: { value: ViewerUrlSources }; isFilteredByGroups?: { value: boolean }; showTutorial?: { value: boolean }; defaultExtraArgs?: { value: string }; @@ -70,6 +72,7 @@ export type PreferencesQueryData = { disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; + viewerUrlSources: ViewerUrlSources; isFilteredByGroups: boolean; showTutorial: boolean; defaultExtraArgs: string; @@ -242,6 +245,7 @@ const createTransformPreferences = ( rawData.disableHeuristicalLayerTypeDetection?.value || false, useLegacyMultichannelApproach: rawData.useLegacyMultichannelApproach?.value || false, + viewerUrlSources: rawData.viewerUrlSources?.value || {}, isFilteredByGroups: rawData.isFilteredByGroups?.value ?? true, showTutorial: rawData.showTutorial?.value ?? true, defaultExtraArgs: rawData.defaultExtraArgs?.value || '', diff --git a/frontend/src/utils/viewerUrl.ts b/frontend/src/utils/viewerUrl.ts new file mode 100644 index 00000000..3dc4d05f --- /dev/null +++ b/frontend/src/utils/viewerUrl.ts @@ -0,0 +1,24 @@ +import type { ValidViewer } from '@/contexts/ViewersContext'; +import type { ViewerUrlSource } from '@/contexts/PreferencesContext'; + +/** + * Resolve the URL template to use for a viewer given the user's chosen source. + * + * Falls back to the deployment-configured template for an absent/unknown source + * or when the requested template is empty, so callers always get a usable value. + */ +export function resolveViewerTemplate( + viewer: ValidViewer, + source: ViewerUrlSource | undefined +): string { + if (!source || source === 'configured') { + return viewer.urlTemplate; + } + if (source === 'manifest') { + return viewer.manifestTemplateUrl || viewer.urlTemplate; + } + if (typeof source === 'object' && source.custom) { + return source.custom; + } + return viewer.urlTemplate; +}