src/components/dives/recent-dives-card.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useCallback, useEffect, useRef } from "react";
import { divesAPI, Dive } from "@/lib/api/dives";
import { useInfiniteResource } from "@/hooks/useInfiniteResource";
import { useWithReturnTo } from "@/hooks/useReturnTo";
import {
CARD_TITLE_ACTION,
CARD_TITLE_ROW,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ButtonLink } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { LoadMoreTrigger } from "@/components/ui/load-more-trigger";
import { BackdropCardList } from "@/components/ui/backdrop-card";
import { DiveCard } from "@/components/dives/dive-card";
import { Plus } from "lucide-react";
import { DiveIcon } from "@/components/logo";
import { cn } from "@/lib/utils";
const RECENT_DIVES_COUNT = 5;
// The page size for the scoped lists on the detail pages. Ten, like the other
// list pages, rather than the API's 100-row ceiling: the rows are links a reader
// scans, and a first paint of ten arrives sooner than one of a hundred.
const DIVES_PER_PAGE = 10;
export interface RecentDivesCardProps {
// False until there's a signed-in user - the list reads the caller's own log
// and takes no user uuid, so the page has to say when the session is known.
enabled: boolean;
// Only show dives made at this dive site. When omitted, shows dives
// regardless of dive site.
diveSiteId?: string;
// Only show dives this gear item was used on. When omitted, shows dives
// regardless of gear.
gearItemId?: string;
// Only show dives that were part of this training course. When omitted, shows
// dives regardless of course.
courseId?: string;
// Only show dives that recorded this species. When omitted, shows dives
// regardless of what was spotted.
speciesId?: string;
// Only show dives that name this person. When omitted, shows dives regardless
// of who was on them.
personId?: string;
// Show every dive in scope, a page at a time as the reader scrolls, rather
// than Home's fixed preview of the latest few.
//
// A flag rather than a large `limit`: the API clamps `items_per_page` to 100,
// so a diver past that number would be shown a list that looks complete and
// is not.
//
// A complete list that holds nothing also drops its header down to its hidden
// heading, as the list pages' cards do: the empty state already says what the
// card is for. The Home page's preview keeps it beside the trips card.
complete?: boolean;
// Re-read the dives on screen, in place, whenever this changes - after an
// edit to the record they are scoped to that shows on their cards: a site's
// name is in their titles and its pin is on their maps.
refreshOn?: unknown;
title?: string;
description?: string;
// Href/label for the header's "view all" button. Pass `null` to hide it
// entirely (e.g. when the card already shows the full list).
viewAllHref?: string | null;
viewAllLabel?: string;
emptyTitle?: string;
emptyDescription?: string;
newDiveHref?: string;
newDiveLabel?: string;
}
// Shows a list of dives for a user, each as a `DiveCard`.
// Used on the Home page (the most recent few) and on the detail pages that
// scope dives to one record - a dive site, a gear item, a course, a species, a
// person - so they all stay in sync.
export function RecentDivesCard({
enabled,
diveSiteId,
gearItemId,
courseId,
speciesId,
personId,
complete = false,
refreshOn,
title = "Recent Dives",
description,
viewAllHref = "/dives",
viewAllLabel = "View all dives",
emptyTitle = "No dives logged yet",
emptyDescription = "Start your diving journey by logging your first dive!",
newDiveHref = "/dives/new",
newDiveLabel = "Log your first dive",
}: RecentDivesCardProps) {
const withReturnTo = useWithReturnTo();
const fetchDives = useCallback(
(page: number, perPage: number) =>
divesAPI.getDives(page, perPage, {
diveSiteUuid: diveSiteId,
gearItemUuid: gearItemId,
courseUuid: courseId,
speciesUuid: speciesId,
personUuid: personId,
}),
[diveSiteId, gearItemId, courseId, speciesId, personId],
);
// The preview asks for its few rows once and stops; a complete list pages
// through in tens. `hasMore` is forced false for the preview so the trigger
// below renders nothing - the header's "View all dives" button is where that
// card's "more" lives, and offering both would be two answers to one question.
const {
items: recentDives,
isLoading: isLoadingDives,
isLoadingMore,
totalCount,
itemsPerPage,
hasMore,
loadFailed,
loadMore,
revalidate,
} = useInfiniteResource<Dive>(fetchDives, {
keyOf: (dive) => dive.uuid,
enabled,
itemsPerPage: complete ? DIVES_PER_PAGE : RECENT_DIVES_COUNT,
errorMessage: "Failed to load dives. Please try again.",
});
// Against the value last refreshed for rather than on every run, so the first
// render and a hidden route shown again read nothing extra.
const refreshedFor = useRef(refreshOn);
useEffect(() => {
if (Object.is(refreshedFor.current, refreshOn)) return;
refreshedFor.current = refreshOn;
void revalidate();
}, [refreshOn, revalidate]);
const isEmpty = !isLoadingDives && recentDives.length === 0;
const isHeaderless = complete && isEmpty;
return (
<Card>
<CardHeader className={cn(isHeaderless && "p-0")}>
{isHeaderless ? (
<CardTitle as="h2" className="sr-only">
{title}
</CardTitle>
) : (
<>
{/* A detail page's card has no link, and sits as high as its neighbours. */}
<div className={cn(viewAllHref && CARD_TITLE_ROW)}>
<CardTitle as="h2" className="flex items-center gap-2">
<DiveIcon className="h-5 w-5" />
{title}
</CardTitle>
{viewAllHref && (
<ButtonLink
variant="outline"
size="sm"
className={CARD_TITLE_ACTION}
href={viewAllHref}
>
{viewAllLabel}
</ButtonLink>
)}
</div>
{description && <CardDescription>{description}</CardDescription>}
</>
)}
</CardHeader>
<CardContent>
{isLoadingDives ? (
// `RECENT_DIVES_COUNT` either way: on the Home page it is exactly
// the preview's size, and on a detail page the real count isn't
// knowable up front, where a few rows is a better guess than a
// screen of them.
<BackdropCardList placeholders={RECENT_DIVES_COUNT} />
) : isEmpty ? (
<EmptyState
icon={DiveIcon}
title={emptyTitle}
description={emptyDescription}
action={
<ButtonLink href={withReturnTo(newDiveHref)}>
<Plus className="h-4 w-4 mr-2" />
{newDiveLabel}
</ButtonLink>
}
/>
) : (
<BackdropCardList>
{recentDives.map((dive) => (
<DiveCard key={dive.uuid} dive={dive} />
))}
</BackdropCardList>
)}
<LoadMoreTrigger
hasMore={complete && hasMore}
isLoading={isLoadingMore}
hasFailed={loadFailed}
loadedCount={recentDives.length}
totalCount={totalCount}
itemsPerPage={itemsPerPage}
itemLabel="dives"
onLoadMore={loadMore}
/>
</CardContent>
</Card>
);
}