UI Refresh

This commit is contained in:
pa
2026-01-02 22:24:28 +09:00
committed by Natsumi
parent b02d287190
commit 00745b54f1
120 changed files with 3931 additions and 2015 deletions

88
package-lock.json generated
View File

@@ -15,10 +15,6 @@
"@element-plus/icons-vue": "^2.3.2",
"@eslint/js": "^9.39.2",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/noto-sans-jp": "^5.2.9",
"@fontsource-variable/noto-sans-kr": "^5.2.9",
"@fontsource-variable/noto-sans-sc": "^5.2.9",
"@fontsource-variable/noto-sans-tc": "^5.2.9",
"@kamiya4047/eslint-plugin-pretty-import": "^0.1.6",
"@sentry/vite-plugin": "^4.6.1",
"@sentry/vue": "^10.32.1",
@@ -47,12 +43,12 @@
"noty": "^3.2.0-beta-deprecated",
"pinia": "^3.0.4",
"prettier": "^3.7.4",
"remixicon": "^4.7.0",
"remixicon": "^4.8.0",
"sass-embedded": "^1.97.1",
"tailwindcss": "^4.1.18",
"vite": "^7.3.0",
"vue": "^3.5.26",
"vue-i18n": "^11.2.2",
"vue-i18n": "^11.2.8",
"vue-marquee-text-component": "^2.0.1",
"vue-router": "^4.6.4",
"vue-showdown": "^4.2.0",
@@ -2351,46 +2347,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-jp": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-jp/-/noto-sans-jp-5.2.9.tgz",
"integrity": "sha512-osPL5f7dvGDjuMuFwDTGPLG37030D8X5zk+3BWea6txAVDFeE/ZIrKW0DY0uSDfRn9+NiKbiFn/2QvZveKXTog==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-kr": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-kr/-/noto-sans-kr-5.2.9.tgz",
"integrity": "sha512-g1BnJdJbnAgRUP8YxyPIm8npZVUbtt6VgtLnkGR7poa/RVbVGd27i+9138DmwRvtbKhJG1fPLQ/V3BonvFykRQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-sc": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-sc/-/noto-sans-sc-5.2.9.tgz",
"integrity": "sha512-ZEEpZlxjYEIVdg85K38mqaoeBcorrN3Z6MaIkwK5w5Dqn/e9v5uVIYr0ukoLsFxaVyEXSi/c3caOeMHjbOMtfA==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/noto-sans-tc": {
"version": "5.2.9",
"resolved": "https://registry.npmjs.org/@fontsource-variable/noto-sans-tc/-/noto-sans-tc-5.2.9.tgz",
"integrity": "sha512-GhtbSE8IZTP3vZj7Fu1G/PERxguMe3jryAbHovSd22Rs7aYdbXQD8vBqkTT/tkHIUn6t2IFReTfgKUoQBPCe+w==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -2451,14 +2407,14 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.2.2",
"@intlify/shared": "11.2.2"
"@intlify/message-compiler": "11.2.8",
"@intlify/shared": "11.2.8"
},
"engines": {
"node": ">= 16"
@@ -2468,13 +2424,13 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.2.2",
"@intlify/shared": "11.2.8",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -2485,9 +2441,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -15566,9 +15522,9 @@
}
},
"node_modules/remixicon": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.7.0.tgz",
"integrity": "sha512-g2pHOofQWARWpxdbrQu5+K3C8ZbqguQFzE54HIMWFCpFa63pumaAltIgZmFMRQpKKBScRWQASQfWxS9asNCcHQ==",
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/remixicon/-/remixicon-4.8.0.tgz",
"integrity": "sha512-8qTM/bWkmsAWitvcL9XrVPVdqHRrdmnNp4zCFBdmIHBygxfHWwoK6NzitbiMyRzjcXKBMlS/ab5M65/ZlxJTVA==",
"dev": true,
"license": "Apache-2.0"
},
@@ -18493,14 +18449,14 @@
}
},
"node_modules/vue-i18n": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
"version": "11.2.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.2.2",
"@intlify/shared": "11.2.2",
"@intlify/core-base": "11.2.8",
"@intlify/shared": "11.2.8",
"@vue/devtools-api": "^6.5.0"
},
"engines": {

View File

@@ -68,12 +68,12 @@
"noty": "^3.2.0-beta-deprecated",
"pinia": "^3.0.4",
"prettier": "^3.7.4",
"remixicon": "^4.7.0",
"remixicon": "^4.8.0",
"sass-embedded": "^1.97.1",
"tailwindcss": "^4.1.18",
"vite": "^7.3.0",
"vue": "^3.5.26",
"vue-i18n": "^11.2.2",
"vue-i18n": "^11.2.8",
"vue-marquee-text-component": "^2.0.1",
"vue-router": "^4.6.4",
"vue-showdown": "^4.2.0",

View File

@@ -56,11 +56,7 @@
});
</script>
<style lang="scss" scoped>
:deep(.el-splitter-bar__dragger) {
width: 4px !important;
}
<style scoped>
/* Add title bar spacing for macOS */
.x-app.with-macos-titlebar {
padding-top: 28px;

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
--offy: 14.5px;
}
.flags {
background: url('../images/flags.png') no-repeat;
background: url('/images/flags.png') no-repeat;
background-size: calc(var(--offx) * 6);
width: var(--offx);
height: calc(var(--offx) / 72 * 52);

53
src/assets/scss/noty.css Normal file
View File

@@ -0,0 +1,53 @@
/* Noty.js */
.noty_layout {
word-break: break-all;
}
.noty_theme__mint.noty_bar {
position: relative;
margin: 4px 0;
overflow: hidden;
border-radius: 2px;
}
.noty_theme__mint.noty_bar .noty_body {
padding: 10px;
font-size: 14px;
}
.noty_theme__mint.noty_bar .noty_buttons {
padding: 10px;
}
.noty_theme__mint.noty_type__alert,
.noty_theme__mint.noty_type__notification {
color: #2f2f2f;
background-color: #fff;
border-bottom: 1px solid #d1d1d1;
}
.noty_theme__mint.noty_type__warning {
color: #fff;
background-color: #ffae42;
border-bottom: 1px solid #e89f3c;
}
.noty_theme__mint.noty_type__error {
color: #fff;
background-color: #de636f;
border-bottom: 1px solid #ca5a65;
}
.noty_theme__mint.noty_type__info,
.noty_theme__mint.noty_type__information {
color: #fff;
background-color: #7f7eff;
border-bottom: 1px solid #7473e8;
}
.noty_theme__mint.noty_type__success {
color: #fff;
background-color: #afc765;
border-bottom: 1px solid #a0b55c;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
<template>
<div @click="confirm" class="avatar-info">
<span style="margin-right: 5px">{{ avatarName }}</span>
<span v-if="avatarType" :class="color" style="margin-right: 5px"><i :class="avatarTypeIcons" /></span>
<span v-if="avatarTags" style="color: #909399; font-family: monospace; font-size: 12px">{{ avatarTags }}</span>
<span v-if="avatarType" :class="color" class="mr-2"><i :class="avatarTypeIcons" /></span>
<span class="mr-2">{{ avatarName }}</span>
<span v-if="avatarTags" style="color: var(--el-text-color-secondary); font-size: 12px">{{ avatarTags }}</span>
</div>
</template>
@@ -17,7 +17,7 @@
imageurl: String,
userid: String,
hintownerid: String,
hintavatarname: String,
hintavatarname: [String, Object],
avatartags: Array
});
@@ -45,7 +45,9 @@
if (!props.imageurl) {
avatarName.value = '';
} else if (props.hintownerid) {
avatarName.value = props.hintavatarname;
if (typeof props.hintavatarname === 'string') {
avatarName.value = props.hintavatarname;
}
ownerId = props.hintownerid;
} else {
try {

View File

@@ -4,6 +4,7 @@
v-loading="loading"
:data="paginatedData"
v-bind="mergedTableProps"
:stripe="false"
:default-sort="resolvedDefaultSort"
@row-click="handleRowClick">
<slot></slot>
@@ -102,7 +103,6 @@
delete rest.defaultSort;
}
return {
stripe: true,
...rest
};
});

View File

@@ -168,7 +168,7 @@
}
</script>
<style scoped lang="scss">
<style scoped>
.toolbar-icon:hover {
opacity: 1;
}

View File

