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 { createRouter, createWebHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
import { routes } from './routes'
|
||||||
{
|
|
||||||
path: '/',
|
export { routes }
|
||||||
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: '/',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
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
|
// Top bar
|
||||||
'topbar.newAnalysis': 'Nouvelle analyse',
|
'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
|
||||||
'home.title': 'Docling Studio',
|
'home.title': 'Docling Studio',
|
||||||
'home.subtitle':
|
'home.subtitle':
|
||||||
|
|
@ -287,6 +307,26 @@ const messages: Messages = {
|
||||||
|
|
||||||
'topbar.newAnalysis': 'New analysis',
|
'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.title': 'Docling Studio',
|
||||||
'home.subtitle':
|
'home.subtitle':
|
||||||
'Analyze, explore and validate the structure of your PDF documents with Docling.',
|
'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