fix: bracket progression protection, cleaner uis
This commit is contained in:
@@ -260,7 +260,17 @@ export default function PlayPage() {
|
||||
Connected as <span className="text-green-300">{username}</span>
|
||||
</p>
|
||||
</div>
|
||||
<PhaseIndicator phase={gamePhase} isMyTurn={isMyTurn} />
|
||||
<div className="flex items-center gap-3">
|
||||
{gamePhase === "connected" && (
|
||||
<button
|
||||
onClick={sendReady}
|
||||
className="rounded-full border border-green-700 bg-green-900/60 px-3 py-1.5 text-sm font-medium text-green-300 transition-colors hover:bg-green-800/70"
|
||||
>
|
||||
Ready Up
|
||||
</button>
|
||||
)}
|
||||
<PhaseIndicator phase={gamePhase} isMyTurn={isMyTurn} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === "reconnecting" && (
|
||||
@@ -270,72 +280,38 @@ export default function PlayPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
|
||||
Match
|
||||
</h2>
|
||||
|
||||
{tournamentMode && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-purple-950/50 border border-purple-700 text-purple-300 text-sm">
|
||||
<span>🏆</span>
|
||||
<span>Tournament mode active</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gamePhase === "connected" && (
|
||||
<button
|
||||
onClick={sendReady}
|
||||
className="w-full py-2.5 bg-green-700 hover:bg-green-600 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
✋ Ready to Play
|
||||
</button>
|
||||
)}
|
||||
|
||||
{gamePhase === "ready" && (
|
||||
<div className="text-center py-3 text-yellow-300 text-sm animate-pulse">
|
||||
⏳ Waiting for opponent...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(gamePhase === "playing" || gamePhase === "game-over") &&
|
||||
myColor && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800">
|
||||
<span className="text-white font-medium text-sm">
|
||||
You are {myColorLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{gamePhase === "playing" && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 px-3 py-2 rounded-lg font-semibold text-sm ${
|
||||
isMyTurn
|
||||
? "bg-green-900/50 border border-green-600 text-green-300 animate-pulse"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isMyTurn
|
||||
? "⬆ Your turn - click a column"
|
||||
: "⏳ Waiting for opponent..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gamePhase === "game-over" && gameResult && !tournamentMode && (
|
||||
<button
|
||||
onClick={sendReady}
|
||||
className="w-full py-2 bg-green-700 hover:bg-green-600 text-white text-sm font-semibold rounded-lg transition-colors"
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{tournamentMode && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-purple-700 bg-purple-950/50 px-3 py-2 text-sm text-purple-300">
|
||||
<span>🏆</span>
|
||||
<span>Tournament mode active</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lg:col-span-2 bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center gap-4 min-h-96">
|
||||
{(gamePhase === "playing" || gamePhase === "game-over") && myColor && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-gray-800 px-3 py-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
You are {myColorLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{gamePhase === "playing" && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold ${
|
||||
isMyTurn
|
||||
? "animate-pulse border border-green-600 bg-green-900/50 text-green-300"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{isMyTurn
|
||||
? "⬆ Your turn - click a column"
|
||||
: "⏳ Waiting for opponent..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center gap-4 min-h-96">
|
||||
{gamePhase === "idle" ? (
|
||||
<div className="text-gray-500 text-center py-10">
|
||||
Connect from the connection page to start.
|
||||
@@ -349,9 +325,9 @@ export default function PlayPage() {
|
||||
<p className="text-gray-500 text-sm max-w-sm">
|
||||
Click the{" "}
|
||||
<span className="text-green-300 font-semibold">
|
||||
Ready to Play
|
||||
Ready Up
|
||||
</span>{" "}
|
||||
button in the Match panel to enter the queue.
|
||||
button beside your status to enter the queue.
|
||||
</p>
|
||||
</div>
|
||||
) : gamePhase === "ready" ? (
|
||||
@@ -405,7 +381,6 @@ export default function PlayPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -106,6 +106,13 @@ export default function SpectatePage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetLiveGames = useCallback(() => {
|
||||
const next = new Map<number, LiveGame>();
|
||||
liveGamesRef.current = next;
|
||||
setLiveGames(next);
|
||||
setSelectedGame(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "disconnected" && shouldRedirectToConnect) {
|
||||
clearRedirectFlag();
|
||||
@@ -156,6 +163,7 @@ export default function SpectatePage() {
|
||||
break;
|
||||
|
||||
case "TOURNAMENT_SCORES":
|
||||
resetLiveGames();
|
||||
setScores(msg.scores);
|
||||
break;
|
||||
|
||||
@@ -199,10 +207,15 @@ export default function SpectatePage() {
|
||||
}
|
||||
|
||||
case "GAME_MATCH_START":
|
||||
updateGame(msg.matchId, {
|
||||
player1: msg.player1,
|
||||
player2: msg.player2,
|
||||
});
|
||||
addLog(
|
||||
`Match started: #${msg.matchId} ${msg.player1} vs ${msg.player2}`,
|
||||
);
|
||||
send(cmd.gameList());
|
||||
send(cmd.gameWatch(msg.matchId));
|
||||
send(cmd.playerList());
|
||||
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||
send(cmd.getData("TOURNAMENT_DATA"));
|
||||
@@ -271,6 +284,9 @@ export default function SpectatePage() {
|
||||
updateGame(msg.matchId, {
|
||||
result: { kind: "win", winner: msg.winner },
|
||||
});
|
||||
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||
setSelectedGame((prev) => (prev === msg.matchId ? null : prev));
|
||||
}
|
||||
setTimeout(() => {
|
||||
send(cmd.gameList());
|
||||
send(cmd.playerList());
|
||||
@@ -284,6 +300,9 @@ export default function SpectatePage() {
|
||||
break;
|
||||
}
|
||||
updateGame(msg.matchId, { result: { kind: "draw" } });
|
||||
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||
setSelectedGame((prev) => (prev === msg.matchId ? null : prev));
|
||||
}
|
||||
break;
|
||||
|
||||
case "GAME_TERMINATED":
|
||||
@@ -292,6 +311,9 @@ export default function SpectatePage() {
|
||||
break;
|
||||
}
|
||||
updateGame(msg.matchId, { result: { kind: "terminated" } });
|
||||
if (tournamentType === "KnockoutBracket" && !knockoutRawData) {
|
||||
setSelectedGame((prev) => (prev === msg.matchId ? null : prev));
|
||||
}
|
||||
send(cmd.gameList());
|
||||
break;
|
||||
|
||||
@@ -311,6 +333,7 @@ export default function SpectatePage() {
|
||||
send(cmd.getData("TOURNAMENT_DATA"));
|
||||
}
|
||||
} else if (msg.key === "TOURNAMENT_DATA" && msg.value) {
|
||||
resetLiveGames();
|
||||
setKnockoutRawData(msg.value);
|
||||
}
|
||||
break;
|
||||
@@ -325,7 +348,15 @@ export default function SpectatePage() {
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [addLog, knockoutRawData, send, subscribe, tournamentType, updateGame]);
|
||||
}, [
|
||||
addLog,
|
||||
knockoutRawData,
|
||||
resetLiveGames,
|
||||
send,
|
||||
subscribe,
|
||||
tournamentType,
|
||||
updateGame,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status !== "connected" || role !== "observer") return;
|
||||
@@ -357,6 +388,49 @@ export default function SpectatePage() {
|
||||
() => buildKnockoutBracket(knockoutRounds, liveGames, tournamentWinner),
|
||||
[knockoutRounds, liveGames, tournamentWinner],
|
||||
);
|
||||
const seedingMatches = useMemo(() => {
|
||||
const seen = new Set<number>();
|
||||
const matches: Array<{
|
||||
id: number;
|
||||
player1: string;
|
||||
player2: string;
|
||||
currentTurnColor: 1 | 2 | null;
|
||||
resultKind: KnockoutMatchView["resultKind"];
|
||||
status: "scheduled" | "live" | "completed";
|
||||
}> = [];
|
||||
|
||||
for (const game of gameList) {
|
||||
seen.add(game.id);
|
||||
const live = liveGames.get(game.id);
|
||||
const resultKind = live?.result?.kind ?? null;
|
||||
if (resultKind) continue;
|
||||
matches.push({
|
||||
id: game.id,
|
||||
player1: game.player1,
|
||||
player2: game.player2,
|
||||
currentTurnColor: resultKind ? null : (live?.currentTurnColor ?? null),
|
||||
resultKind,
|
||||
status: resultKind ? "completed" : live ? "live" : "scheduled",
|
||||
});
|
||||
}
|
||||
|
||||
for (const game of Array.from(liveGames.values()).sort(
|
||||
(a, b) => b.id - a.id,
|
||||
)) {
|
||||
if (seen.has(game.id) || !game.player1 || !game.player2 || game.result)
|
||||
continue;
|
||||
matches.push({
|
||||
id: game.id,
|
||||
player1: game.player1,
|
||||
player2: game.player2,
|
||||
currentTurnColor: game.result ? null : game.currentTurnColor,
|
||||
resultKind: game.result?.kind ?? null,
|
||||
status: game.result ? "completed" : "live",
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}, [gameList, liveGames]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -366,20 +440,12 @@ export default function SpectatePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === "connected" && (
|
||||
<div
|
||||
className={`rounded-xl p-4 border flex items-center gap-4 ${
|
||||
tournamentActive
|
||||
? "bg-purple-950/40 border-purple-600"
|
||||
: "bg-gray-900 border-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div className="text-3xl">{tournamentActive ? "🏆" : "⏳"}</div>
|
||||
{status === "connected" && tournamentActive && (
|
||||
<div className="rounded-xl border border-purple-600 bg-purple-950/40 p-4 flex items-center gap-4">
|
||||
<div className="text-3xl">🏆</div>
|
||||
<div>
|
||||
<div className="font-semibold text-white">
|
||||
{tournamentActive
|
||||
? `Tournament Active - ${tournamentType ?? "Unknown Type"}`
|
||||
: "No Active Tournament"}
|
||||
{`Tournament Active - ${tournamentType ?? "Unknown Type"}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{gameList.length} match{gameList.length !== 1 ? "es" : ""} running
|
||||
@@ -470,9 +536,31 @@ export default function SpectatePage() {
|
||||
</h2>
|
||||
{showKnockoutBracket ? (
|
||||
knockoutBracket.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-gray-700 bg-gray-950/60 px-4 py-6 text-center text-sm text-gray-500">
|
||||
Knockout seeding is still in progress. The bracket will appear
|
||||
once tournament data is available.
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg border border-dashed border-gray-700 bg-gray-950/60 px-4 py-6 text-center text-sm text-gray-500">
|
||||
Knockout seeding is still in progress. The bracket will
|
||||
appear once tournament data is available.
|
||||
</div>
|
||||
{seedingMatches.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{seedingMatches.map((match) => {
|
||||
return (
|
||||
<MatchSelectorCard
|
||||
key={match.id}
|
||||
matchId={match.id}
|
||||
player1={match.player1}
|
||||
player2={match.player2}
|
||||
currentTurnColor={match.currentTurnColor}
|
||||
status={match.status}
|
||||
resultKind={match.resultKind}
|
||||
selectedGame={selectedGame}
|
||||
onSelect={setSelectedGame}
|
||||
className="w-full sm:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto pb-2">
|
||||
@@ -489,74 +577,18 @@ export default function SpectatePage() {
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-center gap-3">
|
||||
{round.matches.map((match) => {
|
||||
const isSelected =
|
||||
match.matchId !== null &&
|
||||
selectedGame === match.matchId;
|
||||
const canSelect = match.matchId !== null;
|
||||
const borderClass =
|
||||
match.status === "completed"
|
||||
? "border-green-700/80"
|
||||
: "border-gray-700";
|
||||
|
||||
const Tag = canSelect ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
<MatchSelectorCard
|
||||
key={match.key}
|
||||
{...(canSelect
|
||||
? {
|
||||
onClick: () =>
|
||||
setSelectedGame(
|
||||
isSelected ? null : match.matchId,
|
||||
),
|
||||
type: "button" as const,
|
||||
}
|
||||
: {})}
|
||||
className={`w-full rounded-xl border bg-gray-950/70 p-3 text-left transition-colors ${borderClass} ${
|
||||
canSelect
|
||||
? isSelected
|
||||
? "ring-1 ring-blue-400"
|
||||
: "hover:border-blue-700/80"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<BracketPlayerRow
|
||||
name={match.player1}
|
||||
playerColor={1}
|
||||
isActive={match.currentTurnColor === 1}
|
||||
/>
|
||||
<BracketPlayerRow
|
||||
name={match.player2}
|
||||
playerColor={2}
|
||||
isActive={match.currentTurnColor === 2}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-1 text-xs">
|
||||
<span className="text-gray-500">
|
||||
{match.matchId !== null
|
||||
? `Match #${match.matchId}`
|
||||
: "Awaiting match"}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
match.status === "completed"
|
||||
? "bg-green-950/70 text-green-300"
|
||||
: match.status === "live"
|
||||
? "bg-blue-950/70 text-blue-300"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{match.status === "completed"
|
||||
? match.resultKind === "draw"
|
||||
? "Tie"
|
||||
: "Complete"
|
||||
: match.status === "live"
|
||||
? "Live"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tag>
|
||||
matchId={match.matchId}
|
||||
player1={match.player1}
|
||||
player2={match.player2}
|
||||
currentTurnColor={match.currentTurnColor}
|
||||
status={match.status}
|
||||
resultKind={match.resultKind}
|
||||
selectedGame={selectedGame}
|
||||
onSelect={setSelectedGame}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -570,54 +602,38 @@ export default function SpectatePage() {
|
||||
No active matches
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{gameList.map((g) => {
|
||||
const live = liveGames.get(g.id);
|
||||
const resultKind = live?.result?.kind ?? null;
|
||||
const status = resultKind
|
||||
? "completed"
|
||||
: live
|
||||
? "live"
|
||||
: "scheduled";
|
||||
return (
|
||||
<button
|
||||
<MatchSelectorCard
|
||||
key={g.id}
|
||||
onClick={() =>
|
||||
setSelectedGame(selectedGame === g.id ? null : g.id)
|
||||
matchId={g.id}
|
||||
player1={g.player1}
|
||||
player2={g.player2}
|
||||
currentTurnColor={
|
||||
resultKind ? null : (live?.currentTurnColor ?? null)
|
||||
}
|
||||
className={`px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
selectedGame === g.id
|
||||
? "border-blue-500 bg-blue-950/50 text-blue-200"
|
||||
: "border-gray-700 bg-gray-800 hover:border-gray-500 text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span className="text-gray-500 text-xs font-mono mr-1">
|
||||
#{g.id}
|
||||
</span>
|
||||
<span className="text-red-400">{g.player1}</span>
|
||||
<span className="text-gray-500 mx-1">vs</span>
|
||||
<span className="text-yellow-400">{g.player2}</span>
|
||||
{live?.result && (
|
||||
<span className="ml-1 text-xs">
|
||||
{live.result.kind === "win"
|
||||
? ` 🏆 ${live.result.winner}`
|
||||
: live.result.kind === "draw"
|
||||
? " 🤝"
|
||||
: " ⛔"}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
status={status}
|
||||
resultKind={resultKind}
|
||||
selectedGame={selectedGame}
|
||||
onSelect={setSelectedGame}
|
||||
className="w-full sm:w-[calc(50%-0.375rem)] xl:w-[calc(33.333%-0.5rem)]"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center gap-4 min-h-64">
|
||||
{!selectedGameData ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<span className="text-4xl text-gray-700">🎯</span>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{gameList.length > 0
|
||||
? "Click a match above to see the board"
|
||||
: "Active matches will appear here"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
{selectedGameData && (
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center gap-4 min-h-64">
|
||||
<>
|
||||
{selectedGameData.result && (
|
||||
<div
|
||||
@@ -649,8 +665,8 @@ export default function SpectatePage() {
|
||||
disabled
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -692,6 +708,87 @@ function BracketPlayerRow({
|
||||
);
|
||||
}
|
||||
|
||||
function MatchSelectorCard({
|
||||
matchId,
|
||||
player1,
|
||||
player2,
|
||||
currentTurnColor,
|
||||
status,
|
||||
resultKind,
|
||||
selectedGame,
|
||||
onSelect,
|
||||
className = "w-full",
|
||||
}: {
|
||||
matchId: number | null;
|
||||
player1: string | null;
|
||||
player2: string | null;
|
||||
currentTurnColor: 1 | 2 | null;
|
||||
status: "scheduled" | "live" | "completed";
|
||||
resultKind: KnockoutMatchView["resultKind"];
|
||||
selectedGame: number | null;
|
||||
onSelect: (matchId: number | null) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isSelected = matchId !== null && selectedGame === matchId;
|
||||
const canSelect = matchId !== null;
|
||||
const borderClass =
|
||||
status === "completed" ? "border-green-700/80" : "border-gray-700";
|
||||
const Tag = canSelect ? "button" : "div";
|
||||
|
||||
return (
|
||||
<Tag
|
||||
{...(canSelect
|
||||
? {
|
||||
onClick: () => onSelect(isSelected ? null : matchId),
|
||||
type: "button" as const,
|
||||
}
|
||||
: {})}
|
||||
className={`${className} rounded-xl border bg-gray-950/70 p-3 text-left transition-colors ${borderClass} ${
|
||||
canSelect
|
||||
? isSelected
|
||||
? "ring-1 ring-blue-400"
|
||||
: "hover:border-blue-700/80"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<BracketPlayerRow
|
||||
name={player1}
|
||||
playerColor={1}
|
||||
isActive={currentTurnColor === 1}
|
||||
/>
|
||||
<BracketPlayerRow
|
||||
name={player2}
|
||||
playerColor={2}
|
||||
isActive={currentTurnColor === 2}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-1 text-xs">
|
||||
<span className="text-gray-500">
|
||||
{matchId !== null ? `Match #${matchId}` : "Awaiting match"}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 ${
|
||||
status === "completed"
|
||||
? "bg-green-950/70 text-green-300"
|
||||
: status === "live"
|
||||
? "bg-blue-950/70 text-blue-300"
|
||||
: "bg-gray-800 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{status === "completed"
|
||||
? resultKind === "draw"
|
||||
? "Tie"
|
||||
: "Complete"
|
||||
: status === "live"
|
||||
? "Live"
|
||||
: "Pending"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function isKnockoutDataMessage(raw: string) {
|
||||
return !raw.includes(":") && (raw.includes(",") || raw.includes("|"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user