+
- {{ $t('app.general.title.home') }}
+ {{ $t(homeLink.title) }}
-
- {{ $t('app.general.title.console') }}
-
+
+
-
+
- {{ $t('app.general.title.gcode_preview') }}
-
+
+
+ {{ $t(item.title) }}
+
+
+
-
+
- {{ $t('app.general.title.jobs') }}
-
+
+
+
+ $menuAlt
+
+
+ {{ $t('app.general.title.more') }}
+
+
+
+
+
+
+ {{ item.icon }}
+
+
+ {{ $t(item.title) }}
+
+
+
+
-
- {{ $t('app.general.title.history') }}
-
+
+
-
+
- {{ $t('app.general.title.timelapse') }}
-
+
+
+
+ $bookmarkMultiple
+
+
+ {{ $t('app.general.title.bookmarks') }}
+
+
+
+
+
+
+
+
+
+ {{ link.title }}
+
+
+
+
-
+
- {{ $t('app.general.title.tune') }}
-
+
+
-
+
- {{ $t('app.general.title.diagnostics') }}
-
+
+
+
+
+
+ {{ isContextItemCollapsed ? '$eye' : '$eyeOff' }}
+
+
+
+
+
+ {{ $t('app.general.label.show_in_sidebar') }}
+
+
+ {{ $t('app.general.label.collapse_to_more_menu') }}
+
+
+ {{ $t('app.general.label.collapse_to_bookmarks') }}
+
+
+
+
-
- {{ $t('app.general.title.configure') }}
-
+
+
+
+ $pencil
+
+
+ {{ $t('app.general.btn.edit') }}
+
+
-
+
+
+ $delete
+
+
+ {{ $t('app.general.btn.delete') }}
+
+
+
+
+
+
+ $eyeOff
+
+
+ {{ $t('app.general.btn.hide') }}
+
+
+
+
+
+
+
- {{ $t('app.general.title.system') }}
-
+
+
+
+ $eye
+
+
+ {{ $t('app.general.label.show_all_in_sidebar') }}
+
+
+
+
+ $plus
+
+
+ {{ $t('app.setting.btn.add_nav_link') }}
+
+
+
+
+
+
+
{{ $t('app.general.title.settings') }}
@@ -117,19 +332,28 @@
v-if="socketReady && !isMobileViewport && canEditLayout"
#append
>
-
+
-
- $apps
-
+
+
+ $apps
+
+
+
+ {{ $t('app.general.btn.adjust_layout') }}
+
+
{{ $t('app.general.btn.adjust_layout') }}
@@ -143,20 +367,234 @@
name="navigation"
/>
+
+
@@ -201,4 +984,13 @@ export default class AppNavDrawer extends Mixins(StateMixin, BrowserMixin) {
:deep(.v-navigation-drawer.no-subnav > .v-navigation-drawer__border) {
display: none;
}
+
+ // GPU acceleration hints for smoother transitions
+ :deep(.v-navigation-drawer) {
+ will-change: width, transform;
+ }
+
+ :deep(.v-navigation-drawer__content) {
+ will-change: transform;
+ }
diff --git a/src/components/layout/AppSettingsNav.vue b/src/components/layout/AppSettingsNav.vue
index b2b3d2b450..af981dbd92 100644
--- a/src/components/layout/AppSettingsNav.vue
+++ b/src/components/layout/AppSettingsNav.vue
@@ -37,6 +37,7 @@ export default class AppSettingsNav extends Vue {
{ name: this.$t('app.setting.title.general'), hash: '#general', visible: true },
{ name: this.$t('app.setting.title.warnings'), hash: '#warnings', visible: true },
{ name: this.$t('app.setting.title.theme'), hash: '#theme', visible: true },
+ { name: this.$t('app.setting.title.navigation'), hash: '#navigation', visible: true },
{ name: this.$t('app.setting.title.authentication'), hash: '#auth', visible: true },
{ name: this.$t('app.setting.title.console'), hash: '#console', visible: true },
{ name: this.$t('app.setting.title.file_browser'), hash: '#browser', visible: true },
diff --git a/src/components/settings/ThemeSettings.vue b/src/components/settings/ThemeSettings.vue
index 85b1485b87..db244540bf 100644
--- a/src/components/settings/ThemeSettings.vue
+++ b/src/components/settings/ThemeSettings.vue
@@ -24,7 +24,7 @@
single-line
hide-details="auto"
:items="themePresets"
- item-value="icon.src"
+ item-value="logo.src"
item-text="name"
return-object
/>
diff --git a/src/components/settings/navigation/NavLinkDialog.vue b/src/components/settings/navigation/NavLinkDialog.vue
new file mode 100644
index 0000000000..6c69f6926d
--- /dev/null
+++ b/src/components/settings/navigation/NavLinkDialog.vue
@@ -0,0 +1,582 @@
+
+
+
+
+
+ {{ isEdit ? $t('app.general.label.edit_nav_link') : $t('app.general.label.add_nav_link') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('app.setting.label.url') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('app.setting.label.nav_link_target') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('app.setting.label.icon_type') }}
+
+
+
+
+
+
+
+
+
+
+
+ ${{ item.value }}
+
+ {{ item.text }}
+
+
+
+ ${{ item.value }}
+
+ {{ item.text }}
+
+
+
+
+
+
+ {{ $t('app.setting.label.custom_svg_icon') }}
+
+
+
+
+ {{ $t('app.setting.label.upload_svg') }}
+
+
![]()
+
+
+ $close
+
+
+
+
+
+ {{ imageError }}
+
+
+
+
+
+ {{ $t('app.setting.label.custom_image') }}
+
+
+
+
+ {{ $t('app.setting.label.upload_image') }}
+
+
![]()
+
+
+ $close
+
+
+
+
+
+ {{ imageError }}
+
+
+
+
+
+
+
+
+
+
+
+
+ $circle
+
+ {{ $t('app.setting.label.custom_color') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/settings/navigation/NavigationSettings.vue b/src/components/settings/navigation/NavigationSettings.vue
new file mode 100644
index 0000000000..04155c918e
--- /dev/null
+++ b/src/components/settings/navigation/NavigationSettings.vue
@@ -0,0 +1,486 @@
+
+
+
+ {{ $t('app.setting.title.navigation') }}
+
+
+
+
+
+
+
+
+
+
+ $menu
+
+ {{ $t('app.setting.btn.import_export_links') }}
+
+
+
+
+
+ $download
+
+
+ {{ $t('app.setting.btn.export_links') }}
+
+
+
+
+ $fileUpload
+
+
+ {{ $t('app.setting.btn.import_links') }}
+
+
+
+
+
+
+
+ $plus
+
+ {{ $t('app.setting.btn.add_nav_link') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ link.title }}
+
+
+
+ {{ link.url }}
+
+
+
+ {{ $t('app.setting.label.theme_nav_link') }}
+
+
+
+
+ {{ isThemeLinkHidden(link.id) ? '$eyeOff' : '$eye' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ link.title }}
+
+
+
+ {{ link.url }}
+
+
+
+
+ $edit
+
+
+
+
+
+ $delete
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('app.setting.btn.import_links') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('app.general.btn.cancel') }}
+
+
+ {{ $t('app.general.btn.save') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/AppNavExternalItem.vue b/src/components/ui/AppNavExternalItem.vue
new file mode 100644
index 0000000000..25301f8405
--- /dev/null
+++ b/src/components/ui/AppNavExternalItem.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/AppNavItem.vue b/src/components/ui/AppNavItem.vue
index 273c6a0b20..2ec57acb48 100644
--- a/src/components/ui/AppNavItem.vue
+++ b/src/components/ui/AppNavItem.vue
@@ -1,16 +1,18 @@
{{ icon }}
@@ -36,7 +38,6 @@ import { Component, Mixins, Prop } from 'vue-property-decorator'
import StateMixin from '@/mixins/state'
import BrowserMixin from '@/mixins/browser'
-import { eventTargetIsContentEditable, keyboardEventToKeyboardShortcut } from '@/util/event-helpers'
import { Globals } from '@/globals'
import isKeyOf from '@/util/is-key-of'
@@ -54,6 +55,12 @@ export default class AppNavItem extends Mixins(StateMixin, BrowserMixin) {
@Prop({ type: String })
readonly icon?: string
+ @Prop({ type: Boolean, default: false })
+ readonly hideTooltip!: boolean
+
+ @Prop({ type: String })
+ readonly dataId?: string
+
get accelerator (): string | undefined {
return isKeyOf(this.to, Globals.KEYBOARD_SHORTCUTS)
? Globals.KEYBOARD_SHORTCUTS[this.to]
@@ -63,35 +70,6 @@ export default class AppNavItem extends Mixins(StateMixin, BrowserMixin) {
get enableKeyboardShortcuts (): boolean {
return this.$typedState.config.uiSettings.general.enableKeyboardShortcuts
}
-
- handleKeyDown (event: KeyboardEvent) {
- if (
- !this.enableKeyboardShortcuts ||
- !this.accelerator
- ) {
- return
- }
-
- const shortcut = keyboardEventToKeyboardShortcut(event)
-
- if (
- shortcut === this.accelerator &&
- !eventTargetIsContentEditable(event) &&
- this.$route.name !== this.to
- ) {
- event.preventDefault()
-
- this.$router.push({ name: this.to })
- }
- }
-
- mounted () {
- window.addEventListener('keydown', this.handleKeyDown, false)
- }
-
- beforeDestroy () {
- window.removeEventListener('keydown', this.handleKeyDown)
- }
}
diff --git a/src/components/ui/AppNavLinkIcon.vue b/src/components/ui/AppNavLinkIcon.vue
new file mode 100644
index 0000000000..c469a1eb10
--- /dev/null
+++ b/src/components/ui/AppNavLinkIcon.vue
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+ {{ icon }}
+
+
+
+
diff --git a/src/globals.ts b/src/globals.ts
index 26ad60b01d..5eacb0e1fe 100644
--- a/src/globals.ts
+++ b/src/globals.ts
@@ -26,6 +26,7 @@ import {
mdiArrowUp,
mdiArrowDown,
mdiArrowLeft,
+ mdiBookmarkMultiple,
mdiArrowRight,
mdiArrowCollapseDown,
mdiViewGridOutline,
@@ -201,6 +202,8 @@ import {
mdiVariable,
mdiArrowDownBold,
mdiArrowUpBold,
+ mdiEye,
+ mdiEyeOff,
mdiDockTop,
mdiDockBottom
} from '@mdi/js'
@@ -442,6 +445,7 @@ export const Icons = Object.freeze({
printer3d: mdiPrinter3d,
printer3dNozzle: mdiPrinter3dNozzleOutline,
printer3dNozzleAlert: mdiPrinter3dNozzleAlertOutline,
+ bookmarkMultiple: mdiBookmarkMultiple,
bedMesh: mdiViewGridOutline,
host: mdiDesktopTower,
history: mdiHistory,
@@ -567,6 +571,8 @@ export const Icons = Object.freeze({
afcUnloadLane: mdiArrowUpBold,
afcLoadLane: mdiArrowDownBold,
afcEjectFilament: mdiEject,
+ eye: mdiEye,
+ eyeOff: mdiEyeOff,
})
export const Waits = Object.freeze({
diff --git a/src/locales/en.yaml b/src/locales/en.yaml
index dd9d0135f5..9ad5761d97 100644
--- a/src/locales/en.yaml
+++ b/src/locales/en.yaml
@@ -237,7 +237,7 @@ app:
add_file: Add File
add_printer: Add printer
add_to_queue: Add to Queue
- adjust_layout: Adjust dashboard layout
+ adjust_layout: Dashboard Layout
adjusted: Adjusted
all: All
auth_unsure: Unsure why you're seeing this?
@@ -261,6 +261,7 @@ app:
forgot_password: Forgotten your password?
go_to_file: Go to file
heaters_off: Heaters off
+ hide: Hide
job_queue: Job Queue
load_all: Load all
login: Login
@@ -336,6 +337,7 @@ app:
actual_time: Actual
add_camera: Add Camera
add_filter: Add Filter
+ add_nav_link: Add Navigation Link
add_preset: Add Preset
add_user: Add user
all: All
@@ -347,6 +349,8 @@ app:
change_password: Change password
clean_nozzle: Clean Nozzle
clear_all: Clear all
+ collapse_to_bookmarks: Collapse to links
+ collapse_to_more_menu: Collapse to More menu
color: Color
command: Command
confirm: Confirm
@@ -361,6 +365,7 @@ app:
disabled_while_printing: Disabled while printing
edit_camera: Edit Camera
edit_filter: Edit Filter
+ edit_nav_link: Edit Navigation Link
edit_preset: Edit Preset
edit_user: Edit user
environment_facing: Environment Facing
@@ -414,6 +419,8 @@ app:
screw_name: Screw name
screw_number: Screw %{index}
services: Services
+ show_all_in_sidebar: Show all in sidebar
+ show_in_sidebar: Show in sidebar
slicer: Slicer
smooth_time: Smooth Time
speed: Speed
@@ -478,6 +485,7 @@ app:
exists: Already exists
invalid_number: Invalid Number
invalid_url: Invalid URL
+ file_too_large: "File exceeds maximum size of %{size}"
invalid_expression: Invalid Expression
max: Max %{max}
min: Min %{min}
@@ -500,6 +508,12 @@ app:
confirm_remove_console_filter: Are you sure you want to remove the console filter %{name}?
confirm_remove_macro_category: Are you sure you want to remove the macro category %{name}?
confirm_remove_printer: Are you sure you want to remove the printer %{name}?
+ confirm_open_nav_link: >-
+ Open external link?
+ %{url}
+ confirm_remove_nav_link: Are you sure you want to remove the navigation link %{name}?
+ confirm_import_links_replace: This will replace all existing custom links with %{count} imported link(s). Continue?
+ confirm_import_links_merge: This will add %{count} link(s) to your existing custom links. Continue?
confirm_remove_thermal_preset: Are you sure you want to remove the thermal preset %{name}?
confirm_remove_user: Are you sure you want to remove user %{username}?
confirm_restore_backup: Are you sure you want to restore this backup? Moonraker will restart immediately after this request is processed.
@@ -591,6 +605,8 @@ app:
timelapse: Timelapse
tool: Tool
tune: Tune
+ bookmarks: Links
+ more: More
tooltip:
browse_metrics: Browse available metrics
estop: Emergency Stop
@@ -652,6 +668,10 @@ app:
add_camera: Add Camera
add_category: Add Category
add_filter: Add Filter
+ add_nav_link: Add Link
+ import_export_links: Import/Export
+ export_links: Export Links
+ import_links: Import Links
add_metric: Add Metric
add_thermal_preset: Add Preset
add_user: Add user
@@ -685,6 +705,7 @@ app:
auto_load_mobile_on_print_start: Automatically load file on mobile devices
auto: Auto
axes: Axes
+ built_in_icon: Built In Icon
camera_flip_x: Flip horizontally
camera_flip_y: Flip vertically
camera_fullscreen_action:
@@ -697,6 +718,13 @@ app:
camera_url_stream: Camera Url Stream
card: Card
collector: Collector
+ color: Color
+ custom_color: Custom Color
+ custom_icon: Custom Icon (SVG)
+ custom_image: Custom Image
+ custom_svg_icon: Custom SVG Icon
+ upload_image: Upload Image
+ upload_svg: Upload SVG
confirm_on_estop: Require confirm on Emergency Stop
confirm_on_power_device_change: Require confirm on Device Power changes
confirm_on_save_config_and_restart: Review Pending Configuration Changes before Save and Restart
@@ -719,6 +747,7 @@ app:
enable_xy_homing: Enable XY Homing
expression: Expression
extrusion_line_width: Extrusion Line Width
+ file: File
firmware_restart: Firmware Restart
flip_horizontal: Horizontal Flip
flip_vertical: Vertical Flip
@@ -733,6 +762,7 @@ app:
gcode_coords: Use GCode Coordinates
host_restart: Host Restart
icon: Icon
+ icon_type: Icon Type
invert_x_control: Invert X control
invert_y_control: Invert Y control
invert_z_control: Invert Z control
@@ -751,6 +781,7 @@ app:
never: Never
none: None
optional: Optional
+ position: Position
power_toggle_in_top_nav: Power toggle in top navigation
primary_color: Primary color
print_eta_calculation: Print ETA calculation
@@ -777,6 +808,7 @@ app:
show_upload_and_print: Show Upload and Print button in top navigation
solid: Solid
starts_with: Starts with
+ theme: Theme
theme_preset: Community preset
thermal_preset_gcode: GCode
thermal_preset_name: Preset Name
@@ -789,12 +821,21 @@ app:
toolhead_z_move_distances: Toolhead Z distance values
type: Type
unit: Unit
+ url: URL
warn_on_cpu_throttled: Warn if CPU throttling is detected
warn_on_stepper_driver_overheating: Warn if stepper driver is over-heating
z_adjust_values: Z Adjust values
date_format: Date format
time_format: Time format
text_sort_order: Text sort order
+ confirm_on_nav_link: Confirm Before Opening Links
+ link_icon_color: Link Icon Color
+ theme_nav_link: Theme Link
+ nav_link_target: Open In
+ nav_link_target_same_tab: Same Tab
+ nav_link_target_new_tab: New Tab
+ import_mode_replace: Replace existing links
+ import_mode_merge: Add to existing links
drag_and_drop_functionality_for_files_and_folders: Drag-and-drop functionality for files and folders
force_move_toggle_warning: Require confirm when activating FORCE_MOVE
show_manual_probe_dialog_automatically: Show Manual Probe dialog automatically
@@ -817,6 +858,7 @@ app:
gcode_preview: Gcode Preview
general: General
macros: Macros
+ navigation: Navigation Links
theme: Theme
thermal_presets: Thermal Presets
tool: Tool
@@ -836,6 +878,27 @@ app:
However, please note that these themes are not officially maintained by the respective brands.
Any issues or inquiries related to the themes should be directed to the Fluidd team.
The use of brand names and logos is for decorative and aesthetic purposes only, and no official association with or endorsement by the brands is implied.
+ nav_link_url_warning: 'See the tooltip for details.'
+ nav_link_url: >-
+ Include the protocol (e.g. https://example.com) for external links.
+ URLs without a protocol will be treated as relative paths by the browser.
+ nav_link_target: >-
+ New Tab: open this link in a new browser tab.
+ Same Tab: open this link in the current tab.
+ nav_link_custom_icon: >-
+ Upload an SVG file (max 64 KB) to use as the link icon.
+ Use "File Colors" to preserve the original SVG colors, or choose
+ theme/custom color to override with a single color.
+ nav_link_custom_image: >-
+ Upload a PNG or JPEG image (max 64 KB) to use as the link icon.
+ icon_type: >-
+ Built In Icon: Choose from Material Design icons. Supports theme or custom colors.
+ Custom SVG Icon: Upload an SVG file. Supports file colors, theme color, or custom colors.
+ Custom Image: Upload a PNG or JPEG. Color override does not apply.
+ nav_link_position: >-
+ Controls the sort order among custom and theme links.
+ Lower numbers appear higher in the sidebar.
+ Links are inserted directly above the Settings icon.
warn_on_cpu_throttled: CPU throttling can cause prints to fail
warn_on_stepper_driver_overheating: Trinamic drivers only
socket:
diff --git a/src/scss/misc.scss b/src/scss/misc.scss
index fbd06d2dbe..a8181d2a9d 100644
--- a/src/scss/misc.scss
+++ b/src/scss/misc.scss
@@ -10,28 +10,6 @@
pointer-events: none !important;
}
-// Apply a common scrollbar application to all elements inside the app
-.v-application--wrap,
-.v-dialog__content {
- ::-webkit-scrollbar {
- transition: all .5s;
- width: 5px;
- height: 8px;
- z-index: 10;
- }
-
- ::-webkit-scrollbar-track {
- background: transparent;
- }
-
- ::-webkit-scrollbar-thumb {
- background: #b3ada7;
- }
-
- ::-webkit-scrollbar-corner {
- background: transparent;
- }
-}
// Ensure the number arrows don't appear on text inputs.
/* Chrome, Safari, Edge, Opera */
diff --git a/src/store/config/actions.ts b/src/store/config/actions.ts
index ac651fc8cb..f408af6dff 100644
--- a/src/store/config/actions.ts
+++ b/src/store/config/actions.ts
@@ -1,6 +1,6 @@
import vuetify from '@/plugins/vuetify'
import type { ActionTree } from 'vuex'
-import type { ConfigState, SaveByPath, InitConfig, InstanceConfig, UiSettings, ThemeConfig, ConfiguredTableHeader } from './types'
+import type { ConfigState, SaveByPath, InitConfig, InstanceConfig, UiSettings, ThemeConfig, ConfiguredTableHeader, CustomNavLink } from './types'
import type { RootState } from '../types'
import { SocketActions } from '@/api/socketActions'
import { loadLocaleMessagesAsync, getStartingLocale } from '@/plugins/i18n'
@@ -188,18 +188,56 @@ export const actions = {
}
},
- async updateTheme ({ state, dispatch }, payload: Partial) {
+ async updateTheme ({ state, commit, dispatch }, payload: Partial) {
+ // Check if theme preset is changing (logo changed) BEFORE updating state
+ const isLogoChanging = payload.logo && payload.logo.src !== state.uiSettings.theme.logo.src
+
const updatedTheme: ThemeConfig = {
...state.uiSettings.theme,
...payload
}
- dispatch('onThemeChange', updatedTheme)
-
- dispatch('saveByPath', {
+ // Save theme FIRST (synchronously) so getters evaluate with new theme
+ commit('setSaveByPath', {
path: 'uiSettings.theme',
- value: updatedTheme,
- server: true
+ value: updatedTheme
})
+
+ // Then clear positions - getter will now return new theme's link with -1 position
+ if (isLogoChanging) {
+ commit('setThemeLinkPositions', {})
+ SocketActions.serverDatabasePostItem('uiSettings.navigation.themeLinkPositions', {})
+ }
+
+ // Apply Vuetify theme changes
+ dispatch('onThemeChange', updatedTheme)
+
+ // Persist theme to database
+ SocketActions.serverDatabasePostItem('uiSettings.theme', updatedTheme)
+ },
+
+ async updateCustomNavLink ({ commit, state }, payload: CustomNavLink) {
+ commit('setCustomNavLink', payload)
+ SocketActions.serverDatabasePostItem('uiSettings.navigation.customLinks', state.uiSettings.navigation.customLinks)
+ },
+
+ async replaceCustomNavLinks ({ commit, state }, payload: CustomNavLink[]) {
+ commit('setCustomNavLinks', payload)
+ SocketActions.serverDatabasePostItem('uiSettings.navigation.customLinks', state.uiSettings.navigation.customLinks)
+ },
+
+ async updateCustomNavLinkPositions ({ commit, state }, payload: { id: string; position: number }[]) {
+ commit('setCustomNavLinkPositions', payload)
+ SocketActions.serverDatabasePostItem('uiSettings.navigation.customLinks', state.uiSettings.navigation.customLinks)
+ },
+
+ async removeCustomNavLink ({ commit, state }, payload: { id: string }) {
+ commit('setRemoveCustomNavLink', payload)
+ SocketActions.serverDatabasePostItem('uiSettings.navigation.customLinks', state.uiSettings.navigation.customLinks)
+ },
+
+ async updateThemeLinkPositions ({ commit, state }, payload: Record) {
+ commit('setThemeLinkPositions', payload)
+ SocketActions.serverDatabasePostItem('uiSettings.navigation.themeLinkPositions', state.uiSettings.navigation.themeLinkPositions)
}
} satisfies ActionTree
diff --git a/src/store/config/getters.ts b/src/store/config/getters.ts
index bef3bacb43..3e73eab02a 100644
--- a/src/store/config/getters.ts
+++ b/src/store/config/getters.ts
@@ -1,9 +1,10 @@
import type { GetterTree } from 'vuex'
-import type { ConfigState, TemperaturePreset, TokenKeys } from './types'
+import type { ConfigState, CustomNavLink, SvgIconPath, TemperaturePreset, TokenKeys } from './types'
import type { RootState } from '../types'
import type { Heater, Fan } from '../printer/types'
import type { AppDataTableHeader } from '@/types'
import md5 from 'md5'
+import { THEME_LINK_ID_PREFIX } from '@/util/nav-link'
export const getters = {
getCurrentInstance: (state) => {
@@ -107,6 +108,59 @@ export const getters = {
}))
},
+ getThemeNavLinks: (state): CustomNavLink[] => {
+ const activePreset = state.hostConfig.themePresets.find(
+ p => p.logo.src === state.uiSettings.theme.logo.src
+ )
+ if (!activePreset) return []
+
+ // Resolve links array: prefer preset.links, fall back to legacy preset.url
+ const links = activePreset.links ??
+ (activePreset.url ? [{ title: activePreset.name, url: activePreset.url }] : [])
+
+ if (links.length === 0) return []
+
+ const baseId = `${THEME_LINK_ID_PREFIX}${activePreset.logo.src}`
+
+ return links.map((link, index) => {
+ // Resolve icon per link via fallback chain
+ const customIcon: string | undefined | SvgIconPath[] = link.icon ??
+ activePreset.logo.icon ??
+ activePreset.icon ??
+ activePreset.logo.src
+
+ // Generate stable IDs: first link keeps legacy ID for backwards compat
+ const id = index === 0 ? baseId : `${baseId}-${index}`
+
+ // Position: use stored position if available, otherwise group at top
+ const storedPosition = state.uiSettings.navigation?.themeLinkPositions?.[id]
+ const position = storedPosition ?? (-1 + index)
+
+ return {
+ id,
+ title: link.title,
+ url: link.url,
+ icon: 'openInNew',
+ customIcon,
+ position
+ }
+ })
+ },
+
+ getDbNavLinks: (state): CustomNavLink[] => {
+ return [...(state.uiSettings.navigation?.customLinks || [])]
+ .sort((a, b) => a.position - b.position)
+ },
+
+ getCustomNavLinks: (state, getters): CustomNavLink[] => {
+ const dbLinks = state.uiSettings.navigation?.customLinks || []
+ const hidden = state.uiSettings.navigation?.hiddenThemeLinks || []
+ const themeLinks = (getters.getThemeNavLinks as CustomNavLink[])
+ .filter(l => !hidden.includes(l.id))
+ const combined = [...dbLinks, ...themeLinks]
+ return combined.sort((a, b) => a.position - b.position)
+ },
+
getTokenKeys: (state) => {
const url = state.apiUrl
const hash = (url) ? md5(url) : ''
diff --git a/src/store/config/mutations.ts b/src/store/config/mutations.ts
index 88e385fb04..84ae573ec0 100644
--- a/src/store/config/mutations.ts
+++ b/src/store/config/mutations.ts
@@ -1,12 +1,13 @@
import Vue from 'vue'
import type { MutationTree } from 'vuex'
-import type { ConfigState, UiSettings, SaveByPath, InstanceConfig, ConfiguredTableHeader, HostConfig, ApiConfig } from './types'
+import type { ConfigState, UiSettings, SaveByPath, InstanceConfig, ConfiguredTableHeader, CustomNavLink, HostConfig, ApiConfig } from './types'
import { defaultState } from './state'
import { Globals } from '@/globals'
import { cloneDeep, mergeWith, set } from 'lodash-es'
import { v4 as uuidv4 } from 'uuid'
import type { FileFilterType } from '../files/types'
import { consola } from 'consola'
+import { isReservedNavLinkId } from '@/util/nav-link'
export const mutations = {
/**
@@ -51,6 +52,18 @@ export const mutations = {
(dest, src) => Array.isArray(dest) ? src : undefined
)
+ // Defensively normalise loaded custom nav links. An empty or reserved ID — e.g. an
+ // external tool writing the Moonraker DB directly with a 'preset-' prefix — would be
+ // misclassified as a non-editable theme link, so reassign a real ID on load.
+ const navLinks = mergedSettings.navigation?.customLinks
+ if (Array.isArray(navLinks)) {
+ for (const link of navLinks) {
+ if (!link.id || isReservedNavLinkId(link.id)) {
+ link.id = uuidv4()
+ }
+ }
+ }
+
Vue.set(state, 'uiSettings', mergedSettings)
}
},
@@ -208,5 +221,58 @@ export const mutations = {
setUpdateThumbnailSizes (state, payload: { name: string; size: number }) {
Vue.set(state.uiSettings.thumbnailSizes, payload.name, payload.size)
+ },
+
+ setCustomNavLink (state, payload: CustomNavLink) {
+ const link = { ...payload }
+ // Never let a stored link claim a reserved theme-link ID — it would be misclassified
+ // as a preset link and become un-editable/un-deletable.
+ if (link.id === '' || isReservedNavLinkId(link.id)) {
+ link.id = uuidv4()
+ state.uiSettings.navigation.customLinks.push(link)
+ } else {
+ const links = state.uiSettings.navigation.customLinks
+ const i = links.findIndex(l => l.id === link.id)
+ if (i >= 0) {
+ Vue.set(links, i, link)
+ } else {
+ // Upsert: an unknown ID is treated as a new link rather than silently dropped.
+ links.push(link)
+ }
+ }
+ },
+
+ setCustomNavLinks (state, payload: CustomNavLink[]) {
+ // Replace the whole collection in one mutation (used by import). Empty or reserved IDs
+ // are reassigned a fresh UUID so the stored set always satisfies the link contract.
+ const links = payload.map(link => ({
+ ...link,
+ id: (link.id === '' || isReservedNavLinkId(link.id)) ? uuidv4() : link.id
+ }))
+ Vue.set(state.uiSettings.navigation, 'customLinks', links)
+ },
+
+ setRemoveCustomNavLink (state, payload: { id: string }) {
+ const links = state.uiSettings.navigation.customLinks
+ const i = links.findIndex(link => link.id === payload.id)
+ if (i >= 0) {
+ links.splice(i, 1)
+ }
+ },
+
+ setCustomNavLinkPositions (state, payload: { id: string; position: number }[]) {
+ const links = state.uiSettings.navigation.customLinks
+ for (const { id, position } of payload) {
+ const index = links.findIndex(l => l.id === id)
+ if (index >= 0) {
+ // Use Vue.set to ensure reactivity
+ Vue.set(links[index], 'position', position)
+ }
+ }
+ },
+
+ setThemeLinkPositions (state, payload: Record) {
+ // Use Vue.set to ensure reactivity when updating nested object
+ Vue.set(state.uiSettings.navigation, 'themeLinkPositions', payload)
}
} satisfies MutationTree
diff --git a/src/store/config/state.ts b/src/store/config/state.ts
index 646858ac62..ccac335f10 100644
--- a/src/store/config/state.ts
+++ b/src/store/config/state.ts
@@ -163,6 +163,16 @@ export const defaultState = (): ConfigState => {
showLaneInfinite: true,
showUnitIcons: true,
showTd1Color: true,
+ },
+ navigation: {
+ customLinks: [],
+ hiddenThemeLinks: [],
+ collapsedSystemLinks: [],
+ systemLinkOrder: [],
+ collapsedCustomLinks: [],
+ themeLinkPositions: {},
+ confirmOnNavLink: false,
+ sidebarExpanded: false
}
}
}
diff --git a/src/store/config/types.ts b/src/store/config/types.ts
index 2f293a8434..32fe03a881 100644
--- a/src/store/config/types.ts
+++ b/src/store/config/types.ts
@@ -1,5 +1,35 @@
import type { FileFilterType } from '../files/types'
+export interface SvgIconPath {
+ d: string
+ fill?: string
+}
+
+export interface CustomNavLink {
+ id: string
+ title: string
+ url: string
+ icon: string
+ customIcon?: string | SvgIconPath[]
+ customImage?: string // base64 data URI (e.g. "data:image/png;base64,...")
+ color?: string
+ position: number
+ // Per-link open behaviour. Absent defaults to opening in a new tab; 'same-tab' keeps the
+ // current tab.
+ target?: 'same-tab' | 'new-tab'
+}
+
+export interface NavigationConfig {
+ customLinks: CustomNavLink[]
+ hiddenThemeLinks: string[]
+ collapsedSystemLinks: string[]
+ systemLinkOrder: string[]
+ collapsedCustomLinks: string[]
+ themeLinkPositions: Record
+ confirmOnNavLink: boolean
+ sidebarExpanded: boolean
+}
+
export interface ConfigState {
[key: string]: any;
apiUrl: string;
@@ -31,6 +61,7 @@ export interface UiSettings {
history: HistoryConfig;
mmu: MmuConfig;
afc: AfcConfig;
+ navigation: NavigationConfig;
}
export interface WarningsConfig {
@@ -176,10 +207,21 @@ export interface ThemePreset {
color: string;
isDark: boolean;
logo: ThemeLogo;
+ url?: string;
+ links?: ThemeLink[];
+ icon?: SvgIconPath[];
+}
+
+export interface ThemeLink {
+ title: string
+ url: string
+ icon?: string
}
export interface ThemeLogo {
src: string;
+ icon?: string;
+ background?: string;
dark?: string;
light?: string;
}
diff --git a/src/util/__tests__/nav-link.spec.ts b/src/util/__tests__/nav-link.spec.ts
new file mode 100644
index 0000000000..2166cf8ddd
--- /dev/null
+++ b/src/util/__tests__/nav-link.spec.ts
@@ -0,0 +1,61 @@
+import {
+ isReservedNavLinkId,
+ isSafeNavLinkUrl,
+ isWithinNavLinkDataUriLimit,
+ resolveNavLinkNewTab,
+ MAX_NAV_LINK_DATA_URI_LENGTH,
+ THEME_LINK_ID_PREFIX
+} from '../nav-link'
+
+describe('isReservedNavLinkId', () => {
+ it.each([
+ [`${THEME_LINK_ID_PREFIX}logo_voron.svg`, true],
+ ['preset-anything', true],
+ ['1c5f-uuid', false],
+ ['', false],
+ [undefined, false]
+ ])('treats "%s" reserved=%s', (id, expected) => {
+ expect(isReservedNavLinkId(id as string | undefined)).toBe(expected)
+ })
+})
+
+describe('isSafeNavLinkUrl', () => {
+ it.each([
+ ['https://example.com', true],
+ ['http://printer.local/spoolman', true],
+ ['/klipperfleet.html', true],
+ ['javascript:alert(1)', false],
+ [' JavaScript:alert(1)', false],
+ ['data:text/html,