890d5dc2b7
Mount useSoundboardSync in SoundboardManagerDialog, thread badges map through SoundboardCategoryGroup/SoundboardRow, render a cloud-state glyph badge inline with each row's size/mime metadata, and wrap handleDelete to attempt a best-effort remote delete via deleteRemoteSound before the local deleteSound call. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
670 lines
22 KiB
TypeScript
670 lines
22 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
|
||
import { deleteSound as deleteRemoteSound } from '@chat-app/shared/chat';
|
||
import { getPttSettings } from '../lib/pttSettings';
|
||
import { codeToShortcut } from '../lib/globalShortcut';
|
||
import { invalidate as invalidateSoundCache, preload } from '../lib/soundboardPlayback';
|
||
import {
|
||
addSound,
|
||
deleteSound,
|
||
getSoundBlob,
|
||
listSounds,
|
||
MAX_SOUND_BYTES,
|
||
reorderCategory,
|
||
type SoundboardEntry,
|
||
subscribeSoundboardChanges,
|
||
updateSound,
|
||
} from '../lib/soundboardStorage';
|
||
import { supabase } from '../lib/supabase';
|
||
import { useSoundboardSync, type SyncBadge } from '../hooks/useSoundboardSync';
|
||
import { Modal } from './Modal';
|
||
import {
|
||
AlertIcon,
|
||
ChevronDownIcon,
|
||
PencilIcon,
|
||
PlusIcon,
|
||
SpinnerIcon,
|
||
TrashIcon,
|
||
} from './icons';
|
||
|
||
interface Props {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
const BYTES_PER_MB = 1024 * 1024;
|
||
const CATEGORY_LIST_ID = 'sb-category-list';
|
||
|
||
// Admin UI for the soundboard. Users add, rename, categorise, reorder,
|
||
// assign hotkeys, adjust per-sound volume, preview and delete clips here.
|
||
// In-call panel only reads the resulting manifest.
|
||
export function SoundboardManagerDialog({ open, onClose }: Props) {
|
||
const { t } = useTranslation(['app']);
|
||
const fileRef = useRef<HTMLInputElement | null>(null);
|
||
const previewRef = useRef<HTMLAudioElement | null>(null);
|
||
const previewUrlRef = useRef<string | null>(null);
|
||
const [entries, setEntries] = useState<SoundboardEntry[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [busyId, setBusyId] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||
const { badges } = useSoundboardSync();
|
||
|
||
const refresh = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
setEntries(await listSounds());
|
||
} catch (err: unknown) {
|
||
console.error('listSounds failed', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
void refresh();
|
||
// External mutations (hotkey fires, multi-tab edits) should reflect
|
||
// immediately while the dialog is open.
|
||
const unsub = subscribeSoundboardChanges(() => {
|
||
void refresh();
|
||
});
|
||
return () => {
|
||
unsub();
|
||
stopPreview();
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open, refresh]);
|
||
|
||
useEffect(() => {
|
||
return () => stopPreview();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const categories = useMemo(() => {
|
||
const set = new Set<string>();
|
||
for (const e of entries) if (e.category) set.add(e.category);
|
||
return Array.from(set).sort((a, b) => a.localeCompare(b));
|
||
}, [entries]);
|
||
|
||
const grouped = useMemo(() => {
|
||
const map = new Map<string, SoundboardEntry[]>();
|
||
for (const e of entries) {
|
||
const key = e.category ?? '';
|
||
const arr = map.get(key);
|
||
if (arr) arr.push(e);
|
||
else map.set(key, [e]);
|
||
}
|
||
return map;
|
||
}, [entries]);
|
||
|
||
function stopPreview(): void {
|
||
const el = previewRef.current;
|
||
if (el) {
|
||
try {
|
||
el.pause();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
el.src = '';
|
||
}
|
||
previewRef.current = null;
|
||
if (previewUrlRef.current) {
|
||
URL.revokeObjectURL(previewUrlRef.current);
|
||
previewUrlRef.current = null;
|
||
}
|
||
setPreviewingId(null);
|
||
}
|
||
|
||
async function handleAdd(file: File): Promise<void> {
|
||
setError(null);
|
||
setBusyId('__add');
|
||
try {
|
||
await addSound({ file });
|
||
await refresh();
|
||
} catch (err: unknown) {
|
||
const code = err instanceof Error ? err.message : 'add_failed';
|
||
if (code === 'sound_too_large') {
|
||
setError(
|
||
t('app:soundboard.error_too_large', {
|
||
defaultValue: 'Datei zu groß (max {{max}} MB).',
|
||
max: MAX_SOUND_BYTES / BYTES_PER_MB,
|
||
}),
|
||
);
|
||
} else if (code === 'sound_not_audio') {
|
||
setError(
|
||
t('app:soundboard.error_not_audio', {
|
||
defaultValue: 'Nur Audio-Dateien werden unterstützt.',
|
||
}),
|
||
);
|
||
} else {
|
||
setError(
|
||
t('app:soundboard.error_generic', {
|
||
defaultValue: 'Sound konnte nicht gespeichert werden.',
|
||
}),
|
||
);
|
||
}
|
||
} finally {
|
||
setBusyId(null);
|
||
if (fileRef.current) fileRef.current.value = '';
|
||
}
|
||
}
|
||
|
||
async function handlePatch(
|
||
id: string,
|
||
patch: Parameters<typeof updateSound>[1],
|
||
): Promise<void> {
|
||
setBusyId(id);
|
||
try {
|
||
await updateSound(id, patch);
|
||
} catch (err: unknown) {
|
||
console.error('updateSound failed', err);
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: string): Promise<void> {
|
||
if (!window.confirm(t('app:soundboard.delete_confirm', { defaultValue: 'Sound löschen?' }))) {
|
||
return;
|
||
}
|
||
setBusyId(id);
|
||
try {
|
||
try {
|
||
await deleteRemoteSound(supabase, id);
|
||
} catch (err) {
|
||
console.warn('remote sound delete failed (local delete proceeds)', err);
|
||
}
|
||
await deleteSound(id);
|
||
invalidateSoundCache(id);
|
||
if (previewingId === id) stopPreview();
|
||
} catch (err: unknown) {
|
||
console.error('deleteSound failed', err);
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
}
|
||
|
||
async function handlePreview(entry: SoundboardEntry): Promise<void> {
|
||
if (previewingId === entry.id) {
|
||
stopPreview();
|
||
return;
|
||
}
|
||
stopPreview();
|
||
const blob = await getSoundBlob(entry.id);
|
||
if (!blob) return;
|
||
const url = URL.createObjectURL(blob);
|
||
const el = new Audio(url);
|
||
el.volume = entry.gain;
|
||
el.onended = () => stopPreview();
|
||
el.onerror = () => stopPreview();
|
||
el.play().catch(() => stopPreview());
|
||
previewRef.current = el;
|
||
previewUrlRef.current = url;
|
||
setPreviewingId(entry.id);
|
||
// Opportunistic warm-up of the AudioBuffer cache so the first in-call
|
||
// playback doesn't pause on decode.
|
||
void preload(entry.id);
|
||
}
|
||
|
||
async function handleReorder(
|
||
category: string | null,
|
||
idx: number,
|
||
dir: -1 | 1,
|
||
): Promise<void> {
|
||
const bucket = grouped.get(category ?? '') ?? [];
|
||
const next = idx + dir;
|
||
if (next < 0 || next >= bucket.length) return;
|
||
const reordered = bucket.slice();
|
||
const tmp = reordered[idx]!;
|
||
reordered[idx] = reordered[next]!;
|
||
reordered[next] = tmp;
|
||
setBusyId('__reorder:' + (category ?? ''));
|
||
try {
|
||
await reorderCategory(
|
||
category,
|
||
reordered.map((e) => e.id),
|
||
);
|
||
} catch (err: unknown) {
|
||
console.error('reorderCategory failed', err);
|
||
} finally {
|
||
setBusyId(null);
|
||
}
|
||
}
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onClose={onClose}
|
||
title={t('app:soundboard.manager_title', { defaultValue: 'Soundboard verwalten' })}
|
||
size="lg"
|
||
>
|
||
<div className="flex flex-col gap-4">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<p className="text-xs text-fg-muted">
|
||
{t('app:soundboard.manager_hint', {
|
||
defaultValue:
|
||
'Beliebig viele Sounds, kein Hotkey nötig. Hotkeys feuern nur während eines Anrufs.',
|
||
})}
|
||
</p>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept="audio/*"
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const f = e.target.files?.[0];
|
||
if (f) void handleAdd(f);
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileRef.current?.click()}
|
||
disabled={busyId !== null}
|
||
className="inline-flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{busyId === '__add' ? (
|
||
<SpinnerIcon className="h-3.5 w-3.5" />
|
||
) : (
|
||
<PlusIcon className="h-3.5 w-3.5" />
|
||
)}
|
||
<span>{t('app:soundboard.add', { defaultValue: 'Sound hinzufügen' })}</span>
|
||
</button>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs text-rose-600 dark:text-rose-300">
|
||
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
|
||
<p className="min-w-0 flex-1 break-words">{error}</p>
|
||
</div>
|
||
)}
|
||
|
||
<datalist id={CATEGORY_LIST_ID}>
|
||
{categories.map((c) => (
|
||
<option key={c} value={c} />
|
||
))}
|
||
</datalist>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center gap-2 text-xs text-fg-muted">
|
||
<SpinnerIcon className="h-3.5 w-3.5 text-accent" />
|
||
</div>
|
||
) : entries.length === 0 ? (
|
||
<p className="rounded-lg border border-line bg-surface-2 px-4 py-6 text-center text-sm text-fg-muted">
|
||
{t('app:soundboard.empty', {
|
||
defaultValue: 'Noch keine Sounds. Lade oben welche hoch.',
|
||
})}
|
||
</p>
|
||
) : (
|
||
<div className="flex flex-col gap-4">
|
||
{Array.from(grouped.entries()).map(([categoryKey, bucket]) => {
|
||
const category = categoryKey === '' ? null : categoryKey;
|
||
return (
|
||
<SoundboardCategoryGroup
|
||
key={categoryKey || '__uncat'}
|
||
category={category}
|
||
entries={bucket}
|
||
entriesTotal={entries}
|
||
busyId={busyId}
|
||
previewingId={previewingId}
|
||
badges={badges}
|
||
onPatch={handlePatch}
|
||
onDelete={handleDelete}
|
||
onPreview={handlePreview}
|
||
onReorder={handleReorder}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface GroupProps {
|
||
category: string | null;
|
||
entries: SoundboardEntry[];
|
||
entriesTotal: SoundboardEntry[];
|
||
busyId: string | null;
|
||
previewingId: string | null;
|
||
badges: Map<string, SyncBadge>;
|
||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||
onDelete: (id: string) => Promise<void>;
|
||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||
onReorder: (category: string | null, idx: number, dir: -1 | 1) => Promise<void>;
|
||
}
|
||
|
||
function SoundboardCategoryGroup({
|
||
category,
|
||
entries,
|
||
entriesTotal,
|
||
busyId,
|
||
previewingId,
|
||
badges,
|
||
onPatch,
|
||
onDelete,
|
||
onPreview,
|
||
onReorder,
|
||
}: GroupProps) {
|
||
const { t } = useTranslation(['app']);
|
||
const [open, setOpen] = useState(true);
|
||
const label =
|
||
category ??
|
||
t('app:soundboard.category_uncategorized', { defaultValue: '(Ohne Kategorie)' });
|
||
return (
|
||
<section className="rounded-lg border border-line bg-surface-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex w-full cursor-pointer items-center justify-between gap-3 px-4 py-2.5 text-left"
|
||
>
|
||
<span className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">
|
||
{label} · {entries.length}
|
||
</span>
|
||
<ChevronDownIcon
|
||
className={'h-4 w-4 text-fg-muted transition ' + (open ? '' : '-rotate-90')}
|
||
/>
|
||
</button>
|
||
{open && (
|
||
<ul className="flex flex-col gap-2 border-t border-line p-3">
|
||
{entries.map((entry, idx) => (
|
||
<SoundboardRow
|
||
key={entry.id}
|
||
entry={entry}
|
||
entriesTotal={entriesTotal}
|
||
isFirst={idx === 0}
|
||
isLast={idx === entries.length - 1}
|
||
busy={busyId === entry.id}
|
||
previewing={previewingId === entry.id}
|
||
badge={badges.get(entry.id)}
|
||
onPatch={onPatch}
|
||
onDelete={onDelete}
|
||
onPreview={onPreview}
|
||
onReorderUp={() => onReorder(category, idx, -1)}
|
||
onReorderDown={() => onReorder(category, idx, 1)}
|
||
/>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface RowProps {
|
||
entry: SoundboardEntry;
|
||
entriesTotal: SoundboardEntry[];
|
||
isFirst: boolean;
|
||
isLast: boolean;
|
||
busy: boolean;
|
||
previewing: boolean;
|
||
badge: SyncBadge | undefined;
|
||
onPatch: (id: string, patch: Parameters<typeof updateSound>[1]) => Promise<void>;
|
||
onDelete: (id: string) => Promise<void>;
|
||
onPreview: (entry: SoundboardEntry) => Promise<void>;
|
||
onReorderUp: () => Promise<void>;
|
||
onReorderDown: () => Promise<void>;
|
||
}
|
||
|
||
function SoundboardRow({
|
||
entry,
|
||
entriesTotal,
|
||
isFirst,
|
||
isLast,
|
||
busy,
|
||
previewing,
|
||
badge,
|
||
onPatch,
|
||
onDelete,
|
||
onPreview,
|
||
onReorderUp,
|
||
onReorderDown,
|
||
}: RowProps) {
|
||
const { t } = useTranslation(['app']);
|
||
const [editingName, setEditingName] = useState(false);
|
||
const [nameDraft, setNameDraft] = useState(entry.name);
|
||
const [categoryDraft, setCategoryDraft] = useState(entry.category ?? '');
|
||
const [capturingHotkey, setCapturingHotkey] = useState(false);
|
||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setNameDraft(entry.name);
|
||
setCategoryDraft(entry.category ?? '');
|
||
}, [entry.name, entry.category]);
|
||
|
||
useEffect(() => {
|
||
if (!capturingHotkey) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (e.code === 'Escape') {
|
||
setCapturingHotkey(false);
|
||
setHotkeyError(null);
|
||
return;
|
||
}
|
||
// Conflict checks: PTT + other soundboard entries with this code.
|
||
const ptt = getPttSettings();
|
||
if (ptt.enabled && ptt.key === e.code) {
|
||
setHotkeyError(
|
||
t('app:soundboard.hotkey_conflict_ptt', {
|
||
defaultValue: 'Konflikt mit Push-to-Talk.',
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
const taken = entriesTotal.find((s) => s.id !== entry.id && s.hotkey === e.code);
|
||
if (taken) {
|
||
setHotkeyError(
|
||
t('app:soundboard.hotkey_conflict_sound', {
|
||
defaultValue: 'Bereits von "{{name}}" belegt.',
|
||
name: taken.name,
|
||
}),
|
||
);
|
||
return;
|
||
}
|
||
setHotkeyError(null);
|
||
setCapturingHotkey(false);
|
||
void onPatch(entry.id, { hotkey: e.code });
|
||
};
|
||
window.addEventListener('keydown', onKey, { capture: true });
|
||
return () => window.removeEventListener('keydown', onKey, { capture: true });
|
||
}, [capturingHotkey, entriesTotal, entry.id, onPatch, t]);
|
||
|
||
const hotkeyLabel = entry.hotkey ? codeToShortcut(entry.hotkey) : null;
|
||
|
||
return (
|
||
<li className="flex flex-wrap items-center gap-3 rounded-md border border-line bg-surface-3 px-3 py-2">
|
||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||
{editingName ? (
|
||
<input
|
||
autoFocus
|
||
value={nameDraft}
|
||
onChange={(e) => setNameDraft(e.target.value)}
|
||
onBlur={() => {
|
||
setEditingName(false);
|
||
if (nameDraft.trim() && nameDraft !== entry.name) {
|
||
void onPatch(entry.id, { name: nameDraft });
|
||
} else {
|
||
setNameDraft(entry.name);
|
||
}
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
(e.target as HTMLInputElement).blur();
|
||
}
|
||
if (e.key === 'Escape') {
|
||
setNameDraft(entry.name);
|
||
setEditingName(false);
|
||
}
|
||
}}
|
||
className="w-full rounded border border-line bg-surface-2 px-2 py-1 text-sm text-fg focus:border-accent focus:outline-none"
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingName(true)}
|
||
className="flex cursor-pointer items-center gap-1.5 self-start text-sm font-semibold text-fg hover:text-accent"
|
||
>
|
||
{entry.name}
|
||
<PencilIcon className="h-3 w-3 opacity-50" />
|
||
</button>
|
||
)}
|
||
<p className="text-[10px] text-fg-muted">
|
||
{(entry.size / BYTES_PER_MB).toFixed(2)} MB · {entry.mime}
|
||
<span
|
||
title={badgeTitle(badge)}
|
||
aria-label={badgeTitle(badge)}
|
||
className="ml-2 inline-flex items-center text-[10px] font-medium text-fg-muted"
|
||
>
|
||
{badgeGlyph(badge)}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
<input
|
||
type="text"
|
||
value={categoryDraft}
|
||
onChange={(e) => setCategoryDraft(e.target.value)}
|
||
onBlur={() => {
|
||
const next = categoryDraft.trim() || null;
|
||
if (next !== (entry.category ?? null)) {
|
||
void onPatch(entry.id, { category: next });
|
||
}
|
||
}}
|
||
list={CATEGORY_LIST_ID}
|
||
placeholder={t('app:soundboard.category_placeholder', {
|
||
defaultValue: 'Kategorie…',
|
||
})}
|
||
className="w-32 shrink-0 rounded border border-line bg-surface-2 px-2 py-1 text-xs text-fg placeholder-fg-muted focus:border-accent focus:outline-none"
|
||
/>
|
||
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setHotkeyError(null);
|
||
setCapturingHotkey((v) => !v);
|
||
}}
|
||
className={
|
||
'inline-flex min-w-[5rem] cursor-pointer items-center justify-center rounded border px-2 py-1 text-[11px] font-mono font-semibold transition ' +
|
||
(capturingHotkey
|
||
? 'animate-pulse border-accent bg-accent/20 text-fg'
|
||
: hotkeyLabel
|
||
? 'border-accent/40 bg-accent/10 text-accent'
|
||
: 'border-line bg-surface-2 text-fg-muted hover:bg-surface')
|
||
}
|
||
title={t('app:soundboard.hotkey_capture', {
|
||
defaultValue: 'Hotkey binden (Esc = abbrechen)',
|
||
})}
|
||
>
|
||
{capturingHotkey
|
||
? t('app:soundboard.hotkey_press', { defaultValue: 'Drücke…' })
|
||
: hotkeyLabel ??
|
||
t('app:soundboard.hotkey_none', { defaultValue: 'Kein Hotkey' })}
|
||
</button>
|
||
{entry.hotkey && !capturingHotkey && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void onPatch(entry.id, { hotkey: null })}
|
||
aria-label={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||
title={t('app:soundboard.hotkey_clear', { defaultValue: 'Hotkey entfernen' })}
|
||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={1}
|
||
step={0.05}
|
||
value={entry.gain}
|
||
onChange={(e) => void onPatch(entry.id, { gain: Number(e.target.value) })}
|
||
className="accent-accent w-20"
|
||
title={t('app:soundboard.gain_title', {
|
||
defaultValue: 'Lautstärke',
|
||
})}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void onPreview(entry)}
|
||
disabled={busy}
|
||
className="inline-flex cursor-pointer items-center gap-1 rounded border border-line bg-surface-2 px-2 py-1 text-[11px] font-medium text-fg transition hover:bg-surface disabled:opacity-50"
|
||
>
|
||
{previewing
|
||
? t('app:soundboard.preview_stop', { defaultValue: 'Stop' })
|
||
: t('app:soundboard.preview', { defaultValue: 'Vorhören' })}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onReorderUp}
|
||
disabled={busy || isFirst}
|
||
aria-label={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||
title={t('app:soundboard.move_up', { defaultValue: 'Nach oben' })}
|
||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||
>
|
||
↑
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onReorderDown}
|
||
disabled={busy || isLast}
|
||
aria-label={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||
title={t('app:soundboard.move_down', { defaultValue: 'Nach unten' })}
|
||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-fg-muted hover:bg-surface-2 hover:text-fg disabled:opacity-40"
|
||
>
|
||
↓
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void onDelete(entry.id)}
|
||
disabled={busy}
|
||
aria-label={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||
title={t('app:soundboard.delete', { defaultValue: 'Löschen' })}
|
||
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded border border-rose-500/30 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 disabled:opacity-50 dark:text-rose-300"
|
||
>
|
||
<TrashIcon className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
|
||
{hotkeyError && capturingHotkey && (
|
||
<p className="basis-full text-[11px] text-rose-600 dark:text-rose-300">
|
||
{hotkeyError}
|
||
</p>
|
||
)}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function badgeGlyph(b: SyncBadge | undefined): string {
|
||
switch (b) {
|
||
case 'uploading': return '↑';
|
||
case 'downloading': return '↓';
|
||
case 'error': return '⚠';
|
||
case 'synced':
|
||
default: return '☁';
|
||
}
|
||
}
|
||
|
||
function badgeTitle(b: SyncBadge | undefined): string {
|
||
switch (b) {
|
||
case 'uploading': return 'Hochladen…';
|
||
case 'downloading': return 'Wird heruntergeladen…';
|
||
case 'error': return 'Synchronisationsfehler';
|
||
case 'synced':
|
||
default: return 'Synchronisiert';
|
||
}
|
||
}
|