feat: 重设计搜索帖子 UI/UX,新增筛选面板与结果页 chips

将高级筛选移入独立面板,统一 URL 状态与清除逻辑,并优化移动端搜索与空结果反馈。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 19:21:55 +08:00
parent c6df703b96
commit 55b1831da2
8 changed files with 1190 additions and 214 deletions

View File

@@ -21,7 +21,6 @@ export default function FeedHeader({
keyword,
tag = '',
author = '',
titleOnly = false,
boards,
stats,
postTotal,
@@ -30,20 +29,16 @@ export default function FeedHeader({
const nav = useNavigate();
const board = boards.find(b => b.id === boardId);
const filtered = !!(keyword || tag || author);
const isSearch = !!(keyword || author);
const isTag = !!tag;
const filtered = isSearch || isTag;
const inBoard = !filtered && boardId > 0 && !!board;
/** 侧栏已有「全部帖子 / 板块名」,中间栏不再重复;仅搜索/标签保留标题 */
let title = '';
if (tag) title = `标签:${tag}`;
else if (keyword || author) {
const parts: string[] = [];
if (keyword) parts.push(titleOnly ? `标题含「${keyword}` : `搜索:${keyword}`);
if (author) parts.push(`作者 ${author}`);
if (boardId && board) parts.push(`板块 ${board.name}`);
title = parts.join(' · ');
}
const TitleTag = titleAs;
let title = '';
if (isTag) title = `标签:${tag}`;
else if (isSearch) title = '搜索结果';
return (
<div className={`feed-head${filtered ? ' feed-head--solo' : ' feed-head--stats-only'}`}>
<div className="feed-head__title">
@@ -77,19 +72,19 @@ export default function FeedHeader({
</span>
</div>
)}
</div>
{filtered && (
<span className="feed-head__meta feed-head__meta--count"> {postTotal} </span>
)}
</div>
{isTag && (
<button
type="button"
className="feed-head__clear"
onClick={() => navigateFeed(nav, '/')}
>
{tag ? '清除标签' : '清除搜索'}
</button>
)}
{filtered && (
<span className="feed-toolbar__count"> {postTotal} </span>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ import { useAuth } from '../hooks/useAuth';
import { useForumLimits } from '../hooks/useForumLimits';
import { useMediaQuery } from '../hooks/useTheme';
import { loginPath } from '../utils/authRedirect';
import { dispatchOpenPostSearch } from '../hooks/usePostSearch';
import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar';
@@ -33,6 +34,13 @@ interface Props {
onScrollRestored?: () => void;
/** 搜索关键词(用于空态文案) */
keyword?: string;
/** 是否为帖子搜索(区别于标签筛选) */
isSearchMode?: boolean;
searchKeyword?: string;
searchAuthor?: string;
searchTitleOnly?: boolean;
searchScopeBoardId?: number;
onClearSearch?: () => void;
/** 当前板块 id0 表示全部 */
boardId?: number;
/** 当前板块名 */
@@ -62,6 +70,12 @@ export default function VirtualPostList({
onScrollTopChange,
onScrollRestored,
keyword = '',
isSearchMode = false,
searchKeyword = '',
searchAuthor = '',
searchTitleOnly = false,
searchScopeBoardId = 0,
onClearSearch,
boardId = 0,
boardName = '',
noBoards = false,
@@ -132,7 +146,7 @@ export default function VirtualPostList({
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
const isInitialLoad = loading && posts.length === 0;
const isEmpty = !loading && posts.length === 0;
const isSearchEmpty = isEmpty && !!keyword.trim();
const isSearchEmpty = isEmpty && (isSearchMode || !!keyword.trim());
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
const isAdmin = user?.role === 'admin';
@@ -173,6 +187,12 @@ export default function VirtualPostList({
return () => el.removeEventListener('scroll', onScroll);
}, [getScrollElement, isMobile]);
const searchSummaryParts: string[] = [];
if (searchKeyword.trim()) searchSummaryParts.push(`关键词「${searchKeyword.trim()}`);
if (searchAuthor.trim()) searchSummaryParts.push(`作者 ${searchAuthor.trim()}`);
if (searchTitleOnly && searchKeyword.trim()) searchSummaryParts.push('仅标题');
if (searchScopeBoardId > 0 && boardName) searchSummaryParts.push(`板块 ${boardName}`);
const emptyActions = (
<div className="empty-feed-actions">
{noBoards ? (
@@ -187,12 +207,15 @@ export default function VirtualPostList({
)
) : isSearchEmpty ? (
<>
<Button type="button" size="sm" variant="outline" onClick={dispatchOpenPostSearch}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => (onClearSearch ? onClearSearch() : nav('/'))}>
</Button>
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
</Button>
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
{user ? '发帖' : '登录后发帖'}
</Button>
</>
) : (
<>
@@ -235,7 +258,9 @@ export default function VirtualPostList({
{noBoards
? (isAdmin ? '创建第一个板块后即可开始发帖' : '管理员创建板块后即可参与讨论')
: isSearchEmpty
? '试试更短的关键词,或浏览标签云 / 板块'
? (searchSummaryParts.length > 0
? `当前筛选:${searchSummaryParts.join(' · ')}。试试更短的关键词或放宽条件。`
: '试试更短的关键词,或浏览标签云 / 板块')
: boardName
? `${boardName}」还没有内容,来发第一篇吧`
: '换个板块看看,或发第一篇内容'}

View File

@@ -0,0 +1,87 @@
import { X } from 'lucide-react';
import type { Board } from '../../api/types';
import type { PostSearchState, SearchFilterKey } from '../../hooks/usePostSearch';
import { dispatchOpenPostSearch } from '../../hooks/usePostSearch';
interface Props {
filters: PostSearchState;
boards: Board[];
onRemove: (key: SearchFilterKey) => void;
onClear: () => void;
}
export default function FeedSearchFilters({ filters, boards, onRemove, onClear }: Props) {
const { keyword, author, titleOnly, scopeBoardId } = filters;
if (!keyword && !author) return null;
const boardName = scopeBoardId > 0
? boards.find((b) => b.id === scopeBoardId)?.name || '当前板块'
: '';
return (
<div className="search-filter-bar">
<div className="search-filter-bar__chips" role="list">
{keyword && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text">{keyword}</span>
<button
type="button"
className="search-filter-chip__remove"
aria-label={`移除关键词 ${keyword}`}
onClick={() => onRemove('keyword')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
{author && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text"> {author}</span>
<button
type="button"
className="search-filter-chip__remove"
aria-label={`移除作者 ${author}`}
onClick={() => onRemove('author')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
{titleOnly && keyword && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text"></span>
<button
type="button"
className="search-filter-chip__remove"
aria-label="取消仅标题"
onClick={() => onRemove('titleOnly')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
{scopeBoardId > 0 && boardName && (
<span className="search-filter-chip" role="listitem">
<span className="search-filter-chip__text">{boardName}</span>
<button
type="button"
className="search-filter-chip__remove"
aria-label={`移除板块 ${boardName}`}
onClick={() => onRemove('board')}
>
<X size={12} aria-hidden />
</button>
</span>
)}
</div>
<div className="search-filter-bar__actions">
<button type="button" className="search-filter-bar__link" onClick={dispatchOpenPostSearch}>
</button>
<button type="button" className="search-filter-bar__link search-filter-bar__link--muted" onClick={onClear}>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,306 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Search, User } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { api } from '../../api/client';
import { useMediaQuery } from '../../hooks/useTheme';
import { useForumLimits } from '../../hooks/useForumLimits';
import {
getRecentSearches,
type PostSearchSubmitInput,
type RecentSearch,
} from '../../hooks/usePostSearch';
import { cn } from '@/lib/utils';
export interface PostSearchDraft {
keyword: string;
author: string;
titleOnly: boolean;
scopeBoardId: number;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
draft: PostSearchDraft;
contextBoardId: number;
contextBoardName?: string;
onSubmit: (input: PostSearchSubmitInput) => boolean;
onClear: () => void;
}
type UserSuggest = { id: number; username: string; nickname: string; avatar?: string };
export default function PostSearchPanel({
open,
onOpenChange,
draft,
contextBoardId,
contextBoardName = '',
onSubmit,
onClear,
}: Props) {
const isMobile = useMediaQuery('(max-width: 768px)');
const { limits } = useForumLimits();
const keywordRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState(draft.keyword);
const [author, setAuthor] = useState(draft.author);
const [titleOnly, setTitleOnly] = useState(draft.titleOnly);
const [scopeBoardId, setScopeBoardId] = useState(draft.scopeBoardId);
const [recent, setRecent] = useState<RecentSearch[]>([]);
const [suggestions, setSuggestions] = useState<UserSuggest[]>([]);
const [suggestOpen, setSuggestOpen] = useState(false);
const authorWrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
setKeyword(draft.keyword);
setAuthor(draft.author);
setTitleOnly(draft.titleOnly);
setScopeBoardId(draft.scopeBoardId);
setRecent(getRecentSearches());
setSuggestions([]);
setSuggestOpen(false);
const t = window.setTimeout(() => keywordRef.current?.focus(), 80);
return () => window.clearTimeout(t);
}, [open, draft]);
useEffect(() => {
if (!open) return;
const q = author.trim();
if (q.length < 1) {
setSuggestions([]);
setSuggestOpen(false);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
api.searchUsers(q, 8).then((r) => {
if (cancelled) return;
const users = Array.isArray(r.users) ? r.users : [];
setSuggestions(users);
setSuggestOpen(users.length > 0);
}).catch(() => {
if (!cancelled) {
setSuggestions([]);
setSuggestOpen(false);
}
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [author, open]);
useEffect(() => {
if (!suggestOpen) return;
const onDoc = (e: MouseEvent) => {
if (authorWrapRef.current?.contains(e.target as Node)) return;
setSuggestOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [suggestOpen]);
const handleSubmit = useCallback(() => {
const ok = onSubmit({
keyword,
author,
titleOnly: !!keyword.trim() && titleOnly,
scopeBoardId: scopeBoardId > 0 ? scopeBoardId : 0,
});
if (ok) onOpenChange(false);
}, [keyword, author, titleOnly, scopeBoardId, onSubmit, onOpenChange]);
const applyRecent = (item: RecentSearch) => {
setKeyword(item.keyword);
setAuthor(item.author);
setTitleOnly(item.titleOnly);
setScopeBoardId(item.scopeBoardId);
};
const pickAuthor = (u: UserSuggest) => {
setAuthor(u.nickname || u.username);
setSuggestOpen(false);
};
const showBoardScope = contextBoardId > 0;
const kwHint = limits.search_keyword_min > 1
? `关键词 ${limits.search_keyword_min}${limits.search_keyword_max}`
: `关键词最多 ${limits.search_keyword_max}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'post-search-panel',
isMobile && 'post-search-panel--mobile',
)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogHeader className="post-search-panel__header">
<DialogTitle></DialogTitle>
<DialogDescription className="post-search-panel__desc">
</DialogDescription>
</DialogHeader>
<div className="post-search-panel__body">
<label className="post-search-field">
<span className="post-search-field__label"></span>
<div className="post-search-field__input-wrap">
<Search size={16} aria-hidden className="post-search-field__icon" />
<input
ref={keywordRef}
type="search"
className="post-search-field__input"
placeholder="输入标题或正文关键词…"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
maxLength={limits.search_keyword_max > 0 ? limits.search_keyword_max : undefined}
enterKeyHint="search"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<span className="post-search-field__hint">{kwHint}</span>
</label>
<label className="post-search-field">
<span className="post-search-field__label"></span>
<div className="post-search-field__input-wrap" ref={authorWrapRef}>
<User size={16} aria-hidden className="post-search-field__icon" />
<input
type="text"
className="post-search-field__input"
placeholder="用户名或昵称"
value={author}
onChange={(e) => setAuthor(e.target.value)}
onFocus={() => suggestions.length > 0 && setSuggestOpen(true)}
autoComplete="off"
/>
{suggestOpen && suggestions.length > 0 && (
<ul className="search-author-suggest" role="listbox">
{suggestions.map((u) => (
<li key={u.id}>
<button
type="button"
className="search-author-suggest__item"
role="option"
onClick={() => pickAuthor(u)}
>
{u.avatar
? <img src={u.avatar} alt="" className="search-author-suggest__avatar" loading="lazy" />
: <span className="search-author-suggest__avatar search-author-suggest__avatar--ph">{(u.nickname || u.username).charAt(0)}</span>}
<span className="search-author-suggest__name">{u.nickname || u.username}</span>
{u.nickname && u.username !== u.nickname && (
<span className="search-author-suggest__user">@{u.username}</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</label>
{showBoardScope && (
<div className="post-search-field">
<span className="post-search-field__label"></span>
<div className="search-scope-pills" role="group" aria-label="搜索范围">
<button
type="button"
className={cn('search-scope-pill', scopeBoardId === 0 && 'active')}
aria-pressed={scopeBoardId === 0}
onClick={() => setScopeBoardId(0)}
>
</button>
<button
type="button"
className={cn('search-scope-pill', scopeBoardId === contextBoardId && 'active')}
aria-pressed={scopeBoardId === contextBoardId}
onClick={() => setScopeBoardId(contextBoardId)}
>
{contextBoardName || '当前板块'}
</button>
</div>
</div>
)}
<div className="post-search-field post-search-field--row">
<div>
<span className="post-search-field__label"></span>
<span className="post-search-field__hint"></span>
</div>
<Switch
checked={titleOnly}
onCheckedChange={setTitleOnly}
disabled={!keyword.trim()}
aria-label="仅搜索标题"
/>
</div>
{recent.length > 0 && (
<div className="post-search-recent">
<span className="post-search-field__label"></span>
<ul className="post-search-recent__list">
{recent.map((item) => {
const label = [
item.keyword && `${item.keyword}`,
item.author && `@${item.author}`,
item.titleOnly && '仅标题',
item.scopeBoardId > 0 && '本板块',
].filter(Boolean).join(' · ') || '搜索';
return (
<li key={`${item.keyword}-${item.author}-${item.at}`}>
<button
type="button"
className="post-search-recent__item"
onClick={() => applyRecent(item)}
>
{label}
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
<div className="post-search-panel__footer">
<Button
type="button"
variant="outline"
onClick={() => {
setKeyword('');
setAuthor('');
setTitleOnly(false);
setScopeBoardId(0);
onClear();
}}
>
</Button>
<Button type="button" onClick={handleSubmit}>
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,270 @@
import { useCallback, useMemo } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { buildHomeUrl } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { notify } from '@/lib/notify';
import { parsePermalinkID, type PermalinkOpts } from '../utils/permalink';
/** 打开搜索面板(全局事件) */
export const POST_SEARCH_OPEN_EVENT = 'post-search-open';
export type SearchFilterKey = 'keyword' | 'author' | 'titleOnly' | 'board';
export interface PostSearchSubmitInput {
keyword?: string;
author?: string;
titleOnly?: boolean;
/** 0 = 全站,>0 = 限定板块 */
scopeBoardId?: number;
}
export interface PostSearchState {
keyword: string;
author: string;
titleOnly: boolean;
scopeBoardId: number;
contextBoardId: number;
isFiltered: boolean;
hasAdvancedFilters: boolean;
}
export interface RecentSearch {
keyword: string;
author: string;
titleOnly: boolean;
scopeBoardId: number;
at: number;
}
const RECENT_KEY = 'jiang13-recent-searches';
const RECENT_MAX = 5;
export function parseBoardIdFromLocation(pathname: string, params: URLSearchParams): number {
const m = pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
if (m) return parsePermalinkID(m[1]) || 0;
const q = Number(params.get('board')) || 0;
return q > 0 ? q : 0;
}
export function parseSearchFromUrl(
pathname: string,
params: URLSearchParams,
): Pick<PostSearchState, 'keyword' | 'author' | 'titleOnly' | 'scopeBoardId'> {
const keyword = params.get('keyword') || '';
const author = params.get('author') || '';
const titleOnly = params.get('title_only') === '1';
const pathBoardId = parseBoardIdFromLocation(pathname, params);
const scopeBoardId = (keyword || author) && pathBoardId > 0 ? pathBoardId : 0;
return { keyword, author, titleOnly, scopeBoardId };
}
function readRecentSearches(): RecentSearch[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as RecentSearch[];
return Array.isArray(parsed) ? parsed.slice(0, RECENT_MAX) : [];
} catch {
return [];
}
}
function writeRecentSearches(items: RecentSearch[]) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify(items.slice(0, RECENT_MAX)));
} catch {
/* 忽略存储失败 */
}
}
export function saveRecentSearch(entry: Omit<RecentSearch, 'at'>) {
const kw = entry.keyword.trim();
const author = entry.author.trim();
if (!kw && !author) return;
const next: RecentSearch = { ...entry, keyword: kw, author, at: Date.now() };
const prev = readRecentSearches().filter(
(r) => !(r.keyword === next.keyword && r.author === next.author
&& r.titleOnly === next.titleOnly && r.scopeBoardId === next.scopeBoardId),
);
writeRecentSearches([next, ...prev]);
}
export function getRecentSearches(): RecentSearch[] {
return readRecentSearches();
}
function validateKeyword(kw: string, limits: PermalinkOpts & { search_keyword_min: number; search_keyword_max: number }): boolean {
if (!kw) return true;
const len = [...kw].length;
if (limits.search_keyword_min > 0 && len < limits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${limits.search_keyword_min} 个字`);
return false;
}
if (limits.search_keyword_max > 0 && len > limits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${limits.search_keyword_max} 个字`);
return false;
}
return true;
}
function isSameSearchTarget(
pathname: string,
params: URLSearchParams,
target: string,
input: Required<Pick<PostSearchSubmitInput, 'keyword' | 'author' | 'titleOnly' | 'scopeBoardId'>>,
): boolean {
const active = parseSearchFromUrl(pathname, params);
const activeBoard = parseBoardIdFromLocation(pathname, params);
const onFeed = pathname === '/' || /^\/board\/\d+/.test(pathname);
return onFeed
&& active.keyword === input.keyword
&& active.author === input.author
&& active.titleOnly === input.titleOnly
&& (input.scopeBoardId > 0 ? activeBoard === input.scopeBoardId : active.scopeBoardId === 0);
}
export function usePostSearch(
limits: PermalinkOpts & { search_keyword_min: number; search_keyword_max: number },
) {
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const contextBoardId = useMemo(
() => parseBoardIdFromLocation(loc.pathname, params),
[loc.pathname, params],
);
const filters = useMemo((): PostSearchState => {
const parsed = parseSearchFromUrl(loc.pathname, params);
const isFiltered = !!(parsed.keyword || parsed.author);
const hasAdvancedFilters = !!(
parsed.author
|| (parsed.titleOnly && parsed.keyword)
|| parsed.scopeBoardId > 0
);
return { ...parsed, contextBoardId, isFiltered, hasAdvancedFilters };
}, [loc.pathname, params, contextBoardId]);
const buildUrl = useCallback((input: PostSearchSubmitInput) => {
const kw = (input.keyword ?? '').trim();
const author = (input.author ?? '').trim();
const titleOnly = !!kw && (input.titleOnly ?? false);
const scopeBoard = input.scopeBoardId ?? 0;
return buildHomeUrl(scopeBoard, 'latest', {
keyword: kw,
author,
titleOnly,
permalink: limits,
});
}, [limits]);
const submitSearch = useCallback((
input: PostSearchSubmitInput,
opts?: { refreshIfSame?: boolean },
) => {
const kw = (input.keyword ?? '').trim();
const author = (input.author ?? '').trim();
const activeKw = (params.get('keyword') || '').trim();
const activeAuthor = (params.get('author') || '').trim();
if (!kw && !author) {
if (activeKw || activeAuthor) navigateFeed(nav, '/');
return false;
}
if (!validateKeyword(kw, limits)) return false;
const titleOnly = !!kw && (input.titleOnly ?? false);
const scopeBoardId = input.scopeBoardId ?? 0;
const target = buildUrl({ keyword: kw, author, titleOnly, scopeBoardId });
const normalized = {
keyword: kw,
author,
titleOnly,
scopeBoardId,
};
if (isSameSearchTarget(loc.pathname, params, target, normalized)) {
if (opts?.refreshIfSame) navigateFeed(nav, target);
return true;
}
saveRecentSearch({ keyword: kw, author, titleOnly, scopeBoardId });
nav(target);
return true;
}, [nav, params, loc.pathname, limits, buildUrl]);
const clearSearch = useCallback(() => {
navigateFeed(nav, '/');
}, [nav]);
const removeFilter = useCallback((key: SearchFilterKey) => {
const current = parseSearchFromUrl(loc.pathname, params);
if (!current.keyword && !current.author) return;
let next: PostSearchSubmitInput;
switch (key) {
case 'keyword':
next = {
keyword: '',
author: current.author,
titleOnly: false,
scopeBoardId: current.scopeBoardId,
};
break;
case 'author':
next = {
keyword: current.keyword,
author: '',
titleOnly: current.titleOnly,
scopeBoardId: current.scopeBoardId,
};
break;
case 'titleOnly':
next = {
keyword: current.keyword,
author: current.author,
titleOnly: false,
scopeBoardId: current.scopeBoardId,
};
break;
case 'board':
next = {
keyword: current.keyword,
author: current.author,
titleOnly: current.titleOnly,
scopeBoardId: 0,
};
break;
default:
return;
}
const kw = (next.keyword ?? '').trim();
const author = (next.author ?? '').trim();
if (!kw && !author) {
clearSearch();
return;
}
nav(buildUrl({
keyword: kw,
author,
titleOnly: next.titleOnly,
scopeBoardId: next.scopeBoardId,
}));
}, [loc.pathname, params, nav, buildUrl, clearSearch]);
return {
filters,
buildUrl,
submitSearch,
clearSearch,
removeFilter,
getRecentSearches,
saveRecentSearch,
};
}
export function dispatchOpenPostSearch() {
window.dispatchEvent(new CustomEvent(POST_SEARCH_OPEN_EVENT));
}

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
import PageLoader from '../components/PageLoader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail } from 'lucide-react';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,7 +24,11 @@ import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed } from '../utils/feedCache';
import { notify } from '@/lib/notify';
import PostSearchPanel from '../components/search/PostSearchPanel';
import {
POST_SEARCH_OPEN_EVENT,
usePostSearch,
} from '../hooks/usePostSearch';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
@@ -63,7 +67,7 @@ export default function MainLayout() {
} | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchExpanded, setSearchExpanded] = useState(false);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
@@ -73,13 +77,10 @@ export default function MainLayout() {
if (m) return parsePermalinkID(m[1]) || 0;
return Number(params.get('board')) || 0;
});
const [keyword, setKeyword] = useState(params.get('keyword') || '');
const [searchAuthor, setSearchAuthor] = useState(params.get('author') || '');
const [searchTitleOnly, setSearchTitleOnly] = useState(params.get('title_only') === '1');
const [searchInBoard, setSearchInBoard] = useState(!!params.get('board') && !!params.get('keyword'));
const [searchAdvanced, setSearchAdvanced] = useState(false);
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
const feedSort = parseFeedSort(params.get('sort'));
const { limits: forumLimits } = useForumLimits();
const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && w.enabled);
@@ -118,15 +119,11 @@ export default function MainLayout() {
setBoardId(Number(params.get('board')) || 0);
}, [loc.pathname, params]);
useEffect(() => {
setKeyword(params.get('keyword') || '');
setSearchAuthor(params.get('author') || '');
setSearchTitleOnly(params.get('title_only') === '1');
setSearchInBoard(!!params.get('board') && (!!params.get('keyword') || !!params.get('author')));
setKeywordDraft(params.get('keyword') || '');
}, [params]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
setSearchExpanded(false);
}, [loc.pathname, loc.search]);
useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
@@ -135,16 +132,35 @@ export default function MainLayout() {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
if (!isMobile) {
setSidebarOpen(false);
setSearchExpanded(false);
}
if (!isMobile) setSidebarOpen(false);
}, [isMobile]);
const openSearchPanel = useCallback(() => setSearchPanelOpen(true), []);
useEffect(() => {
if (!searchExpanded) return;
const t = window.setTimeout(() => searchInputRef.current?.focus(), 50);
return () => window.clearTimeout(t);
}, [searchExpanded]);
const onOpen = () => setSearchPanelOpen(true);
window.addEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
return () => window.removeEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
}, []);
useEffect(() => {
if (isCompose) return;
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return;
const tag = (e.target as HTMLElement | null)?.tagName;
const editable = (e.target as HTMLElement | null)?.isContentEditable;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
e.preventDefault();
if (isMobile) {
setSearchPanelOpen(true);
} else {
searchInputRef.current?.focus();
searchInputRef.current?.select();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isCompose, isMobile]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow;
@@ -268,46 +284,28 @@ export default function MainLayout() {
};
}, [needRecentUsers]);
const doSearch = () => {
const kw = keyword.trim();
const author = searchAuthor.trim();
const activeKw = (params.get('keyword') || '').trim();
const activeAuthor = (params.get('author') || '').trim();
const activeTitleOnly = params.get('title_only') === '1';
const activeBoard = boardId;
if (!kw && !author) {
if (activeKw || activeAuthor) navigateFeed(nav, '/');
return;
}
if (kw) {
const len = [...kw].length;
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
return;
}
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
return;
}
}
const scopeBoard = searchInBoard && boardId > 0 ? boardId : 0;
const target = buildHomeUrl(scopeBoard, 'latest', {
keyword: kw,
const doQuickSearch = () => {
const { author, titleOnly, scopeBoardId } = postSearch.filters;
postSearch.submitSearch({
keyword: keywordDraft,
author,
titleOnly: !!kw && searchTitleOnly,
permalink: forumLimits,
});
const same =
(loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname))
&& activeKw === kw
&& activeAuthor === author
&& activeTitleOnly === (!!kw && searchTitleOnly)
&& activeBoard === scopeBoard;
if (same) {
navigateFeed(nav, target);
return;
}
nav(target);
titleOnly,
scopeBoardId,
}, { refreshIfSame: true });
};
const handleHeaderClear = () => {
const hasUrlSearch = postSearch.filters.isFiltered;
setKeywordDraft('');
if (hasUrlSearch) postSearch.clearSearch();
};
const contextBoard = boards.find((b) => b.id === boardId);
const searchPanelDraft = {
keyword: keywordDraft,
author: postSearch.filters.author,
titleOnly: postSearch.filters.titleOnly,
scopeBoardId: postSearch.filters.scopeBoardId,
};
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
@@ -371,9 +369,9 @@ export default function MainLayout() {
return (
<div className="app-shell">
<div className="app-frame">
<header className={`app-header${searchExpanded && isMobile ? ' app-header--search-open' : ''}`}>
<header className="app-header">
<div className="header-inner">
{isMobile && !isCompose && !searchExpanded && (
{isMobile && !isCompose && (
<button
type="button"
className="header-icon-btn"
@@ -386,18 +384,16 @@ export default function MainLayout() {
<Menu size={18} aria-hidden />
</button>
)}
{!(isMobile && searchExpanded) && (
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
</button>
)}
{!isCompose && isMobile && !searchExpanded && (
{!isCompose && isMobile && (
<button
type="button"
className="header-icon-btn header-search-toggle"
onClick={() => setSearchExpanded(true)}
onClick={openSearchPanel}
aria-label="搜索帖子"
title="搜索"
>
@@ -405,14 +401,13 @@ export default function MainLayout() {
</button>
)}
{!isCompose && (!isMobile || searchExpanded) && (
{!isCompose && !isMobile && (
<form
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}${searchAdvanced ? ' header-search-wrap--advanced' : ''}`}
className="header-search-wrap"
role="search"
onSubmit={e => {
e.preventDefault();
doSearch();
if (isMobile) setSearchExpanded(false);
doQuickSearch();
}}
>
<div className="header-search-row">
@@ -421,79 +416,41 @@ export default function MainLayout() {
ref={searchInputRef}
className="header-search-input"
type="search"
placeholder="搜索帖子..."
placeholder="搜索帖子"
aria-label="搜索帖子"
value={keyword}
onChange={e => setKeyword(e.target.value)}
value={keywordDraft}
onChange={e => setKeywordDraft(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
enterKeyHint="search"
/>
<button
type="button"
className={cn('header-search-adv-toggle', searchAdvanced && 'active')}
onClick={() => setSearchAdvanced((v) => !v)}
aria-expanded={searchAdvanced}
title="高级搜索"
className={cn(
'header-search-filter-btn',
postSearch.filters.hasAdvancedFilters && 'header-search-filter-btn--active',
)}
onClick={openSearchPanel}
aria-label="搜索筛选"
title="筛选"
>
<SlidersHorizontal size={15} aria-hidden />
{postSearch.filters.hasAdvancedFilters && (
<span className="header-search-filter-btn__dot" aria-hidden />
)}
</button>
{(keyword || searchAuthor) && (
{(keywordDraft || postSearch.filters.isFiltered) && (
<button
type="button"
className="header-search-clear"
onClick={() => {
setKeyword('');
setSearchAuthor('');
setSearchTitleOnly(false);
navigateFeed(nav, '/');
}}
onClick={handleHeaderClear}
aria-label="清除搜索"
>×</button>
)}
{isMobile && searchExpanded && (
<button
type="button"
className="header-search-cancel"
onClick={() => setSearchExpanded(false)}
>
</button>
)}
<kbd className="header-search-kbd" aria-hidden>Ctrl K</kbd>
</div>
{searchAdvanced && (
<div className="header-search-advanced">
<label className="header-search-opt">
<input
type="checkbox"
checked={searchTitleOnly}
onChange={(e) => setSearchTitleOnly(e.target.checked)}
/>
</label>
{boardId > 0 && (
<label className="header-search-opt">
<input
type="checkbox"
checked={searchInBoard}
onChange={(e) => setSearchInBoard(e.target.checked)}
/>
</label>
)}
<input
className="header-search-author"
type="text"
placeholder="作者用户名/昵称"
aria-label="作者"
value={searchAuthor}
onChange={(e) => setSearchAuthor(e.target.value)}
/>
</div>
)}
</form>
)}
{!(isMobile && searchExpanded) && (
<div className="header-actions">
{!isCompose && (
<button
@@ -597,10 +554,25 @@ export default function MainLayout() {
)}
</div>
</div>
)}
</div>
</header>
{!isCompose && (
<PostSearchPanel
open={searchPanelOpen}
onOpenChange={setSearchPanelOpen}
draft={searchPanelDraft}
contextBoardId={boardId}
contextBoardName={contextBoard?.name}
onSubmit={(input) => {
const ok = postSearch.submitSearch(input);
if (ok) setKeywordDraft((input.keyword ?? '').trim());
return ok;
}}
onClear={postSearch.clearSearch}
/>
)}
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && (
<Sidebar

View File

@@ -6,9 +6,11 @@ import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import FeedSearchFilters from '../components/search/FeedSearchFilters';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import { parseSearchFromUrl, usePostSearch } from '../hooks/usePostSearch';
import {
getFeedCache,
setFeedCache,
@@ -41,6 +43,7 @@ export default function HomePage() {
const ctx = useOutletContext<LayoutCtx>();
const { branding } = useSiteBranding();
const { limits, loading: limitsLoading } = useForumLimits();
const postSearch = usePostSearch(limits);
const pageSize = Math.max(1, limits.page_size_default);
const boardId = boardIdFromLocation(boardRouteId, params);
@@ -60,7 +63,7 @@ export default function HomePage() {
const feedTitle = tag
? `标签:${tag}`
: keyword || author
? `搜索:${keyword || ''}${author ? (keyword ? ` · 作者 ${author}` : `作者 ${author}`) : ''}${titleOnly ? '(仅标题)' : ''}`
? '搜索结果'
: (boardId && board ? board.name : '');
usePageSEO({
title: feedTitle || undefined,
@@ -273,6 +276,8 @@ export default function HomePage() {
};
const showSortBar = !keyword && !tag && !author;
const searchFilters = parseSearchFromUrl(location.pathname, params);
const isSearchActive = !!(keyword || author);
if (isInvalidBoardRoute || isMissingBoard) {
return (
@@ -298,7 +303,6 @@ export default function HomePage() {
keyword={keyword}
tag={tag}
author={author}
titleOnly={titleOnly}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
@@ -308,6 +312,14 @@ export default function HomePage() {
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
)}
</div>
{isSearchActive && (
<FeedSearchFilters
filters={postSearch.filters}
boards={ctx?.boards ?? []}
onRemove={postSearch.removeFilter}
onClear={postSearch.clearSearch}
/>
)}
</div>
<VirtualPostList
posts={posts}
@@ -325,6 +337,12 @@ export default function HomePage() {
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
keyword={keyword || tag || author}
isSearchMode={!!(keyword || author)}
searchKeyword={keyword}
searchAuthor={author}
searchTitleOnly={titleOnly}
searchScopeBoardId={searchFilters.scopeBoardId}
onClearSearch={postSearch.clearSearch}
boardId={boardId}
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
noBoards={!ctx?.boardsLoading && (ctx?.boards?.length ?? 0) === 0}

View File

@@ -388,13 +388,7 @@ img.site-brand-logo-img {
border-radius: 999px;
background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light);
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s, border-radius 0.15s;
}
.header-search-wrap--advanced {
border-radius: 14px;
padding-bottom: 8px;
max-width: 480px;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.header-search-row {
@@ -405,57 +399,56 @@ img.site-brand-logo-img {
min-height: 36px;
}
.header-search-advanced {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
padding: 0 2px 2px 24px;
}
.header-search-opt {
.header-search-filter-btn {
position: relative;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--color-text-3, #64748b);
cursor: pointer;
white-space: nowrap;
}
.header-search-opt input {
margin: 0;
}
.header-search-author {
flex: 1;
min-width: 7rem;
justify-content: center;
width: 28px;
height: 28px;
padding: 0 0.55rem;
border: 1px solid var(--j13-border-light);
border-radius: 0.35rem;
background: var(--j13-bg-surface, #fff);
font-size: 0.78rem;
color: var(--color-text-1);
font-family: inherit;
}
.header-search-adv-toggle {
flex-shrink: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-3);
font-size: 0.75rem;
font-family: inherit;
padding: 0 2px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.header-search-adv-toggle.active,
.header-search-adv-toggle:hover {
.header-search-filter-btn:hover,
.header-search-filter-btn--active {
background: var(--j13-green-bg);
color: var(--j13-green);
}
.header-search-filter-btn__dot {
position: absolute;
top: 4px;
right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--j13-green);
}
.header-search-kbd {
flex-shrink: 0;
display: none;
align-items: center;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--j13-border-light);
background: var(--j13-bg-surface);
font-size: 10px;
font-family: inherit;
color: var(--color-text-4);
line-height: 1.4;
}
@media (min-width: 1100px) {
.header-search-kbd { display: inline-flex; }
}
.header-search-wrap:focus-within {
background: var(--j13-bg-surface);
border-color: color-mix(in srgb, var(--j13-green) 30%, transparent);
@@ -505,6 +498,325 @@ img.site-brand-logo-img {
color: var(--color-text-1);
}
/* —— 搜索面板 —— */
.post-search-panel {
max-width: 480px;
gap: 0;
padding: 0;
overflow: hidden;
}
.post-search-panel--mobile {
top: auto !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
translate: none !important;
max-width: none !important;
width: 100% !important;
max-height: min(92vh, 640px);
border-radius: 16px 16px 0 0 !important;
animation: none !important;
}
.post-search-panel .post-search-panel__desc {
margin-top: 4px;
font-size: 13px;
color: var(--color-text-3);
}
.post-search-panel > [data-radix-dialog-close] {
top: 14px;
right: 14px;
}
.post-search-panel__header {
padding: 20px 24px 12px;
text-align: left;
}
.post-search-panel__body {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 24px 8px;
}
.post-search-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.post-search-field--row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.post-search-field__label {
font-size: 13px;
font-weight: 600;
color: var(--color-text-1);
}
.post-search-field__hint {
font-size: 11px;
color: var(--color-text-4);
}
.post-search-field__input-wrap {
position: relative;
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
padding: 0 12px;
border: 1px solid var(--j13-border-light);
border-radius: 10px;
background: var(--j13-bg-block-muted);
transition: border-color 0.15s, box-shadow 0.15s;
}
.post-search-field__input-wrap:focus-within {
border-color: color-mix(in srgb, var(--j13-green) 35%, transparent);
box-shadow: 0 0 0 3px var(--j13-green-bg);
background: var(--j13-bg-surface);
}
.post-search-field__icon {
flex-shrink: 0;
color: var(--color-text-3);
}
.post-search-field__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: var(--color-text-1);
font-family: inherit;
}
.post-search-field__input::placeholder {
color: var(--color-text-4);
}
.search-scope-pills {
display: inline-flex;
gap: 6px;
padding: 3px;
border-radius: 10px;
background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light);
}
.search-scope-pill {
border: none;
border-radius: 8px;
padding: 6px 14px;
font-size: 13px;
font-family: inherit;
color: var(--color-text-2);
background: transparent;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.search-scope-pill.active {
background: var(--j13-bg-surface);
color: var(--j13-green);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.search-author-suggest {
position: absolute;
z-index: 10;
top: calc(100% + 4px);
left: 0;
right: 0;
margin: 0;
padding: 4px;
list-style: none;
border: 1px solid var(--j13-border-light);
border-radius: 10px;
background: var(--j13-bg-surface);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
max-height: 220px;
overflow-y: auto;
}
.search-author-suggest__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.12s;
}
.search-author-suggest__item:hover {
background: var(--j13-bg-block-muted);
}
.search-author-suggest__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.search-author-suggest__avatar--ph {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--j13-green);
color: #fff;
font-size: 12px;
font-weight: 600;
}
.search-author-suggest__name {
font-size: 13px;
color: var(--color-text-1);
}
.search-author-suggest__user {
font-size: 11px;
color: var(--color-text-4);
}
.post-search-recent__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.post-search-recent__item {
border: 1px solid var(--j13-border-light);
border-radius: 999px;
padding: 4px 10px;
font-size: 12px;
font-family: inherit;
color: var(--color-text-2);
background: var(--j13-bg-block-muted);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.post-search-recent__item:hover {
border-color: color-mix(in srgb, var(--j13-green) 40%, transparent);
color: var(--j13-green);
}
.post-search-panel__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
border-top: 1px solid var(--j13-border-light);
}
.post-search-panel--mobile .post-search-panel__footer {
padding-bottom: max(20px, env(safe-area-inset-bottom));
}
/* —— 结果页搜索筛选条 —— */
.search-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 12px;
padding: 0 16px 10px;
}
.search-filter-bar__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
flex: 1;
}
.search-filter-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 3px 6px 3px 10px;
border-radius: 999px;
font-size: 12px;
color: var(--color-text-2);
background: var(--j13-bg-block-muted);
border: 1px solid var(--j13-border-light);
}
.search-filter-chip__text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-filter-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-3);
cursor: pointer;
flex-shrink: 0;
transition: background 0.12s, color 0.12s;
}
.search-filter-chip__remove:hover {
background: var(--color-fill-3);
color: var(--color-text-1);
}
.search-filter-bar__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.search-filter-bar__link {
border: none;
background: transparent;
padding: 0;
font-size: 12px;
font-family: inherit;
color: var(--j13-green);
cursor: pointer;
}
.search-filter-bar__link:hover {
text-decoration: underline;
}
.search-filter-bar__link--muted {
color: var(--color-text-3);
}
.header-actions {
display: flex;
align-items: center;
@@ -1453,20 +1765,6 @@ img.site-brand-logo-img {
.sidebar { display: none; }
.header-inner { padding: 0 12px; gap: 8px; }
.header-search-wrap { max-width: none; }
.header-search-wrap--expanded {
flex: 1;
max-width: none;
}
.header-search-cancel {
flex-shrink: 0;
border: none;
background: transparent;
color: var(--j13-green);
font-size: 13px;
font-family: inherit;
padding: 0 4px;
cursor: pointer;
}
.header-compose-btn { width: 34px; padding: 0; justify-content: center; }
.feed-banner-row { flex-direction: row; gap: 10px; }
.sidebar-drawer-extras {
@@ -2013,6 +2311,11 @@ body:has(.admin-topbar) .ptr-indicator {
font-weight: 600;
}
.feed-head__meta--count {
font-size: 12px;
color: var(--color-text-3);
}
.feed-head__dot {
margin: 0 4px;
opacity: 0.55;