From 4570244b48fa4b161fe6ad3967f182c71fd9fe80 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 6 Jun 2026 15:08:47 +0200 Subject: [PATCH] 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. --- .../views/FurniEditorSearchView.tsx | 303 ++++++++++-------- src/hooks/furni-editor/useFurniEditor.ts | 4 +- 2 files changed, 172 insertions(+), 135 deletions(-) diff --git a/src/components/furni-editor/views/FurniEditorSearchView.tsx b/src/components/furni-editor/views/FurniEditorSearchView.tsx index a1eccfb..3f05d6d 100644 --- a/src/components/furni-editor/views/FurniEditorSearchView.tsx +++ b/src/components/furni-editor/views/FurniEditorSearchView.tsx @@ -1,5 +1,5 @@ -import { FC, useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'; -import { Button, Column, Flex, LayoutFurniIconImageView, Text } from '../../../common'; +import { FC, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { Column, Flex, LayoutFurniIconImageView, Text } from '../../../common'; import { FurniItem } from '../../../hooks/furni-editor'; interface FurniEditorSearchViewProps @@ -8,20 +8,43 @@ interface FurniEditorSearchViewProps total: number; page: number; 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; } 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 ; + if(field !== active) return ; - return { dir === 'asc' ? '▲' : '▼' }; + return { dir === 'asc' ? '▲' : '▼' }; }; +const PagBtn: FC<{ disabled?: boolean; onClick: () => void; children: ReactNode; title?: string }> = ({ disabled, onClick, children, title }) => ( + +); + export const FurniEditorSearchView: FC = props => { const { items, total, page, loading, onSearch, onSelect } = props; @@ -29,184 +52,198 @@ export const FurniEditorSearchView: FC = props => const [ typeFilter, setTypeFilter ] = useState(''); const [ sortField, setSortField ] = useState('id'); const [ sortDir, setSortDir ] = useState('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(() => { - initialSearch(); - }, []); + if(didInit.current) return; + didInit.current = true; + onSearch('', '', 1, 'id', 'asc'); + }, [ onSearch ]); - const handleSearch = useCallback(() => - { - onSearch(query, typeFilter, 1); - }, [ query, typeFilter, onSearch ]); + // Keep the page input synced with the authoritative page from the server. + useEffect(() => { setPageInput(String(page)); }, [ page ]); - 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(); - }, [ handleSearch ]); + if(firstQuery.current) { firstQuery.current = false; return; } - const handleSort = useCallback((field: SortField) => - { - setSortDir(prev => (sortField === field ? (prev === 'asc' ? 'desc' : 'asc') : 'asc')); - setSortField(field); - }, [ sortField ]); - - const handleTypeToggle = useCallback((type: string) => - { - setTypeFilter(prev => + const handle = window.setTimeout(() => { - const next = prev === type ? '' : type; + const s = stateRef.current; + onSearch(query, s.typeFilter, 1, s.sortField, s.sortDir); + }, 350); - onSearch(query, next, 1); - - return next; - }); + return () => window.clearTimeout(handle); }, [ 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) => - { - let va: string | number = a[sortField] ?? ''; - let vb: string | number = b[sortField] ?? ''; + 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 ]); - if(typeof va === 'string') va = va.toLowerCase(); - if(typeof vb === 'string') vb = vb.toLowerCase(); + 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 ]); - if(va < vb) return sortDir === 'asc' ? -1 : 1; - if(va > vb) return sortDir === 'asc' ? 1 : -1; - - return 0; - }); - - return sorted; - }, [ items, sortField, sortDir ]); - - const totalPages = Math.ceil(total / 20); + 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 ( - - - - Search + + { /* Search + filters */ } + +
+ + + setQuery(e.target.value) } - onKeyDown={ handleKeyDown } /> - - - { [ '', 's', 'i' ].map(t => ( + { query && - )) } + 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" + >✕ } +
+ + { [ '', 's', 'i' ].map(t => + { + const on = typeFilter === t; + + return ( + + ); + }) } -
- { total > 0 && - - { total } items found + { /* Result count + activity */ } + + + { total > 0 ? `Showing ${ from }–${ to } of ${ total.toLocaleString() }` : (loading ? 'Searching…' : 'No results') } - } + { loading && } + - - + { /* Table */ } +
+
- - - - - - - - + + + { COLUMNS.map(c => ( + + )) } - { sortedItems.map(item => ( + { items.map(item => ( onSelect(item.id) } + className="group cursor-pointer border-b border-slate-100 last:border-0 hover:bg-slate-50 transition-colors" > - - - - - - + + + + - + )) } + { items.length === 0 && loading && + Array.from({ length: 8 }).map((_, i) => ( + + + { COLUMNS.map(c => ) } + + )) } { items.length === 0 && !loading && - - - } + + }
handleSort('id') }> - ID - handleSort('spriteId') }> - Sprite - handleSort('itemName') }> - Name - handleSort('publicName') }> - Public Name - handleSort('type') }> - Type - handleSort('interactionType') }> - Interaction -
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 } +
- + +
+ +
{ item.id }{ item.spriteId }{ item.itemName }{ item.publicName } - + { item.id }{ item.spriteId }{ item.itemName }{ item.publicName || '—' } + { item.type === 's' ? 'Floor' : 'Wall' } { item.interactionType || '-' } + { item.interactionType + ? { item.interactionType } + : } +
No items found
+
No furni found
+
Try a different search or filter
+
-
+ - { totalPages > 1 && - - - Page { page }/{ totalPages } - - - - + { /* Pagination */ } + + { total.toLocaleString() } items + + goTo(1) }>« + goTo(page - 1) }>‹ + + 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" + /> + / { totalPages.toLocaleString() } + = totalPages } onClick={ () => goTo(page + 1) }>› + = totalPages } onClick={ () => goTo(totalPages) }>» - } +
); }; diff --git a/src/hooks/furni-editor/useFurniEditor.ts b/src/hooks/furni-editor/useFurniEditor.ts index 16e9f0f..98d6e9e 100644 --- a/src/hooks/furni-editor/useFurniEditor.ts +++ b/src/hooks/furni-editor/useFurniEditor.ts @@ -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); setError(null); - SendMessageComposer(new FurniEditorSearchComposer(query, type, pg)); + SendMessageComposer(new FurniEditorSearchComposer(query, type, pg, sortField, sortDir)); }, []); const loadDetail = useCallback((id: number) =>