guter zwischenstand
This commit is contained in:
31
webpage/src/components/App.svelte
Normal file
31
webpage/src/components/App.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import Header from "./components/Header.svelte";
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main class="main-layout flex-grow">
|
||||
|
||||
<section class="buzzer-card p-6 h-64">
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Dateiverarbeitung</h3>
|
||||
</section>
|
||||
|
||||
<section class="buzzer-card p-6 h-64">
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Status</h3>
|
||||
</section>
|
||||
|
||||
<section class="buzzer-card p-6 h-96 lg:col-span-1">
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Lokale Sounds</h3>
|
||||
</section>
|
||||
|
||||
<section class="buzzer-card p-6 h-96 lg:col-span-1">
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-widest">Buzzer Sounds</h3>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="hidden sm:flex justify-center items-center py-4 bg-slate-50 border-t border-slate-200 text-[10px] text-slate-400 uppercase tracking-widest">
|
||||
© 2026 Edis Buzzer Management Studio | Nerd Mode Active
|
||||
</footer>
|
||||
</div>
|
||||
@@ -1,61 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { isInitializing, isBluetoothSupported, isSerialSupported } from "../lib/store";
|
||||
import { performHardwareCheck, getBrowserName } from "../lib/init";
|
||||
import { isInitializing, isBluetoothSupported } from "../lib/store";
|
||||
import { performHardwareCheck } from "../lib/init";
|
||||
import ToastContainer from "./ToastContainer.svelte";
|
||||
import { injectDummyDevices } from "../lib/store";
|
||||
|
||||
let browserName = "";
|
||||
onMount(() => {
|
||||
browserName = getBrowserName();
|
||||
onMount(async () => {
|
||||
performHardwareCheck();
|
||||
injectDummyDevices(); // Fügt Dummy-Geräte für Testzwecke hinzu
|
||||
|
||||
if ($isBluetoothSupported) {
|
||||
const { restoreSession } = await import("../lib/bluetooth");
|
||||
await restoreSession();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isInitializing}
|
||||
<div class="fixed inset-0 bg-slate-50 flex items-center justify-center z-[100]">
|
||||
<p class="text-slate-600 font-mono animate-pulse">SYSTEM_CHECK_RUNNING...</p>
|
||||
<div class="fixed inset-0 bg-surface flex items-center justify-center z-[100]">
|
||||
<p class="text-on-surface font-mono animate-pulse text-base md:text-lg text-center">
|
||||
Browserkompatibilität wird geprüft...
|
||||
</p>
|
||||
</div>
|
||||
{:else if !$isBluetoothSupported && !$isSerialSupported}
|
||||
<div class="min-h-[60vh] flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-white border-2 border-red-600 shadow-2xl rounded-sm p-8 pb-4">
|
||||
<h1 class="text-2xl font-black text-red-600 mb-4 uppercase italic">Inkompatibler Browser</h1>
|
||||
{:else if !$isBluetoothSupported}
|
||||
<div
|
||||
class="fixed lg:h-screen inset-0 flex flex-col items-center justify-center p-0 lg:p-4 z-[100] bg-white lg:bg-transparent" style="hyphens:auto;">
|
||||
<div
|
||||
class="w-full h-full lg:h-auto lg:max-w-md bg-red-50 lg:border border-red-600 lg:shadow-xl lg:rounded-lg p-6 lg:p-8 text-red-600 flex flex-col justify-center"
|
||||
>
|
||||
<h1 class="text-2xl font-bold mb-2 text-center">Dein Browser ist... suboptimal</h1>
|
||||
<div class="text-center text-7xl md:text-9xl font-bold mb-4">🥺</div>
|
||||
|
||||
<p class="text-slate-800 mb-6">
|
||||
Du nutzt aktuell <strong>{browserName}</strong>
|
||||
. Dieser Browser unterstützt weder Bluetooth noch serielle USB-Verbindungen.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2 mb-4 text-sm font-mono">
|
||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
||||
<span>Web Bluetooth:</span>
|
||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-b border-slate-100 pb-1">
|
||||
<span>Web Serial:</span>
|
||||
<span class="text-red-600 font-bold">NICHT UNTERSTÜTZT</span>
|
||||
</div>
|
||||
<div class="space-y-4 text-base md:text-lg text-center md:text-left">
|
||||
<p>
|
||||
Leider unterstützt dein Browser die benötigten Bluetooth-Funktionen nicht. Bitte versuche
|
||||
es mit einem aktuellen <span class="font-semibold">Chrome</span>
|
||||
oder einem andern Chromium-basierten Browser.
|
||||
<span class="font-semibold">Winzigweich Kante</span> soll gerüchteweise auch Chromium-basiert sein...
|
||||
</p>
|
||||
<p>
|
||||
Rundreise auf iOS unterstützt Bluetooth leider nicht, aber du kannst es mit einem vernünftigen Gerät oder Browser versuchen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-slate-500 mb-6 italic">
|
||||
(Info: Firefox und Safari blockieren diese Hardware-Schnittstellen aus Prinzip.)
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="https://www.google.com/chrome/"
|
||||
class="block w-full text-center bg-blue-600 text-white py-3 font-bold hover:bg-blue-700 transition uppercase tracking-widest text-sm mb-4"
|
||||
>
|
||||
Googles Glanzeisen installieren
|
||||
</a>
|
||||
|
||||
<p class="text-xs text-slate-500 mb-6 italic">
|
||||
Gerüchten zufolge soll <b>Winzigweichs Kante</b>
|
||||
-Browser diese Technologien auch unterstützen. Aber wer nutzt schon diese Weichware?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<ToastContainer client:load />
|
||||
<ToastContainer />
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
fsInfo,
|
||||
loadConnectionState,
|
||||
availableDevices,
|
||||
transferStats,
|
||||
resetTransferStats,
|
||||
} from "../lib/store";
|
||||
import { refreshRemote } from "../lib/sync";
|
||||
import { fetchFileThroughputTest } from "../lib/transport";
|
||||
import { getFile } from "../lib/transport";
|
||||
|
||||
onMount(() => {
|
||||
restoreSession();
|
||||
@@ -127,11 +129,40 @@
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition"
|
||||
on:click={() => {
|
||||
fetchFileThroughputTest("/lfs/a/countdown");
|
||||
class="mt-4 w-full text-left text-xs text-slate-500 hover:text-slate-700 transition-all"
|
||||
on:click={async () => {
|
||||
// 1. Alles auf Null setzen
|
||||
resetTransferStats();
|
||||
|
||||
// 2. Gesamtgröße für beide Dateien zusammen setzen (z. B. 320000 + 224800)
|
||||
const sizeFile1 = 320000;
|
||||
const sizeFile2 = 224800;
|
||||
transferStats.update((s) => ({
|
||||
...s,
|
||||
overallTotal: sizeFile1 + sizeFile2,
|
||||
currentFileName: "countdown",
|
||||
}));
|
||||
|
||||
try {
|
||||
// 3. Erste Datei laden und auf Abschluss warten
|
||||
const success1 = await getFile("/lfs/a/countdown");
|
||||
|
||||
if (success1) {
|
||||
// 4. Name für die zweite Datei aktualisieren
|
||||
transferStats.update((s) => ({ ...s, currentFileName: "404" }));
|
||||
|
||||
// 5. Zweite Datei laden und auf Abschluss warten
|
||||
await getFile("/lfs/a/404");
|
||||
transferStats.update((s) => ({ ...s, overallDone: s.overallTotal }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Test-Transfer:", err);
|
||||
} finally {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
resetTransferStats();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Durchsatztest mit /lfs/a/countdown
|
||||
Durchsatztest (Mehrere Dateien)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
72
webpage/src/components/DeviceInfo.svelte
Normal file
72
webpage/src/components/DeviceInfo.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import FlashUsage from "./FlashUsage.svelte"
|
||||
import { BatteryEmptyIcon, BatteryLowIcon, BatteryMediumIcon, BatteryHighIcon, BatteryFullIcon, BatteryChargingIcon } from "phosphor-svelte"
|
||||
|
||||
</script>
|
||||
<div class="text-sm">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="key">
|
||||
Modell
|
||||
</td>
|
||||
<td class="value">
|
||||
nrf52840dk-prototyp
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="key">
|
||||
Version
|
||||
</td>
|
||||
<td class="value">
|
||||
2.3.22-debug
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="key">
|
||||
HW-ID
|
||||
</td>
|
||||
<td class="value">
|
||||
<span class="font-mono">DEAD-BEAF-0102-3456</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="key">
|
||||
Batterie
|
||||
</td>
|
||||
<td class="value flex items-center gap-2">
|
||||
85% <BatteryChargingIcon weight="bold" class="w-5 h-5"/> 1200mAh
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="key">
|
||||
Speicher
|
||||
</td>
|
||||
<td class="value">
|
||||
<div class="py-1">
|
||||
<FlashUsage/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@reference "../styles/app.css";
|
||||
|
||||
table {
|
||||
@apply w-full text-left;
|
||||
}
|
||||
|
||||
tr {
|
||||
@apply even:bg-slate-100 border-b border-slate-200 last:border-0;
|
||||
}
|
||||
|
||||
.key {
|
||||
@apply p-1 pl-4 text-right text-text-muted;
|
||||
}
|
||||
|
||||
.value {
|
||||
@apply p-1 pr-4 font-semibold;
|
||||
}
|
||||
13
webpage/src/components/FileList.svelte
Normal file
13
webpage/src/components/FileList.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import FileListItem from "./FileListItem.svelte";
|
||||
import { buzzerAudioFiles } from "../lib/store";
|
||||
import type { BuzzerFile } from "../lib/types";
|
||||
|
||||
export let type: "local" | "buzzer" = "buzzer";
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#each $buzzerAudioFiles as file, index(index)}
|
||||
<FileListItem bind:file={file}/>
|
||||
{/each}
|
||||
</div>
|
||||
35
webpage/src/components/FileListItem.svelte
Normal file
35
webpage/src/components/FileListItem.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { BuzzerFile } from "../lib/types";
|
||||
import { FileAudioIcon } from "phosphor-svelte";
|
||||
export let file: BuzzerFile;
|
||||
</script>
|
||||
<div>
|
||||
<button
|
||||
class="w-full text-left flex-1 px-3 py-1 flex items-center cursor-pointer border-l-4 transition-colors border-b border-b-border-card
|
||||
{file.selected ? 'border-l-blue-600 bg-blue-50 hover:bg-blue-100' : 'border-l-transparent hover:bg-slate-100 hover:border-l-blue-200'} "
|
||||
on:click={() => {file.selected = !file.selected;}}
|
||||
>
|
||||
<FileAudioIcon class="text-blue-600 mr-3 w-5 h-5" />
|
||||
<div class="flex flex-col">
|
||||
<span class="font-light">
|
||||
{file.name || "Unbekannte Datei"}
|
||||
{#if file.metaTags?.t}
|
||||
 - 
|
||||
<span class="font-medium">{file.metaTags.t}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<div class="text-xs">
|
||||
<span class="font-light text-text-muted text-xs">
|
||||
{parseFloat((file.size/1024).toFixed(1))} kB
|
||||
</span>
|
||||
<span>
|
||||
{#if file.metaTags.a}<span class="text-text-muted"> | Author:</span> {file.metaTags.a}{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { storageUsage } from "../lib/store";
|
||||
</script>
|
||||
|
||||
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
|
||||
<div
|
||||
class="h-full bg-slate-400 transition-all duration-500"
|
||||
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
||||
></div>
|
||||
|
||||
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
|
||||
{#if $storageUsage}
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-400">
|
||||
Rate: {($storageUsage.systemBytes / 1048576).toFixed(2)} MB
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-semibold text-slate-400">
|
||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} Sekunden
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-slate-400">Kein Transfer aktiv</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2,39 +2,37 @@
|
||||
import { storageUsage } from "../lib/store";
|
||||
</script>
|
||||
|
||||
<div class="mt-4 w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner">
|
||||
<div class="w-full h-3 bg-slate-100 rounded-sm overflow-hidden flex shadow-inner shadow-sm">
|
||||
<div
|
||||
class="h-full bg-slate-400 transition-all duration-500"
|
||||
class="h-full bg-gradient-to-b from-slate-300 to-slate-400 transition-all duration-500"
|
||||
style="width: {$storageUsage?.systemPercent ?? 0}%"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="h-full bg-indigo-500 transition-all duration-500"
|
||||
class="h-full bg-gradient-to-b from-indigo-300 to-indigo-500 transition-all duration-500"
|
||||
style="width: {$storageUsage?.audioPercent ?? 0}%"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="h-full bg-emerald-500 transition-all duration-500"
|
||||
class="h-full bg-gradient-to-b from-emerald-300 to-emerald-500 transition-all duration-500"
|
||||
style="width: {$storageUsage?.freePercent ?? 0}%"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-xs text-slate-400 flex justify-between uppercase tracking-tighter font-medium">
|
||||
<div class="text-xs text-slate-400 flex justify-between">
|
||||
{#if $storageUsage}
|
||||
<div class="flex gap-4">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-400">System:
|
||||
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
||||
</div>
|
||||
<div>
|
||||
{($storageUsage.systemBytes / 1048576).toFixed(2)} MB</span>
|
||||
<span class="font-semibold text-indigo-500">Audio:
|
||||
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
||||
{($storageUsage.audioBytes / 1048576).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-semibold text-emerald-500">Frei:
|
||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
||||
{($storageUsage.freeBytes / 1048576).toFixed(2)} MB</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-slate-400">Speicherdaten nicht verfügbar</div>
|
||||
|
||||
205
webpage/src/components/Header.svelte
Normal file
205
webpage/src/components/Header.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import HeaderDeviceListItem from "./HeaderDeviceListItem.svelte";
|
||||
import {
|
||||
PlugsIcon,
|
||||
PlugsConnectedIcon,
|
||||
BluetoothIcon,
|
||||
CaretDownIcon,
|
||||
LinkIcon,
|
||||
UsbIcon,
|
||||
SquareIcon,
|
||||
CheckSquareIcon,
|
||||
} from "phosphor-svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import {
|
||||
isConnected, isConnecting, availableDevices, pairedDevices,
|
||||
activeDeviceId, autoConnect, loadConnectionState, fsInfo
|
||||
} from "../lib/store";
|
||||
import { connectBuzzer, disconnectBuzzer, pairBuzzer, forgetDevice, getPairedDevices } from "../lib/bluetooth";
|
||||
|
||||
let showDropdown = false;
|
||||
|
||||
$: lastDeviceId = loadConnectionState()?.deviceId;
|
||||
$: targetDevice = $pairedDevices.find(d => d.id === lastDeviceId);
|
||||
$: canQuickConnect = targetDevice ? $availableDevices.has(targetDevice.id) : false;
|
||||
$: autoConnectIcon = $autoConnect ? CheckSquareIcon : SquareIcon;
|
||||
|
||||
async function handleMainAction() {
|
||||
showDropdown = false;
|
||||
if ($isConnected) {
|
||||
disconnectBuzzer();
|
||||
} else if (canQuickConnect && targetDevice) {
|
||||
await connectBuzzer(targetDevice);
|
||||
} else {
|
||||
await pairBuzzer();
|
||||
}
|
||||
}
|
||||
|
||||
function clickOutside(node: HTMLElement) {
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (!node.contains(event.target as Node) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent("outclick"));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="fixed top-0 left-0 w-full h-16 bg-surface-card border-b border-border-card z-50 flex items-center justify-between px-4 lg:px-8 bg-gradient-to-b from-white to-slate-100"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span class="text-lg uppercase text-brand">
|
||||
<span class="font-extrabold">Edis Buzzer</span>
|
||||
|
||||
<span class="font-light">CONTROL</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="relative" class:disabled={$isConnecting} use:clickOutside on:outclick={() => (showDropdown = false)}>
|
||||
<div
|
||||
class="btn-connect-group"
|
||||
class:connected={$isConnected}
|
||||
class:last-available={!$isConnected && canQuickConnect}
|
||||
class:last-unavailable={!$isConnected && !canQuickConnect}
|
||||
>
|
||||
<button class="btn-main" on:click={handleMainAction} disabled={$isConnecting}>
|
||||
{#if $isConnected}
|
||||
<PlugsIcon weight="fill" class="w-4 h-4" />
|
||||
<span class="hidden sm:inline">Trennen</span>
|
||||
{:else if canQuickConnect}
|
||||
<PlugsConnectedIcon weight="fill" class="w-4 h-4" />
|
||||
<span class="hidden sm:inline">Verbinden</span>
|
||||
{:else}
|
||||
<LinkIcon weight="bold" class="w-4 h-4" />
|
||||
<span class="hidden sm:inline">Neues Gerät</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button class="btn-dropdown" on:click={() => (showDropdown = !showDropdown)} disabled={$isConnecting}>
|
||||
<CaretDownIcon
|
||||
weight="bold"
|
||||
class="w-4 h-4 transition-transform duration-300 {showDropdown ? '-scale-y-100' : ''}"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
transition:slide={{ duration: 300 }}
|
||||
class="absolute right-0 top-12 bg-surface-card shadow-xl rounded-lg border border-border-card p-0 z-50 overflow-hidden
|
||||
w-max max-w-[calc(100vw-2rem)] sm:max-w-md min-w-[16rem]"
|
||||
>
|
||||
{#each $pairedDevices as dev (dev.id)}
|
||||
<HeaderDeviceListItem
|
||||
device={dev}
|
||||
name={dev.name || ''}
|
||||
isConnected={$activeDeviceId === dev.id}
|
||||
isLastConnectedDevice={lastDeviceId === dev.id}
|
||||
isAvailable={$availableDevices.has(dev.id)}
|
||||
on:connect={(e) => { connectBuzzer(e.detail); showDropdown = false; }}
|
||||
on:forget={(e) => forgetDevice(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
{#if $pairedDevices.length === 0}
|
||||
<div class="px-4 py-3 text-xs text-text-muted text-center italic border-b border-border-card">
|
||||
Keine Geräte gekoppelt
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
|
||||
>
|
||||
<button class="flex items-center w-full text-left" on:click={() => { pairBuzzer(); showDropdown = false; }}>
|
||||
<LinkIcon weight="bold" class="mr-2 w-5 h-5" />
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col">
|
||||
<span class="py-2">
|
||||
Buzzer über Bluetooth <BluetoothIcon weight="bold" class="w-4 h-4 flex inline" /> verbinden
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-connect relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
|
||||
>
|
||||
<button class="flex items-center w-full text-left">
|
||||
<LinkIcon weight="bold" class="mr-2 w-5 h-5 opacity-50" />
|
||||
<div class="flex-1 opacity-50">
|
||||
<div class="flex flex-col">
|
||||
<span class="py-2">
|
||||
Buzzer über USB <UsbIcon weight="bold" class="w-4 h-4 flex inline" /> verbinden
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 pr-3 pl-4 py-1 flex items-center menu-auto relative before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1"
|
||||
>
|
||||
<button class="flex items-center w-full text-left" on:click={() => $autoConnect = !$autoConnect}>
|
||||
<svelte:component this={autoConnectIcon} class="mr-2 w-5 h-5" />
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col">
|
||||
<span class="py-2">Automatisch verbinden</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
@reference "../styles/app.css";
|
||||
|
||||
.connected {
|
||||
@apply border-border-card;
|
||||
}
|
||||
|
||||
.connected .btn-main, .connected .btn-dropdown {
|
||||
@apply hover:bg-surface-hover text-slate-700;
|
||||
}
|
||||
|
||||
.last-available, .last-unavailable {
|
||||
@apply border-transparent;
|
||||
}
|
||||
|
||||
.last-available .btn-main, .last-available .btn-dropdown {
|
||||
@apply bg-emerald-600 hover:bg-emerald-700 text-white;
|
||||
}
|
||||
|
||||
.last-unavailable .btn-main, .last-unavailable .btn-dropdown {
|
||||
@apply bg-indigo-600 hover:bg-indigo-700 text-white;
|
||||
}
|
||||
|
||||
.btn-connect-group {
|
||||
@apply flex items-stretch rounded-lg overflow-hidden shadow-sm border;
|
||||
}
|
||||
|
||||
.btn-main {
|
||||
@apply flex items-center gap-2 p-2 text-sm font-semibold transition-colors outline-none cursor-pointer disabled:opacity-50;
|
||||
}
|
||||
|
||||
.btn-dropdown {
|
||||
@apply flex items-center justify-center px-2 py-2 transition-colors outline-none cursor-pointer disabled:opacity-50;
|
||||
}
|
||||
|
||||
.menu-connect {
|
||||
@apply text-blue-700 hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
|
||||
}
|
||||
|
||||
.menu-auto {
|
||||
@apply hover:bg-surface-hover font-semibold border-b last:border-b-0 border-border-card cursor-pointer;
|
||||
}
|
||||
</style>
|
||||
66
webpage/src/components/HeaderDeviceListItem.svelte
Normal file
66
webpage/src/components/HeaderDeviceListItem.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<!-- HeaderDeviceListItem.svelte -->
|
||||
<script lang="ts">
|
||||
import { BluetoothIcon, BluetoothSlashIcon, LinkBreakIcon } from "phosphor-svelte";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let device: any = null;
|
||||
export let isAvailable: boolean = false;
|
||||
export let isLastConnectedDevice: boolean = false;
|
||||
export let isConnected: boolean = false;
|
||||
export let name: string = "";
|
||||
export let type: string = "ble";
|
||||
|
||||
let isHovered = false;
|
||||
const dispatch = createEventDispatcher();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex-1 pr-3 pl-4 py-1 flex items-center relative
|
||||
border-b border-border-card
|
||||
before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1
|
||||
{isAvailable || isConnected ? '' : 'text-text-muted cursor-not-allowed'}
|
||||
{isConnected && isLastConnectedDevice ? 'bg-bg-selected ' : ''}
|
||||
{isAvailable && !isConnected ? 'hover:bg-surface-hover' : ''}
|
||||
{isLastConnectedDevice ? 'before:bg-border-selected' : ''}
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="flex-1 flex items-center text-left min-2-0"
|
||||
on:click={() => dispatch("connect", device)}
|
||||
disabled={!isAvailable || isConnected}
|
||||
>
|
||||
{#if type === "ble"}
|
||||
{#if isAvailable || isConnected}
|
||||
<BluetoothIcon class="text-blue-600 mr-2 w-5 h-5" />
|
||||
{:else}
|
||||
<BluetoothSlashIcon class="mr-2 w-5 h-5" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-sm truncate">
|
||||
{name || "Unbekanntes Gerät"}
|
||||
</span>
|
||||
{#if isConnected}
|
||||
<span class="text-xs font-semibold">Verbunden</span>
|
||||
{:else if isAvailable}
|
||||
<span class="text-xs">In Reichweite</span>
|
||||
{:else if !isAvailable}
|
||||
<span class="text-xs">Nicht in Reichweite</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
on:mouseenter={() => (isHovered = true)}
|
||||
on:mouseleave={() => (isHovered = false)}
|
||||
title="'{name}' entfernen"
|
||||
class="flex-shrink-0"
|
||||
>
|
||||
<LinkBreakIcon
|
||||
weight={isHovered ? "bold" : "regular"}
|
||||
class="w-7 h-7 p-1 ml-3 rounded text-red-600 hover:bg-red-600 hover:text-white"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
301
webpage/src/components/MainGrid.svelte
Normal file
301
webpage/src/components/MainGrid.svelte
Normal file
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import { isConnected, fsInfo } from "../lib/store";
|
||||
import {
|
||||
GearIcon,
|
||||
CloudArrowUpIcon,
|
||||
ListIcon,
|
||||
ArrowClockwiseIcon,
|
||||
DotsThreeVerticalIcon,
|
||||
CheckSquareOffsetIcon,
|
||||
SquareIcon,
|
||||
DownloadIcon,
|
||||
XIcon,
|
||||
} from "phosphor-svelte";
|
||||
import FileList from "./FileList.svelte";
|
||||
import DeviceInfo from "./DeviceInfo.svelte";
|
||||
import { refreshRemote, downloadSelectedFiles } from "../lib/sync";
|
||||
import {
|
||||
buzzerAudioFiles,
|
||||
buzzerFilesCount,
|
||||
selectedBuzzerFilesCount,
|
||||
transferStats,
|
||||
isFetchingRemote,
|
||||
pairedDevices,
|
||||
activeDeviceId,
|
||||
transferDetails,
|
||||
} from "../lib/store";
|
||||
import { SETTINGS } from "../lib/settings";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
let showOverlay = false;
|
||||
let isTransferFinished = false;
|
||||
let overlayTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
$: currentDevice = $pairedDevices.find((d) => d.id === $activeDeviceId);
|
||||
|
||||
$: if ($isConnected) {
|
||||
refreshRemote();
|
||||
}
|
||||
|
||||
$: if ($isFetchingRemote && $transferStats.overallTotal > 0) {
|
||||
// Transfer startet oder läuft
|
||||
showOverlay = true;
|
||||
isTransferFinished = false;
|
||||
clearTimeout(overlayTimeout);
|
||||
} else if (showOverlay && !$isFetchingRemote && $transferStats.overallDone > 0) {
|
||||
// Transfer wurde soeben abgeschlossen
|
||||
isTransferFinished = true;
|
||||
overlayTimeout = setTimeout(closeOverlay, SETTINGS.ui.transferOverlayPersistMs);
|
||||
}
|
||||
|
||||
function closeOverlay() {
|
||||
clearTimeout(overlayTimeout);
|
||||
showOverlay = false;
|
||||
isTransferFinished = false;
|
||||
// Optional: resetTransferStats() aufrufen, um die Werte zu nullen
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) return "∞";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")} Min.`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="main-layout mt-16 lg:mt-20 pb-12">
|
||||
<section class="buzzer-card flex flex-col">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Dateiverarbeitung</h3>
|
||||
<button class="btn" aria-label="Einstellungen">
|
||||
<GearIcon class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="text-center text-2xs tracking-tight font-mono font-semibold">
|
||||
16kHz 16bit MONO | <span class="text-emerald-700">NORMALIZER ON</span>
|
||||
|
|
||||
<span class="text-red-700">COMPRESSOR OFF</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<CloudArrowUpIcon class="w-24 h-24 text-slate-500" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="buzzer-card flex flex-col">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">
|
||||
{#if $isConnected && currentDevice?.name}
|
||||
Geräteinfos: <span class="font-normal">{currentDevice.name}</span>
|
||||
{/if}
|
||||
</h3>
|
||||
<button class="btn" aria-label="Einstellungen" disabled={!$isConnected}>
|
||||
<GearIcon class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative overflow-hidden h-full">
|
||||
<div class="card-body transition-all duration-500 h-full" class:disconnected={!$isConnected}>
|
||||
<DeviceInfo />
|
||||
</div>
|
||||
|
||||
{#if showOverlay}
|
||||
<div
|
||||
class="absolute inset-0 z-10 bg-white/95 backdrop-blur-[2px] p-4 flex flex-col justify-end"
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
{#if isTransferFinished}
|
||||
<button
|
||||
class="absolute top-2 right-2 p-1 text-slate-400 hover:text-slate-700 transition-colors"
|
||||
on:click={closeOverlay}
|
||||
aria-label="Overlay schließen"
|
||||
>
|
||||
<XIcon class="w-5 h-5" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="w-full flex flex-col gap-1">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="relative w-full h-3 bg-slate-100 rounded-sm overflow-hidden shadow-inner shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-gradient-to-b from-indigo-300 to-indigo-500"
|
||||
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent ===
|
||||
0
|
||||
? 'none'
|
||||
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-500"
|
||||
>
|
||||
{$transferDetails.filePercent}%
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-[10px] text-white"
|
||||
style="clip-path: inset(0 {100 -
|
||||
$transferDetails.filePercent}% 0 0); transition: {$transferDetails.filePercent ===
|
||||
0
|
||||
? 'none'
|
||||
: `clip-path ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
|
||||
>
|
||||
{$transferDetails.filePercent}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[10px] px-1">
|
||||
<span class="truncate max-w-[60%]">
|
||||
{$transferStats.currentFileName || "Lade..."}
|
||||
</span>
|
||||
<span>{formatTime($transferDetails.fileEta)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
class="relative w-full h-3 bg-slate-100 rounded-sm overflow-hidden shadow-inner shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full bg-gradient-to-b from-indigo-300 to-indigo-500"
|
||||
style="width: {$transferDetails.totalPercent}%; transition: {$transferDetails.totalPercent ===
|
||||
0
|
||||
? 'none'
|
||||
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-[10px] text-slate-500"
|
||||
>
|
||||
{$transferDetails.totalPercent}%
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center text-[10px] text-white"
|
||||
style="clip-path: inset(0 {100 -
|
||||
$transferDetails.totalPercent}% 0 0); transition: {$transferDetails.totalPercent ===
|
||||
0
|
||||
? 'none'
|
||||
: `clip-path ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
|
||||
>
|
||||
{$transferDetails.totalPercent}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between text-[10px] px-1">
|
||||
<span>{$transferDetails.speedKbs} kB/s</span>
|
||||
<span>{formatTime($transferDetails.totalEta)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mt-1">
|
||||
<button
|
||||
class="px-4 py-1.5 text-xs font-semibold text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={true}
|
||||
>
|
||||
Transfer abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="buzzer-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Lokale Bibliothek</h3>
|
||||
<button class="btn" aria-label="Menu">
|
||||
<ListIcon class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="py-10 text-center">
|
||||
<span class="text-slate-300 uppercase italic tracking-widest">Bibliothek leer</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="buzzer-card relative">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h3 class="card-title">
|
||||
Gerätebibliothek <span class="text-text-muted text-xs font-mono">
|
||||
{$fsInfo?.audioPath}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div>
|
||||
<div class="btn-connect-group group">
|
||||
<button
|
||||
class="btn-main"
|
||||
aria-label="Alle ausgewählten Dateien herunterladen"
|
||||
on:click={() => {
|
||||
downloadSelectedFiles();
|
||||
}}
|
||||
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
|
||||
title="Alle ausgewählten Dateien herunterladen"
|
||||
>
|
||||
<DownloadIcon class="icon" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-main"
|
||||
aria-label="Alles Auswählen"
|
||||
on:click={() => {
|
||||
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: true })));
|
||||
}}
|
||||
disabled={!$isConnected || $buzzerFilesCount === $selectedBuzzerFilesCount}
|
||||
title="Alle auswählen"
|
||||
>
|
||||
<CheckSquareOffsetIcon class="icon" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-main"
|
||||
on:click={() => {
|
||||
buzzerAudioFiles.update((files) => files.map((f) => ({ ...f, selected: false })));
|
||||
}}
|
||||
aria-label="Auswahl löschen"
|
||||
disabled={!$isConnected || $selectedBuzzerFilesCount === 0}
|
||||
title="Auswahl aufheben"
|
||||
>
|
||||
<SquareIcon class="icon" />
|
||||
</button>
|
||||
<button
|
||||
class="btn-main"
|
||||
on:click={() => refreshRemote()}
|
||||
aria-label="Reload"
|
||||
disabled={!$isConnected}
|
||||
title="Dateiliste neu laden"
|
||||
>
|
||||
<ArrowClockwiseIcon class="icon" />
|
||||
</button>
|
||||
<button class="btn-main" aria-label="Menu" disabled={!$isConnected} title="Menu">
|
||||
<DotsThreeVerticalIcon weight="bold" class="icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" class:disconnected={!$isConnected}>
|
||||
<FileList type="buzzer" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@reference "../styles/app.css";
|
||||
|
||||
.btn-connect-group {
|
||||
@apply flex items-stretch rounded overflow-hidden border-transparent transition-all hover:border-slate-200 hover:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-main {
|
||||
@apply border-r-1 border-r-transparent last:border-r-0;
|
||||
|
||||
&:not(:disabled) {
|
||||
@apply hover:border-border-card hover:shadow-sm hover:bg-slate-200 cursor-pointer group-hover:border-r-slate-200;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: color-mix(in srgb, currentColor 50%, transparent);
|
||||
@apply cursor-not-allowed group-hover:border-r-slate-200;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
webpage/src/components/TransferProgress.svelte
Normal file
61
webpage/src/components/TransferProgress.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { transferDetails, transferStats } from "../lib/store";
|
||||
import { fade, slide } from 'svelte/transition';
|
||||
|
||||
// Formatiert Sekunden zu "M:SS m" oder "S s" mit schmalem Leerzeichen
|
||||
$: formatTime = (seconds: number): string => {
|
||||
if (seconds === Infinity || seconds > 3600) return '∞';
|
||||
|
||||
const narrowNbsp = '\u202F';
|
||||
|
||||
if (seconds >= 60) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}${narrowNbsp}m`;
|
||||
}
|
||||
|
||||
return `${Math.floor(seconds)}${narrowNbsp}s`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mt-4 min-h-[100px]">
|
||||
{#if $transferStats.bytesTotal > 0}
|
||||
<div class="flex flex-col gap-3 mt-4 w-full" transition:slide={{ duration: 400 }}>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between text-[10px] tracking-tighter font-bold text-slate-500">
|
||||
<span class="truncate">Datei: {$transferStats.currentFileName || 'Übertragung...'}</span>
|
||||
<span>{formatTime($transferDetails.fileEta)}</span>
|
||||
</div>
|
||||
<div class="w-full h-2 bg-slate-100 rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
class="h-full bg-blue-500 transition-all duration-500 ease-out"
|
||||
style="width: {$transferDetails.filePercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex justify-between text-[10px] tracking-tighter font-bold text-slate-500">
|
||||
<span>Gesamtfortschritt</span>
|
||||
<span>{formatTime($transferDetails.totalEta)}</span>
|
||||
</div>
|
||||
<div class="w-full h-3 bg-slate-100 rounded-full overflow-hidden shadow-inner">
|
||||
<div
|
||||
class="h-full bg-slate-400 transition-all duration-500 ease-out"
|
||||
style="width: {$transferDetails.totalPercent}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between text-[10px] tracking-tighter font-medium text-slate-400">
|
||||
<span>Rate: {$transferDetails.speedKbs.toFixed(0)}kBps</span>
|
||||
<span>{$transferDetails.totalPercent}% abgeschlossen</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 text-xs text-slate-400 italic text-center tracking-widest" transition:slide={{ duration: 400 }}>
|
||||
Kein Transfer aktiv
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user