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 ? (
{`weapons:SPECIAL_${SPLATTERCOLOR_SCREEN_ID}`}
) : 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 (