librqbit 9.0.1

The main library used by rqbit torrent client. The binary is just a small wrapper on top of it.
Documentation
import { useState, useCallback, useMemo } from "react";
import { Virtuoso } from "react-virtuoso";
import { GoSearch, GoX } from "react-icons/go";
import debounce from "lodash.debounce";
import { TorrentListItem } from "../api-types";
import { Spinner } from "./Spinner";
import { TorrentCard } from "./TorrentCard";
import { TorrentDetailsModal } from "./modal/TorrentDetailsModal";
import { useUIStore } from "../stores/uiStore";
import {
  TorrentSortColumn,
  SortDirection,
  StatusFilter,
  SORT_COLUMN_LABELS,
  STATUS_FILTER_LABELS,
  compareTorrents,
  isTorrentVisible,
} from "../helper/torrentFilters";
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";

const DEFAULT_SORT_COLUMN: TorrentSortColumn = "id";
const DEFAULT_SORT_DIRECTION: SortDirection = "desc";

export const CardLayout = (props: {
  torrents: Array<TorrentListItem> | null;
  loading: boolean;
}) => {
  const searchQuery = useUIStore((state) => state.searchQuery);
  const setSearchQuery = useUIStore((state) => state.setSearchQuery);
  const statusFilter = useUIStore((state) => state.statusFilter);
  const setStatusFilter = useUIStore((state) => state.setStatusFilter);

  // Keyboard shortcuts (Ctrl+A, Ctrl+F, Escape)
  useKeyboardShortcuts();
  const detailsModalTorrentId = useUIStore(
    (state) => state.detailsModalTorrentId,
  );
  const closeDetailsModal = useUIStore((state) => state.closeDetailsModal);

  const [localSearch, setLocalSearch] = useState(searchQuery);
  const [sortColumn, setSortColumn] =
    useState<TorrentSortColumn>(DEFAULT_SORT_COLUMN);
  const [sortDirection, setSortDirection] = useState<SortDirection>(
    DEFAULT_SORT_DIRECTION,
  );

  // eslint-disable-next-line react-hooks/exhaustive-deps
  const debouncedSetSearch = useCallback(
    debounce((value: string) => setSearchQuery(value), 150),
    [setSearchQuery],
  );

  const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalSearch(value);
    debouncedSetSearch(value);
  };

  const clearSearch = () => {
    setLocalSearch("");
    setSearchQuery("");
  };

  const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const [col, dir] = e.target.value.split(":") as [
      TorrentSortColumn,
      SortDirection,
    ];
    setSortColumn(col);
    setSortDirection(dir);
  };

  const normalizedQuery = searchQuery.toLowerCase().trim();

  // Sort and filter torrents for virtualization
  const filteredTorrents = useMemo(() => {
    if (!props.torrents) return null;
    return [...props.torrents]
      .filter((t) => isTorrentVisible(t, normalizedQuery, statusFilter))
      .sort((a, b) => compareTorrents(a, b, sortColumn, sortDirection));
  }, [
    props.torrents,
    normalizedQuery,
    statusFilter,
    sortColumn,
    sortDirection,
  ]);

  // Item renderer for react-virtuoso
  const itemContent = useCallback(
    (index: number) => {
      const torrent = filteredTorrents![index];
      return (
        <div className="pb-1.5 sm:pb-2 px-2 sm:px-7 max-w-4xl mx-auto w-full">
          <TorrentCard key={torrent.id} torrent={torrent} />
        </div>
      );
    },
    [filteredTorrents],
  );

  return (
    <div className="flex flex-col h-full">
      <div className="flex items-center gap-1.5 sm:gap-2 w-full max-w-2xl mx-auto mb-2 mt-3 px-2 sm:px-7">
        {/* Search input */}
        <div className="relative flex-1 min-w-0">
          <GoSearch className="absolute left-2.5 sm:left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-tertiary" />
          <input
            type="text"
            data-search-input
            value={localSearch}
            onChange={handleSearchChange}
            placeholder="Search..."
            className="w-full pl-8 sm:pl-9 pr-8 sm:pr-9 py-1.5 sm:py-2 text-sm bg-surface border border-divider rounded-lg focus:outline-none focus:border-primary placeholder:text-tertiary"
          />
          {localSearch && (
            <button
              onClick={clearSearch}
              className="absolute right-1.5 sm:right-2 top-1/2 -translate-y-1/2 p-1 text-tertiary hover:text-secondary rounded cursor-pointer"
            >
              <GoX className="w-4 h-4" />
            </button>
          )}
        </div>

        {/* Status filter */}
        <select
          value={statusFilter}
          onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
          className="py-1.5 sm:py-2 px-2 sm:px-3 text-sm bg-surface border border-divider rounded-lg focus:outline-none focus:border-primary"
        >
          {(Object.keys(STATUS_FILTER_LABELS) as StatusFilter[]).map(
            (status) => (
              <option key={status} value={status}>
                {STATUS_FILTER_LABELS[status]}
              </option>
            ),
          )}
        </select>

        {/* Sort dropdown */}
        <select
          value={`${sortColumn}:${sortDirection}`}
          onChange={handleSortChange}
          className="py-1.5 sm:py-2 px-2 sm:px-3 text-sm bg-surface border border-divider rounded-lg focus:outline-none focus:border-primary"
        >
          {(Object.keys(SORT_COLUMN_LABELS) as TorrentSortColumn[]).flatMap(
            (col) => [
              <option key={`${col}:desc`} value={`${col}:desc`}>
                {SORT_COLUMN_LABELS[col]} ↓
              </option>,
              <option key={`${col}:asc`} value={`${col}:asc`}>
                {SORT_COLUMN_LABELS[col]} ↑
              </option>,
            ],
          )}
        </select>
      </div>
      {filteredTorrents === null ? (
        props.loading ? (
          <Spinner
            className="justify-center m-5"
            label="Loading torrent list"
          />
        ) : null
      ) : filteredTorrents.length === 0 ? (
        <p className="text-center">No existing torrents found.</p>
      ) : (
        <div className="flex-1 min-h-0">
          <Virtuoso
            totalCount={filteredTorrents.length}
            itemContent={itemContent}
          />
        </div>
      )}
      {detailsModalTorrentId !== null && (
        <TorrentDetailsModal
          torrentId={detailsModalTorrentId}
          isOpen={true}
          onClose={closeDetailsModal}
        />
      )}
    </div>
  );
};