i18n
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 8s
CI/CD Pipeline / i18n Catalog Check (push) Failing after 10s
CI/CD Pipeline / Build and Push App Docker Image (push) Skipped
CI/CD Pipeline / Deploy to Kubernetes (push) Skipped

This commit is contained in:
2026-08-08 15:29:40 -07:00
parent 22c282d8fa
commit 0a7b5fa00f
178 changed files with 13099 additions and 1285 deletions
@@ -12,6 +12,7 @@ import {
MagnifyingGlassIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
import { Tournament } from "@/features/tournaments/types";
import TeamList from "./team-list";
import Sheet from "@/components/sheet/sheet";
@@ -27,10 +28,11 @@ interface TeamEditSheetProps {
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
const { data: team } = useTeam(teamId);
const { t } = useLingui();
return (
<Sheet
title={team ? `Edit ${team.name}` : "Edit Team"}
title={team ? t`Edit ${team.name}` : t`Edit Team`}
opened={isOpen}
onChange={onClose}
>
@@ -56,6 +58,7 @@ interface ManageTeamsProps {
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
const [search, setSearch] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const { t } = useLingui();
const {
isOpen: editTeamOpened,
@@ -109,10 +112,10 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
<UsersIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Teams Enrolled
<Trans>No Teams Enrolled</Trans>
</Title>
<Text c="dimmed" ta="center">
This tournament has no enrolled teams yet.
<Trans>This tournament has no enrolled teams yet.</Trans>
</Text>
</Stack>
</Container>
@@ -124,7 +127,7 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
<Container size="100%" px={0}>
<Stack gap="xs">
<TextInput
placeholder="Search teams by name or player..."
placeholder={t`Search teams by name or player...`}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
@@ -134,7 +137,7 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
<Box px="md">
<Text size="xs" c="dimmed">
{filteredTeams.length} of {teams.length} teams
<Trans>{filteredTeams.length} of {teams.length} teams</Trans>
</Text>
</Box>
@@ -145,7 +148,7 @@ const ManageTeams = ({ tournament }: ManageTeamsProps) => {
{filteredTeams.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">
No teams found matching "{search}"
<Trans>No teams found matching "{search}"</Trans>
</Text>
)}
</Stack>
@@ -5,6 +5,7 @@ import {
Box,
Title
} from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { useTeam } from "../queries";
import TeamAvatar from "@/components/team-avatar";
@@ -19,7 +20,7 @@ const RegionalTeamCard = ({ teamId }: RegionalTeamCardProps) => {
return (
<Paper p="sm" withBorder radius="md">
<Text c="red" ta="center" size="sm">
Failed to load team
<Trans>Failed to load team</Trans>
</Text>
</Paper>
);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
Divider,
Title
} from "@mantine/core";
import { Trans } from "@lingui/react/macro";
import { useTeam } from "../queries";
import TeamAvatar from "@/components/team-avatar";
import SongSummary from "./team-form/song-summary";
@@ -22,7 +23,7 @@ const TeamCard = ({ teamId }: TeamCardProps) => {
return (
<Paper p="sm" withBorder radius="md">
<Text c="red" ta="center" size="sm">
Failed to load team
<Trans>Failed to load team</Trans>
</Text>
</Paper>
);
@@ -1,5 +1,6 @@
import { ColorPicker, TextInput, Stack, Group, ColorSwatch, Text } from '@mantine/core';
import React, { useState, useCallback, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
const presetColors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00',
@@ -16,8 +17,10 @@ interface TeamColorPickerProps {
const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
value,
onChange,
label = "Select Color"
label
}) => {
const { t } = useLingui();
const displayLabel = label ?? t`Select Color`;
const [customHex, setCustomHex] = useState(value || '');
const isValidHex = useMemo(() => {
@@ -41,7 +44,7 @@ const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
return (
<Stack gap="md" p="md" w="100%">
<Text fw={500}>{label}</Text>
<Text fw={500}>{displayLabel}</Text>
<ColorPicker
value={value || '#000000'}
@@ -57,11 +60,11 @@ const TeamColorPicker: React.FC<TeamColorPickerProps> = ({
<Group gap="xs" align="flex-end">
<TextInput
style={{ flex: 1 }}
label="Custom Hex Code"
label={t`Custom Hex Code`}
placeholder="#FF0000"
value={customHex}
onChange={handleHexInputChange}
error={customHex && !isValidHex ? 'Invalid hex color format' : undefined}
error={customHex && !isValidHex ? t`Invalid hex color format` : undefined}
w="100%"
/>
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
interface DurationPickerProps {
songDurationMs: number;
@@ -16,8 +17,9 @@ interface IncrementButtonsProps {
}
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
const { t } = useLingui();
const increments = [1, 5, 30, 60];
const labels = ["1s", "5s", "30s", "1m"];
const labels = [t`1s`, t`5s`, t`30s`, t`1m`];
return (
<Group gap={3} wrap="nowrap" flex={1}>
@@ -51,6 +53,7 @@ const DurationPicker = ({
onChange,
disabled = false,
}: DurationPickerProps) => {
const { t } = useLingui();
const songDurationSeconds = Math.floor(songDurationMs / 1000);
const formatTime = useCallback((seconds: number) => {
@@ -91,16 +94,16 @@ const DurationPicker = ({
const getValidationMessage = useCallback((start: number, end: number) => {
const duration = end - start;
if (start < 0 || end > songDurationSeconds) {
return "Segment must be within song duration";
return t`Segment must be within song duration`;
}
if (duration < 10) {
return "Segment must be at least 10 seconds";
return t`Segment must be at least 10 seconds`;
}
if (duration > 15) {
return "Segment must be no more than 15 seconds";
return t`Segment must be no more than 15 seconds`;
}
return null;
}, [songDurationSeconds]);
}, [songDurationSeconds, t]);
const updateTimes = useCallback((newStart: number, newEnd: number) => {
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
@@ -166,14 +169,14 @@ const DurationPicker = ({
return (
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
Walkout Segment ({segmentDuration}s)
<Trans>Walkout Segment ({segmentDuration}s)</Trans>
</Text>
<Stack gap="sm">
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
Start
<Trans>Start</Trans>
</Text>
<TextInput
value={startInputValue}
@@ -209,7 +212,7 @@ const DurationPicker = ({
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
End
<Trans>End</Trans>
</Text>
<TextInput
value={endInputValue}
@@ -14,6 +14,7 @@ import { TeamInput } from "../../types";
import { teamKeys } from "../../queries";
import SongPicker from "./song-picker";
import PlayersPicker from "./players-picker";
import { useLingui } from "@lingui/react/macro";
interface TeamFormProps {
close: () => void;
@@ -31,6 +32,7 @@ const TeamForm = ({
onSubmit
}: TeamFormProps) => {
const isEditMode = !!teamId;
const { t } = useLingui();
const config: UseFormInput<TeamInput> = {
initialValues: {
@@ -49,16 +51,16 @@ const TeamForm = ({
},
onSubmitPreventDefault: "always",
validate: {
name: isNotEmpty("Name is required"),
name: isNotEmpty(t`Name is required`),
players: (value: string[]) =>
value.length > 1 && value[1] !== ""
? undefined
: "Players are required",
song_name: isNotEmpty("Song is required"),
: t`Players are required`,
song_name: isNotEmpty(t`Song is required`),
song_start: (value, values) => {
if (values.song_name && values.song_id) {
if (value === undefined || value === null) {
return "Song start time is required";
return t`Song start time is required`;
}
}
return undefined;
@@ -66,15 +68,15 @@ const TeamForm = ({
song_end: (value, values) => {
if (values.song_name && values.song_id) {
if (value === undefined || value === null) {
return "Song end time is required";
return t`Song end time is required`;
}
if (values.song_start !== undefined && value !== undefined) {
const duration = value - values.song_start;
if (duration < 10) {
return "Song segment must be at least 10 seconds";
return t`Song segment must be at least 10 seconds`;
}
if (duration > 15) {
return "Song segment must be no more than 15 seconds";
return t`Song segment must be no more than 15 seconds`;
}
}
}
@@ -100,8 +102,8 @@ const TeamForm = ({
const mutation = isEditMode ? updateTeam : createTeam;
const errorMessage = isEditMode
? "Failed to update team"
: "Failed to create team";
? t`Failed to update team`
: t`Failed to create team`;
mutation(teamData, {
onSuccess: async (team: any) => {
@@ -143,7 +145,7 @@ const TeamForm = ({
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to upload logo");
throw new Error(error.error || t`Failed to upload logo`);
}
const result = await response.json();
@@ -160,8 +162,8 @@ const TeamForm = ({
);
} catch (error: any) {
const logoErrorMessage = isEditMode
? `Team updated but logo upload failed: ${error.message}`
: `Team created but logo upload failed: ${error.message}`;
? t`Team updated but logo upload failed: ${error.message}`
: t`Team created but logo upload failed: ${error.message}`;
toast.error(logoErrorMessage);
logger.error("Team logo upload error", error);
}
@@ -182,20 +184,20 @@ const TeamForm = ({
},
});
},
[isEditMode, createTeam, updateTeam, queryClient]
[isEditMode, createTeam, updateTeam, queryClient, t]
);
return (
<SlidePanel
onSubmit={form.onSubmit(handleSubmit)}
onCancel={close}
submitText={isEditMode ? "Update Team" : "Create Team"}
cancelText="Cancel"
submitText={isEditMode ? t`Update Team` : t`Create Team`}
cancelText={t`Cancel`}
loading={isPending}
>
<Stack>
<TextInput
label="Name"
label={t`Name`}
withAsterisk
disabled={isEditMode}
key={form.key("name")}
@@ -205,7 +207,7 @@ const TeamForm = ({
<FileInput
key={form.key("logo")}
accept="image/png,image/jpeg,image/gif,image/jpg"
label={isEditMode ? "Change Logo" : "Logo"}
label={isEditMode ? t`Change Logo` : t`Logo`}
leftSection={<LinkIcon size={16} />}
{...form.getInputProps("logo")}
/>
@@ -3,6 +3,7 @@ import { useAuth } from "@/contexts/auth-context";
import { useUnenrolledPlayers } from "@/features/players/queries";
import { Autocomplete, Stack, Text, TextInput } from "@mantine/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLingui } from "@lingui/react/macro";
interface PlayersPickerProps {
tournamentId: string;
@@ -13,6 +14,7 @@ interface PlayersPickerProps {
}
const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }: PlayersPickerProps) => {
const { t } = useLingui();
const { roles, user } = useAuth();
const isAdmin = roles.includes("Admin");
@@ -40,7 +42,7 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
return (
<Stack>
<Autocomplete
label="Player 1"
label={t`Player 1`}
data={options}
disabled
value={playerOne}
@@ -48,7 +50,7 @@ const PlayersPicker = ({ tournamentId, value = [], onChange, disabled, error }:
comboboxProps={{ withinPortal: false }}
/>
<Autocomplete
label="Player 2"
label={t`Player 2`}
data={options}
disabled={disabled}
value={playerTwo}
@@ -8,6 +8,7 @@ import SongSearch from "./song-search";
import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary";
import { MusicNoteIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro";
interface Song {
song_id: string;
@@ -26,6 +27,7 @@ interface SongPickerProps {
}
const SongPicker = ({ form, error }: SongPickerProps) => {
const { t } = useLingui();
const currentSong = useMemo((): Song | undefined => {
const values = form.getValues();
if (values.song_id && values.song_name) {
@@ -63,9 +65,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
error={error}
Component={SongPickerComponent}
componentProps={{}}
title={"Select Song"}
label={"Walkout Song"}
placeholder={"Select your walkout song"}
title={t`Select Song`}
label={t`Walkout Song`}
placeholder={t`Select your walkout song`}
/>
);
};
@@ -76,6 +78,7 @@ interface SongPickerComponentProps {
}
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
const { t } = useLingui();
const handleSongSelect = (track: SpotifyTrack) => {
const defaultStart = 0;
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
@@ -106,7 +109,7 @@ const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps
return (
<Stack styles={{ root: { width: '100%' }}}>
<Text size="lg" fw={500}>Search for a Song</Text>
<Text size="lg" fw={500}><Trans>Search for a Song</Trans></Text>
<SongSearch onChange={handleSongSelect} />
@@ -122,10 +125,10 @@ const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps
</Avatar>
<div>
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
{song?.song_name || "No song selected"}
{song?.song_name || t`No song selected`}
</Text>
<Text size="xs" c="dimmed">
{song?.song_artist || "Select a song to see details"}
{song?.song_artist || t`Select a song to see details`}
</Text>
<Text size="xs" c="dimmed">
{song?.song_album || ""}
@@ -1,13 +1,16 @@
import { Text, Group, Avatar, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { useLingui } from "@lingui/react/macro";
interface SongSearchProps {
onChange: (track: SpotifyTrack) => void;
placeholder?: string;
}
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const SongSearch = ({ onChange, placeholder }: SongSearchProps) => {
const { t } = useLingui();
const resolvedPlaceholder = placeholder ?? t`Search for songs...`;
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
try {
// Use a default search term when query is empty to show popular tracks
@@ -72,7 +75,7 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
searchFn={searchSpotifyTracks}
renderOption={renderOption}
format={formatTrack}
placeholder={placeholder}
placeholder={resolvedPlaceholder}
/>
)
};
@@ -1,5 +1,6 @@
import { Group, Avatar, Text, Box } from "@mantine/core";
import { MusicNoteIcon } from "@phosphor-icons/react";
import { Trans } from "@lingui/react/macro";
interface Song {
song_id: string;
@@ -27,7 +28,7 @@ const SongSummary = ({ song }: SongSummaryProps) => {
return (
<Group gap="xs" c="dimmed">
<MusicNoteIcon size={16} />
<Text size="sm" c="dimmed">No song selected</Text>
<Text size="sm" c="dimmed"><Trans>No song selected</Trans></Text>
</Group>
);
}
@@ -7,28 +7,31 @@ import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-list";
import { MatchListSkeleton } from "./skeleton";
import { Trans, useLingui } from "@lingui/react/macro";
interface ProfileProps {
id: string;
}
const TeamProfile = ({ id }: ProfileProps) => {
const { t } = useLingui();
const { data: team } = useTeam(id);
const { data: matches, isLoading: matchesLoading } = useTeamMatches(id);
const { data: stats, isLoading: statsLoading, error: statsError } = useTeamStats(id);
if (!team) return <Text p="md">Team not found</Text>;
if (!team) return <Text p="md"><Trans>Team not found</Trans></Text>;
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <>
<Stack px="md">
<Text size="md" fw={700}>Players</Text>
<Text size="md" fw={700}><Trans>Players</Trans></Text>
<PlayerList players={team.players} />
</Stack>
<Divider my="md" />
<Stack>
<Text px="md" size="md" fw={700}>Statistics</Text>
<Text px="md" size="md" fw={700}><Trans>Statistics</Trans></Text>
{statsLoading ? (
<StatsSkeleton />
) : (
@@ -38,7 +41,8 @@ const TeamProfile = ({ id }: ProfileProps) => {
</>,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: matchesLoading ? (
<MatchListSkeleton />
) : (
@@ -46,7 +50,8 @@ const TeamProfile = ({ id }: ProfileProps) => {
),
},
{
label: "Tournaments",
label: t`Tournaments`,
value: "tournaments",
content: (
<>
<TournamentList tournaments={team.tournaments || []} />
@@ -3,6 +3,7 @@ import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import { StatsSkeleton } from "@/components/stats-overview";
import HeaderSkeleton from "./header-skeleton";
import { useLingui } from "@lingui/react/macro";
const MatchCardSkeleton = ({ opacity = 1 }: { opacity?: number }) => (
<Paper px="md" py="md" withBorder radius="md" style={{ opacity }}>
@@ -63,17 +64,21 @@ export const OverviewSkeleton = () => (
);
const ProfileSkeleton = () => {
const { t } = useLingui();
const tabs = [
{
label: "Overview",
label: t`Overview`,
value: "overview",
content: <OverviewSkeleton />,
},
{
label: "Matches",
label: t`Matches`,
value: "matches",
content: <MatchListSkeleton />,
},
{
label: "Tournaments",
label: t`Tournaments`,
value: "tournaments",
content: <TournamentList tournaments={[]} loading />,
},
];
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { listPlayers } from '@/features/players/server';
import { useServerSuspenseQuery } from '@/lib/tanstack-query/hooks';
@@ -15,6 +16,7 @@ export const playerQueries = {
};
export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
const { t } = useLingui();
const { data: allPlayers } = useServerSuspenseQuery(playerQueries.all());
const availablePlayers = useMemo(() => {
@@ -30,9 +32,9 @@ export const useAvailablePlayers = (excludedPlayerIds: string[] = []) => {
const playerOptions = useMemo(() =>
availablePlayers.map(player => ({
value: player.id,
label: `${player.first_name} ${player.last_name}`.trim() || 'Unnamed Player'
label: `${player.first_name} ${player.last_name}`.trim() || t`Unnamed Player`
})),
[availablePlayers]
[availablePlayers, t]
);
return {
+4 -2
View File
@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
import { useLingui } from "@lingui/react/macro";
import { createTeam } from "@/features/teams/server";
import { TeamInput } from "@/features/teams/types";
import { logger } from "@/lib/logger";
@@ -6,13 +7,14 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
const useCreateTeam = () => {
const navigate = useNavigate();
const { t } = useLingui();
return useServerMutation({
mutationFn: (data: TeamInput) => createTeam({ data }),
onMutate: (data) => {
logger.info('Creating team', data);
},
successMessage: 'Team created successfully!',
successMessage: t`Team created successfully!`,
});
};
+5 -2
View File
@@ -1,16 +1,19 @@
import { useLingui } from "@lingui/react/macro";
import { updateTeam } from "@/features/teams/server";
import { TeamUpdateInput } from "@/features/teams/types";
import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { logger } from "@/lib/logger";
const useUpdateTeam = (teamId: string) => {
const { t } = useLingui();
return useServerMutation({
mutationFn: (data: TeamUpdateInput) =>
mutationFn: (data: TeamUpdateInput) =>
updateTeam({ data: { id: teamId, updates: data } }),
onMutate: (data) => {
logger.info('Updating team', { teamId, updates: Object.keys(data) });
},
successMessage: 'Team updated successfully!',
successMessage: t`Team updated successfully!`,
});
};