Files
buzzer_2/webpage/src/components/FileListItem.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}
&thinsp;-&thinsp;
<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))}&thinsp;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>