|
| 1 | +import React, { useContext } from "react"; |
| 2 | +import { Link } from "react-router-dom"; |
| 3 | +import styled from "styled-components"; |
| 4 | +import { Declaration, SchemaClass, SchemaEnum } from "./api"; |
| 5 | +import { DeclarationsContext } from "./DeclarationsContext"; |
| 6 | +import { GAMES, GameId } from "~games"; |
| 7 | + |
| 8 | +const Wrapper = styled.div` |
| 9 | + padding: 10px 14px; |
| 10 | + border-top: 1px solid ${(props) => props.theme.groupSeparator}; |
| 11 | +`; |
| 12 | + |
| 13 | +const Title = styled.div` |
| 14 | + font-size: 14px; |
| 15 | + font-weight: 600; |
| 16 | + text-transform: uppercase; |
| 17 | + letter-spacing: 0.04em; |
| 18 | + color: ${(props) => props.theme.textDim}; |
| 19 | + margin-bottom: 6px; |
| 20 | +`; |
| 21 | + |
| 22 | +const RefList = styled.div` |
| 23 | + display: flex; |
| 24 | + flex-wrap: wrap; |
| 25 | + gap: 5px; |
| 26 | +`; |
| 27 | + |
| 28 | +const GameLink = styled(Link)<{ $status: "identical" | "differs" }>` |
| 29 | + display: inline-flex; |
| 30 | + align-items: center; |
| 31 | + gap: 6px; |
| 32 | + padding: 3px 10px; |
| 33 | + border-radius: 6px; |
| 34 | + font-size: 14px; |
| 35 | + text-decoration: none; |
| 36 | + color: ${(props) => props.theme.text}; |
| 37 | + background: ${(props) => props.theme.groupMembers}; |
| 38 | + border: 1px solid ${(props) => props.$status === "identical" ? props.theme.groupBorder : "#c97a1e"}; |
| 39 | + transition: border-color 0.1s; |
| 40 | +
|
| 41 | + &:hover { |
| 42 | + border-color: ${(props) => props.theme.highlight}; |
| 43 | + } |
| 44 | +`; |
| 45 | + |
| 46 | +const StatusDot = styled.span<{ $status: "identical" | "differs" }>` |
| 47 | + width: 8px; |
| 48 | + height: 8px; |
| 49 | + border-radius: 50%; |
| 50 | + background-color: ${(props) => props.$status === "identical" ? "#4a8c2a" : "#c97a1e"}; |
| 51 | + flex-shrink: 0; |
| 52 | +`; |
| 53 | + |
| 54 | +function areClassesEqual(a: SchemaClass, b: SchemaClass): boolean { |
| 55 | + if (a.parents.length !== b.parents.length) return false; |
| 56 | + for (let i = 0; i < a.parents.length; i++) { |
| 57 | + if (a.parents[i].name !== b.parents[i].name || a.parents[i].module !== b.parents[i].module) return false; |
| 58 | + } |
| 59 | + if (a.fields.length !== b.fields.length) return false; |
| 60 | + for (let i = 0; i < a.fields.length; i++) { |
| 61 | + if (a.fields[i].name !== b.fields[i].name || a.fields[i].offset !== b.fields[i].offset) return false; |
| 62 | + if (JSON.stringify(a.fields[i].type) !== JSON.stringify(b.fields[i].type)) return false; |
| 63 | + } |
| 64 | + if (JSON.stringify(a.metadata) !== JSON.stringify(b.metadata)) return false; |
| 65 | + return true; |
| 66 | +} |
| 67 | + |
| 68 | +function areEnumsEqual(a: SchemaEnum, b: SchemaEnum): boolean { |
| 69 | + if (a.alignment !== b.alignment) return false; |
| 70 | + if (a.members.length !== b.members.length) return false; |
| 71 | + for (let i = 0; i < a.members.length; i++) { |
| 72 | + if (a.members[i].name !== b.members[i].name || a.members[i].value !== b.members[i].value) return false; |
| 73 | + } |
| 74 | + if (JSON.stringify(a.metadata) !== JSON.stringify(b.metadata)) return false; |
| 75 | + return true; |
| 76 | +} |
| 77 | + |
| 78 | +function areDeclarationsEqual(a: Declaration, b: Declaration): boolean { |
| 79 | + if (a.kind !== b.kind) return false; |
| 80 | + if (a.kind === "class" && b.kind === "class") return areClassesEqual(a, b); |
| 81 | + if (a.kind === "enum" && b.kind === "enum") return areEnumsEqual(a, b); |
| 82 | + return false; |
| 83 | +} |
| 84 | + |
| 85 | +export function CrossGameRefs({ name, module, kind }: { name: string; module: string; kind: "class" | "enum" }) { |
| 86 | + const { game, allGames, declarations } = useContext(DeclarationsContext); |
| 87 | + if (!allGames) return null; |
| 88 | + |
| 89 | + const current = declarations.find((d) => d.name === name && d.module === module && d.kind === kind); |
| 90 | + if (!current) return null; |
| 91 | + |
| 92 | + const otherGames: { gameId: GameId; gameName: string; status: "identical" | "differs"; module: string }[] = []; |
| 93 | + |
| 94 | + for (const [gameId, gameDeclarations] of allGames) { |
| 95 | + if (gameId === game) continue; |
| 96 | + const match = gameDeclarations.find((d) => d.name === name && d.kind === kind); |
| 97 | + if (match) { |
| 98 | + const gameInfo = GAMES.find((g) => g.id === gameId); |
| 99 | + otherGames.push({ |
| 100 | + gameId, |
| 101 | + gameName: gameInfo?.name ?? gameId, |
| 102 | + status: areDeclarationsEqual(current, match) ? "identical" : "differs", |
| 103 | + module: match.module, |
| 104 | + }); |
| 105 | + } |
| 106 | + } |
| 107 | + |
| 108 | + if (otherGames.length === 0) return null; |
| 109 | + |
| 110 | + return ( |
| 111 | + <Wrapper> |
| 112 | + <Title>Also in</Title> |
| 113 | + <RefList> |
| 114 | + {otherGames.map(({ gameId, gameName, status, module: otherModule }) => ( |
| 115 | + <GameLink key={gameId} to={`/${gameId}/${otherModule}/${name}`} $status={status} title={status === "identical" ? "Identical" : "Differs"}> |
| 116 | + <StatusDot $status={status} /> |
| 117 | + {gameName} |
| 118 | + </GameLink> |
| 119 | + ))} |
| 120 | + </RefList> |
| 121 | + </Wrapper> |
| 122 | + ); |
| 123 | +} |
0 commit comments