Skip to content

Commit 4fc927a

Browse files
committed
release: v3.0.13
Fix gallery app crash when auto-creating groups (#262), remove silent auto-default on first gallery app, make hover-hidden action buttons always visible for touch device accessibility, and consolidate App/Group construction into shared factory functions to prevent field-omission bugs.
1 parent a9cd0bf commit 4fc927a

File tree

11 files changed

+98
-95
lines changed

11 files changed

+98
-95
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
All notable changes to Muximux are documented in this file.
44

5+
## [3.0.13] - 2026-03-08
6+
7+
### Fixed
8+
- Adding an app from the gallery that has a preset group no longer crashes with "missing 'id' property" -- the auto-created group now gets the DnD identifier that Svelte's keyed `{#each}` requires
9+
- Adding the first app from the gallery no longer silently marks it as the default homepage app -- `default` is only set explicitly during onboarding
10+
- Edit/delete buttons on app rows, keybinding combos, custom themes, and custom icons are now always visible -- previously hidden behind hover, making them inaccessible on touch devices
11+
12+
### Changed
13+
- App and Group object construction consolidated into shared `makeApp()`/`makeGroup()` factory functions and `stampAppId()`/`stampGroupId()` helpers -- eliminates duplicated defaults across 4 creation paths and prevents field-omission bugs
14+
515
## [3.0.12] - 2026-03-08
616

717
### Fixed
@@ -281,6 +291,10 @@ Muximux v3 is a complete rewrite. The original PHP bookmark portal has been repl
281291

282292
Muximux v3 is not backwards-compatible with v2. The PHP application has been replaced entirely. Start fresh with the onboarding wizard or create a new `config.yaml` from `config.example.yaml`.
283293

294+
[3.0.13]: https://github.com/mescon/Muximux/releases/tag/v3.0.13
295+
[3.0.12]: https://github.com/mescon/Muximux/releases/tag/v3.0.12
296+
[3.0.11]: https://github.com/mescon/Muximux/releases/tag/v3.0.11
297+
[3.0.10]: https://github.com/mescon/Muximux/releases/tag/v3.0.10
284298
[3.0.9]: https://github.com/mescon/Muximux/releases/tag/v3.0.9
285299
[3.0.8]: https://github.com/mescon/Muximux/releases/tag/v3.0.8
286300
[3.0.7]: https://github.com/mescon/Muximux/releases/tag/v3.0.7

sonar-project.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
sonar.projectKey=mescon_Muximux
22
sonar.organization=mescon
3-
sonar.projectVersion=3.0.12
3+
sonar.projectVersion=3.0.13
44

55
sonar.sources=.
66
sonar.exclusions=**/node_modules/**,**/dist/**,**/vendor/**,**/*_test.go,**/*.test.ts,**/*.spec.ts,**/testdata/**,**/dev/**,**/paraglide/**

web/src/components/IconBrowser.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -436,8 +436,7 @@
436436
{:else}
437437
<button
438438
class="absolute -top-1 -end-1 w-5 h-5 bg-red-500 hover:bg-red-600 rounded-full
439-
text-text-primary flex items-center justify-center opacity-0 group-hover:opacity-100
440-
transition-opacity text-xs"
439+
text-text-primary flex items-center justify-center text-xs"
441440
onclick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteIcon(icon.name); }}
442441
title={m.common_delete()}
443442
>

web/src/components/KeybindingsEditor.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@
226226
{#if binding.editable && binding.combos.length > 1}
227227
<span
228228
role="button"
229-
class="p-0.5 text-text-disabled hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
229+
class="p-0.5 text-text-disabled hover:text-red-400 cursor-pointer"
230230
onclick={(e) => { e.stopPropagation(); handleRemoveCombo(binding.action, i); }}
231231
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); handleRemoveCombo(binding.action, i); } }}
232232
tabindex="0"

web/src/components/OnboardingWizard.svelte

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { fly, fade } from 'svelte/transition';
66
import { flip } from 'svelte/animate';
77
import { dndzone, type DndEvent } from 'svelte-dnd-action';
8-
import type { App, AppIcon as AppIconConfig, Config, Group, NavigationConfig, ThemeConfig, SetupRequest } from '$lib/types';
8+
import { type App, type AppIcon as AppIconConfig, type Config, type Group, type NavigationConfig, type ThemeConfig, type SetupRequest, makeApp } from '$lib/types';
99
import { openModes } from '$lib/constants';
1010
import {
1111
currentStep,
@@ -231,19 +231,14 @@
231231
while (allNames.has(`${app.name} ${num}`)) num++;
232232
233233
const targetGroup = app.group;
234-
const newApp: App = {
234+
const newApp = makeApp({
235235
name: `${app.name} ${num}`,
236236
url: app.defaultUrl,
237-
icon: { type: 'dashboard', name: app.icon, file: '', url: '', variant: 'svg' },
237+
icon: { type: 'dashboard', name: app.icon, file: '', url: '', variant: '' },
238238
color: app.color,
239239
group: targetGroup,
240240
order: selectedCount + get(selectedApps).length,
241-
enabled: true,
242-
default: false,
243-
open_mode: 'iframe',
244-
proxy: false,
245-
scale: 1
246-
};
241+
});
247242
248243
selectedApps.update(apps => [...apps, newApp]);
249244
rebuildDndFromSelections();
@@ -380,10 +375,10 @@
380375
// Fallback when nothing is selected yet
381376
if (apps.length === 0) {
382377
return [
383-
{ name: 'Plex', color: '#E5A00D', icon: { type: 'dashboard' as const, name: 'plex', file: '', url: '', variant: 'svg' }, group: '', order: 0, url: '#', enabled: true, default: true, open_mode: 'iframe' as const, proxy: false, scale: 1 },
384-
{ name: 'Sonarr', color: '#00CCFF', icon: { type: 'dashboard' as const, name: 'sonarr', file: '', url: '', variant: 'svg' }, group: '', order: 1, url: '#', enabled: true, default: false, open_mode: 'iframe' as const, proxy: false, scale: 1 },
385-
{ name: 'Portainer', color: '#13BEF9', icon: { type: 'dashboard' as const, name: 'portainer', file: '', url: '', variant: 'svg' }, group: '', order: 2, url: '#', enabled: true, default: false, open_mode: 'iframe' as const, proxy: false, scale: 1 },
386-
{ name: 'Grafana', color: '#F46800', icon: { type: 'dashboard' as const, name: 'grafana', file: '', url: '', variant: 'svg' }, group: '', order: 3, url: '#', enabled: true, default: false, open_mode: 'iframe' as const, proxy: false, scale: 1 },
378+
makeApp({ name: 'Plex', color: '#E5A00D', icon: { type: 'dashboard', name: 'plex', file: '', url: '', variant: '' }, order: 0, url: '#', default: true }),
379+
makeApp({ name: 'Sonarr', color: '#00CCFF', icon: { type: 'dashboard', name: 'sonarr', file: '', url: '', variant: '' }, order: 1, url: '#' }),
380+
makeApp({ name: 'Portainer', color: '#13BEF9', icon: { type: 'dashboard', name: 'portainer', file: '', url: '', variant: '' }, order: 2, url: '#' }),
381+
makeApp({ name: 'Grafana', color: '#F46800', icon: { type: 'dashboard', name: 'grafana', file: '', url: '', variant: '' }, order: 3, url: '#' }),
387382
];
388383
}
389384
@@ -431,19 +426,11 @@
431426
function addCustomApp() {
432427
if (!customApp.name || !customApp.url) return;
433428
434-
const newApp: App = {
429+
const newApp = makeApp({
435430
name: customApp.name,
436431
url: customApp.url,
437-
icon: { type: 'dashboard', name: '', file: '', url: '', variant: '' },
438-
color: '#22c55e',
439-
group: '',
440432
order: selectedCount,
441-
enabled: true,
442-
default: false,
443-
open_mode: 'iframe',
444-
proxy: false,
445-
scale: 1
446-
};
433+
});
447434
448435
selectedApps.update(apps => [...apps, newApp]);
449436
customApp = { name: '', url: '' };

web/src/components/Settings.svelte

Lines changed: 21 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script lang="ts">
22
import { onMount, untrack } from 'svelte';
33
import { fade, fly } from 'svelte/transition';
4-
import type { App, Config, Group } from '$lib/types';
4+
import { type App, type Config, type Group, makeApp, makeGroup, stampAppId, stampGroupId } from '$lib/types';
55
import IconBrowser from './IconBrowser.svelte';
66
import AppForm from './AppForm.svelte';
77
import AppIcon from './AppIcon.svelte';
@@ -89,32 +89,8 @@
8989
let pendingImport = $state<ImportedConfig | null>(null);
9090
9191
// New app/group templates
92-
const newAppTemplate: App = {
93-
name: '',
94-
url: '',
95-
icon: { type: 'dashboard', name: '', file: '', url: '', variant: '' },
96-
color: '#22c55e',
97-
group: '',
98-
order: 0,
99-
enabled: true,
100-
default: false,
101-
open_mode: 'iframe',
102-
proxy: false,
103-
scale: 1,
104-
health_check: undefined,
105-
health_url: undefined,
106-
shortcut: undefined,
107-
force_icon_background: undefined,
108-
min_role: undefined,
109-
};
110-
111-
const newGroupTemplate: Group = {
112-
name: '',
113-
icon: { type: 'dashboard', name: '', file: '', url: '', variant: '' },
114-
color: '#3498db',
115-
order: 0,
116-
expanded: true
117-
};
92+
const newAppTemplate: App = makeApp();
93+
const newGroupTemplate: Group = makeGroup();
11894
11995
let newApp = $state({ ...newAppTemplate });
12096
let newGroup = $state({ ...newGroupTemplate });
@@ -132,8 +108,8 @@
132108
let groupErrors = $state<Record<string, string>>({});
133109
134110
// Assign stable `id` fields for svelte-dnd-action (must be done once, before building dnd arrays)
135-
untrack(() => localApps).forEach(a => { (a as App & Record<string, unknown>).id = a.name; });
136-
untrack(() => localConfig).groups.forEach(g => { (g as Group & Record<string, unknown>).id = g.name; });
111+
untrack(() => localApps).forEach(stampAppId);
112+
untrack(() => localConfig).groups.forEach(stampGroupId);
137113
138114
// Snapshot taken AFTER id fields are added, so hasChanges starts as false
139115
const initialConfigSnapshot = untrack(() => JSON.stringify(localConfig));
@@ -182,7 +158,7 @@
182158
for (const apps of Object.values(dndGroupedApps)) {
183159
allApps.push(...apps);
184160
}
185-
allApps.forEach(a => { (a as App & Record<string, unknown>).id = a.name; });
161+
allApps.forEach(stampAppId);
186162
localApps = allApps;
187163
if (groupName === '__rebuild__') {
188164
localConfig.groups = [...dndGroups];
@@ -255,17 +231,19 @@
255231
}
256232
appErrors = {};
257233
newApp.order = localApps.length;
258-
const app: App & Record<string, unknown> = { ...newApp };
259-
app.id = app.name;
234+
const app = { ...newApp };
235+
stampAppId(app);
260236
// Auto-create the group if it doesn't exist yet (e.g. gallery apps with preset groups)
261237
if (app.group && !localConfig.groups.some(g => g.name === app.group)) {
262-
localConfig.groups = [...localConfig.groups, {
263-
name: app.group as string,
264-
icon: { type: 'lucide', name: 'folder', file: '', url: '', variant: 'svg', background: '' },
238+
const groupName = app.group as string;
239+
const autoGroup = makeGroup({
240+
name: groupName,
241+
icon: { type: 'lucide', name: 'folder', file: '', url: '', variant: '' },
265242
color: '',
266243
order: localConfig.groups.length,
267-
expanded: true,
268-
}];
244+
});
245+
stampGroupId(autoGroup);
246+
localConfig.groups = [...localConfig.groups, autoGroup];
269247
}
270248
localApps = [...localApps, app];
271249
newApp = { ...newAppTemplate };
@@ -281,8 +259,8 @@
281259
}
282260
groupErrors = {};
283261
newGroup.order = localConfig.groups.length;
284-
const group: Group & Record<string, unknown> = { ...newGroup };
285-
group.id = group.name;
262+
const group = { ...newGroup };
263+
stampGroupId(group);
286264
localConfig.groups = [...localConfig.groups, group];
287265
newGroup = { ...newGroupTemplate };
288266
showAddGroup = false;
@@ -307,7 +285,7 @@
307285
return;
308286
}
309287
editAppErrors = {};
310-
(editingApp as App & Record<string, unknown>).id = editingApp.name;
288+
stampAppId(editingApp);
311289
// Sync DnD app changes back to localApps before rebuilding
312290
const allApps: App[] = [];
313291
for (const apps of Object.values(dndGroupedApps)) {
@@ -342,7 +320,7 @@
342320
return;
343321
}
344322
editGroupErrors = {};
345-
(editingGroup as Group & Record<string, unknown>).id = editingGroup.name;
323+
stampGroupId(editingGroup);
346324
// Sync DnD group changes back to localConfig before rebuilding
347325
localConfig.groups = [...dndGroups];
348326
}
@@ -400,8 +378,8 @@
400378
localApps = pendingImport.apps;
401379
402380
// Assign stable ids for svelte-dnd-action
403-
localApps.forEach(a => { (a as App & Record<string, unknown>).id = a.name; });
404-
localConfig.groups.forEach(g => { (g as Group & Record<string, unknown>).id = g.name; });
381+
localApps.forEach(stampAppId);
382+
localConfig.groups.forEach(stampGroupId);
405383
rebuildDndArrays();
406384
407385
showImportConfirm = false;

web/src/components/settings/AppsTab.svelte

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import { flip } from 'svelte/animate';
3-
import type { App, Group } from '$lib/types';
3+
import { type App, type Group, stampAppId } from '$lib/types';
44
import AppIcon from '../AppIcon.svelte';
55
import { dndzone, type DndEvent } from 'svelte-dnd-action';
66
import * as m from '$lib/paraglide/messages.js';
@@ -92,7 +92,7 @@
9292
}
9393
function handleAppDndFinalize(e: CustomEvent<DndEvent<App>>, groupName: string) {
9494
const newItems = e.detail.items;
95-
newItems.forEach((a, i) => { a.group = groupName; a.order = i; (a as App & Record<string, unknown>).id = a.name; });
95+
newItems.forEach((a, i) => { a.group = groupName; a.order = i; stampAppId(a); });
9696
dndGroupedApps[groupName] = newItems;
9797
onsyncAppOrder(groupName, newItems);
9898
}
@@ -151,16 +151,14 @@
151151
onclick={() => confirmDeleteApp = null}>{m.common_no()}</button>
152152
</div>
153153
{:else}
154-
<div class="flex items-center gap-1 opacity-0 group-hover/app:opacity-100 focus-within:opacity-100 transition-opacity app-actions">
154+
<div class="flex items-center gap-1 app-actions">
155155
<button class="btn btn-ghost btn-icon btn-sm"
156-
tabindex="-1"
157156
onclick={() => onstartEditApp(app)} title={m.common_edit()}>
158157
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
159158
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
160159
</svg>
161160
</button>
162161
<button class="btn btn-ghost btn-icon btn-sm hover:!text-red-400"
163-
tabindex="-1"
164162
onclick={() => handleDeleteApp(app)} title={m.common_delete()}>
165163
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
166164
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />

web/src/components/settings/ThemeTab.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -214,9 +214,8 @@
214214
<div class="absolute top-3 end-3 flex items-center gap-1">
215215
{#if isCustom}
216216
<button
217-
class="w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
217+
class="w-5 h-5 rounded-full flex items-center justify-center"
218218
style="background: var(--status-error); color: white;"
219-
tabindex="-1"
220219
onclick={(e: MouseEvent) => { e.stopPropagation(); handleDeleteTheme(family.darkTheme?.id || family.lightTheme?.id || ''); }}
221220
title={m.theme_deleteTheme()}
222221
aria-label={m.theme_deleteTheme()}

web/src/lib/popularApps.test.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,11 @@ describe('popularApps', () => {
101101
expect(app.url).toBe('http://localhost:32400/web');
102102
});
103103

104-
it('sets first app as default', () => {
105-
const app = templateToApp(template, 'http://test', 0);
106-
expect(app.default).toBe(true);
107-
});
108-
109-
it('sets non-first apps as not default', () => {
110-
const app = templateToApp(template, 'http://test', 1);
111-
expect(app.default).toBe(false);
104+
it('does not auto-set default flag', () => {
105+
const first = templateToApp(template, 'http://test', 0);
106+
const second = templateToApp(template, 'http://test', 1);
107+
expect(first.default).toBe(false);
108+
expect(second.default).toBe(false);
112109
});
113110

114111
it('creates correct icon structure', () => {
@@ -117,7 +114,7 @@ describe('popularApps', () => {
117114
expect(app.icon.name).toBe('plex');
118115
expect(app.icon.file).toBe('');
119116
expect(app.icon.url).toBe('');
120-
expect(app.icon.variant).toBe('svg');
117+
expect(app.icon.variant).toBe('');
121118
expect(app.icon.background).toBe('#2D2200');
122119
});
123120
});

web/src/lib/popularApps.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { App } from './types';
1+
import { type App, makeApp } from './types';
22
import * as m from '$lib/paraglide/messages.js';
33

44
export interface PopularAppTemplate {
@@ -719,24 +719,19 @@ export function getAllGroups(): string[] {
719719

720720
// Convert a template to an App object
721721
export function templateToApp(template: PopularAppTemplate, url: string, order: number): App {
722-
return {
722+
return makeApp({
723723
name: template.name,
724724
url: url || template.defaultUrl,
725725
icon: {
726726
type: template.iconType || 'dashboard',
727727
name: template.icon,
728728
file: '',
729729
url: '',
730-
variant: 'svg',
730+
variant: '',
731731
background: template.iconBackground
732732
},
733733
color: template.color,
734734
group: template.group,
735735
order,
736-
enabled: true,
737-
default: order === 0, // First app is default
738-
open_mode: 'iframe',
739-
proxy: false,
740-
scale: 1
741-
};
736+
});
742737
}

0 commit comments

Comments
 (0)