Merge pull request #232 from scub-france/feature/207-document-centric-routing
feat(#207): document-centric routing skeleton
This commit is contained in:
commit
b74e3b169c
16 changed files with 578 additions and 54 deletions
|
|
@ -1,59 +1,8 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('../../pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/studio',
|
||||
name: 'studio',
|
||||
component: () => import('../../pages/StudioPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: () => import('../../pages/HistoryPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
name: 'documents',
|
||||
component: () => import('../../pages/DocumentsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
component: () => import('../../pages/SearchPage.vue'),
|
||||
},
|
||||
{
|
||||
// Reasoning-trace tunnel. Route is always registered; the page shows
|
||||
// an empty state when the `reasoning` feature flag is off (same pattern
|
||||
// as /search does for ingestion).
|
||||
path: '/reasoning',
|
||||
name: 'reasoning',
|
||||
component: () => import('../../pages/ReasoningPage.vue'),
|
||||
},
|
||||
{
|
||||
// Deep-link into a specific document's reasoning workspace, e.g. shared
|
||||
// by Peter to a teammate.
|
||||
path: '/reasoning/:docId',
|
||||
name: 'reasoning-doc',
|
||||
component: () => import('../../pages/ReasoningPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../pages/SettingsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
redirect: '/',
|
||||
},
|
||||
]
|
||||
import { routes } from './routes'
|
||||
|
||||
export { routes }
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
|
|
|||
81
frontend/src/app/router/router.test.ts
Normal file
81
frontend/src/app/router/router.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { routes } from './routes'
|
||||
import { ROUTES } from '../../shared/routing/names'
|
||||
|
||||
/**
|
||||
* Router test — uses `createMemoryHistory` so we don't need `window`
|
||||
* (vitest defaults to the node environment for performance). The
|
||||
* production router builds the same `routes` table on top of
|
||||
* `createWebHistory` in `index.ts`.
|
||||
*/
|
||||
const buildRouter = () => createRouter({ history: createMemoryHistory(), routes })
|
||||
|
||||
describe('router', () => {
|
||||
it('resolves every 0.6.0 doc-centric route to a component', () => {
|
||||
const router = buildRouter()
|
||||
const cases: Array<{ path: string; name: string }> = [
|
||||
{ path: '/docs', name: ROUTES.DOCS_LIBRARY },
|
||||
{ path: '/docs/new', name: ROUTES.DOCS_NEW },
|
||||
{ path: '/docs/abc', name: ROUTES.DOC_WORKSPACE },
|
||||
{ path: '/index', name: ROUTES.STORES_LIST },
|
||||
{ path: '/index/foo', name: ROUTES.STORE_DETAIL },
|
||||
{ path: '/index/foo/query', name: ROUTES.STORE_QUERY },
|
||||
{ path: '/runs', name: ROUTES.RUNS },
|
||||
{ path: '/runs/run-42', name: ROUTES.RUN_DETAIL },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const resolved = router.resolve(c.path)
|
||||
expect(resolved.name, `route ${c.path}`).toBe(c.name)
|
||||
expect(resolved.matched.length, `route ${c.path} has a component`).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps legacy routes functional', () => {
|
||||
const router = buildRouter()
|
||||
expect(router.resolve('/').name).toBe(ROUTES.HOME)
|
||||
expect(router.resolve('/studio').name).toBe(ROUTES.STUDIO)
|
||||
expect(router.resolve('/documents').name).toBe(ROUTES.DOCUMENTS)
|
||||
expect(router.resolve('/history').name).toBe(ROUTES.HISTORY)
|
||||
expect(router.resolve('/search').name).toBe(ROUTES.SEARCH)
|
||||
expect(router.resolve('/reasoning').name).toBe(ROUTES.REASONING)
|
||||
expect(router.resolve('/reasoning/abc').name).toBe(ROUTES.REASONING_DOC)
|
||||
expect(router.resolve('/settings').name).toBe(ROUTES.SETTINGS)
|
||||
})
|
||||
|
||||
it('passes id and parsed mode to the doc workspace as props', () => {
|
||||
const router = buildRouter()
|
||||
const route = router.resolve({
|
||||
name: ROUTES.DOC_WORKSPACE,
|
||||
params: { id: 'abc' },
|
||||
query: { mode: 'chunks' },
|
||||
})
|
||||
const propsFn = route.matched[0]?.props as
|
||||
| { default?: (r: typeof route) => unknown }
|
||||
| undefined
|
||||
const computed = (propsFn?.default ?? (() => null))(route) as { id: string; mode: string }
|
||||
expect(computed.id).toBe('abc')
|
||||
expect(computed.mode).toBe('chunks')
|
||||
})
|
||||
|
||||
it('falls back to ask when mode is unknown', () => {
|
||||
const router = buildRouter()
|
||||
const route = router.resolve({
|
||||
name: ROUTES.DOC_WORKSPACE,
|
||||
params: { id: 'abc' },
|
||||
query: { mode: 'garbage' },
|
||||
})
|
||||
const propsFn = route.matched[0]?.props as
|
||||
| { default?: (r: typeof route) => unknown }
|
||||
| undefined
|
||||
const computed = (propsFn?.default ?? (() => null))(route) as { mode: string }
|
||||
expect(computed.mode).toBe('ask')
|
||||
})
|
||||
|
||||
it('redirects unknown paths to /', () => {
|
||||
const router = buildRouter()
|
||||
const resolved = router.resolve('/nope/this/does/not/exist')
|
||||
expect(resolved.matched[0]?.redirect).toBeDefined()
|
||||
})
|
||||
})
|
||||
125
frontend/src/app/router/routes.ts
Normal file
125
frontend/src/app/router/routes.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { parseMode } from '../../shared/routing/modes'
|
||||
import { ROUTES } from '../../shared/routing/names'
|
||||
|
||||
/**
|
||||
* Route table used by the production router and by tests.
|
||||
*
|
||||
* Lives in its own file so importing the router definition doesn't
|
||||
* trigger `createWebHistory()` (which needs `window` and breaks the
|
||||
* default node-environment vitest tests). Tests build a router with
|
||||
* `createMemoryHistory()` over this same table.
|
||||
*/
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy routes — kept functional during the 0.6.0 transition.
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
path: '/',
|
||||
name: ROUTES.HOME,
|
||||
component: () => import('../../pages/HomePage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/studio',
|
||||
name: ROUTES.STUDIO,
|
||||
component: () => import('../../pages/StudioPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: ROUTES.HISTORY,
|
||||
component: () => import('../../pages/HistoryPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/documents',
|
||||
name: ROUTES.DOCUMENTS,
|
||||
component: () => import('../../pages/DocumentsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: ROUTES.SEARCH,
|
||||
component: () => import('../../pages/SearchPage.vue'),
|
||||
},
|
||||
{
|
||||
// Reasoning-trace tunnel. Route is always registered; the page shows
|
||||
// an empty state when the `reasoning` feature flag is off (same pattern
|
||||
// as /search does for ingestion).
|
||||
path: '/reasoning',
|
||||
name: ROUTES.REASONING,
|
||||
component: () => import('../../pages/ReasoningPage.vue'),
|
||||
},
|
||||
{
|
||||
// Deep-link into a specific document's reasoning workspace, e.g. shared
|
||||
// by Peter to a teammate.
|
||||
path: '/reasoning/:docId',
|
||||
name: ROUTES.REASONING_DOC,
|
||||
component: () => import('../../pages/ReasoningPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: ROUTES.SETTINGS,
|
||||
component: () => import('../../pages/SettingsPage.vue'),
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 0.6.0 — Document-centric routes (#207). Placeholder pages until E3/E4/E5
|
||||
// implement them; the legacy routes above keep working in parallel.
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
path: '/docs',
|
||||
name: ROUTES.DOCS_LIBRARY,
|
||||
component: () => import('../../pages/DocsLibraryPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/docs/new',
|
||||
name: ROUTES.DOCS_NEW,
|
||||
component: () => import('../../pages/DocsNewPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/docs/:id',
|
||||
name: ROUTES.DOC_WORKSPACE,
|
||||
component: () => import('../../pages/DocWorkspacePage.vue'),
|
||||
props: (route: RouteLocationNormalized) => ({
|
||||
id: String(route.params.id),
|
||||
mode: parseMode(route.query.mode),
|
||||
}),
|
||||
},
|
||||
{
|
||||
path: '/index',
|
||||
name: ROUTES.STORES_LIST,
|
||||
component: () => import('../../pages/StoresListPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/index/:store',
|
||||
name: ROUTES.STORE_DETAIL,
|
||||
component: () => import('../../pages/StoreDetailPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/index/:store/query',
|
||||
name: ROUTES.STORE_QUERY,
|
||||
component: () => import('../../pages/StoreQueryPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/runs',
|
||||
name: ROUTES.RUNS,
|
||||
component: () => import('../../pages/RunsPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/runs/:id',
|
||||
name: ROUTES.RUN_DETAIL,
|
||||
component: () => import('../../pages/RunDetailPage.vue'),
|
||||
props: true,
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 404 — must come last.
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: ROUTES.NOT_FOUND,
|
||||
redirect: '/',
|
||||
},
|
||||
]
|
||||
27
frontend/src/pages/DocWorkspacePage.vue
Normal file
27
frontend/src/pages/DocWorkspacePage.vue
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.docWorkspace')"
|
||||
:hint="hint"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import { type DocMode } from '../shared/routing/modes'
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
/**
|
||||
* Doc workspace placeholder. Receives the doc id (from `:id` path param)
|
||||
* and the resolved mode (from `?mode=` query param, parsed by the
|
||||
* router via `parseMode`). The full workspace is built by #216 (E4)
|
||||
* on top of #218-#224 (E5 chunks editor).
|
||||
*/
|
||||
const props = defineProps<{ id: string; mode: DocMode }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
|
||||
</script>
|
||||
14
frontend/src/pages/DocsLibraryPage.vue
Normal file
14
frontend/src/pages/DocsLibraryPage.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.docsLibrary')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
11
frontend/src/pages/DocsNewPage.vue
Normal file
11
frontend/src/pages/DocsNewPage.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<template>
|
||||
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.docsNew')" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
20
frontend/src/pages/RunDetailPage.vue
Normal file
20
frontend/src/pages/RunDetailPage.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.runDetail')"
|
||||
:hint="hint"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const props = defineProps<{ id: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.runDetail', { id: props.id }))
|
||||
</script>
|
||||
11
frontend/src/pages/RunsPage.vue
Normal file
11
frontend/src/pages/RunsPage.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<template>
|
||||
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.runs')" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
20
frontend/src/pages/StoreDetailPage.vue
Normal file
20
frontend/src/pages/StoreDetailPage.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.storeDetail')"
|
||||
:hint="hint"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const props = defineProps<{ store: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.storeDetail', { store: props.store }))
|
||||
</script>
|
||||
20
frontend/src/pages/StoreQueryPage.vue
Normal file
20
frontend/src/pages/StoreQueryPage.vue
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.storeQuery')"
|
||||
:hint="hint"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const props = defineProps<{ store: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.storeQuery', { store: props.store }))
|
||||
</script>
|
||||
11
frontend/src/pages/StoresListPage.vue
Normal file
11
frontend/src/pages/StoresListPage.vue
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<template>
|
||||
<ComingSoonShell :title="t('comingSoon.title')" :subtitle="t('comingSoon.subtitle.stores')" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
|
@ -19,6 +19,26 @@ const messages: Messages = {
|
|||
// Top bar
|
||||
'topbar.newAnalysis': 'Nouvelle analyse',
|
||||
|
||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||
'comingSoon.title': 'Bientôt disponible',
|
||||
'comingSoon.subtitle.docsLibrary':
|
||||
"La bibliothèque de documents arrive avec la 0.6.0. Vous y verrez l'état du cycle de vie de chaque document, ses stores et ses dernières mises à jour.",
|
||||
'comingSoon.subtitle.docsNew':
|
||||
"L'import multi-fichiers (drop d'un dossier ou sélection multiple) arrive avec la 0.6.0.",
|
||||
'comingSoon.subtitle.docWorkspace':
|
||||
"L'espace de travail document (Inspect / Chunks / Ask) arrive avec la 0.6.0.",
|
||||
'comingSoon.subtitle.stores': 'La liste des stores arrive avec la 0.6.0.',
|
||||
'comingSoon.subtitle.storeDetail':
|
||||
'La vue détaillée du store (documents présents, état par store) arrive avec la 0.6.0.',
|
||||
'comingSoon.subtitle.storeQuery': 'Le playground de requête RAG arrive avec la 0.6.0.',
|
||||
'comingSoon.subtitle.runs': "L'historique des runs (audit / debug) arrive avec la 0.6.0.",
|
||||
'comingSoon.subtitle.runDetail': "Le détail d'un run arrive avec la 0.6.0.",
|
||||
'comingSoon.hint.docWorkspace': 'doc {id} · mode {mode}',
|
||||
'comingSoon.hint.storeDetail': 'store {store}',
|
||||
'comingSoon.hint.storeQuery': 'store {store}',
|
||||
'comingSoon.hint.runDetail': 'run {id}',
|
||||
'comingSoon.backHome': "Retour à l'accueil",
|
||||
|
||||
// Home
|
||||
'home.title': 'Docling Studio',
|
||||
'home.subtitle':
|
||||
|
|
@ -287,6 +307,26 @@ const messages: Messages = {
|
|||
|
||||
'topbar.newAnalysis': 'New analysis',
|
||||
|
||||
// Coming-soon placeholders (0.6.0 doc-centric routes — #207)
|
||||
'comingSoon.title': 'Coming soon',
|
||||
'comingSoon.subtitle.docsLibrary':
|
||||
'The document library lands with 0.6.0. It will show every document with its lifecycle state, the stores it lives in, and when it was last updated.',
|
||||
'comingSoon.subtitle.docsNew':
|
||||
'Multi-file import (drop a folder or pick multiple files) lands with 0.6.0.',
|
||||
'comingSoon.subtitle.docWorkspace':
|
||||
'The doc workspace (Inspect / Chunks / Ask) lands with 0.6.0.',
|
||||
'comingSoon.subtitle.stores': 'The stores list lands with 0.6.0.',
|
||||
'comingSoon.subtitle.storeDetail':
|
||||
'The store detail view (docs present, per-store state) lands with 0.6.0.',
|
||||
'comingSoon.subtitle.storeQuery': 'The RAG query playground lands with 0.6.0.',
|
||||
'comingSoon.subtitle.runs': 'The runs history (audit / debug) lands with 0.6.0.',
|
||||
'comingSoon.subtitle.runDetail': 'Run detail lands with 0.6.0.',
|
||||
'comingSoon.hint.docWorkspace': 'doc {id} · mode {mode}',
|
||||
'comingSoon.hint.storeDetail': 'store {store}',
|
||||
'comingSoon.hint.storeQuery': 'store {store}',
|
||||
'comingSoon.hint.runDetail': 'run {id}',
|
||||
'comingSoon.backHome': 'Back to home',
|
||||
|
||||
'home.title': 'Docling Studio',
|
||||
'home.subtitle':
|
||||
'Analyze, explore and validate the structure of your PDF documents with Docling.',
|
||||
|
|
|
|||
35
frontend/src/shared/routing/modes.test.ts
Normal file
35
frontend/src/shared/routing/modes.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ALL_MODES, DEFAULT_MODE, isDocMode, parseMode } from './modes'
|
||||
|
||||
describe('isDocMode', () => {
|
||||
it.each(['ask', 'inspect', 'chunks'])('accepts %s', (value) => {
|
||||
expect(isDocMode(value)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'foo', 42, {}, []])('rejects %s', (value) => {
|
||||
expect(isDocMode(value)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMode', () => {
|
||||
it('returns the default for missing or unknown values', () => {
|
||||
expect(parseMode(undefined)).toBe(DEFAULT_MODE)
|
||||
expect(parseMode(null)).toBe(DEFAULT_MODE)
|
||||
expect(parseMode('garbage')).toBe(DEFAULT_MODE)
|
||||
expect(parseMode(['chunks'])).toBe(DEFAULT_MODE) // arrays not accepted
|
||||
})
|
||||
|
||||
it.each(['ask', 'inspect', 'chunks'] as const)('respects %s', (mode) => {
|
||||
expect(parseMode(mode)).toBe(mode)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ALL_MODES', () => {
|
||||
it('lists every mode exactly once', () => {
|
||||
expect(new Set(ALL_MODES).size).toBe(ALL_MODES.length)
|
||||
expect(ALL_MODES).toContain('ask')
|
||||
expect(ALL_MODES).toContain('inspect')
|
||||
expect(ALL_MODES).toContain('chunks')
|
||||
})
|
||||
})
|
||||
24
frontend/src/shared/routing/modes.ts
Normal file
24
frontend/src/shared/routing/modes.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Doc workspace mode parsing.
|
||||
*
|
||||
* The doc workspace at `/docs/:id` exposes three modes via the `?mode=`
|
||||
* query param. Anything missing or unknown resolves to the default,
|
||||
* `ask`, so a malformed URL never produces a broken page.
|
||||
*
|
||||
* #210 layers feature-flag-aware redirection on top: if the requested
|
||||
* mode is disabled for the current tenant, the router replaces it with
|
||||
* the first enabled mode (priority `ask` > `chunks` > `inspect`).
|
||||
*/
|
||||
|
||||
export type DocMode = 'ask' | 'inspect' | 'chunks'
|
||||
|
||||
export const DEFAULT_MODE: DocMode = 'ask'
|
||||
export const ALL_MODES: readonly DocMode[] = ['ask', 'inspect', 'chunks'] as const
|
||||
|
||||
export function isDocMode(value: unknown): value is DocMode {
|
||||
return value === 'ask' || value === 'inspect' || value === 'chunks'
|
||||
}
|
||||
|
||||
export function parseMode(raw: unknown): DocMode {
|
||||
return isDocMode(raw) ? raw : DEFAULT_MODE
|
||||
}
|
||||
35
frontend/src/shared/routing/names.ts
Normal file
35
frontend/src/shared/routing/names.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Canonical route name constants — typed to keep callers honest.
|
||||
*
|
||||
* Use:
|
||||
*
|
||||
* router.push({ name: ROUTES.DOC_WORKSPACE, params: { id } })
|
||||
*
|
||||
* instead of stringly-typed names. Adding a route requires touching
|
||||
* exactly two places: the router definition and this file.
|
||||
*/
|
||||
|
||||
export const ROUTES = {
|
||||
// Existing legacy routes — kept until E3/E4/E5 replace them.
|
||||
HOME: 'home',
|
||||
STUDIO: 'studio',
|
||||
HISTORY: 'history',
|
||||
DOCUMENTS: 'documents',
|
||||
SEARCH: 'search',
|
||||
REASONING: 'reasoning',
|
||||
REASONING_DOC: 'reasoning-doc',
|
||||
SETTINGS: 'settings',
|
||||
NOT_FOUND: 'not-found',
|
||||
|
||||
// 0.6.0 — Document-centric routes (#207).
|
||||
DOCS_LIBRARY: 'docs-library',
|
||||
DOCS_NEW: 'docs-new',
|
||||
DOC_WORKSPACE: 'doc-workspace',
|
||||
STORES_LIST: 'stores-list',
|
||||
STORE_DETAIL: 'store-detail',
|
||||
STORE_QUERY: 'store-query',
|
||||
RUNS: 'runs',
|
||||
RUN_DETAIL: 'run-detail',
|
||||
} as const
|
||||
|
||||
export type RouteName = (typeof ROUTES)[keyof typeof ROUTES]
|
||||
101
frontend/src/shared/ui/ComingSoonShell.vue
Normal file
101
frontend/src/shared/ui/ComingSoonShell.vue
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<template>
|
||||
<div class="coming-soon">
|
||||
<div class="coming-soon__card">
|
||||
<div class="coming-soon__badge">0.6.0</div>
|
||||
<h1 class="coming-soon__title">{{ title }}</h1>
|
||||
<p class="coming-soon__subtitle">{{ subtitle }}</p>
|
||||
<p v-if="hint" class="coming-soon__hint">{{ hint }}</p>
|
||||
<RouterLink :to="{ name: ROUTES.HOME }" class="coming-soon__back">
|
||||
{{ t('comingSoon.backHome') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../i18n'
|
||||
|
||||
import { ROUTES } from '../routing/names'
|
||||
|
||||
defineProps<{
|
||||
/** Page title shown as `<h1>`. Typically the i18n-resolved page name. */
|
||||
title: string
|
||||
/** One-sentence description of what the page will do once shipped. */
|
||||
subtitle: string
|
||||
/** Optional secondary hint (e.g. issue link, ETA). */
|
||||
hint?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.coming-soon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 48px);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.coming-soon__card {
|
||||
max-width: 28rem;
|
||||
text-align: center;
|
||||
padding: 2.5rem 2rem;
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.coming-soon__badge {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-accent-soft, #eff6ff);
|
||||
color: var(--color-accent, #1d4ed8);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.coming-soon__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111);
|
||||
}
|
||||
|
||||
.coming-soon__subtitle {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.coming-soon__hint {
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.coming-soon__back {
|
||||
display: inline-block;
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
color: var(--color-accent, #1d4ed8);
|
||||
border: 1px solid var(--color-accent, #1d4ed8);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.coming-soon__back:hover {
|
||||
background: var(--color-accent, #1d4ed8);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue