5180 fichiers 38.5 Mo 0 téléchargements 305 vues mis à jour il y a 6 heures ui
nitro-v36-with-renderer-v36 / src / components / furni-editor / views / FurniEditorSearchView.tsx
FurniEditorSearchView.tsx 17.6 Ko Télécharger le fichier
import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor';
import { suggestInteractionType } from '../furniEditorSuggestions';
import { BaseButton } from '../../../common/BaseButton';
import { ToggleButton } from '../../../common/ToggleButton';
import { Hint } from '../../../common/Tooltip';
import { NativeTextTree } from '../../../common/native-text/NativeTextTree';

interface FurniEditorSearchViewProps {
    items: FurniItem[];
    total: number;
    page: number;
    loading: boolean;
    // Registered interaction types: a row whose stored type is not among them,
    // or whose classname points at another one, is flagged as worth a look.
    interactions: string[];
    onSearch: (query: string, type: string, page: number, sortField: string, sortDir: string) => void;
    onSelect: (id: number) => void;
}

const attention = (item: FurniItem, interactions: string[]): string | null => {
    const stored = item.interactionType.trim().toLowerCase();
    if (stored && !interactions.some((known) => known.toLowerCase() === stored)) return `type "${item.interactionType}" has no class, behaves as default`;
    const hint = suggestInteractionType(item.itemName, interactions);
    if (hint && hint.type.toLowerCase() !== stored) return `classname suggests "${hint.type}" (${hint.reason})`;
    return null;
};

type SortField = 'id' | 'spriteId' | 'itemName' | 'publicName' | 'type' | 'interactionType';
type SortDir = 'asc' | 'desc';

const PAGE_SIZE = 20;

const COLUMNS: { field: SortField; label: string; align: 'left' | 'center' }[] = [
    { field: 'id', label: 'ID', align: 'left' },
    { field: 'spriteId', label: 'Sprite', align: 'left' },
    { field: 'itemName', label: 'Name', align: 'left' },
    { field: 'publicName', label: 'Public Name', align: 'left' },
    { field: 'type', label: 'Type', align: 'center' },
    { field: 'interactionType', label: 'Interaction', align: 'left' }
];

const SortArrow: FC<{ field: SortField; active: SortField; dir: SortDir }> = ({ field, active, dir }) => {
    if (field !== active) return <span className="ml-1 text-slate-300">↕</span>;

    return <span className="ml-1 text-primary">{dir === 'asc' ? '▲' : '▼'}</span>;
};

const PagBtn: FC<{ disabled?: boolean; onClick: () => void; children: ReactNode; title?: string }> = ({ disabled, onClick, children, title }) => (
    <NativeTextTree>
        <Hint content={title}><BaseButton
            type="button"
            disabled={disabled}
            onClick={onClick}
            className="w-7 h-7 flex items-center justify-center rounded-lg border border-slate-200 bg-[#ffffff] text-slate-600 text-sm leading-none hover:bg-slate-50 hover:border-slate-300 disabled:opacity-40 disabled:cursor-not-allowed transition"
        >
            {children}
        </BaseButton></Hint>
    </NativeTextTree>
);

