← back to Open Seo

src/client/features/ai-search/components/SearchHistorySection.tsx

102 lines

import type { ComponentType, ReactNode } from "react";
import { Clock, History, X } from "lucide-react";

type Props<TItem extends { timestamp: number }> = {
  history: TItem[];
  historyLoaded: boolean;
  onRemoveHistoryItem: (timestamp: number) => void;
  /**
   * Renders the clickable area of a history row. The caller is responsible
   * for wrapping `content` in a <Link> (or other clickable element) so that
   * cmd+click and right-click → "open in new tab" behave natively.
   */
  renderItemLink: (item: TItem, content: ReactNode) => ReactNode;
  /** Icon component rendered in the empty state (e.g. Sparkles, MessageSquare). */
  emptyIcon: ComponentType<{ className?: string }>;
  /** Empty-state headline copy. */
  emptyMessage: string;
  /**
   * Label noun used in the "{n} recent {noun}(s)" header (e.g. "lookup",
   * "prompt"). Pluralization is handled by the component.
   */
  noun: string;
  /** Item body — primary (and optional secondary) text shown in each row. */
  renderItem: (item: TItem) => ReactNode;
};

export function SearchHistorySection<TItem extends { timestamp: number }>({
  history,
  historyLoaded,
  onRemoveHistoryItem,
  renderItemLink,
  emptyIcon: EmptyIcon,
  emptyMessage,
  noun,
  renderItem,
}: Props<TItem>) {
  if (!historyLoaded) {
    return null;
  }

  if (history.length === 0) {
    return (
      <section className="rounded-2xl border border-dashed border-base-300 bg-base-100/70 p-6 text-center text-base-content/55 space-y-2">
        <EmptyIcon className="size-9 mx-auto opacity-35" />
        <p className="text-base font-medium text-base-content/80">
          {emptyMessage}
        </p>
      </section>
    );
  }

  return (
    <section className="rounded-2xl border border-base-300 bg-base-100 p-5 md:p-6">
      <div className="flex items-center justify-between mb-4">
        <div className="flex items-center gap-2">
          <History className="size-4 text-base-content/45" />
          <span className="text-sm text-base-content/60">
            {history.length} recent {noun}
            {history.length !== 1 ? "s" : ""}
          </span>
        </div>
      </div>

      <div className="grid gap-2">
        {history.map((item) => (
          <div
            key={item.timestamp}
            className="group flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2"
          >
            {renderItemLink(
              item,
              <>
                <Clock className="size-4 text-base-content/40 shrink-0" />
                <div className="min-w-0">{renderItem(item)}</div>
              </>,
            )}
            <div className="flex items-center gap-2 shrink-0">
              <span className="text-xs text-base-content/40">
                {new Date(item.timestamp).toLocaleDateString(undefined, {
                  month: "short",
                  day: "numeric",
                })}
              </span>
              <button
                type="button"
                className="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 p-1"
                onClick={() => onRemoveHistoryItem(item.timestamp)}
                aria-label="Remove from history"
              >
                <X className="size-3" />
              </button>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

export const HISTORY_ITEM_LINK_CLASS =
  "flex min-w-0 flex-1 items-center gap-3 rounded-md px-1 py-1 text-left transition-colors hover:bg-base-200";