347 lines
11 KiB
Svelte
347 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { type BuzzerFile, SyncState } from "../lib/types";
|
|
import {
|
|
MusicNotesIcon,
|
|
DotsThreeVerticalIcon,
|
|
PlayIcon,
|
|
TrashIcon,
|
|
PencilIcon,
|
|
CircleIcon,
|
|
QuestionIcon,
|
|
TagIcon,
|
|
UserIcon,
|
|
CheckCircleIcon,
|
|
WarningIcon,
|
|
WarningCircleIcon,
|
|
CloudArrowDownIcon,
|
|
} from "phosphor-svelte";
|
|
import {
|
|
isTransferingRemote,
|
|
transferStats,
|
|
transferDetails,
|
|
buzzerAudioFiles,
|
|
localAudioFiles,
|
|
syncStateMap,
|
|
fsInfo,
|
|
} from "../lib/store";
|
|
|
|
import { SETTINGS } from "../lib/settings";
|
|
import { tagEditorState } from "../lib/store";
|
|
import { tooltip } from "../lib/actions/tooltip";
|
|
import { deleteRemoteFile } from "../lib/transport";
|
|
import { deleteLocalFile, playLocalFile, downloadLocalFile } from "../lib/db";
|
|
import { refreshRemote, refreshLocal } from "../lib/sync";
|
|
import { addToast } from "../lib/toast";
|
|
|
|
export let file: BuzzerFile;
|
|
export let type: "local" | "buzzer" = "buzzer";
|
|
let menuOpen = false;
|
|
|
|
$: activeStore = type === "local" ? localAudioFiles : buzzerAudioFiles;
|
|
$: selectedFiles = $activeStore.filter((f) => f.selected);
|
|
$: currentIndex = selectedFiles.findIndex((f) => f.name === $transferStats.currentFileName);
|
|
$: myIndex = selectedFiles.findIndex((f) => f.name === file.name);
|
|
|
|
$: state = (() => {
|
|
if (!file.selected || !$isTransferingRemote) return "default";
|
|
if (file.name === $transferStats.currentFileName) return "active";
|
|
if (myIndex < currentIndex) return "done";
|
|
if (myIndex > currentIndex) return "pending";
|
|
return "default";
|
|
})();
|
|
|
|
$: syncStatus = $syncStateMap[type][file.name] || { state: SyncState.UNKNOWN, linkedFiles: [] };
|
|
|
|
$: statusConfig = (() => {
|
|
switch (syncStatus.state) {
|
|
case SyncState.UNKNOWN:
|
|
return {
|
|
icon: QuestionIcon,
|
|
weight: "fill",
|
|
color: "text-amber-500",
|
|
variant: "warning",
|
|
text: "Prüfsumme fehlt. Bitte Metadaten aktualisieren.",
|
|
};
|
|
case SyncState.SINGLE_SIDED:
|
|
return {
|
|
icon: CircleIcon,
|
|
weight: "bold",
|
|
color: "text-emerald-600",
|
|
variant: "info",
|
|
text: `Datei existiert nur ${type === "buzzer" ? "auf dem Buzzer" : "lokal"}.`,
|
|
};
|
|
case SyncState.SYNCED:
|
|
return {
|
|
icon: CheckCircleIcon,
|
|
weight: "fill",
|
|
color: "text-emerald-500",
|
|
variant: "info",
|
|
text: "Datei ist synchronisiert.",
|
|
};
|
|
case SyncState.CONFLICT:
|
|
return {
|
|
icon: WarningIcon,
|
|
ping: true,
|
|
weight: "fill",
|
|
color: "text-amber-600",
|
|
variant: "warning",
|
|
text: `Konflikt: Name/Tags weichen ab. Vergleiche mit <span class="text-medium text-on-surface bg-white rounded px-1">${syncStatus.linkedFiles[0]}</span> ${type === "buzzer" ? "lokal" : "auf dem Buzzer"}`,
|
|
};
|
|
case SyncState.DUPLICATE:
|
|
const duplicateText =
|
|
syncStatus.linkedFiles.length === 0
|
|
? `Diese Datei ist hier ok, aber ${type === "buzzer" ? "lokal" : "auf dem Buzzer"} existieren mehrere identische Versionen.`
|
|
: `Mehrfach vorhanden: Gleicher Inhalt auch in <span class="text-medium text-on-surface bg-white rounded px-1">${syncStatus.linkedFiles.join("</span> <span class='text-medium text-on-surface bg-white rounded px-1'>")}</span>`;
|
|
return {
|
|
icon: WarningCircleIcon,
|
|
ping: true,
|
|
weight: "fill",
|
|
color: "text-red-600",
|
|
variant: "danger",
|
|
text: duplicateText,
|
|
};
|
|
default:
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
function toggleSelection() {
|
|
if ($isTransferingRemote) return;
|
|
|
|
if (type === "buzzer") {
|
|
buzzerAudioFiles.update((files) =>
|
|
files.map((entry) =>
|
|
entry.name === file.name ? { ...entry, selected: !entry.selected } : entry,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
localAudioFiles.update((files) =>
|
|
files.map((entry) =>
|
|
entry.name === file.name ? { ...entry, selected: !entry.selected } : entry,
|
|
),
|
|
);
|
|
}
|
|
|
|
async function handleDeleteClick() {
|
|
if (!confirm(`Möchten Sie die Datei "${file.name}" wirklich löschen?`)) {
|
|
menuOpen = false;
|
|
return;
|
|
}
|
|
|
|
if (type === "buzzer") {
|
|
try {
|
|
const basePath = $fsInfo?.audioPath || "/lfs/a";
|
|
const fullPath = `${basePath}/${file.name}`;
|
|
await deleteRemoteFile(fullPath);
|
|
addToast(`Datei ${file.name} erfolgreich vom Buzzer gelöscht.`, "success");
|
|
await refreshRemote();
|
|
} catch (error) {
|
|
console.error("Fehler beim Löschen:", error);
|
|
addToast("Fehler beim Löschen der Datei auf dem Buzzer.", "error");
|
|
}
|
|
} else {
|
|
try {
|
|
await deleteLocalFile(file.name);
|
|
addToast(`Lokale Datei ${file.name} gelöscht.`, "success");
|
|
await refreshLocal();
|
|
} catch (error) {
|
|
console.error("Fehler beim Löschen:", error);
|
|
}
|
|
}
|
|
menuOpen = false;
|
|
}
|
|
</script>
|
|
|
|
<svelte:window on:click={() => (menuOpen = false)} />
|
|
|
|
<div class="relative overflow-hidden group/item">
|
|
{#if state === "active"}
|
|
<div
|
|
class="absolute top-0 left-0 h-full bg-indigo-100 z-0"
|
|
style="width: {$transferDetails.filePercent}%; transition: {$transferDetails.filePercent === 0
|
|
? 'none'
|
|
: `width ${SETTINGS.ui.transferUpdateIntervalMs}ms linear`};"
|
|
></div>
|
|
{/if}
|
|
|
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
<button
|
|
class="relative z-10 w-full text-left flex-1 px-3 py-1 pr-16 flex items-center border-l-4 transition-colors border-b border-b-border-card
|
|
{file.selected ? 'border-l-blue-600' : 'border-l-transparent'}
|
|
{file.selected && state !== 'active' ? 'bg-blue-50' : ''}
|
|
{!$isTransferingRemote && file.selected ? 'hover:bg-blue-100 cursor-pointer' : ''}
|
|
{!$isTransferingRemote && !file.selected
|
|
? 'hover:bg-slate-100 hover:border-l-blue-200 cursor-pointer'
|
|
: ''}
|
|
{$isTransferingRemote ? 'cursor-default' : ''}
|
|
{state === 'pending' ? 'grayscale opacity-80' : ''}"
|
|
on:click={toggleSelection}
|
|
on:mouseenter|stopPropagation={() => (menuOpen = true)}
|
|
on:mouseleave|stopPropagation={() => (menuOpen = false)}
|
|
on:blur|stopPropagation={() => (menuOpen = false)}
|
|
on:focus|stopPropagation={() => (menuOpen = true)}
|
|
disabled={$isTransferingRemote}
|
|
>
|
|
<MusicNotesIcon weight="fill" class="mr-2 w-5 h-5 shrink-0" />
|
|
|
|
<div class="flex flex-col flex-1 min-w-0 overflow-hidden pl-1">
|
|
<div class="flex items-center min-w-0">
|
|
<span class="font-light truncate min-w-0 text-sm">
|
|
{file.name || "Unbekannte Datei"}
|
|
{#if file.metaTags?.t}
|
|
 - 
|
|
<span class="font-normal">{file.metaTags.t}</span>
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-0 text-xs text-text-muted mt-0.5 min-w-0">
|
|
{#if statusConfig}
|
|
<span
|
|
use:tooltip={{
|
|
text: statusConfig.text,
|
|
pos: "right",
|
|
variant: statusConfig.variant,
|
|
}}
|
|
>
|
|
{#if statusConfig.ping}
|
|
<span class="mr-1 relative inline-flex size-3.5">
|
|
<svelte:component
|
|
this={statusConfig.icon}
|
|
weight={statusConfig.weight ? statusConfig.weight : "fill"}
|
|
class="opacity-57 text-amber-600 absolute inline-flex h-full w-full animate-ping"
|
|
/>
|
|
<svelte:component
|
|
this={statusConfig.icon}
|
|
weight={statusConfig.weight ? statusConfig.weight : "fill"}
|
|
class="shrink-0 w-3.5 h-3.5 {statusConfig.color}"
|
|
/>
|
|
</span>
|
|
{:else}
|
|
<svelte:component
|
|
this={statusConfig.icon}
|
|
weight={statusConfig.weight ? statusConfig.weight : "fill"}
|
|
class="mr-1 shrink-0 w-3.5 h-3.5 {statusConfig.color}"
|
|
/>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
|
|
<span class="font-light shrink-0">
|
|
{parseFloat((file.size / 1024).toFixed(1))} kB
|
|
</span>
|
|
{#if file.metaTags?.a}
|
|
<UserIcon
|
|
weight="fill"
|
|
class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted"
|
|
/>
|
|
<span class="truncate min-w-0">{file.metaTags.a}</span>
|
|
{/if}
|
|
{#if file.metaTags?.c}
|
|
<TagIcon
|
|
weight="fill"
|
|
class="ml-1 pl-1 mr-0.5 shrink-0 text-slate-500 w-4.5 h-3.5 border-l border-l-text-muted"
|
|
/>
|
|
<span class="truncate min-w-0">{file.metaTags.c}</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
<div
|
|
class="menu-btn-grp group/menu"
|
|
class:is-open={menuOpen}
|
|
on:mouseout|stopPropagation={() => (menuOpen = false)}
|
|
>
|
|
<div
|
|
class="flex items-center overflow-hidden transition-all duration-300 ease-in-out
|
|
{menuOpen
|
|
? 'max-w-[120px] opacity-100'
|
|
: 'max-w-0 opacity-0 group-hover/menu:max-w-[120px] group-hover/menu:opacity-100'}"
|
|
>
|
|
<button class="menu-btn danger" title="Löschen" on:click|stopPropagation={handleDeleteClick}>
|
|
<TrashIcon class="list-menu-icon" />
|
|
</button>
|
|
<button
|
|
class="menu-btn"
|
|
title="Abspielen"
|
|
on:click|stopPropagation={() => {
|
|
downloadLocalFile(file.name);
|
|
menuOpen = false;
|
|
}}
|
|
>
|
|
<CloudArrowDownIcon class="list-menu-icon" />
|
|
</button>
|
|
<button
|
|
class="menu-btn"
|
|
title="Abspielen"
|
|
on:click|stopPropagation={() => {
|
|
if (type === "buzzer") {
|
|
addToast;
|
|
} else {
|
|
playLocalFile(file.name);
|
|
menuOpen = false;
|
|
}
|
|
}}
|
|
>
|
|
<PlayIcon class="list-menu-icon" />
|
|
</button>
|
|
<button
|
|
class="menu-btn"
|
|
title="Datei-Info"
|
|
on:click|stopPropagation={() => {
|
|
tagEditorState.set({ show: true, type, fileName: file.name });
|
|
menuOpen = false;
|
|
}}
|
|
>
|
|
<PencilIcon class="list-menu-icon" />
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
class="menu-btn !border-r-transparent"
|
|
on:click|stopPropagation={() => (menuOpen = !menuOpen)}
|
|
>
|
|
<DotsThreeVerticalIcon class="list-menu-icon" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
@reference "../styles/app.css";
|
|
|
|
.menu-btn-grp {
|
|
@apply absolute right-2 top-1/2 -translate-y-1/2 z-20 overflow-hidden
|
|
p-0 flex items-center backdrop-blur-sm
|
|
rounded-full transition-all;
|
|
}
|
|
|
|
/* Kombiniert Hover von Mausnutzern und aktiven Touch-Zustand */
|
|
.menu-btn-grp:hover,
|
|
.menu-btn-grp.is-open {
|
|
@apply bg-white shadow-sm;
|
|
}
|
|
|
|
.menu-btn {
|
|
@apply border-r border-r-border-card
|
|
flex items-center justify-center shrink-0 transition-colors;
|
|
|
|
&:not(:disabled):not(.danger) {
|
|
@apply hover:bg-surface-hover;
|
|
}
|
|
|
|
&.danger:not(:disabled) {
|
|
@apply text-red-700 hover:bg-red-100;
|
|
}
|
|
|
|
&:disabled {
|
|
color: color-mix(in srgb, currentColor 50%, transparent);
|
|
@apply cursor-not-allowed grayscale;
|
|
}
|
|
}
|
|
</style>
|