i18n
This commit is contained in:
@@ -23,25 +23,27 @@ import { useEffect } from 'react'
|
||||
import toast from '@/lib/sonner'
|
||||
import { logger } from '@/lib/logger'
|
||||
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||
import { Trans, useLingui } from '@lingui/react/macro'
|
||||
import Button from './button'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
const router = useRouter()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useLingui()
|
||||
const isRoot = useMatch({
|
||||
strict: false,
|
||||
select: (state) => state.id === rootRouteId,
|
||||
})
|
||||
const [detailsOpened, { toggle: toggleDetails }] = useDisclosure(false)
|
||||
|
||||
const errorMessage = error?.message || 'Unknown error'
|
||||
const errorMessage = error?.message || t`Unknown error`
|
||||
const errorStack = error?.stack || 'No stack trace available'
|
||||
|
||||
useEffect(() => {
|
||||
logger.error('DefaultCatchBoundary | ', error)
|
||||
|
||||
if (errorMessage.toLowerCase().includes('unauthenticated')) {
|
||||
toast.error('You\'ve been logged out')
|
||||
toast.error(t`You've been logged out`)
|
||||
router.history.push('/login')
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
@@ -53,23 +55,23 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
<Center>
|
||||
<Stack align="center" gap="md">
|
||||
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
||||
<Text size="xl" fw={600}>Access Denied</Text>
|
||||
<Text size="xl" fw={600}><Trans>Access Denied</Trans></Text>
|
||||
<Text c="dimmed" ta="center">
|
||||
You don't have permission to access this page.
|
||||
<Trans>You don't have permission to access this page.</Trans>
|
||||
</Text>
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
<Trans>Go Back</Trans>
|
||||
</Button>
|
||||
<MantineButton
|
||||
component={Link}
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
<Trans>Home</Trans>
|
||||
</MantineButton>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -84,21 +86,21 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
<Stack align="center" gap="md" w="100%">
|
||||
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
||||
|
||||
<Text size="xl" fw={600}>Something went wrong</Text>
|
||||
<Text size="xl" fw={600}><Trans>Something went wrong</Trans></Text>
|
||||
|
||||
<Text c="dimmed" ta="center">
|
||||
An error occurred while loading this page.
|
||||
<Trans>An error occurred while loading this page.</Trans>
|
||||
</Text>
|
||||
|
||||
<Box w="100%" mt="md">
|
||||
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
||||
<Text size="sm" c="dimmed" mb="xs"><Trans>Error: {errorMessage}</Trans></Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-sm"
|
||||
onClick={toggleDetails}
|
||||
fullWidth
|
||||
>
|
||||
{detailsOpened ? 'Hide' : 'Show'} details
|
||||
{detailsOpened ? <Trans>Hide details</Trans> : <Trans>Show details</Trans>}
|
||||
</Button>
|
||||
<Collapse in={detailsOpened}>
|
||||
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||
@@ -112,7 +114,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
variant="light"
|
||||
onClick={() => router.invalidate()}
|
||||
>
|
||||
Retry
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
{isRoot ? (
|
||||
<MantineButton
|
||||
@@ -120,14 +122,14 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||
to="/"
|
||||
variant="filled"
|
||||
>
|
||||
Home
|
||||
<Trans>Home</Trans>
|
||||
</MantineButton>
|
||||
) : (
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go Back
|
||||
<Trans>Go Back</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface AvatarProps
|
||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
@@ -30,6 +31,7 @@ const Avatar = ({
|
||||
contain = false,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const { t } = useLingui();
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const hasImage = Boolean(props.src);
|
||||
|
||||
@@ -102,7 +104,7 @@ const Avatar = ({
|
||||
color="dark"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
aria-label="Close image preview"
|
||||
aria-label={t`Close image preview`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import useNow from '@/hooks/use-now';
|
||||
import { Text, Group } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import classes from './countdown.module.css';
|
||||
|
||||
interface CountdownProps {
|
||||
@@ -34,6 +35,7 @@ function calculateTimeLeft(targetDate: Date, currentTime = new Date()): TimeLeft
|
||||
const pad = (num: number) => num.toString().padStart(2, '0');
|
||||
|
||||
export function Countdown({ date, label, color }: CountdownProps) {
|
||||
const { t } = useLingui();
|
||||
const now = useNow();
|
||||
const timeLeft = useMemo(() => calculateTimeLeft(date, now), [date, now]);
|
||||
|
||||
@@ -45,7 +47,7 @@ export function Countdown({ date, label, color }: CountdownProps) {
|
||||
|
||||
const prefix =
|
||||
timeLeft.days > 0
|
||||
? `${timeLeft.days}d ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||
? `${t`${timeLeft.days}d`} ${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`
|
||||
: `${pad(timeLeft.hours)}:${pad(timeLeft.minutes)}:`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DatePicker, TimeInput } from "@mantine/dates";
|
||||
import { ActionIcon, Stack } from "@mantine/core";
|
||||
import { useRef } from "react";
|
||||
import { ClockIcon } from "@phosphor-icons/react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value: Date | null;
|
||||
@@ -16,6 +17,7 @@ const DateTimePicker = ({
|
||||
label,
|
||||
...rest
|
||||
}: DateTimePickerProps) => {
|
||||
const { t } = useLingui();
|
||||
const timeRef = useRef<HTMLInputElement>(null);
|
||||
const currentDate = value ? new Date(value) : null;
|
||||
|
||||
@@ -73,7 +75,7 @@ const DateTimePicker = ({
|
||||
/>
|
||||
<TimeInput
|
||||
ref={timeRef}
|
||||
label="Time"
|
||||
label={t`Time`}
|
||||
size="md"
|
||||
value={formatTime(currentDate)}
|
||||
onChange={handleTimeChange}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { Trans, useLingui } from '@lingui/react/macro'
|
||||
|
||||
export function IOSInstallPrompt() {
|
||||
const { t } = useLingui()
|
||||
const [show, setShow] = useState(false)
|
||||
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
||||
|
||||
@@ -31,8 +33,8 @@ export function IOSInstallPrompt() {
|
||||
if (!show || !platform) return null
|
||||
|
||||
const instructions = platform === 'ios'
|
||||
? 'Tap Share → Add to Home Screen'
|
||||
: 'Tap Menu (⋮) → Add to Home screen'
|
||||
? t`Tap Share → Add to Home Screen`
|
||||
: t`Tap Menu (⋮) → Add to Home screen`
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
||||
@@ -42,7 +44,7 @@ export function IOSInstallPrompt() {
|
||||
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
||||
Please install FLXN • This will save me Twilio credits as you won't be signed out!
|
||||
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans>
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
||||
{instructions}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Input, InputProps, Group, Text } from "@mantine/core";
|
||||
import { CheckFat, Phone } from "@phosphor-icons/react";
|
||||
import { IMaskInput } from "react-imask";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface PhoneNumberInputProps extends InputProps {
|
||||
id: string;
|
||||
@@ -20,6 +21,7 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
||||
error,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<Input.Wrapper
|
||||
id={id}
|
||||
@@ -35,13 +37,13 @@ const PhoneNumberInput: React.FC<PhoneNumberInputProps> = ({
|
||||
<Group gap={2}>
|
||||
<Phone size={20} /> {" "}
|
||||
<Text c="dimmed" size="sm">
|
||||
+1
|
||||
<Trans>+1</Trans>
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
leftSectionWidth={50}
|
||||
leftSectionProps={{ style: { padding: 0 } }}
|
||||
placeholder="(713) 867-5309"
|
||||
placeholder={t`(713) 867-5309`}
|
||||
onAccept={(_, mask) => onChange(mask.unmaskedValue)}
|
||||
rightSection={
|
||||
value?.length === 10 && (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Flex, Loader, Modal as MantineModal, Title } from "@mantine/core";
|
||||
import { PropsWithChildren, Suspense } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
interface ModalProps extends PropsWithChildren {
|
||||
title?: string;
|
||||
@@ -15,31 +16,35 @@ const Modal: React.FC<ModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onExited,
|
||||
}) => (
|
||||
<MantineModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>{title}</Title>}
|
||||
radius={20}
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
duration: 200,
|
||||
timingFunction: "ease-out",
|
||||
onExited,
|
||||
}}
|
||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
||||
closeButtonProps={{ "aria-label": "Close" }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex justify="center" align="center" w="100%" h={400}>
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
}
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<MantineModal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={<Title order={3}>{title}</Title>}
|
||||
radius={20}
|
||||
transitionProps={{
|
||||
transition: "pop",
|
||||
duration: 200,
|
||||
timingFunction: "ease-out",
|
||||
onExited,
|
||||
}}
|
||||
overlayProps={{ backgroundOpacity: 0.4 }}
|
||||
closeButtonProps={{ "aria-label": t`Close` }}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</MantineModal>
|
||||
);
|
||||
<Suspense
|
||||
fallback={
|
||||
<Flex justify="center" align="center" w="100%" h={400}>
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Suspense>
|
||||
</MantineModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Box, Text, UnstyledButton, Flex, Stack } from "@mantine/core";
|
||||
import { CaretRightIcon } from "@phosphor-icons/react";
|
||||
import React, { ComponentType, useContext } from "react";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { SlidePanelContext } from "./slide-panel-context";
|
||||
|
||||
interface SlidePanelFieldProps {
|
||||
@@ -23,12 +24,13 @@ const SlidePanelField = ({
|
||||
Component,
|
||||
title,
|
||||
label,
|
||||
placeholder = "Select value",
|
||||
placeholder,
|
||||
withAsterisk = false,
|
||||
formatValue,
|
||||
componentProps,
|
||||
error,
|
||||
}: SlidePanelFieldProps) => {
|
||||
const { t, i18n } = useLingui();
|
||||
const context = useContext(SlidePanelContext);
|
||||
|
||||
if (!context) {
|
||||
@@ -53,11 +55,11 @@ const SlidePanelField = ({
|
||||
}
|
||||
if (value != null) {
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString();
|
||||
return value.toLocaleDateString(i18n.locale);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
return placeholder;
|
||||
return placeholder ?? t`Select value`;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeftIcon, CheckIcon } from "@phosphor-icons/react";
|
||||
import { useState, ReactNode } from "react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { SlidePanelContext, type PanelConfig } from "./slide-panel-context";
|
||||
import Button from "@/components/button";
|
||||
|
||||
@@ -28,13 +29,16 @@ const SlidePanel = ({
|
||||
children,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
submitText = "Submit",
|
||||
cancelText = "Cancel",
|
||||
submitText,
|
||||
cancelText,
|
||||
cancelColor = "red",
|
||||
maxHeight = "70vh",
|
||||
formProps = {},
|
||||
loading = false,
|
||||
}: SlidePanelProps) => {
|
||||
const { t } = useLingui();
|
||||
const resolvedSubmitText = submitText ?? t`Submit`;
|
||||
const resolvedCancelText = cancelText ?? t`Cancel`;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [panelConfig, setPanelConfig] = useState<PanelConfig | null>(null);
|
||||
const [tempValue, setTempValue] = useState<any>(null);
|
||||
@@ -113,7 +117,7 @@ const SlidePanel = ({
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{submitText}
|
||||
{resolvedSubmitText}
|
||||
</Button>
|
||||
{onCancel && (
|
||||
<Button
|
||||
@@ -124,7 +128,7 @@ const SlidePanel = ({
|
||||
type="button"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
{resolvedCancelText}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -153,7 +157,7 @@ const SlidePanel = ({
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
onClick={closePanel}
|
||||
aria-label="Back"
|
||||
aria-label={t`Back`}
|
||||
>
|
||||
<ArrowLeftIcon size={24} />
|
||||
</ActionIcon>
|
||||
@@ -162,7 +166,7 @@ const SlidePanel = ({
|
||||
variant="transparent"
|
||||
color="green"
|
||||
onClick={handleConfirm}
|
||||
aria-label="Confirm"
|
||||
aria-label={t`Confirm`}
|
||||
>
|
||||
<CheckIcon size={24} />
|
||||
</ActionIcon>
|
||||
@@ -182,8 +186,8 @@ const SlidePanel = ({
|
||||
/>
|
||||
</ScrollArea.Autosize>
|
||||
<Stack mt="auto" w="100%" gap={2}>
|
||||
<Button mt="md" onClick={handleConfirm}>Confirm</Button>
|
||||
<Button variant="subtle" onClick={closePanel} mt="sm" color="red">Cancel</Button>
|
||||
<Button mt="md" onClick={handleConfirm}><Trans>Confirm</Trans></Button>
|
||||
<Button variant="subtle" onClick={closePanel} mt="sm" color="red"><Trans>Cancel</Trans></Button>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import { BaseStats } from "@/types/stats";
|
||||
|
||||
interface StatsOverviewProps {
|
||||
@@ -62,11 +63,13 @@ const StatItem = ({
|
||||
};
|
||||
|
||||
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (!statsData && !isLoading) {
|
||||
return (
|
||||
<Box p="sm" h="auto" mih={200}>
|
||||
<Text ta="center" size="sm" fw={600} c="dimmed">
|
||||
No stats available yet
|
||||
<Trans>No stats available yet</Trans>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -96,15 +99,15 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
||||
const avgMarginOfLoss = statsData.margin_of_loss ? parseFloat(statsData.margin_of_loss.toFixed(1)) : 0;
|
||||
|
||||
const allStats = [
|
||||
{ label: "Matches Played", value: overallStats.matches, Icon: BoxingGloveIcon },
|
||||
{ label: "Wins", value: overallStats.wins, Icon: CrownIcon },
|
||||
{ label: "Losses", value: overallStats.losses, Icon: XIcon },
|
||||
{ label: "Cups Made", value: overallStats.total_cups_made, Icon: FireIcon },
|
||||
{ label: "Cups Against", value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||
{ label: "Avg Cups Per Match", value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||
{ label: "Avg Cups Against", value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||
{ label: "Avg Win Margin", value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||
{ label: "Avg Loss Margin", value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||
{ label: t`Matches Played`, value: overallStats.matches, Icon: BoxingGloveIcon },
|
||||
{ label: t`Wins`, value: overallStats.wins, Icon: CrownIcon },
|
||||
{ label: t`Losses`, value: overallStats.losses, Icon: XIcon },
|
||||
{ label: t`Cups Made`, value: overallStats.total_cups_made, Icon: FireIcon },
|
||||
{ label: t`Cups Against`, value: overallStats.total_cups_against, Icon: ShieldIcon },
|
||||
{ label: t`Avg Cups Per Match`, value: avgCupsPerMatch >= 0 ? avgCupsPerMatch : null, Icon: ChartLineUpIcon },
|
||||
{ label: t`Avg Cups Against`, value: avgCupsAgainstPerMatch >= 0 ? avgCupsAgainstPerMatch : null, Icon: ShieldCheckIcon },
|
||||
{ label: t`Avg Win Margin`, value: avgMarginOfVictory >= 0 ? avgMarginOfVictory : null, Icon: ArrowUpIcon },
|
||||
{ label: t`Avg Loss Margin`, value: avgMarginOfLoss >= 0 ? avgMarginOfLoss : null, Icon: ArrowDownIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -127,16 +130,17 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
|
||||
};
|
||||
|
||||
export const StatsSkeleton = () => {
|
||||
const { t } = useLingui();
|
||||
const skeletonStats = [
|
||||
{ label: "Matches Played", Icon: BoxingGloveIcon },
|
||||
{ label: "Wins", Icon: CrownIcon },
|
||||
{ label: "Losses", Icon: XIcon },
|
||||
{ label: "Cups Made", Icon: FireIcon },
|
||||
{ label: "Cups Against", Icon: ShieldIcon },
|
||||
{ label: "Avg Cups Per Match", Icon: ChartLineUpIcon },
|
||||
{ label: "Avg Cups Against", Icon: ShieldCheckIcon },
|
||||
{ label: "Avg Win Margin", Icon: ArrowUpIcon },
|
||||
{ label: "Avg Loss Margin", Icon: ArrowDownIcon },
|
||||
{ label: t`Matches Played`, Icon: BoxingGloveIcon },
|
||||
{ label: t`Wins`, Icon: CrownIcon },
|
||||
{ label: t`Losses`, Icon: XIcon },
|
||||
{ label: t`Cups Made`, Icon: FireIcon },
|
||||
{ label: t`Cups Against`, Icon: ShieldIcon },
|
||||
{ label: t`Avg Cups Per Match`, Icon: ChartLineUpIcon },
|
||||
{ label: t`Avg Cups Against`, Icon: ShieldCheckIcon },
|
||||
{ label: t`Avg Win Margin`, Icon: ArrowUpIcon },
|
||||
{ label: t`Avg Loss Margin`, Icon: ArrowDownIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,9 +12,13 @@ import { useRouter } from "@tanstack/react-router";
|
||||
|
||||
interface TabItem {
|
||||
label: string;
|
||||
/** Stable URL slug for the ?tab= param; defaults to label. Set this when label is translated. */
|
||||
value?: string;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
const tabValue = (tab: TabItem) => (tab.value ?? tab.label).toLowerCase();
|
||||
|
||||
interface SwipeableTabsProps {
|
||||
tabs: TabItem[];
|
||||
defaultTab?: number;
|
||||
@@ -36,7 +40,7 @@ function SwipeableTabs({
|
||||
const urlTab = search?.tab;
|
||||
if (typeof urlTab === "string") {
|
||||
const tabIndex = tabs.findIndex(
|
||||
(tab) => tab.label.toLowerCase() === urlTab.toLowerCase()
|
||||
(tab) => tabValue(tab) === urlTab.toLowerCase()
|
||||
);
|
||||
return tabIndex !== -1 ? tabIndex : defaultTab;
|
||||
}
|
||||
@@ -62,7 +66,7 @@ function SwipeableTabs({
|
||||
?.querySelector(".mantine-ScrollArea-viewport")
|
||||
?.scrollTo({ top: 0 });
|
||||
|
||||
const tabLabel = tabs[index].label.toLowerCase();
|
||||
const tabLabel = tabValue(tabs[index]);
|
||||
if (typeof window !== "undefined") {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tabLabel);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
|
||||
export interface TypeaheadOption<T = any> {
|
||||
id: string;
|
||||
@@ -24,12 +25,14 @@ const Typeahead = <T,>({
|
||||
searchFn,
|
||||
renderOption,
|
||||
format,
|
||||
placeholder = "Search...",
|
||||
placeholder,
|
||||
debounceMs = 300,
|
||||
disabled = false,
|
||||
initialValue = "",
|
||||
maxHeight = 200,
|
||||
}: TypeaheadProps<T>) => {
|
||||
const { t } = useLingui();
|
||||
const resolvedPlaceholder = placeholder ?? t`Search...`;
|
||||
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -119,7 +122,7 @@ const Typeahead = <T,>({
|
||||
}
|
||||
await performSearch(searchQuery);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
placeholder={resolvedPlaceholder}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||
disabled={disabled}
|
||||
/>
|
||||
@@ -164,7 +167,7 @@ const Typeahead = <T,>({
|
||||
) : (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
|
||||
{searchQuery.trim() ? t`No results found` : t`Start typing to search...`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user