Skip to content

Commit 9775818

Browse files
mrmeesclaude
andcommitted
feat: add user-configurable custom navigation links in sidebar
Allow users to add external links to the sidebar navigation via Settings. Each link has a title, URL, icon (from existing MDI set), open behavior (new tab / same tab), and sort position. Settings persist to Moonraker's database and sync across browsers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b5fd4b6 commit 9775818

File tree

20 files changed

+1210
-62
lines changed

20 files changed

+1210
-62
lines changed

components.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ declare module 'vue' {
4242
AppNamedSwitch: typeof import('./src/components/ui/AppNamedSwitch.vue')['default']
4343
AppNamedTextField: typeof import('./src/components/ui/AppNamedTextField.vue')['default']
4444
AppNavDrawer: typeof import('./src/components/layout/AppNavDrawer.vue')['default']
45+
AppNavExternalItem: typeof import('./src/components/ui/AppNavExternalItem.vue')['default']
4546
AppNavItem: typeof import('./src/components/ui/AppNavItem.vue')['default']
47+
AppNavLinkIcon: typeof import('./src/components/ui/AppNavLinkIcon.vue')['default']
4648
AppNotificationMenu: typeof import('./src/components/layout/AppNotificationMenu.vue')['default']
4749
AppObservedColumn: typeof import('./src/components/layout/AppObservedColumn.vue')['default']
4850
AppQrCode: typeof import('./src/components/ui/AppQrCode.vue')['default']
@@ -79,6 +81,7 @@ declare module 'vue' {
7981
VAlert: typeof import('vuetify/lib')['VAlert']
8082
VApp: typeof import('vuetify/lib')['VApp']
8183
VAppBar: typeof import('vuetify/lib')['VAppBar']
84+
VAutocomplete: typeof import('vuetify/lib')['VAutocomplete']
8285
VBadge: typeof import('vuetify/lib')['VBadge']
8386
VBtn: typeof import('vuetify/lib')['VBtn']
8487
VBtnToggle: typeof import('vuetify/lib')['VBtnToggle']

public/config.json

Lines changed: 239 additions & 28 deletions
Large diffs are not rendered by default.

public/logo_eva.svg

Lines changed: 1 addition & 1 deletion
Loading

public/logo_ratrig.svg

Lines changed: 1 addition & 1 deletion
Loading

public/logo_z-bolt.svg

Lines changed: 1 addition & 1 deletion
Loading

server/config.json

Lines changed: 246 additions & 27 deletions
Large diffs are not rendered by default.

src/components/layout/AppNavDrawer.vue

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@
105105
{{ $t('app.general.title.system') }}
106106
</app-nav-item>
107107

108+
<app-nav-external-item
109+
v-for="link in customNavLinks"
110+
:key="link.id"
111+
:icon="`$${link.icon}`"
112+
:custom-icon="link.customIcon"
113+
:color="link.color"
114+
:url="link.url"
115+
:confirm="confirmOnNavLink"
116+
>
117+
{{ link.title }}
118+
</app-nav-external-item>
119+
108120
<app-nav-item
109121
icon="$cog"
110122
to="settings"
@@ -151,6 +163,7 @@ import { Component, Mixins, VModel } from 'vue-property-decorator'
151163
152164
import StateMixin from '@/mixins/state'
153165
import BrowserMixin from '@/mixins/browser'
166+
import type { CustomNavLink } from '@/store/config/types'
154167
155168
@Component({})
156169
export default class AppNavDrawer extends Mixins(StateMixin, BrowserMixin) {
@@ -165,6 +178,14 @@ export default class AppNavDrawer extends Mixins(StateMixin, BrowserMixin) {
165178
return this.$typedGetters['server/componentSupport']('timelapse')
166179
}
167180
181+
get customNavLinks (): CustomNavLink[] {
182+
return this.$typedGetters['config/getCustomNavLinks']
183+
}
184+
185+
get confirmOnNavLink (): boolean {
186+
return this.$typedState.config.uiSettings.navigation?.confirmOnNavLink ?? false
187+
}
188+
168189
get enableDiagnostics (): boolean {
169190
return this.$typedState.config.uiSettings.general.enableDiagnostics
170191
}

src/components/layout/AppSettingsNav.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export default class AppSettingsNav extends Vue {
4343
{ name: this.$t('app.setting.title.file_editor'), hash: '#editor', visible: true },
4444
{ name: this.$t('app.setting.title.macros'), hash: '#macros', visible: true },
4545
{ name: this.$tc('app.setting.title.camera', 2), hash: '#camera', visible: true },
46+
{ name: this.$t('app.setting.title.navigation'), hash: '#navigation', visible: true },
4647
{ name: this.$t('app.setting.title.tool'), hash: '#toolhead', visible: true },
4748
{ name: this.$t('app.setting.title.thermal_presets'), hash: '#presets', visible: true },
4849
{ name: this.$t('app.setting.title.gcode_preview'), hash: '#gcodePreview', visible: true },
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<template>
2+
<app-dialog
3+
v-model="open"
4+
:title="isEdit ? $t('app.general.label.edit_nav_link') : $t('app.general.label.add_nav_link')"
5+
max-width="500"
6+
:save-button-text="isEdit ? $t('app.general.btn.save') : $t('app.general.btn.add')"
7+
@save="handleSave"
8+
>
9+
<v-card-text class="pa-0">
10+
<app-setting :title="$t('app.setting.label.name')">
11+
<v-text-field
12+
v-model="link.title"
13+
:rules="[$rules.required]"
14+
hide-details="auto"
15+
filled
16+
dense
17+
/>
18+
</app-setting>
19+
20+
<v-divider />
21+
22+
<app-setting>
23+
<template #title>
24+
<span>{{ $t('app.setting.label.url') }}</span>
25+
<app-inline-help
26+
bottom
27+
small
28+
:tooltip="$t('app.setting.tooltip.nav_link_url')"
29+
/>
30+
</template>
31+
<v-text-field
32+
v-model="link.url"
33+
:rules="[$rules.required, urlSafe]"
34+
hide-details="auto"
35+
filled
36+
dense
37+
/>
38+
</app-setting>
39+
40+
<v-divider />
41+
42+
<app-setting :title="$t('app.setting.label.icon')">
43+
<v-autocomplete
44+
v-model="link.icon"
45+
:items="iconItems"
46+
:rules="link.customIcon ? [] : [$rules.required]"
47+
:disabled="!!link.customIcon"
48+
hide-details="auto"
49+
filled
50+
dense
51+
>
52+
<template #item="{ item }">
53+
<v-icon
54+
class="mr-2"
55+
small
56+
>
57+
${{ item.value }}
58+
</v-icon>
59+
{{ item.text }}
60+
</template>
61+
<template #selection="{ item }">
62+
<v-icon
63+
class="mr-2"
64+
small
65+
>
66+
${{ item.value }}
67+
</v-icon>
68+
{{ item.text }}
69+
</template>
70+
</v-autocomplete>
71+
</app-setting>
72+
73+
<v-divider />
74+
75+
<app-setting>
76+
<template #title>
77+
<span>{{ $t('app.setting.label.custom_icon') }}</span>
78+
<app-inline-help
79+
bottom
80+
small
81+
:tooltip="$t('app.setting.tooltip.nav_link_custom_icon')"
82+
/>
83+
</template>
84+
<v-text-field
85+
v-model="link.customIcon"
86+
:placeholder="$t('app.setting.label.custom_icon_hint')"
87+
hide-details="auto"
88+
filled
89+
dense
90+
/>
91+
</app-setting>
92+
93+
<v-divider />
94+
95+
<app-setting :title="$t('app.setting.label.link_icon_color')">
96+
<v-checkbox
97+
:input-value="link.color === 'theme'"
98+
:label="$t('app.setting.label.use_theme_color')"
99+
hide-details
100+
class="mr-2 mt-0 pt-0"
101+
@change="link.color = $event ? 'theme' : undefined"
102+
/>
103+
<app-btn
104+
v-if="link.color && link.color !== 'theme'"
105+
icon
106+
@click="link.color = undefined"
107+
>
108+
<v-icon dense>
109+
$reset
110+
</v-icon>
111+
</app-btn>
112+
<app-color-picker
113+
:value="link.color && link.color !== 'theme' ? link.color : ''"
114+
:disabled="link.color === 'theme'"
115+
dot
116+
:title="$t('app.setting.label.link_icon_color')"
117+
@input="link.color = $event || undefined"
118+
/>
119+
</app-setting>
120+
121+
<v-divider />
122+
123+
<app-setting>
124+
<template #title>
125+
<span>{{ $t('app.setting.label.position') }}</span>
126+
<app-inline-help
127+
bottom
128+
small
129+
:tooltip="$t('app.setting.tooltip.nav_link_position')"
130+
/>
131+
</template>
132+
<v-text-field
133+
v-model.number="link.position"
134+
:rules="[$rules.required, $rules.numberValid]"
135+
hide-details="auto"
136+
type="number"
137+
filled
138+
dense
139+
/>
140+
</app-setting>
141+
</v-card-text>
142+
</app-dialog>
143+
</template>
144+
145+
<script lang="ts">
146+
import { Component, Vue, Prop, VModel } from 'vue-property-decorator'
147+
import type { CustomNavLink } from '@/store/config/types'
148+
import { Icons } from '@/globals'
149+
150+
@Component({})
151+
export default class NavLinkDialog extends Vue {
152+
@VModel({ type: Boolean })
153+
open?: boolean
154+
155+
@Prop({ type: Object, required: true })
156+
readonly link!: CustomNavLink
157+
158+
get isEdit (): boolean {
159+
return this.link.id !== ''
160+
}
161+
162+
get iconItems () {
163+
return Object.keys(Icons)
164+
.sort()
165+
.map(key => ({
166+
text: key,
167+
value: key
168+
}))
169+
}
170+
171+
get urlSafe () {
172+
return (v: string) => {
173+
const trimmed = v.trim().toLowerCase()
174+
if (/^(javascript|data|vbscript):/i.test(trimmed)) {
175+
return this.$t('app.general.simple_form.error.invalid_url')
176+
}
177+
return true
178+
}
179+
}
180+
181+
handleSave () {
182+
this.$emit('save', this.link)
183+
this.open = false
184+
}
185+
}
186+
</script>

0 commit comments

Comments
 (0)