search page ui fixes
This commit is contained in:
@@ -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
@@ -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
|
||||
“Krsna” finds “Kṛṣṇa.”
|
||||
</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
|
||||
“
|
||||
{effectiveData.query}”
|
||||
</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
|
||||
“
|
||||
{effectiveData.query}”
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user