5180 fichiers 38.5 Mo 0 téléchargements 401 vues mis à jour il y a 7 heures ui
nitro-v36-with-renderer-v36 / src / components / housekeeping / views / soundboard / HousekeepingSoundboardTab.tsx
HousekeepingSoundboardTab.tsx 19.1 Ko Télécharger le fichier
import { GetSoundManager, ISoundboardCatalogSound } from '@octane/renderer';
import { DragEvent, FC, useEffect, useMemo, useState } from 'react';
import { LocalizeText } from '../../../../api';
import { useSoundboardCatalog, useSoundboardManifest } from '../../../../hooks';
import {
    filterCatalogSounds,
    MAX_PAD_COOLDOWN_SECONDS,
    reorderCatalog,
    SoundboardCatalogDraft,
    SoundboardCatalogFilter,
    validateCatalogDraft
} from '../../../../hooks/soundboard/soundboardCatalogState';
import { resolveSoundboardSoundUrl } from '../../../../hooks/soundboard/soundboardUrl';
import { Checkbox } from '../../../../common/Checkbox';
import { Select } from '../../../../common/Select';
import { NumberInput } from '../../../../common/NumberInput';
import { BaseButton } from '../../../../common/BaseButton';
import { SuggestInput } from '../../../../common/SuggestInput';
import { NativeTextTree } from '../../../../common/native-text/NativeTextTree';

const EMPTY_DRAFT: SoundboardCatalogDraft = { id: 0, name: '', classname: '', url: '', minRank: 1, enabled: true, cooldownSeconds: 0 };
const RESULT_KEYS = [
    'success',
    'forbidden',
    'invalid_name',
    'invalid_url',
    'invalid_rank',
    'invalid_order',
    'not_found',
    'persistence_failure',
    'catalog_full',
    'invalid_cooldown'
];

