import clsx from "clsx";
import type { SqlBool } from "kysely";
import { Mic, PenSquare, Star, Trash, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
import { LocaleTime } from "~/components/LocaleTime";
import { SubmitButton } from "~/components/SubmitButton";
import type { ParsedMemento } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { languagesUnified } from "~/modules/i18n/config";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import {
navIconUrl,
SENDOUQ_LOOKING_PAGE,
specialWeaponImageUrl,
TIERS_PAGE,
tierImageUrl,
userPage,
} from "~/utils/urls";
import type {
SQGroup,
SQGroupMember,
SQOwnGroup,
} from "../core/SendouQ.server";
import {
FULL_GROUP_SIZE,
IS_Q_LOOKING_MOBILE_BREAKPOINT,
SENDOUQ,
} from "../q-constants";
import { resolveFutureMatchModes } from "../q-utils";
import styles from "./GroupCard.module.css";
const SENTIMENT_STYLES = {
POSITIVE: styles.avatarPositive,
NEUTRAL: styles.avatarNeutral,
NEGATIVE: styles.avatarNegative,
} as const;
export function GroupCard({
group,
action,
displayOnly = false,
hideVc = false,
hideWeapons = false,
hideNote: _hidenote = false,
showAddNote,
showNote = false,
ownGroup,
}: {
group: SQGroup | SQOwnGroup;
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP" | "MATCH_UP_RECHALLENGE";
displayOnly?: boolean;
hideVc?: SqlBool;
hideWeapons?: SqlBool;
hideNote?: boolean;
showAddNote?: SqlBool;
showNote?: boolean;
ownGroup?: SQOwnGroup;
}) {
const { t } = useTranslation(["q"]);
const user = useUser();
const fetcher = useFetcher();
const hideNote =
displayOnly ||
!group.members ||
group.members.length === FULL_GROUP_SIZE ||
_hidenote;
const isOwnGroup = group.id === ownGroup?.id;
const futureMatchModes = ownGroup
? resolveFutureMatchModes(ownGroup, group)
: null;
const enableKicking = group.usersRole === "OWNER" && !displayOnly;
return (
{group.members ? (
{group.members.map((member) => {
return (
);
})}
) : null}
{futureMatchModes && !group.members ? (
{futureMatchModes.map((mode) => {
return (
);
})}
{group.noScreen ? (
) : null}
) : null}
{group.tier &&
(!group.members || group.members.length === FULL_GROUP_SIZE) ? (
{group.tier.name}
{group.tier.isPlus ? "+" : ""}{" "}
{group.isReplay ? (
<>
/{" "}
{t("q:looking.replay")}
>
) : null}
) : null}
{group.tier && displayOnly && !group.members ? (
{group.tier.name}
{group.tier.isPlus ? "+" : ""}
) : null}
{group.tierRange ? (
{group.tierRange.diff[0] ? (
({group.tierRange.diff[0]})
) : null}
{/** in preview mode they don't see full group tiers (because they don't have a group to compare against) so it is a "true range" */}
{group.tierRange.diff[0] ? (
{t("q:looking.range.or")}
}
>
{t("q:looking.range.or.explanation")}
) : (
"—"
)}
{group.tierRange.diff[1] ? (
(+{group.tierRange.diff[1]})
) : null}
{group.isReplay ? (
{t("q:looking.replay")}
) : null}
) : null}
{group.skillDifference ? (
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" ||
ownGroup?.usersRole === "MANAGER") ? (
{action === "MATCH_UP" || action === "MATCH_UP_RECHALLENGE"
? t("q:looking.groups.actions.startMatch")
: action === "LIKE" && !group.members
? t("q:looking.groups.actions.challenge")
: action === "LIKE"
? t("q:looking.groups.actions.invite")
: action === "GROUP_UP"
? t("q:looking.groups.actions.groupUp")
: t("q:looking.groups.actions.undo")}
) : null}
);
}
function GroupCardContainer({
isOwnGroup,
groupId,
children,
}: {
isOwnGroup: boolean;
groupId: number;
children: React.ReactNode;
}) {
const width = useMainContentWidth();
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
// we don't want it to animate
if (isOwnGroup) return <>{children}>;
return {children} ;
}
function GroupMember({
member,
showActions,
displayOnly,
hideVc,
hideWeapons,
hideNote,
enableKicking,
showAddNote,
showNote,
}: {
member: SQGroupMember;
showActions: boolean;
displayOnly?: boolean;
hideVc?: SqlBool;
hideWeapons?: SqlBool;
hideNote?: boolean;
enableKicking?: boolean;
showAddNote?: SqlBool;
showNote?: boolean;
}) {
const { t } = useTranslation(["q", "user"]);
const user = useUser();
return (
{showNote && member.privateNote ? (
}
>
{member.privateNote.text}
) : (
)}
{member.inGameName ? (
<>
{t("user:ign.short")}:
{" "}
{inGameNameWithoutDiscriminator(member.inGameName)}
>
) : (
member.username
)}
{member.pronouns ? (
{member.pronouns.subject}/{member.pronouns.object}
) : null}
{showActions || displayOnly ? (
) : null}
{member.skill ? : null}
{member.vc && !hideVc ? (
) : null}
{member.plusTier ? (
{member.plusTier}
) : null}
{member.friendCode ? (
FC
}
>
SW-{member.friendCode}
) : null}
{showAddNote ? (
}
className={clsx(styles.addNoteButton, {
[styles.addNoteButtonEdit]: member.privateNote,
})}
>
{member.privateNote
? t("q:looking.groups.editNote")
: t("q:looking.groups.addNote")}
) : null}
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
{member.weapons?.map((weapon) => {
return (
);
})}
) : null}
{member.skillDifference ? (
) : null}
{!hideNote ? (
) : null}
);
}
function MemberNote({
note,
editable,
}: {
note?: string | null;
editable: boolean;
}) {
const { t } = useTranslation(["common", "q"]);
const [editing, setEditing] = React.useState(false);
const startEditing = () => {
setEditing(true);
};
// biome-ignore lint/correctness/useExhaustiveDependencies(note): when note updates exit editing mode
React.useEffect(() => {
setEditing(false);
}, [note]);
if (editing) {
return (
setEditing(false)} />
);
}
if (note) {
return (
{note}{" "}
{editable ? (
{t("q:looking.groups.editNote")}
) : null}
);
}
if (!editable) return null;
return (
{t("q:looking.groups.addNote")}
);
}
function AddPrivateNoteForm({
note,
stopEditing,
}: {
note?: string | null;
stopEditing: () => void;
}) {
const fetcher = useFetcher();
const textareaRef = React.useRef(null);
const { t } = useTranslation(["common"]);
const [value, setValue] = React.useState(note ?? "");
const newValueLegal = value.length <= SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH;
React.useEffect(() => {
if (!textareaRef.current) return;
textareaRef.current.focus();
textareaRef.current.selectionStart = textareaRef.current.selectionEnd =
textareaRef.current.value.length;
}, []);
return (
);
}
function DeletePrivateNoteForm({
targetId,
name,
}: {
targetId: number;
name: string;
}) {
const { t } = useTranslation(["q"]);
return (
);
}
function GroupSkillDifference({
skillDifference,
}: {
skillDifference: NonNullable<
ParsedMemento["groups"][number]["skillDifference"]
>;
}) {
const { t } = useTranslation(["q"]);
if (skillDifference.calculated) {
return (
{t("q:looking.teamSP")} {skillDifference.oldSp} ➜{" "}
{skillDifference.newSp}
);
}
if (skillDifference.newSp) {
return (
{t("q:looking.teamSP.calculated")}: {skillDifference.newSp}
);
}
return (
{t("q:looking.teamSP.calculating")} ({skillDifference.matchesCount}/
{skillDifference.matchesCountNeeded})
);
}
function MemberSkillDifference({
skillDifference,
}: {
skillDifference: NonNullable<
ParsedMemento["users"][number]["skillDifference"]
>;
}) {
const { t } = useTranslation(["q"]);
if (skillDifference.calculated) {
if (skillDifference.spDiff === 0) return null;
const symbol =
skillDifference.spDiff > 0 ? (
▲
) : (
▼
);
return (
{symbol}
{Math.abs(skillDifference.spDiff)}SP
);
}
if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) {
return (
{t("q:looking.sp.calculated")}: {" "}
{skillDifference.newSp ? <>{skillDifference.newSp}SP> : null}
);
}
return (
{t("q:looking.sp.calculating")} (
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
);
}
function MemberRoleManager({
member,
displayOnly,
enableKicking,
}: {
member: Pick;
displayOnly?: boolean;
enableKicking?: boolean;
}) {
const loggedInUser = useUser();
const fetcher = useFetcher();
const { t } = useTranslation(["q"]);
if (displayOnly && member.role !== "OWNER") return null;
return (
}
/>
}
>
{t(`q:roles.${member.role}`)}
{member.role !== "OWNER" && !displayOnly ? (
{member.role === "REGULAR" ? (
{t("q:looking.groups.actions.giveManager")}
) : null}
{member.role === "MANAGER" ? (
{t("q:looking.groups.actions.removeManager")}
) : null}
{enableKicking && member.id !== loggedInUser?.id ? (
{t("q:looking.groups.actions.kick")}
) : null}
) : null}
);
}
function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
const { t } = useTranslation(["q"]);
if (skill === "CALCULATING") {
return (
}
>
{t("q:looking.rankCalculating", {
count: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
})}
);
}
return (
}
>
{skill.tier.name}
{skill.tier.isPlus ? "+" : ""}
{t("q:looking.allTiers")}
{!skill.approximate ? (
{" "}
{ordinalToRoundedSp(skill.ordinal)}
SP
) : null}
);
}
function VoiceChatInfo({
member,
}: {
member: Pick;
}) {
const user = useUser();
const { t } = useTranslation(["q"]);
if (!member.languages || !member.vc) return null;
const Icon =
member.vc === "YES" ? Mic : member.vc === "LISTEN_ONLY" ? Volume2 : VolumeX;
const iconTestId =
member.vc === "YES"
? "microphone-icon"
: member.vc === "LISTEN_ONLY"
? "speaker-icon"
: "speaker-x-icon";
const color = () => {
const languagesMatch =
// small hack to show green for yourself always to avoid confusion
// might show red because root loaders don't reload
// till there is a full page refresh
member.id === user?.id ||
member.languages?.some((l) => user?.languages.includes(l));
if (!languagesMatch) return "text-error";
return member.vc === "YES"
? "text-success"
: member.vc === "LISTEN_ONLY"
? "text-warning"
: "text-error";
};
const languageToFull = (code: string) =>
languagesUnified.find((l) => l.code === code)?.name ?? "";
const languagesString =
member.languages.length > 0
? `(${member.languages.map(languageToFull).join(", ")})`
: null;
return (
}
/>
}
>
{t(`q:vc.${member.vc}`)} {languagesString}
);
}