diff --git a/app/search/filters-menu.tsx b/app/search/filters-menu.tsx new file mode 100644 index 00000000..ea36ad31 --- /dev/null +++ b/app/search/filters-menu.tsx @@ -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; + onTypeChange: (value: string) => void; + isUserChipActive: boolean; + onToggleUsers: () => void; + isMobile: boolean; +}) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const popoverRef = useRef(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 = ( + + ); + + const body = ( +
+ + } + checked={isUserChipActive} + onChange={onToggleUsers} + /> + +
+ + {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 ( + onTypeChange(tab.value)} + /> + ); + })} + +
+ ); + + // Mobile: bottom drawer + if (isMobile) { + return ( + <> + {trigger} + + + + Filters + + {body} + + + + ); + } + + // Desktop: portaled popover + return ( + <> + {trigger} + {open && pos + ? createPortal( +
+ {body} +
, + document.body, + ) + : null} + + ); +} + +// ─── Pieces ──────────────────────────────────────────────────────────── + +function FilterSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+

+ {label} +

+ {children} +
+ ); +} + +function FilterRow({ + label, + icon, + checked, + disabled = false, + onChange, +}: { + label: string; + icon?: React.ReactNode; + checked: boolean; + disabled?: boolean; + onChange: () => void; +}) { + return ( + + ); +} diff --git a/app/search/search-content.tsx b/app/search/search-content.tsx index e47cf3d6..fda4b7c0 100644 --- a/app/search/search-content.tsx +++ b/app/search/search-content.tsx @@ -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(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() {

Find a word, a verse, a name.

-

- Smart search across books, letters, lectures, essays, - journals and magazines. Diacritics are ignored, so - “Krsna” finds “Kṛṣṇa.” -

@@ -473,74 +469,20 @@ function SearchInner() {
- {/* 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. */}
- {/* 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. */} - - - {CONTENT_TABS.map((tab) => { - const active = !isUserChipActive && isTypeActive(tab.value); - return ( - - ); - })} +
{/* Results Area */} @@ -633,48 +575,48 @@ function SearchInner() { !isUserSearch && effectiveData && effectiveData.results.length > 0 && ( - <> - {/* Results count + top pagination */} -
-

- {effectiveData.total.toLocaleString()}{" "} - {effectiveData.total === 1 ? "result " : "results "} for - “ - {effectiveData.query}” -

- {effectiveData.total_pages > 1 && ( - - )} -
- - {/* Result list */} -
- {effectiveData.results.map((result, i) => ( - - ))} -
- - {/* Bottom pagination */} - {effectiveData.total_pages > 1 && ( -
- + <> + {/* Results count + top pagination */} +
+

+ {effectiveData.total.toLocaleString()}{" "} + {effectiveData.total === 1 ? "result " : "results "} for + “ + {effectiveData.query}” +

+ {effectiveData.total_pages > 1 && ( + + )}
- )} - - )} + + {/* Result list */} +
+ {effectiveData.results.map((result, i) => ( + + ))} +
+ + {/* Bottom pagination */} + {effectiveData.total_pages > 1 && ( +
+ +
+ )} + + )}
@@ -902,9 +844,7 @@ function UserResultCard({ user }: { user: UserResult }) {

{user.name}

-

- @{user.handle} -

+

@{user.handle}

); diff --git a/components/site-bottom-nav.tsx b/components/site-bottom-nav.tsx index 1e18e561..6d98f6cb 100644 --- a/components/site-bottom-nav.tsx +++ b/components/site-bottom-nav.tsx @@ -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", )} > diff --git a/components/social/post-scroll-card.tsx b/components/social/post-scroll-card.tsx index 686147ca..e882c4a1 100644 --- a/components/social/post-scroll-card.tsx +++ b/components/social/post-scroll-card.tsx @@ -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 ? ( -
- {onUpdated ? ( - setBgModal(true)} - icon={} - /> - ) : null} - {onDelete ? ( - onDelete(post.id)} - icon={} - /> - ) : null} -
- ) : 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 ? ( +
+ setBgModal(true)} + icon={} + /> +
+ ) : null; return (
- {/* 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). */}
-
+
) : null}
+
{/* Media — image carousel or gradient-with-quote */}