From 1aecb0a4d902cb0e824625e103f7533e7307e1db Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 9 Jul 2026 16:29:04 +0200 Subject: [PATCH] feat: sync active design to URL for refresh/share Switching designs now reflects the active design id in the URL (?design=). A page refresh or shared link reopens that design; an absent or unknown id falls back to the default design. ha-relevant: no --- frontend/src/App.tsx | 16 ++++++- .../src/utils/__tests__/designUrl.test.ts | 44 +++++++++++++++++++ frontend/src/utils/designUrl.ts | 22 ++++++++++ 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 frontend/src/utils/__tests__/designUrl.test.ts create mode 100644 frontend/src/utils/designUrl.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 14a28e9..53086b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { getCenteredPosition } from '@/utils/viewportCenter' import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent' import { generateMarkdownTable } from '@/utils/exportMarkdown' import { copyToClipboard } from '@/utils/clipboard' +import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl' import { ExportModal } from '@/components/modals/ExportModal' import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml' import { parseYamlToCanvas } from '@/utils/importYaml' @@ -171,10 +172,15 @@ export default function App() { }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) const loadDesignsAndCanvas = useCallback(async () => { + // Prefer a design id explicitly requested via the URL (?design=), so a + // refresh or shared link opens that design. Ignore it when it doesn't match + // a known design and fall back to the current/default one. + const urlDesignId = getDesignIdFromUrl() if (STANDALONE) { const designs = standaloneStorage.ensureSeed() setDesigns(designs) - const targetId = activeDesignId ?? designs[0]?.id + const fromUrl = urlDesignId && designs.some((d) => d.id === urlDesignId) ? urlDesignId : null + const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id if (targetId) { setActiveDesign(targetId) loadStandaloneCanvas(targetId) @@ -185,7 +191,8 @@ export default function App() { const res = await designsApi.list() const loadedDesigns = res.data setDesigns(loadedDesigns) - const targetId = activeDesignId ?? loadedDesigns[0]?.id + const fromUrl = urlDesignId && loadedDesigns.some((d) => d.id === urlDesignId) ? urlDesignId : null + const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id if (targetId) { setActiveDesign(targetId) await loadCanvasFromApi(targetId) @@ -260,6 +267,11 @@ export default function App() { } }, [activeDesignId]) + // Reflect the active design into the URL so refresh/share reopens it. + useEffect(() => { + if (activeDesignId) setDesignIdInUrl(activeDesignId) + }, [activeDesignId]) + // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) const redoRef = useRef(redo) diff --git a/frontend/src/utils/__tests__/designUrl.test.ts b/frontend/src/utils/__tests__/designUrl.test.ts new file mode 100644 index 0000000..d84a4f4 --- /dev/null +++ b/frontend/src/utils/__tests__/designUrl.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl' + +describe('designUrl', () => { + beforeEach(() => { + window.history.replaceState(null, '', '/') + }) + + it('returns null when no design param is present', () => { + expect(getDesignIdFromUrl()).toBeNull() + }) + + it('reads the design id from the URL', () => { + window.history.replaceState(null, '', '/?design=abc-123') + expect(getDesignIdFromUrl()).toBe('abc-123') + }) + + it('writes the design id into the URL without adding history', () => { + const before = window.history.length + setDesignIdInUrl('design-42') + expect(getDesignIdFromUrl()).toBe('design-42') + expect(window.history.length).toBe(before) + }) + + it('drops the param when passed null', () => { + setDesignIdInUrl('design-42') + setDesignIdInUrl(null) + expect(getDesignIdFromUrl()).toBeNull() + expect(window.location.search).toBe('') + }) + + it('preserves other query params when updating design', () => { + window.history.replaceState(null, '', '/?key=secret') + setDesignIdInUrl('design-42') + const params = new URLSearchParams(window.location.search) + expect(params.get('key')).toBe('secret') + expect(params.get('design')).toBe('design-42') + }) + + it('round-trips: written id is read back', () => { + setDesignIdInUrl('xyz') + expect(getDesignIdFromUrl()).toBe('xyz') + }) +}) diff --git a/frontend/src/utils/designUrl.ts b/frontend/src/utils/designUrl.ts new file mode 100644 index 0000000..fdad2cb --- /dev/null +++ b/frontend/src/utils/designUrl.ts @@ -0,0 +1,22 @@ +// Sync the active design id with the browser URL (`?design=`), so the +// current design survives a page refresh and can be shared/opened via URL. +// When no `design` param is present, callers fall back to the default design. +const DESIGN_PARAM = 'design' + +/** Read the design id from the current URL, or null when absent. */ +export function getDesignIdFromUrl(): string | null { + if (typeof window === 'undefined') return null + return new URLSearchParams(window.location.search).get(DESIGN_PARAM) +} + +/** + * Reflect the active design id into the URL without adding a history entry + * (replaceState). Pass null to drop the param (back to the default design). + */ +export function setDesignIdInUrl(id: string | null): void { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + if (id) url.searchParams.set(DESIGN_PARAM, id) + else url.searchParams.delete(DESIGN_PARAM) + window.history.replaceState(window.history.state, '', url) +}