fix(catalog): improve empty search state and footer layout

Show centered Nothing Found for catalog searches with no matches, mirror
search results labels at the bottom footer, split nav from the style row,
and drop the desktop catalog footer top margin.
This commit is contained in:
Tommaso Casaburi
2026-05-19 15:38:44 +07:00
parent b549789a23
commit 62cff4e207
43 changed files with 229 additions and 110 deletions
+27 -29
View File
@@ -523,7 +523,6 @@ export const MobileBoardButtons = () => {
const resolvedAddress = useResolvedCommunityAddress();
const communityAddress = resolvedAddress || accountComment?.communityAddress;
const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
@@ -574,19 +573,7 @@ export const MobileBoardButtons = () => {
isInModView={isInModView}
isMobilePlacement={true}
/>
{searchText ? (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('search_results_for')}: <strong>{searchText}</strong>
</span>
) : (
filteredCount > 0 && (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('filtered_threads')}: <strong>{filteredCount}</strong>
</span>
)
)}
<CatalogSearchResultsLabel />
{(isInAllView || showTimeFilter || isInCatalogView) && (
<>
<hr />
@@ -683,6 +670,31 @@ export const PostPageStats = () => {
);
};
export const CatalogSearchResultsLabel = () => {
const { t } = useTranslation();
const { filteredCount, searchText } = useCatalogFiltersStore();
if (searchText) {
return (
<span className={styles.filteredThreadsCount}>
{' '}
{t('search_results_for')}: <strong>{searchText}</strong>
</span>
);
}
if (filteredCount > 0) {
return (
<span className={styles.filteredThreadsCount}>
{' '}
{t('filtered_threads')}: <strong>{filteredCount}</strong>
</span>
);
}
return null;
};
export const DesktopBoardButtons = () => {
const { t } = useTranslation();
const params = useParams();
@@ -698,7 +710,6 @@ export const DesktopBoardButtons = () => {
const isInModView = isModView(location.pathname);
const isInModQueueView = isModQueueView(location.pathname);
const { filteredCount, searchText } = useCatalogFiltersStore();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const showTimeFilter = isMultiboard;
@@ -794,20 +805,7 @@ export const DesktopBoardButtons = () => {
<ModQueueButton boardIdentifier={boardIdentifier} isMobile={false} />
</>
)}
{isInCatalogView && searchText ? (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('search_results_for')}: <strong>{searchText}</strong>
</span>
) : (
isInCatalogView &&
filteredCount > 0 && (
<span className={styles.filteredThreadsCount}>
{' '}
- {t('filtered_threads')}: <strong>{filteredCount}</strong>
</span>
)
)}
{isInCatalogView && <CatalogSearchResultsLabel />}
<span className={styles.rightSideButtons}>
{isInCatalogView && (
<>
@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CatalogFooterFirstRow,
CatalogFooterStyleRow,
PageFooterDesktop,
PageFooterMobile,
StyleOnlyFooterFirstRow,
@@ -51,6 +52,7 @@ vi.mock('../../style-selector/style-selector', () => ({
}));
vi.mock('../../board-buttons/board-buttons', () => ({
CatalogSearchResultsLabel: () => null,
AutoButton: () => createElement('button', { type: 'button' }, 'auto-button'),
CatalogButton: ({
address,
@@ -154,6 +156,7 @@ describe('footer', () => {
isInAllView: true,
communityAddress: 'music-posting.eth',
}),
createElement(CatalogFooterStyleRow),
createElement(ThreadFooterStyleRow),
createElement(PageFooterMobile, {
children: createElement('div', { 'data-testid': 'mobile-child' }, 'mobile-child'),
+4 -1
View File
@@ -4,6 +4,10 @@
padding-bottom: 24px;
}
.footerCatalog {
margin-top: 0;
}
.firstRow {
display: flex;
align-items: center;
@@ -76,7 +80,6 @@
}
.styleLabel {
font-size: 12px;
text-transform: capitalize;
}
+33 -11
View File
@@ -4,7 +4,16 @@ import { useComment } from '@bitsocial/bitsocial-react-hooks';
import BoardsBar from '../boards-bar';
import SiteLegalMeta from '../site-legal-meta';
import StyleSelector from '../style-selector/style-selector';
import { ReturnButton, CatalogButton, TopButton, UpdateButton, AutoButton, PostPageStats, RefreshButton } from '../board-buttons/board-buttons';
import {
CatalogSearchResultsLabel,
ReturnButton,
CatalogButton,
TopButton,
UpdateButton,
AutoButton,
PostPageStats,
RefreshButton,
} from '../board-buttons/board-buttons';
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
import useReplyModalStore from '../../stores/use-reply-modal-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
@@ -22,15 +31,17 @@ import styles from './footer.module.css';
interface PageFooterDesktopProps {
/** Mode-specific first row content (e.g. board pagination or thread controls) */
firstRow: React.ReactNode;
firstRow?: React.ReactNode;
/** Optional row between first row and BoardsBar (e.g. style selector on thread page) */
styleRow?: React.ReactNode;
/** Catalog pages omit the default footer top margin */
variant?: 'catalog';
}
export const PageFooterDesktop = ({ firstRow, styleRow }: PageFooterDesktopProps) => (
<footer className={styles.footer}>
export const PageFooterDesktop = ({ firstRow, styleRow, variant }: PageFooterDesktopProps) => (
<footer className={variant === 'catalog' ? `${styles.footer} ${styles.footerCatalog}` : styles.footer}>
<hr />
<div className={styles.firstRow}>{firstRow}</div>
{firstRow != null ? <div className={styles.firstRow}>{firstRow}</div> : null}
{styleRow != null ? <div className={styles.styleRow}>{styleRow}</div> : null}
<div className={styles.boardsBarRow}>
<BoardsBar />
@@ -60,7 +71,7 @@ export const StyleOnlyFooterFirstRow = () => {
/* -----------------------------------------------------------------------------
* CatalogFooterFirstRow
* Catalog footer first row: Return, Archive, Top, Refresh on left; Style selector on right.
* Catalog footer nav row: Return, Catalog, Top, Refresh, plus search/filter label.
* -------------------------------------------------------------------------- */
interface CatalogFooterFirstRowProps {
@@ -71,7 +82,6 @@ interface CatalogFooterFirstRowProps {
}
export const CatalogFooterFirstRow = ({ communityAddress, isInAllView = false, isInSubscriptionsView = false, isInModView = false }: CatalogFooterFirstRowProps) => {
const { t } = useTranslation();
return (
<div className={styles.footerRow}>
<div className={styles.footerLeft}>
@@ -87,15 +97,27 @@ export const CatalogFooterFirstRow = ({ communityAddress, isInAllView = false, i
<span>
[<RefreshButton />]
</span>
</div>
<div className={styles.footerRight}>
<span className={styles.styleLabel}>{t('style')}:</span>
<StyleSelector />
<CatalogSearchResultsLabel />
</div>
</div>
);
};
/* -----------------------------------------------------------------------------
* CatalogFooterStyleRow
* Catalog style selector on its own row (below nav, above BoardsBar).
* -------------------------------------------------------------------------- */
export const CatalogFooterStyleRow = () => {
const { t } = useTranslation();
return (
<span className={styles.styleRowContent}>
<span className={styles.styleLabel}>{t('style')}:</span>
<StyleSelector />
</span>
);
};
/* -----------------------------------------------------------------------------
* ThreadFooterStyleRow
* Style selector on its own row (thread page only, below first row, above BoardsBar).
+1
View File
@@ -3,6 +3,7 @@ export {
PageFooterMobile,
StyleOnlyFooterFirstRow,
CatalogFooterFirstRow,
CatalogFooterStyleRow,
ThreadFooterFirstRow,
ThreadFooterStyleRow,
ThreadFooterMobile,
+25 -2
View File
@@ -322,8 +322,15 @@ vi.mock('../../../components/catalog-row', () => ({
vi.mock('../../../components/footer', () => ({
CatalogFooterFirstRow: ({ communityAddress }: { communityAddress?: string }) =>
createElement('div', { 'data-testid': 'catalog-first-row' }, communityAddress || 'multi'),
PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow),
createElement(
'div',
{ 'data-testid': 'catalog-first-row' },
communityAddress || 'multi',
testState.searchText ? ` - search_results_for: ${testState.searchText}` : null,
),
CatalogFooterStyleRow: () => createElement('div', { 'data-testid': 'catalog-style-row' }, 'style'),
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow?: React.ReactNode; styleRow?: React.ReactNode }) =>
createElement('div', { 'data-testid': 'catalog-footer-desktop' }, firstRow, styleRow),
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'catalog-footer-mobile' }, children),
}));
@@ -902,6 +909,22 @@ describe('Catalog', () => {
expect(container.querySelector('[data-testid="catalog-first-row"]')?.textContent).toBe('music-posting.eth');
});
it('shows centered nothing found when catalog search has no matches', async () => {
testState.feed = [{ cid: 'network-post', title: 'cats on stage', communityAddress: 'music-posting.eth' }];
testState.searchText = 'asddasd';
testState.hasMore = false;
await renderCatalog({ initialEntry: '/mu/catalog?q=asddasd', routePath: '/:boardIdentifier/catalog' });
expect(container.textContent).toContain('nothing_found');
expect(container.textContent).not.toContain('no_threads');
expect(container.querySelector('[role="status"]')?.className).toContain('searchNothingFound');
expect(container.querySelector('[role="status"]')?.textContent).toBe('nothing_found');
expect(container.querySelectorAll('[data-testid="catalog-row"]')).toHaveLength(0);
expect(container.textContent).toContain('search_results_for');
expect(container.textContent).toContain('asddasd');
});
it('queries scoped recent account posts and still applies local search filtering before injecting them', async () => {
const currentTimestamp = Math.floor(Date.now() / 1000);
testState.feed = [{ cid: 'network-post', title: 'cats on stage', communityAddress: 'music-posting.eth' }];
+16
View File
@@ -3,6 +3,22 @@
color: var(--post-mobile-abbr-text-color);
}
.stateString {
padding: 15px 0 10px 5px;
color: var(--post-mobile-abbr-text-color);
}
.searchNothingFound {
color: red;
display: block;
margin: 0;
padding: 1em 0;
text-align: center;
font-size: x-large;
font-weight: bold;
line-height: normal;
}
.morePostsSuggestion {
padding-bottom: 5px;
}
+51 -33
View File
@@ -25,7 +25,7 @@ import useSortingStore from '../../stores/use-sorting-store';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import { getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
import CatalogRow from '../../components/catalog-row';
import { CatalogFooterFirstRow, PageFooterDesktop, PageFooterMobile } from '../../components/footer';
import { CatalogFooterFirstRow, CatalogFooterStyleRow, PageFooterDesktop, PageFooterMobile } from '../../components/footer';
import { ReturnButton, ArchiveButton, TopButton, RefreshButton } from '../../components/board-buttons/board-buttons';
import mobileFooterStyles from '../../components/footer/footer.module.css';
import LoadingEllipsis from '../../components/loading-ellipsis';
@@ -131,6 +131,20 @@ const CatalogFooter = ({
// Separate component for the loading state when there's no feed
// This also calls useFeedStateString internally to isolate re-renders
interface CatalogSearchNothingFoundProps {
className?: string;
}
const CatalogSearchNothingFound = ({ className }: CatalogSearchNothingFoundProps) => {
const { t } = useTranslation();
return (
<div className={className ?? styles.searchNothingFound} role='status'>
{t('nothing_found')}
</div>
);
};
interface CatalogLoadingProps {
communityAddresses: string[];
hasMore: boolean;
@@ -138,9 +152,10 @@ interface CatalogLoadingProps {
state: string | undefined;
subscriptionsLength: number;
error: Error | undefined;
hasActiveSearch: boolean;
}
const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error }: CatalogLoadingProps) => {
const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state, subscriptionsLength, error, hasActiveSearch }: CatalogLoadingProps) => {
const { t } = useTranslation();
const rawFeedStateString = useFeedStateString(communityAddresses);
@@ -153,7 +168,9 @@ const CatalogLoading = ({ communityAddresses, hasMore, combinedFeedLength, state
) : subscriptionsLength === 0 ? (
<span className='red'>{t('not_subscribed_to_any_board')}</span>
) : !hasMore && combinedFeedLength === 0 ? (
t('no_threads')
hasActiveSearch ? null : (
t('no_threads')
)
) : (
hasMore && <LoadingEllipsis string={loadingStateString} />
)}
@@ -587,6 +604,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const footerMoreThreadsSuggestion = showHiddenThreads ? null : moreThreadsSuggestion;
const footerShowLoadingEllipsis = showHiddenThreads ? isLoadingHiddenCatalogThreads : effectiveInfiniteScroll;
const catalogFooterFirstRow = (
<CatalogFooterFirstRow communityAddress={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
);
const catalogFooterStyleRow = <CatalogFooterStyleRow />;
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes
const footerComponents = useMemo(
@@ -604,16 +626,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterDesktop variant='catalog' firstRow={catalogFooterFirstRow} styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
@@ -633,6 +646,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
catalogFooterFirstRow,
catalogFooterStyleRow,
communityAddress,
isInAllView,
isInSubscriptionsView,
@@ -655,16 +670,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterDesktop variant='catalog' firstRow={catalogFooterFirstRow} styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
@@ -683,6 +689,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
catalogFooterFirstRow,
catalogFooterStyleRow,
communityAddress,
isInAllView,
isInSubscriptionsView,
@@ -692,6 +700,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
],
);
const isFeedLoaded = feed.length > 0 || hiddenThreadsCount > 0 || state === 'failed';
const hasActiveSearch = searchText.trim().length > 0;
const showSearchNothingFound =
hasActiveSearch && !footerHasMore && footerCombinedFeedLength === 0 && state !== 'failed' && !(isInSubscriptionsView && (subscriptions?.length || 0) === 0);
// Process the feed to move "top" posts to the top (applied after display sort)
const processedFeed = useMemo(() => {
@@ -896,6 +907,21 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</>
)}
</>
) : showSearchNothingFound ? (
<>
<CatalogSearchNothingFound />
<hr />
{catalogFooterFirstRow}
<PageFooterDesktop variant='catalog' styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
<TopButton />
<RefreshButton />
</div>
</PageFooterMobile>
</>
) : (
<>
<div className={styles.footer}>
@@ -906,18 +932,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
state={state}
subscriptionsLength={isInSubscriptionsView ? subscriptions?.length || 0 : 1}
error={error}
hasActiveSearch={hasActiveSearch}
/>
</div>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
communityAddress={communityAddress}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
}
/>
<PageFooterDesktop variant='catalog' firstRow={catalogFooterFirstRow} styleRow={catalogFooterStyleRow} />
<PageFooterMobile>
<div className={mobileFooterStyles.mobileFooterButtons}>
<ReturnButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />