Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 33 additions & 13 deletions components/SanctionStatusBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,46 @@ import { computed } from "vue";
import { useI18n } from "vue-i18n";
import FiveStackToolTip from "~/components/FiveStackToolTip.vue";

type SanctionType = "ban" | "mute" | "gag" | "silence";

// `active` is every sanction in force: the pill still shows only `type` (the
// worst), so a row keeps one geometry however many apply, and counts the rest.
const props = withDefaults(
defineProps<{
type: "ban" | "mute" | "gag" | "silence";
type: SanctionType;
active?: SanctionType[];
variant?: "inline" | "overlay";
}>(),
{
active: undefined,
variant: "inline",
},
);

const { t } = useI18n();

const LABEL_KEYS: Record<SanctionType, string> = {
ban: "banned",
mute: "muted",
gag: "gagged",
silence: "silenced",
};

const tone = computed<"ban" | "warn">(() =>
props.type === "ban" ? "ban" : "warn",
);

const label = computed(() => {
const key = {
ban: "banned",
mute: "muted",
gag: "gagged",
silence: "silenced",
}[props.type];
return t(`player.sanctions.${key}`);
});
const label = computed(() => t(`player.sanctions.${LABEL_KEYS[props.type]}`));

const extra = computed(() =>
(props.active ?? []).filter((kind) => kind !== props.type),
);

const activeList = computed(() =>
[props.type, ...extra.value]
.map((kind) => t(`player.sanctions.${LABEL_KEYS[kind]}`))
.join(", "),
);
</script>

<template>
Expand Down Expand Up @@ -60,24 +75,29 @@ const label = computed(() => {
<FiveStackToolTip v-else as-child :delay-duration="120">
<template #trigger>
<span
class="inline-flex items-center gap-1 rounded-sm border px-1 py-0.5 font-mono text-[0.55rem] font-bold uppercase leading-none tracking-wider"
class="inline-flex shrink-0 items-center gap-1 rounded-sm border px-1 py-0.5 font-mono text-[0.55rem] font-bold uppercase leading-none tracking-wider"
:class="
tone === 'ban'
? 'border-destructive/50 bg-destructive/15 text-destructive'
: 'border-[hsl(var(--tac-amber)/0.5)] bg-[hsl(var(--tac-amber)/0.12)] text-[hsl(var(--tac-amber))]'
"
:tabindex="active ? 0 : undefined"
>
<span class="relative flex h-1 w-1 shrink-0">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75"
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-75 motion-reduce:animate-none"
></span>
<span
class="relative inline-flex h-1 w-1 rounded-full bg-current"
></span>
</span>
{{ label }}
<span v-if="extra.length" class="opacity-75">+{{ extra.length }}</span>
</span>
</template>
{{ $t("player.sanctions.title") }}
<template v-if="active">
{{ $t("player.sanctions.active_list", { list: activeList }) }}
</template>
<template v-else>{{ $t("player.sanctions.title") }}</template>
</FiveStackToolTip>
</template>
57 changes: 57 additions & 0 deletions components/community/CommunityIp.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { Link2 } from "lucide-vue-next";
import FiveStackToolTip from "~/components/FiveStackToolTip.vue";

const props = defineProps<{
ip?: string | null;
others?: string[];
scope: "now" | "week";
}>();

const { t } = useI18n();

const shared = computed(() => (props.others?.length ?? 0) > 0);

const tooltip = computed(() =>
t(
props.scope === "now"
? "community.ip.shared_now"
: "community.ip.shared_week",
{ names: (props.others ?? []).join(", ") },
),
);
</script>

<template>
<span
v-if="!ip"
class="font-mono text-xs text-muted-foreground/60"
aria-hidden="true"
>—</span
>
<FiveStackToolTip v-else-if="shared" as-child :delay-duration="120">
<template #trigger>
<span
class="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap font-mono text-xs text-[hsl(var(--tac-amber))]"
tabindex="0"
:aria-label="`${ip}. ${tooltip}`"
data-shared-ip
>
<Link2 class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ ip }}</span>
<span
class="rounded-sm bg-[hsl(var(--tac-amber)/0.12)] px-1 text-[0.65rem]"
>×{{ (others?.length ?? 0) + 1 }}</span
>
</span>
</template>
{{ tooltip }}
</FiveStackToolTip>
<span
v-else
class="block truncate whitespace-nowrap font-mono text-xs text-muted-foreground"
>{{ ip }}</span
>
</template>
119 changes: 119 additions & 0 deletions components/community/CommunityPlayerCell.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed } from "vue";
import { User } from "lucide-vue-next";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
import SanctionStatusBadge from "~/components/SanctionStatusBadge.vue";
import PlayerVacBadge from "~/components/PlayerVacBadge.vue";
import { resolveAvatarUrl } from "~/utilities/avatarUrl";
import { activeSanctions } from "~/utilities/communityStats";

