diff --git a/ui/detection-app/src/App.vue b/ui/detection-app/src/App.vue index 2e15a97..4686056 100644 --- a/ui/detection-app/src/App.vue +++ b/ui/detection-app/src/App.vue @@ -16,8 +16,10 @@ import { usePipelineStore } from './stores/pipeline' import { useSSEConnection } from './composables/useSSEConnection' import { useCheckpointLoader } from './composables/useCheckpointLoader' import { useEditorState } from './composables/useEditorState' +import { useHashRouter } from './composables/useHashRouter' const pipeline = usePipelineStore() +useHashRouter() const logPanel = ref<{ clear: () => void } | null>(null) // SSE connection + pipeline status diff --git a/ui/detection-app/src/composables/useHashRouter.ts b/ui/detection-app/src/composables/useHashRouter.ts new file mode 100644 index 0000000..12b64e4 --- /dev/null +++ b/ui/detection-app/src/composables/useHashRouter.ts @@ -0,0 +1,70 @@ +import { watch, onUnmounted } from 'vue' +import { usePipelineStore } from '../stores/pipeline' + +/** + * Bidirectional sync between URL hash and pipeline store layout state. + * + * Hash format: + * #/ → normal dashboard + * #/editor/ → bbox/region editor for that stage + * #/config/ → stage config editor + * #/source → source selector + * + * Call once in App.vue. Handles popstate (back/forward) and + * updates the hash when store state changes. + */ +export function useHashRouter() { + const pipeline = usePipelineStore() + + function parseHash(hash: string) { + const path = hash.replace(/^#\/?/, '') + if (!path || path === 'dashboard') { + return { mode: 'normal', stage: null as string | null } + } + if (path === 'source') { + return { mode: 'source_selector', stage: null as string | null } + } + const editorMatch = path.match(/^editor\/(.+)$/) + if (editorMatch) { + return { mode: 'bbox_editor', stage: editorMatch[1] } + } + const configMatch = path.match(/^config\/(.+)$/) + if (configMatch) { + return { mode: 'stage_editor', stage: configMatch[1] } + } + return { mode: 'normal', stage: null as string | null } + } + + function applyHash() { + const { mode, stage } = parseHash(window.location.hash) + pipeline.layoutMode = mode + pipeline.editorStage = stage + } + + function updateHash() { + let hash = '#/' + if (pipeline.layoutMode === 'bbox_editor' && pipeline.editorStage) { + hash = `#/editor/${pipeline.editorStage}` + } else if (pipeline.layoutMode === 'stage_editor' && pipeline.editorStage) { + hash = `#/config/${pipeline.editorStage}` + } else if (pipeline.layoutMode === 'source_selector') { + hash = '#/source' + } + if (window.location.hash !== hash) { + window.history.pushState(null, '', hash) + } + } + + // Sync hash → state on load + applyHash() + + // Sync hash → state on back/forward + window.addEventListener('popstate', applyHash) + onUnmounted(() => window.removeEventListener('popstate', applyHash)) + + // Sync state → hash when layout changes + watch( + () => [pipeline.layoutMode, pipeline.editorStage], + updateHash, + ) +} diff --git a/ui/detection-app/src/stores/pipeline.ts b/ui/detection-app/src/stores/pipeline.ts index fc17939..f1e1246 100644 --- a/ui/detection-app/src/stores/pipeline.ts +++ b/ui/detection-app/src/stores/pipeline.ts @@ -1,17 +1,12 @@ /** * Pipeline store — run state, transport controls, checkpoint status. * - * Layout is driven by URL hash: - * #/ → normal dashboard - * #/editor/ → bbox/region editor for that stage - * #/config/ → stage config editor - * #/source → source selector - * * State shape defined in types/store-state.ts. + * Hash routing is handled by useHashRouter composable, not here. */ import { defineStore } from 'pinia' -import { ref, computed, watch } from 'vue' +import { ref, computed } from 'vue' import type { NodeState } from '../types/store-state' import type { CheckpointInfo } from '../types/sse-contract' @@ -26,7 +21,6 @@ export const usePipelineStore = defineStore('pipeline', () => { const checkpoints = ref([]) const error = ref(null) - // Layout mode — synced with URL hash const layoutMode = ref('normal') const editorStage = ref(null) @@ -35,56 +29,6 @@ export const usePipelineStore = defineStore('pipeline', () => { const canReplay = computed(() => checkpoints.value.length > 0) const isEditing = computed(() => layoutMode.value !== 'normal') - // --- Hash routing --- - - function parseHash(hash: string) { - const path = hash.replace(/^#\/?/, '') - if (!path || path === 'dashboard') { - return { mode: 'normal', stage: null } - } - if (path === 'source') { - return { mode: 'source_selector', stage: null } - } - const editorMatch = path.match(/^editor\/(.+)$/) - if (editorMatch) { - return { mode: 'bbox_editor', stage: editorMatch[1] } - } - const configMatch = path.match(/^config\/(.+)$/) - if (configMatch) { - return { mode: 'stage_editor', stage: configMatch[1] } - } - return { mode: 'normal', stage: null } - } - - function applyHash() { - const { mode, stage } = parseHash(window.location.hash) - layoutMode.value = mode - editorStage.value = stage - } - - function updateHash() { - let hash = '#/' - if (layoutMode.value === 'bbox_editor' && editorStage.value) { - hash = `#/editor/${editorStage.value}` - } else if (layoutMode.value === 'stage_editor' && editorStage.value) { - hash = `#/config/${editorStage.value}` - } else if (layoutMode.value === 'source_selector') { - hash = '#/source' - } - if (window.location.hash !== hash) { - window.history.pushState(null, '', hash) - } - } - - // Sync hash → state on load and popstate (back/forward) - applyHash() - window.addEventListener('popstate', applyHash) - - // Sync state → hash when layout changes - watch([layoutMode, editorStage], updateHash) - - // --- Actions --- - function setJob(id: string) { jobId.value = id }