src/components/dives/dive-card.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { Dive } from "@/lib/api/dives";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DeleteMenuItem } from "@/components/ui/item-actions-menu";
import {
BACKDROP_CARD_LINK,
BackdropCard,
BackdropCardFigures,
UnplacedBackdrop,
type BackdropCardFigure,
} from "@/components/ui/backdrop-card";
import { MapBackdrop } from "@/components/map/map-backdrop";
import { DiveTitle } from "@/components/dives/dive-title";
import { diveFacts } from "@/components/dives/dive-facts";
import { diveChips } from "@/components/dives/dive-chips";
import { FactsLine } from "@/components/ui/icon-fact";
import { diveMapLocations } from "@/components/dives/dive-map-locations";
import { DiveIcon } from "@/components/logo";
import { DiveProfileSilhouette } from "@/components/dives/dive-profile-silhouette";
import { formatDurationHoursMinutes } from "@/lib/date-time";
import { useUnits } from "@/hooks/useUnits";
import { useWithReturnTo } from "@/hooks/useReturnTo";
import { formatDepth, formatTemperature } from "@/lib/units";
import { cn } from "@/lib/utils";
import { Edit } from "lucide-react";
// The profile's band at the foot of the backdrop, just clear of the name: the
// `h-14` below.
const SILHOUETTE_HEIGHT = 56;
const SILHOUETTE_GAP = 4;
// The chips' row, over the band's foot: a chip at its widest breakpoint and the
// `bottom-1` it stands on. Lower than the profile's band, which covers it where
// a dive has both.
const CHIPS_HEIGHT = 26;
interface DiveCardProps {
dive: Dive;
// Offers Delete beside Edit. Left out where the list has no delete of its own
// to run.
onDelete?: () => void;
isDeleting?: boolean;
// The control that adds a dive the trip page shows but the trip does not hold
// yet. A card handed one is drawn muted, with it in place of the menu.
addToTrip?: ReactNode;
// A dive of an import rather than of the log, which has no menu: it may not
// exist yet.
importRow?: DiveCardImportRow;
}
export interface DiveCardImportRow {
// Stands in the card's corner, where the menu would.
chip: ReactNode;
// The dive the card links to, which an import's row names apart from the dive
// it shows; left out, the card links to nothing.
linkTo?: string;
// Makes the card a button in place of its link.
open?: { onOpen: () => void; label: string };
}
// One dive as a card, in every list of dives: a map of its sites and fixes as
// the backdrop - the map's water where it has none - with
// its depth curve across the foot of it, and its dive time, deepest point and
// water temperature - or, where it has no temperature, the species it saw.
export function DiveCard({
dive,
onDelete,
isDeleting,
addToTrip,
importRow,
}: DiveCardProps) {
const units = useUnits();
// The dive's page and its edit form both return to the page the card is on.
const withReturnTo = useWithReturnTo();
const outline = dive.depth_outline;
// Whole units, as the dive page's hero rounds them: the second decimal is not
// what anyone reads a dive's depth for. `!= null`, since 0 m is a reading.
const depth = (meters?: number) =>
meters != null ? formatDepth(meters, units, { decimals: 0 }) : "-";
const figures: BackdropCardFigure[] = [
{
label: "Dive time",
value: formatDurationHoursMinutes(dive.submerged_time),
},
{ label: "Max depth", value: depth(dive.max_depth) },
];
// `!= null`, since 0 °C is a reading.
if (dive.bottom_temperature != null) {
figures.push({
label: "Water temp",
value: formatTemperature(dive.bottom_temperature, units, {
decimals: 0,
}),
});
}
// Only into a spare slot: the card's row holds three figures. An import's row
// carries the dive's detail shape, which has the sightings and not their count.
const speciesCount = dive.species_count ?? dive.sightings?.length;
if (figures.length < 3 && speciesCount) {
figures.push({ label: "Species seen", value: speciesCount });
}
const chips = diveChips(dive);
const title = (
<DiveTitle
diveNumber={dive.dive_number}
sites={dive.dive_sites}
course={dive.course_uuid != null}
rating={dive.rating}
/>
);
const linkTo = importRow ? importRow.linkTo : dive.uuid;
return (
<BackdropCard
{...(importRow
? { corner: importRow.chip }
: {
actionsLabel: `Actions for dive #${dive.dive_number}`,
corner: addToTrip,
actions: (
<>
<DropdownMenuItem
render={
<Link href={withReturnTo(`/dives/${dive.uuid}/edit`)} />
}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
{onDelete && (
<DeleteMenuItem onSelect={onDelete} disabled={isDeleting} />
)}
</>
),
})}
muted={!!addToTrip}
backdrop={(coveredBottom) => {
// The map's places and the water's bubbles centre above the profile
// and the chips rather than behind them.
const aboveProfile = outline
? coveredBottom + SILHOUETTE_GAP + SILHOUETTE_HEIGHT
: chips
? coveredBottom + CHIPS_HEIGHT
: coveredBottom;
return (
<>
<MapBackdrop
locations={diveMapLocations(dive)}
subject={`the location of dive #${dive.dive_number}`}
coveredBottom={aboveProfile}
water={
<UnplacedBackdrop
coveredBottom={aboveProfile}
icon={DiveIcon}
/>
}
/>
{/* Without the glow the details' text has: a `drop-shadow` over
the fill's gradient draws it in bands. */}
{outline && (
<div
className="absolute inset-x-3 h-14"
style={{ bottom: coveredBottom + SILHOUETTE_GAP }}
>
<DiveProfileSilhouette
depths={outline.values}
className="size-full"
/>
</div>
)}
</>
);
}}
>
{chips && (
// Over the foot of the backdrop's band rather than above the name in
// the flow, so a dive with chips is as tall as one without. Lifted over
// the card's link, so their hints are reachable, and only as wide as
// they are - up to the card's width - so the link still takes a click
// beside them. Not for a finger, whose tap on a card means the dive.
<div className="relative z-10 h-0 touch:pointer-events-none">
<div className="absolute bottom-1 left-0 max-w-full">{chips}</div>
</div>
)}
{importRow?.open ? (
<button
type="button"
onClick={importRow.open.onOpen}
aria-label={importRow.open.label}
className={cn(BACKDROP_CARD_LINK, "w-full text-left")}
>
{title}
</button>
) : linkTo ? (
<Link
href={withReturnTo(`/dives/${linkTo}`)}
className={BACKDROP_CARD_LINK}
>
{title}
{addToTrip && (
<span className="sr-only">{" (Not in this trip)"}</span>
)}
</Link>
) : (
<div className="font-medium text-foreground">{title}</div>
)}
<div className="text-xs">
<FactsLine facts={diveFacts(dive)} />
</div>
<BackdropCardFigures figures={figures} />
</BackdropCard>
);
}