fix: improve device group logic

fix: timeout not updating
feat: use resolve() for internal paths
This commit is contained in:
Maxi Quoß
2025-09-04 20:36:05 +02:00
parent b1a4a6ce14
commit 75969b037e
16 changed files with 1607 additions and 670 deletions

View File

@@ -33,7 +33,7 @@
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.38.6",
"svelte-check": "^4.3.1",
"tailwindcss": "^4.1.12",
"tailwindcss": "^4.1.13",
"tslib": "^2.8.1",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
@@ -46,7 +46,7 @@
},
"dependencies": {
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/postcss": "^4.1.13",
"cron-parser": "^5.3.1",
"date-fns": "^4.1.0",
"pocketbase": "^0.26.2",

2121
frontend/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { nextCronDate } from '$lib/helpers/cron';
import { m } from '$lib/paraglide/messages';
import { dateFnsLocale } from '$lib/stores/locale';
@@ -43,7 +44,7 @@
{
text: m.device_card_btn_more_edit(),
icon: faPen,
onClick: () => goto(`/device/${device.id}`),
onClick: () => goto(resolve(`/device/${device.id}`)),
requires: $pocketbase.authStore.isSuperuser || $permission.update?.includes(device.id)
}
]);
@@ -90,6 +91,7 @@
<div class="card bg-base-200 shadow-sm" transition:scale={{ delay: 0, duration: 200 }}>
<div class="card-body p-6">
{#if device.link !== ''}
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={device.link} target="_blank">
<h1 class="link card-title">{device.name}</h1>
</a>
@@ -100,7 +102,7 @@
<p class="grow-0">{device.description}</p>
{/if}
<div class="card rounded-box w-full">
<DeviceCardNic {device} />
<DeviceCardNic bind:device />
</div>
{#if device.wake_cron_enabled || device.shutdown_cron_enabled || device.password}
<div class="mt-1 flex flex-row flex-wrap gap-2">

View File

@@ -9,7 +9,7 @@
let { device = $bindable() }: { device: Device } = $props();
let hoverText = $state('');
let disabled = $state(false);
let timeout = 120;
let timeout = $state(120);
let interval: number;
let modalWake: HTMLDialogElement;
let modalShutdown: HTMLDialogElement;
@@ -194,12 +194,14 @@
</div>
{/if}
{#if port.link}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={port.link}
target="_blank"
class="underline"
onclick={(e) => e.stopPropagation()}>{port.name} ({port.number})</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
{port.name} ({port.number})
{/if}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DeviceFormPort from '$lib/components/DeviceFormPort.svelte';
import { nextCronDate, parseCron as validateCron } from '$lib/helpers/cron';
import { m } from '$lib/paraglide/messages';
@@ -68,7 +69,7 @@
.update(device.id, device)
.then(() => {
toast.success(m.toasts_device_updated({ device: device.name }));
goto('/');
goto(resolve('/'));
})
.catch((err) => {
toast.error(err.message);
@@ -84,7 +85,7 @@
.create(device)
.then(() => {
toast.success(m.toasts_device_created({ device: device.name }));
goto('/');
goto(resolve('/'));
})
.catch((err) => {
toast.error(err.message);
@@ -127,7 +128,7 @@
.delete(device.id)
.then(() => {
toast.success(m.toasts_device_deleted({ device: device.name }));
goto('/');
goto(resolve('/'));
})
.catch((err) => {
toast.error(err.message);

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { m } from '$lib/paraglide/messages';
import { backendUrl, permission, pocketbase } from '$lib/stores/pocketbase';
@@ -73,7 +74,7 @@
await $pocketbase.collection('ports').unsubscribe('*');
await $pocketbase.collection('permissions').unsubscribe('*');
$pocketbase.authStore.clear();
goto('/login', { invalidateAll: true });
goto(resolve('/login'), { invalidateAll: true });
}
</script>
@@ -110,14 +111,14 @@
</div>
{/if}
<li>
<a href="/" class="px-4 py-2" class:active={$page.url.pathname === '/'}
<a href={resolve('/')} class="px-4 py-2" class:active={$page.url.pathname === '/'}
><Fa icon={faHome} />{m.home_page_title()}</a
>
</li>
{#if $pocketbase.authStore.isSuperuser}
<li>
<a
href="/users"
href={resolve('/users')}
class="px-4 py-2"
class:active={$page.url.pathname.startsWith('/users')}
><Fa icon={faUsersGear} />{m.users_page_title()}</a
@@ -125,7 +126,7 @@
</li>
<li>
<a
href="/settings/"
href={resolve('/settings/')}
class="px-4 py-2"
class:active={$page.url.pathname.startsWith('/settings')}
><Fa icon={faCog} />{m.settings_page_title()}</a
@@ -168,7 +169,7 @@
</li>
</ul>
</div>
<a class="btn btn-ghost h-full border-0 px-2 text-xl" href="/">
<a class="btn btn-ghost h-full border-0 px-2 text-xl" href={resolve('/')}>
<img
src={$settingsPub?.id && $settingsPub?.favicon
? `${backendUrl}api/files/settings_public/${$settingsPub?.id}/${$settingsPub?.favicon}`
@@ -185,19 +186,22 @@
{/if}
<ul class="menu menu-horizontal h-full gap-1 px-1">
<li class="h-full">
<a href="/" class="p-2" class:menu-active={$page.url.pathname === '/'}
<a href={resolve('/')} class="p-2" class:menu-active={$page.url.pathname === '/'}
><Fa icon={faHome} />{m.home_page_title()}</a
>
</li>
{#if $pocketbase.authStore.isSuperuser}
<li class="h-full">
<a href="/users" class="p-2" class:menu-active={$page.url.pathname.startsWith('/users')}
<a
href={resolve('/users')}
class="p-2"
class:menu-active={$page.url.pathname.startsWith('/users')}
><Fa icon={faUsersGear} />{m.users_page_title()}</a
>
</li>
<li class="h-full">
<a
href="/settings/"
href={resolve('/settings/')}
class="p-2"
class:menu-active={$page.url.pathname.startsWith('/settings')}
><Fa icon={faCog} />{m.settings_page_title()}</a
@@ -242,7 +246,7 @@
<div class="ms-auto justify-end">
{#if $pocketbase.authStore?.record !== null}
{#if $pocketbase.authStore.isSuperuser || $permission.create}
<a class="btn btn-success me-4" href="/device/new">
<a class="btn btn-success me-4" href={resolve('/device/new')}>
<Fa icon={faPlus} />
{m.navbar_new()}
</a>
@@ -263,7 +267,7 @@
: $pocketbase.authStore.record?.username}
</li>
<li>
<a href="/account"><Fa icon={faUserGear} />{m.navbar_edit_account()}</a>
<a href={resolve('/account')}><Fa icon={faUserGear} />{m.navbar_edit_account()}</a>
</li>
<li>
<div onclick={async () => logout()} onkeydown={async () => logout()} role="none">

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import PageLoading from '$lib/components/PageLoading.svelte';
import { m } from '$lib/paraglide/messages';
import { localeStore } from '$lib/stores/locale';
@@ -113,7 +114,7 @@
})
);
toast.success(m.toasts_devices_created_multiple({ count: count }));
goto('/');
goto(resolve('/'));
}
</script>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Navbar from '$lib/components/Navbar.svelte';
import Transition from '$lib/components/Transition.svelte';
import { m } from '$lib/paraglide/messages.js';
import { locales, localizeHref } from '$lib/paraglide/runtime';
import { backendUrl, permission, pocketbase } from '$lib/stores/pocketbase';
import { settingsPub } from '$lib/stores/settings';
import type { Permission } from '$lib/types/permission';
@@ -50,7 +50,7 @@
// redirect to welcome page if setup is not completed
if ($settingsPub.setup_completed === false && page.url.pathname !== '/welcome') {
$pocketbase.authStore.clear();
goto('/welcome');
goto(resolve('/welcome'));
return;
}
@@ -64,7 +64,7 @@
const status = err?.status << 0;
if (status == 401 || status == 403) {
$pocketbase.authStore.clear();
goto('/login');
goto(resolve('/login'));
} else {
console.log('NOT CLEARED');
}
@@ -78,7 +78,7 @@
const status = err?.status << 0;
if (status == 401 || status == 403) {
$pocketbase.authStore.clear();
goto('/login');
goto(resolve('/login'));
}
});
}
@@ -110,9 +110,3 @@
<slot />
</div>
</Transition>
<div class="hidden">
{#each locales as locale (locale)}
<a href={localizeHref(page.url.pathname, { locale })}>{locale}</a>
{/each}
</div>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { browser } from '$app/environment';
import { resolve } from '$app/paths';
import DeviceCard from '$lib/components/DeviceCard.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
import { m } from '$lib/paraglide/messages';
@@ -26,7 +27,10 @@
let searchInput: HTMLInputElement | undefined = $state();
let filteredDevices: Device[] = $derived([]);
let devicesWithoutGroups: Device[] = $derived([]);
let devicesWithGroup: Record<string, Device[]> = $derived({});
let devicesWithGroup: {
group: Group | null;
devices: Device[];
}[] = $derived([]);
const isMac = /Mac|iPhone|iPad/.test(navigator.userAgent);
const gridClass = 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4';
@@ -64,15 +68,21 @@
.sort((a, b) => a[orderBy].localeCompare(b[orderBy], $localeStore, { numeric: true }));
});
$effect(() => {
devicesWithGroup = filteredDevices.reduce(
(groups, dev) => {
dev.expand?.groups?.forEach((group: Group) => {
groups[group.id] = [...(groups[group.id] || []), dev];
});
return groups;
},
{} as Record<string, Device[]>
);
const groups: Record<string, { group: Group; devices: Device[] }> = {};
filteredDevices.forEach((dev) => {
if (dev.expand?.groups?.length) {
for (const g of dev.expand.groups) {
if (!groups[g.id]) groups[g.id] = { group: g, devices: [] };
groups[g.id].devices.push(dev);
}
}
});
devicesWithGroup = Object.values(groups).map((g) => ({
group: g.group,
devices: g.devices.sort((a, b) =>
a[orderBy].localeCompare(b[orderBy], $localeStore, { numeric: true })
)
}));
});
function getAllDevices() {
@@ -170,39 +180,40 @@
</button>
</div>
</div>
{#if orderByGroups}
<div class="space-y-6">
{#if devicesWithoutGroups.length > 0}
<div class={gridClass}>
{#each devicesWithoutGroups as device (device.id)}
<DeviceCard {device} />
{#each devicesWithoutGroups as device, i (device.id)}
<DeviceCard bind:device={devicesWithoutGroups[i]} />
{/each}
</div>
{/if}
{#each Object.entries(devicesWithGroup).sort( ([a], [b]) => a.localeCompare( b, $localeStore, { numeric: true } ) ) as [group, groupDevices], i (i)}
{#each devicesWithGroup.sort( (a, b) => (a.group?.name ?? '').localeCompare( b.group?.name ?? '', $localeStore, { numeric: true } ) ) as { group, devices } (group?.id)}
<div>
<h1 class="mb-3 text-2xl font-bold">
{groupDevices[0].expand.groups.find((grp) => grp.id === group)?.name ||
'Unknown group name'}
<button class="btn btn-sm btn-success btn-soft" onclick={() => wakeGroup(group)}
{group?.name ?? 'Unknown group name'}
<button
class="btn btn-sm btn-success btn-soft"
onclick={() => wakeGroup(group?.id ?? '')}
><Fa icon={faPowerOff} /> {m.home_wake_group()}</button
>
</h1>
<div class={gridClass}>
{#each groupDevices.sort( (a, b) => a[orderBy].localeCompare( b[orderBy], $localeStore, { numeric: true } ) ) as device (device.id)}
<DeviceCard {device} />
{#each devices as device, i (device.id)}
<DeviceCard bind:device={devices[i]} />
{/each}
</div>
</div>
{/each}
</div>
{:else}
{@const sorted = $state
.snapshot(filteredDevices)
.sort((a, b) => a[orderBy].localeCompare(b[orderBy], $localeStore, { numeric: true }))}
<div class={gridClass}>
{#each $state
.snapshot(filteredDevices)
.sort( (a, b) => a[orderBy].localeCompare( b[orderBy], $localeStore, { numeric: true } ) ) as device (device.id)}
<DeviceCard {device} />
{#each sorted as device, i (device.id)}
<DeviceCard bind:device={sorted[i]} />
{/each}
</div>
{/if}
@@ -215,7 +226,9 @@
<h3 class="font-bold">{m.home_no_devices()}</h3>
<div class="text-xs">{m.home_add_first_device()}</div>
</div>
<a href="/device/new" class="btn btn-sm"><Fa icon={faPlus} />{m.home_add_first_device()}</a>
<a href={resolve('/device/new')} class="btn btn-sm"
><Fa icon={faPlus} />{m.home_add_first_device()}</a
>
{:else}
<span>
{m.home_grant_permissions()}

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { m } from '$lib/paraglide/messages';
import type { Locale } from '$lib/paraglide/runtime';
import { getLocale, locales, setLocale } from '$lib/paraglide/runtime';
@@ -111,7 +112,7 @@
if (data.ok) {
toast.success(m.toasts_password_changed());
$pocketbase.authStore.clear();
goto('/login');
goto(resolve('/login'));
} else {
const j = await data.json();
if (j?.data?.password?.message) {

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import DeviceForm from '$lib/components/DeviceForm.svelte';
import PageLoading from '$lib/components/PageLoading.svelte';
@@ -15,7 +16,7 @@
toast(m.toasts_no_permission({ url: page.url.pathname }), {
icon: '⛔'
});
goto('/');
goto(resolve('/'));
}
}
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import DeviceForm from '$lib/components/DeviceForm.svelte';
import NetworkScan from '$lib/components/NetworkScan.svelte';
@@ -51,7 +52,7 @@
toast(m.toasts_no_permission({ url: $page.url.pathname }), {
icon: '⛔'
});
goto('/');
goto(resolve('/'));
}
}
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { toggleVisibility } from '$lib/helpers/forms';
import { m } from '$lib/paraglide/messages';
import { backendUrl, pocketbase } from '$lib/stores/pocketbase';
@@ -18,7 +19,7 @@
onMount(() => {
if ($pocketbase.authStore.isValid) {
goto('/');
goto(resolve('/'));
}
});
@@ -27,14 +28,14 @@
.collection('_superusers')
.authWithPassword(form.email, form.password)
.then(() => {
goto('/');
goto(resolve('/'));
})
.catch(() => {
$pocketbase
.collection('users')
.authWithPassword(form.email, form.password)
.then(() => {
goto('/');
goto(resolve('/'));
})
.catch((err) => {
toast.error(err.message);
@@ -47,7 +48,7 @@
.collection('users')
.authWithOAuth2({ provider: provider.name })
.then(() => {
goto('/');
goto(resolve('/'));
})
.catch((err) => {
console.log(err);

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { PUBLIC_VERSION } from '$env/static/public';
import PageLoading from '$lib/components/PageLoading.svelte';
@@ -24,7 +25,7 @@
toast(m.toasts_no_permission({ url: page.url.pathname }), {
icon: '⛔'
});
goto('/');
goto(resolve('/'));
return;
}
@@ -95,7 +96,7 @@
.then((res) => {
toast.success(m.toasts_settings_saved());
settingsPriv.set(res as SettingsPrivate);
goto('/');
goto(resolve('/'));
})
.catch((err) => {
toast.error(err.message);

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import PageLoading from '$lib/components/PageLoading.svelte';
import { m } from '$lib/paraglide/messages';
@@ -39,7 +40,7 @@
toast(m.toasts_no_permission({ url: $page.url.pathname }), {
icon: '⛔'
});
goto('/');
goto(resolve('/'));
return;
}
});
@@ -216,7 +217,7 @@
{#if devices.length === 0}
<p>
{m.home_no_devices()}
<a href="/device/new" class="link">{m.users_create_new_device()}</a>
<a href={resolve('/device/new')} class="link">{m.users_create_new_device()}</a>
</p>
{:else}
<div class="grid grid-cols-4 justify-items-center gap-4 md:grid-cols-5">

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { toggleVisibility } from '$lib/helpers/forms';
import { m } from '$lib/paraglide/messages.js';
import { pocketbase } from '$lib/stores/pocketbase';
@@ -76,7 +77,7 @@
<h2 class="card-title">{m.welcome_not_expected_title()}</h2>
<p>{m.welcome_not_expected_desc()}</p>
<div class="card-actions justify-end">
<button class="btn btn-success" onclick={() => goto('/')}
<button class="btn btn-success" onclick={() => goto(resolve('/'))}
>{m.welcome_not_expected_back()}</button
>
</div>
@@ -169,7 +170,7 @@
<h2 class="card-title">{m.welcome_step3_page_title()}</h2>
<p>{m.welcome_step3_page_desc()}</p>
<div class="card-actions justify-end">
<button class="btn btn-success" onclick={() => goto('/')}
<button class="btn btn-success" onclick={() => goto(resolve('/'))}
>{m.welcome_step3_btn_done()}</button
>
</div>