export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = (props) => {
    const { items, total, page, loading, interactions, onSearch, onSelect } = props;
    const [onlyFlagged, setOnlyFlagged] = useState(false);
    const flagged = items.map((item) => attention(item, interactions));
    const flaggedCount = flagged.filter(Boolean).length;
    const rows = items.map((item, index) => ({ item, note: flagged[index] })).filter((row) => !onlyFlagged || row.note);
    const [query, setQuery] = useState('');
    const [typeFilter, setTypeFilter] = useState('');
    const [sortField, setSortField] = useState<SortField>('id');
    const [sortDir, setSortDir] = useState<SortDir>('asc');
    const [pageInput, setPageInput] = useState('1');

    const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
    const from = total === 0 ? 0 : (page - 1) * PAGE_SIZE + 1;
    const to = Math.min(page * PAGE_SIZE, total);

    // Latest filter/sort for the debounced query effect (avoids stale closure).
    const stateRef = useRef({ typeFilter, sortField, sortDir });
    stateRef.current = { typeFilter, sortField, sortDir };

    // Initial fetch (once).
    const didInit = useRef(false);
    useEffect(() => {
        if (didInit.current) return;
        didInit.current = true;
        onSearch('', '', 1, 'id', 'asc');
    }, [onSearch]);

    // Keep the page input synced with the authoritative page from the server.
    useEffect(() => {
        setPageInput(String(page));
    }, [page]);

    // Debounced live search as the user types (skips the first render).
    const firstQuery = useRef(true);
    useEffect(() => {
        if (firstQuery.current) {
            firstQuery.current = false;
            return;
        }

        const handle = window.setTimeout(() => {
            const s = stateRef.current;
            onSearch(query, s.typeFilter, 1, s.sortField, s.sortDir);
        }, 350);

        return () => window.clearTimeout(handle);
    }, [query, onSearch]);

    const applyType = useCallback(
        (t: string) => {
            const next = typeFilter === t ? '' : t;
            setTypeFilter(next);
            onSearch(query, next, 1, sortField, sortDir);
        },
        [typeFilter, query, sortField, sortDir, onSearch]
    );

    const applySort = useCallback(
        (field: SortField) => {
            const nextDir: SortDir = sortField === field && sortDir === 'asc' ? 'desc' : 'asc';
            setSortField(field);
            setSortDir(nextDir);
            onSearch(query, typeFilter, 1, field, nextDir);
        },
        [sortField, sortDir, query, typeFilter, onSearch]
    );

    const goTo = useCallback(
        (pg: number) => {
            const clamped = Math.min(Math.max(1, pg || 1), totalPages);
            onSearch(query, typeFilter, clamped, sortField, sortDir);
        },
        [totalPages, query, typeFilter, sortField, sortDir, onSearch]
    );

    const inputClass =
        'w-full pl-9 pr-8 py-2 text-sm leading-normal rounded-lg border border-slate-300 bg-[#ffffff] focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15 transition';

    return (
        <NativeTextTree>
            <Column gap={2} className="h-full">
                {/* Search + filters */}
                <Flex gap={2} alignItems="center">
                    <div className="relative flex-1">
                        <span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-slate-400">
                            <svg
                                className="w-4 h-4"
                                viewBox="0 0 20 20"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth="1.8"
                                strokeLinecap="round"
                                strokeLinejoin="round"
                            >
                                <circle cx="8.5" cy="8.5" r="5.5" />
                                <line x1="12.7" y1="12.7" x2="18" y2="18" />
                            </svg>
                        </span>
                        <input
                            type="text"
                            className={inputClass}
                            placeholder="Search by ID, name or sprite ID…"
                            value={query}
                            onChange={(e) => setQuery(e.target.value)}
                        />
                        {query && (
                            <BaseButton
                                type="button"
                                onClick={() => setQuery('')}
                                className="absolute right-2 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-full text-[11px] text-slate-300 hover:text-slate-500 hover:bg-slate-100"
                            >
                                ✕
                            </BaseButton>
                        )}
                    </div>
                    <Flex gap={1}>
                        {['', 's', 'i'].map((t) => {
                            const on = typeFilter === t;

                            return (
                                <BaseButton
                                    key={t || 'all'}
                                    type="button"
                                    onClick={() => applyType(t)}
                                    className={`px-3 py-1.5 text-[12px] font-medium rounded-lg border transition ${on ? 'bg-[#418db0] border-[#418db0] text-[#ffffff] shadow-sm' : 'bg-slate-100 border-slate-200 text-slate-500 hover:bg-slate-200 hover:text-slate-600'}`}
                                >
                                    {t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall'}
                                </BaseButton>
                            );
                        })}
                    </Flex>
                </Flex>

                {/* Result count + activity */}
                <Flex alignItems="center" gap={2} className="px-0.5">
                    <Text className="text-[11px] text-slate-500">
                        {total > 0 ? `Showing ${from}–${to} of ${total.toLocaleString()}` : loading ? 'Searching…' : 'No results'}
                    </Text>
                    {loading && <span className="w-3 h-3 rounded-full border-2 border-slate-300 border-t-primary animate-spin" />}
                    {flaggedCount > 0 && (
                        <Hint content="Rows whose interaction type has no class or does not match the classname"><ToggleButton
                            type="button"
                            onClick={() => setOnlyFlagged((prev) => !prev)} pressed={onlyFlagged}
                            className={`ml-auto inline-flex items-center gap-1.5 text-[10px] font-medium rounded-full border px-2 py-0.5 transition ${onlyFlagged ? 'bg-amber-100 border-amber-300 text-amber-800' : 'bg-[#ffffff] border-slate-200 text-slate-500 hover:border-slate-300'}`}
                        >
                            <span className="w-1.5 h-1.5 rounded-full bg-[#f59e0b]" />
                            {flaggedCount} to check on this page
                        </ToggleButton></Hint>
                    )}
                </Flex>

                {/* Table */}
                <div className="flex-1 overflow-auto rounded-xl border border-slate-200 shadow-sm bg-[#ffffff]">
                    <table className="w-full text-xs border-collapse">
                        <thead>
                            <tr className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200 select-none">
                                <th className="px-2 py-2 w-[52px]"></th>
                                {COLUMNS.map((c) => (
                                    <th
                                        key={c.field}
                                        onClick={() => applySort(c.field)}
                                        className={`px-2 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-500 cursor-pointer hover:text-slate-700 ${c.align === 'center' ? 'text-center' : 'text-left'}`}
                                    >
                                        {c.label}
                                        <SortArrow field={c.field} active={sortField} dir={sortDir} />
                                    </th>
                                ))}
                            </tr>
                        </thead>
                        <tbody>
                            {rows.map(({ item, note }) => (
                                <tr
                                    key={item.id}
                                    onClick={() => onSelect(item.id)}
                                    title={note ?? undefined}
                                    className="group cursor-pointer border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors"
                                >
                                    <td className="px-2 py-1.5">
                                        <div className="w-9 h-9 rounded-lg bg-slate-50 border border-slate-100 group-hover:border-slate-200 flex items-center justify-center overflow-hidden">
                                            <LayoutFurniIconImageView productType={item.type} productClassId={item.spriteId} />
                                        </div>
                                    </td>
                                    <td className="px-2 py-1.5 font-mono text-slate-500">{item.id}</td>
                                    <td className="px-2 py-1.5 font-mono text-slate-500">{item.spriteId}</td>
                                    <td className="px-2 py-1.5 text-slate-700 font-medium truncate max-w-[160px]" title={note ?? item.itemName}>
                                        {note && <span aria-label={note} className="inline-block w-1.5 h-1.5 rounded-full bg-[#f59e0b] mr-1.5 align-middle" />}
                                        {item.itemName}
                                    </td>
                                    <td className="px-2 py-1.5 text-slate-500 truncate max-w-[150px]" title={item.publicName}>
                                        {item.publicName || '—'}
                                    </td>
                                    <td className="px-2 py-1.5 text-center">
                                        <span
                                            className={`inline-block px-2 py-0.5 rounded-md text-[#ffffff] text-[10px] font-semibold ${item.type === 's' ? 'bg-[#418db0]' : 'bg-[#64748b]'}`}
                                        >
                                            {item.type === 's' ? 'Floor' : 'Wall'}
                                        </span>
                                    </td>
                                    <td className="px-2 py-1.5">
                                        {item.interactionType ? (
                                            <span className="inline-block px-1.5 py-0.5 rounded bg-slate-100 text-slate-500 text-[10px] font-mono">
                                                {item.interactionType}
                                            </span>
                                        ) : (
                                            <span className="text-slate-300">—</span>
                                        )}
                                    </td>
                                </tr>
                            ))}
                            {items.length === 0 &&
                                loading &&
                                Array.from({ length: 8 }).map((_, i) => (
                                    <tr key={`sk-${i}`} className="border-b border-slate-100">
                                        <td className="px-2 py-1.5">
                                            <div className="w-9 h-9 rounded-lg bg-slate-100 animate-pulse" />
                                        </td>
                                        {COLUMNS.map((c) => (
                                            <td key={c.field} className="px-2 py-1.5">
                                                <div className="h-3 rounded bg-slate-100 animate-pulse" />
                                            </td>
                                        ))}
                                    </tr>
                                ))}
                            {items.length === 0 && !loading && (
                                <tr>
                                    <td colSpan={7} className="px-2 py-10 text-center">
                                        <div className="text-slate-400 text-sm">No furni found</div>
                                        <div className="text-slate-300 text-[11px] mt-0.5">Try a different search or filter</div>
                                    </td>
                                </tr>
                            )}
                        </tbody>
                    </table>
                </div>

                {/* Pagination */}
                <Flex justifyContent="between" alignItems="center" className="px-0.5">
                    <Text className="text-[11px] text-slate-400">{total.toLocaleString()} items</Text>
                    <Flex alignItems="center" gap={1}>
                        <PagBtn title="First" disabled={page <= 1} onClick={() => goTo(1)}>
                            «
                        </PagBtn>
                        <PagBtn title="Previous" disabled={page <= 1} onClick={() => goTo(page - 1)}>
                            ‹
                        </PagBtn>
                        <Flex alignItems="center" gap={1} className="px-1 text-[11px] text-slate-500">
                            <input
                                type="text"
                                value={pageInput}
                                onChange={(e) => setPageInput(e.target.value.replace(/[^0-9]/g, ''))}
                                onKeyDown={(e) => {
                                    if (e.key === 'Enter') goTo(Number(pageInput));
                                }}
                                className="w-12 px-1.5 py-1 text-center rounded-lg border border-slate-200 bg-[#ffffff] focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/15"
                            />
                            <span className="text-slate-400">/ {totalPages.toLocaleString()}</span>
                        </Flex>
                        <PagBtn title="Next" disabled={page >= totalPages} onClick={() => goTo(page + 1)}>
                            ›
                        </PagBtn>
                        <PagBtn title="Last" disabled={page >= totalPages} onClick={() => goTo(totalPages)}>
                            »
                        </PagBtn>
                    </Flex>
                </Flex>
            </Column>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En 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