Now supports running under a sub path

Just set the SPOOLMAN_BASE_PATH environment variable and it should work.

Resolves #95
This commit is contained in:
Donkie
2024-05-10 11:36:33 +02:00
parent fc532ff697
commit bc32f1e890
18 changed files with 158 additions and 43 deletions

View File

@@ -35,6 +35,11 @@
SPOOLMAN_HOST=0.0.0.0 SPOOLMAN_HOST=0.0.0.0
SPOOLMAN_PORT=7912 SPOOLMAN_PORT=7912
# Change base path
# Set this if you want to host Spoolman at a sub-path
# If you want the root to be e.g. myhost.com/spoolman
# Then set this to /spoolman
#SPOOLMAN_BASE_PATH=
# Enable Collect Prometheus metrics at database # Enable Collect Prometheus metrics at database
# Default: FALSE # Default: FALSE

View File

@@ -2,12 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#DC7734" /> <meta name="theme-color" content="#DC7734" />
<meta name="description" content="Spoolman - Keep track of your inventory of 3D-printer filament spools." /> <meta name="description" content="Spoolman - Keep track of your inventory of 3D-printer filament spools." />
<meta name="robots" content="noindex,nofollow" /> <meta name="robots" content="noindex,nofollow" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<script type="text/javascript" src="./config.js"></script>
<title>Spoolman</title> <title>Spoolman</title>
</head> </head>

View File

