Ctrl K
ComunidadGuías…

src/components/dives/dives-page-frame.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useState, type ReactNode, type Ref } from "react";
import { CloudUpload, Plus } from "lucide-react";
import { DiveIcon } from "@/components/logo";
import { EmptyState } from "@/components/ui/empty-state";

import { ButtonLink } from "@/components/ui/button";
import { HERO_BODY, IndexHero } from "@/components/ui/map-hero";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { CountBadge } from "@/components/ui/count-badge";
import {
  ListCardHeader,
  useIsEmptyList,
} from "@/components/ui/list-card-header";
import { LoadMoreTrigger } from "@/components/ui/load-more-trigger";
import { ListFiltersButton } from "@/components/ui/list-filters-button";
import {
  DivesFilters,
  diveFiltersChanged,
  hasDiveFilters,
  NO_DIVE_FILTERS,
  type DiveListFilters,
} from "@/components/dives/dives-filters";
import type { Tag } from "@/lib/api/tags";
import { BackdropCardList } from "@/components/ui/backdrop-card";

export interface DivesPageFrameProps {
  isLoading: boolean;
  totalCount: number;
  /** Whether the query that answered `totalCount` narrowed the list. */
  isCountNarrowed?: boolean;
  itemsPerPage: number;
  /** One `DiveCard` per dive - list items, for the list this frame draws. */
  cards?: ReactNode[];
  /**
   * The numbering card above the list card. It draws nothing until its own
   * request lands, and nothing at all for a log already numbered consecutively
   * in date order, so leaving it out is the page's own first render.
   */
  numbering?: ReactNode;
  /** The drafts card, above the numbering. It too draws nothing while there are none. */
  drafts?: ReactNode;
  /**
   * Attached to the page's `h1`, which is `tabIndex={-1}` so it can be given
   * focus when something between it and the list removes itself.
   */
  headingRef?: Ref<HTMLHeadingElement>;
  isLoadingMore?: boolean;
  loadFailed?: boolean;
  hasMore?: boolean;
  onLoadMore?: () => void;
  /** The tag, the type and the order the list is read in. */
  filters?: DiveListFilters;
  onFiltersChange?: (filters: DiveListFilters) => void;
  /**
   * Fired each time the filter panel is opened. The page reads the diver's tags
   * off the back of it - the panel is shut on arrival, so most visits need no
   * such request at all.
   */
  onFiltersOpened?: () => void;
  /** What the tag select offers. */
  tags?: readonly Tag[];
}

const noop = () => {};

// Everything /dives draws before its cards exist, kept apart from the data render so
// the page's first render is this frame. Every data-varying prop is optional,
// and the defaults are that first render.
export function DivesPageFrame({
  isLoading,
  totalCount,
  isCountNarrowed = false,
  itemsPerPage,
  cards = [],
  numbering,
  drafts,
  headingRef,
  isLoadingMore = false,
  loadFailed = false,
  hasMore = false,
  onLoadMore = noop,
  filters = NO_DIVE_FILTERS,
  onFiltersChange = noop,
  onFiltersOpened = noop,
  tags,
}: DivesPageFrameProps) {
  const [isPanelOpen, setPanelOpen] = useState(false);
  const isNarrowed = hasDiveFilters(filters);
  // An unstarted logbook, rather than one filtered down to nothing - see
  // `useIsEmptyList`.
  const isEmptyList = useIsEmptyList({
    isLoading,
    count: cards.length,
    isNarrowed,
  });

  // Spaced by the container rather than by a margin on each block: the
  // numbering card is absent more often than not, and a gap it carried itself
  // would be left behind on the pages where it draws nothing.
  return (
    <div>
      <IndexHero
        title="Dives"
        description="Manage and track your diving activities"
        headingRef={headingRef}
        actions={
          <>
            <ButtonLink variant="ghost" size="sm" href="/import">
              <CloudUpload className="h-4 w-4 mr-2" />
              Import
            </ButtonLink>
            <ButtonLink size="sm" href="/dives/new">
              <Plus className="h-4 w-4 mr-2" />
              Log new dive
            </ButtonLink>
          </>
        }
      />

      <div className={cn(HERO_BODY, "space-y-6 max-sm:space-y-2.5")}>
        {drafts}
        {numbering}

        <Card>
          <ListCardHeader title="Dive Log" isEmpty={isEmptyList}>
            <CountBadge
              count={totalCount}
              isLoading={isLoading}
              label="dive"
              total
              isNarrowed={isCountNarrowed}
            />
            <ListFiltersButton
              controls="dive-filters"
              isOpen={isPanelOpen}
              onOpenChange={(open) => {
                if (open) onFiltersOpened();
                setPanelOpen(open);
              }}
              onClear={() => onFiltersChange(NO_DIVE_FILTERS)}
              isChanged={diveFiltersChanged(filters)}
              label="Filter and sort dives"
            />
          </ListCardHeader>
          {/* Hidden rather than unmounted, so `aria-controls` points at
              something, and gone with the button that opens it for a list with
              nothing in it to narrow. */}
          {!isEmptyList && (
            <CardContent id="dive-filters" hidden={!isPanelOpen}>
              <DivesFilters
                filters={filters}
                onFiltersChange={onFiltersChange}
                tags={tags}
              />
            </CardContent>
          )}
          {/* The card is the list's header - its count and its filters - and
              what it says when there is nothing to list. The dives themselves
              are cards of their own, so they sit below it rather than in it. */}
          {!isLoading && cards.length === 0 && (
            <CardContent>
              {isNarrowed ? (
                // A list filtered to nothing is not an empty logbook, so it
                // keeps one line and no "log your first dive" - see "One
                // `EmptyState`, and the filtered list is not one" in
                // DECISIONS.md.
                <div className="text-center py-12 text-muted-foreground">
                  No dives match those filters.
                </div>
              ) : (
                <EmptyState
                  icon={DiveIcon}
                  title="No dives logged yet"
                  description="Start by adding your first dive!"
                  action={
                    <ButtonLink href="/dives/new">
                      <Plus className="h-4 w-4 mr-2" />
                      Log your first dive
                    </ButtonLink>
                  }
                />
              )}
            </CardContent>
          )}
        </Card>

        {(isLoading || cards.length > 0) && (
          // One dive to a row below `lg`, two above, as /trips: a card may hold
          // a map, and a browser keeps only so many of those per page - see
          // `BackdropCard`.
          <BackdropCardList
            layout="grid gap-4 max-sm:gap-2.5 lg:grid-cols-2"
            placeholders={itemsPerPage}
          >
            {cards}
          </BackdropCardList>
        )}

        <LoadMoreTrigger
          hasMore={hasMore}
          isLoading={isLoadingMore}
          hasFailed={loadFailed}
          loadedCount={cards.length}
          totalCount={totalCount}
          itemsPerPage={itemsPerPage}
          itemLabel="dives"
          onLoadMore={onLoadMore}
        />
      </div>
    </div>
  );
}