Geräte als Favoriten markieren und benennen

- Favoriten-Stern je Gerät; favorisierte Geräte werden in der Liste oben
  einsortiert. Favorit + eigener Name bleiben im Protokoll erhalten, auch
  ohne gespeicherten Scan-Snapshot
- Gerät umbenennen (customName) über neuen TextPromptDialog
- toggleFavorite / renameDevice in protocols.ts
- TextPromptDialog + ConfirmDialog: schlanke Modal-Komponenten als Ersatz
  für die verbotenen Browser-prompt()/confirm(); melden sich als Overlay an
  (Hardware-Backbutton schließt sie)
- Protokoll-Löschen nutzt jetzt ConfirmDialog statt confirm()

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 22:52:23 +02:00
parent 50793e4e5d
commit 484b5f96fa
4 changed files with 197 additions and 5 deletions

View file

@ -0,0 +1,56 @@
<script lang="ts">
/**
* Bestätigungs-Modal — ersetzt das im Projekt verbotene `confirm()`.
* Meldet sich als Overlay an, damit der Hardware-Backbutton es schließt.
*/
import { onMount, onDestroy } from 'svelte';
import { pushOverlay } from '$lib/overlay.svelte';
let {
title,
message = '',
confirmLabel = 'OK',
danger = false,
onconfirm,
oncancel,
}: {
title: string;
message?: string;
confirmLabel?: string;
danger?: boolean;
onconfirm: () => void;
oncancel: () => void;
} = $props();
let off: (() => void) | undefined;
onMount(() => {
off = pushOverlay(oncancel);
});
onDestroy(() => off?.());
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) oncancel();
}}
>
<div class="w-full max-w-sm rounded-lg bg-zinc-900 p-4">
<h2 class="text-sm font-semibold text-zinc-200">{title}</h2>
{#if message}<p class="mt-1 text-xs text-zinc-400">{message}</p>{/if}
<div class="mt-3 flex justify-end gap-2">
<button class="rounded px-3 py-1.5 text-sm text-zinc-400" onclick={oncancel}>
Abbrechen
</button>
<button
class="rounded px-3 py-1.5 text-sm font-medium text-white {danger
? 'bg-red-600'
: 'bg-sky-600'}"
onclick={onconfirm}
>
{confirmLabel}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,73 @@
<script lang="ts">
/**
* Schlankes Eingabe-Modal für eine einzelne Textzeile (Gerät benennen,
* Scan benennen …). Ersetzt das im Projekt verbotene `prompt()`.
*
* Meldet sich als Overlay an, damit der Hardware-Backbutton es schließt.
*/
import { onMount, onDestroy } from 'svelte';
import { pushOverlay } from '$lib/overlay.svelte';
let {
title,
label = '',
value = '',
placeholder = '',
submitLabel = 'Speichern',
onsubmit,
oncancel,
}: {
title: string;
label?: string;
value?: string;
placeholder?: string;
submitLabel?: string;
onsubmit: (value: string) => void;
oncancel: () => void;
} = $props();
let text = $state(value);
let off: (() => void) | undefined;
onMount(() => {
off = pushOverlay(oncancel);
});
onDestroy(() => off?.());
</script>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) oncancel();
}}
>
<div class="w-full max-w-sm rounded-lg bg-zinc-900 p-4">
<h2 class="mb-2 text-sm font-semibold text-zinc-200">{title}</h2>
{#if label}
<label class="mb-1 block text-xs text-zinc-400" for="tp-input">{label}</label>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
id="tp-input"
class="w-full rounded border border-zinc-700 bg-zinc-800 px-2 py-1.5 text-sm text-zinc-100"
bind:value={text}
{placeholder}
autofocus
onkeydown={(e) => {
if (e.key === 'Enter') onsubmit(text.trim());
}}
/>
<div class="mt-3 flex justify-end gap-2">
<button class="rounded px-3 py-1.5 text-sm text-zinc-400" onclick={oncancel}>
Abbrechen
</button>
<button
class="rounded bg-sky-600 px-3 py-1.5 text-sm font-medium text-white"
onclick={() => onsubmit(text.trim())}
>
{submitLabel}
</button>
</div>
</div>
</div>

View file

@ -61,6 +61,18 @@ export function upsertDevice(
return created;
}
/** Favoriten-Markierung eines Geräts umschalten */
export function toggleFavorite(protocol: Protocol, clientId: string): void {
const d = protocol.devices.find((x) => x.clientId === clientId);
if (d) d.isFavorite = !d.isFavorite;
}
/** Einem Gerät einen eigenen Namen geben (leerer Name = zurücksetzen) */
export function renameDevice(protocol: Protocol, clientId: string, name: string): void {
const d = protocol.devices.find((x) => x.clientId === clientId);
if (d) d.customName = name.trim() || undefined;
}
/** Messung zum Protokoll hinzufügen */
export function addMeasurement(
protocol: Protocol,

View file

@ -8,8 +8,10 @@
import ToolDialog from '$lib/components/ToolDialog.svelte';
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
import DeviceCard from '$lib/components/DeviceCard.svelte';
import TextPromptDialog from '$lib/components/TextPromptDialog.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
import { addMeasurement, upsertDevice } from '$lib/protocols';
import { addMeasurement, upsertDevice, toggleFavorite, renameDevice } from '$lib/protocols';
import { sync } from '$lib/sync.svelte';
import { toast } from '$lib/toast.svelte';
import { pushOverlay } from '$lib/overlay.svelte';
@ -22,12 +24,21 @@
let activeTool = $state<Tool | null>(null);
let activeDevice = $state<Device | undefined>(undefined);
let saving = $state(false);
let renameTarget = $state<Device | null>(null);
let confirmDelete = $state(false);
let appStateListener: PluginListenerHandle | null = null;
const protocolTools = TOOLS.filter((t) => t.scope === 'protocol');
const deviceTools = TOOLS.filter((t) => t.scope === 'device');
/** Geräte mit Favoriten zuerst */
const sortedDevices = $derived(
[...(protocol?.devices ?? [])].sort(
(a, b) => Number(b.isFavorite ?? false) - Number(a.isFavorite ?? false),
),
);
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
@ -127,9 +138,25 @@
);
}
async function removeProtocol() {
/** Favoriten-Stern eines Geräts umschalten */
function doToggleFav(device: Device) {
if (!protocol) return;
toggleFavorite(protocol, device.clientId);
void persist();
}
/** Gerät umbenennen (aus dem Namens-Dialog) */
function doRename(name: string) {
if (protocol && renameTarget) {
renameDevice(protocol, renameTarget.clientId, name);
void persist();
}
renameTarget = null;
}
async function doDelete() {
confirmDelete = false;
if (!protocol) return;
if (!confirm('Dieses Protokoll wirklich löschen?')) return;
await deleteProtocol(protocol.clientUuid);
await sync.refreshPending();
goto('/auftraege/');
@ -227,18 +254,20 @@
Noch keine Geräte — IP-Scanner ausführen, um das Netz zu erfassen.
</p>
{/if}
{#each protocol.devices as device (device.clientId)}
{#each sortedDevices as device (device.clientId)}
<DeviceCard
{device}
measurements={measurementsFor(device.clientId)}
tools={deviceTools}
onrun={(tool) => openTool(tool, device)}
onfavorite={() => doToggleFav(device)}
onrename={() => (renameTarget = device)}
/>
{/each}
</section>
<div class="px-3">
<button class="text-xs text-red-400 underline" onclick={removeProtocol}>
<button class="text-xs text-red-400 underline" onclick={() => (confirmDelete = true)}>
Protokoll löschen
</button>
</div>
@ -264,6 +293,28 @@
onrun={runTool}
/>
{/if}
{#if renameTarget}
<TextPromptDialog
title="Gerät benennen"
label="Eigener Name"
value={renameTarget.customName ?? ''}
placeholder={renameTarget.hostname ?? renameTarget.ip}
onsubmit={doRename}
oncancel={() => (renameTarget = null)}
/>
{/if}
{#if confirmDelete}
<ConfirmDialog
title="Protokoll löschen?"
message="Das Protokoll und alle Messungen werden lokal entfernt."
confirmLabel="Löschen"
danger
onconfirm={doDelete}
oncancel={() => (confirmDelete = false)}
/>
{/if}
{:else}
<div class="flex min-h-screen items-center justify-center text-zinc-500">Lädt …</div>
{/if}