diff options
Diffstat (limited to 'src/frontend/app/components/arrivals/ArrivalCard.tsx')
| -rw-r--r-- | src/frontend/app/components/arrivals/ArrivalCard.tsx | 231 |
1 files changed, 157 insertions, 74 deletions
diff --git a/src/frontend/app/components/arrivals/ArrivalCard.tsx b/src/frontend/app/components/arrivals/ArrivalCard.tsx index 5cfbaa3..6952f8f 100644 --- a/src/frontend/app/components/arrivals/ArrivalCard.tsx +++ b/src/frontend/app/components/arrivals/ArrivalCard.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, LocateIcon } from "lucide-react"; -import React, { useMemo } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import Marquee from "react-fast-marquee"; import { useTranslation } from "react-i18next"; import LineIcon from "~/components/LineIcon"; import { type Arrival } from "../../api/schema"; @@ -7,9 +8,56 @@ import "./ArrivalCard.css"; interface ArrivalCardProps { arrival: Arrival; + onClick?: () => void; } -export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => { +const AutoMarquee = ({ text }: { text: string }) => { + const containerRef = useRef<HTMLDivElement>(null); + const [shouldScroll, setShouldScroll] = useState(false); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const checkScroll = () => { + const charWidth = 8; + const availableWidth = el.offsetWidth; + const textWidth = text.length * charWidth; + setShouldScroll(textWidth > availableWidth); + }; + + checkScroll(); + const observer = new ResizeObserver(checkScroll); + observer.observe(el); + return () => observer.disconnect(); + }, [text]); + + if (shouldScroll) { + return ( + <div ref={containerRef} className="w-full overflow-hidden"> + <Marquee speed={40} gradient={false}> + <div className="mr-32 text-xs font-mono text-slate-500 dark:text-slate-400"> + {text} + </div> + </Marquee> + </div> + ); + } + + return ( + <div + ref={containerRef} + className="w-full overflow-hidden text-xs font-mono text-slate-500 dark:text-slate-400 truncate" + > + {text} + </div> + ); +}; + +export const ArrivalCard: React.FC<ArrivalCardProps> = ({ + arrival, + onClick, +}) => { const { t } = useTranslation(); const { route, headsign, estimate, delay, shift } = arrival; @@ -36,6 +84,14 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => { kind?: "regular" | "gps" | "delay" | "warning"; }> = []; + // Badge/Shift info as a chip + if (headsign.badge) { + chips.push({ + label: headsign.badge, + kind: "regular", + }); + } + // Delay chip if (delay) { const delta = Math.round(delay.minutes); @@ -43,7 +99,7 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => { if (delta === 0) { chips.push({ - label: "OK", + label: t("estimates.delay_on_time"), tone: "delay-ok", kind: "delay", }); @@ -55,14 +111,14 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => { ? "delay-warn" : "delay-critical"; chips.push({ - label: `R${delta}`, + label: t("estimates.delay_positive", { minutes: delta }), tone, kind: "delay", }); } else { const tone = absDelta <= 2 ? "delay-ok" : "delay-early"; chips.push({ - label: `A${absDelta}`, + label: t("estimates.delay_negative", { minutes: absDelta }), tone, kind: "delay", }); @@ -80,23 +136,42 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => { // Precision chips if (estimate.precision === "unsure") { chips.push({ - label: "!", + label: t("estimates.low_accuracy"), tone: "warning", kind: "warning", }); } else if (estimate.precision === "confident") { chips.push({ - label: "", // Just the icon for reduced + label: t("estimates.bus_gps_position"), kind: "gps", }); } + if (estimate.precision === "scheduled") { + chips.push({ + label: t("estimates.no_realtime"), + tone: "warning", + kind: "warning", + }); + } + return chips; - }, [delay, shift, estimate.precision]); + }, [delay, shift, estimate.precision, t, headsign.badge]); + + const isClickable = !!onClick && estimate.precision !== "past"; + const Tag = isClickable ? "button" : "div"; return ( - <div className="flex-none flex items-center gap-2.5 min-h-12 rounded px-3 py-2.5 transition-all bg-slate-50 dark:bg-slate-800 shadow-sm"> - <div className="shrink-0 min-w-[7ch]"> + <Tag + type={isClickable ? "button" : undefined} + onClick={isClickable ? onClick : undefined} + className={`w-full text-left flex items-start gap-3 rounded-xl px-3 py-3 transition-all bg-slate-50 dark:bg-slate-800 border border-gray-200 dark:border-gray-700 shadow-sm ${ + isClickable + ? "hover:border-blue-400 dark:hover:border-blue-500 active:scale-[0.98] cursor-pointer" + : "" + }`} + > + <div className="shrink-0 min-w-[7ch] mt-0.5"> <LineIcon line={route.shortName} colour={route.colour} @@ -105,72 +180,80 @@ export const ReducedArrivalCard: React.FC<ArrivalCardProps> = ({ arrival }) => { /> </div> <div className="flex-1 min-w-0 flex flex-col gap-1"> - <span - className={`text-base font-medium overflow-hidden text-ellipsis line-clamp-2 leading-tight ${estimate.precision == "past" ? "line-through" : ""}`} - > - {headsign.destination} - </span> - {metaChips.length > 0 && ( - <div className="flex items-center gap-1 flex-wrap"> - {metaChips.map((chip, idx) => { - let chipColourClasses = ""; - switch (chip.tone) { - case "delay-ok": - chipColourClasses = - "bg-green-600/20 dark:bg-green-600/30 text-green-700 dark:text-green-300"; - break; - case "delay-warn": - chipColourClasses = - "bg-amber-600/20 dark:bg-yellow-600/30 text-amber-700 dark:text-yellow-300"; - break; - case "delay-critical": - chipColourClasses = - "bg-red-400/20 dark:bg-red-600/30 text-red-600 dark:text-red-300"; - break; - case "delay-early": - chipColourClasses = - "bg-blue-400/20 dark:bg-blue-600/30 text-blue-700 dark:text-blue-300"; - break; - case "warning": - chipColourClasses = - "bg-orange-400/20 dark:bg-orange-600/30 text-orange-700 dark:text-orange-300"; - break; - default: - chipColourClasses = - "bg-black/[0.06] dark:bg-white/[0.12] text-slate-600 dark:text-slate-400"; - } - - return ( - <span - key={`${chip.label}-${idx}`} - className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`} - > - {chip.kind === "gps" && ( - <LocateIcon className="w-3 h-3 my-0.5 inline-block" /> - )} - {chip.kind === "warning" && ( - <AlertTriangle className="w-3 h-3 my-0.5 inline-block" /> - )} - {chip.label} - </span> - ); - })} + <div className="flex justify-between items-start gap-2"> + <div className="flex-1 min-w-0"> + <span + className={`text-base font-bold overflow-hidden text-ellipsis line-clamp-2 leading-tight text-slate-900 dark:text-slate-100 ${estimate.precision == "past" ? "line-through opacity-60" : ""}`} + > + {headsign.destination} + </span> + {headsign.marquee && ( + <div className="mt-0.5"> + <AutoMarquee text={headsign.marquee} /> + </div> + )} + </div> + <div + className={` + inline-flex items-center justify-center px-2 py-1 rounded-lg shrink-0 min-w-12 + ${timeClass} + `.trim()} + > + <div className="flex flex-col items-center leading-none"> + <span className="text-lg font-bold">{etaValue}</span> + <span className="text-[0.55rem] font-bold uppercase tracking-tighter opacity-80"> + {etaUnit} + </span> + </div> </div> - )} - </div> - <div - className={` - inline-flex items-center justify-center px-2 py-1.5 rounded-xl shrink-0 - ${timeClass} - `.trim()} - > - <div className="flex flex-col items-center leading-none"> - <span className="text-lg font-bold leading-none">{etaValue}</span> - <span className="text-[0.65rem] uppercase tracking-wider mt-0.5 opacity-90"> - {etaUnit} - </span> + </div> + + <div className="flex items-center gap-0.5 flex-wrap"> + {metaChips.map((chip, idx) => { + let chipColourClasses = ""; + switch (chip.tone) { + case "delay-ok": + chipColourClasses = + "bg-green-600/10 dark:bg-green-600/20 text-green-700 dark:text-green-300"; + break; + case "delay-warn": + chipColourClasses = + "bg-amber-600/10 dark:bg-yellow-600/20 text-amber-700 dark:text-yellow-300"; + break; + case "delay-critical": + chipColourClasses = + "bg-red-400/10 dark:bg-red-600/20 text-red-600 dark:text-red-300"; + break; + case "delay-early": + chipColourClasses = + "bg-blue-400/10 dark:bg-blue-600/20 text-blue-700 dark:text-blue-300"; + break; + case "warning": + chipColourClasses = + "bg-orange-400/10 dark:bg-orange-600/20 text-orange-700 dark:text-orange-300"; + break; + default: + chipColourClasses = + "bg-black/[0.04] dark:bg-white/[0.08] text-slate-500 dark:text-slate-400"; + } + + return ( + <span + key={`${chip.label}-${idx}`} + className={`text-xs px-2.5 py-0.5 rounded-full flex items-center justify-center gap-1 shrink-0 font-medium tracking-wide ${chipColourClasses}`} + > + {chip.kind === "gps" && ( + <LocateIcon className="w-2.5 h-2.5 inline-block" /> + )} + {chip.kind === "warning" && ( + <AlertTriangle className="w-2.5 h-2.5 inline-block" /> + )} + {chip.label} + </span> + ); + })} </div> </div> - </div> + </Tag> ); }; |
