Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3aa3347
feature: tournament check-in, free agents, join rules and stats leade…
lukepolo Aug 29, 2026
13f3773
wip
lukepolo Aug 30, 2026
39d34db
fix: tournament check-in and free-agent registration defects
lukepolo Aug 30, 2026
b75f82f
chore: translate tournament check-in and free-agent strings across al…
lukepolo Aug 30, 2026
6f2205f
feature: sanctions settings area and tournament registration invites
lukepolo Aug 30, 2026
5e8c75c
chore: translate sanctions and tournament invite strings across all l…
lukepolo Aug 30, 2026
c88e8a6
feature: free agent lobby parties in the pool and draft preview, tour…
lukepolo Aug 30, 2026
88dae8a
chore: translate free agent party strings across all locales
lukepolo Aug 30, 2026
e1a5768
wip
lukepolo Aug 31, 2026
0bb028e
wip
lukepolo Aug 31, 2026
8243312
feature: tournament team invites and expiring invite links replacing …
lukepolo Aug 31, 2026
6278c26
wip
lukepolo Aug 31, 2026
e5013a6
chore: translate tournament invite link strings and drop the passcode…
lukepolo Aug 31, 2026
050b99b
feature: split the tournament organizer panel into animated Add Team …
lukepolo Aug 31, 2026
7dfeace
chore: translate organizer panel and invite gating strings
lukepolo Aug 31, 2026
22bfe62
fix: move the tournament organizer invite tools out of the narrow asi…
lukepolo Aug 31, 2026
988ec02
fix: translate tournament invite refusals instead of rendering the ap…
lukepolo Aug 31, 2026
8e50e78
chore: translate invite refusal reasons across all locales
lukepolo Aug 31, 2026
757c33f
chore: use Anmeldung consistently for German tournament registration
lukepolo Aug 31, 2026
850b830
style: remove always-on amber corner brackets and unbox the tournamen…
lukepolo Aug 31, 2026
7e45799
style: drop card frames from tournament tab bodies
lukepolo Aug 31, 2026
99c115f
wip
lukepolo Aug 31, 2026
68b19b0
wip
lukepolo Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .nuxtignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Git worktrees live here (Claude Code creates them at .claude/worktrees/*), and
# at least one symlinks its node_modules back to ../../../node_modules. Nitro's
# dev storage watches rootDir with followSymlinks:true, so chokidar walks that
# link and registers a watch for every node_modules file — ~28.7k registrations
# per process, past the point where macOS starts throwing
# "EMFILE: too many open files, watch" and Nuxt restart-loops.
#
# This lives in .nuxtignore rather than nuxt.config's `ignore` because the
# `ignore` option is broken in Nuxt 3.17.2: @nuxt/schema's $resolve uses
# `for (const pattern in val)` over the array, so it adds the index ("0")
# instead of your pattern. .nuxtignore is read directly by resolveIgnorePatterns.
.claude
8 changes: 0 additions & 8 deletions components/CustomMatch.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@ const compactClasses =
:to="{ name: 'matches-create' }"
:class="[baseClasses, compact ? compactClasses : fullClasses]"
>
<span
class="absolute top-2 left-2 w-[14px] h-[14px] border-0 border-solid border-[hsl(var(--tac-amber))] border-t-2 border-l-2 pointer-events-none z-[2] [transition:border-color_180ms_ease]"
aria-hidden="true"
></span>
<span
class="absolute bottom-2 right-2 w-[14px] h-[14px] border-0 border-solid border-[hsl(var(--tac-amber))] border-b-2 border-r-2 pointer-events-none z-[2] [transition:border-color_180ms_ease]"
aria-hidden="true"
></span>
<span
class="absolute inset-0 z-0 pointer-events-none opacity-0 [background-image:repeating-linear-gradient(180deg,transparent_0,transparent_3px,hsl(var(--tac-amber)/0.03)_3px,hsl(var(--tac-amber)/0.03)_4px)] transition-opacity [transition-duration:220ms] group-hover/cm:opacity-100"
aria-hidden="true"
Expand Down
20 changes: 0 additions & 20 deletions components/PlayerElo.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,6 @@ const isMobile = useMediaQuery("(max-width: 768px)");
:collision-padding="12"
:class="cardClasses"
>
<span
:class="[cornerTickClasses, '-left-px -top-px border-l border-t']"
aria-hidden="true"
></span>
<span
:class="[cornerTickClasses, '-right-px -top-px border-r border-t']"
aria-hidden="true"
></span>
<span
:class="[cornerTickClasses, '-left-px -bottom-px border-l border-b']"
aria-hidden="true"
></span>
<span
:class="[cornerTickClasses, '-right-px -bottom-px border-r border-b']"
aria-hidden="true"
></span>

<span :class="scanlineClasses" aria-hidden="true"></span>

<header :class="headerClasses">
Expand Down Expand Up @@ -415,9 +398,6 @@ export default {
"animate-[playerelo-in_180ms_cubic-bezier(0.2,0.8,0.2,1)]",
].join(" ");
},
cornerTickClasses(): string {
return "absolute h-[8px] w-[8px] border-[hsl(var(--tac-amber))] pointer-events-none";
},
scanlineClasses(): string {
return "pointer-events-none absolute inset-0 mix-blend-overlay opacity-60 [background-image:repeating-linear-gradient(0deg,transparent_0,transparent_3px,hsl(0_0%_100%/0.02)_3px,hsl(0_0%_100%/0.02)_4px)]";
},
Expand Down
9 changes: 0 additions & 9 deletions components/ServiceLogs.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,6 @@ async function downloadFullLogs(service: string) {
class="flex flex-col overflow-hidden border border-border bg-[linear-gradient(180deg,hsl(var(--card)/0.6)_0%,hsl(var(--card)/0.25)_100%)] [backdrop-filter:blur(6px)]"
:class="maximized ? 'fixed inset-0 z-50 bg-background' : 'relative'"
>
<span
aria-hidden="true"
class="pointer-events-none absolute left-2 top-2 h-3 w-3 border-l-2 border-t-2 border-[hsl(var(--tac-amber))]"
/>
<span
aria-hidden="true"
class="pointer-events-none absolute bottom-2 right-2 h-3 w-3 border-b-2 border-r-2 border-[hsl(var(--tac-amber))]"
/>

<header
class="flex flex-wrap items-center gap-1.5 border-b border-border/70 px-3 py-2 sm:gap-2 sm:px-4 sm:py-3"
>
Expand Down
10 changes: 0 additions & 10 deletions components/TacticalPageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
} from "~/utilities/tacticalClasses";

defineProps<{
corners?: "both" | "tl" | "br" | "none";
inlineActions?: boolean;
stackActions?: boolean;
}>();
Expand All @@ -28,15 +27,6 @@ const tacticalTabs = {
<header
class="relative overflow-hidden rounded-lg border border-border bg-[linear-gradient(180deg,hsl(var(--card)/0.55)_0%,hsl(var(--card)/0.25)_100%)] px-4 py-4 sm:px-6 sm:py-5 [backdrop-filter:blur(6px)]"
>
<span
aria-hidden="true"
class="pointer-events-none absolute left-2 top-2 h-[14px] w-[14px] border-l-2 border-t-2 border-[hsl(var(--tac-amber))]"
></span>
<span
aria-hidden="true"
class="pointer-events-none absolute bottom-2 right-2 h-[14px] w-[14px] border-b-2 border-r-2 border-[hsl(var(--tac-amber))]"
></span>

<div
class="flex gap-3"
:class="
Expand Down
80 changes: 70 additions & 10 deletions components/TeamInviteNotification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,31 @@ import TimeAgo from "./TimeAgo.vue";

<template>
<div>
<h3 class="text-lg font-semibold mb-2">
<template v-if="type === 'tournament'">
{{
$t("team.invite.tournament_title", { id: invite.tournament_team_id })
}}
</template>
{{ $t("team.invite.team_title") }}
</h3>
<!-- One title, chosen by type. The three invites read almost the same and
the only thing telling them apart is this line, so it must never show
two of them at once. -->
<h3 class="text-lg font-semibold mb-2">{{ title }}</h3>

<template v-if="type === 'tournament'">
<template v-if="isRegistration">
<!-- Same table, same accept type, two addressings: a row carrying a team
is an invite for that team to register, not for you personally. -->
{{
isTeamRegistration
? $t("team.invite.tournament_team_registration_message", {
team: invite.team.name,
})
: $t("team.invite.tournament_registration_message")
}}
<p class="text-sm text-muted-foreground mb-2">
<NuxtLink
:to="`/tournaments/${invite.tournament.id}`"
class="underline"
>
{{ invite.tournament.name }}
</NuxtLink>
</p>
</template>
<template v-else-if="isTournamentTeam">
{{ $t("team.invite.tournament_message", { team: invite.team.name }) }}
<p class="text-sm text-muted-foreground mb-2">
<NuxtLink
Expand Down Expand Up @@ -49,6 +64,20 @@ import TimeAgo from "./TimeAgo.vue";
<script lang="ts">
import { generateMutation } from "~/graphql/graphqlGen";

/**
* `type` is passed straight through to the shared acceptInvite/denyInvite
* action, whose dispatch is now explicit and throws on anything it does not
* know:
*
* "team" -> team_invites
* "tournament" (legacy) /
* "tournament-team" -> tournament_team_invites (join a REGISTERED team)
* "tournament-registration" -> tournament_invites (register at all)
*
* The existing call sites still send "tournament": it is what every deployed
* client sends, and repointing it would break accept/deny mid-upgrade for
* anyone whose tab has not reloaded.
*/
export default {
props: {
invite: {
Expand All @@ -60,6 +89,30 @@ export default {
required: true,
},
},
computed: {
isRegistration(): boolean {
return this.type === "tournament-registration";
},
isTeamRegistration(): boolean {
return this.isRegistration && !!this.invite.team_id;
},
isTournamentTeam(): boolean {
return this.type === "tournament" || this.type === "tournament-team";
},
title(): string {
if (this.isRegistration) {
return this.$t(
this.isTeamRegistration
? "team.invite.tournament_team_registration_title"
: "team.invite.tournament_registration_title",
) as string;
}
if (this.isTournamentTeam) {
return this.$t("team.invite.tournament_title") as string;
}
return this.$t("team.invite.team_title") as string;
},
},
methods: {
async acceptInvite(inviteId: string) {
await this.$apollo.mutate({
Expand All @@ -76,7 +129,14 @@ export default {
}),
});

if (this.type === "tournament") {
// Accepting a registration invite writes the same
// tournament_registration_unlocks row a redeemed invite link writes, so
// the tournament page's existing entry gate is already satisfied when we
// land on it — there is nothing extra to do here.
if (this.isRegistration) {
return this.$router.push(`/tournaments/${this.invite.tournament.id}`);
}
if (this.isTournamentTeam) {
return this.$router.push(
`/tournaments/${this.invite.team.tournament.id}`,
);
Expand Down
2 changes: 1 addition & 1 deletion components/award/AwardCase.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ function formatAwardDate(iso?: string | null) {
}

const frameClasses =
"relative overflow-hidden rounded-lg border border-border px-6 py-6 [background:linear-gradient(180deg,hsl(var(--card)_/_0.7)_0%,hsl(var(--card)_/_0.35)_100%)] [backdrop-filter:blur(6px)] before:pointer-events-none before:absolute before:left-2 before:top-2 before:h-[14px] before:w-[14px] before:border-l-2 before:border-t-2 before:border-[hsl(var(--tac-amber))] before:content-[''] after:pointer-events-none after:absolute after:bottom-2 after:right-2 after:h-[14px] after:w-[14px] after:border-b-2 after:border-r-2 after:border-[hsl(var(--tac-amber))] after:content-['']";
"relative overflow-hidden rounded-lg border border-border px-6 py-6 [background:linear-gradient(180deg,hsl(var(--card)_/_0.7)_0%,hsl(var(--card)_/_0.35)_100%)] [backdrop-filter:blur(6px)]";

const scanlineClasses =
"pointer-events-none absolute inset-0 [background-image:repeating-linear-gradient(3deg,transparent_0,transparent_3px,hsl(var(--tac-amber)_/_0.025)_3px,hsl(var(--tac-amber)_/_0.025)_4px)]";
Expand Down
8 changes: 0 additions & 8 deletions components/draft-games/DraftActiveAlert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,6 @@ const acknowledge = () => {
<div
class="relative overflow-hidden rounded-lg border border-border px-6 py-8 [backdrop-filter:blur(10px)] [background:linear-gradient(180deg,hsl(var(--card)/0.95)_0%,hsl(var(--card)/0.85)_100%)] [box-shadow:0_0_0_1px_hsl(var(--tac-amber)/0.3),0_0_40px_hsl(var(--tac-amber)/0.18)]"
>
<span
aria-hidden="true"
class="pointer-events-none absolute left-2 top-2 h-[14px] w-[14px] border-l-2 border-t-2 border-[hsl(var(--tac-amber))]"
></span>
<span
aria-hidden="true"
class="pointer-events-none absolute bottom-2 right-2 h-[14px] w-[14px] border-b-2 border-r-2 border-[hsl(var(--tac-amber))]"
></span>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-30 [background-image:repeating-linear-gradient(180deg,transparent_0,transparent_3px,hsl(var(--tac-amber)/0.04)_3px,hsl(var(--tac-amber)/0.04)_4px)]"
Expand Down
10 changes: 9 additions & 1 deletion components/draft-games/DraftRoom.vue
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import AnimatedFilters from "~/components/common/AnimatedFilters.vue";
import mapLabel from "~/utilities/mapLabel";
import ChatLobby from "~/components/chat/ChatLobby.vue";
import VoiceChannelCard from "~/components/voice/VoiceChannelCard.vue";
import { matchHasEnded } from "~/utilities/matchTeamLobby";
import HeightSwap from "~/components/ui/transitions/HeightSwap.vue";
import CheckIntoMatch from "~/components/match/CheckIntoMatch.vue";
import MatchRegionVeto from "~/components/match/MatchRegionVeto.vue";
Expand Down Expand Up @@ -150,6 +151,12 @@ const myMatchLineupId = computed(() => {
? props.match?.lineup_1_id
: props.match?.lineup_2_id;
});
// Voice closes a few minutes after the match reaches a terminal status and the
// API stops admitting the channel with it; team chat keeps working, which is why
// this is not simply myMatchLineupId.
const myVoiceLineupId = computed(() =>
matchHasEnded(props.match) ? null : myMatchLineupId.value,
);
const teamChatLobbyId = computed(() =>
myMatchLineupId.value ? `${props.match.id}:${myMatchLineupId.value}` : null,
);
Expand Down Expand Up @@ -1825,10 +1832,11 @@ const start = () => {
section appeared -- which left the same three controls on
screen twice as soon as it had. -->
<VoiceChannelCard
v-if="myVoiceLineupId"
show-empty
class="mt-3"
kind="match"
:channel-id="myMatchLineupId"
:channel-id="myVoiceLineupId"
:label="$t('layouts.voice_panel.team_comms')"
/>
</div>
Expand Down
29 changes: 29 additions & 0 deletions components/hub/NotificationsPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import LeagueScheduleStack from "~/components/notification/LeagueScheduleStack.v
v-if="
team_invites.length > 0 ||
tournament_team_invites.length > 0 ||
tournament_invites.length > 0 ||
notifications.length > 0
"
/>
Expand Down Expand Up @@ -113,6 +114,30 @@ import LeagueScheduleStack from "~/components/notification/LeagueScheduleStack.v
</div>
</Transition>

<!-- Its own block rather than folded into the one above: a registration
invite and a team invite are separate tables with separate accept
types, and stacking them would hide which one the player is answering. -->
<Transition
enter-active-class="notif-fold"
enter-from-class="notif-fold-collapsed"
leave-active-class="notif-fold"
leave-to-class="notif-fold-collapsed"
>
<div v-if="tournament_invites.length > 0" class="grid grid-rows-[1fr]">
<div class="min-h-0">
<div class="mb-3 p-3 bg-card/60 border border-border rounded-md">
<TeamInviteNotification
type="tournament-registration"
:invite="invite"
:key="invite.id"
v-for="invite of tournament_invites"
/>
<Separator v-if="notifications.length > 0" />
</div>
</div>
</div>
</Transition>

<TransitionGroup
tag="div"
class="flex flex-col"
Expand Down Expand Up @@ -244,6 +269,9 @@ export default {
tournament_team_invites() {
return useNotificationStore().tournament_team_invites;
},
tournament_invites() {
return useNotificationStore().tournament_invites;
},
draft_invites() {
return useNotificationStore().draft_invites;
},
Expand All @@ -264,6 +292,7 @@ export default {
this.scheduleTasks.length > 0 ||
this.team_invites.length > 0 ||
this.tournament_team_invites.length > 0 ||
this.tournament_invites.length > 0 ||
this.draft_invites.length > 0 ||
this.notifications.length > 0
);
Expand Down
29 changes: 25 additions & 4 deletions components/match/CheckInDeadline.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const props = withDefaults(
// sits on the page background and has to draw its own frame.
variant?: "cta" | "standalone";
checkedIn?: boolean;
// The readout is reused for countdowns that are not a match check-in
// deadline -- a tournament check-in window opening, or kickoff. The match
// copy would then announce "Check in within" on a window that is not open
// yet, and promise an auto-cancellation that nothing performs. Pass a
// prefix (and `null` help to drop the tooltip) for those.
prefixLabel?: string;
helpLabel?: string | null;
}>(),
{
variant: "cta",
Expand Down Expand Up @@ -113,12 +120,26 @@ const description = computed(() => {
return "";
}

const prefix = props.checkedIn
? t("match.check_in.deadline_checked_in")
: t("match.check_in.deadline");
const prefix =
props.prefixLabel ??
(props.checkedIn
? t("match.check_in.deadline_checked_in")
: t("match.check_in.deadline"));

return `${prefix} ${label.value}`;
});

// `undefined` means "not overridden" and falls back to the match copy; an
// explicit `null` means this countdown has no consequence worth a tooltip.
const help = computed(() =>
props.helpLabel === undefined
? t("match.check_in.deadline_help")
: props.helpLabel,
);

const title = computed(() =>
help.value ? `${description.value} — ${help.value}` : description.value,
);
</script>

<template>
Expand All @@ -132,7 +153,7 @@ const description = computed(() => {
]"
role="timer"
:aria-label="description"
:title="`${description} — ${$t('match.check_in.deadline_help')}`"
:title="title"
>
<!-- Only in the last ten seconds, and only ever as an indicator dot --
the digits stay still so they stay readable. -->
Expand Down
8 changes: 0 additions & 8 deletions components/match/MatchActiveAlert.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,6 @@ const { manuallyOpened } = useMatchReadyModal();
v-if="match"
class="relative overflow-hidden rounded-lg border border-border px-6 py-8 [backdrop-filter:blur(10px)] [background:linear-gradient(180deg,hsl(var(--card)/0.95)_0%,hsl(var(--card)/0.85)_100%)] [box-shadow:0_0_0_1px_hsl(var(--tac-amber)/0.3),0_0_40px_hsl(var(--tac-amber)/0.18)]"
>
<span
aria-hidden="true"
class="pointer-events-none absolute left-2 top-2 h-[14px] w-[14px] border-l-2 border-t-2 border-[hsl(var(--tac-amber))]"
></span>
<span
aria-hidden="true"
class="pointer-events-none absolute bottom-2 right-2 h-[14px] w-[14px] border-b-2 border-r-2 border-[hsl(var(--tac-amber))]"
></span>
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 opacity-30 [background-image:repeating-linear-gradient(180deg,transparent_0,transparent_3px,hsl(var(--tac-amber)/0.04)_3px,hsl(var(--tac-amber)/0.04)_4px)]"
Expand Down
Loading