wip: rewrite playerlist

This commit is contained in:
pa
2026-01-07 16:20:52 +09:00
committed by Natsumi
parent 9ae8789d14
commit 6cfefb50ab
6 changed files with 641 additions and 346 deletions

View File

@@ -12,7 +12,8 @@
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:class="getHeaderClass(header)">
:class="getHeaderClass(header)"
:style="getPinnedStyle(header.column, true)">
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
@@ -23,11 +24,12 @@
<TableBody>
<template v-if="table.getRowModel().rows?.length">
<template v-for="row in table.getRowModel().rows" :key="row.id">
<TableRow>
<TableRow @click="handleRowClick(row)">
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="getCellClass(cell)">
:class="getCellClass(cell)"
:style="getPinnedStyle(cell.column, false)">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</TableCell>
</TableRow>
@@ -56,7 +58,7 @@
</div>
</div>
<div class="mt-4 flex w-full items-center gap-3">
<div v-if="showPagination" class="mt-4 flex w-full items-center gap-3">
<div v-if="pageSizes.length" class="inline-flex items-center flex-1 justify-end gap-2">
<span class="text-xs text-muted-foreground">{{ t('table.pagination.rows_per_page') }}</span>
<Select v-model="pageSizeValue">
@@ -137,6 +139,10 @@
type: String,
default: 'No results.'
},
showPagination: {
type: Boolean,
default: true
},
onPageSizeChange: {
type: Function,
default: null
@@ -144,6 +150,10 @@
onPageChange: {
type: Function,
default: null
},
onRowClick: {
type: Function,
default: null
}
});
@@ -197,7 +207,9 @@
const getCellClass = (cell) => {
const columnDef = cell?.column?.columnDef;
const meta = columnDef?.meta ?? {};
const isPinned = Boolean(cell?.column?.getIsPinned?.());
return joinClasses(
isPinned ? 'bg-background' : null,
resolveClassValue(meta.class, cell?.getContext?.()),
resolveClassValue(meta.cellClass, cell?.getContext?.()),
resolveClassValue(meta.tdClass, cell?.getContext?.()),
@@ -206,6 +218,31 @@
);
};
const getPinnedStyle = (column, isHeader) => {
const pinned = column?.getIsPinned?.();
if (!pinned) return null;
const style = {
position: 'sticky',
zIndex: isHeader ? 30 : 20
};
const size = column?.getSize?.();
if (Number.isFinite(size)) {
style.width = `${size}px`;
}
if (pinned === 'left') {
const left = column?.getStart?.('left');
if (Number.isFinite(left)) style.left = `${left}px`;
} else if (pinned === 'right') {
const right = column?.getAfter?.('right');
if (Number.isFinite(right)) style.right = `${right}px`;
}
return style;
};
const handlePageSizeChange = (size) => {
if (props.onPageSizeChange) {
props.onPageSizeChange(size);
@@ -214,7 +251,7 @@
};
const pageSizeProxy = computed({
get: () => props.table.getState().pagination.pageSize,
get: () => props.table.getState?.().pagination?.pageSize ?? 0,
set: (size) => handlePageSizeChange(size)
});
@@ -224,7 +261,7 @@
});
const currentPage = computed({
get: () => props.table.getState().pagination.pageIndex + 1,
get: () => (props.table.getState?.().pagination?.pageIndex ?? 0) + 1,
set: (page) => {
props.table.setPageIndex(page - 1);
if (props.onPageChange) {
@@ -232,4 +269,9 @@
}
}
});
const handleRowClick = (row) => {
if (!props.onRowClick) return;
props.onRowClick(row);
};
</script>

View File