export const HousekeepingSoundboardTab: FC = () => {
    const { sounds, lastResult, pendingOperation, request, upsert, reorder } = useSoundboardCatalog();
    const { manifest, classnames: knownClassnames } = useSoundboardManifest();
    const [query, setQuery] = useState('');
    const [filter, setFilter] = useState<SoundboardCatalogFilter>('all');
    const [draft, setDraft] = useState<SoundboardCatalogDraft>(EMPTY_DRAFT);
    const [orderedIds, setOrderedIds] = useState<number[]>([]);
    const [draggedId, setDraggedId] = useState<number | null>(null);
    const [previewFailed, setPreviewFailed] = useState(false);

    useEffect(() => request(), [request]);
    useEffect(() => setOrderedIds(sounds.map((sound) => sound.id)), [sounds]);
    useEffect(() => {
        if (lastResult?.operation !== 1 || lastResult.resultCode !== 0 || lastResult.soundId <= 0) return;

        setDraft((current) => (current.id === 0 ? { ...current, id: lastResult.soundId } : current));
    }, [lastResult]);

    const orderedSounds = useMemo(
        () => orderedIds.map((id) => sounds.find((sound) => sound.id === id)).filter((sound): sound is ISoundboardCatalogSound => !!sound),
        [orderedIds, sounds]
    );
    const filteredSounds = useMemo(() => filterCatalogSounds(orderedSounds, query, filter), [orderedSounds, query, filter]);
    const validation = validateCatalogDraft(draft);
    const draftLocked = pendingOperation !== null;
    const orderChanged = orderedIds.length > 0 && orderedIds.some((id, index) => id !== sounds[index]?.id);

    // Switches a pad on or off from its row, keeping everything else it has.
    const toggleEnabled = (sound: ISoundboardCatalogSound) => {
        upsert({
            id: sound.id,
            name: sound.name,
            classname: sound.classname ?? '',
            url: sound.url,
            minRank: sound.minRank,
            enabled: !sound.enabled,
            cooldownSeconds: sound.cooldownSeconds
        });
    };

    const editSound = (sound: ISoundboardCatalogSound) => {
        setDraft({
            id: sound.id,
            name: sound.name,
            classname: sound.classname ?? '',
            url: sound.url,
            minRank: sound.minRank,
            enabled: sound.enabled,
            cooldownSeconds: sound.cooldownSeconds
        });
    };

    const preview = (candidate: SoundboardCatalogDraft) => {
        if (!validateCatalogDraft(candidate).valid) return;

        setPreviewFailed(false);
        void GetSoundManager()
            .playSoundboardPreview(resolveSoundboardSoundUrl({ classname: candidate.classname?.trim() ?? '', url: candidate.url?.trim() ?? '' }, manifest))
            .then((played) => setPreviewFailed(!played));
    };

    const moveByOffset = (id: number, offset: number) => {
        setOrderedIds((current) => {
            const source = [...new Set(current)];
            const index = source.indexOf(id);
            const target = index + offset;
            if (index < 0 || target < 0 || target >= source.length) return source;

            [source[index], source[target]] = [source[target], source[index]];
            return source;
        });
    };

    const dropOn = (event: DragEvent, targetId: number) => {
        event.preventDefault();
        if (draggedId !== null) setOrderedIds((current) => reorderCatalog(current, draggedId, targetId));
        setDraggedId(null);
    };

    const resultKey = lastResult ? (RESULT_KEYS[lastResult.resultCode] ?? 'persistence_failure') : null;

    return (
        <NativeTextTree>
            <div className="flex flex-col gap-2.5">
                <div className="grid grid-cols-[1fr_auto] gap-2">
                    <input
                        type="search"
                        aria-label={LocalizeText('housekeeping.soundboard.search')}
                        placeholder={LocalizeText('housekeeping.soundboard.search')}
                        value={query}
                        onChange={(event) => setQuery(event.target.value)}
                        className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs"
                    />
                    <Select
                        aria-label={LocalizeText('housekeeping.soundboard.filter')}
                        value={filter}
                        onChange={(event) => setFilter(event.target.value as SoundboardCatalogFilter)}
                        className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs"
                    >
                        <option value="all">{LocalizeText('housekeeping.soundboard.filter.all')}</option>
                        <option value="enabled">{LocalizeText('housekeeping.soundboard.filter.enabled')}</option>
                        <option value="disabled">{LocalizeText('housekeeping.soundboard.filter.disabled')}</option>
                    </Select>
                </div>

                {resultKey && (
                    <div
                        role="status"
                        className={`rounded border px-2 py-1 text-xs ${lastResult.resultCode === 0 ? 'border-emerald-300 bg-emerald-50' : 'border-rose-300 bg-rose-50'}`}
                    >
                        {LocalizeText(`housekeeping.soundboard.result.${resultKey}`)}
                    </div>
                )}

                {previewFailed && (
                    <div role="alert" className="rounded border border-rose-300 bg-rose-50 px-2 py-1 text-xs">
                        {LocalizeText('housekeeping.soundboard.preview_failed')}
                    </div>
                )}

                <div className="grid grid-cols-2 gap-2 rounded border border-sky-200 bg-sky-50/40 p-2">
                    <div className="col-span-2 text-xs font-bold">
                        {draft.id > 0
                            ? LocalizeText('housekeeping.soundboard.editing', ['name', 'id'], [draft.name || '-', String(draft.id)])
                            : LocalizeText('housekeeping.soundboard.new_sound')}
                    </div>
                    <label className="flex flex-col gap-0.5 text-[10px] font-semibold uppercase tracking-wide">
                        {LocalizeText('housekeeping.soundboard.name')}
                        <input
                            aria-label={LocalizeText('housekeeping.soundboard.name')}
                            disabled={draftLocked}
                            value={draft.name}
                            onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))}
                            className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs font-normal normal-case"
                        />
                    </label>
                    <label className="flex flex-col gap-0.5 text-[10px] font-semibold uppercase tracking-wide">
                        {LocalizeText('housekeeping.soundboard.classname')}
                        <SuggestInput
                            aria-label={LocalizeText('housekeeping.soundboard.classname')}
                            disabled={draftLocked}
                            options={knownClassnames}
                            placeholder={knownClassnames[0] ?? ''}
                            value={draft.classname}
                            onChange={(classname) => setDraft((current) => ({ ...current, classname }))}
                            className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs font-normal normal-case"
                        />
                    </label>
                    <label className="flex flex-col gap-0.5 text-[10px] font-semibold uppercase tracking-wide">
                        {LocalizeText('housekeeping.soundboard.url')}
                        <input
                            aria-label={LocalizeText('housekeeping.soundboard.url')}
                            disabled={draftLocked}
                            value={draft.url}
                            onChange={(event) => setDraft((current) => ({ ...current, url: event.target.value }))}
                            className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs font-normal normal-case"
                        />
                    </label>
                    <label className="flex flex-col gap-0.5 text-[10px] font-semibold uppercase tracking-wide">
                        {LocalizeText('housekeeping.soundboard.min_rank')}
                        <NumberInput min={1} step={1} aria-label={LocalizeText('housekeeping.soundboard.min_rank')} disabled={draftLocked} value={draft.minRank} onChange={(event) => setDraft((current) => ({ ...current, minRank: Number(event.target.value) }))} className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs font-normal normal-case" />
                    </label>
                    <label className="flex flex-col gap-0.5 text-[10px] font-semibold uppercase tracking-wide">
                        {LocalizeText('housekeeping.soundboard.cooldown')}
                        <NumberInput min={0} max={MAX_PAD_COOLDOWN_SECONDS} step={1} aria-label={LocalizeText('housekeeping.soundboard.cooldown')} disabled={draftLocked} value={draft.cooldownSeconds} onChange={(event) => setDraft((current) => ({ ...current, cooldownSeconds: Number(event.target.value) }))} className="rounded border border-zinc-300 bg-white px-2 py-1 text-xs font-normal normal-case" />
                    </label>
                    <label className="flex items-center gap-2 self-end py-1 text-xs font-semibold">
                        <Checkbox checked={draft.enabled} disabled={draftLocked} onCheckedChange={(nextChecked) => setDraft((current) => ({ ...current, enabled: nextChecked }))} />
                        {LocalizeText('housekeeping.soundboard.enabled')}
                    </label>
                    {!validation.valid && (
                        <div role="alert" className="col-span-2 text-[10px] text-rose-700">
                            {Object.values(validation.errors)
                                .map((error) => LocalizeText(`housekeeping.soundboard.validation.${error}`))
                                .join(' · ')}
                        </div>
                    )}
                    <div className="col-span-2 flex flex-wrap justify-end gap-1.5">
                        <BaseButton
                            type="button"
                            disabled={draftLocked}
                            onClick={() => setDraft({ ...EMPTY_DRAFT })}
                            className="rounded bg-zinc-200 px-2 py-1 text-xs font-semibold disabled:opacity-40"
                        >
                            {LocalizeText('housekeeping.soundboard.create')}
                        </BaseButton>
                        <BaseButton
                            type="button"
                            disabled={!validation.valid}
                            onClick={() => preview(draft)}
                            className="rounded bg-sky-600 px-2 py-1 text-xs font-semibold text-white disabled:opacity-40"
                        >
                            {LocalizeText('housekeeping.soundboard.preview')}
                        </BaseButton>
                        <BaseButton
                            type="button"
                            disabled={!validation.valid || pendingOperation !== null}
                            onClick={() => upsert(draft)}
                            className="rounded bg-emerald-600 px-2 py-1 text-xs font-semibold text-white disabled:opacity-40"
                        >
                            {LocalizeText('housekeeping.soundboard.save')}
                        </BaseButton>
                    </div>
                </div>

                <div className="flex max-h-[360px] flex-col gap-1 overflow-y-auto">
                    {filteredSounds.map((sound) => {
                        const orderIndex = orderedIds.indexOf(sound.id);

                        return (
                            <div
                                key={sound.id}
                                data-testid={`soundboard-catalog-row-${sound.id}`}
                                draggable
                                onDragStart={() => setDraggedId(sound.id)}
                                onDragOver={(event) => event.preventDefault()}
                                onDrop={(event) => dropOn(event, sound.id)}
                                className={`grid grid-cols-[1fr_auto] items-center gap-2 rounded border bg-white px-2 py-1.5 ${draft.id === sound.id ? 'border-sky-400 ring-1 ring-sky-300' : 'border-zinc-200'} ${sound.enabled ? '' : 'opacity-60'}`}
                            >
                                <div className="min-w-0">
                                    <div className="truncate text-xs font-semibold">
                                        {sound.name} <span className="font-normal text-zinc-400">#{sound.id}</span>
                                    </div>
                                    <div className="flex flex-wrap items-center gap-1 text-[10px] text-zinc-500">
                                        <span className="truncate">{sound.classname || sound.url}</span>
                                        <span className="rounded bg-zinc-100 px-1">rank {sound.minRank}</span>
                                        {sound.cooldownSeconds > 0 && <span className="rounded bg-amber-100 px-1">{sound.cooldownSeconds}s</span>}
                                        <span className={`rounded px-1 ${sound.enabled ? 'bg-emerald-100 text-emerald-800' : 'bg-rose-100 text-rose-800'}`}>
                                            {sound.enabled
                                                ? LocalizeText('housekeeping.soundboard.filter.enabled')
                                                : LocalizeText('housekeeping.soundboard.filter.disabled')}
                                        </span>
                                    </div>
                                </div>
                                <div className="flex items-center gap-1">
                                    <BaseButton
                                        type="button"
                                        aria-label={`${LocalizeText('housekeeping.soundboard.preview')} ${sound.name}`}
                                        onClick={() => preview(sound)}
                                        className="rounded bg-sky-100 px-1.5 py-1 text-[10px]"
                                    >
                                        ▶
                                    </BaseButton>
                                    <BaseButton
                                        type="button"
                                        disabled={draftLocked}
                                        aria-label={`${LocalizeText('housekeeping.soundboard.edit')} ${sound.name}`}
                                        onClick={() => editSound(sound)}
                                        className="rounded bg-zinc-200 px-1.5 py-1 text-[10px] disabled:opacity-40"
                                    >
                                        {LocalizeText('housekeeping.soundboard.edit')}
                                    </BaseButton>
                                    <BaseButton
                                        type="button"
                                        disabled={draftLocked}
                                        aria-label={`${LocalizeText(sound.enabled ? 'housekeeping.soundboard.disable' : 'housekeeping.soundboard.enable')} ${sound.name}`}
                                        onClick={() => toggleEnabled(sound)}
                                        className="rounded bg-zinc-100 px-1.5 py-1 text-[10px] disabled:opacity-40"
                                    >
                                        {sound.enabled ? '⏻' : '○'}
                                    </BaseButton>
                                    <BaseButton
                                        type="button"
                                        disabled={orderIndex <= 0}
                                        aria-label={`${LocalizeText('housekeeping.soundboard.move_up')} ${sound.name}`}
                                        onClick={() => moveByOffset(sound.id, -1)}
                                        className="rounded bg-zinc-100 px-1.5 py-1 text-[10px] disabled:opacity-30"
                                    >
                                        ↑
                                    </BaseButton>
                                    <BaseButton
                                        type="button"
                                        disabled={orderIndex < 0 || orderIndex >= orderedIds.length - 1}
                                        aria-label={`${LocalizeText('housekeeping.soundboard.move_down')} ${sound.name}`}
                                        onClick={() => moveByOffset(sound.id, 1)}
                                        className="rounded bg-zinc-100 px-1.5 py-1 text-[10px] disabled:opacity-30"
                                    >
                                        ↓
                                    </BaseButton>
                                </div>
                            </div>
                        );
                    })}
                </div>

                <div className="flex items-center justify-end gap-2">
                    {orderChanged && <span className="text-[10px] text-amber-700">{LocalizeText('housekeeping.soundboard.order_changed')}</span>}
                    <BaseButton
                        type="button"
                        disabled={!orderChanged || pendingOperation !== null}
                        onClick={() => reorder(orderedIds)}
                        className="rounded bg-violet-600 px-2.5 py-1 text-xs font-semibold text-white disabled:opacity-40"
                    >
                        {LocalizeText('housekeeping.soundboard.save_order')}
                    </BaseButton>
                </div>
            </div>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
3En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser