mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 15:48:23 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
190
apps/web-react/app/components/StreamListItems.tsx
Normal file
190
apps/web-react/app/components/StreamListItems.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { isToday, isTomorrow } from "date-fns";
|
||||
import { Bookmark, BookmarkCheck } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import type { SidebarStream } from "~/features/core/streams/streams.server";
|
||||
import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { navIconUrl, tournamentInfoPage } from "~/utils/urls";
|
||||
import { Image } from "./Image";
|
||||
import { ListLink } from "./SideNav";
|
||||
import styles from "./StreamListItems.module.css";
|
||||
import { TierPill } from "./TierPill";
|
||||
|
||||
export function StreamListItems({
|
||||
streams,
|
||||
onClick,
|
||||
isLoggedIn,
|
||||
savedTournamentIds,
|
||||
}: {
|
||||
streams: SidebarStream[];
|
||||
onClick?: () => void;
|
||||
isLoggedIn?: boolean;
|
||||
savedTournamentIds?: number[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["front"]);
|
||||
const formatDistanceToNow = useFormatDistanceToNow();
|
||||
const { formatter: timeFormatter } = useDateTimeFormat({
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
const { formatter: dateTimeFormatter } = useDateTimeFormat({
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
});
|
||||
const isHydrated = useHydrated();
|
||||
|
||||
const formatRelativeDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
const timeStr = timeFormatter.format(date);
|
||||
|
||||
if (isToday(date)) {
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
numeric: "auto",
|
||||
});
|
||||
const dayStr = rtf.format(0, "day");
|
||||
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.language, {
|
||||
numeric: "auto",
|
||||
});
|
||||
const dayStr = rtf.format(1, "day");
|
||||
return `${dayStr.charAt(0).toUpperCase() + dayStr.slice(1)}, ${timeStr}`;
|
||||
}
|
||||
|
||||
return dateTimeFormatter.format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{streams.map((stream, i) => {
|
||||
const startsAtDate = databaseTimestampToDate(stream.startsAt);
|
||||
const isUpcoming = startsAtDate.getTime() > Date.now();
|
||||
const prevStream = streams.at(i - 1);
|
||||
const prevIsLive =
|
||||
prevStream &&
|
||||
databaseTimestampToDate(prevStream.startsAt).getTime() <= Date.now();
|
||||
const showUpcomingDivider = isUpcoming && prevIsLive;
|
||||
const tournamentId = stream.id.startsWith("upcoming-")
|
||||
? Number(stream.id.replace("upcoming-", ""))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={stream.id}>
|
||||
{showUpcomingDivider ? (
|
||||
<div className={styles.upcomingDivider}>
|
||||
{t("front:sideNav.streams.upcoming")}
|
||||
</div>
|
||||
) : null}
|
||||
<ListLink
|
||||
to={stream.url}
|
||||
imageUrl={stream.imageUrl}
|
||||
overlayIconUrl={stream.overlayIconUrl}
|
||||
subtitle={
|
||||
stream.peakXp ? (
|
||||
<span className={styles.xpSubtitle}>
|
||||
<Image
|
||||
path={navIconUrl("xsearch")}
|
||||
alt=""
|
||||
className={styles.xpIcon}
|
||||
/>
|
||||
{stream.peakXp}
|
||||
</span>
|
||||
) : stream.subtitle ? (
|
||||
stream.subtitle
|
||||
) : !isHydrated ? (
|
||||
<span className="invisible">Placeholder</span>
|
||||
) : isUpcoming ? (
|
||||
formatRelativeDate(stream.startsAt)
|
||||
) : (
|
||||
formatDistanceToNow(startsAtDate, {
|
||||
addSuffix: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
badge={
|
||||
!isUpcoming ? (
|
||||
"LIVE"
|
||||
) : (
|
||||
<div className={styles.badgeRow}>
|
||||
{isLoggedIn && tournamentId !== null ? (
|
||||
<SaveTournamentStreamButton
|
||||
tournamentId={tournamentId}
|
||||
isSaved={
|
||||
savedTournamentIds?.includes(tournamentId) ?? false
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{streamTierBadge(stream)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
badgeVariant={!isUpcoming ? "warning" : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{stream.name}
|
||||
</ListLink>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveTournamentStreamButton({
|
||||
tournamentId,
|
||||
isSaved,
|
||||
}: {
|
||||
tournamentId: number;
|
||||
isSaved: boolean;
|
||||
}) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const optimisticSaved =
|
||||
fetcher.formData?.get("_action") === "SAVE_TOURNAMENT"
|
||||
? true
|
||||
: fetcher.formData?.get("_action") === "UNSAVE_TOURNAMENT"
|
||||
? false
|
||||
: isSaved;
|
||||
|
||||
const Icon = optimisticSaved ? BookmarkCheck : Bookmark;
|
||||
|
||||
return (
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={tournamentInfoPage(tournamentId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
// biome-ignore lint/plugin: bare icon button that also posts revalidateRoot for the root loader
|
||||
name="_action"
|
||||
value={optimisticSaved ? "UNSAVE_TOURNAMENT" : "SAVE_TOURNAMENT"}
|
||||
/>
|
||||
<input type="hidden" name="revalidateRoot" value="true" />
|
||||
<button type="submit" className={styles.saveIconButton} title="Save">
|
||||
<Icon size={14} />
|
||||
</button>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function streamTierBadge(stream: SidebarStream): React.ReactNode {
|
||||
const tier = stream.tier ?? stream.tentativeTier;
|
||||
if (!tier) return undefined;
|
||||
|
||||
return (
|
||||
<div className={styles.tierBadge}>
|
||||
<TierPill
|
||||
tier={tier}
|
||||
isTentative={!stream.tier && !!stream.tentativeTier}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user