@@ -1,13 +1,31 @@
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
export function useDataTableScrollHeight(containerRef, options = {}) {
const offset = options.offset ?? 127;
const toolbarHeight = options.toolbarHeight ?? 0;
const paginationHeight = options.paginationHeight ?? 0;
const {
offset = 127,
toolbarHeight = 0,
paginationHeight = 0,
extraOffsetRefs = [],
subtractContainerPadding = false
} = options;
const maxHeight = ref(0);
let resizeObserver;
const observedElements = new Set();
const getPadding = (el) => {
if (!subtractContainerPadding || !el) {
return 0;
}
const style = getComputedStyle(el);
return (Number.parseFloat(style.paddingTop) || 0) + (Number.parseFloat(style.paddingBottom) || 0);
};
const getHeight = (maybeRef) => {
const el = maybeRef?.value;
return el && typeof el.getBoundingClientRect === 'function' ? el.getBoundingClientRect().height : 0;
};
const recalc = () => {
const containerEl = containerRef?.value;
@@ -15,38 +33,68 @@ export function useDataTableScrollHeight(containerRef, options = {}) {
return;
}
const extraOffset = extraOffsetRefs.reduce((sum, ref) => sum + getHeight(ref), 0);
const available =
containerEl.clientHeight -
getPadding(containerEl) -
offset -
toolbarHeight -
paginationHeight;
paginationHeight -
extraOffset;
maxHeight.value = Math.max(0, available);
};
onMounted(() => {
recalc();
resizeObserver = new ResizeObserver(() => {
recalc();
});
if (containerRef?.value) {
resizeObserver.observe(containerRef.value);
const updateObservedElements = () => {
if (!resizeObserver) {
return;
}
const nextObserved = new Set([
containerRef?.value,
...extraOffsetRefs.map((ref) => ref?.value)
].filter(Boolean));
for (const el of observedElements) {
if (!nextObserved.has(el)) {
resizeObserver.unobserve(el);
observedElements.delete(el);
}
}
for (const el of nextObserved) {
if (!observedElements.has(el)) {
resizeObserver.observe(el);
observedElements.add(el);
}
}
};
onMounted(() => {
resizeObserver = new ResizeObserver(recalc);
updateObservedElements();
recalc();
});
watch(
() => [containerRef?.value, ...extraOffsetRefs.map((r) => r?.value)],
() => {
updateObservedElements();
recalc();
},
{ flush: 'post' }
);
onUnmounted(() => {
resizeObserver?.disconnect();
observedElements.clear();
});
const tableStyle = computed(() => {
if (!Number.isFinite(maxHeight.value) || maxHeight.value <= 0) {
return undefined;
}
return {
maxHeight: `${maxHeight.value}px`
};
if (!Number.isFinite(maxHeight.value) || maxHeight.value <= 0) return undefined;
return { maxHeight: `${maxHeight.value}px` };
});
return {

View File

@@ -84,23 +84,6 @@ export const useInstanceStore = defineStore('Instance', () => {
const currentInstanceUsersData = ref([]);
const currentInstanceUsersTableProps = reactive({
stripe: true,
size: 'small',
defaultSort: {
prop: 'timer',
order: 'descending'
}
});
const currentInstanceUsersTable = computed(() => {
return {
data: currentInstanceWorld.value.ref.id
? currentInstanceUsersData.value
: [],
tableProps: currentInstanceUsersTableProps
};
});
watch(
() => watchState.isLoggedIn,
(isLoggedIn) => {
@@ -1229,7 +1212,7 @@ export const useInstanceStore = defineStore('Instance', () => {
previousInstancesInfoDialogVisible,
previousInstancesInfoDialogInstanceId,
instanceJoinHistory,
currentInstanceUsersTable,
currentInstanceUsersData,
applyInstance,
updateCurrentInstanceWorld,

View File

@@ -12,7 +12,7 @@
<ResizableHandle :disabled="isNavCollapsed" class="opacity-0"></ResizableHandle>
<ResizablePanel :default-size="mainDefaultSize" :order="2">
<RouterView v-slot="{ Component }">
<KeepAlive include="Feed,GameLog,PlayerList">
<KeepAlive include="Feed,GameLog,Search">
<component :is="Component" />
</KeepAlive>
</RouterView>

View File

@@ -1,7 +1,11 @@
<template>
<div class="x-container" ref="playerListRef">
<div style="display: flex; flex-direction: column; height: 100%">
<div v-if="currentInstanceWorld.ref.id" style="display: flex; height: 120px">
<div class="flex h-full min-h-0 flex-col">
<div
v-if="currentInstanceWorld.ref.id"
ref="playerListHeaderRef"
style="display: flex; height: 120px"
class="mb-7">
<img
:src="currentInstanceWorld.ref.thumbnailImageUrl"
class="x-link"
@@ -171,217 +175,19 @@
</div>
</div>
<div v-if="photonLoggingEnabled" style="margin-bottom: 10px">
<div v-if="photonLoggingEnabled" ref="playerListPhotonRef" style="margin-bottom: 10px">
<PhotonEventTable @show-chatbox-blacklist="showChatboxBlacklistDialog" />
</div>
<div class="current-instance-table">
<DataTable
v-bind="currentInstanceUsersTable"
layout="table"
style="margin-top: 10px; cursor: pointer"
@row-click="selectCurrentInstanceRow">
<el-table-column :label="t('table.playerList.avatar')" width="70" prop="photo" fixed>
<template #default="scope">
<div v-if="userImage(scope.row.ref)" class="flex items-center pl-2">
<img :src="userImage(scope.row.ref)" class="friends-list-avatar" loading="lazy" />
</div>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.timer')" width="90" prop="timer" sortable fixed>
<template #default="scope">
<Timer :epoch="scope.row.timer" />
</template>
</el-table-column>
<el-table-column
class="table-user"
:label="t('table.playerList.displayName')"
width="200"
prop="displayName"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'displayName')"
fixed>
<template #default="scope">
<span
v-if="randomUserColours"
:style="{ color: scope.row.ref.$userColour }"
v-text="scope.row.ref.displayName"></span>
<span v-else v-text="scope.row.ref.displayName"></span>
</template>
</el-table-column>
<el-table-column
:label="t('table.playerList.rank')"
width="110"
prop="$trustSortNum"
:sortable="true">
<template #default="scope">
<span
class="name"
:class="scope.row.ref.$trustClass"
v-text="scope.row.ref.$trustLevel"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.status')" min-width="200" prop="ref.status">
<template #default="scope">
<template v-if="scope.row.ref.status">
<i
class="x-user-status"
:class="statusClass(scope.row.ref.status)"
style="margin-right: 3px"></i>
<span v-text="scope.row.ref.statusDescription"></span>
<!--//- el-table-column(label="Group" min-width="180" prop="groupOnNameplate" sortable)-->
<!--//- template(v-once #default="scope")-->
<!--//- span(v-text="scope.row.groupOnNameplate")-->
</template>
</template>
</el-table-column>
<el-table-column
v-if="photonLoggingEnabled"
:label="t('table.playerList.photonId')"
width="110"
prop="photonId"
sortable>
<template #default="scope">
<template v-if="chatboxUserBlacklist.has(scope.row.ref.id)">
<TooltipWrapper side="left" content="Unblock chatbox messages">
<el-button
text
:icon="Mute"
size="small"
style="color: red; margin-right: 5px"
@click.stop="deleteChatboxUserBlacklist(scope.row.ref.id)"></el-button>
</TooltipWrapper>
</template>
<template v-else>
<TooltipWrapper side="left" content="Block chatbox messages">
<el-button
text
:icon="Microphone"
size="small"
style="margin-right: 5px"
@click.stop="addChatboxUserBlacklist(scope.row.ref)"></el-button>
</TooltipWrapper>
</template>
<span v-text="scope.row.photonId"></span>
</template>
</el-table-column>
<el-table-column
:label="t('table.playerList.icon')"
prop="isMaster"
width="90"
align="center"
sortable
:sort-method="sortInstanceIcon">
<template #default="scope">
<span></span>
<TooltipWrapper v-if="scope.row.isMaster" side="left" content="Instance Master">
<span>👑</span>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.isModerator" side="left" content="Moderator">
<span>⚔️</span>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.isFriend" side="left" content="Friend">
<span>💚</span>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.isBlocked" side="left" content="Blocked">
<el-icon style="color: red"><CircleClose /></el-icon>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.isMuted" side="left" content="Muted">
<el-icon style="color: var(--el-color-warning)"><Mute /></el-icon>
</TooltipWrapper>
<TooltipWrapper
v-if="scope.row.isAvatarInteractionDisabled"
side="left"
content="Avatar Interaction Disabled
">
<el-icon style="color: var(--el-color-warning)"><Pointer /></el-icon>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.isChatBoxMuted" side="left" content="Chatbox Muted">
<el-icon style="color: var(--el-color-warning)"><ChatLineRound /></el-icon>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.timeoutTime" side="left" content="Timeout">
<span style="color: var(--el-color-danger)">🔴{{ scope.row.timeoutTime }}s</span>
</TooltipWrapper>
<TooltipWrapper v-if="scope.row.ageVerified" side="left" content="18+ Verified">
<i class="ri-id-card-line"></i>
</TooltipWrapper>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.platform')" prop="inVRMode" width="90">
<template #default="scope">
<template v-if="scope.row.ref.$platform">
<span
v-if="scope.row.ref.$platform === 'standalonewindows'"
style="color: var(--el-color-primary)"
><i class="ri-computer-line"></i
></span>
<span
v-else-if="scope.row.ref.$platform === 'android'"
style="color: var(--el-color-success)"
><i class="ri-android-line"></i
></span>
<span v-else-if="scope.row.ref.$platform === 'ios'" style="color: var(--el-color-info)"
><i class="ri-apple-line"></i
></span>
<span v-else>{{ scope.row.ref.$platform }}</span>
</template>
<template v-if="scope.row.inVRMode !== null">
<span v-if="scope.row.inVRMode">VR</span>
<span
v-else-if="
scope.row.ref.last_platform === 'android' ||
scope.row.ref.last_platform === 'ios'
"
>M</span
>
<span v-else>D</span>
</template>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.language')" width="100" prop="ref.$languages">
<template #default="scope">
<TooltipWrapper v-for="item in scope.row.ref.$languages" :key="item.key" side="top">
<template #content>
<span>{{ item.value }} ({{ item.key }})</span>
</template>
<span
class="flags"
:class="languageClass(item.key)"
style="display: inline-block; margin-right: 5px"></span>
</TooltipWrapper>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.bioLink')" width="100" prop="ref.bioLinks">
<template #default="scope">
<div style="display: flex; align-items: center">
<TooltipWrapper
v-for="(link, index) in scope.row.ref.bioLinks?.filter(Boolean)"
:key="index">
<template #content>
<span v-text="link"></span>
</template>
<img
:src="getFaviconUrl(link)"
style="
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 5px;
cursor: pointer;
"
@click.stop="openExternalLink(link)"
loading="lazy" />
</TooltipWrapper>
</div>
</template>
</el-table-column>
<el-table-column :label="t('table.playerList.note')" width="400" prop="ref.note">
<template #default="scope">
<span v-text="scope.row.ref.note"></span>
</template>
</el-table-column>
</DataTable>
<div class="current-instance-table flex min-h-0 flex-1">
<DataTableLayout
class="[&_th]:px-2.5! [&_th]:py-0.75! [&_td]:px-2.5! [&_td]:py-0.75! [&_tr]:h-7!"
:table="playerListTable"
:table-style="playerListTableStyle"
:loading="false"
:total-items="playerListTotalItems"
:show-pagination="false"
:on-row-click="handlePlayerListRowClick" />
</div>
</div>
<ChatboxBlacklistDialog
@@ -391,20 +197,12 @@
</template>
<script setup>
import { ChatLineRound, CircleClose, HomeFilled, Microphone, Mute, Pointer } from '@element-plus/icons-vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onActivated, onMounted, ref, watch } from 'vue';
import { getCoreRowModel, getPaginationRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table';
import { HomeFilled } from '@element-plus/icons-vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import {
commaNumber,
formatDateFilter,
getFaviconUrl,
languageClass,
openExternalLink,
statusClass,
userImage
} from '../../shared/utils';
import {
useAppearanceSettingsStore,
useGalleryStore,
@@ -414,8 +212,15 @@
useUserStore,
useWorldStore
} from '../../stores';
import { commaNumber, formatDateFilter } from '../../shared/utils';
import { DataTableLayout } from '../../components/ui/data-table';
import { createColumns } from './columns.jsx';
import { useDataTableScrollHeight } from '../../composables/useDataTableScrollHeight';
import { valueUpdater } from '../../components/ui/table/utils';
import { watchState } from '../../service/watchState';
import ChatboxBlacklistDialog from './dialogs/ChatboxBlacklistDialog.vue';
import Timer from '../../components/Timer.vue';
const PhotonEventTable = defineAsyncComponent(() => import('./components/PhotonEventTable.vue'));
@@ -428,65 +233,19 @@
const { lastLocation } = storeToRefs(useLocationStore());
const { currentInstanceLocation, currentInstanceWorld } = storeToRefs(useInstanceStore());
const { getCurrentInstanceUserList } = useInstanceStore();
const { currentInstanceUsersTable } = storeToRefs(useInstanceStore());
const { currentInstanceUsersData } = storeToRefs(useInstanceStore());
const { showFullscreenImageDialog } = useGalleryStore();
const { currentUser } = storeToRefs(useUserStore());
const playerListRef = ref(null);
const tableHeight = ref(0);
const playerListHeaderRef = ref(null);
const playerListPhotonRef = ref(null);
onMounted(() => {
if (playerListRef.value) {
resizeObserver.observe(playerListRef.value);
}
});
const resizeObserver = new ResizeObserver(() => {
setPlayerListTableHeight();
});
function setPlayerListTableHeight() {
if (currentInstanceWorld.value.ref.id) {
tableHeight.value = playerListRef.value.clientHeight - 110;
return;
}
if (currentInstanceUsersTable.value.data.length === 0) {
tableHeight.value = playerListRef.value.clientHeight;
return;
}
if (playerListRef.value) {
tableHeight.value = playerListRef.value.clientHeight - 110;
}
}
watch(
() => currentInstanceWorld.value.ref.id,
() => {
setPlayerListTableHeight();
}
);
onUnmounted(() => {
resizeObserver.disconnect();
});
const compactCellStyle = () => ({
padding: '4px 10px'
});
const compactInstanceUsersTable = computed(() => {
const baseTableConfig = currentInstanceUsersTable.value;
const tableProps = baseTableConfig.tableProps || {};
return {
...baseTableConfig,
tableProps: {
...tableProps,
cellStyle: compactCellStyle,
headerCellStyle: compactCellStyle,
height: tableHeight.value
}
};
const { tableStyle: playerListTableStyle } = useDataTableScrollHeight(playerListRef, {
offset: 30,
paginationHeight: 0,
subtractContainerPadding: true,
extraOffsetRefs: [playerListHeaderRef, playerListPhotonRef]
});
const { t } = useI18n();
@@ -525,25 +284,65 @@
getCurrentInstanceUserList();
}
function sortInstanceIcon(a, b) {
const getValue = (item) => {
let value = 0;
if (item.isMaster) value += 1000;
if (item.isModerator) value += 500;
if (item.isFriend) value += 200;
if (item.isBlocked) value -= 100;
if (item.isMuted) value -= 50;
if (item.isAvatarInteractionDisabled) value -= 20;
if (item.isChatBoxMuted) value -= 10;
return value;
};
return getValue(b) - getValue(a);
}
function sortAlphabetically(a, b, field) {
if (!a[field] || !b[field]) return 0;
return a[field].toLowerCase().localeCompare(b[field].toLowerCase());
}
const sorting = ref([]);
const pagination = ref({
pageIndex: 0,
pageSize: 500
});
const columnPinning = ref({
left: ['avatar', 'timer', 'displayName'],
right: []
});
const playerListColumns = computed(() =>
createColumns({
randomUserColours,
photonLoggingEnabled,
chatboxUserBlacklist,
onBlockChatbox: addChatboxUserBlacklist,
onUnblockChatbox: deleteChatboxUserBlacklist,
sortAlphabetically
})
);
const playerListTable = useVueTable({
data: currentInstanceUsersData.value,
columns: playerListColumns.value,
getRowId: (row) => `${row?.ref?.id ?? ''}:${row?.displayName ?? ''}`,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: (updaterOrValue) => valueUpdater(updaterOrValue, sorting),
onPaginationChange: (updaterOrValue) => valueUpdater(updaterOrValue, pagination),
onColumnPinningChange: (updaterOrValue) => valueUpdater(updaterOrValue, columnPinning),
state: {
get sorting() {
return sorting.value;
},
get pagination() {
return pagination.value;
},
get columnPinning() {
return columnPinning.value;
}
},
initialState: {
columnPinning: columnPinning.value,
pagination: pagination.value
}
});
const playerListTotalItems = computed(() => playerListTable.getRowModel().rows.length);
const handlePlayerListRowClick = (row) => {
selectCurrentInstanceRow(row?.original ?? null);
};
</script>
<style>
@@ -556,15 +355,4 @@
text-overflow: ellipsis;
vertical-align: middle;
}
#x-app .current-instance-table .el-table .el-table__cell {
padding: 3px 10px !important;
}
.table-user {
color: var(--x-table-user-text-color);
}
.friends-list-avatar {
width: 16px !important;
height: 16px !important;
object-fit: cover;
}
</style>

View File

@@ -0,0 +1,434 @@
import Timer from '../../components/Timer.vue';
import { Button } from '../../components/ui/button';
import { TooltipWrapper } from '../../components/ui/tooltip';
import { ArrowUpDown } from 'lucide-vue-next';
import {
getFaviconUrl,
languageClass,
openExternalLink,
statusClass,
userImage
} from '../../shared/utils';
import { i18n } from '../../plugin';
const { t } = i18n.global;
const sortButton = ({ column, label }) => (
<Button
variant="ghost"
size="sm"
class="-ml-2 h-8 px-2"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
{label}
<ArrowUpDown class="ml-1 h-4 w-4" />
</Button>
);
const getInstanceIconWeight = (item) => {
if (!item) return 0;
let value = 0;
if (item.isMaster) value += 1000;
if (item.isModerator) value += 500;
if (item.isFriend) value += 200;
if (item.isBlocked) value -= 100;
if (item.isMuted) value -= 50;
if (item.isAvatarInteractionDisabled) value -= 20;
if (item.isChatBoxMuted) value -= 10;
return value;
};
export const createColumns = ({
randomUserColours,
photonLoggingEnabled,
chatboxUserBlacklist,
onBlockChatbox,
onUnblockChatbox,
sortAlphabetically
}) => {
/** @type {import('@tanstack/vue-table').ColumnDef<any, any>[]} */
const cols = [
{
id: 'avatar',
accessorFn: (row) => row?.photo,
header: () => t('table.playerList.avatar'),
size: 70,
enableSorting: false,
meta: {
class: 'w-[70px]'
},
cell: ({ row }) => {
const userRef = row.original?.ref;
const src = userImage(userRef);
if (!src) return null;
return (
<div class="flex items-center pl-2">
<img
src={src}
class="h-4 w-4 rounded-sm object-cover"
loading="lazy"
/>
</div>
);
}
},
{
id: 'timer',
accessorFn: (row) => row?.timer,
header: ({ column }) =>
sortButton({ column, label: t('table.playerList.timer') }),
size: 90,
meta: {
class: 'w-[90px]'
},
sortingFn: (rowA, rowB) =>
(rowA.original?.timer ?? 0) - (rowB.original?.timer ?? 0),
cell: ({ row }) => <Timer epoch={row.original?.timer} />
},
{
id: 'displayName',
accessorFn: (row) => row?.displayName,
header: ({ column }) =>
sortButton({
column,
label: t('table.playerList.displayName')
}),
size: 200,
meta: {
class: 'w-[200px]'
},
sortingFn: (rowA, rowB) =>
sortAlphabetically(rowA.original, rowB.original, 'displayName'),
cell: ({ row }) => {
const userRef = row.original?.ref;
const style = randomUserColours?.value
? { color: userRef?.$userColour }
: null;
return (
<span
class="text-(--x-table-user-text-color)"
style={style}
>
{userRef?.displayName ?? ''}
</span>
);
}
},
{
id: 'rank',
accessorFn: (row) => row?.ref?.$trustSortNum,
header: ({ column }) =>
sortButton({ column, label: t('table.playerList.rank') }),
size: 110,
meta: {
class: 'w-[110px]'
},
sortingFn: (rowA, rowB) =>
(rowA.original?.ref?.$trustSortNum ?? 0) -
(rowB.original?.ref?.$trustSortNum ?? 0),
cell: ({ row }) => {
const userRef = row.original?.ref;
return (
<span
class={['name', userRef?.$trustClass]
.filter(Boolean)
.join(' ')}
>
{userRef?.$trustLevel ?? ''}
</span>
);
}
},
{
id: 'status',
accessorFn: (row) => row?.ref?.statusDescription,
header: () => t('table.playerList.status'),
minSize: 200,
enableSorting: false,
meta: {
class: 'min-w-[200px]'
},
cell: ({ row }) => {
const userRef = row.original?.ref;
const status = userRef?.status;
return (
<span class="flex w-full min-w-0 items-center gap-2">
<i
class={[
'x-user-status',
'shrink-0',
status ? statusClass(status) : null
]}
></i>
<span class="min-w-0 truncate">
{userRef?.statusDescription ?? ''}
</span>
</span>
);
}
}
];
if (photonLoggingEnabled?.value) {
cols.push({
id: 'photonId',
accessorFn: (row) => row?.photonId,
header: ({ column }) =>
sortButton({ column, label: t('table.playerList.photonId') }),
size: 110,
meta: {
class: 'w-[110px]'
},
sortingFn: (rowA, rowB) =>
(rowA.original?.photonId ?? 0) - (rowB.original?.photonId ?? 0),
cell: ({ row }) => {
const userRef = row.original?.ref;
const userId = userRef?.id;
const isBlocked =
userId && chatboxUserBlacklist?.value?.has?.(userId);
return (
<div class="flex items-center">
{userId ? (
<button
class={
isBlocked
? 'mr-1 text-xs underline text-destructive'
: 'mr-1 text-xs underline'
}
onClick={(e) => {
e.stopPropagation();
if (isBlocked) {
onUnblockChatbox(userId);
} else {
onBlockChatbox(userRef);
}
}}
>
{isBlocked ? 'Unblock' : 'Block'}
</button>
) : null}
<span>{String(row.original?.photonId ?? '')}</span>
</div>
);
}
});
}
cols.push(
{
id: 'icon',
header: ({ column }) =>
sortButton({ column, label: t('table.playerList.icon') }),
size: 90,
accessorFn: (row) => getInstanceIconWeight(row),
meta: {
class: 'w-[90px] text-center'
},
sortingFn: (rowA, rowB, columnId) => {
const a = rowA.getValue(columnId) ?? 0;
const b = rowB.getValue(columnId) ?? 0;
return b - a;
},
cell: ({ row }) => {
const r = row.original;
return (
<div class="flex items-center justify-center gap-1">
{r?.isMaster ? (
<TooltipWrapper
side="left"
content="Instance Master"
>
<span>👑</span>
</TooltipWrapper>
) : null}
{r?.isModerator ? (
<TooltipWrapper side="left" content="Moderator">
<span></span>
</TooltipWrapper>
) : null}
{r?.isFriend ? (
<TooltipWrapper side="left" content="Friend">
<span>💚</span>
</TooltipWrapper>
) : null}
{r?.isBlocked ? (
<TooltipWrapper side="left" content="Blocked">
<span class="text-destructive"></span>
</TooltipWrapper>
) : null}
{r?.isMuted ? (
<TooltipWrapper side="left" content="Muted">
<span class="text-muted-foreground">🔇</span>
</TooltipWrapper>
) : null}
{r?.isAvatarInteractionDisabled ? (
<TooltipWrapper
side="left"
content="Avatar Interaction Disabled"
>
<span class="text-muted-foreground">🚫</span>
</TooltipWrapper>
) : null}
{r?.isChatBoxMuted ? (
<TooltipWrapper side="left" content="Chatbox Muted">
<span class="text-muted-foreground">💬</span>
</TooltipWrapper>
) : null}
{r?.timeoutTime ? (
<TooltipWrapper side="left" content="Timeout">
<span class="text-destructive">
🔴{r.timeoutTime}s
</span>
</TooltipWrapper>
) : null}
{r?.ageVerified ? (
<TooltipWrapper side="left" content="18+ Verified">
<i class="ri-id-card-line x-tag-age-verification"></i>
</TooltipWrapper>
) : null}
</div>
);
}
},
{
id: 'platform',
header: () => t('table.playerList.platform'),
size: 90,
enableSorting: false,
meta: {
class: 'w-[90px]'
},
cell: ({ row }) => {
const userRef = row.original?.ref;
const platform = userRef?.$platform;
const inVRMode = row.original?.inVRMode;
const platformIcon =
platform === 'standalonewindows' ? (
<i class="ri-computer-line x-tag-platform-pc" />
) : platform === 'android' ? (
<i class="ri-android-line x-tag-platform-quest" />
) : platform === 'ios' ? (
<i class="ri-apple-line x-tag-platform-ios" />
) : platform ? (
<span>{String(platform)}</span>
) : null;
const mode =
inVRMode === null || inVRMode === undefined
? null
: inVRMode
? 'VR'
: userRef?.last_platform === 'android' ||
userRef?.last_platform === 'ios'
? 'M'
: 'D';
return (
<div class="flex items-center gap-1">
{platformIcon}
{mode ? <span>{mode}</span> : null}
</div>
);
}
},
{
id: 'language',
header: () => t('table.playerList.language'),
size: 100,
enableSorting: false,
meta: {
class: 'w-[100px]'
},
cell: ({ row }) => {
const userRef = row.original?.ref;
const langs = userRef?.$languages ?? [];
return (
<div>
{langs.map((item) => (
<TooltipWrapper
key={item.key}
side="top"
v-slots={{
content: () => (
<span>
{item.value} ({item.key})
</span>
)
}}
>
<span
class={[
'flags',
'inline-block',
'mr-1',
languageClass(item.key)
]}
/>
</TooltipWrapper>
))}
</div>
);
}
},
{
id: 'bioLink',
header: () => t('table.playerList.bioLink'),
size: 100,
enableSorting: false,
meta: {
class: 'w-[100px]'
},
cell: ({ row }) => {
const links =
row.original?.ref?.bioLinks?.filter(Boolean) ?? [];
return (
<div class="flex items-center">
{links.map((link, index) => (
<TooltipWrapper
key={index}
v-slots={{
content: () => (
<span>{String(link ?? '')}</span>
)
}}
>
<img
src={getFaviconUrl(link)}
class="h-4 w-4 mr-1 align-middle cursor-pointer"
loading="lazy"
onClick={(e) => {
e.stopPropagation();
openExternalLink(String(link));
}}
/>
</TooltipWrapper>
))}
</div>
);
}
},
{
id: 'note',
accessorFn: (row) => row?.ref?.note,
header: () => t('table.playerList.note'),
size: 400,
enableSorting: false,
meta: {
class: 'w-[150px]'
},
cell: ({ row }) => {
const note = row.original?.ref?.note;
const text =
typeof note === 'string' || typeof note === 'number'
? String(note)
: '';
return <span>{text}</span>;
}
}
);
return cols;
};