diff --git a/next-ui/README.md b/next-ui/README.md
index 987612421..3b630474a 100644
--- a/next-ui/README.md
+++ b/next-ui/README.md
@@ -60,3 +60,56 @@ Components are automatically imported using [unplugin-vue-components](https://gi
## Icons
[UnoCSS Icons preset](https://unocss.dev/presets/icons) is used for icons, with the MDI set from Iconify.
+
+## Base URL
+
+The generated bundle is server by Apache Tomcat when running Spring. By default the site is hosted at `/`, but if `server.servlet-context-path` is set, the base URL can be dynamic.
+
+The base URL needs to be set correctly so the web app works:
+
+- in the API client
+- in the Vue Router, to properly handle the web history
+- in the generated bundle, to load other files (js/css/images)
+
+1. Vite is [configured](./vite.config.mts) with the experimental `renderBuiltUrl`, which will use a dynamic function (`window.buildUrl`) defined in `index.html` to generate the asset path at runtime. This is only supported withing JS files though.
+2. To handle the dynamic path in `index.html`, a Gradle task `prepareThymeLeafNext` modifies `index.html` to duplicate `href`, `src` and `content` attributes as Thymeleaf variants.
+
+ For example the following:
+
+ ```html
+
+
+ ```
+
+ will be transformed to:
+
+ ```html
+
+
+ ```
+
+ In Thymeleaf, `@{}` will prepend the path with the context path dynamically when serving `index.html`.
+
+3. when the `index.html` is served by the `IndexController`, a `baseUrl` attribute is injected, which contains the servlet context path (by default `/`, but could be `/komga` for example)
+4. the `index.html` contains a Thymeleaf script block that will be processed when serving the page, effectively injecting the `baseUrl` value into `window.ressourceBaseUrl`.
+5. `window.ressourceBaseUrl` is subsequently used in Typescript code to set the base URL for:
+ - the API client and the images served [by API](./src/api/base.ts)
+ - the [Vue Router](./src/router/index.ts)
diff --git a/next-ui/index.html b/next-ui/index.html
index 663685422..60cf0a116 100644
--- a/next-ui/index.html
+++ b/next-ui/index.html
@@ -11,6 +11,16 @@
content="width=device-width, initial-scale=1.0"
/>
Komga
+
+
diff --git a/next-ui/src/api/base.ts b/next-ui/src/api/base.ts
new file mode 100644
index 000000000..b9bd31ebf
--- /dev/null
+++ b/next-ui/src/api/base.ts
@@ -0,0 +1,8 @@
+declare global {
+ interface Window {
+ resourceBaseUrl: string
+ }
+}
+
+export const API_BASE_URL =
+ import.meta.env.VITE_KOMGA_API_URL || window.location.origin + window.resourceBaseUrl
diff --git a/next-ui/src/api/images.ts b/next-ui/src/api/images.ts
index 4b2b595dd..22431a5cc 100644
--- a/next-ui/src/api/images.ts
+++ b/next-ui/src/api/images.ts
@@ -1,14 +1,16 @@
+import { API_BASE_URL } from '@/api/base'
+
export function seriesThumbnailUrl(seriesId?: string): string | undefined {
- if (seriesId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/series/${seriesId}/thumbnail`
+ if (seriesId) return `${API_BASE_URL}/api/v1/series/${seriesId}/thumbnail`
return undefined
}
export function bookThumbnailUrl(bookId?: string): string | undefined {
- if (bookId) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/books/${bookId}/thumbnail`
+ if (bookId) return `${API_BASE_URL}/api/v1/books/${bookId}/thumbnail`
return undefined
}
export function pageHashKnownThumbnailUrl(hash?: string): string | undefined {
- if (hash) return `${import.meta.env.VITE_KOMGA_API_URL}/api/v1/page-hashes/${hash}/thumbnail`
+ if (hash) return `${API_BASE_URL}/api/v1/page-hashes/${hash}/thumbnail`
return undefined
}
diff --git a/next-ui/src/api/komga-client.ts b/next-ui/src/api/komga-client.ts
index d1dea65ff..eaf61ed44 100644
--- a/next-ui/src/api/komga-client.ts
+++ b/next-ui/src/api/komga-client.ts
@@ -1,6 +1,7 @@
import type { Middleware } from 'openapi-fetch'
import createClient from 'openapi-fetch'
import type { paths } from '@/generated/openapi/komga'
+import { API_BASE_URL } from '@/api/base'
// Middleware that throws on error, so it works with Pinia Colada
const coladaMiddleware: Middleware = {
@@ -27,7 +28,7 @@ const coladaMiddleware: Middleware = {
}
const client = createClient({
- baseUrl: import.meta.env.VITE_KOMGA_API_URL,
+ baseUrl: API_BASE_URL,
// required to pass the session cookie on all requests
credentials: 'include',
// required to avoid browser basic-auth popups
diff --git a/next-ui/src/mocks/api/httpTyped.ts b/next-ui/src/mocks/api/httpTyped.ts
index 514399b9d..b56a07e98 100644
--- a/next-ui/src/mocks/api/httpTyped.ts
+++ b/next-ui/src/mocks/api/httpTyped.ts
@@ -1,4 +1,5 @@
import { createOpenApiHttp } from 'openapi-msw'
import type { paths } from '@/generated/openapi/komga'
+import { API_BASE_URL } from '@/api/base'
-export const httpTyped = createOpenApiHttp({ baseUrl: import.meta.env.VITE_KOMGA_API_URL })
+export const httpTyped = createOpenApiHttp({ baseUrl: API_BASE_URL })
diff --git a/next-ui/src/router/index.ts b/next-ui/src/router/index.ts
index a32130b4b..de4c9394a 100644
--- a/next-ui/src/router/index.ts
+++ b/next-ui/src/router/index.ts
@@ -10,7 +10,9 @@ import { setupLayouts } from 'virtual:generated-layouts'
import { routes } from 'vue-router/auto-routes'
const router = createRouter({
- history: createWebHistory(import.meta.env.BASE_URL),
+ history: createWebHistory(
+ import.meta.env.PROD ? window.resourceBaseUrl : import.meta.env.BASE_URL,
+ ),
routes: setupLayouts(routes),
})
diff --git a/next-ui/vite.config.mts b/next-ui/vite.config.mts
index 9e818ed2c..12c50de87 100644
--- a/next-ui/vite.config.mts
+++ b/next-ui/vite.config.mts
@@ -81,6 +81,17 @@ export default defineConfig({
server: {
port: 3000,
},
+ base: '/',
+ // support for the runtime base url (depending on the server.servlet.context-path)
+ // window.buildUrl is a function defined in index.html that dynamically provides the path
+ // it only works within js files though
+ experimental: {
+ renderBuiltUrl(filename, { hostType }) {
+ if (hostType === 'js') return { runtime: `window.buildUrl(${JSON.stringify(filename)})` }
+ // else if (hostType === 'html') return `@{/${filename}}`
+ else return { relative: true }
+ },
+ },
optimizeDeps: {
exclude: ['vuetify'],
},