diff --git a/src/components/layout/AppNavDrawer.vue b/src/components/layout/AppNavDrawer.vue
index 61b93a8d08..1e0d420e8d 100644
--- a/src/components/layout/AppNavDrawer.vue
+++ b/src/components/layout/AppNavDrawer.vue
@@ -105,6 +105,18 @@
{{ $t('app.general.title.system') }}
+
+
+
+
+
diff --git a/src/components/ui/AppNavItemExternal.vue b/src/components/ui/AppNavItemExternal.vue
new file mode 100644
index 0000000000..fd39ace912
--- /dev/null
+++ b/src/components/ui/AppNavItemExternal.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
+ {{ icon || '$link' }}
+
+
+
+ {{ title }}
+
+
+
+ {{ title }}
+
+
+
+
+
+
diff --git a/src/locales/en.yaml b/src/locales/en.yaml
index 81116bccbb..e6d2eeaba0 100644
--- a/src/locales/en.yaml
+++ b/src/locales/en.yaml
@@ -1243,12 +1243,15 @@ app:
remove_model: Remove %{name} model
msg:
hint: >-
- If saving as something other than %{name}, you can choose to also remove
- the %{name} model
+ If saving as something other than '%{name}', you can choose to also remove
+ the '%{name}' model
not_found: No existing beacon models found.
tooltip:
delete: Delete Model
load: Load Model
+ external_navigation:
+ tooltip:
+ external_link: External navigation link
database:
btn:
compact_database: Compact Database
diff --git a/src/store/index.ts b/src/store/index.ts
index 6f167b22d9..65cb4bb7e5 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -32,6 +32,7 @@ import { sensors } from './sensors'
import { database } from './database'
import { analysis } from './analysis'
import { afc } from './afc'
+import { plugins } from './plugins'
Vue.use(Vuex)
@@ -64,7 +65,8 @@ export const storeOptions = {
sensors,
database,
analysis,
- afc
+ afc,
+ plugins
} satisfies RootModules,
mutations: {},
actions: {
diff --git a/src/store/plugins/__tests__/plugins.spec.ts b/src/store/plugins/__tests__/plugins.spec.ts
new file mode 100644
index 0000000000..87876dde8f
--- /dev/null
+++ b/src/store/plugins/__tests__/plugins.spec.ts
@@ -0,0 +1,114 @@
+import { getters } from '../getters'
+import { mutations } from '../mutations'
+import { defaultState } from '../state'
+
+const mockServerFilesGet = vi.fn()
+
+vi.mock('@/api/httpClientActions', () => ({
+ httpClientActions: {
+ serverFilesGet: (...args: any[]) => mockServerFilesGet(...args)
+ }
+}))
+
+import { actions } from '../actions'
+
+describe('plugins store', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('state', () => {
+ it('returns default state', () => {
+ const state = defaultState()
+ expect(state.naviPoints).toEqual([])
+ expect(state.naviPointsLoaded).toBe(false)
+ })
+ })
+
+ describe('getters', () => {
+ it('getNaviPoints returns naviPoints from state', () => {
+ const state = {
+ naviPoints: [
+ { title: 'Test', href: '/test', target: '_self', icon: 'mdi-test', position: 1 }
+ ],
+ naviPointsLoaded: true
+ }
+ const result = getters.getNaviPoints(state, {} as any, {} as any, {} as any)
+ expect(result).toEqual(state.naviPoints)
+ })
+
+ it('getNaviPointsLoaded returns loaded status from state', () => {
+ const state = {
+ naviPoints: [],
+ naviPointsLoaded: true
+ }
+ const result = getters.getNaviPointsLoaded(state, {} as any, {} as any, {} as any)
+ expect(result).toBe(true)
+ })
+ })
+
+ describe('mutations', () => {
+ it('setReset resets state', () => {
+ const state = {
+ naviPoints: [{ title: 'Test', href: '/test', target: '_self', icon: '', position: 1 }],
+ naviPointsLoaded: true
+ }
+ mutations.setReset(state)
+ expect(state.naviPoints).toEqual([])
+ expect(state.naviPointsLoaded).toBe(false)
+ })
+
+ it('setNaviPoints sets naviPoints', () => {
+ const state = defaultState()
+ const points = [
+ { title: 'KlipperFleet', href: '/klipperfleet.html', target: '_self', icon: 'mdi-fleet', position: 86 }
+ ]
+ mutations.setNaviPoints(state, points)
+ expect(state.naviPoints).toEqual(points)
+ })
+
+ it('setNaviPointsLoaded sets loaded status', () => {
+ const state = defaultState()
+ mutations.setNaviPointsLoaded(state, true)
+ expect(state.naviPointsLoaded).toBe(true)
+ })
+ })
+
+ describe('actions', () => {
+ it('reset dispatches setReset mutation', async () => {
+ const commit = vi.fn()
+ await (actions as any).reset({ commit })
+ expect(commit).toHaveBeenCalledWith('setReset')
+ })
+
+ it('fetchNaviPoints fetches and sets navi points', async () => {
+ const mockPoints = [
+ { title: 'KlipperFleet', href: '/klipperfleet.html', target: '_self', icon: '', position: 86 }
+ ]
+
+ mockServerFilesGet.mockResolvedValue({
+ data: mockPoints
+ })
+
+ const commit = vi.fn()
+ const rootState = { config: { apiUrl: 'http://localhost' } }
+
+ await (actions as any).fetchNaviPoints({ commit, rootState } as any)
+
+ expect(commit).toHaveBeenCalledWith('setNaviPoints', expect.any(Array))
+ expect(commit).toHaveBeenCalledWith('setNaviPointsLoaded', true)
+ })
+
+ it('fetchNaviPoints handles errors gracefully', async () => {
+ mockServerFilesGet.mockRejectedValue(new Error('Not found'))
+
+ const commit = vi.fn()
+ const rootState = { config: { apiUrl: 'http://localhost' } }
+
+ await (actions as any).fetchNaviPoints({ commit, rootState } as any)
+
+ expect(commit).toHaveBeenCalledWith('setNaviPointsLoaded', true)
+ expect(commit).not.toHaveBeenCalledWith('setNaviPoints', expect.any(Array))
+ })
+ })
+})
diff --git a/src/store/plugins/actions.ts b/src/store/plugins/actions.ts
new file mode 100644
index 0000000000..4c6219d176
--- /dev/null
+++ b/src/store/plugins/actions.ts
@@ -0,0 +1,33 @@
+import type { ActionTree } from 'vuex'
+import type { PluginsState } from './types'
+import type { RootState } from '../types'
+import { httpClientActions } from '@/api/httpClientActions'
+
+export const actions: ActionTree = {
+ async reset ({ commit }) {
+ commit('setReset')
+ },
+
+ async fetchNaviPoints ({ commit }) {
+ try {
+ const response = await httpClientActions.serverFilesGet('config/.theme/navi.json')
+
+ if (Array.isArray(response?.data)) {
+ const points = response.data.map((item: any) => ({
+ title: item.title ?? 'Unknown',
+ href: item.href ?? '#',
+ target: item.target ?? '_self',
+ icon: item.icon ?? '',
+ position: item.position ?? 999,
+ visible: item.visible ?? true
+ }))
+
+ commit('setNaviPoints', points)
+ commit('setNaviPointsLoaded', true)
+ }
+ } catch (err) {
+ console.debug('Unable to fetch .theme/navi.json:', err)
+ commit('setNaviPointsLoaded', true)
+ }
+ }
+}
diff --git a/src/store/plugins/getters.ts b/src/store/plugins/getters.ts
new file mode 100644
index 0000000000..e70e4a4021
--- /dev/null
+++ b/src/store/plugins/getters.ts
@@ -0,0 +1,13 @@
+import type { GetterTree } from 'vuex'
+import type { PluginsState } from './types'
+import type { RootState } from '../types'
+
+export const getters: GetterTree = {
+ getNaviPoints: (state) => {
+ return state.naviPoints
+ },
+
+ getNaviPointsLoaded: (state) => {
+ return state.naviPointsLoaded
+ }
+}
diff --git a/src/store/plugins/index.ts b/src/store/plugins/index.ts
new file mode 100644
index 0000000000..d6a83e6382
--- /dev/null
+++ b/src/store/plugins/index.ts
@@ -0,0 +1,17 @@
+import type { Module } from 'vuex'
+import { state } from './state'
+import { getters } from './getters'
+import { actions } from './actions'
+import { mutations } from './mutations'
+import type { PluginsState } from './types'
+import type { RootState } from '../types'
+
+const namespaced = true
+
+export const plugins: Module = {
+ namespaced,
+ state,
+ getters,
+ actions,
+ mutations
+}
diff --git a/src/store/plugins/mutations.ts b/src/store/plugins/mutations.ts
new file mode 100644
index 0000000000..77a7048f0b
--- /dev/null
+++ b/src/store/plugins/mutations.ts
@@ -0,0 +1,17 @@
+import type { MutationTree } from 'vuex'
+import type { PluginsState, NaviPoint } from './types'
+
+export const mutations: MutationTree = {
+ setReset (state) {
+ state.naviPoints = []
+ state.naviPointsLoaded = false
+ },
+
+ setNaviPoints (state, points: NaviPoint[]) {
+ state.naviPoints = points
+ },
+
+ setNaviPointsLoaded (state, loaded: boolean) {
+ state.naviPointsLoaded = loaded
+ }
+}
diff --git a/src/store/plugins/state.ts b/src/store/plugins/state.ts
new file mode 100644
index 0000000000..3a3c8d16e0
--- /dev/null
+++ b/src/store/plugins/state.ts
@@ -0,0 +1,10 @@
+import type { PluginsState } from './types'
+
+export const defaultState = (): PluginsState => {
+ return {
+ naviPoints: [],
+ naviPointsLoaded: false
+ }
+}
+
+export const state = defaultState()
diff --git a/src/store/plugins/types.ts b/src/store/plugins/types.ts
new file mode 100644
index 0000000000..8f58869e61
--- /dev/null
+++ b/src/store/plugins/types.ts
@@ -0,0 +1,13 @@
+export interface NaviPoint {
+ title: string;
+ href: string;
+ target?: string;
+ icon: string;
+ position: number;
+ visible?: boolean;
+}
+
+export interface PluginsState {
+ naviPoints: NaviPoint[];
+ naviPointsLoaded: boolean;
+}
diff --git a/src/store/types.ts b/src/store/types.ts
index 2f3924695f..7393bf3caf 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -26,6 +26,7 @@ import type { sensors } from './sensors'
import type { database } from './database'
import type { analysis } from './analysis'
import type { afc } from './afc'
+import type { plugins } from './plugins'
import type { storeOptions } from '.'
type RootModulesType = {
@@ -55,7 +56,8 @@ type RootModulesType = {
sensors: typeof sensors,
database: typeof database,
analysis: typeof analysis,
- afc: typeof afc
+ afc: typeof afc,
+ plugins: typeof plugins,
}
type RootStateType = {
diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json
index bceb5bda63..c531bd5809 100644
--- a/tsconfig.vitest.json
+++ b/tsconfig.vitest.json
@@ -5,7 +5,9 @@
"src/**/__tests__/*",
"env.d.ts",
],
- "exclude": [],
+ "exclude": [
+ "src/store/**/__tests__/*"
+ ],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",