search page ui fixes

This commit is contained in:
gosangam
2026-05-31 11:09:43 +05:30
parent 489adb0eeb
commit 17fb83087f
4 changed files with 384 additions and 146 deletions
+299
View File
@@ -0,0 +1,299 @@
"use client";
/**
* SearchFiltersMenu — collapses the search-page filter chip row into
* a single trigger + multi-select dropdown.
*
* Trigger button:
* • Shows "Filters" + a count badge when one or more filters are
* active. The badge sums Users (1, if on) OR content-type count
* (Users is mutually exclusive with content narrowing).
*
* Dropdown content:
* • "Mode" section — single Users toggle (drives the @ prefix on
* the input via onToggleUsers).
* • "Content types" section — Books / Letters / Transcriptions / …
* each a checkbox. When Users is on, this group is disabled
* (content narrowing doesn't apply to user search).
*
* Layout: popover anchored to the trigger on desktop; bottom drawer on
* mobile — same modal-vs-drawer convention the rest of the app's menus
* use (PostCardMenu, SharePostSheet, CollectionsDrawer).
*
* "All" is intentionally NOT a checkbox — it's the implicit state when
* zero content types are selected. We render no row for it.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
AtSign,
Check,
ChevronDown,
SlidersHorizontal,
} from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
export type ContentTab = { readonly label: string; readonly value: string };
export function SearchFiltersMenu({
contentTabs,
selectedTypes,
onTypeChange,
isUserChipActive,
onToggleUsers,
isMobile,
}: {
contentTabs: readonly ContentTab[];
selectedTypes: Set<string>;
onTypeChange: (value: string) => void;
isUserChipActive: boolean;
onToggleUsers: () => void;
isMobile: boolean;
}) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
// Users mode is mutually exclusive with content narrowing, so the
// active count is one OR the other — never their sum.
const activeCount = isUserChipActive ? 1 : selectedTypes.size;
// Desktop popover positioning — below the trigger, left-aligned.
useEffect(() => {
if (!open || isMobile) {
setPos(null);
return;
}
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const width = 280;
const pad = 8;
let left = rect.left;
if (left + width > window.innerWidth - pad) {
left = window.innerWidth - width - pad;
}
if (left < pad) left = pad;
const top = rect.bottom + 6;
setPos({ top, left });
}
recompute();
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
return () => {
window.removeEventListener("resize", recompute);
window.removeEventListener("scroll", recompute, true);
};
}, [open, isMobile]);
// Click-outside + Escape close (desktop only; mobile uses Drawer's
// own dismiss handlers).
useEffect(() => {
if (!open || isMobile) return;
function onPointer(e: PointerEvent) {
if (popoverRef.current?.contains(e.target as Node)) return;
if (triggerRef.current?.contains(e.target as Node)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
const t = setTimeout(() => {
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
}, 0);
return () => {
clearTimeout(t);
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [open, isMobile]);
const trigger = (
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="menu"
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
activeCount > 0
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
<SlidersHorizontal className="size-4" />
Filters
{activeCount > 0 ? (
<span
className={cn(
"ml-0.5 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none tabular-nums",
"bg-primary-foreground/20 text-primary-foreground",
)}
>
{activeCount}
</span>
) : null}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
open && "rotate-180",
)}
aria-hidden
/>
</button>
);
const body = (
<div className="py-2">
<FilterSection label="Mode">
<FilterRow
label="Users"
icon={<AtSign className="size-4" />}
checked={isUserChipActive}
onChange={onToggleUsers}
/>
</FilterSection>
<div className="border-t border-border my-1" />
<FilterSection label="Content types">
{contentTabs
// "All" is the implicit default state — no row needed.
.filter((t) => t.value !== "all")
.map((tab) => {
const checked =
!isUserChipActive && selectedTypes.has(tab.value);
return (
<FilterRow
key={tab.value}
label={tab.label}
checked={checked}
disabled={isUserChipActive}
onChange={() => onTypeChange(tab.value)}
/>
);
})}
</FilterSection>
</div>
);
// Mobile: bottom drawer
if (isMobile) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={setOpen}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left border-b border-border pb-3">
<DrawerTitle>Filters</DrawerTitle>
</DrawerHeader>
{body}
</DrawerContent>
</Drawer>
</>
);
}
// Desktop: portaled popover
return (
<>
{trigger}
{open && pos
? createPortal(
<div
ref={popoverRef}
role="menu"
style={{
position: "fixed",
top: pos.top,
left: pos.left,
zIndex: 60,
}}
className={cn(
"w-[280px] rounded-lg border border-border bg-card shadow-lg",
"animate-in fade-in-0 zoom-in-95",
)}
>
{body}
</div>,
document.body,
)
: null}
</>
);
}
// ─── Pieces ────────────────────────────────────────────────────────────
function FilterSection({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="px-2 py-1.5">
<p className="text-[10px] uppercase tracking-[0.18em] font-semibold text-muted-foreground/70 mb-1.5 px-2">
{label}
</p>
{children}
</div>
);
}
function FilterRow({
label,
icon,
checked,
disabled = false,
onChange,
}: {
label: string;
icon?: React.ReactNode;
checked: boolean;
disabled?: boolean;
onChange: () => void;
}) {
return (
<button
type="button"
role="menuitemcheckbox"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
"hover:bg-muted focus:bg-muted outline-none",
disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent focus:bg-transparent",
)}
>
<span
className={cn(
"size-[18px] rounded border-2 flex items-center justify-center shrink-0 transition-colors",
checked
? "bg-primary border-primary"
: "bg-background border-muted-foreground/40",
)}
>
{checked ? (
<Check className="size-3 text-primary-foreground" strokeWidth={3} />
) : null}
</span>
{icon ? (
<span className="text-muted-foreground shrink-0">{icon}</span>
) : null}
<span className="text-sm text-foreground flex-1 truncate">{label}</span>
</button>
);
}
+62 -122
View File
@@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { buildSearchResultUrl } from "@/lib/text-highlight"; import { buildSearchResultUrl } from "@/lib/text-highlight";
import { KnowledgebaseIndex } from "@/components/knowledgebase-index"; import { KnowledgebaseIndex } from "@/components/knowledgebase-index";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { SearchFiltersMenu } from "./filters-menu";
import { import {
Search, Search,
Loader2, Loader2,
@@ -197,6 +199,9 @@ function SearchInner() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Drives the popover-vs-drawer split inside SearchFiltersMenu —
// same convention as the rest of the app's overflow menus.
const isMobile = useMediaQuery("(max-width: 767px)");
// Read initial state from URL // Read initial state from URL
const initialQuery = searchParams.get("q") || ""; const initialQuery = searchParams.get("q") || "";
@@ -241,12 +246,8 @@ function SearchInner() {
// When the query is empty, derive these as "no results" during render // When the query is empty, derive these as "no results" during render
// instead of setting state inside an effect. // instead of setting state inside an effect.
const effectiveData = const effectiveData = isQueryEmpty || isUserSearch ? null : data;
isQueryEmpty || isUserSearch ? null : data; const hasSearched = isUserSearch ? !isUserQueryEmpty : !isQueryEmpty;
const hasSearched =
isUserSearch
? !isUserQueryEmpty
: !isQueryEmpty;
// Sync URL when state changes // Sync URL when state changes
const pushUrl = useCallback( const pushUrl = useCallback(
@@ -423,11 +424,6 @@ function SearchInner() {
<h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-4xl leading-tight text-foreground"> <h1 className="mt-4 font-[family-name:var(--font-title)] font-semibold text-2xl sm:text-4xl leading-tight text-foreground">
Find a word, a verse, a name. Find a word, a verse, a name.
</h1> </h1>
<p className="mt-3 max-w-xl text-base text-muted-foreground leading-relaxed">
Smart search across books, letters, lectures, essays,
journals and magazines. Diacritics are ignored, so
&ldquo;Krsna&rdquo; finds &ldquo;Kṛṣṇa.&rdquo;
</p>
</section> </section>
<main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16"> <main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16">
@@ -473,74 +469,20 @@ function SearchInner() {
</button> </button>
</div> </div>
{/* Content Type Tabs */} {/* Filters — single trigger that opens a popover (desktop) or
drawer (mobile) of multi-select checkboxes. The trigger
carries a count badge when one or more filters are active.
Users is mutually exclusive with content narrowing inside
the menu. */}
<div className="mt-5 flex flex-wrap gap-2"> <div className="mt-5 flex flex-wrap gap-2">
{/* Users chip — toggles the "@" prefix on the input. Sits at <SearchFiltersMenu
the start of the row so its at-sign icon reads as a kind contentTabs={CONTENT_TABS}
of "search mode" indicator. */} selectedTypes={selectedTypes}
<button onTypeChange={handleTypeChange}
type="button" isUserChipActive={isUserChipActive}
onClick={toggleUsersChip} onToggleUsers={toggleUsersChip}
aria-pressed={isUserChipActive} isMobile={isMobile}
className={cn( />
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
isUserChipActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
)}
>
<AtSign className="size-4" />
Users
{userData &&
isUserChipActive &&
userData.results.length > 0 && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none",
"bg-primary-foreground/20 text-primary-foreground",
)}
>
{userData.results.length}
</span>
)}
</button>
{CONTENT_TABS.map((tab) => {
const active = !isUserChipActive && isTypeActive(tab.value);
return (
<button
key={tab.value}
onClick={() => handleTypeChange(tab.value)}
disabled={isUserChipActive}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground",
// When the @ prefix is on, content tabs don't apply —
// grey them out so the chip group reads as "user
// mode is exclusive".
isUserChipActive &&
"opacity-40 cursor-not-allowed hover:bg-muted hover:text-muted-foreground",
)}
>
{tab.label}
{effectiveData &&
active &&
selectedTypes.size <= 1 &&
effectiveData.total > 0 && (
<span
className={cn(
"ml-1 inline-flex items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold leading-none",
"bg-primary-foreground/20 text-primary-foreground",
)}
>
{effectiveData.total.toLocaleString()}
</span>
)}
</button>
);
})}
</div> </div>
{/* Results Area */} {/* Results Area */}
@@ -633,48 +575,48 @@ function SearchInner() {
!isUserSearch && !isUserSearch &&
effectiveData && effectiveData &&
effectiveData.results.length > 0 && ( effectiveData.results.length > 0 && (
<> <>
{/* Results count + top pagination */} {/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3"> <div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{effectiveData.total.toLocaleString()}{" "} {effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result " : "results "} for {effectiveData.total === 1 ? "result " : "results "} for
&ldquo; &ldquo;
{effectiveData.query}&rdquo; {effectiveData.query}&rdquo;
</p> </p>
{effectiveData.total_pages > 1 && ( {effectiveData.total_pages > 1 && (
<PaginationControls <PaginationControls
page={effectiveData.page} page={effectiveData.page}
totalPages={effectiveData.total_pages} totalPages={effectiveData.total_pages}
onPageChange={handlePageChange} onPageChange={handlePageChange}
compact compact
/> />
)} )}
</div>
{/* Result list */}
<div className="space-y-3">
{effectiveData.results.map((result, i) => (
<ResultCard
key={`${result.collection}-${result.id || i}`}
result={result}
/>
))}
</div>
{/* Bottom pagination */}
{effectiveData.total_pages > 1 && (
<div className="mt-8">
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
total={effectiveData.total}
onPageChange={handlePageChange}
/>
</div> </div>
)}
</> {/* Result list */}
)} <div className="space-y-3">
{effectiveData.results.map((result, i) => (
<ResultCard
key={`${result.collection}-${result.id || i}`}
result={result}
/>
))}
</div>
{/* Bottom pagination */}
{effectiveData.total_pages > 1 && (
<div className="mt-8">
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
total={effectiveData.total}
onPageChange={handlePageChange}
/>
</div>
)}
</>
)}
</div> </div>
</main> </main>
</div> </div>
@@ -902,9 +844,7 @@ function UserResultCard({ user }: { user: UserResult }) {
<p className="text-sm font-semibold text-foreground truncate"> <p className="text-sm font-semibold text-foreground truncate">
{user.name} {user.name}
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">@{user.handle}</p>
@{user.handle}
</p>
</div> </div>
</Link> </Link>
); );
+2 -2
View File
@@ -92,9 +92,9 @@ export function SiteBottomNav() {
aria-label={item.label} aria-label={item.label}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
className={cn( className={cn(
"h-14 flex flex-col items-center justify-center gap-0.5", "w-14 h-14 rounded-md flex flex-col items-center justify-center",
active active
? "text-primary" ? "text-white bg-primary"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
)} )}
> >
+21 -22
View File
@@ -29,13 +29,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { ImageIcon, Trash2 } from "lucide-react"; import { ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social"; import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference"; import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared"; import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer"; import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
import { UpdateBackgroundModal } from "./update-background-modal"; import { UpdateBackgroundModal } from "./update-background-modal";
export function PostScrollCard({ export function PostScrollCard({
@@ -54,38 +55,35 @@ export function PostScrollCard({
const [bgModal, setBgModal] = useState(false); const [bgModal, setBgModal] = useState(false);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const ownerCluster = // Update Background stays as a media-overlay button — it's context-
onUpdated || onDelete ? ( // specific to the visible card surface and reads more naturally
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5"> // positioned over the image itself. Delete has moved into the
{onUpdated ? ( // header's three-dot menu (PostCardMenu) where Block / Report also
<OwnerButton // live, so the overlay is now Update-Background-only.
label="Update background" const ownerCluster = onUpdated ? (
onClick={() => setBgModal(true)} <div className="absolute top-3 right-3 z-10">
icon={<ImageIcon className="size-4" />} <OwnerButton
/> label="Update background"
) : null} onClick={() => setBgModal(true)}
{onDelete ? ( icon={<ImageIcon className="size-4" />}
<OwnerButton />
label="Remove" </div>
onClick={() => onDelete(post.id)} ) : null;
icon={<Trash2 className="size-4" />}
/>
) : null}
</div>
) : null;
return ( return (
<article <article
id={`post-${post.id}`} id={`post-${post.id}`}
className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24" className="rounded-2xl overflow-hidden bg-card ring-1 ring-foreground/10 scroll-mt-20 sm:scroll-mt-24"
> >
{/* Author header */} {/* Author header — identity on the left, three-dot overflow menu
on the right (Delete for author, Block/Report for everyone
else; PostCardMenu hides itself when there are no items). */}
<header className="flex items-center gap-3 px-4 py-3"> <header className="flex items-center gap-3 px-4 py-3">
<AuthorAvatar <AuthorAvatar
src={post.author_avatar_url ?? null} src={post.author_avatar_url ?? null}
name={post.author_name ?? "Unknown"} name={post.author_name ?? "Unknown"}
/> />
<div className="min-w-0"> <div className="min-w-0 flex-1">
<Link <Link
href={ href={
post.author_handle ? `/u/${post.author_handle}` : "#" post.author_handle ? `/u/${post.author_handle}` : "#"
@@ -100,6 +98,7 @@ export function PostScrollCard({
</span> </span>
) : null} ) : null}
</div> </div>
<PostCardMenu post={post} onDelete={onDelete} />
</header> </header>
{/* Media — image carousel or gradient-with-quote */} {/* Media — image carousel or gradient-with-quote */}