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 { buildSearchResultUrl } from "@/lib/text-highlight";
import { KnowledgebaseIndex } from "@/components/knowledgebase-index";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { SearchFiltersMenu } from "./filters-menu";
import {
Search,
Loader2,
@@ -197,6 +199,9 @@ function SearchInner() {
const searchParams = useSearchParams();
const router = useRouter();
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
const initialQuery = searchParams.get("q") || "";
@@ -241,12 +246,8 @@ function SearchInner() {
// When the query is empty, derive these as "no results" during render
// instead of setting state inside an effect.
const effectiveData =
isQueryEmpty || isUserSearch ? null : data;
const hasSearched =
isUserSearch
? !isUserQueryEmpty
: !isQueryEmpty;
const effectiveData = isQueryEmpty || isUserSearch ? null : data;
const hasSearched = isUserSearch ? !isUserQueryEmpty : !isQueryEmpty;
// Sync URL when state changes
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">
Find a word, a verse, a name.
</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>
<main className="max-w-5xl mx-auto px-4 sm:px-6 pb-16">
@@ -473,74 +469,20 @@ function SearchInner() {
</button>
</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">
{/* Users chip — toggles the "@" prefix on the input. Sits at
the start of the row so its at-sign icon reads as a kind
of "search mode" indicator. */}
<button
type="button"
onClick={toggleUsersChip}
aria-pressed={isUserChipActive}
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>
);
})}
<SearchFiltersMenu
contentTabs={CONTENT_TABS}
selectedTypes={selectedTypes}
onTypeChange={handleTypeChange}
isUserChipActive={isUserChipActive}
onToggleUsers={toggleUsersChip}
isMobile={isMobile}
/>
</div>
{/* Results Area */}
@@ -633,48 +575,48 @@ function SearchInner() {
!isUserSearch &&
effectiveData &&
effectiveData.results.length > 0 && (
<>
{/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground">
{effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result " : "results "} for
&ldquo;
{effectiveData.query}&rdquo;
</p>
{effectiveData.total_pages > 1 && (
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
onPageChange={handlePageChange}
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}
/>
<>
{/* Results count + top pagination */}
<div className="mb-4 flex items-center justify-between flex-wrap gap-3">
<p className="text-sm text-muted-foreground">
{effectiveData.total.toLocaleString()}{" "}
{effectiveData.total === 1 ? "result " : "results "} for
&ldquo;
{effectiveData.query}&rdquo;
</p>
{effectiveData.total_pages > 1 && (
<PaginationControls
page={effectiveData.page}
totalPages={effectiveData.total_pages}
onPageChange={handlePageChange}
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>
</main>
</div>
@@ -902,9 +844,7 @@ function UserResultCard({ user }: { user: UserResult }) {
<p className="text-sm font-semibold text-foreground truncate">
{user.name}
</p>
<p className="text-xs text-muted-foreground truncate">
@{user.handle}
</p>
<p className="text-xs text-muted-foreground truncate">@{user.handle}</p>
</div>
</Link>
);
+2 -2
View File
@@ -92,9 +92,9 @@ export function SiteBottomNav() {
aria-label={item.label}
aria-current={active ? "page" : undefined}
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
? "text-primary"
? "text-white bg-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
+21 -22
View File
@@ -29,13 +29,14 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, Trash2 } from "lucide-react";
import { ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
import { UpdateBackgroundModal } from "./update-background-modal";
export function PostScrollCard({
@@ -54,38 +55,35 @@ export function PostScrollCard({
const [bgModal, setBgModal] = useState(false);
const [expanded, setExpanded] = useState(false);
const ownerCluster =
onUpdated || onDelete ? (
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5">
{onUpdated ? (
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
) : null}
{onDelete ? (
<OwnerButton
label="Remove"
onClick={() => onDelete(post.id)}
icon={<Trash2 className="size-4" />}
/>
) : null}
</div>
) : null;
// Update Background stays as a media-overlay button — it's context-
// specific to the visible card surface and reads more naturally
// positioned over the image itself. Delete has moved into the
// header's three-dot menu (PostCardMenu) where Block / Report also
// live, so the overlay is now Update-Background-only.
const ownerCluster = onUpdated ? (
<div className="absolute top-3 right-3 z-10">
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
</div>
) : null;
return (
<article
id={`post-${post.id}`}
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">
<AuthorAvatar
src={post.author_avatar_url ?? null}
name={post.author_name ?? "Unknown"}
/>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<Link
href={
post.author_handle ? `/u/${post.author_handle}` : "#"
@@ -100,6 +98,7 @@ export function PostScrollCard({
</span>
) : null}
</div>
<PostCardMenu post={post} onDelete={onDelete} />
</header>
{/* Media — image carousel or gradient-with-quote */}