@@ -15,9 +15,13 @@
{{ t('dialog.user.info.close_instance') }} </el-button
><br /><br />
</template>
<span><span style="color: #409eff">PC: </span>{{ props.instance.platforms.standalonewindows }}</span
<span
><span style="color: var(--el-color-primary)">PC: </span
>{{ props.instance.platforms.standalonewindows }}</span
><br />
<span><span style="color: #67c23a">Android: </span>{{ props.instance.platforms.android }}</span
<span
><span style="color: var(--el-color-success)">Android: </span
>{{ props.instance.platforms.android }}</span
><br />
<span>{{ t('dialog.user.info.instance_game_version') }} {{ props.instance.gameServerVersion }}</span
><br />
@@ -46,13 +50,13 @@
<span v-if="props.friendcount" style="margin-left: 5px">({{ props.friendcount }})</span>
<span
v-if="state.isValidInstance && !props.instance.hasCapacityForYou"
style="margin-left: 5px; color: lightcoral"
style="margin-left: 5px; color: var(--el-color-danger)"
>{{ t('dialog.user.info.instance_full') }}</span
>
<span v-if="props.instance.queueSize" style="margin-left: 5px"
>{{ t('dialog.user.info.instance_queue') }} {{ props.instance.queueSize }}</span
>
<span v-if="state.isAgeGated" style="margin-left: 5px; color: lightcoral">{{
<span v-if="state.isAgeGated" style="margin-left: 5px; color: var(--el-color-danger)">{{
t('dialog.user.info.instance_age_gated')
}}</span>
</div>

View File

@@ -1,24 +1,33 @@
<template>
<div>
<span v-if="!text" class="transparent">-</span>
<span v-show="text">
<span
:class="{ 'x-link': link && location !== 'private' && location !== 'offline' }"
@click="handleShowWorldDialog">
<el-icon :class="['is-loading', 'inline-block']" style="margin-right: 3px" v-if="isTraveling"
><Loading
/></el-icon>
<span>{{ text }}</span>
</span>
<span v-if="groupName" :class="{ 'x-link': link }" @click="handleShowGroupDialog">({{ groupName }})</span>
<span
v-if="region"
:class="['flags', 'inline-block', 'ml-5', region, 'transform-[translateY(3px)]']"></span>
<NativeTooltip v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
<div v-if="!text" class="transparent">-</div>
<div v-show="text" class="flex items-center">
<div v-if="region" :class="['flags', 'mr-1.5', region]"></div>
<el-tooltip
:content="`${t('dialog.new_instance.instance_id')}: #${instanceName}`"
:disabled="!instanceName"
:show-after="300"
placement="top">
<div
:class="['x-location', { 'x-link': link && location !== 'private' && location !== 'offline' }]"
class="inline-flex min-w-0 flex-nowrap items-center overflow-hidden"
@click="handleShowWorldDialog">
<el-icon :class="['is-loading']" class="mr-1" v-if="isTraveling"><Loading /></el-icon>
<span class="min-w-0 truncate">{{ text }}</span>
<span
v-if="groupName"
class="ml-0.5 whitespace-nowrap"
:class="{ 'x-link': link }"
@click.stop="handleShowGroupDialog">
({{ groupName }})
</span>
</div>
</el-tooltip>
<el-tooltip v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
<el-icon :class="['inline-block', 'ml-5']" style="color: lightcoral"><WarnTriangleFilled /></el-icon>
</NativeTooltip>
</el-tooltip>
<el-icon v-if="strict" :class="['inline-block', 'ml-5']"><Lock /></el-icon>
</span>
</div>
</div>
</template>
@@ -30,6 +39,7 @@
import { useGroupStore, useInstanceStore, useSearchStore, useWorldStore } from '../stores';
import { getGroupName, getWorldName, parseLocation } from '../shared/utils';
import { accessTypeLocaleKeyMap } from '../shared/constants';
const { t } = useI18n();
@@ -67,6 +77,7 @@
const isTraveling = ref(false);
const groupName = ref('');
const isClosed = ref(false);
const instanceName = ref('');
let isDisposed = false;
onBeforeUnmount(() => {
@@ -108,7 +119,8 @@
isTraveling.value = true;
}
const L = parseLocation(instanceId);
setText(L, L.instanceName);
setText(L);
instanceName.value = L.instanceName;
if (!L.isRealInstance) {
return;
}
@@ -116,7 +128,8 @@
const instanceRef = cachedInstances.get(L.tag);
if (typeof instanceRef !== 'undefined') {
if (instanceRef.displayName) {
setText(L, instanceRef.displayName);
setText(L);
instanceName.value = instanceRef.displayName;
}
if (instanceRef.closedAt) {
isClosed.value = true;
@@ -147,7 +160,9 @@
strict.value = L.strict;
}
function setText(L, instanceName) {
function setText(L) {
const accessTypeLabel = translateAccessType(L.accessTypeName);
if (L.isOffline) {
text.value = 'Offline';
} else if (L.isPrivate) {
@@ -156,13 +171,13 @@
text.value = 'Traveling';
} else if (typeof props.hint === 'string' && props.hint !== '') {
if (L.instanceId) {
text.value = `${props.hint} #${instanceName} ${L.accessTypeName}`;
text.value = `${props.hint} · ${accessTypeLabel}`;
} else {
text.value = props.hint;
}
} else if (L.worldId) {
if (L.instanceId) {
text.value = `${L.worldId} #${instanceName} ${L.accessTypeName}`;
text.value = `${L.worldId} · ${accessTypeLabel}`;
} else {
text.value = L.worldId;
}
@@ -172,7 +187,7 @@
.then((name) => {
if (!isDisposed && name && currentInstanceId() === L.tag) {
if (L.instanceId) {
text.value = `${name} #${instanceName} ${L.accessTypeName}`;
text.value = `${name} · ${translateAccessType(L.accessTypeName)}`;
} else {
text.value = name;
}
@@ -182,13 +197,21 @@
console.error(e);
});
} else if (L.instanceId) {
text.value = `${ref.name} #${instanceName} ${L.accessTypeName}`;
text.value = `${ref.name} · ${accessTypeLabel}`;
} else {
text.value = ref.name;
}
}
}
function translateAccessType(accessTypeName) {
const key = accessTypeLocaleKeyMap[accessTypeName];
if (!key) {
return accessTypeName;
}
return t(key);
}
function handleShowWorldDialog() {
if (props.link) {
let instanceId = currentInstanceId();
@@ -218,15 +241,13 @@
</script>
<style scoped>
.inline-block {
display: inline-block;
}
.ml-5 {
margin-left: 5px;
}
.transparent {
color: transparent;
}
:global(html.dark .x-location),
:global(:root.dark .x-location),
:global(:root[data-theme='dark'] .x-location) {
color: var(--color-zinc-300);
}
</style>

View File

@@ -1,14 +1,14 @@
<template>
<span>
<span class="x-location-world">
<span v-if="region" :class="['flags', 'inline-block', 'mr-1.25', region]"></span>
<span @click="showLaunchDialog" class="x-link">
<el-icon v-if="isUnlocked" :class="['inline-block', 'mr-5']"><Unlock /></el-icon>
<span>#{{ instanceName }} {{ accessTypeName }}</span>
<el-icon v-if="isUnlocked" :class="['inline-block', 'mr-1.25']"><Unlock /></el-icon>
<span> {{ accessTypeName }} #{{ instanceName }}</span>
</span>
<span v-if="groupName" @click="showGroupDialog" class="x-link">({{ groupName }})</span>
<span v-if="region" :class="['flags', 'inline-block', 'ml-5', region, 'transform-[translateY(3px)]']"></span>
<NativeTooltip v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
<el-tooltip v-if="isClosed" :content="t('dialog.user.info.instance_closed')">
<el-icon :class="['inline-block', 'ml-5']" style="color: lightcoral"><WarnTriangleFilled /></el-icon>
</NativeTooltip>
</el-tooltip>
<el-icon v-if="strict" style="display: inline-block; margin-left: 5px"><Lock /></el-icon>
</span>
</template>
@@ -21,6 +21,7 @@
import { useGroupStore, useInstanceStore, useLaunchStore } from '../stores';
import { getGroupName, parseLocation } from '../shared/utils';
import { accessTypeLocaleKeyMap } from '../shared/constants';
const { t } = useI18n();
const { cachedInstances } = useInstanceStore();
@@ -52,7 +53,7 @@
function parse() {
const locObj = props.locationobject;
location.value = locObj.tag;
accessTypeName.value = locObj.accessTypeName;
accessTypeName.value = translateAccessType(locObj.accessTypeName);
strict.value = locObj.strict;
shortName.value = locObj.shortName;
@@ -97,6 +98,14 @@
}
}
function translateAccessType(accessTypeNameRaw) {
const key = accessTypeLocaleKeyMap[accessTypeNameRaw];
if (!key) {
return accessTypeNameRaw;
}
return t(key);
}
watch(() => props.locationobject, parse, { immediate: true });
watch(
@@ -126,11 +135,9 @@
display: inline-block;
}
.ml-5 {
margin-left: 5px;
}
.mr-5 {
margin-right: 5px;
:global(html.dark .x-location-world),
:global(:root.dark .x-location-world),
:global(:root[data-theme='dark'] .x-location-world) {
color: var(--color-zinc-100);
}
</style>

View File

@@ -1,537 +0,0 @@
<template>
<span
ref="triggerEl"
class="vrcx-native-tooltip__trigger"
:style="triggerStyle"
@mouseenter="onEnter"
@mouseleave="onLeave"
@focusin="onEnter"
@focusout="onLeave"
@keydown.esc="close">
<slot />
</span>
<div
ref="tooltipEl"
class="vrcx-native-tooltip__content"
:class="[
placementClass,
{
'has-arrow': props.showArrow,
'is-open': isOpen,
'is-closing': isClosing
}
]"
:style="contentStyle"
popover="manual"
role="tooltip"
@mouseenter="cancelClose"
@mouseleave="onLeave">
<slot name="content">
<span class="vrcx-native-tooltip__text" v-text="content" />
</slot>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, ref } from 'vue';
const props = defineProps({
content: {
type: String,
default: ''
},
showAfter: {
type: Number,
default: 0
},
placement: {
type: String,
default: 'top'
},
enterMs: {
type: Number,
default: 120
},
exitMs: {
type: Number,
default: 100
},
offset: {
type: Number,
default: 8
},
maxWidth: {
type: String,
default: '260px'
},
disabled: {
type: Boolean,
default: false
},
showArrow: {
type: Boolean,
default: true
}
});
const ARROW_SIZE_PX = 10;
const triggerEl = ref(null);
const tooltipEl = ref(null);
const isOpen = ref(false);
const isClosing = ref(false);
const anchorName = `--vrcx-tt-${Math.random().toString(36).slice(2, 10)}`;
const triggerStyle = computed(() => {
return {
'anchor-name': anchorName
};
});
const contentStyle = computed(() => {
const effectiveOffsetPx = props.offset + (props.showArrow ? ARROW_SIZE_PX / 2 : 0);
return {
'position-anchor': anchorName,
'--vrcx-tt-enter': `${props.enterMs}ms`,
'--vrcx-tt-exit': `${props.exitMs}ms`,
'--vrcx-tt-offset': `${effectiveOffsetPx}px`,
'--vrcx-tt-max-width': props.maxWidth,
'--vrcx-tt-shift-x': `${shiftX.value}px`,
'--vrcx-tt-shift-y': `${shiftY.value}px`,
'--vrcx-tt-arrow-x': `${arrowX.value}px`,
'--vrcx-tt-arrow-y': `${arrowY.value}px`
};
});
const placementClass = computed(() => {
const normalized = String(props.placement || 'top').toLowerCase();
return `is-${normalized}`;
});
const shiftX = ref(0);
const shiftY = ref(0);
const arrowX = ref(0);
const arrowY = ref(0);
const timers = {
open: 0,
close: 0,
hide: 0
};
function clearTimer(key) {
const id = timers[key];
if (id) {
window.clearTimeout(id);
timers[key] = 0;
}
}
function clearAllTimers() {
clearTimer('open');
clearTimer('close');
clearTimer('hide');
}
function resetOffsets() {
shiftX.value = 0;
shiftY.value = 0;
arrowX.value = 0;
arrowY.value = 0;
}
function isPopoverOpen(el) {
return Boolean(el?.matches?.(':popover-open'));
}
function updateViewportShift() {
const el = tooltipEl.value;
if (!el) {
return;
}
shiftX.value = 0;
shiftY.value = 0;
const rect = el.getBoundingClientRect();
const margin = 8;
const vw = window.innerWidth;
const vh = window.innerHeight;
let dx = 0;
let dy = 0;
if (rect.left < margin) {
dx += margin - rect.left;
}
if (rect.right > vw - margin) {
dx -= rect.right - (vw - margin);
}
if (rect.top < margin) {
dy += margin - rect.top;
}
if (rect.bottom > vh - margin) {
dy -= rect.bottom - (vh - margin);
}
shiftX.value = Math.round(dx);
shiftY.value = Math.round(dy);
}
function updateArrowPosition() {
if (!props.showArrow) {
return;
}
const trigger = triggerEl.value;
const tooltip = tooltipEl.value;
if (!trigger || !tooltip) {
return;
}
const placement = String(props.placement || 'top').toLowerCase();
const tr = trigger.getBoundingClientRect();
const tt = tooltip.getBoundingClientRect();
const cs = window.getComputedStyle(tooltip);
const padLeft = Number.parseFloat(cs.paddingLeft) || 0;
const padRight = Number.parseFloat(cs.paddingRight) || 0;
const padTop = Number.parseFloat(cs.paddingTop) || 0;
const padBottom = Number.parseFloat(cs.paddingBottom) || 0;
const padding = 12;
const half = ARROW_SIZE_PX / 2;
if (placement.startsWith('top') || placement.startsWith('bottom')) {
const desired = tr.left + tr.width / 2 - tt.left;
const edgeLeft = Math.max(padding, padLeft) + half;
const edgeRight = Math.max(padding, padRight) + half;
const min = edgeLeft;
const max = tt.width - edgeRight;
const clamped = min > max ? tt.width / 2 : Math.min(Math.max(desired, min), max);
arrowX.value = Math.round(clamped);
arrowY.value = 0;
return;
}
if (placement.startsWith('left') || placement.startsWith('right')) {
const desired = tr.top + tr.height / 2 - tt.top;
const edgeTop = Math.max(padding, padTop) + half;
const edgeBottom = Math.max(padding, padBottom) + half;
const min = edgeTop;
const max = tt.height - edgeBottom;
const clamped = min > max ? tt.height / 2 : Math.min(Math.max(desired, min), max);
arrowY.value = Math.round(clamped);
arrowX.value = 0;
}
}
function open() {
if (props.disabled) {
return;
}
const el = tooltipEl.value;
if (!el) {
return;
}
clearAllTimers();
const doOpen = () => {
timers.open = 0;
const tooltip = tooltipEl.value;
if (!tooltip) {
return;
}
const alreadyOpen = isPopoverOpen(tooltip);
isClosing.value = false;
if (!alreadyOpen) {
isOpen.value = false;
tooltip.showPopover();
}
window.requestAnimationFrame(() => {
updateViewportShift();
window.requestAnimationFrame(() => {
updateArrowPosition();
isOpen.value = true;
});
});
};
if (props.showAfter > 0) {
timers.open = window.setTimeout(doOpen, props.showAfter);
return;
}
doOpen();
}
function close(immediate = false) {
const el = tooltipEl.value;
if (!el) {
return;
}
clearAllTimers();
if (immediate) {
isOpen.value = false;
isClosing.value = false;
resetOffsets();
if (isPopoverOpen(el)) {
el.hidePopover();
}
return;
}
isOpen.value = false;
isClosing.value = true;
timers.hide = window.setTimeout(() => {
timers.hide = 0;
isClosing.value = false;
resetOffsets();
if (isPopoverOpen(el)) {
el.hidePopover();
}
}, props.exitMs);
}
function onEnter() {
open();
}
function onLeave() {
clearTimer('open');
clearTimer('close');
timers.close = window.setTimeout(() => {
timers.close = 0;
close();
}, 80);
}
function cancelClose() {
clearTimer('close');
clearTimer('hide');
if (isPopoverOpen(tooltipEl.value)) {
isClosing.value = false;
isOpen.value = true;
}
}
onBeforeUnmount(() => {
close(true);
clearAllTimers();
});
</script>
<style scoped>
.vrcx-native-tooltip__trigger {
display: inline-flex;
align-items: center;
justify-content: center;
}
.vrcx-native-tooltip__content {
position: fixed;
inset: auto;
overflow: visible;
clip-path: none;
inline-size: max-content;
max-inline-size: min(var(--vrcx-tt-max-width), calc(100vw - 16px));
min-inline-size: 0;
padding: 6px 10px;
border-radius: var(--el-border-radius-base);
border: 0;
background: var(--el-tooltip-bg-color, color-mix(in srgb, var(--el-color-black) 88%, transparent));
color: var(--el-tooltip-text-color, var(--el-color-white));
box-shadow: none;
font-size: 12px;
line-height: 1.35;
white-space: pre-line;
word-break: break-word;
overflow-wrap: anywhere;
opacity: 0;
transition-property: opacity;
transition-duration: var(--vrcx-tt-exit);
transition-timing-function: linear;
transition-behavior: allow-discrete;
pointer-events: auto;
}
:global(html.dark) .vrcx-native-tooltip__content {
background: var(--el-tooltip-bg-color, color-mix(in srgb, var(--el-color-black) 96%, transparent));
}
.vrcx-native-tooltip__content.has-arrow::before {
content: '';
position: absolute;
width: 10px;
height: 10px;
background: var(--el-tooltip-bg-color, color-mix(in srgb, var(--el-color-black) 88%, transparent));
transform: rotate(45deg);
}
:global(html.dark) .vrcx-native-tooltip__content.has-arrow::before {
background: var(--el-tooltip-bg-color, color-mix(in srgb, var(--el-color-black) 96%, transparent));
}
.vrcx-native-tooltip__content.has-arrow.is-top::before,
.vrcx-native-tooltip__content.has-arrow.is-top-start::before,
.vrcx-native-tooltip__content.has-arrow.is-top-end::before {
left: var(--vrcx-tt-arrow-x, 50%);
bottom: -5px;
translate: -50% 0;
}
.vrcx-native-tooltip__content.has-arrow.is-bottom::before,
.vrcx-native-tooltip__content.has-arrow.is-bottom-start::before,
.vrcx-native-tooltip__content.has-arrow.is-bottom-end::before {
left: var(--vrcx-tt-arrow-x, 50%);
top: -5px;
translate: -50% 0;
}
.vrcx-native-tooltip__content.has-arrow.is-left::before,
.vrcx-native-tooltip__content.has-arrow.is-left-start::before,
.vrcx-native-tooltip__content.has-arrow.is-left-end::before {
top: var(--vrcx-tt-arrow-y, 50%);
right: -5px;
translate: 0 -50%;
}
.vrcx-native-tooltip__content.has-arrow.is-right::before,
.vrcx-native-tooltip__content.has-arrow.is-right-start::before,
.vrcx-native-tooltip__content.has-arrow.is-right-end::before {
top: var(--vrcx-tt-arrow-y, 50%);
left: -5px;
translate: 0 -50%;
}
.vrcx-native-tooltip__content:popover-open.is-open {
opacity: 1;
transition-duration: var(--vrcx-tt-enter);
}
.vrcx-native-tooltip__content:popover-open.is-closing {
opacity: 0;
transition-duration: var(--vrcx-tt-exit);
}
.vrcx-native-tooltip__content.is-top {
left: anchor(center);
bottom: anchor(top);
translate: calc(-50% + var(--vrcx-tt-shift-x)) calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-y));
transform-origin: bottom center;
}
.vrcx-native-tooltip__content.is-top-start {
left: anchor(left);
bottom: anchor(top);
translate: var(--vrcx-tt-shift-x) calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-y));
transform-origin: bottom left;
}
.vrcx-native-tooltip__content.is-top-end {
right: anchor(right);
bottom: anchor(top);
translate: var(--vrcx-tt-shift-x) calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-y));
transform-origin: bottom right;
}
.vrcx-native-tooltip__content.is-bottom {
left: anchor(center);
top: anchor(bottom);
translate: calc(-50% + var(--vrcx-tt-shift-x)) calc(var(--vrcx-tt-offset) + var(--vrcx-tt-shift-y));
transform-origin: top center;
}
.vrcx-native-tooltip__content.is-bottom-start {
left: anchor(left);
top: anchor(bottom);
translate: var(--vrcx-tt-shift-x) calc(var(--vrcx-tt-offset) + var(--vrcx-tt-shift-y));
transform-origin: top left;
}
.vrcx-native-tooltip__content.is-bottom-end {
right: anchor(right);
top: anchor(bottom);
translate: var(--vrcx-tt-shift-x) calc(var(--vrcx-tt-offset) + var(--vrcx-tt-shift-y));
transform-origin: top right;
}
.vrcx-native-tooltip__content.is-left {
right: anchor(left);
top: anchor(center);
translate: calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-x)) calc(-50% + var(--vrcx-tt-shift-y));
transform-origin: center right;
}
.vrcx-native-tooltip__content.is-left-start {
right: anchor(left);
top: anchor(top);
translate: calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-x)) var(--vrcx-tt-shift-y);
transform-origin: top right;
}
.vrcx-native-tooltip__content.is-left-end {
right: anchor(left);
bottom: anchor(bottom);
translate: calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-x)) var(--vrcx-tt-shift-y);
transform-origin: bottom right;
}
.vrcx-native-tooltip__content.is-right {
left: anchor(right);
top: anchor(center);
translate: calc(var(--vrcx-tt-offset) + var(--vrcx-tt-shift-x)) calc(-50% + var(--vrcx-tt-shift-y));
transform-origin: center left;
}
.vrcx-native-tooltip__content.is-right-start {
left: anchor(right);
top: anchor(top);
translate: calc(var(--vrcx-tt-offset) + var(--vrcx-tt-shift-x)) var(--vrcx-tt-shift-y);
transform-origin: top left;
}
.vrcx-native-tooltip__content.is-right-end {
left: anchor(right);
bottom: anchor(bottom);
translate: calc(var(--vrcx-tt-offset) + var(--vrcx-tt-shift-x)) var(--vrcx-tt-shift-y);
transform-origin: bottom left;
}
.vrcx-native-tooltip__content:not([class*='is-']) {
left: anchor(center);
bottom: anchor(top);
translate: calc(-50% + var(--vrcx-tt-shift-x)) calc((-1 * var(--vrcx-tt-offset)) + var(--vrcx-tt-shift-y));
}
.vrcx-native-tooltip__text {
display: block;
white-space: pre-line;
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="x-menu-container nav-menu-container">
<div class="x-menu-container nav-menu-container" :class="{ 'is-collapsed': isCollapsed }">
<template v-if="navLayoutReady">
<div>
<div class="nav-menu-body mt-5">
<div v-if="updateInProgress" class="pending-update" @click="showVRCXUpdateDialog">
<el-progress
type="circle"
@@ -16,80 +16,71 @@
type="success"
plain
style="font-size: 19px; height: 36px; width: 44px; margin: 10px"
@click="showVRCXUpdateDialog"
><i class="ri-download-line"></i
></el-button>
@click="showVRCXUpdateDialog">
<i class="ri-download-line"></i>
</el-button>
</div>
<el-menu collapse :default-active="activeMenuIndex" :collapse-transition="false" ref="navMenuRef">
<el-popover
v-for="item in navMenuItems"
:disabled="!item.entries?.length"
:key="item.index"
:ref="(el) => setNavPopoverRef(el, item.index)"
placement="right-start"
trigger="hover"
:hide-after="isSteamVRRunning ? 400 : 150"
:show-arrow="false"
:offset="0"
:width="navPopoverWidth"
transition="nav-menu-slide"
@before-enter="handleSubMenuBeforeEnter()"
:popper-style="navPopoverStyle"
popper-class="nav-menu-popover-popper">
<div class="nav-menu-popover">
<div class="nav-menu-popover__header">
<i :class="item.icon"></i>
<el-menu ref="navMenuRef" class="nav-menu" :collapse="isCollapsed" :collapse-transition="false">
<template v-for="item in menuItems" :key="item.index">
<el-menu-item
v-if="!item.children?.length"
:index="item.index"
:class="{ notify: isNavItemNotified(item) }"
@click="handleMenuItemClick(item)">
<i :class="item.icon"></i>
<template #title>
<span>{{ item.titleIsCustom ? item.title : t(item.title || '') }}</span>
</div>
<div class="nav-menu-popover__menu">
<button
v-for="entry in item.entries"
:key="entry.label"
type="button"
:class="['nav-menu-popover__menu-item', { notify: isEntryNotified(entry) }]"
@click="handleSubmenuClick(entry, item.index)">
<i v-if="entry.icon" :class="entry.icon" class="nav-menu-popover__menu-icon"></i>
<span class="nav-menu-popover__menu-label">{{ t(entry.label) }}</span>
</button>
</div>
</div>
<template #reference>
</template>
</el-menu-item>
<el-sub-menu v-else :index="item.index">
<template #title>
<div :class="{ notify: isNavItemNotified(item) }">
<i :class="item.icon"></i>
<span v-show="!isCollapsed">{{
item.titleIsCustom ? item.title : t(item.title || '')
}}</span>
</div>
</template>
<el-menu-item
:index="item.index"
:class="{ notify: isNavItemNotified(item) }"
@click="handleMenuItemClick(item)">
<i :class="item.icon"></i>
<template #title v-if="item.tooltip">
<span>{{ item.tooltipIsCustom ? item.tooltip : t(item.tooltip) }}</span>
v-for="entry in item.children"
:key="entry.index"
:index="entry.index"
class="pl-8!"
:class="{ notify: isEntryNotified(entry) }"
@click="handleSubmenuClick(entry, item.index)">
<i v-show="entry.icon" :class="entry.icon"></i>
<template #title>
<span>{{ t(entry.label) }}</span>
</template>
</el-menu-item>
</template>
</el-popover>
</el-sub-menu>
</template>
</el-menu>
<el-divider style="width: calc(100% - 18px); margin-left: 9px"></el-divider>
<NativeTooltip :content="t('prompt.direct_access_omni.header')" placement="right">
<div class="bottom-button" @click="directAccessPaste"><i class="ri-compass-3-line"></i></div>
</NativeTooltip>
</div>
<div class="nav-menu-container-bottom">
<NativeTooltip v-if="branch === 'Nightly'" :show-after="150" :content="'Feedback'" placement="right">
<div class="nav-menu-container-bottom mb-4">
<el-tooltip
v-if="branch === 'Nightly'"
:show-after="150"
:content="'Feedback'"
:disabled="!isCollapsed"
placement="right">
<div
class="bottom-button"
id="feedback"
@click="!sentryErrorReporting && setSentryErrorReporting()">
<i class="ri-feedback-line"></i>
<span v-show="!isCollapsed" class="bottom-button__label">Feedback</span>
</div>
</NativeTooltip>
</el-tooltip>
<el-popover
v-model:visible="supportMenuVisible"
placement="right"
trigger="click"
popper-style="padding:4px;border-radius:8px;"
:offset="4"
:offset="-10"
:show-arrow="false"
:width="200"
:hide-after="0">
@@ -119,11 +110,18 @@
</div>
<template #reference>
<div>
<NativeTooltip :show-after="150" :content="t('nav_tooltip.help_support')" placement="right">
<el-tooltip
:show-after="150"
:content="t('nav_tooltip.help_support')"
placement="right"
:disabled="!isCollapsed">
<div class="bottom-button">
<i class="ri-question-line"></i>
<span v-show="!isCollapsed" class="bottom-button__label">{{
t('nav_tooltip.help_support')
}}</span>
</div>
</NativeTooltip>
</el-tooltip>
</div>
</template>
</el-popover>
@@ -133,7 +131,7 @@
placement="right"
trigger="click"
popper-style="padding:4px;border-radius:8px;"
:offset="4"
:offset="-10"
:show-arrow="false"
:width="200"
:hide-after="0">
@@ -143,7 +141,7 @@
<div class="nav-menu-settings__meta">
<span class="nav-menu-settings__title" @click="openGithub"
>VRCX
<i class="ri-heart-3-fill" style="color: #64cd8a; font-size: 14px"></i>
<i class="ri-heart-3-fill nav-menu-settings__heart"></i>
</span>
<span class="nav-menu-settings__version">{{ version }}</span>
</div>
@@ -170,7 +168,7 @@
:class="{ 'is-active': themeMode === theme }"
@click="handleThemeSelect(theme)">
<span class="nav-menu-theme__label">{{ themeDisplayName(theme) }}</span>
<span v-if="themeMode === theme" class="nav-menu-theme__check"></span>
<span v-if="themeMode === theme" class="nav-menu-theme__check"></span>
</button>
</div>
<template #reference>
@@ -190,9 +188,24 @@
<template #reference>
<div class="bottom-button">
<i class="ri-settings-3-line"></i>
<span v-show="!isCollapsed" class="bottom-button__label">{{
t('nav_tooltip.manage')
}}</span>
</div>
</template>
</el-popover>
<el-tooltip
:show-after="150"
:content="t('nav_tooltip.expand_menu')"
:disabled="!isCollapsed"
placement="right">
<div class="bottom-button" @click="toggleNavCollapse">
<i class="ri-side-bar-line"></i>
<span v-show="!isCollapsed" class="bottom-button__label">{{
t('nav_tooltip.collapse_menu')
}}</span>
</div>
</el-tooltip>
</div>
</template>
</div>
@@ -215,9 +228,9 @@
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useAuthStore,
useGameStore,
useSearchStore,
useUiStore,
useUserStore,
useVRCXUpdaterStore
} from '../stores';
import { THEME_CONFIG, links, navDefinitions } from '../shared/constants';
@@ -226,8 +239,6 @@
import configRepository from '../service/config';
import 'remixicon/fonts/remixicon.css';
const CustomNavDialog = defineAsyncComponent(() => import('./dialogs/CustomNavDialog.vue'));
const { t, locale } = useI18n();
@@ -257,26 +268,13 @@
},
{ type: 'item', key: 'notification' },
{ type: 'item', key: 'charts' },
{ type: 'item', key: 'tools' }
{ type: 'item', key: 'tools' },
{ type: 'item', key: 'direct-access' }
];
const navDefinitionMap = new Map(navDefinitions.map((item) => [item.key, item]));
const DEFAULT_FOLDER_ICON = 'ri-menu-fold-line';
const navPopoverWidth = 250;
const navPopoverStyle = {
zIndex: 500,
borderRadius: '0',
border: '1px solid var(--el-border-color)',
borderLeft: 'none',
borderBottom: 'none',
borderTop: 'none',
boxShadow: '0 8px 20px rgba(0,0,0,0.05)',
padding: '0',
background: 'var(--el-bg-color)',
height: '100vh'
};
const VRCXUpdaterStore = useVRCXUpdaterStore();
const { pendingVRCXUpdate, pendingVRCXInstall, updateInProgress, updateProgress, branch, appVersion } =
storeToRefs(VRCXUpdaterStore);
@@ -288,18 +286,19 @@
const { setSentryErrorReporting } = useAdvancedSettingsStore();
const { logout } = useAuthStore();
const appearanceSettingsStore = useAppearanceSettingsStore();
const { themeMode } = storeToRefs(appearanceSettingsStore);
const { isSteamVRRunning } = storeToRefs(useGameStore());
const { themeMode, isNavCollapsed: isCollapsed } = storeToRefs(appearanceSettingsStore);
const userStore = useUserStore();
const { currentUser } = storeToRefs(userStore);
const { showUserDialog } = userStore;
const settingsMenuVisible = ref(false);
const themeMenuVisible = ref(false);
const supportMenuVisible = ref(false);
const navMenuRef = ref(null);
const navPopoverRefs = new Map();
const navLayout = ref([]);
const navLayoutReady = ref(false);
const navMenuItems = computed(() => {
const menuItems = computed(() => {
const items = [];
navLayout.value.forEach((entry) => {
if (entry.type === 'item') {
@@ -310,7 +309,7 @@
items.push({
...definition,
index: definition.key,
tooltipIsCustom: false,
title: definition.tooltip || definition.labelKey,
titleIsCustom: false
});
return;
@@ -324,7 +323,6 @@
items.push({
...definition,
index: definition.key,
tooltipIsCustom: false,
titleIsCustom: false
});
});
@@ -334,83 +332,41 @@
const folderEntries = folderDefinitions.map((definition) => ({
label: definition.labelKey,
routeName: definition.routeName,
key: definition.key,
icon: definition.icon
index: definition.key,
icon: definition.icon,
action: definition.action
}));
items.push({
index: entry.id,
icon: entry.icon || DEFAULT_FOLDER_ICON,
tooltip: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
tooltipIsCustom: true,
title: entry.name?.trim() || t('nav_menu.custom_nav.folder_name_placeholder'),
titleIsCustom: true,
entries: folderEntries
children: folderEntries
});
}
});
return items;
});
const folderCyclePointers = new Map();
const navigateToFolderEntry = (folderIndex, entry) => {
if (!entry) {
return;
}
if (entry.routeName) {
handleRouteChange(entry.routeName, folderIndex);
return;
}
if (entry.path) {
router.push(entry.path);
if (folderIndex) {
navMenuRef.value?.updateActiveIndex(folderIndex);
}
}
};
const handleFolderCycleNavigation = (item) => {
if (!item?.entries?.length) {
return;
}
const entries = item.entries.filter((entry) => Boolean(entry?.routeName || entry?.path));
if (!entries.length) {
return;
}
let pointer = folderCyclePointers.get(item.index) ?? 0;
if (pointer >= entries.length || pointer < 0) {
pointer = 0;
}
const entry = entries[pointer];
folderCyclePointers.set(item.index, (pointer + 1) % entries.length);
navigateToFolderEntry(item.index, entry);
};
const activeMenuIndex = computed(() => {
const currentRouteName = router.currentRoute.value?.name;
if (!currentRouteName) {
const firstEntry = navLayout.value[0];
if (!firstEntry) {
return 'feed';
}
return firstEntry.type === 'folder' ? firstEntry.id : firstEntry.key;
const currentRoute = router.currentRoute.value;
const currentRouteName = currentRoute?.name;
const navKey = currentRoute?.meta?.navKey || currentRouteName;
if (!navKey) {
return getFirstNavRoute(navLayout.value) || 'feed';
}
for (const entry of navLayout.value) {
if (entry.type === 'item' && entry.key === currentRouteName) {
if (entry.type === 'item' && entry.key === navKey) {
return entry.key;
}
if (entry.type === 'folder' && entry.items?.includes(currentRouteName)) {
return entry.id;
if (entry.type === 'folder' && entry.items?.includes(navKey)) {
return navKey;
}
}
const fallback = navLayout.value[0];
if (!fallback) {
return 'feed';
}
return fallback.type === 'folder' ? fallback.id : fallback.key;
return getFirstNavRoute(navLayout.value) || 'feed';
});
const version = computed(() => appVersion.value?.split('VRCX ')?.[1] || '-');
@@ -448,6 +404,10 @@
const generateFolderId = () => `nav-folder-${dayjs().toISOString()}-${Math.random().toString().slice(2, 4)}`;
const showCurrentUserDialog = () => {
showUserDialog(currentUser.value?.id);
};
const sanitizeLayout = (layout) => {
const usedKeys = new Set();
const normalized = [];
@@ -627,52 +587,47 @@
if (notifiedMenus.value.includes(item.index)) {
return true;
}
if (item.entries?.length) {
return item.entries.some((entry) => isEntryNotified(entry));
if (item.children?.length) {
return item.children.some((entry) => isEntryNotified(entry));
}
return false;
};
const setNavPopoverRef = (el, index) => {
if (!index) {
return;
}
if (el) {
navPopoverRefs.set(index, el);
} else {
navPopoverRefs.delete(index);
}
};
const closeNavPopover = (index) => {
navPopoverRefs.get(index)?.hide?.();
};
const handleSubmenuClick = (entry, index) => {
if (!entry) {
return;
}
const entries = navMenuItems.value.find((item) => item.index === index)?.entries || [];
const indexOfEntry = entries.findIndex((e) => e.label === entry.label);
folderCyclePointers.set(index, (indexOfEntry + 1) % entries.length);
if (entry.routeName) {
handleRouteChange(entry.routeName, index || entry.routeName);
} else if (entry.path) {
router.push(entry.path);
if (index) {
navMenuRef.value?.updateActiveIndex(index);
}
}
closeNavPopover(index);
};
const handleSubMenuBeforeEnter = () => {
const closeNavFlyouts = () => {
settingsMenuVisible.value = false;
supportMenuVisible.value = false;
themeMenuVisible.value = false;
};
const triggerNavAction = (entry, navIndex = entry?.index) => {
if (!entry) {
return;
}
if (entry.action === 'direct-access') {
closeNavFlyouts();
directAccessPaste();
if (navIndex) {
navMenuRef.value?.updateActiveIndex(navIndex);
}
return;
}
if (entry.routeName) {
handleRouteChange(entry.routeName, navIndex);
closeNavFlyouts();
return;
}
if (entry.path) {
router.push(entry.path);
if (navIndex) {
navMenuRef.value?.updateActiveIndex(navIndex);
}
closeNavFlyouts();
}
};
const handleRouteChange = (routeName, navIndex = routeName) => {
if (!routeName) {
return;
@@ -697,17 +652,23 @@
}
});
const getFirstNavRoute = (layout) => {
function getFirstNavRoute(layout) {
for (const entry of layout) {
if (entry.type === 'item') {
return entry.key;
const definition = navDefinitionMap.get(entry.key);
if (definition?.routeName) {
return definition.routeName;
}
}
if (entry.type === 'folder' && entry.items?.length) {
return entry.items[0];
const definition = entry.items.map((key) => navDefinitionMap.get(key)).find((def) => def?.routeName);
if (definition?.routeName) {
return definition.routeName;
}
}
}
return null;
};
}
let hasNavigatedToInitialRoute = false;
const navigateToFirstNavEntry = () => {
@@ -724,15 +685,17 @@
}
};
const handleSubmenuClick = (entry, index) => {
const navIndex = index || entry?.index;
triggerNavAction(entry, navIndex);
};
const handleMenuItemClick = (item) => {
if (!item) {
return;
}
if (item.entries?.length) {
handleFolderCycleNavigation(item);
return;
}
handleRouteChange(item.routeName, item.index);
triggerNavAction(item, item?.index);
};
const toggleNavCollapse = () => {
appearanceSettingsStore.toggleNavCollapsed();
};
onMounted(async () => {
@@ -755,146 +718,147 @@
:deep(.el-divider) {
margin: 0;
}
.nav-menu-container {
position: relative;
width: 240px;
height: 100%;
display: flex;
flex: 0 0 240px;
flex-direction: column;
align-items: center;
justify-content: space-between;
align-items: stretch;
justify-content: flex-start;
z-index: 600;
background-color: var(--el-bg-color);
border-right: 1px solid var(--el-border-color);
background-color: var(--el-bg-color-page);
box-shadow: none;
.el-menu {
background: 0;
border: 0;
}
.el-menu-item i[class*='ri-'] {
font-size: 19px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
vertical-align: middle;
}
.bottom-button {
font-size: 19px;
width: 64px;
height: 56px;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
vertical-align: middle;
cursor: pointer;
}
.bottom-button:hover {
background-color: var(--el-menu-hover-bg-color);
transition:
border-color var(--el-transition-duration),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
.nav-menu-container-bottom {
display: flex;
flex-direction: column;
backdrop-filter: blur(14px) saturate(130%);
}
.nav-menu-body {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden auto;
align-items: center;
}
.nav-menu {
background: transparent;
border: 0;
width: 100%;
}
.nav-menu :deep(.el-menu-item),
.nav-menu :deep(.el-sub-menu__title) {
height: 46px;
line-height: 46px;
display: flex;
align-items: center;
column-gap: 10px;
font-size: 13px;
padding: 0 20px !important;
}
.nav-menu :deep(.el-menu-item i[class*='ri-']),
.nav-menu :deep(.el-sub-menu__title i[class*='ri-']) {
font-size: 19px;
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
vertical-align: middle;
line-height: 1;
flex-shrink: 0;
}
.nav-menu :deep(.el-sub-menu__title > div) {
display: inline-flex;
align-items: center;
gap: 10px;
}
.nav-menu :deep(.el-sub-menu__icon-arrow) {
right: 8px;
}
.bottom-button {
font-size: 19px;
width: 100%;
height: 46px;
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 20px;
text-align: left;
vertical-align: middle;
cursor: pointer;
box-sizing: border-box;
& > span {
font-size: 13px;
}
}
.nav-menu-popover {
.bottom-button i {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.bottom-button__label {
font-size: 13px;
color: var(--el-text-color-regular);
white-space: nowrap;
}
.bottom-button:hover {
background-color: var(--el-menu-hover-bg-color);
transition:
border-color var(--el-transition-duration),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
.nav-menu-container-bottom {
display: flex;
flex-direction: column;
height: 100%;
min-width: 240px;
background-color: var(--el-bg-color);
border-left: 1px solid var(--el-border-color);
overflow: hidden;
}
.nav-menu-popover__header {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 0 20px;
border-bottom: 1px solid var(--el-border-color-light, var(--el-border-color));
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.nav-menu-container.is-collapsed .nav-menu :deep(.el-menu-item),
.nav-menu-container.is-collapsed .nav-menu :deep(.el-sub-menu__title) {
column-gap: 0;
justify-content: center;
padding: 0;
}
.nav-menu-popover__header i {
font-size: 18px;
color: var(--el-color-primary);
}
.nav-menu-container.is-collapsed {
width: 64px;
flex-basis: 64px;
}
.nav-menu-popover__menu {
display: flex;
flex-direction: column;
flex: 1;
gap: 6px;
padding: 12px 12px 16px;
overflow-y: auto;
scrollbar-width: thin;
}
.nav-menu-container.is-collapsed .nav-menu :deep(.el-sub-menu__title > div) {
gap: 0;
}
.nav-menu-popover__menu::-webkit-scrollbar {
width: 6px;
}
.nav-menu-container.is-collapsed .bottom-button {
width: 100%;
justify-content: center;
gap: 0;
padding: 0;
text-align: center;
}
.nav-menu-popover__menu::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.18);
border-radius: 3px;
}
:deep(.el-menu-item .el-menu-tooltip__trigger) {
justify-content: center;
}
.nav-menu-popover__menu::-webkit-scrollbar-track {
background: transparent;
}
.nav-menu-popover__menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: none;
background: transparent;
text-align: left;
color: var(--el-text-color-primary);
font-size: 13px;
border-radius: 6px;
cursor: pointer;
transition: background-color var(--el-transition-duration);
}
.nav-menu-popover__menu-item.notify::after {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--el-text-color-primary);
margin-left: auto;
}
.nav-menu-popover__menu-item:hover {
background-color: var(--el-menu-hover-bg-color);
}
.nav-menu-popover__menu-item:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
.nav-menu-popover__menu-icon {
font-size: 16px;
color: var(--el-text-color-secondary);
}
.nav-menu-popover__menu-label {
font-weight: 600;
}
:deep(.el-button.is-text:not(.is-disabled):hover) {
background-color: var(--el-menu-hover-bg-color);
}
.nav-menu-settings {
@@ -930,6 +894,11 @@
cursor: pointer;
}
.nav-menu-settings__heart {
font-size: 14px;
color: var(--el-color-success);
}
.nav-menu-settings__version {
font-size: 11px;
}
@@ -942,8 +911,8 @@
width: 100%;
border: none;
background: transparent;
color: var(--el-text-color-primary);
font-size: 14px;
color: var(--el-text-color-regular);
font-size: 13px;
border-radius: 4px;
transition: background-color var(--el-transition-duration);
cursor: pointer;
@@ -962,7 +931,7 @@
}
.nav-menu-settings__item--danger:hover {
background-color: rgba(245, 108, 108, 0.18);
background-color: color-mix(in oklch, var(--el-color-danger) 18%, transparent);
}
}
@@ -971,23 +940,6 @@
flex-direction: column;
gap: 8px;
.nav-menu-support__search {
padding: 10px 12px;
border-radius: 8px;
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
.nav-menu-support__heading {
padding: 4px 12px 0;
font-size: 13px;
font-weight: 700;
color: var(--el-text-color-primary);
}
.nav-menu-support__section {
display: flex;
flex-direction: column;
@@ -1015,7 +967,6 @@
padding: 6px 10px;
border: none;
background: transparent;
color: var(--el-text-color-primary);
font-size: 13px;
border-radius: 6px;
transition: background-color var(--el-transition-duration);
@@ -1031,18 +982,4 @@
background-color: var(--el-menu-hover-bg-color);
}
}
:global(.nav-menu-slide-enter-active),
:global(.nav-menu-slide-leave-active) {
transition:
opacity 0.1s ease,
transform 0.1s ease;
transform-origin: left center;
}
:global(.nav-menu-slide-enter-from),
:global(.nav-menu-slide-leave-to) {
opacity: 0;
transform: translateX(-12px);
}
</style>

View File

@@ -4,7 +4,8 @@
class="x-dialog x-avatar-dialog"
v-model="avatarDialog.visible"
:show-close="false"
width="700px">
top="10vh"
width="930px">
<div v-loading="avatarDialog.loading">
<div style="display: flex">
<img
@@ -246,11 +247,7 @@
style="margin-left: 5px"
@click="selectAvatarWithoutConfirmation(avatarDialog.id)"></el-button>
</el-tooltip>
<el-dropdown
trigger="click"
size="small"
style="margin-left: 5px"
@command="avatarDialogCommand">
<el-dropdown trigger="click" style="margin-left: 5px" @command="avatarDialogCommand">
<el-button
:type="avatarDialog.isBlocked ? 'danger' : 'default'"
:icon="MoreFilled"

View File

@@ -321,7 +321,7 @@
}
</script>
<style lang="scss" scoped>
<style scoped>
.img-size {
width: 500px;
height: 375px;

View File

@@ -68,12 +68,12 @@
<span
v-if="avatar.releaseStatus === 'public'"
class="extra"
style="color: #67c23a"
style="color: var(--el-color-success)"
v-text="avatar.releaseStatus"></span>
<span
v-else-if="avatar.releaseStatus === 'private'"
class="extra"
style="color: #f56c6c"
style="color: var(--el-color-danger)"
v-text="avatar.releaseStatus"></span>
<span v-else class="extra" v-text="avatar.releaseStatus"></span>
<span class="extra" v-text="avatarTagStrings.get(avatar.id)"></span>

View File

@@ -329,8 +329,9 @@
);
const openFolderEditor = (index) => {
folderEditor.isEditing = !!index;
folderEditor.index = folderEditor.isEditing ? index : -1;
const isEditing = index !== undefined && index !== null;
folderEditor.isEditing = isEditing;
folderEditor.index = isEditing ? index : -1;
if (folderEditor.isEditing) {
const entry = localLayout.value[index];
folderEditor.data = {

View File

@@ -3,7 +3,8 @@
:z-index="groupDialogIndex"
v-model="groupDialog.visible"
:show-close="false"
width="770px"
top="10vh"
width="930px"
class="x-dialog x-group-dialog">
<div v-loading="groupDialog.loading" class="group-body">
<div style="display: flex">
@@ -258,11 +259,7 @@
@click="joinGroup(groupDialog.id)"></el-button>
</el-tooltip>
</template>
<el-dropdown
trigger="click"
size="small"
style="margin-left: 5px"
@command="groupDialogCommand">
<el-dropdown trigger="click" style="margin-left: 5px" @command="groupDialogCommand">
<el-button
:type="groupDialog.ref.membershipStatus === 'userblocked' ? 'danger' : 'default'"
:icon="MoreFilled"
@@ -616,7 +613,8 @@
<span class="name">{{ t('dialog.group.info.links') }}</span>
<div
v-if="groupDialog.ref.links && groupDialog.ref.links.length > 0"
style="margin-top: 5px">
style="margin-top: 5px"
class="flex">
<template v-for="(link, index) in groupDialog.ref.links" :key="index">
<el-tooltip v-if="link">
<template #content>
@@ -1086,7 +1084,6 @@
<el-tabs
v-model="groupDialogGalleryCurrentName"
v-loading="isGroupGalleryLoading"
type="card"
style="margin-top: 10px">
<template v-for="(gallery, index) in groupDialog.ref.galleries" :key="index">
<el-tab-pane>
@@ -1839,7 +1836,7 @@
}
}
</script>
<style lang="scss" scoped>
<style scoped>
.time-group-container {
display: flex;
flex-direction: column;

View File

@@ -7,7 +7,7 @@
width="90vw">
<div>
<h3>{{ groupMemberModeration.groupRef.name }}</h3>
<el-tabs type="card" style="height: 100%">
<el-tabs style="height: 100%">
<el-tab-pane :label="t('dialog.group_member_moderation.members')">
<div style="margin-top: 10px">
<el-button

View File

@@ -5,7 +5,7 @@
:title="t('dialog.new_instance.header')"
width="650px"
append-to-body>
<el-tabs v-model="newInstanceDialog.selectedTab" type="card" @tab-click="newInstanceTabClick">
<el-tabs v-model="newInstanceDialog.selectedTab" @tab-click="newInstanceTabClick">
<el-tab-pane name="Normal" :label="t('dialog.new_instance.normal')">
<el-form :model="newInstanceDialog" label-width="150px">
<el-form-item :label="t('dialog.new_instance.access_type')">

View File

@@ -63,14 +63,7 @@
</div>
<template #footer>
<el-button
size="small"
@click="
redirectToToolsTab();
showGalleryDialog();
"
>{{ t('dialog.boop_dialog.emoji_manager') }}</el-button
>
<el-button size="small" @click="showGalleryPage">{{ t('dialog.boop_dialog.emoji_manager') }}</el-button>
<el-button size="small" @click="closeDialog">{{ t('dialog.boop_dialog.cancel') }}</el-button>
<el-button size="small" :disabled="!sendBoopDialog.userId" @click="sendBoop">{{
t('dialog.boop_dialog.send')
@@ -87,7 +80,6 @@
import { notificationRequest, userRequest } from '../../api';
import { miscRequest } from '../../api';
import { photonEmojis } from '../../shared/constants/photon.js';
import { redirectToToolsTab } from '../../shared/utils/base/ui';
import { useGalleryStore } from '../../stores';
import { useNotificationStore } from '../../stores';
import { useUserStore } from '../../stores/user.js';
@@ -98,7 +90,7 @@
const { sendBoopDialog } = storeToRefs(useUserStore());
const { notificationTable } = storeToRefs(useNotificationStore());
const { showGalleryDialog, refreshEmojiTable } = useGalleryStore();
const { showGalleryPage, refreshEmojiTable } = useGalleryStore();
const { emojiTable } = storeToRefs(useGalleryStore());
const { isLocalUserVrcPlusSupporter } = storeToRefs(useUserStore());

View File

@@ -21,7 +21,7 @@
@click="userDialogCommand('Add Favorite')"></el-button>
</el-tooltip>
</template>
<el-dropdown trigger="click" size="small" @command="onCommand">
<el-dropdown trigger="click" @command="onCommand">
<el-button
:type="
userDialog.incomingRequest || userDialog.outgoingRequest
@@ -132,7 +132,7 @@
:icon="CircleCheck"
command="Moderation Unblock"
divided
style="color: #f56c6c">
style="color: var(--el-color-danger)">
{{ t('dialog.user.actions.moderation_unblock') }}
</el-dropdown-item>
<el-dropdown-item
@@ -147,7 +147,7 @@
v-if="userDialog.isMute"
:icon="Microphone"
command="Moderation Unmute"
style="color: #f56c6c">
style="color: var(--el-color-danger)">
{{ t('dialog.user.actions.moderation_unmute') }}
</el-dropdown-item>
<el-dropdown-item
@@ -161,7 +161,7 @@
v-if="userDialog.isMuteChat"
:icon="ChatLineRound"
command="Moderation Enable Chatbox"
style="color: #f56c6c">
style="color: var(--el-color-danger)">
{{ t('dialog.user.actions.moderation_enable_chatbox') }}
</el-dropdown-item>
<el-dropdown-item v-else :icon="ChatDotRound" command="Moderation Disable Chatbox">
@@ -179,7 +179,7 @@
v-if="userDialog.isInteractOff"
:icon="Pointer"
command="Moderation Enable Avatar Interaction"
style="color: #f56c6c">
style="color: var(--el-color-danger)">
{{ t('dialog.user.actions.moderation_enable_avatar_interaction') }}
</el-dropdown-item>
<el-dropdown-item v-else :icon="CircleClose" command="Moderation Disable Avatar Interaction">
@@ -189,7 +189,11 @@
{{ t('dialog.user.actions.report_hacking') }}
</el-dropdown-item>
<template v-if="userDialog.isFriend">
<el-dropdown-item :icon="Delete" command="Unfriend" divided style="color: #f56c6c">
<el-dropdown-item
:icon="Delete"
command="Unfriend"
divided
style="color: var(--el-color-danger)">
{{ t('dialog.user.actions.unfriend') }}
</el-dropdown-item>
</template>

View File

@@ -5,7 +5,7 @@
v-model="userDialog.visible"
:show-close="false"
top="10vh"
width="940px">
width="930px">
<div v-loading="userDialog.loading">
<UserSummaryHeader
:get-user-state-text="getUserStateText"
@@ -252,7 +252,7 @@
style="margin-left: 5px; padding: 0"
@click="showBioDialog"></el-button>
</div>
<div style="margin-top: 5px" class="flex">
<div style="margin-top: 5px" class="flex items-center">
<el-tooltip v-for="(link, index) in userDialog.ref.bioLinks" :key="index">
<template #content>
<span v-text="link"></span>
@@ -426,10 +426,13 @@
<div class="x-friend-item" @click="toggleAvatarCopying">
<div class="detail">
<span class="name">{{ t('dialog.user.info.avatar_cloning') }}</span>
<span v-if="currentUser.allowAvatarCopying" class="extra" style="color: #67c23a">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="extra" style="color: #f56c6c">{{
<span
v-if="currentUser.allowAvatarCopying"
class="extra"
style="color: var(--el-color-success)"
>{{ t('dialog.user.info.avatar_cloning_allow') }}</span
>
<span v-else class="extra" style="color: var(--el-color-danger)">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
@@ -437,10 +440,13 @@
<div class="x-friend-item" @click="toggleAllowBooping">
<div class="detail">
<span class="name">{{ t('dialog.user.info.booping') }}</span>
<span v-if="currentUser.isBoopingEnabled" class="extra" style="color: #67c23a">{{
t('dialog.user.info.avatar_cloning_allow')
}}</span>
<span v-else class="extra" style="color: #f56c6c">{{
<span
v-if="currentUser.isBoopingEnabled"
class="extra"
style="color: var(--el-color-success)"
>{{ t('dialog.user.info.avatar_cloning_allow') }}</span
>
<span v-else class="extra" style="color: var(--el-color-danger)">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
@@ -451,10 +457,10 @@
<span
v-if="!currentUser.hasSharedConnectionsOptOut"
class="extra"
style="color: #67c23a"
style="color: var(--el-color-success)"
>{{ t('dialog.user.info.avatar_cloning_allow') }}</span
>
<span v-else class="extra" style="color: #f56c6c">{{
<span v-else class="extra" style="color: var(--el-color-danger)">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
@@ -467,10 +473,10 @@
<span
v-if="userDialog.ref.allowAvatarCopying"
class="extra"
style="color: #67c23a"
style="color: var(--el-color-success)"
>{{ t('dialog.user.info.avatar_cloning_allow') }}</span
>
<span v-else class="extra" style="color: #f56c6c">{{
<span v-else class="extra" style="color: var(--el-color-danger)">{{
t('dialog.user.info.avatar_cloning_deny')
}}</span>
</div>
@@ -634,9 +640,10 @@
t('dialog.user.groups.total_count', { count: userGroups.groups.length })
}}</span>
<template v-if="userDialogGroupEditMode">
<span style="margin-left: 10px; color: #909399; font-size: 10px">{{
t('dialog.user.groups.hold_shift')
}}</span>
<span
style="margin-left: 10px; color: var(--el-text-color-secondary); font-size: 10px"
>{{ t('dialog.user.groups.hold_shift') }}</span
>
</template>
</div>
<div style="display: flex; align-items: center">
@@ -872,7 +879,7 @@
size="small"
:icon="Close"
circle
style="color: #f56c6c; margin-left: 5px"
style="color: var(--el-color-danger); margin-left: 5px"
@click.stop="leaveGroup(group.id)">
</el-button>
<el-button
@@ -892,7 +899,7 @@
<span style="font-weight: bold; font-size: 16px">{{
t('dialog.user.groups.own_groups')
}}</span>
<span style="color: #909399; font-size: 12px; margin-left: 5px"
<span style="color: var(--el-text-color-secondary); font-size: 12px; margin-left: 5px"
>{{ userGroups.ownGroups.length }}/{{
cachedConfig?.constants?.GROUPS?.MAX_OWNED
}}</span
@@ -936,9 +943,10 @@
<span style="font-weight: bold; font-size: 16px">{{
t('dialog.user.groups.mutual_groups')
}}</span>
<span style="color: #909399; font-size: 12px; margin-left: 5px">{{
userGroups.mutualGroups.length
}}</span>
<span
style="color: var(--el-text-color-secondary); font-size: 12px; margin-left: 5px"
>{{ userGroups.mutualGroups.length }}</span
>
<div
class="x-friend-list"
style="margin-top: 10px; margin-bottom: 15px; min-height: 60px">
@@ -978,7 +986,7 @@
<span style="font-weight: bold; font-size: 16px">{{
t('dialog.user.groups.groups')
}}</span>
<span style="color: #909399; font-size: 12px; margin-left: 5px">
<span style="color: var(--el-text-color-secondary); font-size: 12px; margin-left: 5px">
{{ userGroups.remainingGroups.length }}
<template v-if="currentUser.id === userDialog.id">
/
@@ -1143,7 +1151,12 @@
:class="userFavoriteWorldsStatus(list[1])">
</i>
<span style="font-weight: bold; font-size: 14px" v-text="list[0]"></span>
<span style="color: #909399; font-size: 10px; margin-left: 5px"
<span
style="
color: var(--el-text-color-secondary);
font-size: 10px;
margin-left: 5px;
"
>{{ list[2].length }}/{{ favoriteLimits.maxFavoritesPerGroup.world }}</span
>
</span>
@@ -1272,13 +1285,13 @@
<span
v-if="avatar.releaseStatus === 'public'"
class="extra"
style="color: #67c23a"
style="color: var(--el-color-success)"
v-text="avatar.releaseStatus">
</span>
<span
v-else-if="avatar.releaseStatus === 'private'"
class="extra"
style="color: #f56c6c"
style="color: var(--el-color-danger)"
v-text="avatar.releaseStatus">
</span>
<span v-else class="extra" v-text="avatar.releaseStatus"></span>
@@ -1400,11 +1413,11 @@
userRequest,
worldRequest
} from '../../../api';
import { getNextDialogIndex, redirectToToolsTab } from '../../../shared/utils/base/ui';
import { processBulk, request } from '../../../service/request';
import { userDialogGroupSortingOptions, userDialogMutualFriendSortingOptions } from '../../../shared/constants';
import { userDialogWorldOrderOptions, userDialogWorldSortingOptions } from '../../../shared/constants/';
import { database } from '../../../service/database';
import { getNextDialogIndex } from '../../../shared/utils/base/ui';
import SendInviteDialog from '../InviteDialog/SendInviteDialog.vue';
import UserSummaryHeader from './UserSummaryHeader.vue';
@@ -1455,7 +1468,7 @@
const { refreshInviteMessageTableData } = useInviteStore();
const { friendLogTable } = storeToRefs(useFriendStore());
const { getFriendRequest, handleFriendDelete } = useFriendStore();
const { clearInviteImageUpload, showFullscreenImageDialog } = useGalleryStore();
const { clearInviteImageUpload, showFullscreenImageDialog, showGalleryPage } = useGalleryStore();
const { logout } = useAuthStore();
const { cachedConfig } = storeToRefs(useAuthStore());
@@ -1896,6 +1909,9 @@
}
} else if (command === 'Previous Instances') {
showPreviousInstancesUserDialog(D.ref);
} else if (command === 'Manage Gallery') {
userDialog.value.visible = false;
showGalleryPage();
} else if (command === 'Invite To Group') {
showInviteGroupDialog('', D.id);
} else if (command === 'Send Boop') {

View File

@@ -316,7 +316,7 @@
}
</script>
<style lang="scss" scoped>
<style scoped>
.img-size {
width: 500px;
height: 375px;

View File

@@ -3,8 +3,9 @@
:z-index="worldDialogIndex"
class="x-dialog x-world-dialog"
v-model="isDialogVisible"
top="10vh"
:show-close="false"
width="770px">
width="930px">
<div v-loading="worldDialog.loading">
<div style="display: flex">
<img
@@ -204,11 +205,7 @@
style="margin-left: 5px"
@click="worldDialogCommand('Add Favorite')" />
</el-tooltip>
<el-dropdown
trigger="click"
size="small"
style="margin-left: 5px"
@command="worldDialogCommand">
<el-dropdown trigger="click" style="margin-left: 5px" @command="worldDialogCommand">
<el-button type="default" :icon="MoreFilled" size="large" circle />
<template #dropdown>
<el-dropdown-menu>
@@ -302,7 +299,10 @@
command="Delete Persistent Data">
{{ t('dialog.world.actions.delete_persistent_data') }}
</el-dropdown-item>
<el-dropdown-item :icon="Delete" command="Delete" style="color: #f56c6c">
<el-dropdown-item
:icon="Delete"
command="Delete"
style="color: var(--el-color-danger)">
{{ t('dialog.world.actions.delete') }}
</el-dropdown-item>
</template>

View File

@@ -0,0 +1,141 @@
import { ref } from 'vue';
import colors from 'tailwindcss/colors';
import configRepository from '../service/config';
// Tailwind indigo-500 in OKLCH
const DEFAULT_PRIMARY = 'oklch(58.5% 0.233 277.117)';
const DARK_WEIGHT = 0.2;
const CONFIG_KEY = 'VRCX_elPrimaryColor';
const STYLE_ID = 'el-dynamic-theme';
let elementThemeInstance = null;
/**
* Keep okLCH as-is; otherwise normalize hex; fallback to default.
* @param {string} color
* @param {string} fallback
*/
function toPrimaryColor(color, fallback = DEFAULT_PRIMARY) {
if (typeof color === 'string' && color.trim()) {
if (color.trim().startsWith('oklch(')) {
return color.trim();
}
}
return fallback;
}
/**
* Update Element Plus CSS variables based on a primary color.
* Light colors use Tailwind palette directly; only dark-2 is calculated.
* Dark mode overrides light-9 with a softer tint for better contrast.
* @param {string} primary
* @param {object|null} palette
*/
function setElementPlusColors(primary, palette = null) {
let styleEl = document.getElementById(STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
document.head.appendChild(styleEl);
}
// Derive Element Plus light steps either from a palette or by mixing with white.
const safePalette = palette || null;
const lightValues = safePalette
? ['400', '300', '200', '100', '50', '50', '50', '50', '50'].map(
(key) => safePalette[key] || primary
)
: Array.from({ length: 9 }, (_, idx) => {
const whitePercent = (idx + 1) * 10;
const primaryPercent = 100 - whitePercent;
return `color-mix(in oklch, ${primary} ${primaryPercent}%, white ${whitePercent}%)`;
});
const lights = lightValues
.map(
(value, index) =>
` --el-color-primary-light-${index + 1}: ${value};`
)
.join('\n');
const darkPercent = DARK_WEIGHT * 100;
const primaryPercent = 100 - darkPercent;
const darkValue = `color-mix(in oklch, ${primary} ${primaryPercent}%, black ${darkPercent}%)`;
const darkLight9 = `color-mix(in oklch, ${primary} 18%, transparent)`;
const baseSelector =
":root, html.dark, :root.dark, :root[data-theme='dark']";
const darkSelector = "html.dark, :root.dark, :root[data-theme='dark']";
styleEl.textContent =
`${baseSelector} {\n --el-color-primary: ${primary};\n${lights}\n --el-color-primary-dark-2: ${darkValue};\n}\n` +
`${darkSelector} {\n --el-color-primary-light-9: ${darkLight9};\n}`;
}
function findTailwindPalette(primary) {
const entries = Object.values(colors);
for (const palette of entries) {
if (
palette &&
typeof palette === 'object' &&
palette['500'] === primary
) {
return palette;
}
}
return null;
}
/**
* Shared Element Plus theme controller.
* @param {string} defaultColor
*/
export function useElementTheme(defaultColor = DEFAULT_PRIMARY) {
if (elementThemeInstance) {
return elementThemeInstance;
}
const currentPrimary = ref(defaultColor);
const isApplying = ref(false);
let initialized = false;
const applyPrimaryColor = async (color, palette = null) => {
const nextColor = toPrimaryColor(color, currentPrimary.value);
const effectivePalette = palette || findTailwindPalette(nextColor);
isApplying.value = true;
setElementPlusColors(nextColor, effectivePalette);
currentPrimary.value = nextColor;
try {
await configRepository.setString(CONFIG_KEY, nextColor);
} catch (error) {
console.warn('Failed to persist theme color', error);
} finally {
isApplying.value = false;
}
};
const initPrimaryColor = async (fallbackColor = currentPrimary.value) => {
if (initialized) {
return;
}
initialized = true;
const storedColor =
(await configRepository.getString(CONFIG_KEY)) ||
fallbackColor ||
DEFAULT_PRIMARY;
await applyPrimaryColor(storedColor);
};
elementThemeInstance = {
currentPrimary,
isApplying,
applyPrimaryColor,
initPrimaryColor
};
return elementThemeInstance;
}
export { toPrimaryColor };

View File

@@ -0,0 +1,44 @@
import { onMounted, onUnmounted, ref } from 'vue';
export function useTableHeight(tableRef, options = {}) {
const containerRef = ref(null);
const offset = options.offset ?? 127;
const immediate = options.immediate ?? true;
let resizeObserver;
const setTableHeight = () => {
if (!tableRef?.value || !containerRef.value) {
return;
}
tableRef.value.tableProps = {
...(tableRef.value.tableProps || {}),
// @ts-ignore default is null
height: containerRef.value.clientHeight - offset
};
};
onMounted(() => {
if (immediate) {
setTableHeight();
}
resizeObserver = new ResizeObserver(() => {
setTableHeight();
});
if (containerRef.value) {
resizeObserver.observe(containerRef.value);
}
});
onUnmounted(() => {
resizeObserver?.disconnect();
});
return {
containerRef,
setTableHeight
};
}

View File

@@ -11,8 +11,6 @@
<title>VRCX</title>
<!-- <link rel="stylesheet" href="app.css" /> -->
<link rel="preconnect" href="https://api.vrchat.cloud" />
<link rel="preconnect" href="https://files.vrchat.cloud" />
<link rel="preconnect" href="https://d348imysud55la.cloudfront.net" />

View File

@@ -1782,6 +1782,8 @@
"notification": {
"date": "Date",
"type": "Type",
"user": "User",
"group": "Group",
"user_group": "User/Group",
"photo": "Photo",
"message": "Message",

View File

@@ -21,7 +21,10 @@
"about": "About",
"profile": "Profile",
"settings": "Settings",
"help_support": "Help & Support"
"manage": "Manage",
"help_support": "Help & Support",
"expand_menu": "Expand Menu",
"collapse_menu": "Collapse Menu"
},
"nav_menu": {
"resources": "RESOURCES",
@@ -292,6 +295,10 @@
"bulk_unfriend_selection": "Bulk Unfriend Selection",
"load": "Load missing entries",
"load_tooltip": "Load",
"load_dialog_title": "Load missing entries",
"load_dialog_message": "Retrieving missing profile fields for your friends.",
"load_cancel": "Cancel",
"load_complete": "Missing entries loaded",
"favorites_only_tooltip": "Filter favorites only",
"search_placeholder": "Search",
"filter_placeholder": "Filter",
@@ -560,7 +567,8 @@
"table_max_size": "Table Max Size",
"table_page_sizes": "Table Page Sizes",
"table_page_sizes_error": "Page size must be a number between 1 and 1000",
"show_notification_icon_dot": "Show Tray Notification Dot"
"show_notification_icon_dot": "Show Tray Notification Dot",
"compact_table_mode": "Compact Table Mode"
},
"timedate": {
"header": "Time/Date",
@@ -569,6 +577,9 @@
"time_format_12": "12 Hour",
"force_iso_date_format": "Force ISO Date Format"
},
"theme_color": {
"header": "Theme Color"
},
"side_panel": {
"header": "Side Panel",
"sorting": {
@@ -873,7 +884,7 @@
}
},
"side_panel": {
"search_placeholder": "Search",
"search_placeholder": "Search Friend",
"search_result_active": "Offline",
"search_result_offline": "Active",
"search_result_more": "Search More:",
@@ -2254,6 +2265,8 @@
"notification": {
"date": "Date",
"type": "Type",
"user": "User",
"group": "Group",
"user_group": "User/Group",
"photo": "Photo",
"message": "Message",

View File

@@ -1899,6 +1899,8 @@
"notification": {
"date": "Fecha",
"type": "Tipo",
"user": "Usuario",
"group": "Grupo",
"user_group": "Usuario/Grupo",
"photo": "Foto",
"message": "Mensaje",

View File

@@ -1788,6 +1788,8 @@
"notification": {
"date": "Date",
"type": "Type",
"user": "Utilisateur",
"group": "Groupe",
"user_group": "Utilisateur/Groupe",
"photo": "Photo",
"message": "Message",

View File

@@ -1660,6 +1660,8 @@
"notification": {
"date": "Date",
"type": "Type",
"user": "Felhasználó",
"group": "Csoport",
"user_group": "User/Group",
"photo": "Photo",
"message": "Message",

View File

@@ -2082,6 +2082,8 @@
"notification": {
"date": "日付",
"type": "種類",
"user": "ユーザー",
"group": "グループ",
"user_group": "ユーザーまたはグループ",
"photo": "画像",
"message": "メッセージ",

View File

@@ -1674,6 +1674,8 @@
"notification": {
"date": "날짜",
"type": "유형",
"user": "유저",
"group": "그룹",
"user_group": "User/Group",
"photo": "사진",
"message": "메시지",

View File

@@ -2225,6 +2225,8 @@
"notification": {
"date": "Data",
"type": "Typ",
"user": "Użytkownik",
"group": "Grupa",
"user_group": "Użytkownik/Grupa",
"photo": "Obrazek",
"message": "Wiadomość",

View File

@@ -1660,6 +1660,8 @@
"notification": {
"date": "Data",
"type": "Tipo",
"user": "Usuário",
"group": "Grupo",
"user_group": "Usuário/Grupo",
"photo": "Foto",
"message": "Mensagem",

View File

@@ -2069,6 +2069,8 @@
"notification": {
"date": "Дата",
"type": "Тип",
"user": "Пользователь",
"group": "Группа",
"user_group": "Пользователь/Группа",
"photo": "Фото",
"message": "Сообщение",

View File

@@ -1964,6 +1964,8 @@
"notification": {
"date": "วันที่",
"type": "ประเภท",
"user": "ผู้ใช้",
"group": "กลุ่ม",
"user_group": "ผู้ใช้/กลุ่ม",
"photo": "รูปภาพ",
"message": "ข้อความ",

View File

@@ -1660,6 +1660,8 @@
"notification": {
"date": "Date",
"type": "Type",
"user": "Người chơi",
"group": "Nhóm",
"user_group": "User/Group",
"photo": "Photo",
"message": "Message",

View File

@@ -2207,6 +2207,8 @@
"notification": {
"date": "时间",
"type": "类型",
"user": "玩家",
"group": "群组",
"user_group": "玩家/群组",
"photo": "封面",
"message": "消息",

View File

@@ -2192,6 +2192,8 @@
"notification": {
"date": "時間",
"type": "類型",
"user": "用戶",
"group": "群組",
"user_group": "用戶/群組",
"photo": "照片",
"message": "訊息",

View File

@@ -8,12 +8,10 @@ import LastJoin from '../components/LastJoin.vue';
import Launch from '../components/Launch.vue';
import Location from '../components/Location.vue';
import LocationWorld from '../components/LocationWorld.vue';
import NativeTooltip from '../components/NativeTooltip.vue';
import Timer from '../components/Timer.vue';
export function initComponents(app) {
app.component('Location', Location);
app.component('NativeTooltip', NativeTooltip);
app.component('Timer', Timer);
app.component('InstanceInfo', InstanceInfo);
app.component('LastJoin', LastJoin);

View File

@@ -19,7 +19,7 @@ export function initNoty(isVrOverlay = false) {
},
layout: 'bottomLeft',
theme: 'mint',
timeout: 6000
timeout: 2000
});
}
}

View File

@@ -19,6 +19,7 @@ import PlayerList from './../views/PlayerList/PlayerList.vue';
import Search from './../views/Search/Search.vue';
import Settings from './../views/Settings/Settings.vue';
import Tools from './../views/Tools/Tools.vue';
import Gallery from './../views/Tools/Gallery.vue';
const routes = [
{
@@ -83,6 +84,12 @@ const routes = [
component: Charts
},
{ path: 'tools', name: 'tools', component: Tools },
{
path: 'tools/gallery',
name: 'gallery',
component: Gallery,
meta: { navKey: 'tools' }
},
{ path: 'settings', name: 'settings', component: Settings }
]
}

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 645 B

View File

Before

Width:  |  Height:  |  Size: 541 B

After

Width:  |  Height:  |  Size: 541 B

View File

Before

Width:  |  Height:  |  Size: 843 B

After

Width:  |  Height:  |  Size: 843 B

View File

Before

Width:  |  Height:  |  Size: 737 B

After

Width:  |  Height:  |  Size: 737 B

View File

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 659 B

View File

Before

Width:  |  Height:  |  Size: 591 B

After

Width:  |  Height:  |  Size: 591 B

View File

Before

Width:  |  Height:  |  Size: 557 B

After

Width:  |  Height:  |  Size: 557 B

View File

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 903 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 823 B

After

Width:  |  Height:  |  Size: 823 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,13 @@
const accessTypeLocaleKeyMap = {
public: 'dialog.new_instance.access_type_public',
group: 'dialog.new_instance.access_type_group',
'friends+': 'dialog.new_instance.access_type_friend_plus',
friends: 'dialog.new_instance.access_type_friend',
'invite+': 'dialog.new_instance.access_type_invite_plus',
invite: 'dialog.new_instance.access_type_invite',
groupPublic: 'dialog.new_instance.group_access_type_public',
groupPlus: 'dialog.new_instance.group_access_type_plus',
groupMembers: 'dialog.new_instance.group_access_type_members'
};
export { accessTypeLocaleKeyMap };

View File

@@ -10,3 +10,4 @@ export * from './moderation';
export * from './themes';
export * from './link';
export * from './ui';
export * from './accessType';

View File

@@ -1,52 +1,69 @@
import amoled from '../../assets/scss/themes/theme.amoled.scss?url';
import dark from '../../assets/scss/themes/theme.dark.scss?url';
import darkblue from '../../assets/scss/themes/theme.darkblue.scss?url';
import material3 from '../../assets/scss/themes/theme.material3.scss?url';
import appCss from '../../app.css?url';
// import appLegacy from '../../assets/scss/themes/app_legacy.scss?url';
// import material3 from '../../assets/scss/themes/theme.material3.scss?url';
export const THEME_CONFIG = {
system: {
cssFile: '',
cssFiles: [appCss],
isDark: 'system',
name: 'System'
},
light: {
cssFile: '',
cssFiles: [appCss],
isDark: false,
useDarkClass: false,
name: 'Light'
},
dark: { cssFile: dark, isDark: true, name: 'Dark' },
darkblue: {
cssFile: darkblue,
dark: {
cssFiles: [appCss],
isDark: true,
name: 'Dark Blue'
},
amoled: {
cssFile: amoled,
isDark: true,
name: 'Amoled'
},
// darkvanillaold: {
// cssFile: darkvanillaold,
useDarkClass: true,
name: 'Dark'
}
// darkold: {
// cssFiles: [appLegacy, dark],
// isDark: true,
// useDarkClass: false,
// name: 'Dark (Old)'
// },
// darkblue: {
// cssFiles: [appLegacy, darkblue],
// isDark: true,
// useDarkClass: false,
// name: 'Dark Blue'
// },
// amoled: {
// cssFiles: [appLegacy, amoled],
// isDark: true,
// useDarkClass: false,
// name: 'Amoled'
// },
// darkvanillaold: {
// cssFiles: [appLegacy, darkvanillaold],
// isDark: true,
// useDarkClass: false,
// name: 'Dark Vanilla Old'
// },
// darkvanilla: {
// cssFile: darkvanilla,
// cssFiles: [appLegacy, darkvanilla],
// isDark: true,
// useDarkClass: false,
// name: 'Dark Vanilla'
// },
// pink: {
// cssFile: pink,
// cssFiles: [appLegacy, pink],
// isDark: true,
// useDarkClass: false,
// name: 'Pink'
// },
material3: {
cssFile: material3,
isDark: true,
name: 'Material 3',
fontLinks: [
'https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600&family=Noto+Sans+TC:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500&family=Noto+Sans+JP:wght@300;400;500&family=Roboto&display=swap',
'https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200'
]
}
// material3: {
// cssFiles: [appLegacy, material3],
// isDark: true,
// useDarkClass: false,
// name: 'Material 3',
// fontLinks: [
// 'https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;600&family=Noto+Sans+TC:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500&family=Noto+Sans+JP:wght@300;400;500&family=Roboto&display=swap',
// 'https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200'
// ]
// }
};

View File

@@ -96,6 +96,13 @@ const navDefinitions = [
tooltip: 'nav_tooltip.tools',
labelKey: 'nav_tooltip.tools',
routeName: 'tools'
},
{
key: 'direct-access',
icon: 'ri-compass-3-line',
tooltip: 'prompt.direct_access_omni.header',
labelKey: 'prompt.direct_access_omni.header',
action: 'direct-access'
}
];

View File

@@ -48,6 +48,25 @@ function applyThemeFonts(themeKey, fontLinks = []) {
});
}
function ensureStylesheetLink(id) {
const linkEl = /** @type {HTMLLinkElement | null} */ (
document.getElementById(id)
);
if (!linkEl) {
const created = document.createElement('link');
created.setAttribute('id', id);
created.rel = 'stylesheet';
document.head.appendChild(created);
return created;
}
return linkEl;
}
function removeStylesheetLink(id) {
const linkEl = document.getElementById(id);
linkEl?.remove();
}
function changeAppThemeStyle(themeMode) {
if (themeMode === 'system') {
themeMode = systemIsDarkMode() ? 'dark' : 'light';
@@ -61,27 +80,35 @@ function changeAppThemeStyle(themeMode) {
themeConfig = THEME_CONFIG[themeMode];
}
let filePathPrefix = 'file://vrcx/';
if (LINUX) {
filePathPrefix = './';
}
if (process.env.NODE_ENV === 'development') {
filePathPrefix = 'http://localhost:9000/';
console.log('Using development file path prefix:', filePathPrefix);
const cssFiles = Array.isArray(themeConfig.cssFiles)
? themeConfig.cssFiles.filter(Boolean)
: themeConfig.cssFile
? [themeConfig.cssFile]
: [];
if (cssFiles.length > 0) {
const $appThemeStyle = ensureStylesheetLink('app-theme-style');
$appThemeStyle.href = cssFiles[0];
} else {
removeStylesheetLink('app-theme-style');
}
let $appThemeStyle = document.getElementById('app-theme-style');
if (!$appThemeStyle) {
$appThemeStyle = document.createElement('link');
$appThemeStyle.setAttribute('id', 'app-theme-style');
$appThemeStyle.rel = 'stylesheet';
document.head.appendChild($appThemeStyle);
if (cssFiles.length > 1) {
const $appThemeOverlayStyle = ensureStylesheetLink(
'app-theme-overlay-style'
);
$appThemeOverlayStyle.href = cssFiles[1];
} else {
removeStylesheetLink('app-theme-overlay-style');
}
$appThemeStyle.href = themeConfig.cssFile ? themeConfig.cssFile : '';
applyThemeFonts(themeMode, themeConfig.fontLinks);
if (themeConfig.isDark) {
const shouldUseDarkClass =
typeof themeConfig.useDarkClass === 'boolean'
? themeConfig.useDarkClass
: Boolean(themeConfig.isDark);
if (shouldUseDarkClass) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
@@ -258,19 +285,19 @@ function setLoginContainerStyle(isDarkMode) {
loginContainerStyle.id = 'login-container-style';
loginContainerStyle.type = 'text/css';
const backgroundColor = isDarkMode ? '#101010' : '#ffffff';
const inputBackgroundColor = isDarkMode ? '#333333' : '#ffffff';
const inputBorder = isDarkMode ? '1px solid #3b3b3b' : '1px solid #DCDFE6';
const backgroundFallback = isDarkMode ? '#101010' : '#ffffff';
const inputBackgroundFallback = isDarkMode ? '#1f1f1f' : '#ffffff';
const borderFallback = isDarkMode ? '#3b3b3b' : '#DCDFE6';
loginContainerStyle.innerHTML = `
.x-login-container {
background-color: ${backgroundColor} !important;
background-color: var(--el-bg-color-page, ${backgroundFallback}) !important;
transition: background-color 0.3s ease;
}
.x-login-container .el-input__wrapper {
background-color: ${inputBackgroundColor} !important;
border: ${inputBorder} !important;
background-color: var(--el-bg-color, ${inputBackgroundFallback}) !important;
border: 1px solid var(--el-border-color, ${borderFallback}) !important;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
`;

View File

@@ -21,6 +21,7 @@ import { AppDebug } from '../service/appConfig';
import { handleImageUploadInput } from '../shared/utils/imageUpload';
import { useAdvancedSettingsStore } from './settings/advanced';
import { watchState } from '../service/watchState';
import { router } from '../plugin/router';
import miscReq from '../api/misc';
@@ -122,8 +123,16 @@ export const useGalleryStore = defineStore('Gallery', () => {
}
}
function showGalleryDialog() {
function showGalleryPage() {
galleryDialogVisible.value = true;
if (router.currentRoute.value?.name === 'gallery') {
loadGalleryData();
return;
}
router.push({ name: 'gallery' });
}
function loadGalleryData() {
refreshGalleryTable();
refreshVRCPlusIconsTable();
refreshEmojiTable();
@@ -572,7 +581,8 @@ export const useGalleryStore = defineStore('Gallery', () => {
fullscreenImageDialog,
cachedEmoji,
showGalleryDialog,
showGalleryPage,
loadGalleryData,
refreshGalleryTable,
refreshVRCPlusIconsTable,
inviteImageUpload,

View File

@@ -12,9 +12,12 @@ import {
systemIsDarkMode,
updateTrustColorClasses
} from '../../shared/utils/base/ui';
import { THEME_CONFIG } from '../../shared/constants';
import { database } from '../../service/database';
import { getNameColour } from '../../shared/utils';
import { languageCodes } from '../../localization';
import { loadLocalizedStrings } from '../../plugin';
import { useElementTheme } from '../../composables/useElementTheme';
import { useFeedStore } from '../feed';
import { useGameLogStore } from '../gameLog';
import { useUiStore } from '../ui';
@@ -24,7 +27,6 @@ import { useVrcxStore } from '../vrcx';
import { watchState } from '../../service/watchState';
import configRepository from '../../service/config';
import { languageCodes } from '../../localization';
export const useAppearanceSettingsStore = defineStore(
'AppearanceSettings',
@@ -42,6 +44,7 @@ export const useAppearanceSettingsStore = defineStore(
const MAX_TABLE_PAGE_SIZE = 1000;
const DEFAULT_TABLE_PAGE_SIZES = [10, 15, 20, 25, 50, 100];
const { initPrimaryColor } = useElementTheme();
const appLanguage = ref('en');
const themeMode = ref('');
@@ -71,7 +74,8 @@ export const useAppearanceSettingsStore = defineStore(
const hideUserMemos = ref(false);
const hideUnfriends = ref(false);
const randomUserColours = ref(false);
const trustColor = ref({
const compactTableMode = ref(false);
const TRUST_COLOR_DEFAULTS = Object.freeze({
untrusted: '#CCCCCC',
basic: '#1778FF',
known: '#2BCF5C',
@@ -80,8 +84,10 @@ export const useAppearanceSettingsStore = defineStore(
vip: '#FF2626',
troll: '#782F2F'
});
const trustColor = ref({ ...TRUST_COLOR_DEFAULTS });
const currentCulture = ref('');
const notificationIconDot = ref(false);
const isNavCollapsed = ref(true);
const isSideBarTabShow = computed(() => {
const currentRouteName = router.currentRoute.value?.name;
return !(
@@ -118,8 +124,10 @@ export const useAppearanceSettingsStore = defineStore(
hideUserMemosConfig,
hideUnfriendsConfig,
randomUserColoursConfig,
compactTableModeConfig,
trustColorConfig,
notificationIconDotConfig
notificationIconDotConfig,
navIsCollapsedConfig
] = await Promise.all([
configRepository.getString('VRCX_appLanguage'),
configRepository.getString('VRCX_ThemeMode', 'system'),
@@ -163,19 +171,13 @@ export const useAppearanceSettingsStore = defineStore(
configRepository.getBool('VRCX_hideUserMemos', false),
configRepository.getBool('VRCX_hideUnfriends', false),
configRepository.getBool('VRCX_randomUserColours', false),
configRepository.getBool('VRCX_compactTableMode', false),
configRepository.getString(
'VRCX_trustColor',
JSON.stringify({
untrusted: '#CCCCCC',
basic: '#1778FF',
known: '#2BCF5C',
trusted: '#FF7B42',
veteran: '#B18FFF',
vip: '#FF2626',
troll: '#782F2F'
})
JSON.stringify(TRUST_COLOR_DEFAULTS)
),
configRepository.getBool('VRCX_notificationIconDot', true)
configRepository.getBool('VRCX_notificationIconDot', true),
configRepository.getBool('VRCX_navIsCollapsed', true)
]);
if (!appLanguageConfig) {
@@ -193,8 +195,18 @@ export const useAppearanceSettingsStore = defineStore(
await changeAppLanguage(appLanguageConfig);
}
themeMode.value = themeModeConfig;
const normalizedThemeMode = normalizeThemeMode(themeModeConfig);
if (normalizedThemeMode !== themeModeConfig) {
configRepository.setString(
'VRCX_ThemeMode',
normalizedThemeMode
);
}
themeMode.value = normalizedThemeMode;
applyThemeMode();
await changeAppThemeStyle(themeMode.value);
await initPrimaryColor();
displayVRCPlusIconsAsAvatar.value =
displayVRCPlusIconsAsAvatarConfig;
@@ -222,7 +234,13 @@ export const useAppearanceSettingsStore = defineStore(
sidebarSortMethod3.value = sidebarSortMethods.value[2];
}
trustColor.value = JSON.parse(trustColorConfig);
if (trustColorConfig !== JSON.stringify(TRUST_COLOR_DEFAULTS)) {
await configRepository.setString(
'VRCX_trustColor',
JSON.stringify(TRUST_COLOR_DEFAULTS)
);
}
trustColor.value = { ...TRUST_COLOR_DEFAULTS };
asideWidth.value = asideWidthConfig;
isSidebarGroupByInstance.value = isSidebarGroupByInstanceConfig;
isHideFriendsInSameInstance.value =
@@ -234,6 +252,11 @@ export const useAppearanceSettingsStore = defineStore(
hideUnfriends.value = hideUnfriendsConfig;
randomUserColours.value = randomUserColoursConfig;
notificationIconDot.value = notificationIconDotConfig;
compactTableMode.value = compactTableModeConfig;
applyCompactTableMode(compactTableMode.value);
isNavCollapsed.value = navIsCollapsedConfig;
await configRepository.remove('VRCX_navWidth');
// Migrate old settings
// Assume all exist if one does
@@ -256,6 +279,12 @@ export const useAppearanceSettingsStore = defineStore(
{ flush: 'sync' }
);
function normalizeThemeMode(mode) {
return Object.prototype.hasOwnProperty.call(THEME_CONFIG, mode)
? mode
: 'light';
}
/**
*
* @param {string} language
@@ -421,8 +450,9 @@ export const useAppearanceSettingsStore = defineStore(
* @param {string} mode
*/
function setThemeMode(mode) {
themeMode.value = mode;
configRepository.setString('VRCX_ThemeMode', mode);
const normalizedThemeMode = normalizeThemeMode(mode);
themeMode.value = normalizedThemeMode;
configRepository.setString('VRCX_ThemeMode', normalizedThemeMode);
applyThemeMode();
}
function applyThemeMode() {
@@ -557,18 +587,24 @@ export const useAppearanceSettingsStore = defineStore(
JSON.stringify(methods)
);
}
/**
* @param {number} panelNumber
* @param {Array<number>} widthArray
*/
function setAsideWidth(panelNumber, widthArray) {
if (Array.isArray(widthArray) && widthArray[1]) {
function setNavCollapsed(collapsed) {
isNavCollapsed.value = collapsed;
configRepository.setBool('VRCX_navIsCollapsed', collapsed);
}
function toggleNavCollapsed() {
setNavCollapsed(!isNavCollapsed.value);
}
function setAsideWidth(widthOrArray) {
let width = null;
if (Array.isArray(widthOrArray) && widthOrArray.length) {
width = widthOrArray[widthOrArray.length - 1];
} else if (typeof widthOrArray === 'number') {
width = widthOrArray;
}
if (width) {
requestAnimationFrame(() => {
asideWidth.value = widthArray[1];
configRepository.setInt(
'VRCX_sidePanelWidth',
widthArray[1]
);
asideWidth.value = width;
configRepository.setInt('VRCX_sidePanelWidth', width);
});
}
}
@@ -614,14 +650,22 @@ export const useAppearanceSettingsStore = defineStore(
randomUserColours.value
);
}
function setCompactTableMode() {
compactTableMode.value = !compactTableMode.value;
applyCompactTableMode(compactTableMode.value);
configRepository.setBool(
'VRCX_compactTableMode',
compactTableMode.value
);
}
/**
* @param {object} color
*/
function setTrustColor(color) {
trustColor.value = color;
trustColor.value = { ...TRUST_COLOR_DEFAULTS };
configRepository.setString(
'VRCX_trustColor',
JSON.stringify(color)
JSON.stringify(trustColor.value)
);
}
@@ -741,6 +785,15 @@ export const useAppearanceSettingsStore = defineStore(
await userColourInit();
}
function applyCompactTableMode(isCompact) {
const className = 'is-compact-table';
if (isCompact) {
document.documentElement.classList.add(className);
} else {
document.documentElement.classList.remove(className);
}
}
return {
appLanguage,
themeMode,
@@ -766,10 +819,12 @@ export const useAppearanceSettingsStore = defineStore(
hideUserMemos,
hideUnfriends,
randomUserColours,
compactTableMode,
trustColor,
currentCulture,
isSideBarTabShow,
notificationIconDot,
isNavCollapsed,
setAppLanguage,
setDisplayVRCPlusIconsAsAvatar,
@@ -793,6 +848,7 @@ export const useAppearanceSettingsStore = defineStore(
setHideUserMemos,
setHideUnfriends,
setRandomUserColours,
setCompactTableMode,
setTrustColor,
saveThemeMode,
tryInitUserColours,
@@ -802,7 +858,10 @@ export const useAppearanceSettingsStore = defineStore(
applyUserTrustLevel,
changeAppLanguage,
promptMaxTableSizeDialog,
setNotificationIconDot
setNotificationIconDot,
applyCompactTableMode,
setNavCollapsed,
toggleNavCollapsed
};
}
);

View File

@@ -1,8 +1,5 @@
<template>
<div id="chart" class="x-container">
<div class="options-container" style="margin-top: 0">
<span class="header">{{ t('view.charts.header') }}</span>
</div>
<el-tabs v-model="activeTab" class="charts-tabs">
<el-tab-pane :label="t('view.charts.instance_activity.header')" name="instance"></el-tab-pane>
<el-tab-pane :label="t('view.charts.mutual_friend.tab_label')" name="mutual"></el-tab-pane>
@@ -33,7 +30,7 @@
</script>
<style scoped>
.charts-tabs {
margin-bottom: 12px;
:deep(.el-tabs__header) {
margin: 0;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div>
<div ref="instanceActivityRef" class="pt-12">
<div class="options-container instance-activity" style="margin-top: 0">
<div>
<span>{{ t('view.charts.instance_activity.header') }}</span>
@@ -151,6 +151,33 @@
const { currentUser } = storeToRefs(useUserStore());
const { t } = useI18n();
const instanceActivityRef = ref(null);
const instanceActivityResizeObserver = new ResizeObserver(() => {
setInstanceActivityHeight();
});
function setInstanceActivityHeight() {
if (instanceActivityRef.value) {
const availableHeight = window.innerHeight - 100;
instanceActivityRef.value.style.height = `${availableHeight}px`;
instanceActivityRef.value.style.overflowY = 'auto';
}
}
onMounted(() => {
if (instanceActivityRef.value) {
instanceActivityResizeObserver.observe(instanceActivityRef.value);
}
setInstanceActivityHeight();
});
onBeforeUnmount(() => {
if (instanceActivityRef.value) {
instanceActivityResizeObserver.unobserve(instanceActivityRef.value);
}
});
const {
barWidth,
isDetailVisible,
@@ -623,7 +650,7 @@
align-items: center;
justify-content: center;
margin-top: 100px;
color: #5c5c5c;
color: var(--el-text-color-secondary);
}
.divider {
padding: 0 400px;

View File

@@ -1,5 +1,5 @@
<template>
<div class="mutual-graph">
<div class="mutual-graph pt-12" ref="mutualGraphRef">
<div class="options-container mutual-graph__toolbar">
<div class="mutual-graph__actions">
<el-tooltip :content="t('view.charts.mutual_friend.force_dialog.open_label')" placement="top">
@@ -207,6 +207,20 @@
return parsed.invalid ? null : parsed.value;
};
const mutualGraphRef = ref(null);
const mutualGraphResizeObserver = new ResizeObserver(() => {
setMutualGraphHeight();
});
function setMutualGraphHeight() {
if (mutualGraphRef.value) {
const availableHeight = window.innerHeight - 100;
mutualGraphRef.value.style.height = `${availableHeight}px`;
mutualGraphRef.value.style.overflowY = 'auto';
}
}
onMounted(() => {
nextTick(() => {
if (!chartRef.value) {
@@ -215,6 +229,8 @@
createChartInstance();
resizeObserver = new ResizeObserver(() => chartInstance?.resize());
resizeObserver.observe(chartRef.value);
mutualGraphResizeObserver.observe(mutualGraphRef.value);
setMutualGraphHeight();
});
});
@@ -227,6 +243,9 @@
chartInstance.dispose();
chartInstance = null;
}
if (mutualGraphResizeObserver) {
mutualGraphResizeObserver.disconnect();
}
});
watch(
@@ -676,8 +695,8 @@
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 0;
margin-bottom: 8px;
margin-top: 8px;
margin-bottom: 0;
background: transparent;
border: none;
box-shadow: none;

View File

@@ -1580,6 +1580,7 @@
justify-content: space-between;
font-weight: 600;
font-size: 14px;
margin-bottom: 9px;
}
.group-section__list {

View File

@@ -796,6 +796,7 @@
justify-content: space-between;
font-weight: 600;
font-size: 14px;
margin-bottom: 9px;
}
.group-section__list {

View File

@@ -1276,6 +1276,7 @@
justify-content: space-between;
font-weight: 600;
font-size: 14px;
margin-bottom: 9px;
}
.group-section__list {

View File

@@ -1,14 +1,13 @@
<template>
<div class="x-container feed">
<div class="x-container feed" ref="feedRef">
<div style="margin: 0 0 10px; display: flex; align-items: center">
<div style="flex: none; margin-right: 10px; display: flex; align-items: center">
<NativeTooltip
placement="bottom"
:content="t('view.feed.favorites_only_tooltip')"
:enter-ms="140"
:exit-ms="120">
<el-switch v-model="feedTable.vip" active-color="#13ce66" @change="feedTableLookup"></el-switch>
</NativeTooltip>
<el-tooltip placement="bottom" :content="t('view.feed.favorites_only_tooltip')">
<el-switch
v-model="feedTable.vip"
active-color="var(--el-color-success)"
@change="feedTableLookup"></el-switch>
</el-tooltip>
</div>
<el-select
v-model="feedTable.filter"
@@ -33,9 +32,9 @@
</div>
<DataTable v-bind="feedTable" :data="feedDisplayData">
<el-table-column type="expand" width="20">
<el-table-column type="expand" width="30">
<template #default="scope">
<div style="position: relative; font-size: 14px">
<div style="position: relative; font-size: 14px" class="pl-5">
<template v-if="scope.row.type === 'GPS'">
<Location
v-if="scope.row.previousLocation"
@@ -45,9 +44,7 @@
timeToText(scope.row.time)
}}</el-tag>
<br />
<span style="margin-right: 5px">
<el-icon><Right /></el-icon>
</span>
<span style="margin-right: 5px"> </span>
<Location
v-if="scope.row.location"
:location="scope.row.location"
@@ -91,7 +88,7 @@
</template>
</div>
<span style="position: relative; margin: 0 10px">
<el-icon><Right /></el-icon>
{{ ' → ' }}
</span>
<div style="display: inline-block; vertical-align: top; width: 160px">
@@ -116,9 +113,7 @@
<i class="x-user-status" :class="statusClass(scope.row.previousStatus)"></i>
<span style="margin-left: 5px" v-text="scope.row.previousStatusDescription"></span>
<br />
<span>
<el-icon><Right /></el-icon>
</span>
<span> </span>
<i class="x-user-status" :class="statusClass(scope.row.status)" style="margin: 0 5px"></i>
<span v-text="scope.row.statusDescription"></span>
@@ -132,27 +127,29 @@
</template>
</el-table-column>
<el-table-column :label="t('table.feed.date')" prop="created_at" width="130">
<el-table-column :label="t('table.feed.date')" prop="created_at" width="140">
<template #default="scope">
<NativeTooltip placement="right">
<el-tooltip placement="right">
<template #content>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</NativeTooltip>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.feed.type')" prop="type" width="80">
<el-table-column :label="t('table.feed.type')" prop="type" width="130">
<template #default="scope">
<span v-text="t('view.feed.filters.' + scope.row.type)"></span>
<el-tag type="info" effect="plain" size="small">{{
t('view.feed.filters.' + scope.row.type)
}}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('table.feed.user')" prop="displayName" width="180">
<el-table-column :label="t('table.feed.user')" prop="displayName" width="190">
<template #default="scope">
<span
class="x-link"
class="x-link table-user"
style="padding-right: 10px"
@click="showUserDialog(scope.row.userId)"
v-text="scope.row.displayName"></span>
@@ -178,17 +175,12 @@
<template v-else-if="scope.row.type === 'Status'">
<template v-if="scope.row.statusDescription === scope.row.previousStatusDescription">
<i class="x-user-status" :class="statusClass(scope.row.previousStatus)"></i>
<span style="margin: 0 5px">
<el-icon><Right /></el-icon>
</span>
<span class="mx-2"> </span>
<i class="x-user-status" :class="statusClass(scope.row.status)"></i>
</template>
<template v-else>
<i
class="x-user-status"
:class="statusClass(scope.row.status)"
style="margin-right: 3px"></i>
<i class="x-user-status mr-2" :class="statusClass(scope.row.status)"></i>
<span v-text="scope.row.statusDescription"></span>
</template>
</template>
@@ -210,13 +202,13 @@
</template>
<script setup>
import { Right } from '@element-plus/icons-vue';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { formatDateFilter, statusClass, timeToText } from '../../shared/utils';
import { useFeedStore, useUserStore } from '../../stores';
import { useTableHeight } from '../../composables/useTableHeight';
const { showUserDialog } = useUserStore();
const { feedTable } = storeToRefs(useFeedStore());
@@ -226,6 +218,8 @@
const { t } = useI18n();
const { containerRef: feedRef } = useTableHeight(feedTable);
/**
* Function that format the differences between two strings with HTML tags
* markerStartTag and markerEndTag are optional, if emitted, the differences will be highlighted with yellow and underlined.
@@ -341,3 +335,9 @@
.replace(/<br> /g, '<br>');
}
</script>
<style scoped>
.table-user {
color: var(--x-table-user-text-color) !important;
}
</style>

View File

@@ -1,92 +1,59 @@
<template>
<div class="x-container">
<div style="padding: 0 10px 0 10px">
<div class="x-container" ref="friendsListRef">
<div>
<div style="display: flex; align-items: center; justify-content: space-between">
<span class="header">{{ t('view.friend_list.header') }}</span>
<div style="font-size: 13px; display: flex; align-items: center">
<el-button size="small" @click="openChartsTab" style="margin-right: 10px">
{{ t('view.friend_list.load_mutual_friends') }}
</el-button>
<div v-if="friendsListBulkUnfriendMode" style="display: inline-block; margin-right: 10px">
<el-button size="small" @click="showBulkUnfriendSelectionConfirm">
{{ t('view.friend_list.bulk_unfriend_selection') }}
</el-button>
<!-- el-button(size="small" @click="showBulkUnfriendAllConfirm" style="margin-right:5px") Bulk Unfriend All-->
</div>
<div style="display: flex; align-items: center; margin-right: 10px">
<span class="name">{{ t('view.friend_list.bulk_unfriend') }}</span>
<el-switch
v-model="friendsListBulkUnfriendMode"
style="margin-left: 5px"
@change="toggleFriendsListBulkUnfriendMode"></el-switch>
</div>
<span>{{ t('view.friend_list.load') }}</span>
<template v-if="friendsListLoading">
<span style="margin-left: 5px" v-text="friendsListLoadingProgress"></span>
<el-tooltip placement="top" :content="t('view.friend_list.cancel_tooltip')">
<el-button
size="small"
:icon="Loading"
circle
style="margin-left: 5px"
@click="friendsListLoading = false"></el-button>
</el-tooltip>
</template>
<template v-else>
<el-tooltip placement="top" :content="t('view.friend_list.load_tooltip')">
<el-button
size="small"
:icon="RefreshLeft"
circle
style="margin-left: 5px"
@click="friendsListLoadUsers"></el-button>
</el-tooltip>
</template>
</div>
</div>
<div style="margin: 10px 0 0 10px; display: flex; align-items: center">
<div style="flex: none; margin-right: 10px; display: flex; align-items: center">
<el-tooltip placement="bottom" :content="t('view.friend_list.favorites_only_tooltip')">
<el-switch
v-model="friendsListSearchFilterVIP"
active-color="#13ce66"
active-color="var(--el-color-success)"
@change="friendsListSearchChange"></el-switch>
</el-tooltip>
<el-select
v-model="friendsListSearchFilters"
multiple
clearable
collapse-tags
style="margin: 0 10px; width: 150px"
:placeholder="t('view.friend_list.filter_placeholder')"
@change="friendsListSearchChange">
<el-option
v-for="type in ['Display Name', 'User Name', 'Rank', 'Status', 'Bio', 'Note', 'Memo']"
:key="type"
:label="type"
:value="type"></el-option>
</el-select>
<el-input
v-model="friendsListSearch"
:placeholder="t('view.friend_list.search_placeholder')"
clearable
style="width: 250px"
@change="friendsListSearchChange"></el-input>
</div>
<div class="flex items-center">
<div v-if="friendsListBulkUnfriendMode" class="inline-block mr-10">
<el-button @click="showBulkUnfriendSelectionConfirm">
{{ t('view.friend_list.bulk_unfriend_selection') }}
</el-button>
<!-- el-button(size="small" @click="showBulkUnfriendAllConfirm" style="margin-right:5px") Bulk Unfriend All-->
</div>
<div class="flex items-center mr-3">
<span class="name mr-2 text-xs">{{ t('view.friend_list.bulk_unfriend') }}</span>
<el-switch
v-model="friendsListBulkUnfriendMode"
@change="toggleFriendsListBulkUnfriendMode"></el-switch>
</div>
<div class="flex items-center">
<el-button @click="openChartsTab">
{{ t('view.friend_list.load_mutual_friends') }}
</el-button>
<el-button @click="friendsListLoadUsers">{{ t('view.friend_list.load') }}</el-button>
</div>
</div>
<el-input
v-model="friendsListSearch"
:placeholder="t('view.friend_list.search_placeholder')"
clearable
style="flex: 1"
@change="friendsListSearchChange"></el-input>
<el-select
v-model="friendsListSearchFilters"
multiple
clearable
collapse-tags
style="flex: 0.3; margin: 0 10px"
:placeholder="t('view.friend_list.filter_placeholder')"
@change="friendsListSearchChange">
<el-option
v-for="type in ['Display Name', 'User Name', 'Rank', 'Status', 'Bio', 'Note', 'Memo']"
:key="type"
:label="type"
:value="type"></el-option>
</el-select>
<el-tooltip placement="top" :content="t('view.friend_list.refresh_tooltip')">
<el-button
type="default"
:icon="Refresh"
circle
style="flex: none"
@click="friendsListSearchChange"></el-button>
</el-tooltip>
</div>
<DataTable
v-loading="friendsListLoading"
v-bind="friendsListTable"
:table-props="{ height: 'calc(100vh - 170px)', size: 'small' }"
style="margin-top: 10px; cursor: pointer"
@row-click="selectFriendsListRow">
<el-table-column v-if="friendsListBulkUnfriendMode" width="55">
@@ -98,39 +65,38 @@
</el-button>
</template>
</el-table-column>
<el-table-column :label="t('table.friendList.no')" width="70" prop="$friendNumber" :sortable="true">
<el-table-column width="20"></el-table-column>
<el-table-column
:label="t('table.friendList.no')"
width="70"
prop="$friendNumber"
:sortable="true"
fixed="left">
<template #default="{ row }">
<span>{{ row.$friendNumber ? row.$friendNumber : '' }}</span>
</template>
</el-table-column>
<el-table-column :label="t('table.friendList.avatar')" width="70" prop="photo">
<el-table-column :label="t('table.friendList.avatar')" width="90" prop="photo">
<template #default="{ row }">
<el-popover placement="right" :width="500" trigger="hover">
<template #reference>
<img :src="userImage(row, true)" class="friends-list-avatar" loading="lazy" />
</template>
<img
:src="userImageFull(row)"
:class="['friends-list-avatar', 'x-popover-image']"
style="cursor: pointer"
@click="showFullscreenImageDialog(userImageFull(row))"
loading="lazy" />
</el-popover>
<div class="flex items-center">
<img :src="userImage(row, true)" class="friends-list-avatar" loading="lazy" />
</div>
</template>
</el-table-column>
<el-table-column
:label="t('table.friendList.displayName')"
min-width="140"
min-width="200"
prop="displayName"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'displayName')">
:sort-method="(a, b) => sortAlphabetically(a, b, 'displayName')"
fixed="left">
<template #default="{ row }">
<span :style="{ color: randomUserColours ? row.$userColour : undefined }" class="name">{{
row.displayName
}}</span>
</template>
</el-table-column>
<el-table-column :label="t('table.friendList.rank')" width="110" prop="$trustSortNum" :sortable="true">
<el-table-column :label="t('table.friendList.rank')" width="140" prop="$trustSortNum" :sortable="true">
<template #default="{ row }">
<span
v-if="randomUserColours"
@@ -142,7 +108,7 @@
</el-table-column>
<el-table-column
:label="t('table.friendList.status')"
min-width="180"
min-width="200"
prop="status"
sortable
:sort-method="(a, b) => sortStatus(a.status, b.status)">
@@ -157,7 +123,7 @@
</el-table-column>
<el-table-column
:label="t('table.friendList.language')"
width="110"
width="130"
prop="$languages"
sortable
:sort-method="(a, b) => sortLanguages(a, b)">
@@ -173,7 +139,7 @@
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.friendList.bioLink')" width="100" prop="bioLinks">
<el-table-column :label="t('table.friendList.bioLink')" width="130" prop="bioLinks">
<template #default="{ row }">
<el-tooltip v-for="(link, index) in row.bioLinks.filter(Boolean)" :key="index">
<template #content>
@@ -197,8 +163,14 @@
:label="t('table.friendList.joinCount')"
width="120"
prop="$joinCount"
sortable></el-table-column>
<el-table-column :label="t('table.friendList.timeTogether')" width="140" prop="$timeSpent" sortable>
sortable
align="right"></el-table-column>
<el-table-column
:label="t('table.friendList.timeTogether')"
width="140"
prop="$timeSpent"
sortable
align="right">
<template #default="{ row }">
<span v-if="row.$timeSpent">{{ timeToText(row.$timeSpent) }}</span>
</template>
@@ -210,17 +182,26 @@
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, '$lastSeen')">
<template #default="{ row }">
<span>{{ formatDateFilter(row.$lastSeen, 'long') }}</span>
<span>{{
formatDateFilter(row.$lastSeen, 'long') === '-'
? ''
: formatDateFilter(row.$lastSeen, 'long')
}}</span>
</template>
</el-table-column>
<el-table-column :label="t('table.friendList.mutualFriends')" width="120" prop="$mutualCount" sortable>
<el-table-column
:label="t('table.friendList.mutualFriends')"
width="120"
prop="$mutualCount"
sortable
align="right">
<template #default="{ row }">
<span v-if="row.$mutualCount">{{ row.$mutualCount }}</span>
<span v-else></span> </template
></el-table-column>
<el-table-column
:label="t('table.friendList.lastActivity')"
width="170"
width="200"
prop="last_activity"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'last_activity')">
@@ -230,7 +211,7 @@
</el-table-column>
<el-table-column
:label="t('table.friendList.lastLogin')"
width="170"
width="200"
prop="last_login"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'last_login')">
@@ -246,23 +227,42 @@
:sort-method="(a, b) => sortAlphabetically(a, b, 'date_joined')"></el-table-column>
<el-table-column :label="t('table.friendList.unfriend')" width="100" align="center">
<template #default="{ row }">
<el-button
text
:icon="Close"
<i
class="ri-user-unfollow-line"
style="color: #f56c6c"
size="small"
@click.stop="confirmDeleteFriend(row.id)"></el-button>
@click.stop="confirmDeleteFriend(row.id)"></i>
</template>
</el-table-column>
</DataTable>
<el-dialog
v-model="friendsListLoadDialogVisible"
:title="t('view.friend_list.load_dialog_title')"
width="420px"
:close-on-click-modal="false"
:close-on-press-escape="false"
:show-close="false"
align-center>
<div style="margin-bottom: 10px" v-text="t('view.friend_list.load_dialog_message')"></div>
<el-progress
:percentage="friendsListLoadingPercent"
:text-inside="true"
:stroke-width="16"></el-progress>
<div style="margin-top: 10px; text-align: right">
<span>{{ friendsListLoadingCurrent }} / {{ friendsListLoadingTotal }}</span>
</div>
<template #footer>
<el-button @click="cancelFriendsListLoad">
{{ t('view.friend_list.load_cancel') }}
</el-button>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup>
import { Close, Loading, Refresh, RefreshLeft } from '@element-plus/icons-vue';
import { nextTick, reactive, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus';
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
@@ -276,19 +276,13 @@
sortStatus,
statusClass,
timeToText,
userImage,
userImageFull
userImage
} from '../../shared/utils';
import {
useAppearanceSettingsStore,
useFriendStore,
useGalleryStore,
useSearchStore,
useUserStore
} from '../../stores';
import { useAppearanceSettingsStore, useFriendStore, useSearchStore, useUserStore } from '../../stores';
import { friendRequest, userRequest } from '../../api';
import removeConfusables, { removeWhitespace } from '../../service/confusables';
import { router } from '../../plugin/router';
import { useTableHeight } from '../../composables/useTableHeight';
const { t } = useI18n();
@@ -299,21 +293,34 @@
const { randomUserColours } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { stringComparer, friendsListSearch } = storeToRefs(useSearchStore());
const { showFullscreenImageDialog } = useGalleryStore();
const friendsListSearchFilters = ref([]);
const friendsListTable = reactive({
data: [],
tableProps: { stripe: true, size: 'small', defaultSort: { prop: '$friendNumber', order: 'descending' } },
tableProps: {
stripe: true,
size: 'small',
defaultSort: { prop: '$friendNumber', order: 'descending' },
scrollbarAlwaysOn: true
},
pageSize: 100,
paginationProps: { layout: 'sizes,prev,pager,next,total', pageSizes: [50, 100, 250, 500] }
});
const friendsListBulkUnfriendMode = ref(false);
const friendsListLoading = ref(false);
const friendsListLoadingProgress = ref('');
const friendsListLoadingCurrent = ref(0);
const friendsListLoadingTotal = ref(0);
const friendsListLoadDialogVisible = ref(false);
const friendsListSearchFilterVIP = ref(false);
const selectedFriends = ref(new Set());
const friendsListLoadingPercent = computed(() => {
if (!friendsListLoadingTotal.value) return 0;
return Math.min(100, Math.round((friendsListLoadingCurrent.value / friendsListLoadingTotal.value) * 100));
});
const { containerRef: friendsListRef } = useTableHeight(ref(friendsListTable));
const route = useRoute();
watch(
@@ -432,27 +439,43 @@
}
async function friendsListLoadUsers() {
friendsListLoading.value = true;
let i = 0;
const toFetch = Array.from(friends.value.values())
.filter((ctx) => ctx.ref && !ctx.ref.date_joined)
.map((ctx) => ctx.id);
const total = toFetch.length;
friendsListLoadingTotal.value = total;
friendsListLoadingCurrent.value = 0;
if (!total) {
ElMessage.success(t('view.friend_list.load_complete'));
return;
}
friendsListLoading.value = true;
friendsListLoadDialogVisible.value = true;
let cancelled = false;
for (const userId of toFetch) {
if (!friendsListLoading.value) {
friendsListLoadingProgress.value = '';
return;
cancelled = true;
break;
}
i++;
friendsListLoadingProgress.value = `${i}/${total}`;
friendsListLoadingCurrent.value += 1;
try {
await userRequest.getUser({ userId });
} catch (err) {
console.error(err);
}
}
friendsListLoadingProgress.value = '';
friendsListLoading.value = false;
friendsListLoadDialogVisible.value = false;
friendsListLoadingCurrent.value = 0;
friendsListLoadingTotal.value = 0;
if (!cancelled) {
ElMessage.success(t('view.friend_list.load_complete'));
}
}
function cancelFriendsListLoad() {
friendsListLoading.value = false;
friendsListLoadDialogVisible.value = false;
}
function selectFriendsListRow(val) {
@@ -476,3 +499,11 @@
router.push({ name: 'charts' });
}
</script>
<style scoped>
.friends-list-avatar {
object-fit: cover;
height: 22px;
width: 22px;
}
</style>

View File

@@ -1,6 +1,5 @@
<template>
<div class="x-container">
<!-- 工具栏 -->
<div class="x-container" ref="friendLogRef">
<div style="margin: 0 0 10px; display: flex; align-items: center">
<el-select
v-model="friendLogTable.filters[0].value"
@@ -40,27 +39,25 @@
</template>
</el-table-column>
<el-table-column :label="t('table.friendLog.type')" prop="type" width="150">
<el-table-column :label="t('table.friendLog.type')" prop="type" width="200">
<template #default="scope">
<span v-text="t('view.friend_log.filters.' + scope.row.type)"></span>
<el-tag type="info" effect="plain" size="small"
><span v-text="t('view.friend_log.filters.' + scope.row.type)"></span
></el-tag>
</template>
</el-table-column>
<el-table-column :label="t('table.friendLog.user')" prop="displayName">
<template #default="scope">
<span v-if="scope.row.type === 'DisplayName'">
{{ scope.row.previousDisplayName }} <el-icon><Right /></el-icon>&nbsp;
</span>
<span v-if="scope.row.type === 'DisplayName'">{{ scope.row.previousDisplayName }} → </span>
<span
class="x-link"
class="x-link table-user"
style="padding-right: 10px"
@click="showUserDialog(scope.row.userId)"
v-text="scope.row.displayName || scope.row.userId"></span>
>{{ scope.row.displayName || scope.row.userId }}
</span>
<template v-if="scope.row.type === 'TrustLevel'">
<span>
({{ scope.row.previousTrustLevel }} <el-icon><Right /></el-icon>
{{ scope.row.trustLevel }})</span
>
<span>({{ scope.row.previousTrustLevel }} → {{ scope.row.trustLevel }})</span>
</template>
</template>
</el-table-column>
@@ -69,28 +66,27 @@
<template #default="scope">
<el-button
v-if="shiftHeld"
style="color: #f56c6c"
style="color: var(--el-color-danger)"
text
:icon="Close"
size="small"
class="button-pd-0"
@click="deleteFriendLog(scope.row)"></el-button>
<el-button
<i
v-else
text
:icon="Delete"
size="small"
class="button-pd-0"
@click="deleteFriendLogPrompt(scope.row)"></el-button>
class="ri-delete-bin-line"
style="opacity: 0.85"
@click="deleteFriendLogPrompt(scope.row)"></i>
</template>
</el-table-column>
<el-table-column width="5"></el-table-column>
</DataTable>
</div>
</template>
<script setup>
import { Close, Delete, Right } from '@element-plus/icons-vue';
import { computed, watch } from 'vue';
import { Close } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
@@ -100,6 +96,7 @@
import { useAppearanceSettingsStore, useFriendStore, useUiStore, useUserStore } from '../../stores';
import { formatDateFilter, removeFromArray } from '../../shared/utils';
import { database } from '../../service/database';
import { useTableHeight } from '../../composables/useTableHeight';
import configRepository from '../../service/config';
@@ -108,6 +105,8 @@
const { friendLogTable } = storeToRefs(useFriendStore());
const { shiftHeld } = storeToRefs(useUiStore());
const { containerRef: friendLogRef } = useTableHeight(friendLogTable);
const friendLogDisplayData = computed(() => {
const data = friendLogTable.value.data;
return data.slice().sort((a, b) => {
@@ -160,4 +159,7 @@
.button-pd-0 {
padding: 0 !important;
}
.table-user {
color: var(--x-table-user-text-color);
}
</style>

View File

@@ -32,7 +32,7 @@
<el-slider
v-model="cardScale"
class="friend-view__slider"
:min="0.6"
:min="0.5"
:max="1.0"
:step="0.01"
:show-tooltip="false" />
@@ -45,8 +45,8 @@
<el-slider
v-model="cardSpacing"
class="friend-view__slider"
:min="0.5"
:max="1.5"
:min="0.25"
:max="1.0"
:step="0.05"
:show-tooltip="false" />
</div>
@@ -688,7 +688,7 @@
});
</script>
<style scoped lang="scss">
<style scoped>
.friend-view {
display: grid;
grid-template-rows: auto 1fr;
@@ -699,12 +699,12 @@
display: flex;
gap: 20px;
align-items: center;
padding: 6px 10px 0 2px;
padding: 6px 2px 0 2px;
}
.friend-view__toolbar--loading {
justify-content: flex-end;
color: rgba(15, 23, 42, 0.55);
color: var(--el-text-color-secondary);
font-size: 13px;
font-weight: 500;
}
@@ -720,7 +720,7 @@
flex: 1;
flex-wrap: wrap;
justify-content: flex-end;
color: rgba(15, 23, 42, 0.65);
color: var(--el-text-color-regular);
}
.friend-view__settings-label {
@@ -746,7 +746,7 @@
.friend-view__scale-value {
font-size: 12px;
font-weight: 600;
color: rgba(15, 23, 42, 0.55);
color: var(--el-text-color-secondary);
min-width: 42px;
text-align: right;
}
@@ -762,14 +762,14 @@
}
.friend-view__scroll {
padding: 2px 10px 2px 2px;
padding: 2px;
}
.friend-view__initial-loading {
display: grid;
place-items: center;
min-height: 240px;
color: rgba(15, 23, 42, 0.45);
color: var(--el-text-color-secondary);
}
.friend-view__grid {
@@ -780,7 +780,7 @@
);
gap: var(--friend-card-gap, 18px);
justify-content: start;
padding-right: 2px;
padding: 2px;
}
.friend-view__instances {
@@ -802,7 +802,7 @@
margin: 5px 10px;
font-weight: 600;
font-size: 13px;
color: rgba(15, 23, 42, 0.75);
color: var(--el-text-color-primary);
}
.friend-view__divider {
@@ -810,7 +810,7 @@
align-items: center;
gap: 12px;
margin: 16px 4px;
color: rgba(15, 23, 42, 0.6);
color: var(--el-text-color-regular);
font-size: 13px;
font-weight: 600;
}
@@ -820,7 +820,7 @@
content: '';
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.35);
background: var(--el-border-color);
}
.friend-view__divider-text {
@@ -829,14 +829,14 @@
.friend-view__instance-count {
font-size: 12px;
color: rgba(15, 23, 42, 0.45);
color: var(--el-text-color-secondary);
}
.friend-view__empty {
display: grid;
place-items: center;
min-height: 240px;
color: rgba(0, 0, 0, 0.45);
color: var(--el-text-color-secondary);
font-size: 15px;
letter-spacing: 0.5px;
}
@@ -847,7 +847,7 @@
justify-content: center;
gap: 8px;
padding: 18px 0 12px;
color: rgba(0, 0, 0, 0.55);
color: var(--el-text-color-secondary);
font-size: 14px;
}

View File

@@ -12,11 +12,11 @@
</el-avatar>
</div>
<span class="friend-card__status-dot" :class="statusDotClass"></span>
<div class="friend-card__name" :title="friend.name">{{ friend.name }}</div>
<div class="friend-card__name ml-0.5" :title="friend.name">{{ friend.name }}</div>
</div>
<div class="friend-card__body">
<div class="friend-card__signature" :title="friend.ref?.statusDescription">
<i v-if="friend.ref?.statusDescription" class="ri-pencil-line" style="opacity: 0.7"></i>
<i v-if="friend.ref?.statusDescription" class="ri-pencil-line mr-0.5" style="opacity: 0.7"></i>
{{ friend.ref?.statusDescription || '&nbsp;' }}
</div>
<div v-if="displayInstanceInfo" @click.stop class="friend-card__world" :title="friend.worldName">
@@ -87,17 +87,17 @@
});
</script>
<style scoped lang="scss">
<style scoped>
.friend-card {
--card-scale: 1;
--card-spacing: 1;
position: relative;
display: grid;
gap: calc(14px * var(--card-scale) * var(--card-spacing));
border-radius: calc(8px * var(--card-scale));
background: #fff;
border-radius: 8px;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color);
box-shadow: 0 calc(6px * var(--card-scale)) calc(16px * var(--card-scale)) rgba(15, 23, 42, 0.04);
box-shadow: var(--el-box-shadow-lighter);
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
@@ -105,7 +105,7 @@
min-width: var(--friend-card-min-width, 220px);
&:hover {
box-shadow: 0 calc(10px * var(--card-scale)) calc(24px * var(--card-scale)) rgba(15, 23, 42, 0.07);
box-shadow: var(--el-box-shadow-light);
transform: translateY(calc(-2px * var(--card-scale)));
}
}
@@ -123,8 +123,8 @@
}
.friend-card__avatar {
border: 1px solid rgba(255, 255, 255, 0.85);
box-shadow: 0 calc(5px * var(--card-scale)) calc(10px * var(--card-scale)) rgba(15, 23, 42, 0.14);
border: 1px solid var(--el-border-color);
box-shadow: var(--el-box-shadow-lighter);
}
.friend-card__status-dot {
@@ -134,8 +134,8 @@
inline-size: calc(12px * var(--card-scale));
block-size: calc(12px * var(--card-scale));
border-radius: 999px;
border: calc(2px * var(--card-scale)) solid #fff;
box-shadow: 0 0 calc(4px * var(--card-scale)) rgba(15, 23, 42, 0.12);
border: calc(2px * var(--card-scale)) solid var(--el-bg-color-overlay);
box-shadow: var(--el-box-shadow-lighter);
pointer-events: none;
}
@@ -144,23 +144,23 @@
}
.friend-card__status-dot--online {
background: linear-gradient(145deg, #67c23a, #4aa12d);
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(103, 194, 58, 0.4);
background: #67c23a;
box-shadow: 0 0 calc(8px * var(--card-scale)) color-mix(in oklch, #67c23a 40%, transparent);
}
.friend-card__status-dot--join {
background: linear-gradient(145deg, #409eff, #2f7ed9);
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(64, 158, 255, 0.4);
background: #409eff;
box-shadow: 0 0 calc(8px * var(--card-scale)) color-mix(in oklch, #409eff 40%, transparent);
}
.friend-card__status-dot--busy {
background: linear-gradient(145deg, #ff2c2c, #d81f1f);
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(255, 44, 44, 0.4);
background: #ff2c2c;
box-shadow: 0 0 calc(8px * var(--card-scale)) color-mix(in oklch, #ff2c2c 40%, transparent);
}
.friend-card__status-dot--ask {
background: linear-gradient(145deg, #ff9500, #d97800);
box-shadow: 0 0 calc(8px * var(--card-scale)) rgba(255, 149, 0, 0.4);
background: #ff9500;
box-shadow: 0 0 calc(8px * var(--card-scale)) color-mix(in oklch, #ff9500 40%, transparent);
}
.friend-card__body {
@@ -171,7 +171,7 @@
.friend-card__name {
font-size: calc(17px * var(--card-scale));
font-weight: 600;
color: #1f2937;
color: var(--el-text-color-primary);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
@@ -181,7 +181,7 @@
.friend-card__signature {
margin-top: calc(6px * var(--card-spacing));
font-size: calc(13px * var(--card-scale));
color: rgba(31, 41, 55, 0.7);
color: var(--el-text-color-secondary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
@@ -194,12 +194,21 @@
justify-content: center;
min-height: calc(40px * var(--card-scale));
padding: calc(6px * var(--card-scale)) calc(10px * var(--card-scale));
border-radius: calc(12px * var(--card-scale));
background: rgba(148, 163, 184, 0.18);
color: rgba(71, 85, 105, 0.95);
border-radius: calc(10px * var(--card-scale));
background: var(--el-fill-color);
color: var(--el-text-color-regular);
font-size: calc(12px * var(--card-scale));
line-height: 1.3;
box-sizing: border-box;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
:global(html.dark) .friend-card__world,
:global(:root.dark) .friend-card__world,
:global(:root[data-theme='dark']) .friend-card__world {
color: var(--color-zinc-300);
}
.friend-card__location {

View File

@@ -1,13 +1,13 @@
<template>
<div class="x-container">
<div class="x-container" ref="gameLogRef">
<div style="margin: 0 0 10px; display: flex; align-items: center">
<div style="flex: none; margin-right: 10px; display: flex; align-items: center">
<NativeTooltip placement="bottom" :content="t('view.feed.favorites_only_tooltip')">
<el-tooltip placement="bottom" :content="t('view.feed.favorites_only_tooltip')">
<el-switch
v-model="gameLogTable.vip"
active-color="#13ce66"
active-color="var(--el-color-success)"
@change="gameLogTableLookup"></el-switch>
</NativeTooltip>
</el-tooltip>
</div>
<el-select
v-model="gameLogTable.filter"
@@ -41,46 +41,47 @@
</div>
<DataTable v-bind="gameLogTable" :data="gameLogDisplayData">
<el-table-column :label="t('table.gameLog.date')" prop="created_at" width="130">
<el-table-column :label="t('table.gameLog.date')" prop="created_at" width="140">
<template #default="scope">
<NativeTooltip placement="right">
<el-tooltip placement="right">
<template #content>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</NativeTooltip>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.type')" prop="type" width="120">
<el-table-column :label="t('table.gameLog.type')" prop="type" width="150">
<template #default="scope">
<el-tag
v-if="scope.row.location && scope.row.type !== 'Location'"
type="info"
effect="plain"
size="small">
<span
class="x-link"
@click="showWorldDialog(scope.row.location)"
v-text="t('view.game_log.filters.' + scope.row.type)"></span>
</el-tag>
<el-tag v-else type="info" effect="plain" size="small">
<span v-text="t('view.game_log.filters.' + scope.row.type)"></span>
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.user')" prop="displayName" width="200">
<template #default="scope">
<span
v-if="scope.row.location && scope.row.type !== 'Location'"
class="x-link"
@click="showWorldDialog(scope.row.location)"
v-text="t('view.game_log.filters.' + scope.row.type)"></span>
<span v-else v-text="t('view.game_log.filters.' + scope.row.type)"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.icon')" prop="isFriend" width="70" align="center">
<template #default="scope">
v-if="scope.row.displayName"
class="x-link table-user"
style="padding-right: 10px"
@click="lookupUser(scope.row)"
v-text="scope.row.displayName"></span>
<template v-if="gameLogIsFriend(scope.row)">
<span v-if="gameLogIsFavorite(scope.row)">⭐</span>
<span v-else>💚</span>
</template>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('table.gameLog.user')" prop="displayName" width="180">
<template #default="scope">
<span
v-if="scope.row.displayName"
class="x-link"
style="padding-right: 10px"
@click="lookupUser(scope.row)"
v-text="scope.row.displayName"></span>
</template>
</el-table-column>
@@ -158,31 +159,38 @@
size="small"
class="small-button"
@click="deleteGameLogEntry(scope.row)"></el-button>
<el-button
<i
class="ri-delete-bin-line small-button"
style="opacity: 0.85"
v-else
text
:icon="Delete"
size="small"
class="small-button"
@click="deleteGameLogEntryPrompt(scope.row)"></el-button>
@click="deleteGameLogEntryPrompt(scope.row)"></i>
</template>
<NativeTooltip placement="top" :content="t('dialog.previous_instances.info')">
<el-tooltip
v-if="scope.row.type === 'Location'"
placement="top"
:content="t('dialog.previous_instances.info')">
<el-button
v-if="scope.row.type === 'Location'"
v-if="shiftHeld"
text
:icon="DataLine"
size="small"
class="small-button"
@click="showPreviousInstancesInfoDialog(scope.row.location)"></el-button>
</NativeTooltip>
<i
v-else
style="opacity: 0.85"
class="ri-file-list-2-line small-button"
@click="showPreviousInstancesInfoDialog(scope.row.location)"></i>
</el-tooltip>
</template>
</el-table-column>
<el-table-column width="5"></el-table-column>
</DataTable>
</div>
</template>
<script setup>
import { Close, DataLine, Delete } from '@element-plus/icons-vue';
import { Close, DataLine } from '@element-plus/icons-vue';
import { ElMessageBox } from 'element-plus';
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
@@ -194,6 +202,7 @@
import { formatDateFilter, openExternalLink, removeFromArray } from '../../shared/utils';
import { database } from '../../service/database';
import { useSharedFeedStore } from '../../stores';
import { useTableHeight } from '../../composables/useTableHeight';
const { showWorldDialog } = useWorldStore();
const { lookupUser } = useUserStore();
@@ -252,6 +261,8 @@
const { t } = useI18n();
const emit = defineEmits(['updateGameLogSessionTable']);
const { containerRef: gameLogRef } = useTableHeight(gameLogTable);
function deleteGameLogEntry(row) {
removeFromArray(gameLogTable.value.data, row);
database.deleteGameLogEntry(row);
@@ -281,5 +292,9 @@
.small-button {
padding: 0;
height: 18px;
cursor: pointer;
}
.table-user {
color: var(--x-table-user-text-color) !important;
}
</style>

Some files were not shown because too many files have changed in this diff Show More