mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
24h clock user preference (#2618)
This commit is contained in:
parent
48d98b2a27
commit
24008775aa
|
|
@ -5,6 +5,7 @@ import type { GearType, Tables, UserWithPlusTier } from "~/db/tables";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { BuildWeaponWithTop500Info } from "~/features/builds/builds-types";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import type {
|
||||
Ability as AbilityType,
|
||||
BuildAbilitiesTuple,
|
||||
|
|
@ -57,7 +58,7 @@ interface BuildProps {
|
|||
export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
||||
const user = useUser();
|
||||
const { t } = useTranslation(["weapons", "builds", "common", "game-misc"]);
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDate } = useTimeFormat();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const {
|
||||
|
|
@ -129,14 +130,11 @@ export function BuildCard({ build, owner, canEdit = false }: BuildProps) {
|
|||
className={clsx("whitespace-nowrap", { invisible: !isMounted })}
|
||||
>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(updatedAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)
|
||||
? formatDate(databaseTimestampToDate(updatedAt), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
: "t"}
|
||||
</time>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { TooltipRendererProps } from "react-charts/types/components/Tooltip
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { Theme, useTheme } from "~/features/theme/core/provider";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
|
||||
export default function Chart({
|
||||
options,
|
||||
|
|
@ -104,7 +105,7 @@ function ChartTooltip({
|
|||
headerSuffix = "",
|
||||
valueSuffix = "",
|
||||
}: ChartTooltipProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDate } = useTimeFormat();
|
||||
const dataPoints = focusedDatum?.interactiveGroup ?? [];
|
||||
|
||||
const header = () => {
|
||||
|
|
@ -112,7 +113,7 @@ function ChartTooltip({
|
|||
if (!primaryValue) return null;
|
||||
|
||||
if (primaryValue instanceof Date) {
|
||||
return primaryValue.toLocaleDateString(i18n.language, {
|
||||
return formatDate(primaryValue, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type * as React from "react";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
|
||||
export function RelativeTime({
|
||||
children,
|
||||
|
|
@ -9,12 +10,13 @@ export function RelativeTime({
|
|||
timestamp: number;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<abbr
|
||||
title={
|
||||
isMounted
|
||||
? new Date(timestamp).toLocaleString("en-US", {
|
||||
? formatDateTime(new Date(timestamp), {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useRef, useState } from "react";
|
|||
import { Dialog, Popover } from "react-aria-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { SendouButton } from "./elements/Button";
|
||||
import { CheckmarkIcon } from "./icons/Checkmark";
|
||||
import { ClipboardIcon } from "./icons/Clipboard";
|
||||
|
|
@ -26,7 +27,7 @@ export default function TimePopover({
|
|||
className?: string;
|
||||
footerText?: string;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDateTime, formatTime } = useTimeFormat();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
|
|
@ -60,7 +61,7 @@ export default function TimePopover({
|
|||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{time.toLocaleString(i18n.language, options)}
|
||||
{formatDateTime(time, options)}
|
||||
</button>
|
||||
<Popover
|
||||
isOpen={open}
|
||||
|
|
@ -71,7 +72,7 @@ export default function TimePopover({
|
|||
<Dialog>
|
||||
<div className="stack sm">
|
||||
<div className="text-center" suppressHydrationWarning>
|
||||
{time.toLocaleTimeString(i18n.language, {
|
||||
{formatTime(time, {
|
||||
timeZoneName: "long",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
gap: 8px;
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
top: 55px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -832,6 +832,14 @@ export interface UserPreferences {
|
|||
disallowScrimPickupsFromUntrusted?: boolean;
|
||||
defaultCalendarFilters?: CalendarFilters;
|
||||
defaultScrimsFilters?: ScrimFilters;
|
||||
/**
|
||||
* What time format the user prefers?
|
||||
*
|
||||
* "auto" = use browser default (default value)
|
||||
* "24h" = 24 hour format (e.g. 14:00)
|
||||
* "12h" = 12 hour format (e.g. 2:00 PM)
|
||||
* */
|
||||
clockFormat?: "24h" | "12h" | "auto";
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { Pagination } from "~/components/Pagination";
|
|||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { artPage, newArtPage, userArtPage, userPage } from "~/utils/urls";
|
||||
import { ResponsiveMasonry } from "../../../modules/responsive-masonry/components/ResponsiveMasonry";
|
||||
|
|
@ -90,19 +91,16 @@ export function ArtGrid({
|
|||
}
|
||||
|
||||
function BigImageDialog({ close, art }: { close: () => void; art: ListedArt }) {
|
||||
const { i18n } = useTranslation();
|
||||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
heading={databaseTimestampToDate(art.createdAt).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
heading={formatDate(databaseTimestampToDate(art.createdAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
onClose={close}
|
||||
isFullScreen
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
|
||||
import { loader } from "../loaders/suspended.server";
|
||||
|
|
@ -7,6 +8,7 @@ export { loader };
|
|||
|
||||
export default function SuspendedPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const ends = (() => {
|
||||
if (!data.banned || data.banned === 1) return null;
|
||||
|
|
@ -21,7 +23,7 @@ export default function SuspendedPage() {
|
|||
{ends ? (
|
||||
<div suppressHydrationWarning>
|
||||
Ends:{" "}
|
||||
{ends.toLocaleString("en-US", {
|
||||
{formatDateTime(ends, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { SendouButton } from "~/components/elements/Button";
|
|||
import { ModeImage } from "~/components/Image";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { possibleApValues } from "~/features/build-analyzer";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { abilities } from "~/modules/in-game-lists/abilities";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type {
|
||||
|
|
@ -194,7 +195,8 @@ function DateFilter({
|
|||
filter: DateBuildFilter;
|
||||
onChange: (filter: Partial<DateBuildFilter>) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["builds"]);
|
||||
const { t } = useTranslation(["builds"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const selectValue = () => {
|
||||
const dateString = dateToYYYYMMDD(new Date(filter.date));
|
||||
|
|
@ -239,7 +241,7 @@ function DateFilter({
|
|||
return (
|
||||
<option key={patch} value={dateString}>
|
||||
{patch} (
|
||||
{date.toLocaleDateString(i18n.language, {
|
||||
{formatDate(date, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { TrophyIcon } from "~/components/icons/Trophy";
|
|||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { navIconUrl } from "~/utils/urls";
|
||||
import type { CalendarEvent, ShowcaseCalendarEvent } from "../calendar-types";
|
||||
|
|
@ -23,7 +24,7 @@ export function TournamentCard({
|
|||
className?: string;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation(["front", "common"]);
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
const isShowcase = tournament.type === "showcase";
|
||||
const isCalendar = tournament.type === "calendar";
|
||||
|
|
@ -34,7 +35,7 @@ export function TournamentCard({
|
|||
if (!isMounted) return "Placeholder";
|
||||
|
||||
const date = databaseTimestampToDate(tournament.startTime);
|
||||
return date.toLocaleString(i18n.language, {
|
||||
return formatDateTime(date, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { Table } from "~/components/Table";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -80,8 +81,9 @@ export const handle: SendouRouteHandle = {
|
|||
export default function CalendarEventPage() {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { i18n, t } = useTranslation(["common", "calendar"]);
|
||||
const { t } = useTranslation(["common", "calendar"]);
|
||||
const isMounted = useIsMounted();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
|
|
@ -100,17 +102,14 @@ export default function CalendarEventPage() {
|
|||
</span>
|
||||
<time dateTime={databaseTimestampToDate(startTime).toISOString()}>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(startTime).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)
|
||||
? formatDateTime(databaseTimestampToDate(startTime), {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
: null}
|
||||
</time>
|
||||
</React.Fragment>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type { CalendarEventTag, Tables } from "~/db/tables";
|
|||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import type { RankedModeShort } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
|
|
@ -136,6 +137,7 @@ export default function CalendarNewEventPage() {
|
|||
function TemplateTournamentForm() {
|
||||
const { recentTournaments } = useLoaderData<typeof loader>();
|
||||
const [eventId, setEventId] = React.useState("");
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
if (!recentTournaments) return null;
|
||||
|
||||
|
|
@ -154,10 +156,10 @@ function TemplateTournamentForm() {
|
|||
{recentTournaments.map((event) => (
|
||||
<option key={event.id} value={event.id} suppressHydrationWarning>
|
||||
{event.name} (
|
||||
{databaseTimestampToDate(event.startTime).toLocaleDateString(
|
||||
"en-US",
|
||||
{ month: "numeric", day: "numeric" },
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(event.startTime), {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
})}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { LinkIcon } from "~/components/icons/Link";
|
|||
import { Main } from "~/components/Main";
|
||||
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
|
||||
import { useCollapsableEvents } from "~/features/calendar/calendar-hooks";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { dayMonthYearToDateValue } from "~/utils/dates";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
|
@ -144,20 +145,17 @@ function NavigateButton({
|
|||
daysInterval: ReturnType<typeof daysForCalendar>["shown"];
|
||||
filters?: CalendarLoaderData["filters"];
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDate } = useTimeFormat();
|
||||
const lowestDate = daysInterval[0];
|
||||
const highestDate = daysInterval[daysInterval.length - 1];
|
||||
|
||||
const dateToString = (
|
||||
day: ReturnType<typeof daysForCalendar>["shown"][number],
|
||||
) =>
|
||||
new Date(new Date().getFullYear(), day.month, day.day).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
},
|
||||
);
|
||||
formatDate(new Date(new Date().getFullYear(), day.month, day.day), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -254,7 +252,7 @@ function DayEventsColumn({
|
|||
}
|
||||
|
||||
function DayHeader(props: { date: number; month: number }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const date = new Date(new Date().getFullYear(), props.month, props.date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
|
@ -266,12 +264,12 @@ function DayHeader(props: { date: number; month: number }) {
|
|||
})}
|
||||
data-testid={isToday ? "today-header" : undefined}
|
||||
>
|
||||
{date.toLocaleDateString(i18n.language, {
|
||||
{formatDate(date, {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
<div className={styles.dayHeaderWeekday}>
|
||||
{date.toLocaleDateString(i18n.language, {
|
||||
{formatDate(date, {
|
||||
weekday: "long",
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -294,7 +292,7 @@ function ClockHeader({
|
|||
hiddenShown: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
const isInThePast = (toDate ?? date).getTime() < Date.now();
|
||||
|
||||
|
|
@ -306,12 +304,12 @@ function ClockHeader({
|
|||
"text-lighter italic": isInThePast,
|
||||
})}
|
||||
>
|
||||
{date.toLocaleTimeString(i18n.language, {
|
||||
{formatTime(date, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
{toDate
|
||||
? ` - ${toDate.toLocaleTimeString(i18n.language, {
|
||||
? ` - ${formatTime(toDate, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})}`
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
|||
import { Avatar } from "../../../components/Avatar";
|
||||
import { SendouButton } from "../../../components/elements/Button";
|
||||
import { SubmitButton } from "../../../components/SubmitButton";
|
||||
import { useTimeFormat } from "../../../hooks/useTimeFormat";
|
||||
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { useChat, useChatAutoScroll } from "../chat-hooks";
|
||||
import type { ChatMessage, ChatProps, ChatUser } from "../chat-types";
|
||||
|
|
@ -269,19 +270,19 @@ function SystemMessage({
|
|||
}
|
||||
|
||||
function MessageTimestamp({ timestamp }: { timestamp: number }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDateTime, formatTime } = useTimeFormat();
|
||||
const moreThanDayAgo = sub(new Date(), { days: 1 }) > new Date(timestamp);
|
||||
|
||||
return (
|
||||
<time className="chat__message__time">
|
||||
{moreThanDayAgo
|
||||
? new Date(timestamp).toLocaleString(i18n.language, {
|
||||
? formatDateTime(new Date(timestamp), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: new Date(timestamp).toLocaleTimeString(i18n.language)}
|
||||
: formatTime(new Date(timestamp))}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { TournamentCard } from "~/features/calendar/components/TournamentCard";
|
|||
import type * as Changelog from "~/features/front-page/core/Changelog.server";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import styles from "~/styles/front.module.css";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
|
|
@ -104,10 +105,11 @@ function DesktopSideNav() {
|
|||
}
|
||||
|
||||
function SeasonBanner() {
|
||||
const { t, i18n } = useTranslation(["front"]);
|
||||
const { t } = useTranslation(["front"]);
|
||||
const season = Seasons.next(new Date()) ?? Seasons.currentOrPrevious()!;
|
||||
const _previousSeason = Seasons.previous();
|
||||
const isMounted = useIsMounted();
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const isInFuture = new Date() < season.starts;
|
||||
const isShowingPreviousSeason = _previousSeason?.nth === season.nth;
|
||||
|
|
@ -122,12 +124,12 @@ function SeasonBanner() {
|
|||
</div>
|
||||
{isMounted ? (
|
||||
<div className={styles.seasonBannerDates}>
|
||||
{season.starts.toLocaleDateString(i18n.language, {
|
||||
{formatDate(season.starts, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{season.ends.toLocaleDateString(i18n.language, {
|
||||
{formatDate(season.ends, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { lfgNewPostPage, navIconUrl, userPage } from "~/utils/urls";
|
||||
|
|
@ -258,7 +259,8 @@ function PostTime({
|
|||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["lfg"]);
|
||||
const { t } = useTranslation(["lfg"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const createdAtDate = databaseTimestampToDate(createdAt);
|
||||
const updatedAtDate = databaseTimestampToDate(updatedAt);
|
||||
|
|
@ -267,7 +269,7 @@ function PostTime({
|
|||
|
||||
return (
|
||||
<div className="text-lighter text-xs font-bold">
|
||||
{createdAtDate.toLocaleString(i18n.language, {
|
||||
{formatDate(createdAtDate, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { TrashIcon } from "~/components/icons/Trash";
|
|||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import TimePopover from "~/components/TimePopover";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { scrimPage, tournamentRegisterPage, userPage } from "~/utils/urls";
|
||||
|
|
@ -333,7 +334,8 @@ function ScrimActionButtons({
|
|||
action: ScrimPostCardProps["action"];
|
||||
post: ScrimPost;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["scrims", "common"]);
|
||||
const { t } = useTranslation(["scrims", "common"]);
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const user = useUser();
|
||||
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
|
||||
const [isViewRequestModalOpen, setIsViewRequestModalOpen] = useState(false);
|
||||
|
|
@ -399,15 +401,12 @@ function ScrimActionButtons({
|
|||
{t("scrims:requestModal.at.label")}
|
||||
</div>
|
||||
<div className="text-lighter">
|
||||
{databaseTimestampToDate(userRequest.at).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
},
|
||||
)}
|
||||
{formatDateTime(databaseTimestampToDate(userRequest.at), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
@ -474,7 +473,8 @@ export function ScrimRequestCard({
|
|||
canAccept,
|
||||
showFooter = true,
|
||||
}: ScrimRequestCardProps) {
|
||||
const { t, i18n } = useTranslation(["scrims", "common"]);
|
||||
const { t } = useTranslation(["scrims", "common"]);
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
const owner = request.users.find((user) => user.isOwner) ?? request.users[0];
|
||||
const isPickup = !request.team?.name;
|
||||
|
|
@ -533,7 +533,7 @@ export function ScrimRequestCard({
|
|||
data-testid="confirm-modal-trigger-button"
|
||||
>
|
||||
{t("scrims:acceptModal.confirmFor", {
|
||||
time: confirmedTime.toLocaleTimeString(i18n.language, {
|
||||
time: formatTime(confirmedTime, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
|
|
@ -545,7 +545,7 @@ export function ScrimRequestCard({
|
|||
trigger={
|
||||
<SendouButton size="small">
|
||||
{t("scrims:acceptModal.confirmFor", {
|
||||
time: confirmedTime.toLocaleTimeString(i18n.language, {
|
||||
time: formatTime(confirmedTime, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { SendouDialog } from "~/components/elements/Dialog";
|
|||
import { SelectFormField } from "~/components/form/SelectFormField";
|
||||
import { SendouForm } from "~/components/form/SendouForm";
|
||||
import { TextAreaFormField } from "~/components/form/TextAreaFormField";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { joinListToNaturalString, nullFilledArray } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import type { loader as scrimsLoader } from "../loaders/scrims.server";
|
||||
|
|
@ -22,8 +23,9 @@ export function ScrimRequestModal({
|
|||
post: ScrimPost;
|
||||
close: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["scrims"]);
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const data = useLoaderData<typeof scrimsLoader>();
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
const timeOptions = post.rangeEnd
|
||||
? generateTimeOptions(
|
||||
|
|
@ -31,7 +33,7 @@ export function ScrimRequestModal({
|
|||
databaseTimestampToDate(post.rangeEnd),
|
||||
).map((timestamp) => ({
|
||||
value: timestamp,
|
||||
label: new Date(timestamp).toLocaleTimeString(i18n.language, {
|
||||
label: formatTime(new Date(timestamp), {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { AddNewButton } from "~/components/AddNewButton";
|
|||
import { LinkButton, SendouButton } from "~/components/elements/Button";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
|
@ -201,10 +202,10 @@ function ScrimsDaySection({
|
|||
posts: ScrimPost[];
|
||||
filters: ScrimFilters;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const user = useUser();
|
||||
const [showFiltered, setShowFiltered] = React.useState(false);
|
||||
const [showRequestPending, setShowRequestPending] = React.useState(false);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const filteredPosts = posts.filter((post) =>
|
||||
Scrim.applyFilters(post, filters),
|
||||
|
|
@ -220,14 +221,11 @@ function ScrimsDaySection({
|
|||
<div className="stack md">
|
||||
<div className="stack xxs">
|
||||
<h2 className="text-sm">
|
||||
{databaseTimestampToDate(posts[0].at).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
},
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(posts[0].at), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
})}
|
||||
</h2>
|
||||
{user ? (
|
||||
<AvailableScrimsFilterButtons
|
||||
|
|
@ -333,8 +331,9 @@ function AvailableScrimsFilterButtons({
|
|||
}
|
||||
|
||||
function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
|
||||
const { i18n, t } = useTranslation(["scrims"]);
|
||||
const { t } = useTranslation(["scrims"]);
|
||||
const user = useUser();
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const postsByDay = R.groupBy(posts, (post) =>
|
||||
databaseTimestampToDate(post.at).getDate(),
|
||||
|
|
@ -348,14 +347,11 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
|
|||
return (
|
||||
<div key={day} className="stack md">
|
||||
<h2 className="text-sm">
|
||||
{databaseTimestampToDate(posts![0].at).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
},
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(posts![0].at), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
})}
|
||||
</h2>
|
||||
<div className="stack lg">
|
||||
{posts!.map((post) => {
|
||||
|
|
@ -406,7 +402,7 @@ function ScrimsDaySeparatedOwnedCards({ posts }: { posts: ScrimPost[] }) {
|
|||
}
|
||||
|
||||
function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const postsByDay = R.groupBy(posts, (post) =>
|
||||
databaseTimestampToDate(post.at).getDate(),
|
||||
|
|
@ -420,14 +416,11 @@ function ScrimsDaySeparatedBookedCards({ posts }: { posts: ScrimPost[] }) {
|
|||
return (
|
||||
<div key={day} className="stack md">
|
||||
<h2 className="text-sm">
|
||||
{databaseTimestampToDate(posts![0].at).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
},
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(posts![0].at), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
})}
|
||||
</h2>
|
||||
<div className="stack lg">
|
||||
{posts!.map((post) => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPri
|
|||
import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server";
|
||||
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
|
|
@ -105,7 +106,8 @@ export default function QMatchPage() {
|
|||
const user = useUser();
|
||||
const isStaff = useHasRole("STAFF");
|
||||
const isMounted = useIsMounted();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const { t } = useTranslation(["q"]);
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [showWeaponsForm, setShowWeaponsForm] = React.useState(false);
|
||||
const [searchParams] = useSearchParams();
|
||||
|
|
@ -154,16 +156,13 @@ export default function QMatchPage() {
|
|||
})}
|
||||
>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(data.match.createdAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
},
|
||||
)
|
||||
? formatDateTime(databaseTimestampToDate(data.match.createdAt), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: // reserve place
|
||||
"0/0/0 0:00"}
|
||||
</div>
|
||||
|
|
@ -243,7 +242,8 @@ function Score({
|
|||
ownTeamReported: boolean;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const { t } = useTranslation(["q"]);
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const reporter =
|
||||
data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ??
|
||||
|
|
@ -292,16 +292,13 @@ function Score({
|
|||
>
|
||||
{t("q:match.reportedBy", { name: reporter?.username ?? "admin" })}{" "}
|
||||
{isMounted
|
||||
? databaseTimestampToDate(reportedAt).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
},
|
||||
)
|
||||
? formatDateTime(databaseTimestampToDate(reportedAt), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
: ""}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useUser } from "~/features/auth/core/user";
|
|||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
|
||||
|
|
@ -253,8 +254,9 @@ function GroupMember({
|
|||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["q", "user"]);
|
||||
const { t } = useTranslation(["q", "user"]);
|
||||
const user = useUser();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
|
|
@ -283,15 +285,16 @@ function GroupMember({
|
|||
)}
|
||||
>
|
||||
<div className="text-xxs text-lighter">
|
||||
{databaseTimestampToDate(
|
||||
member.privateNote.updatedAt,
|
||||
).toLocaleString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
{formatDateTime(
|
||||
databaseTimestampToDate(member.privateNote.updatedAt),
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<DeletePrivateNoteForm
|
||||
name={member.username}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useChat } from "~/features/chat/chat-hooks";
|
|||
import { Chat } from "~/features/chat/components/Chat";
|
||||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
|
@ -97,10 +98,11 @@ export default function QLookingPage() {
|
|||
}
|
||||
|
||||
function InfoText() {
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const { t } = useTranslation(["q"]);
|
||||
const isMounted = useIsMounted();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
if (data.expiryStatus === "EXPIRED") {
|
||||
return (
|
||||
|
|
@ -161,13 +163,10 @@ function InfoText() {
|
|||
<span className="text-xxs">
|
||||
{isMounted
|
||||
? t("q:looking.lastUpdatedAt", {
|
||||
time: new Date(data.lastUpdated).toLocaleTimeString(
|
||||
i18n.language,
|
||||
{
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
),
|
||||
time: formatTime(new Date(data.lastUpdated), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
})
|
||||
: "Placeholder"}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node";
|
|||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseRequestPayload } from "~/utils/remix.server";
|
||||
import { parseRequestPayload, successToast } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { settingsEditSchema } from "../settings-schemas";
|
||||
|
||||
|
|
@ -33,7 +33,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
});
|
||||
break;
|
||||
}
|
||||
case "PLACEHOLDER": {
|
||||
case "UPDATE_CLOCK_FORMAT": {
|
||||
await UserRepository.updatePreferences(user.id, {
|
||||
clockFormat: data.newValue,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
|
@ -41,5 +44,5 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
|||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return successToast("Settings updated");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export default function SettingsPage() {
|
|||
<h2 className="text-lg">{t("common:pages.settings")}</h2>
|
||||
<LanguageSelector />
|
||||
<ThemeSelector />
|
||||
{user ? <ClockFormatSelector /> : null}
|
||||
{user ? (
|
||||
<>
|
||||
<PushNotificationsEnabler />
|
||||
|
|
@ -157,6 +158,38 @@ function ThemeSelector() {
|
|||
);
|
||||
}
|
||||
|
||||
function ClockFormatSelector() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const handleClockFormatChange = (
|
||||
event: React.ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
const newFormat = event.target.value as "auto" | "24h" | "12h";
|
||||
fetcher.submit(
|
||||
{ _action: "UPDATE_CLOCK_FORMAT", newValue: newFormat },
|
||||
{ method: "post", encType: "application/json" },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor="clock-format">{t("common:settings.clockFormat")}</Label>
|
||||
<select
|
||||
id="clock-format"
|
||||
defaultValue={user?.preferences.clockFormat ?? "auto"}
|
||||
onChange={handleClockFormatChange}
|
||||
disabled={fetcher.state !== "idle"}
|
||||
>
|
||||
<option value="auto">{t("common:clockFormat.auto")}</option>
|
||||
<option value="24h">{t("common:clockFormat.24h")}</option>
|
||||
<option value="12h">{t("common:clockFormat.12h")}</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// adapted from https://pqvst.com/2023/11/21/web-push-notifications/
|
||||
function PushNotificationsEnabler() {
|
||||
const { t } = useTranslation(["common"]);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export const settingsEditSchema = z.union([
|
|||
newValue: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("PLACEHOLDER"),
|
||||
_action: _action("UPDATE_CLOCK_FORMAT"),
|
||||
newValue: z.enum(["auto", "24h", "12h"]),
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { UsersIcon } from "~/components/icons/Users";
|
|||
import { Placement } from "~/components/Placement";
|
||||
import { Table } from "~/components/Table";
|
||||
import type { TeamResultsLoaderData } from "~/features/team/loaders/t.$customUrl.results.server";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { tournamentTeamPage, userPage } from "~/utils/urls";
|
||||
|
||||
|
|
@ -17,7 +18,8 @@ interface TeamResultsTableProps {
|
|||
}
|
||||
|
||||
export function TeamResultsTable({ results }: TeamResultsTableProps) {
|
||||
const { t, i18n } = useTranslation("user");
|
||||
const { t } = useTranslation("user");
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<Table>
|
||||
|
|
@ -42,14 +44,11 @@ export function TeamResultsTable({ results }: TeamResultsTableProps) {
|
|||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
{databaseTimestampToDate(result.startTime).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(result.startTime), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<div className="stack horizontal xs items-center">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTournament } from "~/features/tournament/routes/to.$id";
|
|||
import { resolveLeagueRoundStartDate } from "~/features/tournament/tournament-utils";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { TOURNAMENT } from "../../../tournament/tournament-constants";
|
||||
import { useDeadline } from "./useDeadline";
|
||||
|
||||
|
|
@ -51,15 +52,7 @@ export function RoundHeader({
|
|||
{hasDeadline ? <Deadline roundId={roundId} bestOf={bestOf} /> : null}
|
||||
</div>
|
||||
) : leagueRoundStartDate ? (
|
||||
<div className="elim-bracket__round-header__infos">
|
||||
<div>
|
||||
{leagueRoundStartDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
→
|
||||
</div>
|
||||
</div>
|
||||
<LeagueRoundStartDate date={leagueRoundStartDate} />
|
||||
) : (
|
||||
<div className="elim-bracket__round-header__infos invisible">
|
||||
Hidden
|
||||
|
|
@ -69,10 +62,27 @@ export function RoundHeader({
|
|||
);
|
||||
}
|
||||
|
||||
function LeagueRoundStartDate({ date }: { date: Date }) {
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<div className="elim-bracket__round-header__infos">
|
||||
<div>
|
||||
{formatDate(date, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}{" "}
|
||||
→
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
|
||||
useAutoRerender("ten seconds");
|
||||
const isMounted = useIsMounted();
|
||||
const deadline = useDeadline(roundId, bestOf);
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
if (!deadline) return null;
|
||||
|
||||
|
|
@ -83,7 +93,7 @@ function Deadline({ roundId, bestOf }: { roundId: number; bestOf: number }) {
|
|||
})}
|
||||
>
|
||||
DL{" "}
|
||||
{deadline.toLocaleTimeString("en-US", {
|
||||
{formatTime(deadline, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
|||
import { tournamentWebsocketRoom } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
|
||||
import { SENDOU_INK_BASE_URL, tournamentJoinPage } from "~/utils/urls";
|
||||
import {
|
||||
|
|
@ -40,6 +41,7 @@ import "../components/Bracket/bracket.css";
|
|||
|
||||
export default function TournamentBracketsPage() {
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const { formatDateTime, formatTime } = useTimeFormat();
|
||||
const visibility = useVisibilityChange();
|
||||
const { revalidate } = useRevalidator();
|
||||
const user = useUser();
|
||||
|
|
@ -253,16 +255,13 @@ export default function TournamentBracketsPage() {
|
|||
{bracket.startTime ? (
|
||||
<span suppressHydrationWarning>
|
||||
(open{" "}
|
||||
{sub(bracket.startTime, { hours: 1 }).toLocaleString(
|
||||
"en-US",
|
||||
{
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
weekday: "long",
|
||||
},
|
||||
)}{" "}
|
||||
{formatDateTime(sub(bracket.startTime, { hours: 1 }), {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
weekday: "long",
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{bracket.startTime.toLocaleTimeString("en-US", {
|
||||
{formatTime(bracket.startTime, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
|
|||
import { Table } from "~/components/Table";
|
||||
import { BanUserModal } from "~/features/tournament-organization/components/BanUserModal";
|
||||
import type { OrganizationPageLoaderData } from "~/features/tournament-organization/loaders/org.$slug.server";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { userPage } from "~/utils/urls";
|
||||
import styles from "../components/BannedPlayersList.module.css";
|
||||
|
|
@ -20,7 +21,8 @@ export function BannedUsersList({
|
|||
}: {
|
||||
bannedUsers: NonNullable<OrganizationPageLoaderData["bannedUsers"]>;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["org"]);
|
||||
const { t } = useTranslation(["org"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const bannedUsersKey = (bannedUsers ?? [])
|
||||
.map((u) => [u.id, u.privateNote].join("-"))
|
||||
|
|
@ -78,9 +80,7 @@ export function BannedUsersList({
|
|||
<BanNote note={bannedUser.privateNote} />
|
||||
</td>
|
||||
<td className="text-sm text-lighter whitespace-nowrap">
|
||||
{databaseTimestampToDate(
|
||||
bannedUser.updatedAt,
|
||||
).toLocaleDateString(i18n.language, {
|
||||
{formatDate(databaseTimestampToDate(bannedUser.updatedAt), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
|
|
@ -88,13 +88,14 @@ export function BannedUsersList({
|
|||
</td>
|
||||
<td className="text-sm text-lighter whitespace-nowrap">
|
||||
{bannedUser.expiresAt
|
||||
? databaseTimestampToDate(
|
||||
bannedUser.expiresAt,
|
||||
).toLocaleDateString(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})
|
||||
? formatDate(
|
||||
databaseTimestampToDate(bannedUser.expiresAt),
|
||||
{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
)
|
||||
: t("org:banned.permanent")}
|
||||
</td>
|
||||
<td className={styles.actionsCell}>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import clsx from "clsx";
|
|||
import { LinkButton } from "~/components/elements/Button";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate, nullPaddedDatesOfMonth } from "~/utils/dates";
|
||||
import type { loader } from "../loaders/org.$slug.server";
|
||||
|
||||
|
|
@ -105,6 +106,7 @@ const monthYearSearchParams = ({ month, year }: MonthYear) =>
|
|||
]).toString();
|
||||
function MonthSelector({ month, year }: { month: number; year: number }) {
|
||||
const date = new Date(Date.UTC(year, month, 15));
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<div className="org__calendar__month-selector">
|
||||
|
|
@ -123,7 +125,7 @@ function MonthSelector({ month, year }: { month: number; year: number }) {
|
|||
{"<"}
|
||||
</LinkButton>
|
||||
<div>
|
||||
{date.toLocaleDateString("en-US", {
|
||||
{formatDate(date, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { Pagination } from "~/components/Pagination";
|
|||
import { Placement } from "~/components/Placement";
|
||||
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
|
||||
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { useHasPermission, useHasRole } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
|
|
@ -324,7 +325,8 @@ function SeriesHeader({
|
|||
}: {
|
||||
series: NonNullable<SerializeFrom<typeof loader>["series"]>;
|
||||
}) {
|
||||
const { i18n, t } = useTranslation(["org"]);
|
||||
const { t } = useTranslation(["org"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
|
|
@ -343,13 +345,10 @@ function SeriesHeader({
|
|||
{series.established ? (
|
||||
<div className="text-lighter text-italic text-xs">
|
||||
{t("org:events.established.short")}{" "}
|
||||
{databaseTimestampToDate(series.established).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(series.established), {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -450,7 +449,7 @@ function EventInfo({
|
|||
event: SerializeFrom<typeof loader>["events"][number];
|
||||
showYear?: boolean;
|
||||
}) {
|
||||
const { i18n } = useTranslation();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
|
|
@ -468,16 +467,13 @@ function EventInfo({
|
|||
<div>
|
||||
<div className="org__event-info__name">{event.name}</div>
|
||||
<time className="org__event-info__time" suppressHydrationWarning>
|
||||
{databaseTimestampToDate(event.startTime).toLocaleString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
year: showYear ? "numeric" : undefined,
|
||||
},
|
||||
)}
|
||||
{formatDateTime(databaseTimestampToDate(event.startTime), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
year: showYear ? "numeric" : undefined,
|
||||
})}
|
||||
</time>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tour
|
|||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
|
@ -376,9 +377,10 @@ function RegistrationProgress({
|
|||
members?: unknown[];
|
||||
mapPool?: unknown[];
|
||||
}) {
|
||||
const { i18n, t } = useTranslation(["tournament"]);
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const tournament = useTournament();
|
||||
const isMounted = useIsMounted();
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
const completedIfTruthy = (condition: unknown) =>
|
||||
condition ? "completed" : "incomplete";
|
||||
|
|
@ -419,15 +421,17 @@ function RegistrationProgress({
|
|||
tournament.ctx.startTime.getTime();
|
||||
|
||||
const registrationClosesAtString = isMounted
|
||||
? (tournament.isLeagueSignup
|
||||
? tournament.ctx.startTime
|
||||
: tournament.registrationClosesAt
|
||||
).toLocaleTimeString(i18n.language, {
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
})
|
||||
? formatTime(
|
||||
tournament.isLeagueSignup
|
||||
? tournament.ctx.startTime
|
||||
: tournament.registrationClosesAt,
|
||||
{
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
},
|
||||
)
|
||||
: "";
|
||||
|
||||
return (
|
||||
|
|
@ -502,14 +506,15 @@ function CheckIn({
|
|||
endDate: Date;
|
||||
checkedIn?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["tournament"]);
|
||||
const { t } = useTranslation(["tournament"]);
|
||||
const isMounted = useIsMounted();
|
||||
const fetcher = useFetcher();
|
||||
const { formatTime } = useTimeFormat();
|
||||
|
||||
useAutoRerender();
|
||||
|
||||
const checkInStartsString = isMounted
|
||||
? startDate.toLocaleTimeString(i18n.language, {
|
||||
? formatTime(startDate, {
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "2-digit",
|
||||
|
|
@ -518,7 +523,7 @@ function CheckIn({
|
|||
: "";
|
||||
|
||||
const checkInEndsString = isMounted
|
||||
? endDate.toLocaleTimeString(i18n.language, {
|
||||
? formatTime(endDate, {
|
||||
minute: "numeric",
|
||||
hour: "numeric",
|
||||
day: "2-digit",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SendouPopover } from "~/components/elements/Popover";
|
|||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { Placement } from "~/components/Placement";
|
||||
import { Table } from "~/components/Table";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import {
|
||||
calendarEventPage,
|
||||
|
|
@ -30,7 +31,8 @@ export function UserResultsTable({
|
|||
id,
|
||||
hasHighlightCheckboxes,
|
||||
}: UserResultsTableProps) {
|
||||
const { t, i18n } = useTranslation("user");
|
||||
const { t } = useTranslation("user");
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
const placementHeaderId = `${id}-th-placement`;
|
||||
|
||||
|
|
@ -82,14 +84,11 @@ export function UserResultsTable({
|
|||
</div>
|
||||
</td>
|
||||
<td className="whitespace-nowrap">
|
||||
{databaseTimestampToDate(result.startTime).toLocaleDateString(
|
||||
i18n.language,
|
||||
{
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
{formatDate(databaseTimestampToDate(result.startTime), {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</td>
|
||||
<td id={nameCellId}>
|
||||
<div className="stack horizontal xs items-center">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { Main } from "~/components/Main";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { USER } from "~/features/user-page/user-page-constants";
|
||||
import { addModNoteSchema } from "~/features/user-page/user-page-schemas";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { action } from "../actions/u.$identifier.admin.server";
|
||||
import { loader } from "../loaders/u.$identifier.admin.server";
|
||||
|
|
@ -48,13 +49,14 @@ export default function UserAdminPage() {
|
|||
|
||||
function AccountInfos() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<dl className={styles.dl}>
|
||||
<dt>User account created at</dt>
|
||||
<dd>
|
||||
{data.createdAt
|
||||
? databaseTimestampToDate(data.createdAt).toLocaleString("en-US", {
|
||||
? formatDateTime(databaseTimestampToDate(data.createdAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
@ -66,7 +68,7 @@ function AccountInfos() {
|
|||
|
||||
<dt>Discord account created at</dt>
|
||||
<dd>
|
||||
{new Date(data.discordAccountCreatedAt).toLocaleString("en-US", {
|
||||
{formatDateTime(new Date(data.discordAccountCreatedAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
@ -103,6 +105,7 @@ function AccountInfos() {
|
|||
function ModNotes() {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
if (!data.modNotes || data.modNotes.length === 0) {
|
||||
return (
|
||||
|
|
@ -118,7 +121,7 @@ function ModNotes() {
|
|||
{data.modNotes.map((note) => (
|
||||
<div key={note.noteId}>
|
||||
<p className="font-bold">
|
||||
{databaseTimestampToDate(note.createdAt).toLocaleString("en-US", {
|
||||
{formatDateTime(databaseTimestampToDate(note.createdAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
@ -186,6 +189,7 @@ function NewModNoteDialog() {
|
|||
|
||||
function BanLog() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
if (!data.banLogs || data.banLogs.length === 0) {
|
||||
return <p className="text-center text-lighter italic">No bans</p>;
|
||||
|
|
@ -196,7 +200,7 @@ function BanLog() {
|
|||
{data.banLogs.map((ban) => (
|
||||
<div key={ban.createdAt}>
|
||||
<p className="font-bold">
|
||||
{databaseTimestampToDate(ban.createdAt).toLocaleString("en-US", {
|
||||
{formatDateTime(databaseTimestampToDate(ban.createdAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
@ -214,7 +218,7 @@ function BanLog() {
|
|||
<p className="ml-2">
|
||||
Banned till:{" "}
|
||||
{ban.banned !== 1
|
||||
? databaseTimestampToDate(ban.banned).toLocaleString("en-US", {
|
||||
? formatDateTime(databaseTimestampToDate(ban.banned), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
@ -240,6 +244,7 @@ function BanLog() {
|
|||
|
||||
function FriendCodes() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
if (!data.friendCodes || data.friendCodes.length === 0) {
|
||||
return <p className="text-center text-lighter italic">No friend codes</p>;
|
||||
|
|
@ -252,7 +257,7 @@ function FriendCodes() {
|
|||
<p className="font-bold">{fc.friendCode}</p>
|
||||
<p className="ml-2">
|
||||
{index === 0 ? "Current" : "Past"} - Added on{" "}
|
||||
{databaseTimestampToDate(fc.createdAt).toLocaleString("en-US", {
|
||||
{formatDateTime(databaseTimestampToDate(fc.createdAt), {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import type {
|
|||
} from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { useWeaponUsage } from "~/hooks/swr";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
|
|
@ -180,7 +181,8 @@ function SeasonHeader({
|
|||
seasonViewed: number;
|
||||
seasonsParticipatedIn: number[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["user"]);
|
||||
const { t } = useTranslation(["user"]);
|
||||
const { formatDate } = useTimeFormat();
|
||||
const isMounted = useIsMounted();
|
||||
const { starts, ends } = Seasons.nthToDateRange(seasonViewed);
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -218,13 +220,13 @@ function SeasonHeader({
|
|||
>
|
||||
{isMounted ? (
|
||||
<>
|
||||
{new Date(starts).toLocaleString(i18n.language, {
|
||||
{formatDate(new Date(starts), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: isDifferentYears ? "numeric" : undefined,
|
||||
})}{" "}
|
||||
-{" "}
|
||||
{new Date(ends).toLocaleString(i18n.language, {
|
||||
{formatDate(new Date(ends), {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
|
|
@ -667,6 +669,8 @@ function CanceledMatchesDialog({
|
|||
}: {
|
||||
canceledMatches: NonNullable<UserSeasonsPageLoaderData["canceled"]>;
|
||||
}) {
|
||||
const { formatDateTime } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<SendouDialog
|
||||
trigger={
|
||||
|
|
@ -684,7 +688,7 @@ function CanceledMatchesDialog({
|
|||
<div key={match.id}>
|
||||
<Link to={sendouQMatchPage(match.id)}>#{match.id}</Link>
|
||||
<div>
|
||||
{databaseTimestampToDate(match.createdAt).toLocaleString()}
|
||||
{formatDateTime(databaseTimestampToDate(match.createdAt))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -701,6 +705,7 @@ function Results({
|
|||
results: UserSeasonsPageLoaderData["results"];
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { formatDate } = useTimeFormat();
|
||||
const [, setSearchParams] = useSearchParams();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -737,14 +742,11 @@ function Results({
|
|||
)}
|
||||
>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(result.createdAt).toLocaleString(
|
||||
"en",
|
||||
{
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
},
|
||||
)
|
||||
? formatDate(databaseTimestampToDate(result.createdAt), {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "t"}
|
||||
</div>
|
||||
{result.type === "GROUP_MATCH" ? (
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { YouTubeEmbed } from "~/components/YouTubeEmbed";
|
|||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useSearchParamState } from "~/hooks/useSearchParamState";
|
||||
import { useTimeFormat } from "~/hooks/useTimeFormat";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
|
|
@ -72,12 +73,12 @@ export default function VodPage() {
|
|||
defaultValue: 0,
|
||||
revive: Number,
|
||||
});
|
||||
const { i18n } = useTranslation();
|
||||
const isMounted = useIsMounted();
|
||||
const [autoplay, setAutoplay] = React.useState(false);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common", "vods"]);
|
||||
const user = useUser();
|
||||
const { formatDate } = useTimeFormat();
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
|
|
@ -98,9 +99,7 @@ export default function VodPage() {
|
|||
})}
|
||||
>
|
||||
{isMounted
|
||||
? databaseTimestampToDate(
|
||||
data.vod.youtubeDate,
|
||||
).toLocaleDateString(i18n.language, {
|
||||
? formatDate(databaseTimestampToDate(data.vod.youtubeDate), {
|
||||
day: "numeric",
|
||||
month: "numeric",
|
||||
year: "numeric",
|
||||
|
|
|
|||
88
app/hooks/useTimeFormat.ts
Normal file
88
app/hooks/useTimeFormat.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
|
||||
const H12_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
hour12: true,
|
||||
hourCycle: "h12" as const,
|
||||
};
|
||||
const H24_TIME_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||
hour12: false,
|
||||
hourCycle: "h23" as const,
|
||||
minute: "2-digit",
|
||||
};
|
||||
|
||||
function getClockFormatOptions(
|
||||
clockFormat: "auto" | "24h" | "12h" | undefined,
|
||||
language: string,
|
||||
): Intl.DateTimeFormatOptions {
|
||||
if (!clockFormat || clockFormat === "auto") {
|
||||
const isEnglish = language === "en";
|
||||
if (isEnglish) {
|
||||
return H12_TIME_OPTIONS;
|
||||
}
|
||||
return H24_TIME_OPTIONS;
|
||||
}
|
||||
|
||||
if (clockFormat === "24h") {
|
||||
return H24_TIME_OPTIONS;
|
||||
}
|
||||
|
||||
return H12_TIME_OPTIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for formatting dates and times according to user preferences and locale.
|
||||
* Respects the user's clock format preference (12h/24h) and current language.
|
||||
*
|
||||
* @example
|
||||
* const { formatDateTime, formatTime, formatDate } = useTimeFormat();
|
||||
*
|
||||
* // Format full date and time
|
||||
* formatDateTime(new Date('2025-01-15T14:30:00'));
|
||||
* // => "1/15/2025, 2:30 PM" (12h) or "1/15/2025, 14:30" (24h)
|
||||
*
|
||||
* // Format time only
|
||||
* formatTime(new Date('2025-01-15T14:30:00'));
|
||||
* // => "2:30 PM" (12h) or "14:30" (24h)
|
||||
*
|
||||
* // Format date only
|
||||
* formatDate(new Date('2025-01-15'));
|
||||
* // => "1/15/2025"
|
||||
*
|
||||
* // Custom options
|
||||
* formatDateTime(new Date(), { dateStyle: 'full', timeStyle: 'short' });
|
||||
* // => "Wednesday, January 15, 2025 at 2:30 PM"
|
||||
*/
|
||||
export function useTimeFormat() {
|
||||
const { i18n } = useTranslation();
|
||||
const user = useUser();
|
||||
const clockFormat = user?.preferences?.clockFormat;
|
||||
const clockOptions = getClockFormatOptions(clockFormat, i18n.language);
|
||||
|
||||
const formatDateTime = (date: Date, options?: Intl.DateTimeFormatOptions) => {
|
||||
return date.toLocaleString(
|
||||
i18n.language,
|
||||
options?.hour
|
||||
? {
|
||||
...options,
|
||||
...clockOptions,
|
||||
}
|
||||
: {
|
||||
...options,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (date: Date, options?: Intl.DateTimeFormatOptions) => {
|
||||
return date.toLocaleTimeString(i18n.language, {
|
||||
...options,
|
||||
...clockOptions,
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions) => {
|
||||
return date.toLocaleDateString(i18n.language, options);
|
||||
};
|
||||
|
||||
return { formatDateTime, formatTime, formatDate };
|
||||
}
|
||||
|
|
@ -64,10 +64,11 @@ import "~/styles/vars.css";
|
|||
export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
|
||||
if (isRevalidation(args)) return true;
|
||||
|
||||
// // reload on language change so the selected language gets set into the cookie
|
||||
const lang = args.nextUrl.searchParams.get("lng");
|
||||
// user settings, lang change etc. require revalidation on root loader
|
||||
const isSettingsPage = args.currentUrl.pathname === "/settings";
|
||||
if (isSettingsPage) return true;
|
||||
|
||||
return Boolean(lang);
|
||||
return false;
|
||||
};
|
||||
|
||||
export const meta: MetaFunction = (args) => {
|
||||
|
|
|
|||
|
|
@ -38,4 +38,41 @@ test.describe("Settings", () => {
|
|||
|
||||
expect(newContents).not.toBe(oldContents);
|
||||
});
|
||||
|
||||
test("updates clock format preference", async ({ page }) => {
|
||||
await seed(page);
|
||||
await impersonate(page);
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: "/",
|
||||
});
|
||||
|
||||
const tournamentCard = page.getByTestId("tournament-card").first();
|
||||
const timeElement = tournamentCard.locator("time");
|
||||
const initialTime = await timeElement.textContent();
|
||||
|
||||
expect(initialTime).toMatch(/AM|PM/);
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: SETTINGS_PAGE,
|
||||
});
|
||||
|
||||
const clockFormatSelect = page.locator("#clock-format");
|
||||
await clockFormatSelect.selectOption("24h");
|
||||
|
||||
await expect(page.getByText("Settings updated")).toBeVisible();
|
||||
|
||||
await navigate({
|
||||
page,
|
||||
url: "/",
|
||||
});
|
||||
|
||||
const newTime = await tournamentCard.locator("time").textContent();
|
||||
|
||||
expect(newTime).not.toMatch(/AM|PM/);
|
||||
expect(newTime).not.toBe(initialTime);
|
||||
expect(newTime).toContain(":");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -306,6 +306,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -306,6 +306,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -306,6 +306,10 @@
|
|||
"settings.notifications.disableInfo": "To disable push notifications check your browser settings",
|
||||
"settings.notifications.browserNotSupported": "Push notifications are not supported on this browser",
|
||||
"settings.notifications.permissionDenied": "Push notifications were denied. Check your browser settings to re-enable",
|
||||
"settings.clockFormat": "Clock format",
|
||||
"clockFormat.auto": "Auto (use language default)",
|
||||
"clockFormat.24h": "24-hour",
|
||||
"clockFormat.12h": "12-hour (AM/PM)",
|
||||
"badges.selector.none": "No badges selected",
|
||||
"badges.selector.select": "Select badge to add",
|
||||
"api.title": "API Access",
|
||||
|
|
|
|||
|
|
@ -308,6 +308,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "Ninguna insignia seleccionada",
|
||||
"badges.selector.select": "Seleccionar insignia añadir",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -308,6 +308,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "Ninguna insignia seleccionada",
|
||||
"badges.selector.select": "Seleccionar insignia añadir",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -308,6 +308,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -308,6 +308,10 @@
|
|||
"settings.notifications.disableInfo": "Regarder les paramètre de votre navigateur pour désactiver les notification push.",
|
||||
"settings.notifications.browserNotSupported": "Les notifications push ne sont pas supporter par cet navigateur",
|
||||
"settings.notifications.permissionDenied": "Les notifications push ont été refusées. Vérifiez les paramètres de votre navigateur pour les réactiver",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "Aucun badges est sélectionné",
|
||||
"badges.selector.select": "Selectionner un badge pour l'ajouter",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -307,6 +307,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -308,6 +308,10 @@
|
|||
"settings.notifications.disableInfo": "Per disattivare le notifiche push, controllare le impostazioni del browser",
|
||||
"settings.notifications.browserNotSupported": "Le notifiche push non sono supportate su questo browser",
|
||||
"settings.notifications.permissionDenied": "Le notifiche push sono state negate. Controlla le impostazioni del browser per riattivarle",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "Nessuna medaglia selezionata",
|
||||
"badges.selector.select": "Seleziona medaglia da aggiungere",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -302,6 +302,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -302,6 +302,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -306,6 +306,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -309,6 +309,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -308,6 +308,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "",
|
||||
"badges.selector.select": "",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -309,6 +309,10 @@
|
|||
"settings.notifications.disableInfo": "Чтобы отключить push-уведомления проверьте настройки вашего браузера",
|
||||
"settings.notifications.browserNotSupported": "Push-уведомления в вашем браузере не поддерживаются",
|
||||
"settings.notifications.permissionDenied": "Push-уведомления были запрещены. Проверьте настройки вашего бразуера, чтобы включить их обратно",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "Награды не выбраны",
|
||||
"badges.selector.select": "Выберите награды для добавления",
|
||||
"api.title": "",
|
||||
|
|
|
|||
|
|
@ -302,6 +302,10 @@
|
|||
"settings.notifications.disableInfo": "",
|
||||
"settings.notifications.browserNotSupported": "",
|
||||
"settings.notifications.permissionDenied": "",
|
||||
"settings.clockFormat": "",
|
||||
"clockFormat.auto": "",
|
||||
"clockFormat.24h": "",
|
||||
"clockFormat.12h": "",
|
||||
"badges.selector.none": "没有选择任何徽章",
|
||||
"badges.selector.select": "选择徽章并添加",
|
||||
"api.title": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user