@@ -26,6 +26,8 @@ import loadable from "@loadable/component";
import SpoolmanNotificationProvider from "./components/notificationProvider"; import SpoolmanNotificationProvider from "./components/notificationProvider";
import { SpoolmanLayout } from "./components/layout"; import { SpoolmanLayout } from "./components/layout";
import liveProvider from "./components/liveProvider"; import liveProvider from "./components/liveProvider";
import { getAPIURL, getBasePath } from "./utils/url";
import { Favicon } from "./components/favicon";
interface ResourcePageProps { interface ResourcePageProps {
resource: "spools" | "filaments" | "vendors"; resource: "spools" | "filaments" | "vendors";
@@ -84,7 +86,7 @@ function App() {
} }
return ( return (
<BrowserRouter> <BrowserRouter basename={getBasePath() + "/"}>
<RefineKbarProvider> <RefineKbarProvider>
<ColorModeContextProvider> <ColorModeContextProvider>
<ConfigProvider <ConfigProvider
@@ -96,11 +98,11 @@ function App() {
}} }}
> >
<Refine <Refine
dataProvider={dataProvider(import.meta.env.VITE_APIURL)} dataProvider={dataProvider(getAPIURL())}
notificationProvider={SpoolmanNotificationProvider} notificationProvider={SpoolmanNotificationProvider}
i18nProvider={i18nProvider} i18nProvider={i18nProvider}
routerProvider={routerBindings} routerProvider={routerBindings}
liveProvider={liveProvider(import.meta.env.VITE_APIURL)} liveProvider={liveProvider(getAPIURL())}
resources={[ resources={[
{ {
name: "home", name: "home",
@@ -227,6 +229,7 @@ function App() {
<UnsavedChangesNotifier /> <UnsavedChangesNotifier />
<DocumentTitleHandler /> <DocumentTitleHandler />
<ReactQueryDevtools /> <ReactQueryDevtools />
<Favicon url={getBasePath() + "/favicon.svg"} />
</Refine> </Refine>
</ConfigProvider> </ConfigProvider>
</ColorModeContextProvider> </ColorModeContextProvider>

View File

@@ -0,0 +1,20 @@
import { useEffect } from "react";
/**
* Renders a favicon element in the head of the HTML document with the specified URL.
*
* @param {string} props.url - The URL of the favicon image.
* @return {JSX.Element} - An empty JSX element.
*/
export function Favicon(props: { url: string }) {
useEffect(() => {
let link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (!link) {
link = document.createElement("link") as HTMLLinkElement;
link.rel = "icon";
document.getElementsByTagName("head")[0].appendChild(link);
}
link.href = props.url;
}, [props.url]);
return <></>;
}

View File

@@ -5,6 +5,7 @@ import { Version } from "./version";
import { Button } from "antd"; import { Button } from "antd";
import Logo from "../icon.svg?react"; import Logo from "../icon.svg?react";
import { useTranslate } from "@refinedev/core"; import { useTranslate } from "@refinedev/core";
import { getBasePath } from "../utils/url";
const SpoolmanFooter: React.FC = () => { const SpoolmanFooter: React.FC = () => {
const t = useTranslate(); const t = useTranslate();
@@ -27,7 +28,7 @@ const SpoolmanFooter: React.FC = () => {
<Button <Button
icon={ icon={
<img <img
src="/kofi_s_logo_nolabel.png" src={getBasePath() + "/kofi_s_logo_nolabel.png"}
style={{ style={{
height: "1.6em", height: "1.6em",
}} }}

View File

@@ -1,4 +1,5 @@
import { BaseKey, LiveEvent, LiveProvider } from "@refinedev/core"; import { BaseKey, LiveEvent, LiveProvider } from "@refinedev/core";
import { getBasePath } from "../utils/url";
/** /**
* A spoolman websocket event. * A spoolman websocket event.

View File

@@ -1,8 +1,9 @@
import { LiveEvent } from "@refinedev/core"; import { LiveEvent } from "@refinedev/core";
import React from "react"; import React from "react";
import liveProvider from "./liveProvider"; import liveProvider from "./liveProvider";
import { getAPIURL } from "../utils/url";
const liveProviderInstance = liveProvider(import.meta.env.VITE_APIURL); const liveProviderInstance = liveProvider(getAPIURL());
/** /**
* Hook that subscribes to live updates for the items in the dataSource * Hook that subscribes to live updates for the items in the dataSource

View File

@@ -3,14 +3,14 @@ import { IFilament } from "../pages/filaments/model";
import { IVendor } from "../pages/vendors/model"; import { IVendor } from "../pages/vendors/model";
import { ColumnFilterItem } from "antd/es/table/interface"; import { ColumnFilterItem } from "antd/es/table/interface";
import { Tooltip } from "antd"; import { Tooltip } from "antd";
import { getAPIURL } from "../utils/url";
export function useSpoolmanFilamentFilter(enabled: boolean = false) { export function useSpoolmanFilamentFilter(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<IFilament[], unknown, ColumnFilterItem[]>({ return useQuery<IFilament[], unknown, ColumnFilterItem[]>({
enabled: enabled, enabled: enabled,
queryKey: ["filaments"], queryKey: ["filaments"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/filament"); const response = await fetch(getAPIURL() + "/filament");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }
@@ -86,12 +86,11 @@ export function useSpoolmanFilamentFilter(enabled: boolean = false) {
} }
export function useSpoolmanFilamentNames(enabled: boolean = false) { export function useSpoolmanFilamentNames(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<IFilament[], unknown, string[]>({ return useQuery<IFilament[], unknown, string[]>({
enabled: enabled, enabled: enabled,
queryKey: ["filaments"], queryKey: ["filaments"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/filament"); const response = await fetch(getAPIURL() + "/filament");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }
@@ -115,12 +114,11 @@ export function useSpoolmanFilamentNames(enabled: boolean = false) {
} }
export function useSpoolmanVendors(enabled: boolean = false) { export function useSpoolmanVendors(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<IVendor[], unknown, string[]>({ return useQuery<IVendor[], unknown, string[]>({
enabled: enabled, enabled: enabled,
queryKey: ["vendors"], queryKey: ["vendors"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/vendor"); const response = await fetch(getAPIURL() + "/vendor");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }
@@ -137,12 +135,11 @@ export function useSpoolmanVendors(enabled: boolean = false) {
} }
export function useSpoolmanMaterials(enabled: boolean = false) { export function useSpoolmanMaterials(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({ return useQuery<string[]>({
enabled: enabled, enabled: enabled,
queryKey: ["materials"], queryKey: ["materials"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/material"); const response = await fetch(getAPIURL() + "/material");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }
@@ -155,12 +152,11 @@ export function useSpoolmanMaterials(enabled: boolean = false) {
} }
export function useSpoolmanArticleNumbers(enabled: boolean = false) { export function useSpoolmanArticleNumbers(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({ return useQuery<string[]>({
enabled: enabled, enabled: enabled,
queryKey: ["articleNumbers"], queryKey: ["articleNumbers"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/article-number"); const response = await fetch(getAPIURL() + "/article-number");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }
@@ -173,12 +169,11 @@ export function useSpoolmanArticleNumbers(enabled: boolean = false) {
} }
export function useSpoolmanLotNumbers(enabled: boolean = false) { export function useSpoolmanLotNumbers(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({ return useQuery<string[]>({
enabled: enabled, enabled: enabled,
queryKey: ["lotNumbers"], queryKey: ["lotNumbers"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/lot-number"); const response = await fetch(getAPIURL() + "/lot-number");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }
@@ -191,12 +186,11 @@ export function useSpoolmanLotNumbers(enabled: boolean = false) {
} }
export function useSpoolmanLocations(enabled: boolean = false) { export function useSpoolmanLocations(enabled: boolean = false) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<string[]>({ return useQuery<string[]>({
enabled: enabled, enabled: enabled,
queryKey: ["locations"], queryKey: ["locations"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/location"); const response = await fetch(getAPIURL() + "/location");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }

View File

@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Spin, Typography } from "antd"; import { Spin, Typography } from "antd";
import { getAPIURL } from "../utils/url";
const { Text } = Typography; const { Text } = Typography;
@@ -15,12 +16,10 @@ interface IInfo {
} }
export const Version: React.FC = () => { export const Version: React.FC = () => {
const apiEndpoint = import.meta.env.VITE_APIURL;
const infoResult = useQuery<IInfo>({ const infoResult = useQuery<IInfo>({
queryKey: ["info"], queryKey: ["info"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(apiEndpoint + "/info"); const response = await fetch(getAPIURL() + "/info");
if (!response.ok) { if (!response.ok) {
throw new Error("Network response was not ok"); throw new Error("Network response was not ok");
} }

View File

@@ -2,6 +2,7 @@ import i18n from "i18next";
import detector from "i18next-browser-languagedetector"; import detector from "i18next-browser-languagedetector";
import Backend from "i18next-xhr-backend"; import Backend from "i18next-xhr-backend";
import { initReactI18next } from "react-i18next"; import { initReactI18next } from "react-i18next";
import { getBasePath } from "./utils/url";
interface Language { interface Language {
name: string; name: string;
@@ -106,7 +107,7 @@ i18n
.init({ .init({
supportedLngs: Object.keys(languages), supportedLngs: Object.keys(languages),
backend: { backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json", loadPath: getBasePath() + "/locales/{{lng}}/{{ns}}.json",
}, },
defaultNS: "common", defaultNS: "common",
fallbackLng: "en", fallbackLng: "en",

View File

@@ -1,7 +1,7 @@
import { getAPIURL } from "../../utils/url";
import { ISpool } from "./model"; import { ISpool } from "./model";
export async function setSpoolArchived(spool: ISpool, archived: boolean) { export async function setSpoolArchived(spool: ISpool, archived: boolean) {
const apiEndpoint = import.meta.env.VITE_APIURL;
const init: RequestInit = { const init: RequestInit = {
method: "PATCH", method: "PATCH",
headers: { headers: {
@@ -11,6 +11,6 @@ export async function setSpoolArchived(spool: ISpool, archived: boolean) {
archived: archived, archived: archived,
}), }),
}; };
const request = new Request(apiEndpoint + "/spool/" + spool.id); const request = new Request(getAPIURL() + "/spool/" + spool.id);
await fetch(request, init); await fetch(request, init);
} }

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { getAPIURL } from "./url";
export enum FieldType { export enum FieldType {
text = "text", text = "text",
@@ -34,11 +35,10 @@ export interface Field extends FieldParameters {
} }
export function useGetFields(entity_type: EntityType) { export function useGetFields(entity_type: EntityType) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<Field[]>({ return useQuery<Field[]>({
queryKey: ["fields", entity_type], queryKey: ["fields", entity_type],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${apiEndpoint}/field/${entity_type}`); const response = await fetch(`${getAPIURL()}/field/${entity_type}`);
return response.json(); return response.json();
}, },
}); });
@@ -47,10 +47,9 @@ export function useGetFields(entity_type: EntityType) {
export function useSetField(entity_type: EntityType) { export function useSetField(entity_type: EntityType) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiEndpoint = import.meta.env.VITE_APIURL;
return useMutation<Field[], unknown, { key: string; params: FieldParameters }, { previousFields?: Field[] }>({ return useMutation<Field[], unknown, { key: string; params: FieldParameters }, { previousFields?: Field[] }>({
mutationFn: async ({ key, params }) => { mutationFn: async ({ key, params }) => {
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, { const response = await fetch(`${getAPIURL()}/field/${entity_type}/${key}`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -110,10 +109,9 @@ export function useSetField(entity_type: EntityType) {
export function useDeleteField(entity_type: EntityType) { export function useDeleteField(entity_type: EntityType) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiEndpoint = import.meta.env.VITE_APIURL;
return useMutation<Field[], unknown, string>({ return useMutation<Field[], unknown, string>({
mutationFn: async (key) => { mutationFn: async (key) => {
const response = await fetch(`${apiEndpoint}/field/${entity_type}/${key}`, { const response = await fetch(`${getAPIURL()}/field/${entity_type}/${key}`, {
method: "DELETE", method: "DELETE",
}); });

View File

@@ -1,4 +1,5 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getAPIURL } from "./url";
interface SettingResponseValue { interface SettingResponseValue {
value: string; value: string;
@@ -11,22 +12,20 @@ interface SettingsResponse {
} }
export function useGetSettings() { export function useGetSettings() {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<SettingsResponse>({ return useQuery<SettingsResponse>({
queryKey: ["settings"], queryKey: ["settings"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${apiEndpoint}/setting`); const response = await fetch(`${getAPIURL()}/setting/`);
return response.json(); return response.json();
}, },
}); });
} }
export function useGetSetting(key: string) { export function useGetSetting(key: string) {
const apiEndpoint = import.meta.env.VITE_APIURL;
return useQuery<SettingResponseValue>({ return useQuery<SettingResponseValue>({
queryKey: ["settings", key], queryKey: ["settings", key],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${apiEndpoint}/setting/${key}`); const response = await fetch(`${getAPIURL()}/setting/${key}`);
return response.json(); return response.json();
}, },
}); });
@@ -35,10 +34,9 @@ export function useGetSetting(key: string) {
export function useSetSetting() { export function useSetSetting() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const apiEndpoint = import.meta.env.VITE_APIURL;
return useMutation<SettingResponseValue, unknown, { key: string; value: unknown }>({ return useMutation<SettingResponseValue, unknown, { key: string; value: unknown }>({
mutationFn: async ({ key, value }) => { mutationFn: async ({ key, value }) => {
const response = await fetch(`${apiEndpoint}/setting/${key}`, { const response = await fetch(`${getAPIURL()}/setting/${key}`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

35
client/src/utils/url.ts Normal file
View File

@@ -0,0 +1,35 @@
declare global {
interface Window {
SPOOLMAN_BASE_PATH: string;
}
}
/**
* Returns the base path of the application.
*
* If a base path is set, this returns e.g. "/spoolman". If none is set, it returns "".
*
* @return {string} The base path of the application. If the `SPOOLMAN_BASE_PATH`
* window variable is set and not empty, it is returned. Otherwise, the
* default base path "" is returned.
*/
export function getBasePath(): string {
if (window.SPOOLMAN_BASE_PATH && window.SPOOLMAN_BASE_PATH.length > 0) {
return window.SPOOLMAN_BASE_PATH;
} else {
return "";
}
}
/**
* A function that returns the Spoolman API URL
* This returns e.g. "/spoolman/api/v1" if the base path is "/spoolman"
*
* @return {string} The API URL
*/
export function getAPIURL(): string {
if (!import.meta.env.VITE_APIURL) {
throw new Error("VITE_APIURL is not set");
}
return getBasePath() + import.meta.env.VITE_APIURL;
}

View File

@@ -4,5 +4,6 @@ import svgr from "vite-plugin-svgr";
export default defineConfig({ export default defineConfig({
base: '',
plugins: [react(), svgr()], plugins: [react(), svgr()],
}); });

View File

@@ -2,24 +2,37 @@
# ruff: noqa: PTH118 # ruff: noqa: PTH118
import logging
import os import os
from pathlib import Path
from typing import Union from typing import Union
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
class SinglePageApplication(StaticFiles): class SinglePageApplication(StaticFiles):
"""Serve a single page application.""" """Serve a single page application."""
def __init__(self, directory: str) -> None: def __init__(self, directory: str, base_path: str) -> None:
"""Construct.""" """Construct."""
super().__init__(directory=directory, packages=None, html=True, check_dir=True) super().__init__(directory=directory, packages=None, html=True, check_dir=True)
self.base_path = base_path.removeprefix("/")
def lookup_path(self, path: str) -> tuple[str, Union[os.stat_result, None]]: def lookup_path(self, path: str) -> tuple[str, Union[os.stat_result, None]]:
"""Return index.html if the requested file cannot be found.""" """Return index.html if the requested file cannot be found."""
path = path.removeprefix(self.base_path).removeprefix("/")
full_path, stat_result = super().lookup_path(path) full_path, stat_result = super().lookup_path(path)
if stat_result is None: if stat_result is None:
ext = Path(path).suffix
# Check if user is looking for some specific non-document file
if len(ext) > 1 and ext != ".html":
# If so, return 404
return ("", None)
# Otherwise, they did look for a document, lead them to index.html
return super().lookup_path("index.html") return super().lookup_path("index.html")
return (full_path, stat_result) return (full_path, stat_result)

View File

@@ -398,3 +398,19 @@ def is_metrics_enabled() -> bool:
raise ValueError( raise ValueError(
f"Failed to parse SPOOLMAN_METRICS_ENABLED variable: Unknown metrics enabled '{metrics_enabled}'.", f"Failed to parse SPOOLMAN_METRICS_ENABLED variable: Unknown metrics enabled '{metrics_enabled}'.",
) )
def get_base_path() -> str:
"""Get the base path.
This is formated so that it always starts with a /, and does not end with a /
Returns:
str: The base path.
"""
path = os.getenv("SPOOLMAN_BASE_PATH", "")
if len(path) == 0:
return ""
# Ensure it starts with / and does not end with /
return "/" + path.strip("/")

View File

@@ -1,4 +1,5 @@
"""Main entrypoint to the server.""" """Main entrypoint to the server."""
import logging import logging
import subprocess import subprocess
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@@ -8,7 +9,7 @@ import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse, RedirectResponse, Response
from prometheus_client import generate_latest from prometheus_client import generate_latest
from scheduler.asyncio.scheduler import Scheduler from scheduler.asyncio.scheduler import Scheduler
@@ -38,12 +39,12 @@ app = FastAPI(
version=env.get_version(), version=env.get_version(),
) )
app.add_middleware(GZipMiddleware) app.add_middleware(GZipMiddleware)
app.mount("/api/v1", v1_app) app.mount(env.get_base_path() + "/api/v1", v1_app)
# WA for prometheus /metrics bind with SinglePageApp at root # WA for prometheus /metrics bind with SinglePageApp at root
@app.get( @app.get(
"/metrics", env.get_base_path() + "/metrics",
response_class=PlainTextResponse, response_class=PlainTextResponse,
name="Get metrics for prometheus", name="Get metrics for prometheus",
description=( description=(
@@ -55,8 +56,36 @@ def get_metrics() -> bytes:
return generate_latest(registry) return generate_latest(registry)
app.mount("", app=SinglePageApplication(directory="client/dist")) base_path = env.get_base_path()
if base_path != "":
logger.info("Base path is: %s", base_path)
# If base path is set, add a redirect from non-slash suffix to slash
# suffix. Otherwise it won't work.
@app.get(base_path)
def root_redirect() -> Response:
"""Redirect to base path."""
return RedirectResponse(base_path + "/")
# Return a dynamic js config file
# This is so that the client side can access the base path variable.
@app.get(env.get_base_path() + "/config.js")
def get_configjs() -> Response:
"""Return a dynamic js config file."""
if '"' in base_path:
raise ValueError("Base path contains quotes, which are not allowed.")
return Response(
content=f"""
window.SPOOLMAN_BASE_PATH = "{base_path}";
""",
media_type="text/javascript",
)
# Mount the client side app
app.mount(base_path, app=SinglePageApplication(directory="client/dist", base_path=env.get_base_path()))
# Allow all origins if in debug mode # Allow all origins if in debug mode
if env.is_debug_mode(): if env.is_debug_mode():