feat(furni-editor): modernize search/list view + live search + server sort

- Restyle to match the editor: slate palette, rounded card, search bar
  with icon + focus ring, teal filter chips, larger furni thumbnails,
  loading skeleton + empty state.
- Live search (350ms debounce) with clear button; filters/sort apply
  immediately.
- Pagination: first/prev/next/last + page-jump input + 'Showing X-Y of Z'
  (was Prev/Next only across 3002 pages).
- Header sort now queries the server (sortField/sortDir) instead of
  reordering only the 20 visible rows; useFurniEditor.searchItems passes
  the sort through.
This commit is contained in:
simoleo89
2026-06-06 15:08:47 +02:00
parent e44b828eaf
commit d699964542
2 changed files with 172 additions and 135 deletions
@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'; import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common'; import { Column, Flex, LayoutFurniIconImageView, Text } from '../../../common';
import { FurniItem } from '../../../hooks/furni-editor'; import { FurniItem } from '../../../hooks/furni-editor';
interface FurniEditorSearchViewProps interface FurniEditorSearchViewProps
@@ -8,20 +8,43 @@ interface FurniEditorSearchViewProps
total: number; total: number;
page: number; page: number;
loading: boolean; loading: boolean;
onSearch: (query: string, type: string, page: number) => void; onSearch: (query: string, type: string, page: number, sortField: string, sortDir: string) => void;
onSelect: (id: number) => void; onSelect: (id: number) => void;
} }
type SortField = 'id' | 'spriteId' | 'itemName' | 'publicName' | 'type' | 'interactionType'; type SortField = 'id' | 'spriteId' | 'itemName' | 'publicName' | 'type' | 'interactionType';
type SortDir = 'asc' | 'desc'; 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 }) => const SortArrow: FC<{ field: SortField; active: SortField; dir: SortDir }> = ({ field, active, dir }) =>
{ {
if(field !== active) return <span className="ml-0.5 opacity-30"></span>; if(field !== active) return <span className="ml-1 text-slate-300"></span>;
return <span className="ml-0.5">{ dir === 'asc' ? '▲' : '▼' }</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 }) => (
<button
type="button"
title={ title }
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 }
</button>
);
export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props => export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
{ {
const { items, total, page, loading, onSearch, onSelect } = props; const { items, total, page, loading, onSearch, onSelect } = props;
@@ -29,184 +52,198 @@ export const FurniEditorSearchView: FC<FurniEditorSearchViewProps> = props =>
const [ typeFilter, setTypeFilter ] = useState(''); const [ typeFilter, setTypeFilter ] = useState('');
const [ sortField, setSortField ] = useState<SortField>('id'); const [ sortField, setSortField ] = useState<SortField>('id');
const [ sortDir, setSortDir ] = useState<SortDir>('asc'); const [ sortDir, setSortDir ] = useState<SortDir>('asc');
const [ pageInput, setPageInput ] = useState('1');
const initialSearch = useEffectEvent(() => onSearch('', '', 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(() => useEffect(() =>
{ {
initialSearch(); if(didInit.current) return;
}, []); didInit.current = true;
onSearch('', '', 1, 'id', 'asc');
}, [ onSearch ]);
const handleSearch = useCallback(() => // Keep the page input synced with the authoritative page from the server.
{ useEffect(() => { setPageInput(String(page)); }, [ page ]);
onSearch(query, typeFilter, 1);
}, [ query, typeFilter, onSearch ]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => // Debounced live search as the user types (skips the first render).
const firstQuery = useRef(true);
useEffect(() =>
{ {
if(e.key === 'Enter') handleSearch(); if(firstQuery.current) { firstQuery.current = false; return; }
}, [ handleSearch ]);
const handleSort = useCallback((field: SortField) => const handle = window.setTimeout(() =>
{
setSortDir(prev => (sortField === field ? (prev === 'asc' ? 'desc' : 'asc') : 'asc'));
setSortField(field);
}, [ sortField ]);
const handleTypeToggle = useCallback((type: string) =>
{
setTypeFilter(prev =>
{ {
const next = prev === type ? '' : type; const s = stateRef.current;
onSearch(query, s.typeFilter, 1, s.sortField, s.sortDir);
}, 350);
onSearch(query, next, 1); return () => window.clearTimeout(handle);
return next;
});
}, [ query, onSearch ]); }, [ query, onSearch ]);
const sortedItems = useMemo(() => const applyType = useCallback((t: string) =>
{ {
const sorted = [ ...items ]; const next = typeFilter === t ? '' : t;
setTypeFilter(next);
onSearch(query, next, 1, sortField, sortDir);
}, [ typeFilter, query, sortField, sortDir, onSearch ]);
sorted.sort((a, b) => const applySort = useCallback((field: SortField) =>
{ {
let va: string | number = a[sortField] ?? ''; const nextDir: SortDir = (sortField === field && sortDir === 'asc') ? 'desc' : 'asc';
let vb: string | number = b[sortField] ?? ''; setSortField(field);
setSortDir(nextDir);
onSearch(query, typeFilter, 1, field, nextDir);
}, [ sortField, sortDir, query, typeFilter, onSearch ]);
if(typeof va === 'string') va = va.toLowerCase(); const goTo = useCallback((pg: number) =>
if(typeof vb === 'string') vb = vb.toLowerCase(); {
const clamped = Math.min(Math.max(1, pg || 1), totalPages);
onSearch(query, typeFilter, clamped, sortField, sortDir);
}, [ totalPages, query, typeFilter, sortField, sortDir, onSearch ]);
if(va < vb) return sortDir === 'asc' ? -1 : 1; 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';
if(va > vb) return sortDir === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [ items, sortField, sortDir ]);
const totalPages = Math.ceil(total / 20);
return ( return (
<Column gap={ 1 } className="h-full"> <Column gap={ 2 } className="h-full">
<Flex gap={ 1 } alignItems="end"> { /* Search + filters */ }
<Column gap={ 0 } className="flex-1"> <Flex gap={ 2 } alignItems="center">
<Text small bold>Search</Text> <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 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.2-3.2" /></svg>
</span>
<input <input
type="text" type="text"
className="w-full px-2 py-1 text-xs leading-normal rounded-sm border border-[#ccc] min-h-[calc(1.5em+0.5rem+2px)]" className={ inputClass }
placeholder="ID, name or sprite ID..." placeholder="Search by ID, name or sprite ID"
value={ query } value={ query }
onChange={ e => setQuery(e.target.value) } onChange={ e => setQuery(e.target.value) }
onKeyDown={ handleKeyDown }
/> />
</Column> { query &&
<Flex gap={ 1 }>
{ [ '', 's', 'i' ].map(t => (
<button <button
key={ t || 'all' } type="button"
className={ `px-2 py-1 text-[11px] rounded border cursor-pointer transition-colors ${ onClick={ () => setQuery('') }
typeFilter === t 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"
? 'bg-[#1e7295] text-white border-[#1e7295]' ></button> }
: 'bg-white text-[#333] border-[#ccc] hover:bg-[#f0f0f0]' </div>
}` } <Flex gap={ 1 }>
onClick={ () => handleTypeToggle(t) } { [ '', 's', 'i' ].map(t =>
> {
{ t === '' ? 'All' : t === 's' ? 'Floor' : 'Wall' } const on = typeFilter === t;
</button>
)) } return (
<button
key={ t || 'all' }
type="button"
onClick={ () => applyType(t) }
className={ `px-3 py-1.5 text-[12px] font-medium rounded-lg border transition ${ on ? 'bg-[#1E7295] border-[#1E7295] 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' }
</button>
);
}) }
</Flex> </Flex>
<Button variant="primary" disabled={ loading } onClick={ handleSearch }>
{ loading ? '...' : 'Search' }
</Button>
</Flex> </Flex>
{ total > 0 && { /* Result count + activity */ }
<Text small variant="gray" className="text-[10px]"> <Flex alignItems="center" gap={ 2 } className="px-0.5">
{ total } items found <Text className="text-[11px] text-slate-500">
{ total > 0 ? `Showing ${ from }${ to } of ${ total.toLocaleString() }` : (loading ? 'Searching…' : 'No results') }
</Text> </Text>
} { loading && <span className="w-3 h-3 rounded-full border-2 border-slate-300 border-t-primary animate-spin" /> }
</Flex>
<Column gap={ 0 } className="flex-1 overflow-auto border border-[#ccc] rounded bg-white"> { /* Table */ }
<table className="w-full text-xs"> <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> <thead>
<tr className="bg-[#e8e8e8] sticky top-0 select-none"> <tr className="sticky top-0 z-10 bg-slate-50 border-b border-slate-200 select-none">
<th className="px-1 py-1 text-center w-[50px]"></th> <th className="px-2 py-2 w-[52px]"></th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('id') }> { COLUMNS.map(c => (
ID<SortArrow field="id" active={ sortField } dir={ sortDir } /> <th
</th> key={ c.field }
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('spriteId') }> onClick={ () => applySort(c.field) }
Sprite<SortArrow field="spriteId" active={ sortField } dir={ sortDir } /> 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' }` }
</th> >
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('itemName') }> { c.label }<SortArrow field={ c.field } active={ sortField } dir={ sortDir } />
Name<SortArrow field="itemName" active={ sortField } dir={ sortDir } /> </th>
</th> )) }
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('publicName') }>
Public Name<SortArrow field="publicName" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-center cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('type') }>
Type<SortArrow field="type" active={ sortField } dir={ sortDir } />
</th>
<th className="px-2 py-1 text-left cursor-pointer hover:bg-[#ddd]" onClick={ () => handleSort('interactionType') }>
Interaction<SortArrow field="interactionType" active={ sortField } dir={ sortDir } />
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{ sortedItems.map(item => ( { items.map(item => (
<tr <tr
key={ item.id } key={ item.id }
className="cursor-pointer hover:bg-[#d4edfa] border-b border-[#eee] transition-colors"
onClick={ () => onSelect(item.id) } onClick={ () => onSelect(item.id) }
className="group cursor-pointer border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors"
> >
<td className="px-1 py-1 text-center"> <td className="px-2 py-1.5">
<LayoutFurniIconImageView productType={ item.type } productClassId={ item.spriteId } className="inline-block scale-125" /> <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>
<td className="px-2 py-1 font-mono">{ item.id }</td> <td className="px-2 py-1.5 font-mono text-slate-500">{ item.id }</td>
<td className="px-2 py-1 font-mono">{ item.spriteId }</td> <td className="px-2 py-1.5 font-mono text-slate-500">{ item.spriteId }</td>
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.itemName }>{ item.itemName }</td> <td className="px-2 py-1.5 text-slate-700 font-medium truncate max-w-[160px]" title={ item.itemName }>{ item.itemName }</td>
<td className="px-2 py-1 truncate max-w-[120px]" title={ item.publicName }>{ item.publicName }</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 text-center"> <td className="px-2 py-1.5 text-center">
<span className={ `px-1.5 py-0.5 rounded text-white text-[10px] font-medium ${ item.type === 's' ? 'bg-[#1e7295]' : 'bg-[#6b7280]' }` }> <span className={ `inline-block px-2 py-0.5 rounded-md text-[#ffffff] text-[10px] font-semibold ${ item.type === 's' ? 'bg-[#1E7295]' : 'bg-[#64748b]' }` }>
{ item.type === 's' ? 'Floor' : 'Wall' } { item.type === 's' ? 'Floor' : 'Wall' }
</span> </span>
</td> </td>
<td className="px-2 py-1 text-[10px]">{ item.interactionType || '-' }</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> </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 && { items.length === 0 && !loading &&
<tr> <tr>
<td colSpan={ 7 } className="px-2 py-4 text-center text-[#999]">No items found</td> <td colSpan={ 7 } className="px-2 py-10 text-center">
</tr> <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> </tbody>
</table> </table>
</Column> </div>
{ totalPages > 1 && { /* Pagination */ }
<Flex gap={ 1 } justifyContent="between" alignItems="center"> <Flex justifyContent="between" alignItems="center" className="px-0.5">
<Text small variant="gray"> <Text className="text-[11px] text-slate-400">{ total.toLocaleString() } items</Text>
Page { page }/{ totalPages } <Flex alignItems="center" gap={ 1 }>
</Text> <PagBtn title="First" disabled={ page <= 1 } onClick={ () => goTo(1) }>«</PagBtn>
<Flex gap={ 1 }> <PagBtn title="Previous" disabled={ page <= 1 } onClick={ () => goTo(page - 1) }></PagBtn>
<Button <Flex alignItems="center" gap={ 1 } className="px-1 text-[11px] text-slate-500">
variant="secondary" <input
disabled={ page <= 1 } type="text"
onClick={ () => onSearch(query, typeFilter, page - 1) } value={ pageInput }
> onChange={ e => setPageInput(e.target.value.replace(/[^0-9]/g, '')) }
Prev onKeyDown={ e => { if(e.key === 'Enter') goTo(Number(pageInput)); } }
</Button> 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"
<Button />
variant="secondary" <span className="text-slate-400">/ { totalPages.toLocaleString() }</span>
disabled={ page >= totalPages }
onClick={ () => onSearch(query, typeFilter, page + 1) }
>
Next
</Button>
</Flex> </Flex>
<PagBtn title="Next" disabled={ page >= totalPages } onClick={ () => goTo(page + 1) }></PagBtn>
<PagBtn title="Last" disabled={ page >= totalPages } onClick={ () => goTo(totalPages) }>»</PagBtn>
</Flex> </Flex>
} </Flex>
</Column> </Column>
); );
}; };
+2 -2
View File
@@ -209,11 +209,11 @@ export const useFurniEditor = () =>
} }
}); });
const searchItems = useCallback((query: string, type: string, pg: number) => const searchItems = useCallback((query: string, type: string, pg: number, sortField: string = 'id', sortDir: string = 'asc') =>
{ {
setLoading(true); setLoading(true);
setError(null); setError(null);
SendMessageComposer(new FurniEditorSearchComposer(query, type, pg)); SendMessageComposer(new FurniEditorSearchComposer(query, type, pg, sortField, sortDir));
}, []); }, []);
const loadDetail = useCallback((id: number) => const loadDetail = useCallback((id: number) =>