// One geometry for every row: a state never adds a tag or a line, it only
// changes what an existing slot shows (the one sanction pill, the avatar, the
// sub-line), so a roster of mixed players stays aligned.
const props = defineProps<{
steamId: string;
name?: string | null;
player?: Record<string, any> | null;
detail?: string | null;
dim?: boolean;
}>();

const apiDomain = useRuntimeConfig().public.apiDomain;

// A players row exists for anyone 5stack has ever seen or imported; only a
// registered one has signed in and has an account worth linking to.
const hasAccount = computed(() => !!props.player?.is_registered);

const displayName = computed(() =>
hasAccount.value
? props.player?.name || props.name || props.steamId
: props.name || props.player?.name || props.steamId,
);

const inGameName = computed(() =>
hasAccount.value && props.name && props.name !== props.player?.name
? props.name
: null,
);

const avatarSrc = computed(() =>
resolveAvatarUrl(props.player?.avatar_url, apiDomain),
);

const sanctions = computed(() => activeSanctions(props.player));

const profileLink = computed(() =>
hasAccount.value
? { name: "players-id", params: { id: props.steamId } }
: undefined,
);
</script>

<template>
<div class="flex min-w-0 items-center gap-2.5">
<NuxtLink
v-if="hasAccount"
:to="profileLink"
class="shrink-0"
tabindex="-1"
aria-hidden="true"
>
<Avatar
shape="square"
class="h-8 w-8 transition-[filter] duration-200"
:class="dim && 'brightness-75 grayscale-[0.6]'"
>
<AvatarImage
v-if="avatarSrc"
:src="avatarSrc"
:alt="displayName"
draggable="false"
/>
<AvatarFallback class="text-xs font-bold">
{{ displayName.slice(0, 2) }}
</AvatarFallback>
</Avatar>
</NuxtLink>
<span
v-else
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
data-testid="steam-only-avatar"
>
<User class="h-3.5 w-3.5" />
</span>

<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5">
<NuxtLink
v-if="hasAccount"
:to="profileLink"
class="truncate font-semibold transition-colors hover:text-[hsl(var(--tac-amber))]"
>
{{ displayName }}
</NuxtLink>
<span v-else class="truncate font-medium text-foreground/85">
{{ displayName }}
</span>
<SanctionStatusBadge
v-if="sanctions.length"
:type="sanctions[0]"
:active="sanctions"
/>
<PlayerVacBadge v-if="player" :player="player" />
</div>
<div class="truncate font-mono text-[0.7rem] text-muted-foreground">
<template v-if="!hasAccount">
<span class="text-muted-foreground/80">{{
$t("community.player.steam_only")
}}</span>
· {{ steamId }}
</template>
<template v-else-if="inGameName">
{{ $t("community.player.in_game", { name: inGameName }) }}
</template>
<template v-else>{{ steamId }}</template>
<template v-if="detail"> · {{ detail }}</template>
</div>
</div>
</div>
</template>
83 changes: 83 additions & 0 deletions components/community/CommunityPlayerMenu.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { Copy, History, MoreVertical, User } from "lucide-vue-next";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import SteamIcon from "~/components/icons/SteamIcon.vue";
import { toast } from "@/components/ui/toast";

const props = defineProps<{
steamId: string;
name: string;
hasAccount: boolean;
}>();

const emit = defineEmits<{ (e: "history"): void }>();

const { t } = useI18n();
const router = useRouter();

function viewProfile() {
void router.push({ name: "players-id", params: { id: props.steamId } });
}

async function copySteamId() {
if (!navigator.clipboard?.writeText) {
return;
}

await navigator.clipboard.writeText(props.steamId);

toast({ title: t("player.context_menu.steam_id_copied") });
}

function openSteamProfile() {
window.open(
`https://steamcommunity.com/profiles/${props.steamId}`,
"_blank",
"noopener",
);
}
</script>

<template>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button
variant="outline"
size="icon"
class="shrink-0"
:aria-label="t('community.player.more', { name })"
>
<MoreVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<DropdownMenuLabel class="truncate">{{ name }}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem @select="emit('history')">
<History />
<span>{{ $t("community.player.sanction_history") }}</span>
</DropdownMenuItem>
<DropdownMenuItem v-if="hasAccount" @select="viewProfile">
<User />
<span>{{ $t("player.context_menu.view_profile") }}</span>
</DropdownMenuItem>
<DropdownMenuItem @select="copySteamId">
<Copy />
<span>{{ $t("player.context_menu.copy_steam_id") }}</span>
</DropdownMenuItem>
<DropdownMenuItem @select="openSteamProfile">
<SteamIcon class="size-4 fill-current" />
<span>{{ $t("ui.tooltips.view_steam_profile") }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
Loading
Loading