refactor: app.js (#1291)

* refactor: frontend

* Fix avatar gallery sort

* Update .NET dependencies

* Update npm dependencies

electron v37.1.0

* bulkRefreshFriends

* fix dark theme

* Remove crowdin

* Fix config.json dialog not updating

* VRCX log file fixes & add Cef log

* Remove SharedVariable, fix startup

* Revert init theme change

* Logging date not working? Fix WinformThemer designer error

* Add Cef request hander, no more escaping main page

* clean

* fix

* fix

* clean

* uh

* Apply thememode at startup, fixes random user colours

* Split database into files

* Instance info remove empty lines

* Open external VRC links with VRCX

* Electron fixes

* fix userdialog style

* ohhhh

* fix store

* fix store

* fix: load all group members after kicking a user

* fix: world dialog favorite button style

* fix: Clear VRCX Cache Timer input value

* clean

* Fix VR overlay

* Fix VR overlay 2

* Fix Discord discord rich presence for RPC worlds

* Clean up age verified user tags

* Fix playerList being occupied after program reload

* no `this`

* Fix login stuck loading

* writable: false

* Hide dialogs on logout

* add flush sync option

* rm LOGIN event

* rm LOGOUT event

* remove duplicate event listeners

* remove duplicate event listeners

* clean

* remove duplicate event listeners

* clean

* fix theme style

* fix t

* clearable

* clean

* fix ipcEvent

* Small changes

* Popcorn Palace support

* Remove checkActiveFriends

* Clean up

* Fix dragEnterCef

* Block API requests when not logged in

* Clear state on login & logout

* Fix worldDialog instances not updating

* use <script setup>

* Fix avatar change event, CheckGameRunning at startup

* Fix image dragging

* fix

* Remove PWI

* fix updateLoop

* add webpack-dev-server to dev environment

* rm unnecessary chunks

* use <script setup>

* webpack-dev-server changes

* use <script setup>

* use <script setup>

* Fix UGC text size

* Split login event

* t

* use <script setup>

* fix

* Update .gitignore and enable checkJs in jsconfig

* fix i18n t

* use <script setup>

* use <script setup>

* clean

* global types

* fix

* use checkJs for debugging

* Add watchState for login watchers

* fix .vue template

* type fixes

* rm Vue.filter

* Cef v138.0.170, VC++ 2022

* Settings fixes

* Remove 'USER:CURRENT'

* clean up 2FA callbacks

* remove userApply

* rm i18n import

* notification handling to use notification store methods

* refactor favorite handling to use favorite store methods and clean up event emissions

* refactor moderation handling to use dedicated functions for player moderation events

* refactor friend handling to use dedicated functions for friend events

* Fix program startup, move lang init

* Fix friend state

* Fix status change error

* Fix user notes diff

* fix

* rm group event

* rm auth event

* rm avatar event

* clean

* clean

* getUser

* getFriends

* getFavoriteWorlds, getFavoriteAvatars

* AvatarGalleryUpload btn style & package.json update

* Fix friend requests

* Apply user

* Apply world

* Fix note diff

* Fix VR overlay

* Fixes

* Update build scripts

* Apply avatar

* Apply instance

* Apply group

* update hidden VRC+ badge

* Fix sameInstance "private"

* fix 502/504 API errors

* fix 502/504 API errors

* clean

* Fix friend in same instance on orange showing twice in friends list

* Add back in broken friend state repair methods

* add types

---------

Co-authored-by: Natsumi <cmcooper123@hotmail.com>
This commit is contained in:
pa
2025-07-14 12:00:08 +09:00
committed by GitHub
parent 952fd77ed5
commit f4f78bb5ec
323 changed files with 47745 additions and 43326 deletions

View File

@@ -1,32 +1,23 @@
<template>
<div id="chart" class="x-container">
<div id="chart" class="x-container" v-show="menuActiveIndex === 'charts'">
<div class="options-container" style="margin-top: 0">
<span class="header">{{ $t('view.charts.header') }}</span>
<span class="header">{{ t('view.charts.header') }}</span>
</div>
<InstanceActivity
:get-world-name="getWorldName"
:is-dark-mode="isDarkMode"
:dt-hour12="dtHour12"
:friends-map="friendsMap"
:local-favorite-friends="localFavoriteFriends"
@open-previous-instance-info-dialog="$emit('open-previous-instance-info-dialog', $event)" />
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
<keep-alive>
<InstanceActivity v-if="menuActiveIndex === 'charts'" />
<el-backtop target="#chart" :right="30" :bottom="30"></el-backtop>
</keep-alive>
</div>
</template>
<script>
<script setup>
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import InstanceActivity from './components/InstanceActivity.vue';
export default {
name: 'ChartsTab',
components: {
InstanceActivity
},
props: {
getWorldName: Function,
isDarkMode: Boolean,
dtHour12: Boolean,
friendsMap: Map,
localFavoriteFriends: Set
}
};
import { useUiStore } from '../../stores';
const { t } = useI18n();
const uiStore = useUiStore();
const { menuActiveIndex } = storeToRefs(uiStore);
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -2,336 +2,332 @@
<div style="width: 100%">
<div style="height: 25px; margin-top: 60px">
<transition name="el-fade-in-linear">
<location
<Location
v-show="!isLoading"
class="location"
:location="activityDetailData[0].location"
is-open-previous-instance-info-dialog
@open-previous-instance-info-dialog="
$emit('open-previous-instance-info-dialog', $event)
"></location>
:location="activityDetailData[0]?.location"
is-open-previous-instance-info-dialog />
</transition>
</div>
<div ref="activityDetailChart"></div>
<div ref="activityDetailChartRef"></div>
</div>
</template>
<script>
<script setup>
import { ref, watch, computed, onDeactivated, onMounted } from 'vue';
import dayjs from 'dayjs';
import utils from '../../../classes/utils';
import Location from '../../../components/Location.vue';
import { storeToRefs } from 'pinia';
export default {
name: 'InstanceActivityDetail',
components: {
Location
import { loadEcharts, timeToText } from '../../../shared/utils';
import { useUserStore, useAppearanceSettingsStore } from '../../../stores';
const { isDarkMode, dtHour12 } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { currentUser } = storeToRefs(useUserStore());
const props = defineProps({
activityDetailData: {
type: Array,
required: true
},
inject: ['API', 'showUserDialog'],
props: {
activityDetailData: {
type: Array,
required: true
},
isDarkMode: {
type: Boolean,
required: true
},
dtHour12: {
type: Boolean,
required: true
},
barWidth: {
type: Number,
required: true,
default: 10
barWidth: {
type: Number,
required: true,
default: 10
}
});
const activityDetailChartRef = ref(null);
const echarts = ref(null);
const isLoading = ref(true);
const echartsInstance = ref(null);
const usersFirstActivity = ref(null);
const resizeObserver = ref(null);
const startTimeStamp = computed(() => {
return props.activityDetailData.find((item) => item.user_id === currentUser.value.id)?.joinTime.valueOf();
});
const endTimeStamp = computed(() => {
return props.activityDetailData.find((item) => item.user_id === currentUser.value.id)?.leaveTime.valueOf();
});
watch(
() => isDarkMode.value,
() => {
if (echartsInstance.value) {
echartsInstance.value.dispose();
echartsInstance.value = null;
initEcharts();
}
},
data() {
return {
echarts: null,
isLoading: true,
echartsInstance: null,
usersFirstActivity: null,
resizeObserver: null
};
},
computed: {
startTimeStamp() {
return this.activityDetailData
.find((item) => item.user_id === this.API.currentUser.id)
?.joinTime.valueOf();
},
endTimeStamp() {
return this.activityDetailData
.find((item) => item.user_id === this.API.currentUser.id)
?.leaveTime.valueOf();
}
);
watch(
() => dtHour12.value,
() => {
if (echartsInstance.value) {
initEcharts();
}
},
watch: {
isDarkMode() {
if (this.echartsInstance) {
this.echartsInstance.dispose();
this.echartsInstance = null;
this.initEcharts();
}
},
dtHour12() {
if (this.echartsInstance) {
this.initEcharts();
}
}
},
created() {
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.echartsInstance.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
}
});
},
mounted() {
this.initEcharts(true);
},
deactivated() {
// prevent switch tab play resize animation
this.resizeObserver.disconnect();
},
}
);
methods: {
async initEcharts(isFirstLoad = false) {
if (!this.echarts) {
this.echarts = await utils.loadEcharts();
}
initResizeObserver();
const chartsHeight = this.activityDetailData.length * (this.barWidth + 10) + 200;
const chartDom = this.$refs.activityDetailChart;
if (!this.echartsInstance) {
this.echartsInstance = this.echarts.init(chartDom, `${this.isDarkMode ? 'dark' : null}`, {
height: chartsHeight,
useDirtyRect: this.activityDetailData.length > 80
});
this.resizeObserver.observe(chartDom);
}
onMounted(() => {
initEcharts(true);
});
this.echartsInstance.resize({
height: chartsHeight,
onDeactivated(() => {
// prevent switch tab play resize animation
resizeObserver.value.disconnect();
});
function initResizeObserver() {
resizeObserver.value = new ResizeObserver((entries) => {
for (const entry of entries) {
echartsInstance.value.resize({
width: entry.contentRect.width,
animation: {
duration: 300
}
});
this.echartsInstance.setOption(isFirstLoad ? {} : this.getNewOption(), { lazyUpdate: true });
this.echartsInstance.on('click', 'yAxis', this.handleClickYAxisLabel);
setTimeout(() => {
this.isLoading = false;
}, 200);
},
handleClickYAxisLabel(params) {
const userData = this.usersFirstActivity[params.dataIndex];
if (userData?.user_id) {
this.showUserDialog(userData.user_id);
}
},
getNewOption() {
// grouping player activity entries by user_id and calculate below:
// 1. offset: the time from startTimeStamp or the previous entry's tail to the current entry's joinTime
// 2. time: the time the user spent in the instance
// 3. tail: the time from startTimeStamp to the current entry's leaveTime
// 4. entry: the original activity detail entry
const userGroupedEntries = new Map();
// uniqueUserEntries has each user's first entry and used to keep the order of the users calculated in InstanceActivity.vue
const uniqueUserEntries = [];
for (const entry of this.activityDetailData) {
if (!userGroupedEntries.has(entry.user_id)) {
userGroupedEntries.set(entry.user_id, []);
uniqueUserEntries.push(entry);
}
const elements = userGroupedEntries.get(entry.user_id);
const offset = Math.max(
0,
elements.length === 0
? entry.joinTime.valueOf() - this.startTimeStamp
: entry.joinTime.valueOf() - this.startTimeStamp - elements[elements.length - 1].tail
);
const tail =
elements.length === 0
? offset + entry.time
: elements[elements.length - 1].tail + offset + entry.time;
const element = { offset, time: entry.time, tail, entry };
elements.push(element);
}
this.usersFirstActivity = uniqueUserEntries;
const generateSeries = () => {
const maxEntryCount = Math.max(
...Array.from(userGroupedEntries.values()).map((entries) => entries.length)
);
const placeholderSeries = (data) => {
return {
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data
};
};
const timeSeries = (data) => {
return {
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: this.barWidth,
emphasis: {
focus: 'self'
},
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data
};
};
// generate series having placeholder and time series for each user
const series = Array(maxEntryCount)
.fill(0)
.flatMap((_, index) => {
return [
placeholderSeries(
uniqueUserEntries.map((entry) => {
const element = userGroupedEntries.get(entry.user_id)[index];
return element ? element.offset : 0;
})
),
timeSeries(
uniqueUserEntries.map((entry) => {
const element = userGroupedEntries.get(entry.user_id)[index];
return element ? element.time : 0;
})
)
];
});
return series;
};
const friendOrFavIcon = (display_name) => {
const foundItem = this.activityDetailData.find((item) => item.display_name === display_name);
if (!foundItem) {
return '';
}
if (foundItem.isFavorite) {
return '⭐';
}
if (foundItem.isFriend) {
return '💚';
}
return '';
};
const getTooltip = (params) => {
const activityDetailData = this.activityDetailData;
const param = params;
const userData = uniqueUserEntries[param.dataIndex];
const isTimeSeries = params.seriesIndex % 2 === 1;
if (!isTimeSeries) {
return '';
}
const targetEntryIndex = Math.floor(params.seriesIndex / 2);
if (!activityDetailData || !userData) {
return '';
}
// first, find the user's entries, then get the focused entry
const instanceData = userGroupedEntries.get(userData.user_id)[targetEntryIndex].entry;
const format = this.dtHour12 ? 'hh:mm:ss A' : 'HH:mm:ss';
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format(format);
const formattedJoinDateTime = dayjs(instanceData.joinTime).format(format);
const timeString = utils.timeToText(instanceData.time, true);
const color = param.color;
return `
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 55px; background-color: ${color}; margin-right: 5px;"></div>
<div>
<div>${instanceData.display_name} ${friendOrFavIcon(instanceData.display_name)}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const format = this.dtHour12 ? 'hh:mm A' : 'HH:mm';
const echartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'shadow'
},
formatter: getTooltip
},
grid: {
top: 50,
left: 160,
right: 90
},
yAxis: {
type: 'category',
axisLabel: {
interval: 0,
formatter: (value) => {
const MAX_LENGTH = 20;
const len = value.length;
return `${friendOrFavIcon(value)} ${len > MAX_LENGTH ? `${value.substring(0, MAX_LENGTH)}...` : value}`;
}
},
inverse: true,
data: uniqueUserEntries.map((item) => item.display_name),
triggerEvent: true
},
xAxis: {
type: 'value',
min: 0,
max: this.endTimeStamp - this.startTimeStamp,
axisLine: { show: true },
axisLabel: {
formatter: (value) => dayjs(value + this.startTimeStamp).format(format)
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: generateSeries(),
backgroundColor: 'rgba(0, 0, 0, 0)'
};
return echartsOption;
}
});
}
async function initEcharts(isFirstLoad = false) {
if (!echarts.value) {
echarts.value = await loadEcharts();
}
};
const chartsHeight = props.activityDetailData.length * (props.barWidth + 10) + 200;
const chartDom = activityDetailChartRef.value;
if (!echartsInstance.value) {
echartsInstance.value = echarts.value.init(chartDom, `${isDarkMode.value ? 'dark' : null}`, {
height: chartsHeight,
useDirtyRect: props.activityDetailData.length > 80
});
resizeObserver.value.observe(chartDom);
}
echartsInstance.value.resize({
height: chartsHeight,
animation: {
duration: 300
}
});
echartsInstance.value.setOption(isFirstLoad ? {} : getNewOption(), { lazyUpdate: true });
echartsInstance.value.on('click', 'yAxis', handleClickYAxisLabel);
setTimeout(() => {
isLoading.value = false;
}, 200);
}
function handleClickYAxisLabel(params) {
const userData = usersFirstActivity.value[params.dataIndex];
if (userData?.user_id) {
showUserDialog(userData.user_id);
}
}
function getNewOption() {
// grouping player activity entries by user_id and calculate below:
// 1. offset: the time from startTimeStamp or the previous entry's tail to the current entry's joinTime
// 2. time: the time the user spent in the instance
// 3. tail: the time from startTimeStamp to the current entry's leaveTime
// 4. entry: the original activity detail entry
const userGroupedEntries = new Map();
// uniqueUserEntries has each user's first entry and used to keep the order of the users calculated in InstanceActivity.vue
const uniqueUserEntries = [];
for (const entry of props.activityDetailData) {
if (!userGroupedEntries.has(entry.user_id)) {
userGroupedEntries.set(entry.user_id, []);
uniqueUserEntries.push(entry);
}
const elements = userGroupedEntries.get(entry.user_id);
const offset = Math.max(
0,
elements.length === 0
? entry.joinTime.valueOf() - startTimeStamp.value
: entry.joinTime.valueOf() - startTimeStamp.value - elements[elements.length - 1].tail
);
const tail =
elements.length === 0 ? offset + entry.time : elements[elements.length - 1].tail + offset + entry.time;
const element = { offset, time: entry.time, tail, entry };
elements.push(element);
}
usersFirstActivity.value = uniqueUserEntries;
const generateSeries = () => {
const maxEntryCount = Math.max(...Array.from(userGroupedEntries.values()).map((entries) => entries.length));
const placeholderSeries = (data) => {
return {
name: 'Placeholder',
type: 'bar',
stack: 'Total',
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
},
emphasis: {
itemStyle: {
borderColor: 'transparent',
color: 'transparent'
}
},
data
};
};
const timeSeries = (data) => {
return {
name: 'Time',
type: 'bar',
stack: 'Total',
colorBy: 'data',
barWidth: props.barWidth,
emphasis: {
focus: 'self'
},
itemStyle: {
borderRadius: 2,
shadowBlur: 2,
shadowOffsetX: 0.7,
shadowOffsetY: 0.5
},
data
};
};
// generate series having placeholder and time series for each user
const series = Array(maxEntryCount)
.fill(0)
.flatMap((_, index) => {
return [
placeholderSeries(
uniqueUserEntries.map((entry) => {
const element = userGroupedEntries.get(entry.user_id)[index];
return element ? element.offset : 0;
})
),
timeSeries(
uniqueUserEntries.map((entry) => {
const element = userGroupedEntries.get(entry.user_id)[index];
return element ? element.time : 0;
})
)
];
});
return series;
};
const friendOrFavIcon = (display_name) => {
const foundItem = props.activityDetailData.find((item) => item.display_name === display_name);
if (!foundItem) {
return '';
}
if (foundItem.isFavorite) {
return '⭐';
}
if (foundItem.isFriend) {
return '💚';
}
return '';
};
const getTooltip = (params) => {
const activityDetailData = props.activityDetailData;
const param = params;
const userData = uniqueUserEntries[param.dataIndex];
const isTimeSeries = params.seriesIndex % 2 === 1;
if (!isTimeSeries) {
return '';
}
const targetEntryIndex = Math.floor(params.seriesIndex / 2);
if (!activityDetailData || !userData) {
return '';
}
// first, find the user's entries, then get the focused entry
const instanceData = userGroupedEntries.get(userData.user_id)[targetEntryIndex].entry;
const format = dtHour12.value ? 'hh:mm:ss A' : 'HH:mm:ss';
const formattedLeftDateTime = dayjs(instanceData.leaveTime).format(format);
const formattedJoinDateTime = dayjs(instanceData.joinTime).format(format);
const timeString = timeToText(instanceData.time, true);
const color = param.color;
return `
<div style="display: flex; align-items: center;">
<div style="width: 10px; height: 55px; background-color: ${color}; margin-right: 5px;"></div>
<div>
<div>${instanceData.display_name} ${friendOrFavIcon(instanceData.display_name)}</div>
<div>${formattedJoinDateTime} - ${formattedLeftDateTime}</div>
<div>${timeString}</div>
</div>
</div>
`;
};
const format = dtHour12.value ? 'hh:mm A' : 'HH:mm';
const echartsOption = {
tooltip: {
trigger: 'item',
axisPointer: {
type: 'shadow'
},
formatter: getTooltip
},
grid: {
top: 50,
left: 160,
right: 90
},
yAxis: {
type: 'category',
axisLabel: {
interval: 0,
formatter: (value) => {
const MAX_LENGTH = 20;
const len = value.length;
return `${friendOrFavIcon(value)} ${len > MAX_LENGTH ? `${value.substring(0, MAX_LENGTH)}...` : value}`;
}
},
inverse: true,
data: uniqueUserEntries.map((item) => item.display_name),
triggerEvent: true
},
xAxis: {
type: 'value',
min: 0,
max: endTimeStamp.value - startTimeStamp.value,
axisLine: { show: true },
axisLabel: {
formatter: (value) => dayjs(value + startTimeStamp.value).format(format)
},
splitLine: { lineStyle: { type: 'dashed' } }
},
series: generateSeries(),
backgroundColor: 'rgba(0, 0, 0, 0)'
};
return echartsOption;
}
defineExpose({
echartsInstance,
initEcharts
});
</script>
<style scoped>

View File

@@ -2,317 +2,222 @@
<div v-show="menuActiveIndex === 'favorite'" class="x-container">
<div style="font-size: 13px; position: absolute; display: flex; right: 0; z-index: 1; margin-right: 15px">
<div v-if="editFavoritesMode" style="display: inline-block; margin-right: 10px">
<el-button size="small" @click="clearBulkFavoriteSelection">{{ $t('view.favorite.clear') }}</el-button>
<el-button size="small" @click="bulkCopyFavoriteSelection">{{ $t('view.favorite.copy') }}</el-button>
<el-button size="small" @click="clearBulkFavoriteSelection">{{ t('view.favorite.clear') }}</el-button>
<el-button size="small" @click="bulkCopyFavoriteSelection">{{ t('view.favorite.copy') }}</el-button>
<el-button size="small" @click="showBulkUnfavoriteSelectionConfirm">{{
$t('view.favorite.bulk_unfavorite')
t('view.favorite.bulk_unfavorite')
}}</el-button>
</div>
<div style="display: flex; align-items: center; margin-right: 10px">
<span class="name">{{ $t('view.favorite.edit_mode') }}</span>
<span class="name">{{ t('view.favorite.edit_mode') }}</span>
<el-switch v-model="editFavoritesMode" style="margin-left: 5px"></el-switch>
</div>
<el-tooltip placement="bottom" :content="$t('view.favorite.refresh_tooltip')" :disabled="hideTooltips">
<el-tooltip placement="bottom" :content="t('view.favorite.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
:loading="API.isFavoriteLoading"
@click="
API.refreshFavorites();
getLocalWorldFavorites();
"
:loading="isFavoriteLoading"
size="small"
icon="el-icon-refresh"
circle></el-button>
circle
@click="
refreshFavorites();
getLocalWorldFavorites();
"></el-button>
</el-tooltip>
</div>
<el-tabs v-model="currentTabName" v-loading="API.isFavoriteLoading" type="card" style="height: 100%">
<el-tab-pane name="friend" :label="$t('view.favorite.friends.header')" lazy>
<el-tabs v-model="currentTabName" v-loading="isFavoriteLoading" type="card" style="height: 100%">
<el-tab-pane name="friend" :label="t('view.favorite.friends.header')">
<FavoritesFriendTab
:favorite-friends="favoriteFriends"
:sort-favorites.sync="isSortByTime"
:hide-tooltips="hideTooltips"
:grouped-by-group-key-favorite-friends="groupedByGroupKeyFavoriteFriends"
:edit-favorites-mode="editFavoritesMode"
@show-friend-import-dialog="showFriendImportDialog"
@save-sort-favorites-option="saveSortFavoritesOption"
@change-favorite-group-name="changeFavoriteGroupName" />
</el-tab-pane>
<el-tab-pane name="world" :label="$t('view.favorite.worlds.header')" lazy>
<el-tab-pane name="world" :label="t('view.favorite.worlds.header')" lazy>
<FavoritesWorldTab
@show-world-import-dialog="showWorldImportDialog"
@save-sort-favorites-option="saveSortFavoritesOption"
@change-favorite-group-name="changeFavoriteGroupName"
@new-instance-self-invite="newInstanceSelfInvite"
@refresh-local-world-favorite="refreshLocalWorldFavorites"
@delete-local-world-favorite-group="deleteLocalWorldFavoriteGroup"
@remove-local-world-favorite="removeLocalWorldFavorite"
@rename-local-world-favorite-group="renameLocalWorldFavoriteGroup"
@new-local-world-favorite-group="newLocalWorldFavoriteGroup"
:sort-favorites.sync="isSortByTime"
:hide-tooltips="hideTooltips"
:favorite-worlds="favoriteWorlds"
:edit-favorites-mode="editFavoritesMode"
:shift-held="shiftHeld"
:refresh-local-world-favorites="refreshLocalWorldFavorites"
:local-world-favorite-groups="localWorldFavoriteGroups"
:local-world-favorites="localWorldFavorites"
:local-world-favorites-list="localWorldFavoritesList" />
</el-tab-pane>
<el-tab-pane name="avatar" :label="$t('view.favorite.avatars.header')" lazy>
<FavoritesAvatarTab
:sort-favorites.sync="isSortByTime"
:hide-tooltips="hideTooltips"
:shift-held="shiftHeld"
:edit-favorites-mode="editFavoritesMode"
:avatar-history-array="avatarHistoryArray"
:refreshing-local-favorites="refreshingLocalFavorites"
:local-avatar-favorite-groups="localAvatarFavoriteGroups"
:local-avatar-favorites="localAvatarFavorites"
:favorite-avatars="favoriteAvatars"
:local-avatar-favorites-list="localAvatarFavoritesList"
@show-avatar-import-dialog="showAvatarImportDialog"
@save-sort-favorites-option="saveSortFavoritesOption"
@change-favorite-group-name="changeFavoriteGroupName"
@remove-local-avatar-favorite="removeLocalAvatarFavorite"
@select-avatar-with-confirmation="selectAvatarWithConfirmation"
@prompt-clear-avatar-history="promptClearAvatarHistory"
@prompt-new-local-avatar-favorite-group="promptNewLocalAvatarFavoriteGroup"
@refresh-local-avatar-favorites="refreshLocalAvatarFavorites"
@prompt-local-avatar-favorite-group-rename="promptLocalAvatarFavoriteGroupRename"
@prompt-local-avatar-favorite-group-delete="promptLocalAvatarFavoriteGroupDelete" />
@refresh-local-world-favorite="refreshLocalWorldFavorites" />
</el-tab-pane>
<el-tab-pane name="avatar" :label="t('view.favorite.avatars.header')" lazy>
<FavoritesAvatarTab
:hide-tooltips="hideTooltips"
:edit-favorites-mode="editFavoritesMode"
:refreshing-local-favorites="refreshingLocalFavorites"
@change-favorite-group-name="changeFavoriteGroupName"
@refresh-local-avatar-favorites="refreshLocalAvatarFavorites" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
<script setup>
import { ref, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import * as workerTimers from 'worker-timers';
import { avatarRequest, favoriteRequest, worldRequest } from '../../api';
import { useAppearanceSettingsStore, useFavoriteStore, useUiStore, useAvatarStore } from '../../stores';
import FavoritesAvatarTab from './components/FavoritesAvatarTab.vue';
import FavoritesFriendTab from './components/FavoritesFriendTab.vue';
import FavoritesWorldTab from './components/FavoritesWorldTab.vue';
import FavoritesAvatarTab from './components/FavoritesAvatarTab.vue';
import { avatarRequest, favoriteRequest, worldRequest } from '../../api';
import * as workerTimers from 'worker-timers';
export default {
name: 'FavoritesTab',
components: {
FavoritesFriendTab,
FavoritesWorldTab,
FavoritesAvatarTab
},
inject: ['API'],
props: {
menuActiveIndex: String,
hideTooltips: Boolean,
shiftHeld: Boolean,
favoriteFriends: Array,
sortFavorites: Boolean,
groupedByGroupKeyFavoriteFriends: Object,
favoriteWorlds: Array,
localWorldFavoriteGroups: Array,
localWorldFavorites: Object,
avatarHistoryArray: Array,
localAvatarFavoriteGroups: Array,
localAvatarFavorites: Object,
favoriteAvatars: Array,
localAvatarFavoritesList: Array,
localWorldFavoritesList: Array
},
data() {
return {
editFavoritesMode: false,
refreshingLocalFavorites: false,
currentTabName: 'friend'
};
},
computed: {
isSortByTime: {
get() {
return this.sortFavorites;
},
set(value) {
this.$emit('update:sort-favorites', value);
}
}
},
methods: {
showBulkUnfavoriteSelectionConfirm() {
const elementsTicked = [];
// check favorites type
for (const ctx of this.favoriteFriends) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
for (const ctx of this.favoriteWorlds) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
for (const ctx of this.favoriteAvatars) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
if (elementsTicked.length === 0) {
return;
}
this.$confirm(
`Are you sure you want to unfavorite ${elementsTicked.length} favorites?
This action cannot be undone.`,
`Delete ${elementsTicked.length} favorites?`,
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
this.bulkUnfavoriteSelection(elementsTicked);
}
}
}
);
},
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const {
favoriteFriends,
favoriteWorlds,
favoriteAvatars,
isFavoriteLoading,
localAvatarFavoritesList,
localWorldFavoritesList
} = storeToRefs(useFavoriteStore());
const {
refreshFavorites,
refreshFavoriteGroups,
clearBulkFavoriteSelection,
bulkCopyFavoriteSelection,
getLocalWorldFavorites,
handleFavoriteGroup
} = useFavoriteStore();
const { menuActiveIndex } = storeToRefs(useUiStore());
const { applyAvatar } = useAvatarStore();
bulkUnfavoriteSelection(elementsTicked) {
for (const id of elementsTicked) {
favoriteRequest.deleteFavorite({
objectId: id
});
}
this.editFavoritesMode = false;
},
changeFavoriteGroupName(ctx) {
this.$prompt(
$t('prompt.change_favorite_group_name.description'),
$t('prompt.change_favorite_group_name.header'),
{
distinguishCancelAndClose: true,
cancelButtonText: $t('prompt.change_favorite_group_name.cancel'),
confirmButtonText: $t('prompt.change_favorite_group_name.change'),
inputPlaceholder: $t('prompt.change_favorite_group_name.input_placeholder'),
inputValue: ctx.displayName,
inputPattern: /\S+/,
inputErrorMessage: $t('prompt.change_favorite_group_name.input_error'),
callback: (action, instance) => {
if (action === 'confirm') {
favoriteRequest
.saveFavoriteGroup({
type: ctx.type,
group: ctx.name,
displayName: instance.inputValue
})
.then(() => {
this.$message({
message: $t('prompt.change_favorite_group_name.message.success'),
type: 'success'
});
// load new group name
this.API.refreshFavoriteGroups();
});
}
}
}
);
},
const editFavoritesMode = ref(false);
const refreshingLocalFavorites = ref(false);
const currentTabName = ref('friend');
async refreshLocalAvatarFavorites() {
if (this.refreshingLocalFavorites) {
return;
}
this.refreshingLocalFavorites = true;
for (const avatarId of this.localAvatarFavoritesList) {
if (!this.refreshingLocalFavorites) {
break;
}
try {
await avatarRequest.getAvatar({
avatarId
});
} catch (err) {
console.error(err);
}
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
}
this.refreshingLocalFavorites = false;
},
async refreshLocalWorldFavorites() {
if (this.refreshingLocalFavorites) {
return;
}
this.refreshingLocalFavorites = true;
for (const worldId of this.localWorldFavoritesList) {
if (!this.refreshingLocalFavorites) {
break;
}
try {
await worldRequest.getWorld({
worldId
});
} catch (err) {
console.error(err);
}
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
}
this.refreshingLocalFavorites = false;
},
clearBulkFavoriteSelection() {
this.$emit('clear-bulk-favorite-selection');
},
bulkCopyFavoriteSelection() {
this.$emit('bulk-copy-favorite-selection', this.currentTabName);
},
getLocalWorldFavorites() {
this.$emit('get-local-world-favorites');
},
showFriendImportDialog() {
this.$emit('show-friend-import-dialog');
},
saveSortFavoritesOption() {
this.$emit('save-sort-favorites-option');
},
showWorldImportDialog() {
this.$emit('show-world-import-dialog');
},
newInstanceSelfInvite(worldId) {
this.$emit('new-instance-self-invite', worldId);
},
deleteLocalWorldFavoriteGroup(group) {
this.$emit('delete-local-world-favorite-group', group);
},
removeLocalWorldFavorite(worldId, group) {
this.$emit('remove-local-world-favorite', worldId, group);
},
showAvatarImportDialog() {
this.$emit('show-avatar-import-dialog');
},
removeLocalAvatarFavorite(avatarId, group) {
this.$emit('remove-local-avatar-favorite', avatarId, group);
},
selectAvatarWithConfirmation(id) {
this.$emit('select-avatar-with-confirmation', id);
},
promptClearAvatarHistory() {
this.$emit('prompt-clear-avatar-history');
},
promptNewLocalAvatarFavoriteGroup() {
this.$emit('prompt-new-local-avatar-favorite-group');
},
promptLocalAvatarFavoriteGroupRename(group) {
this.$emit('prompt-local-avatar-favorite-group-rename', group);
},
promptLocalAvatarFavoriteGroupDelete(group) {
this.$emit('prompt-local-avatar-favorite-group-delete', group);
},
renameLocalWorldFavoriteGroup(inputValue, group) {
this.$emit('rename-local-world-favorite-group', inputValue, group);
},
newLocalWorldFavoriteGroup(inputValue) {
this.$emit('new-local-world-favorite-group', inputValue);
function showBulkUnfavoriteSelectionConfirm() {
const elementsTicked = [];
// check favorites type
for (const ctx of favoriteFriends.value) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
};
for (const ctx of favoriteWorlds.value) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
for (const ctx of favoriteAvatars.value) {
if (ctx.$selected) {
elementsTicked.push(ctx.id);
}
}
if (elementsTicked.length === 0) {
return;
}
proxy.$confirm(
`Are you sure you want to unfavorite ${elementsTicked.length} favorites?
This action cannot be undone.`,
`Delete ${elementsTicked.length} favorites?`,
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
bulkUnfavoriteSelection(elementsTicked);
}
}
}
);
}
function bulkUnfavoriteSelection(elementsTicked) {
for (const id of elementsTicked) {
favoriteRequest.deleteFavorite({
objectId: id
});
}
editFavoritesMode.value = false;
}
function changeFavoriteGroupName(ctx) {
proxy.$prompt(
t('prompt.change_favorite_group_name.description'),
t('prompt.change_favorite_group_name.header'),
{
distinguishCancelAndClose: true,
cancelButtonText: t('prompt.change_favorite_group_name.cancel'),
confirmButtonText: t('prompt.change_favorite_group_name.change'),
inputPlaceholder: t('prompt.change_favorite_group_name.input_placeholder'),
inputValue: ctx.displayName,
inputPattern: /\S+/,
inputErrorMessage: t('prompt.change_favorite_group_name.input_error'),
callback: (action, instance) => {
if (action === 'confirm') {
favoriteRequest
.saveFavoriteGroup({
type: ctx.type,
group: ctx.name,
displayName: instance.inputValue
})
.then((args) => {
handleFavoriteGroup({
json: args.json,
params: {
favoriteGroupId: args.json.id
}
});
proxy.$message({
message: t('prompt.change_favorite_group_name.message.success'),
type: 'success'
});
// load new group name
refreshFavoriteGroups();
});
}
}
}
);
}
async function refreshLocalAvatarFavorites() {
if (refreshingLocalFavorites.value) {
return;
}
refreshingLocalFavorites.value = true;
for (const avatarId of localAvatarFavoritesList.value) {
if (!refreshingLocalFavorites.value) {
break;
}
try {
const args = await avatarRequest.getAvatar({
avatarId
});
applyAvatar(args.json);
} catch (err) {
console.error(err);
}
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
}
refreshingLocalFavorites.value = false;
}
async function refreshLocalWorldFavorites() {
if (refreshingLocalFavorites.value) {
return;
}
refreshingLocalFavorites.value = true;
for (const worldId of localWorldFavoritesList.value) {
if (!refreshingLocalFavorites.value) {
break;
}
try {
await worldRequest.getWorld({
worldId
});
} catch (err) {
console.error(err);
}
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 1000);
});
}
refreshingLocalFavorites.value = false;
}
</script>

View File

@@ -16,7 +16,7 @@
</el-tooltip>
<el-dropdown-menu slot="dropdown">
<template
v-for="groupAPI in API.favoriteAvatarGroups"
v-for="groupAPI in favoriteAvatarGroups"
v-if="isLocalFavorite || groupAPI.name !== group.name">
<el-dropdown-item
:key="groupAPI.name"
@@ -36,22 +36,22 @@
<el-tooltip
v-if="favorite.deleted"
placement="left"
:content="$t('view.favorite.unavailable_tooltip')">
:content="t('view.favorite.unavailable_tooltip')">
<i class="el-icon-warning" style="color: #f56c6c; margin-left: 5px"></i>
</el-tooltip>
<el-tooltip
v-if="favorite.ref.releaseStatus === 'private'"
placement="left"
:content="$t('view.favorite.private')">
:content="t('view.favorite.private')">
<i class="el-icon-warning" style="color: #e6a23c; margin-left: 5px"></i>
</el-tooltip>
<el-tooltip
v-if="favorite.ref.releaseStatus !== 'private' && !favorite.deleted"
placement="left"
:content="$t('view.favorite.select_avatar_tooltip')"
:content="t('view.favorite.select_avatar_tooltip')"
:disabled="hideTooltips">
<el-button
:disabled="API.currentUser.currentAvatar === favorite.id"
:disabled="currentUser.currentAvatar === favorite.id"
size="mini"
icon="el-icon-check"
circle
@@ -60,7 +60,7 @@
</el-tooltip>
<el-tooltip
placement="right"
:content="$t('view.favorite.unfavorite_tooltip')"
:content="t('view.favorite.unfavorite_tooltip')"
:disabled="hideTooltips">
<el-button
v-if="shiftHeld"
@@ -82,10 +82,10 @@
<template v-else>
<el-tooltip
placement="left"
:content="$t('view.favorite.select_avatar_tooltip')"
:content="t('view.favorite.select_avatar_tooltip')"
:disabled="hideTooltips">
<el-button
:disabled="API.currentUser.currentAvatar === favorite.id"
:disabled="currentUser.currentAvatar === favorite.id"
size="mini"
circle
style="margin-left: 5px"
@@ -96,7 +96,7 @@
<el-tooltip
v-if="isLocalFavorite"
placement="right"
:content="$t('view.favorite.unfavorite_tooltip')"
:content="t('view.favorite.unfavorite_tooltip')"
:disabled="hideTooltips">
<el-button
v-if="shiftHeld"
@@ -139,104 +139,84 @@
</div>
</template>
<script>
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { favoriteRequest } from '../../../api';
import { $app } from '../../../app';
import {
useAppearanceSettingsStore,
useAvatarStore,
useFavoriteStore,
useUiStore,
useUserStore
} from '../../../stores';
export default {
name: 'FavoritesAvatarItem',
inject: ['API', 'showFavoriteDialog'],
props: {
favorite: Object,
group: [Object, String],
editFavoritesMode: Boolean,
shiftHeld: Boolean,
hideTooltips: Boolean,
isLocalFavorite: Boolean
},
computed: {
isSelected: {
get() {
return this.favorite.$selected;
},
set(value) {
this.$emit('handle-select', value);
}
},
localFavFakeRef() {
// local favorite no "ref" property
return this.isLocalFavorite ? this.favorite : this.favorite.ref;
},
tooltipContent() {
return $t(this.isLocalFavorite ? 'view.favorite.copy_tooltip' : 'view.favorite.move_tooltip');
},
smallThumbnail() {
return (
this.localFavFakeRef.thumbnailImageUrl.replace('256', '128') ||
this.localFavFakeRef.thumbnailImageUrl
);
}
},
methods: {
moveFavorite(ref, group, type) {
favoriteRequest
.deleteFavorite({
objectId: ref.id
})
.then(() => {
favoriteRequest.addFavorite({
type,
favoriteId: ref.id,
tags: group.name
});
});
},
selectAvatarWithConfirmation() {
this.$emit('select-avatar-with-confirmation', this.favorite.id);
},
deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({
objectId
const props = defineProps({
favorite: Object,
group: [Object, String],
editFavoritesMode: Boolean,
isLocalFavorite: Boolean
});
const emit = defineEmits(['click', 'handle-select']);
const { t } = useI18n();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { favoriteAvatarGroups } = storeToRefs(useFavoriteStore());
const { removeLocalAvatarFavorite, showFavoriteDialog } = useFavoriteStore();
const { selectAvatarWithConfirmation } = useAvatarStore();
const { shiftHeld } = storeToRefs(useUiStore());
const { currentUser } = storeToRefs(useUserStore());
const isSelected = computed({
get: () => props.favorite.$selected,
set: (value) => emit('handle-select', value)
});
const localFavFakeRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite.ref));
const tooltipContent = computed(() =>
t(props.isLocalFavorite ? 'view.favorite.copy_tooltip' : 'view.favorite.move_tooltip')
);
const smallThumbnail = computed(
() => localFavFakeRef.value.thumbnailImageUrl.replace('256', '128') || localFavFakeRef.value.thumbnailImageUrl
);
function moveFavorite(ref, group, type) {
favoriteRequest.deleteFavorite({ objectId: ref.id }).then(() => {
favoriteRequest.addFavorite({
type,
favoriteId: ref.id,
tags: group.name
});
});
}
function deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({ objectId });
}
function addFavoriteAvatar(groupAPI) {
return favoriteRequest
.addFavorite({
type: 'avatar',
favoriteId: props.favorite.id,
tags: groupAPI.name
})
.then((args) => {
$app.$message({
message: 'Avatar added to favorites',
type: 'success'
});
// FIXME: 메시지 수정
// this.$confirm('Continue? Delete Favorite', 'Confirm', {
// confirmButtonText: 'Confirm',
// cancelButtonText: 'Cancel',
// type: 'info',
// callback: (action) => {
// if (action === 'confirm') {
// API.deleteFavorite({
// objectId
// });
// }
// }
// });
},
addFavoriteAvatar(groupAPI) {
return favoriteRequest
.addFavorite({
type: 'avatar',
favoriteId: this.favorite.id,
tags: groupAPI.name
})
.then((args) => {
this.$message({
message: 'Avatar added to favorites',
type: 'success'
});
return args;
});
}
return args;
});
},
handleDropdownItemClick(groupAPI) {
if (this.isLocalFavorite) {
this.addFavoriteAvatar(groupAPI);
} else {
this.moveFavorite(this.favorite.ref, groupAPI, 'avatar');
}
},
removeLocalAvatarFavorite() {
this.$emit('remove-local-avatar-favorite', this.favorite.id, this.group);
}
function handleDropdownItemClick(groupAPI) {
if (props.isLocalFavorite) {
addFavoriteAvatar(groupAPI);
} else {
moveFavorite(props.favorite.ref, groupAPI, 'avatar');
}
};
}
</script>

View File

@@ -8,16 +8,16 @@
<span class="name" v-text="favorite.name"></span>
<span class="extra" v-text="favorite.authorName"></span>
</div>
<el-tooltip placement="left" :content="$t('view.favorite.select_avatar_tooltip')" :disabled="hideTooltips">
<el-tooltip placement="left" :content="t('view.favorite.select_avatar_tooltip')" :disabled="hideTooltips">
<el-button
:disabled="API.currentUser.currentAvatar === favorite.id"
:disabled="currentUser.currentAvatar === favorite.id"
size="mini"
icon="el-icon-check"
circle
style="margin-left: 5px"
@click.stop="selectAvatarWithConfirmation"></el-button>
</el-tooltip>
<template v-if="API.cachedFavoritesByObjectId.has(favorite.id)">
<template v-if="cachedFavoritesByObjectId.has(favorite.id)">
<el-tooltip placement="right" content="Unfavorite" :disabled="hideTooltips">
<el-button
type="default"
@@ -43,26 +43,28 @@
</div>
</template>
<script>
export default {
name: 'FavoritesAvatarLocalHistoryItem',
inject: ['API', 'showFavoriteDialog'],
props: {
favorite: {
type: Object,
required: true
},
hideTooltips: Boolean
},
computed: {
smallThumbnail() {
return this.favorite.thumbnailImageUrl.replace('256', '128') || this.favorite.thumbnailImageUrl;
}
},
methods: {
selectAvatarWithConfirmation() {
this.$emit('select-avatar-with-confirmation', this.favorite.id);
}
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { useAppearanceSettingsStore, useAvatarStore, useFavoriteStore, useUserStore } from '../../../stores';
const { t } = useI18n();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { cachedFavoritesByObjectId } = storeToRefs(useFavoriteStore());
const { showFavoriteDialog } = useFavoriteStore();
const { selectAvatarWithConfirmation } = useAvatarStore();
const { currentUser } = storeToRefs(useUserStore());
const props = defineProps({
favorite: {
type: Object,
required: true
}
};
});
const smallThumbnail = computed(() => {
return props.favorite.thumbnailImageUrl.replace('256', '128') || props.favorite.thumbnailImageUrl;
});
</script>

View File

@@ -3,29 +3,29 @@
<div style="display: flex; align-items: center; justify-content: space-between">
<div>
<el-button size="small" @click="showAvatarExportDialog">
{{ $t('view.favorite.export') }}
{{ t('view.favorite.export') }}
</el-button>
<el-button size="small" style="margin-left: 5px" @click="showAvatarImportDialog">
{{ $t('view.favorite.import') }}
{{ t('view.favorite.import') }}
</el-button>
</div>
<div style="display: flex; align-items: center; font-size: 13px; margin-right: 10px">
<span class="name" style="margin-right: 5px; line-height: 10px">
{{ $t('view.favorite.sort_by') }}
{{ t('view.favorite.sort_by') }}
</span>
<el-radio-group v-model="sortFav" style="margin-right: 12px" @change="saveSortFavoritesOption">
<el-radio :label="false">
{{ $t('view.settings.appearance.appearance.sort_favorite_by_name') }}
{{ t('view.settings.appearance.appearance.sort_favorite_by_name') }}
</el-radio>
<el-radio :label="true">
{{ $t('view.settings.appearance.appearance.sort_favorite_by_date') }}
{{ t('view.settings.appearance.appearance.sort_favorite_by_date') }}
</el-radio>
</el-radio-group>
<el-input
v-model="avatarFavoriteSearch"
clearable
size="mini"
:placeholder="$t('view.favorite.avatars.search')"
:placeholder="t('view.favorite.avatars.search')"
style="width: 200px"
@input="searchAvatarFavorites" />
</div>
@@ -56,16 +56,16 @@
</div>
</div>
<span style="display: block; margin-top: 20px">
{{ $t('view.favorite.avatars.vrchat_favorites') }}
{{ t('view.favorite.avatars.vrchat_favorites') }}
</span>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in API.favoriteAvatarGroups" :key="group.name">
<el-collapse-item v-for="group in favoriteAvatarGroups" :key="group.name">
<template slot="title">
<span style="font-weight: bold; font-size: 14px; margin-left: 10px" v-text="group.displayName" />
<span style="color: #909399; font-size: 12px; margin-left: 10px">
{{ group.count }}/{{ group.capacity }}
</span>
<el-tooltip placement="top" :content="$t('view.favorite.rename_tooltip')" :disabled="hideTooltips">
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :disabled="hideTooltips">
<el-button
size="mini"
icon="el-icon-edit"
@@ -73,7 +73,7 @@
style="margin-left: 10px"
@click.stop="changeFavoriteGroupName(group)" />
</el-tooltip>
<el-tooltip placement="right" :content="$t('view.favorite.clear_tooltip')" :disabled="hideTooltips">
<el-tooltip placement="right" :content="t('view.favorite.clear_tooltip')" :disabled="hideTooltips">
<el-button
size="mini"
icon="el-icon-delete"
@@ -89,12 +89,9 @@
:favorite="favorite"
:group="group"
:hide-tooltips="hideTooltips"
:shift-held="shiftHeld"
:edit-favorites-mode="editFavoritesMode"
style="display: inline-block; width: 300px; margin-right: 15px"
@handle-select="favorite.$selected = $event"
@remove-local-avatar-favorite="removeLocalAvatarFavorite"
@select-avatar-with-confirmation="selectAvatarWithConfirmation"
@click="showAvatarDialog(favorite.id)" />
</div>
<div
@@ -132,7 +129,6 @@
style="display: inline-block; width: 300px; margin-right: 15px"
:favorite="favorite"
:hide-tooltips="hideTooltips"
@select-avatar-with-confirmation="selectAvatarWithConfirmation"
@click="showAvatarDialog(favorite.id)" />
</div>
<div
@@ -148,21 +144,21 @@
<span>No Data</span>
</div>
</el-collapse-item>
<span style="display: block; margin-top: 20px">{{ $t('view.favorite.avatars.local_favorites') }}</span>
<span style="display: block; margin-top: 20px">{{ t('view.favorite.avatars.local_favorites') }}</span>
<br />
<el-button size="small" :disabled="!isLocalUserVrcplusSupporter" @click="promptNewLocalAvatarFavoriteGroup">
{{ $t('view.favorite.avatars.new_group') }}
{{ t('view.favorite.avatars.new_group') }}
</el-button>
<el-button
v-if="!refreshingLocalFavorites"
size="small"
style="margin-left: 5px"
@click="refreshLocalAvatarFavorites">
{{ $t('view.favorite.avatars.refresh') }}
{{ t('view.favorite.avatars.refresh') }}
</el-button>
<el-button v-else size="small" style="margin-left: 5px" @click="refreshingLocalFavorites = false">
<i class="el-icon-loading" style="margin-right: 5px"></i>
<span>{{ $t('view.favorite.avatars.cancel_refresh') }}</span>
<span>{{ t('view.favorite.avatars.cancel_refresh') }}</span>
</el-button>
<el-collapse-item
v-for="group in localAvatarFavoriteGroups"
@@ -173,7 +169,7 @@
<span :style="{ color: '#909399', fontSize: '12px', marginLeft: '10px' }">{{
getLocalAvatarFavoriteGroupLength(group)
}}</span>
<el-tooltip placement="top" :content="$t('view.favorite.rename_tooltip')" :disabled="hideTooltips">
<el-tooltip placement="top" :content="t('view.favorite.rename_tooltip')" :disabled="hideTooltips">
<el-button
size="mini"
icon="el-icon-edit"
@@ -181,10 +177,7 @@
:style="{ marginLeft: '5px' }"
@click.stop="promptLocalAvatarFavoriteGroupRename(group)"></el-button>
</el-tooltip>
<el-tooltip
placement="right"
:content="$t('view.favorite.delete_tooltip')"
:disabled="hideTooltips">
<el-tooltip placement="right" :content="t('view.favorite.delete_tooltip')" :disabled="hideTooltips">
<el-button
size="mini"
icon="el-icon-delete"
@@ -202,11 +195,8 @@
:favorite="favorite"
:group="group"
:hide-tooltips="hideTooltips"
:shift-held="shiftHeld"
:edit-favorites-mode="editFavoritesMode"
@handle-select="favorite.$selected = $event"
@remove-local-avatar-favorite="removeLocalAvatarFavorite"
@select-avatar-with-confirmation="selectAvatarWithConfirmation"
@click="showAvatarDialog(favorite.id)" />
</div>
<div
@@ -223,178 +213,205 @@
</div>
</el-collapse-item>
</el-collapse>
<AvatarExportDialog
:avatar-export-dialog-visible.sync="avatarExportDialogVisible"
:favorite-avatars="favoriteAvatars"
:local-avatar-favorite-groups="localAvatarFavoriteGroups"
:local-avatar-favorites="localAvatarFavorites"
:local-avatar-favorites-list="localAvatarFavoritesList" />
<AvatarExportDialog :avatar-export-dialog-visible.sync="avatarExportDialogVisible" />
</div>
</template>
<script>
<script setup>
import { ref, computed, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { favoriteRequest } from '../../../api';
import { useAppearanceSettingsStore, useAvatarStore, useFavoriteStore, useUserStore } from '../../../stores';
import AvatarExportDialog from '../dialogs/AvatarExportDialog.vue';
import FavoritesAvatarItem from './FavoritesAvatarItem.vue';
import FavoritesAvatarLocalHistoryItem from './FavoritesAvatarLocalHistoryItem.vue';
import AvatarExportDialog from '../dialogs/AvatarExportDialog.vue';
import { favoriteRequest } from '../../../api';
export default {
name: 'FavoritesAvatarTab',
components: { FavoritesAvatarItem, FavoritesAvatarLocalHistoryItem, AvatarExportDialog },
inject: ['API', 'showAvatarDialog'],
props: {
sortFavorites: Boolean,
hideTooltips: Boolean,
shiftHeld: Boolean,
editFavoritesMode: Boolean,
avatarHistoryArray: Array,
refreshingLocalFavorites: Boolean,
localAvatarFavoriteGroups: Array,
localAvatarFavorites: Object,
favoriteAvatars: Array,
localAvatarFavoritesList: Array
defineProps({
editFavoritesMode: {
type: Boolean,
default: false
},
data() {
return {
avatarExportDialogVisible: false,
avatarFavoriteSearch: '',
avatarFavoriteSearchResults: []
};
refreshingLocalFavorites: {
type: Boolean,
default: false
}
});
const { proxy } = getCurrentInstance();
const emit = defineEmits(['change-favorite-group-name', 'refresh-local-avatar-favorites']);
const { hideTooltips, sortFavorites } = storeToRefs(useAppearanceSettingsStore());
const { setSortFavorites } = useAppearanceSettingsStore();
const { favoriteAvatars, favoriteAvatarGroups, localAvatarFavorites, localAvatarFavoriteGroups } =
storeToRefs(useFavoriteStore());
const {
showAvatarImportDialog,
getLocalAvatarFavoriteGroupLength,
deleteLocalAvatarFavoriteGroup,
renameLocalAvatarFavoriteGroup,
newLocalAvatarFavoriteGroup,
saveSortFavoritesOption
} = useFavoriteStore();
const { avatarHistoryArray } = storeToRefs(useAvatarStore());
const { promptClearAvatarHistory, showAvatarDialog } = useAvatarStore();
const { currentUser } = storeToRefs(useUserStore());
const { t } = useI18n();
const avatarExportDialogVisible = ref(false);
const avatarFavoriteSearch = ref('');
const avatarFavoriteSearchResults = ref([]);
const sortFav = computed({
get() {
return sortFavorites.value;
},
computed: {
sortFav: {
get() {
return this.sortFavorites;
},
set(value) {
this.$emit('update:sort-favorites', value);
set() {
setSortFavorites();
}
});
const groupedByGroupKeyFavoriteAvatars = computed(() => {
const groupedByGroupKeyFavoriteAvatars = {};
favoriteAvatars.value.forEach((avatar) => {
if (avatar.groupKey) {
if (!groupedByGroupKeyFavoriteAvatars[avatar.groupKey]) {
groupedByGroupKeyFavoriteAvatars[avatar.groupKey] = [];
}
},
groupedByGroupKeyFavoriteAvatars() {
const groupedByGroupKeyFavoriteAvatars = {};
this.favoriteAvatars.forEach((avatar) => {
if (avatar.groupKey) {
if (!groupedByGroupKeyFavoriteAvatars[avatar.groupKey]) {
groupedByGroupKeyFavoriteAvatars[avatar.groupKey] = [];
}
groupedByGroupKeyFavoriteAvatars[avatar.groupKey].push(avatar);
}
});
return groupedByGroupKeyFavoriteAvatars;
},
isLocalUserVrcplusSupporter() {
return this.API.currentUser.$isVRCPlus;
groupedByGroupKeyFavoriteAvatars[avatar.groupKey].push(avatar);
}
},
methods: {
getLocalAvatarFavoriteGroupLength(group) {
const favoriteGroup = this.localAvatarFavorites[group];
if (!favoriteGroup) {
return 0;
}
return favoriteGroup.length;
},
searchAvatarFavorites() {
let ref = null;
const search = this.avatarFavoriteSearch.toLowerCase();
if (search.length < 3) {
this.avatarFavoriteSearchResults = [];
return;
}
});
const results = [];
for (let i = 0; i < this.localAvatarFavoriteGroups.length; ++i) {
const group = this.localAvatarFavoriteGroups[i];
if (!this.localAvatarFavorites[group]) {
continue;
}
for (let j = 0; j < this.localAvatarFavorites[group].length; ++j) {
ref = this.localAvatarFavorites[group][j];
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
return groupedByGroupKeyFavoriteAvatars;
});
const isLocalUserVrcplusSupporter = computed(() => currentUser.value.$isVRCPlus);
function searchAvatarFavorites() {
let ref = null;
const search = avatarFavoriteSearch.value.toLowerCase();
if (search.length < 3) {
avatarFavoriteSearchResults.value = [];
return;
}
const results = [];
for (let i = 0; i < localAvatarFavoriteGroups.value.length; ++i) {
const group = localAvatarFavoriteGroups.value[i];
if (!localAvatarFavorites.value[group]) {
continue;
}
for (let j = 0; j < localAvatarFavorites.value[group].length; ++j) {
ref = localAvatarFavorites.value[group][j];
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
for (let i = 0; i < this.favoriteAvatars.length; ++i) {
ref = this.favoriteAvatars[i].ref;
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
this.avatarFavoriteSearchResults = results;
},
clearFavoriteGroup(ctx) {
// FIXME: 메시지 수정
this.$confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
}
});
},
showAvatarExportDialog() {
this.avatarExportDialogVisible = true;
},
showAvatarImportDialog() {
this.$emit('show-avatar-import-dialog');
},
saveSortFavoritesOption() {
this.$emit('save-sort-favorites-option');
},
changeFavoriteGroupName(group) {
this.$emit('change-favorite-group-name', group);
},
removeLocalAvatarFavorite(id, group) {
this.$emit('remove-local-avatar-favorite', id, group);
},
selectAvatarWithConfirmation(id) {
this.$emit('select-avatar-with-confirmation', id);
},
promptClearAvatarHistory() {
this.$emit('prompt-clear-avatar-history');
},
promptNewLocalAvatarFavoriteGroup() {
this.$emit('prompt-new-local-avatar-favorite-group');
},
refreshLocalAvatarFavorites() {
this.$emit('refresh-local-avatar-favorites');
},
promptLocalAvatarFavoriteGroupRename(group) {
this.$emit('prompt-local-avatar-favorite-group-rename', group);
},
promptLocalAvatarFavoriteGroupDelete(group) {
this.$emit('prompt-local-avatar-favorite-group-delete', group);
}
}
};
for (let i = 0; i < favoriteAvatars.value.length; ++i) {
ref = favoriteAvatars.value[i].ref;
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
avatarFavoriteSearchResults.value = results;
}
function clearFavoriteGroup(ctx) {
proxy.$confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
}
});
}
function showAvatarExportDialog() {
avatarExportDialogVisible.value = true;
}
function changeFavoriteGroupName(group) {
emit('change-favorite-group-name', group);
}
function promptNewLocalAvatarFavoriteGroup() {
proxy.$prompt(t('prompt.new_local_favorite_group.description'), t('prompt.new_local_favorite_group.header'), {
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.new_local_favorite_group.ok'),
cancelButtonText: t('prompt.new_local_favorite_group.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.new_local_favorite_group.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
newLocalAvatarFavoriteGroup(instance.inputValue);
}
}
});
}
function refreshLocalAvatarFavorites() {
emit('refresh-local-avatar-favorites');
}
function promptLocalAvatarFavoriteGroupRename(group) {
proxy.$prompt(
t('prompt.local_favorite_group_rename.description'),
t('prompt.local_favorite_group_rename.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.local_favorite_group_rename.save'),
cancelButtonText: t('prompt.local_favorite_group_rename.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.local_favorite_group_rename.input_error'),
inputValue: group,
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
renameLocalAvatarFavoriteGroup(instance.inputValue, group);
}
}
}
);
}
function promptLocalAvatarFavoriteGroupDelete(group) {
proxy.$confirm(`Delete Group? ${group}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deleteLocalAvatarFavoriteGroup(group);
}
}
});
}
</script>

View File

@@ -10,12 +10,12 @@
class="name"
:style="{ color: favorite.ref.$userColour }"
v-text="favorite.ref.displayName"></span>
<location
<Location
class="extra"
v-if="favorite.ref.location !== 'offline'"
:location="favorite.ref.location"
:traveling="favorite.ref.travelingToLocation"
:link="false"></location>
:link="false" />
<span v-else v-text="favorite.ref.statusDescription"></span>
</div>
<template v-if="editFavoritesMode">
@@ -27,7 +27,7 @@
<el-button type="default" icon="el-icon-back" size="mini" circle></el-button>
</el-tooltip>
<el-dropdown-menu slot="dropdown">
<template v-for="groupAPI in API.favoriteFriendGroups">
<template v-for="groupAPI in favoriteFriendGroups">
<el-dropdown-item
v-if="groupAPI.name !== group.name"
:key="groupAPI.name"
@@ -82,63 +82,36 @@
</div>
</template>
<script>
import Location from '../../../components/Location.vue';
<script setup>
import { storeToRefs } from 'pinia';
import { favoriteRequest } from '../../../api';
export default {
components: { Location },
inject: ['showUserDialog', 'userImage', 'userStatusClass', 'API', 'showFavoriteDialog'],
props: {
favorite: {
type: Object,
required: true
},
hideTooltips: {
type: Boolean,
default: false
},
shiftHeld: {
type: Boolean,
default: false
},
group: {
type: Object,
required: true
},
editFavoritesMode: Boolean
},
methods: {
moveFavorite(ref, group, type) {
favoriteRequest
.deleteFavorite({
objectId: ref.id
})
.then(() => {
favoriteRequest.addFavorite({
type,
favoriteId: ref.id,
tags: group.name
});
});
},
deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({
objectId
});
// FIXME: 메시지 수정
// this.$confirm('Continue? Delete Favorite', 'Confirm', {
// confirmButtonText: 'Confirm',
// cancelButtonText: 'Cancel',
// type: 'info',
// callback: (action) => {
// if (action === 'confirm') {
// API.deleteFavorite({
// objectId
// });
// }
// }
// });
}
}
};
import { userImage, userStatusClass } from '../../../shared/utils';
import { useAppearanceSettingsStore, useFavoriteStore, useUiStore } from '../../../stores';
defineProps({
favorite: { type: Object, required: true },
group: { type: Object, required: true },
editFavoritesMode: Boolean
});
defineEmits(['click']);
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { favoriteFriendGroups } = storeToRefs(useFavoriteStore());
const { showFavoriteDialog } = useFavoriteStore();
const { shiftHeld } = storeToRefs(useUiStore());
function moveFavorite(ref, group, type) {
favoriteRequest.deleteFavorite({ objectId: ref.id }).then(() => {
favoriteRequest.addFavorite({
type,
favoriteId: ref.id,
tags: group.name
});
});
}
function deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({ objectId });
}
</script>

View File

@@ -21,7 +21,7 @@
</div>
<span style="display: block; margin-top: 30px">{{ $t('view.favorite.avatars.vrchat_favorites') }}</span>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in API.favoriteFriendGroups" :key="group.name">
<el-collapse-item v-for="group in favoriteFriendGroups" :key="group.name">
<template slot="title">
<span
style="font-weight: bold; font-size: 14px; margin-left: 10px"
@@ -70,73 +70,67 @@
</div>
</el-collapse-item>
</el-collapse>
<FriendExportDialog
:friend-export-dialog-visible.sync="friendExportDialogVisible"
:favorite-friends="favoriteFriends" />
<FriendExportDialog :friend-export-dialog-visible.sync="friendExportDialogVisible" />
</div>
</template>
<script>
import FavoritesFriendItem from './FavoritesFriendItem.vue';
import FriendExportDialog from '../dialogs/FriendExportDialog.vue';
<script setup>
import { ref, getCurrentInstance, computed } from 'vue';
import { storeToRefs } from 'pinia';
import { favoriteRequest } from '../../../api';
import { useAppearanceSettingsStore, useFavoriteStore, useUserStore } from '../../../stores';
import FriendExportDialog from '../dialogs/FriendExportDialog.vue';
import FavoritesFriendItem from './FavoritesFriendItem.vue';
export default {
name: 'FavoritesFriendTab',
components: { FriendExportDialog, FavoritesFriendItem },
inject: ['showUserDialog', 'API'],
props: {
favoriteFriends: Array,
sortFavorites: Boolean,
hideTooltips: Boolean,
groupedByGroupKeyFavoriteFriends: Object,
editFavoritesMode: Boolean
defineProps({
editFavoritesMode: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['change-favorite-group-name']);
const { proxy } = getCurrentInstance();
const { hideTooltips, sortFavorites } = storeToRefs(useAppearanceSettingsStore());
const { setSortFavorites } = useAppearanceSettingsStore();
const { showUserDialog } = useUserStore();
const { favoriteFriendGroups, groupedByGroupKeyFavoriteFriends } = storeToRefs(useFavoriteStore());
const { showFriendImportDialog, saveSortFavoritesOption } = useFavoriteStore();
const friendExportDialogVisible = ref(false);
const sortFav = computed({
get() {
return sortFavorites.value;
},
data() {
return {
friendExportDialogVisible: false
};
},
computed: {
sortFav: {
get() {
return this.sortFavorites;
},
set(value) {
this.$emit('update:sort-favorites', value);
set(value) {
setSortFavorites(value);
}
});
function showFriendExportDialog() {
friendExportDialogVisible.value = true;
}
function clearFavoriteGroup(ctx) {
proxy.$confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
}
},
methods: {
showFriendExportDialog() {
this.friendExportDialogVisible = true;
},
showFriendImportDialog() {
this.$emit('show-friend-import-dialog');
},
saveSortFavoritesOption() {
this.$emit('save-sort-favorites-option');
},
});
}
clearFavoriteGroup(ctx) {
// FIXME: 메시지 수정
this.$confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
}
});
},
changeFavoriteGroupName(group) {
this.$emit('change-favorite-group-name', group);
}
}
};
function changeFavoriteGroupName(group) {
emit('change-favorite-group-name', group);
}
</script>

View File

@@ -6,11 +6,11 @@
<img v-lazy="smallThumbnail" />
</div>
<div class="detail">
<span class="name">{{ localFavFakeRef.name }}</span>
<span v-if="localFavFakeRef.occupants" class="extra"
<span class="name" v-once>{{ localFavFakeRef.name }}</span>
<span v-if="localFavFakeRef.occupants" class="extra" v-once
>{{ localFavFakeRef.authorName }} ({{ localFavFakeRef.occupants }})</span
>
<span v-else class="extra">{{ localFavFakeRef.authorName }}</span>
<span v-else class="extra" v-once>{{ localFavFakeRef.authorName }}</span>
</div>
<template v-if="editFavoritesMode">
<el-dropdown trigger="click" size="mini" style="margin-left: 5px" @click.native.stop>
@@ -21,7 +21,7 @@
<el-button type="default" icon="el-icon-back" size="mini" circle></el-button>
</el-tooltip>
<el-dropdown-menu slot="dropdown">
<template v-for="groupAPI in API.favoriteWorldGroups">
<template v-for="groupAPI in favoriteWorldGroups">
<el-dropdown-item
v-if="isLocalFavorite || groupAPI.name !== group.name"
:key="groupAPI.name"
@@ -59,7 +59,7 @@
size="mini"
icon="el-icon-message"
style="margin-left: 5px"
@click.stop="$emit('new-instance-self-invite', favorite.id)"
@click.stop="newInstanceSelfInvite(favorite.id)"
circle></el-button>
</el-tooltip>
<el-tooltip
@@ -109,7 +109,7 @@
<template v-else>
<div class="avatar"></div>
<div class="detail">
<span>{{ favorite.name || favorite.id }}</span>
<span v-once>{{ favorite.name || favorite.id }}</span>
<el-tooltip
v-if="!isLocalFavorite && favorite.deleted"
placement="left"
@@ -128,104 +128,82 @@
</div>
</template>
<script>
<script setup>
import { storeToRefs } from 'pinia';
import { computed, getCurrentInstance } from 'vue';
import { favoriteRequest } from '../../../api';
import { useAppearanceSettingsStore, useFavoriteStore, useInviteStore, useUiStore } from '../../../stores';
export default {
name: 'FavoritesWorldItem',
inject: ['API', 'showFavoriteDialog'],
props: {
group: [Object, String],
favorite: Object,
editFavoritesMode: Boolean,
hideTooltips: Boolean,
shiftHeld: Boolean,
isLocalFavorite: { type: Boolean, required: false }
},
computed: {
isSelected: {
get() {
return this.favorite.$selected;
},
set(value) {
this.$emit('handle-select', value);
}
},
localFavFakeRef() {
// local favorite no "ref" property
return this.isLocalFavorite ? this.favorite : this.favorite.ref;
},
smallThumbnail() {
return (
this.localFavFakeRef.thumbnailImageUrl.replace('256', '128') ||
this.localFavFakeRef.thumbnailImageUrl
);
}
},
methods: {
handleDropdownItemClick(groupAPI) {
if (this.isLocalFavorite) {
this.addFavoriteWorld(this.localFavFakeRef, groupAPI, true);
} else {
this.moveFavorite(this.localFavFakeRef, groupAPI, 'world');
}
},
handleDeleteFavorite() {
if (this.isLocalFavorite) {
this.$emit('remove-local-world-favorite', this.favorite.id, this.group);
} else {
this.deleteFavorite(this.favorite.id);
}
},
moveFavorite(ref, group, type) {
favoriteRequest
.deleteFavorite({
objectId: ref.id
})
.then(() => {
favoriteRequest.addFavorite({
type,
favoriteId: ref.id,
tags: group.name
});
});
},
deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({
objectId
});
// FIXME: 메시지 수정
// this.$confirm('Continue? Delete Favorite', 'Confirm', {
// confirmButtonText: 'Confirm',
// cancelButtonText: 'Cancel',
// type: 'info',
// callback: (action) => {
// if (action === 'confirm') {
// API.deleteFavorite({
// objectId
// });
// }
// }
// });
},
addFavoriteWorld(ref, group, message) {
// wait API splitting PR Merged
return favoriteRequest
.addFavorite({
type: 'world',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
this.$message({
message: 'World added to favorites',
type: 'success'
});
}
return args;
});
}
const props = defineProps({
group: [Object, String],
favorite: Object,
editFavoritesMode: Boolean,
isLocalFavorite: { type: Boolean, default: false }
});
const emit = defineEmits(['handle-select', 'remove-local-world-favorite', 'click']);
const { proxy } = getCurrentInstance();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { favoriteWorldGroups } = storeToRefs(useFavoriteStore());
const { showFavoriteDialog } = useFavoriteStore();
const { newInstanceSelfInvite } = useInviteStore();
const { shiftHeld } = storeToRefs(useUiStore());
const isSelected = computed({
get: () => props.favorite.$selected,
set: (value) => emit('handle-select', value)
});
const localFavFakeRef = computed(() => (props.isLocalFavorite ? props.favorite : props.favorite.ref));
const smallThumbnail = computed(() => {
const url = localFavFakeRef.value.thumbnailImageUrl.replace('256', '128');
return url || localFavFakeRef.value.thumbnailImageUrl;
});
function handleDropdownItemClick(groupAPI) {
if (props.isLocalFavorite) {
addFavoriteWorld(localFavFakeRef.value, groupAPI, true);
} else {
moveFavorite(localFavFakeRef.value, groupAPI, 'world');
}
};
}
function handleDeleteFavorite() {
if (props.isLocalFavorite) {
emit('remove-local-world-favorite', props.favorite.id, props.group);
} else {
deleteFavorite(props.favorite.id);
}
}
function moveFavorite(refObj, group, type) {
favoriteRequest.deleteFavorite({ objectId: refObj.id }).then(() => {
favoriteRequest.addFavorite({
type,
favoriteId: refObj.id,
tags: group.name
});
});
}
function deleteFavorite(objectId) {
favoriteRequest.deleteFavorite({ objectId });
}
function addFavoriteWorld(refObj, group, message) {
return favoriteRequest
.addFavorite({
type: 'world',
favoriteId: refObj.id,
tags: group.name
})
.then((args) => {
if (message) {
proxy.$message({ message: 'World added to favorites', type: 'success' });
}
return args;
});
}
</script>

View File

@@ -3,16 +3,13 @@
<div style="display: flex; align-items: center; justify-content: space-between">
<div>
<el-button size="small" @click="showExportDialog">{{ $t('view.favorite.export') }}</el-button>
<el-button size="small" style="margin-left: 5px" @click="$emit('show-world-import-dialog')">{{
<el-button size="small" style="margin-left: 5px" @click="showWorldImportDialog">{{
$t('view.favorite.import')
}}</el-button>
</div>
<div style="display: flex; align-items: center; font-size: 13px; margin-right: 10px">
<span class="name" style="margin-right: 5px; line-height: 10px">{{ $t('view.favorite.sort_by') }}</span>
<el-radio-group
v-model="sortFav"
style="margin-right: 12px"
@change="$emit('save-sort-favorites-option')">
<el-radio-group v-model="sortFav" style="margin-right: 12px" @change="saveSortFavoritesOption">
<el-radio :label="false">{{
$t('view.settings.appearance.appearance.sort_favorite_by_name')
}}</el-radio>
@@ -59,7 +56,7 @@
</div>
<span style="display: block; margin-top: 20px">{{ $t('view.favorite.worlds.vrchat_favorites') }}</span>
<el-collapse style="border: 0">
<el-collapse-item v-for="group in API.favoriteWorldGroups" :key="group.name">
<el-collapse-item v-for="group in favoriteWorldGroups" :key="group.name">
<template slot="title">
<div style="display: flex; align-items: center">
<span
@@ -125,10 +122,8 @@
:favorite="favorite"
:edit-favorites-mode="editFavoritesMode"
:hide-tooltips="hideTooltips"
:shift-held="shiftHeld"
@click="showWorldDialog(favorite.id)"
@handle-select="favorite.$selected = $event"
@new-instance-self-invite="newInstanceSelfInvite" />
@handle-select="favorite.$selected = $event" />
</div>
<div
v-else
@@ -153,7 +148,7 @@
v-if="!refreshingLocalFavorites"
size="small"
style="margin-left: 5px"
@click="$emit('refresh-local-world-favorite')"
@click="refreshLocalWorldFavorite"
>{{ $t('view.favorite.worlds.refresh') }}</el-button
>
<el-button v-else size="small" style="margin-left: 5px" @click="refreshingLocalFavorites = false">
@@ -196,9 +191,7 @@
:favorite="favorite"
:edit-favorites-mode="editFavoritesMode"
:hide-tooltips="hideTooltips"
:shift-held="shiftHeld"
@click="showWorldDialog(favorite.id)"
@new-instance-self-invite="newInstanceSelfInvite"
@remove-local-world-favorite="removeLocalWorldFavorite" />
</div>
<div
@@ -215,236 +208,247 @@
</div>
</el-collapse-item>
</el-collapse>
<WorldExportDialog
:favorite-worlds="favoriteWorlds"
:world-export-dialog-visible.sync="worldExportDialogVisible"
:local-world-favorites="localWorldFavorites"
:local-world-favorite-groups="localWorldFavoriteGroups"
:local-world-favorites-list="localWorldFavoritesList" />
<WorldExportDialog :world-export-dialog-visible.sync="worldExportDialogVisible" />
</div>
</template>
<script>
import FavoritesWorldItem from './FavoritesWorldItem.vue';
import WorldExportDialog from '../dialogs/WorldExportDialog.vue';
<script setup>
import { computed, ref, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { favoriteRequest } from '../../../api';
import { useAppearanceSettingsStore, useFavoriteStore, useWorldStore } from '../../../stores';
import WorldExportDialog from '../dialogs/WorldExportDialog.vue';
import FavoritesWorldItem from './FavoritesWorldItem.vue';
export default {
name: 'FavoritesWorldTab',
components: {
FavoritesWorldItem,
WorldExportDialog
defineProps({
editFavoritesMode: {
type: Boolean,
default: false
},
inject: ['API', 'showWorldDialog'],
props: {
sortFavorites: Boolean,
hideTooltips: Boolean,
favoriteWorlds: Array,
editFavoritesMode: Boolean,
shiftHeld: Boolean,
refreshingLocalFavorites: Boolean,
localWorldFavoriteGroups: Array,
localWorldFavorites: Object,
localWorldFavoritesList: Array
},
data() {
return {
worldGroupVisibilityOptions: ['private', 'friends', 'public'],
worldFavoriteSearch: '',
worldExportDialogVisible: false,
worldFavoriteSearchResults: []
};
},
computed: {
groupedByGroupKeyFavoriteWorlds() {
const groupedByGroupKeyFavoriteWorlds = {};
refreshingLocalFavorites: {
type: Boolean,
default: false
}
});
this.favoriteWorlds.forEach((world) => {
if (world.groupKey) {
if (!groupedByGroupKeyFavoriteWorlds[world.groupKey]) {
groupedByGroupKeyFavoriteWorlds[world.groupKey] = [];
}
groupedByGroupKeyFavoriteWorlds[world.groupKey].push(world);
}
});
const emit = defineEmits([
'change-favorite-group-name',
'save-sort-favorites-option',
'refresh-local-world-favorite'
]);
return groupedByGroupKeyFavoriteWorlds;
},
sortFav: {
get() {
return this.sortFavorites;
},
set(value) {
this.$emit('update:sort-favorites', value);
const { proxy } = getCurrentInstance();
const { t } = useI18n();
const { hideTooltips, sortFavorites } = storeToRefs(useAppearanceSettingsStore());
const { setSortFavorites } = useAppearanceSettingsStore();
const { favoriteWorlds, favoriteWorldGroups, localWorldFavorites, localWorldFavoriteGroups } =
storeToRefs(useFavoriteStore());
const {
showWorldImportDialog,
getLocalWorldFavoriteGroupLength,
deleteLocalWorldFavoriteGroup,
renameLocalWorldFavoriteGroup,
removeLocalWorldFavorite,
newLocalWorldFavoriteGroup,
handleFavoriteGroup
} = useFavoriteStore();
const { showWorldDialog } = useWorldStore();
const worldGroupVisibilityOptions = ref(['private', 'friends', 'public']);
const worldExportDialogVisible = ref(false);
const worldFavoriteSearch = ref('');
const worldFavoriteSearchResults = ref([]);
const groupedByGroupKeyFavoriteWorlds = computed(() => {
const groupedByGroupKeyFavoriteWorlds = {};
favoriteWorlds.value.forEach((world) => {
if (world.groupKey) {
if (!groupedByGroupKeyFavoriteWorlds[world.groupKey]) {
groupedByGroupKeyFavoriteWorlds[world.groupKey] = [];
}
groupedByGroupKeyFavoriteWorlds[world.groupKey].push(world);
}
});
return groupedByGroupKeyFavoriteWorlds;
});
const sortFav = computed({
get() {
return sortFavorites.value;
},
set() {
setSortFavorites();
}
});
function showExportDialog() {
worldExportDialogVisible.value = true;
}
function userFavoriteWorldsStatusForFavTab(visibility) {
let style = '';
if (visibility === 'public') {
style = '';
} else if (visibility === 'friends') {
style = 'success';
} else {
style = 'info';
}
return style;
}
function changeWorldGroupVisibility(name, visibility) {
const params = {
type: 'world',
group: name,
visibility
};
favoriteRequest.saveFavoriteGroup(params).then((args) => {
handleFavoriteGroup({
json: args.json,
params: {
favoriteGroupId: args.json.id
}
});
proxy.$message({
message: 'Group visibility changed',
type: 'success'
});
return args;
});
}
function promptNewLocalWorldFavoriteGroup() {
proxy.$prompt(t('prompt.new_local_favorite_group.description'), t('prompt.new_local_favorite_group.header'), {
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.new_local_favorite_group.ok'),
cancelButtonText: t('prompt.new_local_favorite_group.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.new_local_favorite_group.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
newLocalWorldFavoriteGroup(instance.inputValue);
}
}
},
methods: {
showExportDialog() {
this.worldExportDialogVisible = true;
},
});
}
userFavoriteWorldsStatusForFavTab(visibility) {
let style = '';
if (visibility === 'public') {
style = '';
} else if (visibility === 'friends') {
style = 'success';
} else {
style = 'info';
function promptLocalWorldFavoriteGroupRename(group) {
proxy.$prompt(
t('prompt.local_favorite_group_rename.description'),
t('prompt.local_favorite_group_rename.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: t('prompt.local_favorite_group_rename.save'),
cancelButtonText: t('prompt.local_favorite_group_rename.cancel'),
inputPattern: /\S+/,
inputErrorMessage: t('prompt.local_favorite_group_rename.input_error'),
inputValue: group,
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
renameLocalWorldFavoriteGroup(instance.inputValue, group);
}
}
return style;
},
changeWorldGroupVisibility(name, visibility) {
const params = {
type: 'world',
group: name,
visibility
};
favoriteRequest.saveFavoriteGroup(params).then((args) => {
this.$message({
message: 'Group visibility changed',
type: 'success'
}
);
}
function promptLocalWorldFavoriteGroupDelete(group) {
proxy.$confirm(`Delete Group? ${group}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deleteLocalWorldFavoriteGroup(group);
}
}
});
}
function clearFavoriteGroup(ctx) {
proxy.$confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
return args;
});
},
promptNewLocalWorldFavoriteGroup() {
this.$prompt(
$t('prompt.new_local_favorite_group.description'),
$t('prompt.new_local_favorite_group.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: $t('prompt.new_local_favorite_group.ok'),
cancelButtonText: $t('prompt.new_local_favorite_group.cancel'),
inputPattern: /\S+/,
inputErrorMessage: $t('prompt.new_local_favorite_group.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
this.$emit('new-local-world-favorite-group', instance.inputValue);
}
}
}
);
},
promptLocalWorldFavoriteGroupRename(group) {
this.$prompt(
$t('prompt.local_favorite_group_rename.description'),
$t('prompt.local_favorite_group_rename.header'),
{
distinguishCancelAndClose: true,
confirmButtonText: $t('prompt.local_favorite_group_rename.save'),
cancelButtonText: $t('prompt.local_favorite_group_rename.cancel'),
inputPattern: /\S+/,
inputErrorMessage: $t('prompt.local_favorite_group_rename.input_error'),
inputValue: group,
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
this.$emit('rename-local-world-favorite-group', instance.inputValue, group);
}
}
}
);
},
promptLocalWorldFavoriteGroupDelete(group) {
this.$confirm(`Delete Group? ${group}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
this.$emit('delete-local-world-favorite-group', group);
}
}
});
},
getLocalWorldFavoriteGroupLength(group) {
const favoriteGroup = this.localWorldFavorites[group];
if (!favoriteGroup) {
return 0;
}
return favoriteGroup.length;
},
}
});
}
clearFavoriteGroup(ctx) {
// FIXME: 메시지 수정
this.$confirm('Continue? Clear Group', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
favoriteRequest.clearFavoriteGroup({
type: ctx.type,
group: ctx.name
});
}
}
});
},
searchWorldFavorites(worldFavoriteSearch) {
let ref = null;
const search = worldFavoriteSearch.toLowerCase();
if (search.length < 3) {
this.worldFavoriteSearchResults = [];
return;
function searchWorldFavorites(worldFavoriteSearch) {
let ref = null;
const search = worldFavoriteSearch.toLowerCase();
if (search.length < 3) {
worldFavoriteSearchResults.value = [];
return;
}
const results = [];
for (let i = 0; i < localWorldFavoriteGroups.value.length; ++i) {
const group = localWorldFavoriteGroups.value[i];
if (!localWorldFavorites.value[group]) {
continue;
}
for (let j = 0; j < localWorldFavorites.value[group].length; ++j) {
ref = localWorldFavorites.value[group][j];
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
const results = [];
for (let i = 0; i < this.localWorldFavoriteGroups.length; ++i) {
const group = this.localWorldFavoriteGroups[i];
if (!this.localWorldFavorites[group]) {
continue;
}
for (let j = 0; j < this.localWorldFavorites[group].length; ++j) {
ref = this.localWorldFavorites[group][j];
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
for (let i = 0; i < this.favoriteWorlds.length; ++i) {
ref = this.favoriteWorlds[i].ref;
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
this.worldFavoriteSearchResults = results;
},
changeFavoriteGroupName(group) {
this.$emit('change-favorite-group-name', group);
},
newInstanceSelfInvite(event) {
this.$emit('new-instance-self-invite', event);
},
removeLocalWorldFavorite(param1, param2) {
this.$emit('remove-local-world-favorite', param1, param2);
}
}
};
for (let i = 0; i < favoriteWorlds.value.length; ++i) {
ref = favoriteWorlds.value[i].ref;
if (
!ref ||
typeof ref.id === 'undefined' ||
typeof ref.name === 'undefined' ||
typeof ref.authorName === 'undefined'
) {
continue;
}
if (ref.name.toLowerCase().includes(search) || ref.authorName.toLowerCase().includes(search)) {
if (!results.some((r) => r.id === ref.id)) {
results.push(ref);
}
}
}
worldFavoriteSearchResults.value = results;
}
function changeFavoriteGroupName(group) {
emit('change-favorite-group-name', group);
}
function refreshLocalWorldFavorite() {
emit('refresh-local-world-favorite');
}
function saveSortFavoritesOption() {
emit('save-sort-favorites-option');
}
</script>
<style scoped></style>

View File

@@ -1,5 +1,5 @@
<template>
<safe-dialog :visible.sync="isDialogVisible" :title="$t('dialog.avatar_export.header')" width="650px">
<safe-dialog :visible.sync="isDialogVisible" :title="t('dialog.avatar_export.header')" width="650px">
<el-checkbox-group
v-model="exportSelectedOptions"
style="margin-bottom: 10px"
@@ -26,7 +26,7 @@
<el-dropdown-item style="display: block; margin: 10px 0" @click.native="selectAvatarExportGroup(null)">
All Favorites
</el-dropdown-item>
<template v-for="groupAPI in API.favoriteAvatarGroups">
<template v-for="groupAPI in favoriteAvatarGroups">
<el-dropdown-item
:key="groupAPI.name"
style="display: block; margin: 10px 0"
@@ -79,145 +79,152 @@
</safe-dialog>
</template>
<script>
export default {
name: 'AvatarExportDialog',
inject: ['API'],
props: {
avatarExportDialogVisible: Boolean,
favoriteAvatars: Array,
localAvatarFavoriteGroups: Array,
localAvatarFavorites: Object,
localAvatarFavoritesList: Array
},
data() {
return {
avatarExportContent: '',
avatarExportFavoriteGroup: null,
avatarExportLocalFavoriteGroup: null,
exportSelectedOptions: ['ID', 'Name'],
exportSelectOptions: [
{ label: 'ID', value: 'id' },
{ label: 'Name', value: 'name' },
{ label: 'Author ID', value: 'authorId' },
{ label: 'Author Name', value: 'authorName' },
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
]
};
},
computed: {
isDialogVisible: {
get() {
return this.avatarExportDialogVisible;
},
set(value) {
this.$emit('update:avatar-export-dialog-visible', value);
}
}
},
watch: {
avatarExportDialogVisible(visible) {
if (visible) {
this.showAvatarExportDialog();
}
}
},
methods: {
showAvatarExportDialog() {
this.avatarExportFavoriteGroup = null;
this.avatarExportLocalFavoriteGroup = null;
this.updateAvatarExportDialog();
},
handleCopyAvatarExportData(event) {
if (event.target.tagName === 'TEXTAREA') {
event.target.select();
}
navigator.clipboard
.writeText(this.avatarExportContent)
.then(() => {
this.$message({
message: 'Copied successfully!',
type: 'success',
duration: 2000
});
})
.catch((err) => {
console.error('Copy failed:', err);
this.$message.error('Copy failed!');
});
},
updateAvatarExportDialog() {
const formatter = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const propsForQuery = this.exportSelectOptions
.filter((option) => this.exportSelectedOptions.includes(option.label))
.map((option) => option.value);
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { useAvatarStore, useFavoriteStore } from '../../../stores';
function resText(ref) {
let resArr = [];
propsForQuery.forEach((e) => {
resArr.push(formatter(ref?.[e]));
});
return resArr.join(',');
}
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const lines = [this.exportSelectedOptions.join(',')];
const props = defineProps({
avatarExportDialogVisible: {
type: Boolean,
required: true
}
});
if (this.avatarExportFavoriteGroup) {
this.API.favoriteAvatarGroups.forEach((group) => {
if (!this.avatarExportFavoriteGroup || this.avatarExportFavoriteGroup === group) {
this.favoriteAvatars.forEach((ref) => {
if (group.key === ref.groupKey) {
lines.push(resText(ref.ref));
}
});
}
});
} else if (this.avatarExportLocalFavoriteGroup) {
const favoriteGroup = this.localAvatarFavorites[this.avatarExportLocalFavoriteGroup];
if (!favoriteGroup) {
return;
}
for (let i = 0; i < favoriteGroup.length; ++i) {
const ref = favoriteGroup[i];
lines.push(resText(ref));
}
} else {
// export all
this.favoriteAvatars.forEach((ref) => {
lines.push(resText(ref.ref));
});
for (let i = 0; i < this.localAvatarFavoritesList.length; ++i) {
const avatarId = this.localAvatarFavoritesList[i];
const ref = this.API.cachedAvatars.get(avatarId);
if (typeof ref !== 'undefined') {
lines.push(resText(ref));
}
}
}
this.avatarExportContent = lines.join('\n');
},
selectAvatarExportGroup(group) {
this.avatarExportFavoriteGroup = group;
this.avatarExportLocalFavoriteGroup = null;
this.updateAvatarExportDialog();
},
selectAvatarExportLocalGroup(group) {
this.avatarExportLocalFavoriteGroup = group;
this.avatarExportFavoriteGroup = null;
this.updateAvatarExportDialog();
},
getLocalAvatarFavoriteGroupLength(group) {
const favoriteGroup = this.localAvatarFavorites[group];
if (!favoriteGroup) {
return 0;
}
return favoriteGroup.length;
const emit = defineEmits(['update:avatarExportDialogVisible']);
const favoriteStore = useFavoriteStore();
const {
favoriteAvatars,
favoriteAvatarGroups,
localAvatarFavorites,
localAvatarFavoritesList,
localAvatarFavoriteGroups
} = storeToRefs(favoriteStore);
const { getLocalAvatarFavoriteGroupLength } = favoriteStore;
const avatarStore = useAvatarStore();
const { cachedAvatars } = storeToRefs(avatarStore);
const avatarExportContent = ref('');
const avatarExportFavoriteGroup = ref(null);
const avatarExportLocalFavoriteGroup = ref(null);
const exportSelectedOptions = ref(['ID', 'Name']);
const exportSelectOptions = ref([
{ label: 'ID', value: 'id' },
{ label: 'Name', value: 'name' },
{ label: 'Author ID', value: 'authorId' },
{ label: 'Author Name', value: 'authorName' },
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
]);
const isDialogVisible = computed({
get() {
return props.avatarExportDialogVisible;
},
set(value) {
emit('update:avatarExportDialogVisible', value);
}
});
watch(
() => props.avatarExportDialogVisible,
(value) => {
if (value) {
showAvatarExportDialog();
}
}
};
);
function showAvatarExportDialog() {
avatarExportFavoriteGroup.value = null;
avatarExportLocalFavoriteGroup.value = null;
updateAvatarExportDialog();
}
function handleCopyAvatarExportData(event) {
if (event.target.tagName === 'TEXTAREA') {
event.target.select();
}
navigator.clipboard
.writeText(avatarExportContent.value)
.then(() => {
proxy.$message({
message: 'Copied successfully!',
type: 'success',
duration: 2000
});
})
.catch((err) => {
console.error('Copy failed:', err);
proxy.$message.error('Copy failed!');
});
}
function updateAvatarExportDialog() {
const formatter = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const propsForQuery = exportSelectOptions.value
.filter((option) => exportSelectedOptions.value.includes(option.label))
.map((option) => option.value);
function resText(ref) {
let resArr = [];
propsForQuery.forEach((e) => {
resArr.push(formatter(ref?.[e]));
});
return resArr.join(',');
}
const lines = [exportSelectedOptions.value.join(',')];
if (avatarExportFavoriteGroup.value) {
favoriteAvatarGroups.value.forEach((group) => {
if (!avatarExportFavoriteGroup.value || avatarExportFavoriteGroup.value === group) {
favoriteAvatars.value.forEach((ref) => {
if (group.key === ref.groupKey) {
lines.push(resText(ref.ref));
}
});
}
});
} else if (avatarExportLocalFavoriteGroup.value) {
const favoriteGroup = localAvatarFavorites.value[avatarExportLocalFavoriteGroup.value];
if (!favoriteGroup) {
return;
}
for (let i = 0; i < favoriteGroup.length; ++i) {
const ref = favoriteGroup[i];
lines.push(resText(ref));
}
} else {
// export all
favoriteAvatars.value.forEach((ref) => {
lines.push(resText(ref.ref));
});
for (let i = 0; i < localAvatarFavoritesList.value.length; ++i) {
const avatarId = localAvatarFavoritesList.value[i];
const ref = cachedAvatars.value.get(avatarId);
if (typeof ref !== 'undefined') {
lines.push(resText(ref));
}
}
}
avatarExportContent.value = lines.join('\n');
}
function selectAvatarExportGroup(group) {
avatarExportFavoriteGroup.value = group;
avatarExportLocalFavoriteGroup.value = null;
updateAvatarExportDialog();
}
function selectAvatarExportLocalGroup(group) {
avatarExportLocalFavoriteGroup.value = group;
avatarExportFavoriteGroup.value = null;
updateAvatarExportDialog();
}
</script>

View File

@@ -1,22 +1,22 @@
<template>
<safe-dialog
ref="avatarImportDialog"
ref="avatarImportDialogRef"
:visible.sync="isVisible"
:title="$t('dialog.avatar_import.header')"
:title="t('dialog.avatar_import.header')"
width="650px">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="font-size: 12px">{{ $t('dialog.avatar_import.description') }}</div>
<div style="font-size: 12px">{{ t('dialog.avatar_import.description') }}</div>
<div style="display: flex; align-items: center">
<div v-if="avatarImportDialog.progress">
{{ $t('dialog.avatar_import.process_progress') }} {{ avatarImportDialog.progress }} /
{{ t('dialog.avatar_import.process_progress') }} {{ avatarImportDialog.progress }} /
{{ avatarImportDialog.progressTotal }}
<i class="el-icon-loading" style="margin: 0 5px"></i>
</div>
<el-button v-if="avatarImportDialog.loading" size="small" @click="cancelAvatarImport">
{{ $t('dialog.avatar_import.cancel') }}
{{ t('dialog.avatar_import.cancel') }}
</el-button>
<el-button v-else size="small" :disabled="!avatarImportDialog.input" @click="processAvatarImportList">
{{ $t('dialog.avatar_import.process_list') }}
{{ t('dialog.avatar_import.process_list') }}
</el-button>
</div>
</div>
@@ -38,12 +38,12 @@
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<span v-else>
{{ $t('dialog.avatar_import.select_group_placeholder') }}
{{ t('dialog.avatar_import.select_group_placeholder') }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
<el-dropdown-menu slot="dropdown">
<template v-for="groupAPI in API.favoriteAvatarGroups">
<template v-for="groupAPI in favoriteAvatarGroups">
<el-dropdown-item
:key="groupAPI.name"
style="display: block; margin: 10px 0"
@@ -63,7 +63,7 @@
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<span v-else>
{{ $t('dialog.avatar_import.select_group_placeholder') }}
{{ t('dialog.avatar_import.select_group_placeholder') }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
@@ -88,7 +88,7 @@
</div>
<div>
<el-button size="small" @click="clearAvatarImportTable">
{{ $t('dialog.avatar_import.clear_table') }}
{{ t('dialog.avatar_import.clear_table') }}
</el-button>
<el-button
size="small"
@@ -100,27 +100,27 @@
!avatarImportDialog.avatarImportLocalFavoriteGroup)
"
@click="importAvatarImportTable">
{{ $t('dialog.avatar_import.import') }}
{{ t('dialog.avatar_import.import') }}
</el-button>
</div>
</div>
<span v-if="avatarImportDialog.importProgress" style="margin: 10px">
<i class="el-icon-loading" style="margin-right: 5px"></i>
{{ $t('dialog.avatar_import.import_progress') }}
{{ t('dialog.avatar_import.import_progress') }}
{{ avatarImportDialog.importProgress }}/{{ avatarImportDialog.importProgressTotal }}
</span>
<br />
<template v-if="avatarImportDialog.errors">
<el-button size="small" @click="avatarImportDialog.errors = ''">
{{ $t('dialog.avatar_import.clear_errors') }}
{{ t('dialog.avatar_import.clear_errors') }}
</el-button>
<h2 style="font-weight: bold; margin: 5px 0">
{{ $t('dialog.avatar_import.errors') }}
{{ t('dialog.avatar_import.errors') }}
</h2>
<pre style="white-space: pre-wrap; font-size: 12px" v-text="avatarImportDialog.errors"></pre>
</template>
<data-tables v-loading="avatarImportDialog.loading" v-bind="avatarImportTable" style="margin-top: 10px">
<el-table-column :label="$t('table.import.image')" width="70" prop="thumbnailImageUrl">
<el-table-column :label="t('table.import.image')" width="70" prop="thumbnailImageUrl">
<template slot-scope="scope">
<el-popover placement="right" height="500px" trigger="hover">
<img slot="reference" v-lazy="scope.row.thumbnailImageUrl" class="friends-list-avatar" />
@@ -132,21 +132,21 @@
</el-popover>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.name')" prop="name">
<el-table-column :label="t('table.import.name')" prop="name">
<template slot-scope="scope">
<span class="x-link" @click="showAvatarDialog(scope.row.id)">
{{ scope.row.name }}
</span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.author')" width="120" prop="authorName">
<el-table-column :label="t('table.import.author')" width="120" prop="authorName">
<template slot-scope="scope">
<span class="x-link" @click="showUserDialog(scope.row.authorId)">
{{ scope.row.authorName }}
</span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.status')" width="70" prop="releaseStatus">
<el-table-column :label="t('table.import.status')" width="70" prop="releaseStatus">
<template slot-scope="scope">
<span
:style="{
@@ -161,7 +161,7 @@
</span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.action')" width="90" align="right">
<el-table-column :label="t('table.import.action')" width="90" align="right">
<template slot-scope="scope">
<el-button type="text" icon="el-icon-close" size="mini" @click="deleteItemAvatarImport(scope.row)">
</el-button>
@@ -171,186 +171,191 @@
</safe-dialog>
</template>
<script>
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { avatarRequest, favoriteRequest } from '../../../api';
import utils from '../../../classes/utils';
import { adjustDialogZ, removeFromArray } from '../../../shared/utils';
import { useAvatarStore, useFavoriteStore, useGalleryStore, useUserStore } from '../../../stores';
export default {
name: 'AvatarImportDialog',
inject: ['API', 'adjustDialogZ', 'showFullscreenImageDialog', 'showUserDialog', 'showAvatarDialog'],
props: {
getLocalAvatarFavoriteGroupLength: Function,
localAvatarFavoriteGroups: Array,
avatarImportDialogInput: String,
avatarImportDialogVisible: Boolean
const emit = defineEmits(['update:avatarImportDialogInput']);
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const { showUserDialog } = useUserStore();
const { favoriteAvatarGroups, avatarImportDialogInput, avatarImportDialogVisible, localAvatarFavoriteGroups } =
storeToRefs(useFavoriteStore());
const { addLocalAvatarFavorite, getLocalAvatarFavoriteGroupLength } = useFavoriteStore();
const { showAvatarDialog, applyAvatar } = useAvatarStore();
const { showFullscreenImageDialog } = useGalleryStore();
const avatarImportDialog = ref({
loading: false,
progress: 0,
progressTotal: 0,
input: '',
avatarIdList: new Set(),
errors: '',
avatarImportFavoriteGroup: null,
avatarImportLocalFavoriteGroup: null,
importProgress: 0,
importProgressTotal: 0
});
const avatarImportTable = ref({
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
data() {
return {
avatarImportDialog: {
loading: false,
progress: 0,
progressTotal: 0,
input: '',
avatarIdList: new Set(),
errors: '',
avatarImportFavoriteGroup: null,
avatarImportLocalFavoriteGroup: null,
importProgress: 0,
importProgressTotal: 0
},
avatarImportTable: {
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
layout: 'table'
}
};
layout: 'table'
});
const avatarImportDialogRef = ref(null);
const isVisible = computed({
get() {
return avatarImportDialogVisible.value;
},
computed: {
isVisible: {
get() {
return this.avatarImportDialogVisible;
},
set(value) {
this.$emit('update:avatar-import-dialog-visible', value);
}
}
},
watch: {
avatarImportDialogVisible(value) {
if (value) {
this.adjustDialogZ(this.$refs.avatarImportDialog.$el);
this.clearAvatarImportTable();
this.resetAvatarImport();
if (this.avatarImportDialogInput) {
this.avatarImportDialog.input = this.avatarImportDialogInput;
this.processAvatarImportList();
this.$emit('update:avatar-import-dialog-input', '');
}
}
}
},
methods: {
async processAvatarImportList() {
const D = this.avatarImportDialog;
D.loading = true;
const regexAvatarId = /avtr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match = [];
const avatarIdList = new Set();
while ((match = regexAvatarId.exec(D.input)) !== null) {
avatarIdList.add(match[0]);
}
D.input = '';
D.errors = '';
D.progress = 0;
D.progressTotal = avatarIdList.size;
const data = Array.from(avatarIdList);
for (let i = 0; i < data.length; ++i) {
if (!this.isVisible) {
this.resetAvatarImport();
}
if (!D.loading || !this.isVisible) {
break;
}
const avatarId = data[i];
if (!D.avatarIdList.has(avatarId)) {
try {
const args = await avatarRequest.getAvatar({
avatarId
});
this.avatarImportTable.data.push(args.ref);
D.avatarIdList.add(avatarId);
} catch (err) {
D.errors = D.errors.concat(`AvatarId: ${avatarId}\n${err}\n\n`);
}
}
D.progress++;
if (D.progress === avatarIdList.size) {
D.progress = 0;
}
}
D.loading = false;
},
set(value) {
avatarImportDialogVisible.value = value;
}
});
deleteItemAvatarImport(ref) {
utils.removeFromArray(this.avatarImportTable.data, ref);
this.avatarImportDialog.avatarIdList.delete(ref.id);
},
resetAvatarImport() {
this.avatarImportDialog.input = '';
this.avatarImportDialog.errors = '';
},
clearAvatarImportTable() {
this.avatarImportTable.data = [];
this.avatarImportDialog.avatarIdList = new Set();
},
selectAvatarImportGroup(group) {
this.avatarImportDialog.avatarImportLocalFavoriteGroup = null;
this.avatarImportDialog.avatarImportFavoriteGroup = group;
},
selectAvatarImportLocalGroup(group) {
this.avatarImportDialog.avatarImportFavoriteGroup = null;
this.avatarImportDialog.avatarImportLocalFavoriteGroup = group;
},
cancelAvatarImport() {
this.avatarImportDialog.loading = false;
},
addFavoriteAvatar(ref, group, message) {
return favoriteRequest
.addFavorite({
type: 'avatar',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
this.$message({
message: 'Avatar added to favorites',
type: 'success'
});
}
return args;
});
},
async importAvatarImportTable() {
const D = this.avatarImportDialog;
if (!D.avatarImportFavoriteGroup && !D.avatarImportLocalFavoriteGroup) {
return;
}
D.loading = true;
const data = [...this.avatarImportTable.data].reverse();
D.importProgressTotal = data.length;
let ref = '';
try {
for (let i = data.length - 1; i >= 0; i--) {
if (!D.loading || !this.isVisible) {
break;
}
ref = data[i];
if (D.avatarImportFavoriteGroup) {
await this.addFavoriteAvatar(ref, D.avatarImportFavoriteGroup, false);
} else if (D.avatarImportLocalFavoriteGroup) {
this.$emit('addLocalAvatarFavorite', ref.id, D.avatarImportLocalFavoriteGroup);
}
utils.removeFromArray(this.avatarImportTable.data, ref);
D.avatarIdList.delete(ref.id);
D.importProgress++;
}
} catch (err) {
D.errors = `Name: ${ref.name}\nAvatarId: ${ref.id}\n${err}\n\n`;
} finally {
D.importProgress = 0;
D.importProgressTotal = 0;
D.loading = false;
watch(
() => avatarImportDialogVisible.value,
(value) => {
if (value) {
adjustDialogZ(avatarImportDialogRef.value.$el);
clearAvatarImportTable();
resetAvatarImport();
if (avatarImportDialogInput.value) {
avatarImportDialog.value.input = avatarImportDialogInput.value;
processAvatarImportList();
emit('update:avatarImportDialogInput', '');
}
}
}
};
);
async function processAvatarImportList() {
const D = avatarImportDialog.value;
D.loading = true;
const regexAvatarId = /avtr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match = [];
const avatarIdList = new Set();
while ((match = regexAvatarId.exec(D.input)) !== null) {
avatarIdList.add(match[0]);
}
D.input = '';
D.errors = '';
D.progress = 0;
D.progressTotal = avatarIdList.size;
const data = Array.from(avatarIdList);
for (let i = 0; i < data.length; ++i) {
if (!isVisible.value) {
resetAvatarImport();
}
if (!D.loading || !isVisible.value) {
break;
}
const avatarId = data[i];
if (!D.avatarIdList.has(avatarId)) {
try {
const args = await avatarRequest.getAvatar({
avatarId
});
const ref = applyAvatar(args.json);
avatarImportTable.value.data.push(ref);
D.avatarIdList.add(avatarId);
} catch (err) {
D.errors = D.errors.concat(`AvatarId: ${avatarId}\n${err}\n\n`);
}
}
D.progress++;
if (D.progress === avatarIdList.size) {
D.progress = 0;
}
}
D.loading = false;
}
function deleteItemAvatarImport(ref) {
removeFromArray(avatarImportTable.value.data, ref);
avatarImportDialog.value.avatarIdList.delete(ref.id);
}
function resetAvatarImport() {
avatarImportDialog.value.input = '';
avatarImportDialog.value.errors = '';
}
function clearAvatarImportTable() {
avatarImportTable.value.data = [];
avatarImportDialog.value.avatarIdList = new Set();
}
function selectAvatarImportGroup(group) {
avatarImportDialog.value.avatarImportLocalFavoriteGroup = null;
avatarImportDialog.value.avatarImportFavoriteGroup = group;
}
function selectAvatarImportLocalGroup(group) {
avatarImportDialog.value.avatarImportFavoriteGroup = null;
avatarImportDialog.value.avatarImportLocalFavoriteGroup = group;
}
function cancelAvatarImport() {
avatarImportDialog.value.loading = false;
}
function addFavoriteAvatar(ref, group, message) {
return favoriteRequest
.addFavorite({
type: 'avatar',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
proxy.$message({
message: 'Avatar added to favorites',
type: 'success'
});
}
return args;
});
}
async function importAvatarImportTable() {
const D = avatarImportDialog.value;
if (!D.avatarImportFavoriteGroup && !D.avatarImportLocalFavoriteGroup) {
return;
}
D.loading = true;
const data = [...avatarImportTable.value.data].reverse();
D.importProgressTotal = data.length;
let ref = '';
try {
for (let i = data.length - 1; i >= 0; i--) {
if (!D.loading || !isVisible.value) {
break;
}
ref = data[i];
if (D.avatarImportFavoriteGroup) {
await addFavoriteAvatar(ref, D.avatarImportFavoriteGroup, false);
} else if (D.avatarImportLocalFavoriteGroup) {
addLocalAvatarFavorite(ref.id, D.avatarImportLocalFavoriteGroup);
}
removeFromArray(avatarImportTable.value.data, ref);
D.avatarIdList.delete(ref.id);
D.importProgress++;
}
} catch (err) {
D.errors = `Name: ${ref.name}\nAvatarId: ${ref.id}\n${err}\n\n`;
} finally {
D.importProgress = 0;
D.importProgressTotal = 0;
D.loading = false;
}
}
</script>

View File

@@ -2,7 +2,7 @@
<safe-dialog
:visible.sync="isDialogVisible"
class="x-dialog"
:title="$t('dialog.friend_export.header')"
:title="t('dialog.friend_export.header')"
width="650px"
destroy-on-close>
<el-dropdown trigger="click" size="small" @click.native.stop>
@@ -19,7 +19,7 @@
<el-dropdown-item style="display: block; margin: 10px 0" @click.native="selectFriendExportGroup(null)">
All Favorites
</el-dropdown-item>
<template v-for="groupAPI in API.favoriteFriendGroups">
<template v-for="groupAPI in favoriteFriendGroups">
<el-dropdown-item
:key="groupAPI.name"
style="display: block; margin: 10px 0"
@@ -42,86 +42,94 @@
</safe-dialog>
</template>
<script>
export default {
name: 'FriendExportDialog',
inject: ['API'],
props: {
friendExportDialogVisible: Boolean,
favoriteFriends: Array
},
data() {
return {
friendExportFavoriteGroup: null,
friendExportContent: ''
};
},
computed: {
isDialogVisible: {
get() {
return this.friendExportDialogVisible;
},
set(value) {
this.$emit('update:friend-export-dialog-visible', value);
}
}
},
watch: {
friendExportDialogVisible(value) {
if (value) {
this.showFriendExportDialog();
}
}
},
methods: {
showFriendExportDialog() {
this.friendExportFavoriteGroup = null;
this.updateFriendExportDialog();
},
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { useFavoriteStore } from '../../../stores';
handleCopyFriendExportData(event) {
if (event.target.tagName === 'TEXTAREA') {
event.target.select();
}
navigator.clipboard
.writeText(this.friendExportContent)
.then(() => {
this.$message({
message: 'Copied successfully!',
type: 'success',
duration: 2000
});
})
.catch((err) => {
console.error('Copy failed:', err);
this.$message.error('Copy failed!');
});
},
const { t } = useI18n();
const { proxy } = getCurrentInstance();
updateFriendExportDialog() {
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const lines = ['UserID,Name'];
this.API.favoriteFriendGroups.forEach((group) => {
if (!this.friendExportFavoriteGroup || this.friendExportFavoriteGroup === group) {
this.favoriteFriends.forEach((ref) => {
if (group.key === ref.groupKey) {
lines.push(`${_(ref.id)},${_(ref.name)}`);
}
});
}
});
this.friendExportContent = lines.join('\n');
},
const props = defineProps({
friendExportDialogVisible: {
type: Boolean,
required: true
}
});
selectFriendExportGroup(group) {
this.friendExportFavoriteGroup = group;
this.updateFriendExportDialog();
const emit = defineEmits(['update:friendExportDialogVisible']);
const favoriteStore = useFavoriteStore();
const { favoriteFriends, favoriteFriendGroups } = storeToRefs(favoriteStore);
const friendExportFavoriteGroup = ref(null);
const friendExportContent = ref('');
const isDialogVisible = computed({
get() {
return props.friendExportDialogVisible;
},
set(value) {
emit('update:friendExportDialogVisible', value);
}
});
watch(
() => props.friendExportDialogVisible,
(value) => {
if (value) {
showFriendExportDialog();
}
}
};
);
function showFriendExportDialog() {
friendExportFavoriteGroup.value = null;
updateFriendExportDialog();
}
function handleCopyFriendExportData(event) {
if (event.target.tagName === 'TEXTAREA') {
event.target.select();
}
navigator.clipboard
.writeText(friendExportContent.value)
.then(() => {
proxy.$message({
message: 'Copied successfully!',
type: 'success',
duration: 2000
});
})
.catch((err) => {
console.error('Copy failed:', err);
proxy.$message.error('Copy failed!');
});
}
function updateFriendExportDialog() {
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const lines = ['UserID,Name'];
favoriteFriendGroups.value.forEach((group) => {
if (!friendExportFavoriteGroup.value || friendExportFavoriteGroup.value === group) {
favoriteFriends.value.forEach((ref) => {
if (group.key === ref.groupKey) {
lines.push(`${_(ref.id)},${_(ref.name)}`);
}
});
}
});
friendExportContent.value = lines.join('\n');
}
function selectFriendExportGroup(group) {
friendExportFavoriteGroup.value = group;
updateFriendExportDialog();
}
</script>

View File

@@ -1,22 +1,22 @@
<template>
<safe-dialog
ref="friendImportDialog"
ref="friendImportDialogRef"
:visible.sync="isVisible"
:title="$t('dialog.friend_import.header')"
:title="t('dialog.friend_import.header')"
width="650px">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="font-size: 12px">{{ $t('dialog.friend_import.description') }}</div>
<div style="font-size: 12px">{{ t('dialog.friend_import.description') }}</div>
<div style="display: flex; align-items: center">
<div v-if="friendImportDialog.progress">
{{ $t('dialog.friend_import.process_progress') }} {{ friendImportDialog.progress }} /
{{ t('dialog.friend_import.process_progress') }} {{ friendImportDialog.progress }} /
{{ friendImportDialog.progressTotal }}
<i class="el-icon-loading" style="margin: 0 5px"></i>
</div>
<el-button v-if="friendImportDialog.loading" size="small" @click="cancelFriendImport">
{{ $t('dialog.friend_import.cancel') }}
{{ t('dialog.friend_import.cancel') }}
</el-button>
<el-button v-else size="small" :disabled="!friendImportDialog.input" @click="processFriendImportList">
{{ $t('dialog.friend_import.process_list') }}
{{ t('dialog.friend_import.process_list') }}
</el-button>
</div>
</div>
@@ -38,12 +38,12 @@
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<span v-else
>{{ $t('dialog.friend_import.select_group_placeholder') }}
>{{ t('dialog.friend_import.select_group_placeholder') }}
<i class="el-icon-arrow-down el-icon--right"></i
></span>
</el-button>
<el-dropdown-menu slot="dropdown">
<template v-for="groupAPI in API.favoriteFriendGroups">
<template v-for="groupAPI in favoriteFriendGroups">
<el-dropdown-item
:key="groupAPI.name"
style="display: block; margin: 10px 0"
@@ -64,7 +64,7 @@
</div>
<div>
<el-button size="small" :disabled="friendImportTable.data.length === 0" @click="clearFriendImportTable">
{{ $t('dialog.friend_import.clear_table') }}
{{ t('dialog.friend_import.clear_table') }}
</el-button>
<el-button
size="small"
@@ -72,26 +72,26 @@
style="margin: 5px"
:disabled="friendImportTable.data.length === 0 || !friendImportDialog.friendImportFavoriteGroup"
@click="importFriendImportTable">
{{ $t('dialog.friend_import.import') }}
{{ t('dialog.friend_import.import') }}
</el-button>
</div>
</div>
<span v-if="friendImportDialog.importProgress" style="margin: 10px">
<i class="el-icon-loading" style="margin-right: 5px"></i>
{{ $t('dialog.friend_import.import_progress') }} {{ friendImportDialog.importProgress }}/{{
{{ t('dialog.friend_import.import_progress') }} {{ friendImportDialog.importProgress }}/{{
friendImportDialog.importProgressTotal
}}
</span>
<br />
<template v-if="friendImportDialog.errors">
<el-button size="small" @click="friendImportDialog.errors = ''">
{{ $t('dialog.friend_import.clear_errors') }}
{{ t('dialog.friend_import.clear_errors') }}
</el-button>
<h2 style="font-weight: bold; margin: 5px 0">{{ $t('dialog.friend_import.errors') }}</h2>
<h2 style="font-weight: bold; margin: 5px 0">{{ t('dialog.friend_import.errors') }}</h2>
<pre style="white-space: pre-wrap; font-size: 12px" v-text="friendImportDialog.errors"></pre>
</template>
<data-tables v-loading="friendImportDialog.loading" v-bind="friendImportTable" style="margin-top: 10px">
<el-table-column :label="$t('table.import.image')" width="70" prop="currentAvatarThumbnailImageUrl">
<el-table-column :label="t('table.import.image')" width="70" prop="currentAvatarThumbnailImageUrl">
<template slot-scope="scope">
<el-popover placement="right" height="500px" trigger="hover">
<template slot="reference">
@@ -105,14 +105,14 @@
</el-popover>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.name')" prop="displayName">
<el-table-column :label="t('table.import.name')" prop="displayName">
<template slot-scope="scope">
<span class="x-link" :title="scope.row.displayName" @click="showUserDialog(scope.row.id)">
{{ scope.row.displayName }}
</span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.action')" width="90" align="right">
<el-table-column :label="t('table.import.action')" width="90" align="right">
<template slot-scope="scope">
<el-button type="text" icon="el-icon-close" size="mini" @click="deleteItemFriendImport(scope.row)">
</el-button>
@@ -122,175 +122,174 @@
</safe-dialog>
</template>
<script>
import utils from '../../../classes/utils';
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { favoriteRequest, userRequest } from '../../../api';
import { adjustDialogZ, removeFromArray, userImage, userImageFull } from '../../../shared/utils';
import { useFavoriteStore, useGalleryStore, useUserStore } from '../../../stores';
export default {
name: 'FriendImportDialog',
inject: ['API', 'userImage', 'userImageFull', 'showFullscreenImageDialog', 'showUserDialog', 'adjustDialogZ'],
props: {
friendImportDialogVisible: {
type: Boolean,
required: true
},
friendImportDialogInput: {
type: String,
required: false,
default: ''
}
const { proxy } = getCurrentInstance();
const { t } = useI18n();
const emit = defineEmits(['update:friendImportDialogInput']);
const { showUserDialog } = useUserStore();
const { favoriteFriendGroups, friendImportDialogInput, friendImportDialogVisible } =
storeToRefs(useFavoriteStore());
const { showFullscreenImageDialog } = useGalleryStore();
const friendImportDialog = ref({
loading: false,
progress: 0,
progressTotal: 0,
input: '',
userIdList: new Set(),
errors: '',
friendImportFavoriteGroup: null,
importProgress: 0,
importProgressTotal: 0
});
const friendImportTable = ref({
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
data() {
return {
friendImportDialog: {
loading: false,
progress: 0,
progressTotal: 0,
input: '',
userIdList: new Set(),
errors: '',
friendImportFavoriteGroup: null,
importProgress: 0,
importProgressTotal: 0
},
friendImportTable: {
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
layout: 'table'
}
};
layout: 'table'
});
const friendImportDialogRef = ref(null);
const isVisible = computed({
get() {
return friendImportDialogVisible.value;
},
computed: {
isVisible: {
get() {
return this.friendImportDialogVisible;
},
set(value) {
this.$emit('update:friend-import-dialog-visible', value);
set(value) {
friendImportDialogVisible.value = value;
}
});
watch(
() => friendImportDialogVisible.value,
(value) => {
if (value) {
adjustDialogZ(friendImportDialogRef.value.$el);
clearFriendImportTable();
resetFriendImport();
if (friendImportDialogInput.value) {
friendImportDialog.value.input = friendImportDialogInput.value;
processFriendImportList();
emit('update:friendImportDialogInput', '');
}
}
},
watch: {
friendImportDialogVisible(value) {
if (value) {
this.adjustDialogZ(this.$refs.friendImportDialog.$el);
this.clearFriendImportTable();
this.resetFriendImport();
if (this.friendImportDialogInput) {
this.friendImportDialog.input = this.friendImportDialogInput;
this.processFriendImportList();
this.$emit('update:friend-import-dialog-input', '');
}
}
}
},
methods: {
cancelFriendImport() {
this.friendImportDialog.loading = false;
},
deleteItemFriendImport(ref) {
utils.removeFromArray(this.friendImportTable.data, ref);
this.friendImportDialog.userIdList.delete(ref.id);
},
clearFriendImportTable() {
this.friendImportTable.data = [];
this.friendImportDialog.userIdList = new Set();
},
selectFriendImportGroup(group) {
this.friendImportDialog.friendImportFavoriteGroup = group;
},
async importFriendImportTable() {
const D = this.friendImportDialog;
D.loading = true;
if (!D.friendImportFavoriteGroup) {
return;
}
const data = [...this.friendImportTable.data].reverse();
D.importProgressTotal = data.length;
let ref = '';
try {
for (let i = data.length - 1; i >= 0; i--) {
if (!D.loading || !this.isVisible) {
break;
}
ref = data[i];
await this.addFavoriteUser(ref, D.friendImportFavoriteGroup, false);
utils.removeFromArray(this.friendImportTable.data, ref);
D.userIdList.delete(ref.id);
D.importProgress++;
}
} catch (err) {
D.errors = `Name: ${ref.displayName}\nUserId: ${ref.id}\n${err}\n\n`;
} finally {
D.importProgress = 0;
D.importProgressTotal = 0;
D.loading = false;
}
},
addFavoriteUser(ref, group, message) {
return favoriteRequest
.addFavorite({
type: 'friend',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
this.$message({
message: 'Friend added to favorites',
type: 'success'
});
}
return args;
});
},
async processFriendImportList() {
const D = this.friendImportDialog;
D.loading = true;
const regexFriendId = /usr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match = [];
const userIdList = new Set();
while ((match = regexFriendId.exec(D.input)) !== null) {
userIdList.add(match[0]);
}
D.input = '';
D.errors = '';
D.progress = 0;
D.progressTotal = userIdList.size;
const data = Array.from(userIdList);
for (let i = 0; i < data.length; ++i) {
if (!this.isVisible) {
this.resetFriendImport();
}
if (!D.loading || !this.isVisible) {
break;
}
const userId = data[i];
if (!D.userIdList.has(userId)) {
try {
const args = await userRequest.getUser({
userId
});
this.friendImportTable.data.push(args.ref);
D.userIdList.add(userId);
} catch (err) {
D.errors = D.errors.concat(`UserId: ${userId}\n${err}\n\n`);
}
}
D.progress++;
if (D.progress === userIdList.size) {
D.progress = 0;
}
}
D.loading = false;
},
resetFriendImport() {
this.friendImportDialog.input = '';
this.friendImportDialog.errors = '';
}
}
};
);
function cancelFriendImport() {
friendImportDialog.value.loading = false;
}
function deleteItemFriendImport(ref) {
removeFromArray(friendImportTable.value.data, ref);
friendImportDialog.value.userIdList.delete(ref.id);
}
function clearFriendImportTable() {
friendImportTable.value.data = [];
friendImportDialog.value.userIdList = new Set();
}
function selectFriendImportGroup(group) {
friendImportDialog.value.friendImportFavoriteGroup = group;
}
async function importFriendImportTable() {
const D = friendImportDialog.value;
D.loading = true;
if (!D.friendImportFavoriteGroup) {
return;
}
const data = [...friendImportTable.value.data].reverse();
D.importProgressTotal = data.length;
let ref = '';
try {
for (let i = data.length - 1; i >= 0; i--) {
if (!D.loading || !isVisible.value) {
break;
}
ref = data[i];
await addFavoriteUser(ref, D.friendImportFavoriteGroup, false);
removeFromArray(friendImportTable.value.data, ref);
D.userIdList.delete(ref.id);
D.importProgress++;
}
} catch (err) {
D.errors = `Name: ${ref.displayName}\nUserId: ${ref.id}\n${err}\n\n`;
} finally {
D.importProgress = 0;
D.importProgressTotal = 0;
D.loading = false;
}
}
function addFavoriteUser(ref, group, message) {
return favoriteRequest
.addFavorite({
type: 'friend',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
proxy.$message({
message: 'Friend added to favorites',
type: 'success'
});
}
return args;
});
}
async function processFriendImportList() {
const D = friendImportDialog.value;
D.loading = true;
const regexFriendId = /usr_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match = [];
const userIdList = new Set();
while ((match = regexFriendId.exec(D.input)) !== null) {
userIdList.add(match[0]);
}
D.input = '';
D.errors = '';
D.progress = 0;
D.progressTotal = userIdList.size;
const data = Array.from(userIdList);
for (let i = 0; i < data.length; ++i) {
if (!isVisible.value) {
resetFriendImport();
}
if (!D.loading || !isVisible.value) {
break;
}
const userId = data[i];
if (!D.userIdList.has(userId)) {
try {
const args = await userRequest.getUser({
userId
});
friendImportTable.value.data.push(args.ref);
D.userIdList.add(userId);
} catch (err) {
D.errors = D.errors.concat(`UserId: ${userId}\n${err}\n\n`);
}
}
D.progress++;
if (D.progress === userIdList.size) {
D.progress = 0;
}
}
D.loading = false;
}
function resetFriendImport() {
friendImportDialog.value.input = '';
friendImportDialog.value.errors = '';
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<safe-dialog :visible.sync="isDialogVisible" :title="$t('dialog.world_export.header')" width="650px">
<safe-dialog :visible.sync="isDialogVisible" :title="t('dialog.world_export.header')" width="650px">
<el-checkbox-group
v-model="exportSelectedOptions"
style="margin-bottom: 10px"
@@ -26,7 +26,7 @@
<el-dropdown-item style="display: block; margin: 10px 0" @click.native="selectWorldExportGroup(null)">
None
</el-dropdown-item>
<template v-for="groupAPI in API.favoriteWorldGroups">
<template v-for="groupAPI in favoriteWorldGroups">
<el-dropdown-item
:key="groupAPI.name"
style="display: block; margin: 10px 0"
@@ -81,151 +81,157 @@
</safe-dialog>
</template>
<script>
export default {
name: 'WorldExportDialog',
inject: ['API'],
props: {
favoriteWorlds: Array,
worldExportDialogVisible: Boolean,
localWorldFavorites: Object,
localWorldFavoriteGroups: Array,
localWorldFavoritesList: Array
<script setup>
import { ref, computed, watch, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { useFavoriteStore, useWorldStore } from '../../../stores';
const props = defineProps({
worldExportDialogVisible: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['update:WorldExportDialogVisible']);
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const favoriteStore = useFavoriteStore();
const {
favoriteWorlds,
favoriteWorldGroups,
localWorldFavorites,
localWorldFavoriteGroups,
localWorldFavoritesList
} = storeToRefs(favoriteStore);
const { getLocalWorldFavoriteGroupLength } = favoriteStore;
const { cachedWorlds } = storeToRefs(useWorldStore());
const worldExportContent = ref('');
const worldExportFavoriteGroup = ref(null);
const worldExportLocalFavoriteGroup = ref(null);
// Storage of selected filtering options for model and world export
const exportSelectedOptions = ref(['ID', 'Name']);
const exportSelectOptions = ref([
{ label: 'ID', value: 'id' },
{ label: 'Name', value: 'name' },
{ label: 'Author ID', value: 'authorId' },
{ label: 'Author Name', value: 'authorName' },
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
]);
const isDialogVisible = computed({
get() {
return props.worldExportDialogVisible;
},
data() {
return {
worldExportContent: '',
worldExportFavoriteGroup: null,
worldExportLocalFavoriteGroup: null,
// Storage of selected filtering options for model and world export
exportSelectedOptions: ['ID', 'Name'],
exportSelectOptions: [
{ label: 'ID', value: 'id' },
{ label: 'Name', value: 'name' },
{ label: 'Author ID', value: 'authorId' },
{ label: 'Author Name', value: 'authorName' },
{ label: 'Thumbnail', value: 'thumbnailImageUrl' }
]
};
},
computed: {
isDialogVisible: {
get() {
return this.worldExportDialogVisible;
},
set(value) {
this.$emit('update:world-export-dialog-visible', value);
}
}
},
watch: {
worldExportDialogVisible(value) {
if (value) {
this.showWorldExportDialog();
}
}
},
methods: {
showWorldExportDialog() {
this.worldExportFavoriteGroup = null;
this.worldExportLocalFavoriteGroup = null;
this.updateWorldExportDialog();
},
set(value) {
emit('update:WorldExportDialogVisible', value);
}
});
handleCopyWorldExportData(event) {
if (event.target.tagName === 'TEXTAREA') {
event.target.select();
}
navigator.clipboard
.writeText(this.worldExportContent)
.then(() => {
this.$message({
message: 'Copied successfully!',
type: 'success',
duration: 2000
});
})
.catch((err) => {
console.error('Copy failed:', err);
this.$message.error('Copy failed!');
});
},
updateWorldExportDialog() {
const formatter = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const propsForQuery = this.exportSelectOptions
.filter((option) => this.exportSelectedOptions.includes(option.label))
.map((option) => option.value);
function resText(ref) {
let resArr = [];
propsForQuery.forEach((e) => {
resArr.push(formatter(ref?.[e]));
});
return resArr.join(',');
}
const lines = [this.exportSelectedOptions.join(',')];
if (this.worldExportFavoriteGroup) {
this.API.favoriteWorldGroups.forEach((group) => {
if (this.worldExportFavoriteGroup === group) {
this.favoriteWorlds.forEach((ref) => {
if (group.key === ref.groupKey) {
lines.push(resText(ref.ref));
}
});
}
});
} else if (this.worldExportLocalFavoriteGroup) {
const favoriteGroup = this.localWorldFavorites[this.worldExportLocalFavoriteGroup];
if (!favoriteGroup) {
return;
}
for (let i = 0; i < favoriteGroup.length; ++i) {
const ref = favoriteGroup[i];
lines.push(resText(ref));
}
} else {
// export all
this.favoriteWorlds.forEach((ref) => {
lines.push(resText(ref.ref));
});
for (let i = 0; i < this.localWorldFavoritesList.length; ++i) {
const worldId = this.localWorldFavoritesList[i];
const ref = this.API.cachedWorlds.get(worldId);
if (typeof ref !== 'undefined') {
lines.push(resText(ref));
}
}
}
this.worldExportContent = lines.join('\n');
},
selectWorldExportGroup(group) {
this.worldExportFavoriteGroup = group;
this.worldExportLocalFavoriteGroup = null;
this.updateWorldExportDialog();
},
selectWorldExportLocalGroup(group) {
this.worldExportLocalFavoriteGroup = group;
this.worldExportFavoriteGroup = null;
this.updateWorldExportDialog();
},
getLocalWorldFavoriteGroupLength(group) {
const favoriteGroup = this.localWorldFavorites[group];
if (!favoriteGroup) {
return 0;
}
return favoriteGroup.length;
watch(
() => props.worldExportDialogVisible,
(value) => {
if (value) {
showWorldExportDialog();
}
}
};
);
function showWorldExportDialog() {
worldExportFavoriteGroup.value = null;
worldExportLocalFavoriteGroup.value = null;
updateWorldExportDialog();
}
function handleCopyWorldExportData(event) {
if (event.target.tagName === 'TEXTAREA') {
event.target.select();
}
navigator.clipboard
.writeText(worldExportContent.value)
.then(() => {
proxy.$message({
message: 'Copied successfully!',
type: 'success',
duration: 2000
});
})
.catch((err) => {
console.error('Copy failed:', err);
proxy.$message.error('Copy failed!');
});
}
function updateWorldExportDialog() {
const formatter = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const propsForQuery = exportSelectOptions.value
.filter((option) => exportSelectedOptions.value.includes(option.label))
.map((option) => option.value);
function resText(ref) {
let resArr = [];
propsForQuery.forEach((e) => {
resArr.push(formatter(ref?.[e]));
});
return resArr.join(',');
}
const lines = [exportSelectedOptions.value.join(',')];
if (worldExportFavoriteGroup.value) {
favoriteWorldGroups.value.forEach((group) => {
if (worldExportFavoriteGroup.value === group) {
favoriteWorlds.value.forEach((ref) => {
if (group.key === ref.groupKey) {
lines.push(resText(ref.ref));
}
});
}
});
} else if (worldExportLocalFavoriteGroup.value) {
const favoriteGroup = localWorldFavorites.value[worldExportLocalFavoriteGroup.value];
if (!favoriteGroup) {
return;
}
for (let i = 0; i < favoriteGroup.length; ++i) {
const ref = favoriteGroup[i];
lines.push(resText(ref));
}
} else {
// export all
favoriteWorlds.value.forEach((ref) => {
lines.push(resText(ref.ref));
});
for (let i = 0; i < localWorldFavoritesList.value.length; ++i) {
const worldId = localWorldFavoritesList.value[i];
const ref = cachedWorlds.value.get(worldId);
if (typeof ref !== 'undefined') {
lines.push(resText(ref));
}
}
}
worldExportContent.value = lines.join('\n');
}
function selectWorldExportGroup(group) {
worldExportFavoriteGroup.value = group;
worldExportLocalFavoriteGroup.value = null;
updateWorldExportDialog();
}
function selectWorldExportLocalGroup(group) {
worldExportLocalFavoriteGroup.value = group;
worldExportFavoriteGroup.value = null;
updateWorldExportDialog();
}
</script>

View File

@@ -1,24 +1,24 @@
<template>
<safe-dialog
ref="worldImportDialog"
ref="worldImportDialogRef"
:visible.sync="isVisible"
:title="$t('dialog.world_import.header')"
:title="t('dialog.world_import.header')"
width="650px"
top="10vh"
class="x-dialog">
<div style="display: flex; align-items: center; justify-content: space-between">
<div style="font-size: 12px">{{ $t('dialog.world_import.description') }}</div>
<div style="font-size: 12px">{{ t('dialog.world_import.description') }}</div>
<div style="display: flex; align-items: center">
<div v-if="worldImportDialog.progress">
{{ $t('dialog.world_import.process_progress') }}
{{ t('dialog.world_import.process_progress') }}
{{ worldImportDialog.progress }} / {{ worldImportDialog.progressTotal }}
<i class="el-icon-loading" style="margin: 0 5px"></i>
</div>
<el-button v-if="worldImportDialog.loading" size="small" @click="cancelWorldImport">
{{ $t('dialog.world_import.cancel') }}
{{ t('dialog.world_import.cancel') }}
</el-button>
<el-button v-else size="small" :disabled="!worldImportDialog.input" @click="processWorldImportList">
{{ $t('dialog.world_import.process_list') }}
{{ t('dialog.world_import.process_list') }}
</el-button>
</div>
</div>
@@ -41,12 +41,12 @@
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<span v-else>
{{ $t('dialog.world_import.select_vrchat_group_placeholder') }}
{{ t('dialog.world_import.select_vrchat_group_placeholder') }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
<el-dropdown-menu slot="dropdown">
<template v-for="groupAPI in API.favoriteWorldGroups">
<template v-for="groupAPI in favoriteWorldGroups">
<el-dropdown-item
:key="groupAPI.name"
style="display: block; margin: 10px 0"
@@ -65,7 +65,7 @@
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<span v-else>
{{ $t('dialog.world_import.select_local_group_placeholder') }}
{{ t('dialog.world_import.select_local_group_placeholder') }}
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
@@ -90,7 +90,7 @@
</div>
<div>
<el-button size="small" :disabled="worldImportTable.data.length === 0" @click="clearWorldImportTable">
{{ $t('dialog.world_import.clear_table') }}
{{ t('dialog.world_import.clear_table') }}
</el-button>
<el-button
size="small"
@@ -102,27 +102,27 @@
!worldImportDialog.worldImportLocalFavoriteGroup)
"
@click="importWorldImportTable">
{{ $t('dialog.world_import.import') }}
{{ t('dialog.world_import.import') }}
</el-button>
</div>
</div>
<span v-if="worldImportDialog.importProgress" style="margin: 10px">
<i class="el-icon-loading" style="margin-right: 5px"></i>
{{ $t('dialog.world_import.import_progress') }}
{{ t('dialog.world_import.import_progress') }}
{{ worldImportDialog.importProgress }}/{{ worldImportDialog.importProgressTotal }}
</span>
<br />
<template v-if="worldImportDialog.errors">
<el-button size="small" @click="worldImportDialog.errors = ''">
{{ $t('dialog.world_import.clear_errors') }}
{{ t('dialog.world_import.clear_errors') }}
</el-button>
<h2 style="font-weight: bold; margin: 5px 0">
{{ $t('dialog.world_import.errors') }}
{{ t('dialog.world_import.errors') }}
</h2>
<pre style="white-space: pre-wrap; font-size: 12px" v-text="worldImportDialog.errors"></pre>
</template>
<data-tables v-loading="worldImportDialog.loading" v-bind="worldImportTable" style="margin-top: 10px">
<el-table-column :label="$t('table.import.image')" width="70" prop="thumbnailImageUrl">
<el-table-column :label="t('table.import.image')" width="70" prop="thumbnailImageUrl">
<template slot-scope="scope">
<el-popover placement="right" height="500px" trigger="hover">
<img slot="reference" v-lazy="scope.row.thumbnailImageUrl" class="friends-list-avatar" />
@@ -134,12 +134,12 @@
</el-popover>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.name')" prop="name">
<el-table-column :label="t('table.import.name')" prop="name">
<template slot-scope="scope">
<span class="x-link" @click="showWorldDialog(scope.row.id)" v-text="scope.row.name"></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.author')" width="120" prop="authorName">
<el-table-column :label="t('table.import.author')" width="120" prop="authorName">
<template slot-scope="scope">
<span
class="x-link"
@@ -147,7 +147,7 @@
v-text="scope.row.authorName"></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.status')" width="70" prop="releaseStatus">
<el-table-column :label="t('table.import.status')" width="70" prop="releaseStatus">
<template slot-scope="scope">
<span
:style="{
@@ -163,7 +163,7 @@
"></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.import.action')" width="90" align="right">
<el-table-column :label="t('table.import.action')" width="90" align="right">
<template slot-scope="scope">
<el-button
type="text"
@@ -176,185 +176,193 @@
</safe-dialog>
</template>
<script>
<script setup>
import { ref, watch, computed, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { favoriteRequest, worldRequest } from '../../../api';
import utils from '../../../classes/utils';
import { adjustDialogZ, removeFromArray } from '../../../shared/utils';
import { useFavoriteStore, useGalleryStore, useUserStore, useWorldStore } from '../../../stores';
export default {
name: 'WorldImportDialog',
inject: ['API', 'showFullscreenImageDialog', 'showUserDialog', 'adjustDialogZ', 'showWorldDialog'],
props: {
worldImportDialogVisible: Boolean,
worldImportDialogInput: String,
getLocalWorldFavoriteGroupLength: Function,
localWorldFavoriteGroups: Array
const { showUserDialog } = useUserStore();
const { favoriteWorldGroups, worldImportDialogInput, worldImportDialogVisible, localWorldFavoriteGroups } =
storeToRefs(useFavoriteStore());
const { getLocalWorldFavoriteGroupLength, addLocalWorldFavorite } = useFavoriteStore();
const { showWorldDialog } = useWorldStore();
const { showFullscreenImageDialog } = useGalleryStore();
const emit = defineEmits(['update:worldImportDialogInput']);
const { proxy } = getCurrentInstance();
const { t } = useI18n();
const worldImportDialogRef = ref(null);
const worldImportDialog = ref({
loading: false,
progress: 0,
progressTotal: 0,
input: '',
worldIdList: new Set(),
errors: '',
worldImportFavoriteGroup: null,
worldImportLocalFavoriteGroup: null,
importProgress: 0,
importProgressTotal: 0
});
const worldImportTable = ref({
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
data() {
return {
worldImportDialog: {
loading: false,
progress: 0,
progressTotal: 0,
input: '',
worldIdList: new Set(),
errors: '',
worldImportFavoriteGroup: null,
worldImportLocalFavoriteGroup: null,
importProgress: 0,
importProgressTotal: 0
},
worldImportTable: {
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
layout: 'table'
}
};
layout: 'table'
});
const isVisible = computed({
get() {
return worldImportDialogVisible.value;
},
computed: {
isVisible: {
get() {
return this.worldImportDialogVisible;
},
set(visible) {
this.$emit('update:world-import-dialog-visible', visible);
}
}
},
watch: {
worldImportDialogVisible(visible) {
if (visible) {
this.adjustDialogZ(this.$refs.worldImportDialog.$el);
this.clearWorldImportTable();
this.resetWorldImport();
if (this.worldImportDialogInput) {
this.worldImportDialog.input = this.worldImportDialogInput;
this.processWorldImportList();
this.$emit('update:world-import-dialog-input', '');
}
}
}
},
methods: {
resetWorldImport() {
this.worldImportDialog.input = '';
this.worldImportDialog.errors = '';
},
async processWorldImportList() {
const D = this.worldImportDialog;
D.loading = true;
const regexWorldId = /wrld_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match = [];
const worldIdList = new Set();
while ((match = regexWorldId.exec(D.input)) !== null) {
worldIdList.add(match[0]);
}
D.input = '';
D.errors = '';
D.progress = 0;
D.progressTotal = worldIdList.size;
const data = Array.from(worldIdList);
for (let i = 0; i < data.length; ++i) {
if (!this.isVisible) {
this.resetWorldImport();
}
if (!D.loading || !this.isVisible) {
break;
}
const worldId = data[i];
if (!D.worldIdList.has(worldId)) {
try {
const args = await worldRequest.getWorld({
worldId
});
this.worldImportTable.data.push(args.ref);
D.worldIdList.add(worldId);
} catch (err) {
D.errors = D.errors.concat(`WorldId: ${worldId}\n${err}\n\n`);
}
}
D.progress++;
if (D.progress === worldIdList.size) {
D.progress = 0;
}
}
D.loading = false;
},
deleteItemWorldImport(ref) {
utils.removeFromArray(this.worldImportTable.data, ref);
this.worldImportDialog.worldIdList.delete(ref.id);
},
set(visible) {
worldImportDialogVisible.value = visible;
}
});
clearWorldImportTable() {
this.worldImportTable.data = [];
this.worldImportDialog.worldIdList = new Set();
},
selectWorldImportGroup(group) {
this.worldImportDialog.worldImportLocalFavoriteGroup = null;
this.worldImportDialog.worldImportFavoriteGroup = group;
},
selectWorldImportLocalGroup(group) {
this.worldImportDialog.worldImportFavoriteGroup = null;
this.worldImportDialog.worldImportLocalFavoriteGroup = group;
},
cancelWorldImport() {
this.worldImportDialog.loading = false;
},
async importWorldImportTable() {
const D = this.worldImportDialog;
if (!D.worldImportFavoriteGroup && !D.worldImportLocalFavoriteGroup) {
return;
watch(
() => worldImportDialogVisible.value,
(visible) => {
if (visible) {
adjustDialogZ(worldImportDialogRef.value.$el);
clearWorldImportTable();
resetWorldImport();
if (worldImportDialogInput.value) {
worldImportDialog.value.input = worldImportDialogInput.value;
processWorldImportList();
emit('update:worldImportDialogInput', '');
}
D.loading = true;
const data = [...this.worldImportTable.data].reverse();
D.importProgressTotal = data.length;
let ref = '';
try {
for (let i = data.length - 1; i >= 0; i--) {
if (!D.loading || !this.isVisible) {
break;
}
ref = data[i];
if (D.worldImportFavoriteGroup) {
await this.addFavoriteWorld(ref, D.worldImportFavoriteGroup, false);
} else if (D.worldImportLocalFavoriteGroup) {
this.$emit('addLocalWorldFavorite', ref.id, D.worldImportLocalFavoriteGroup);
}
utils.removeFromArray(this.worldImportTable.data, ref);
D.worldIdList.delete(ref.id);
D.importProgress++;
}
} catch (err) {
D.errors = `Name: ${ref.name}\nWorldId: ${ref.id}\n${err}\n\n`;
} finally {
D.importProgress = 0;
D.importProgressTotal = 0;
D.loading = false;
}
},
addFavoriteWorld(ref, group, message) {
return favoriteRequest
.addFavorite({
type: 'world',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
this.$message({
message: 'World added to favorites',
type: 'success'
});
}
return args;
});
}
}
};
);
function resetWorldImport() {
worldImportDialog.value.input = '';
worldImportDialog.value.errors = '';
}
async function processWorldImportList() {
const D = worldImportDialog.value;
D.loading = true;
const regexWorldId = /wrld_[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}/g;
let match = [];
const worldIdList = new Set();
while ((match = regexWorldId.exec(D.input)) !== null) {
worldIdList.add(match[0]);
}
D.input = '';
D.errors = '';
D.progress = 0;
D.progressTotal = worldIdList.size;
const data = Array.from(worldIdList);
for (let i = 0; i < data.length; ++i) {
if (!isVisible.value) {
resetWorldImport();
}
if (!D.loading || !isVisible.value) {
break;
}
const worldId = data[i];
if (!D.worldIdList.has(worldId)) {
try {
const args = await worldRequest.getWorld({
worldId
});
worldImportTable.value.data.push(args.ref);
D.worldIdList.add(worldId);
} catch (err) {
D.errors = D.errors.concat(`WorldId: ${worldId}\n${err}\n\n`);
}
}
D.progress++;
if (D.progress === worldIdList.size) {
D.progress = 0;
}
}
D.loading = false;
}
function deleteItemWorldImport(ref) {
removeFromArray(worldImportTable.value.data, ref);
worldImportDialog.value.worldIdList.delete(ref.id);
}
function clearWorldImportTable() {
worldImportTable.value.data = [];
worldImportDialog.value.worldIdList = new Set();
}
function selectWorldImportGroup(group) {
worldImportDialog.value.worldImportLocalFavoriteGroup = null;
worldImportDialog.value.worldImportFavoriteGroup = group;
}
function selectWorldImportLocalGroup(group) {
worldImportDialog.value.worldImportFavoriteGroup = null;
worldImportDialog.value.worldImportLocalFavoriteGroup = group;
}
function cancelWorldImport() {
worldImportDialog.value.loading = false;
}
async function importWorldImportTable() {
const D = worldImportDialog.value;
if (!D.worldImportFavoriteGroup && !D.worldImportLocalFavoriteGroup) {
return;
}
D.loading = true;
const data = [...worldImportTable.value.data].reverse();
D.importProgressTotal = data.length;
let ref = '';
try {
for (let i = data.length - 1; i >= 0; i--) {
if (!D.loading || !isVisible.value) {
break;
}
ref = data[i];
if (D.worldImportFavoriteGroup) {
await addFavoriteWorld(ref, D.worldImportFavoriteGroup, false);
} else if (D.worldImportLocalFavoriteGroup) {
addLocalWorldFavorite(ref, D.worldImportLocalFavoriteGroup);
}
removeFromArray(worldImportTable.value.data, ref);
D.worldIdList.delete(ref.id);
D.importProgress++;
}
} catch (err) {
D.errors = `Name: ${ref.name}\nWorldId: ${ref.id}\n${err}\n\n`;
} finally {
D.importProgress = 0;
D.importProgressTotal = 0;
D.loading = false;
}
}
function addFavoriteWorld(ref, group, message) {
return favoriteRequest
.addFavorite({
type: 'world',
favoriteId: ref.id,
tags: group.name
})
.then((args) => {
if (message) {
proxy.$message({
message: 'World added to favorites',
type: 'success'
});
}
return args;
});
}
</script>

View File

@@ -13,7 +13,7 @@
v-model="feedTable.filter"
multiple
clearable
style="flex: 1; height: 40px"
style="flex: 1"
:placeholder="t('view.feed.filter_placeholder')"
@change="feedTableLookup">
<el-option
@@ -26,7 +26,7 @@
v-model="feedTable.search"
:placeholder="t('view.feed.search_placeholder')"
clearable
style="flex: none; width: 150px; margin: 0 10px"
style="flex: none; width: 150px; margin-left: 10px"
@keyup.native.13="feedTableLookup"
@change="feedTableLookup"></el-input>
</div>
@@ -36,10 +36,10 @@
<template #default="scope">
<div style="position: relative; font-size: 14px">
<template v-if="scope.row.type === 'GPS'">
<location
<Location
v-if="scope.row.previousLocation"
:location="scope.row.previousLocation"
style="display: inline-block"></location>
style="display: inline-block" />
<el-tag type="info" effect="plain" size="mini" style="margin-left: 5px">{{
timeToText(scope.row.time)
}}</el-tag>
@@ -47,29 +47,29 @@
<span style="margin-right: 5px">
<i class="el-icon-right"></i>
</span>
<location
<Location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
:grouphint="scope.row.groupName" />
</template>
<template v-else-if="scope.row.type === 'Offline'">
<template v-if="scope.row.location">
<location
<Location
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
:grouphint="scope.row.groupName" />
<el-tag type="info" effect="plain" size="mini" style="margin-left: 5px">{{
timeToText(scope.row.time)
}}</el-tag>
</template>
</template>
<template v-else-if="scope.row.type === 'Online'">
<location
<Location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
:grouphint="scope.row.groupName" />
</template>
<template v-else-if="scope.row.type === 'Avatar'">
<div style="display: flex; align-items: center">
@@ -83,12 +83,12 @@
class="x-link"
style="flex: none; width: 160px; height: 120px; border-radius: 4px" />
<br />
<avatar-info
<AvatarInfo
:imageurl="scope.row.previousCurrentAvatarThumbnailImageUrl"
:userid="scope.row.userId"
:hintownerid="scope.row.previousOwnerId"
:hintavatarname="scope.row.previousAvatarName"
:avatartags="scope.row.previousCurrentAvatarTags"></avatar-info>
:avatartags="scope.row.previousCurrentAvatarTags" />
</template>
</div>
<img
@@ -110,12 +110,12 @@
class="x-link"
style="flex: none; width: 160px; height: 120px; border-radius: 4px" />
<br />
<avatar-info
<AvatarInfo
:imageurl="scope.row.currentAvatarThumbnailImageUrl"
:userid="scope.row.userId"
:hintownerid="scope.row.ownerId"
:hintavatarname="scope.row.avatarName"
:avatartags="scope.row.currentAvatarTags"></avatar-info>
:avatartags="scope.row.currentAvatarTags" />
</template>
</div>
<img
@@ -175,13 +175,7 @@
</template>
<template v-else-if="scope.row.type === 'Bio'">
<pre
style="
font-family: inherit;
font-size: 12px;
white-space: pre-wrap;
line-height: 25px;
line-height: 22px;
"
style="font-family: inherit; font-size: 12px; white-space: pre-wrap; line-height: 22px"
v-html="formatDifference(scope.row.previousBio, scope.row.bio)"></pre>
</template>
</div>
@@ -192,9 +186,9 @@
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
@@ -218,18 +212,18 @@
<el-table-column :label="t('table.feed.detail')">
<template #default="scope">
<template v-if="scope.row.type === 'GPS'">
<location
<Location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
:grouphint="scope.row.groupName" />
</template>
<template v-else-if="scope.row.type === 'Offline' || scope.row.type === 'Online'">
<location
<Location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
:grouphint="scope.row.groupName" />
</template>
<template v-else-if="scope.row.type === 'Status'">
<template v-if="scope.row.statusDescription === scope.row.previousStatusDescription">
@@ -299,12 +293,12 @@
</template>
</template>
<template v-else-if="scope.row.type === 'Avatar'">
<avatar-info
<AvatarInfo
:imageurl="scope.row.currentAvatarImageUrl"
:userid="scope.row.userId"
:hintownerid="scope.row.ownerId"
:hintavatarname="scope.row.avatarName"
:avatartags="scope.row.currentAvatarTags"></avatar-info>
:avatartags="scope.row.currentAvatarTags" />
</template>
<template v-else-if="scope.row.type === 'Bio'">
<span v-text="scope.row.bio"></span>
@@ -315,41 +309,21 @@
</div>
</template>
<script>
export default {
name: 'FeedTab'
};
</script>
<script setup>
import { inject } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../classes/utils';
import Location from '../../components/Location.vue';
import { useGalleryStore, useAppearanceSettingsStore, useUserStore, useFeedStore, useUiStore } from '../../stores';
import { timeToText, statusClass, formatDateFilter } from '../../shared/utils';
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { feedTable } = storeToRefs(useFeedStore());
const { feedTableLookup } = useFeedStore();
const { menuActiveIndex } = storeToRefs(useUiStore());
const { showFullscreenImageDialog } = useGalleryStore();
const { t } = useI18n();
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const statusClass = inject('statusClass');
const showUserDialog = inject('showUserDialog');
defineProps({
menuActiveIndex: {
type: String,
default: 'feed'
},
hideTooltips: {
type: Boolean,
default: false
},
feedTable: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits(['feedTableLookup']);
/**
* 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.
@@ -464,12 +438,4 @@
.replace(/<br>[ ]+<br>/g, '<br><br>')
.replace(/<br> /g, '<br>');
}
function feedTableLookup() {
emit('feedTableLookup');
}
function timeToText(time) {
return utils.timeToText(time);
}
</script>

View File

@@ -2,27 +2,27 @@
<div v-show="menuActiveIndex === 'friendList'" class="x-container">
<div style="padding: 0 10px 0 10px">
<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">
<span class="header">{{ t('view.friend_list.header') }}</span>
<div style="font-size: 13px; display: flex; align-items: center">
<div v-if="friendsListBulkUnfriendMode" style="display: inline-block; margin-right: 10px">
<el-button size="small" @click="showBulkUnfriendSelectionConfirm">
{{ $t('view.friend_list.bulk_unfriend_selection') }}
{{ 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: inline-block; margin-right: 10px">
<span class="name">{{ $t('view.friend_list.bulk_unfriend') }}</span>
<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>
<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')"
:content="t('view.friend_list.cancel_tooltip')"
:disabled="hideTooltips">
<el-button
size="mini"
@@ -35,7 +35,7 @@
<template v-else>
<el-tooltip
placement="top"
:content="$t('view.friend_list.load_tooltip')"
:content="t('view.friend_list.load_tooltip')"
:disabled="hideTooltips">
<el-button
size="mini"
@@ -52,7 +52,7 @@
<div style="flex: none; margin-right: 10px; display: flex; align-items: center">
<el-tooltip
placement="bottom"
:content="$t('view.friend_list.favorites_only_tooltip')"
:content="t('view.friend_list.favorites_only_tooltip')"
:disabled="hideTooltips">
<el-switch
v-model="friendsListSearchFilterVIP"
@@ -61,11 +61,10 @@
</el-tooltip>
</div>
<el-input
:value="friendsListSearch"
:placeholder="$t('view.friend_list.search_placeholder')"
v-model="friendsListSearch"
:placeholder="t('view.friend_list.search_placeholder')"
clearable
style="flex: 1"
@input="$emit('update:friends-list-search', $event)"
@change="friendsListSearchChange"></el-input>
<el-select
v-model="friendsListSearchFilters"
@@ -73,7 +72,7 @@
clearable
collapse-tags
style="flex: none; width: 200px; margin: 0 10px"
:placeholder="$t('view.friend_list.filter_placeholder')"
:placeholder="t('view.friend_list.filter_placeholder')"
@change="friendsListSearchChange">
<el-option
v-for="type in ['Display Name', 'User Name', 'Rank', 'Status', 'Bio', 'Note', 'Memo']"
@@ -81,7 +80,7 @@
:label="type"
:value="type"></el-option>
</el-select>
<el-tooltip placement="top" :content="$t('view.friend_list.refresh_tooltip')" :disabled="hideTooltips">
<el-tooltip placement="top" :content="t('view.friend_list.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
icon="el-icon-refresh"
@@ -109,12 +108,12 @@
</el-button>
</template>
</el-table-column>
<el-table-column :label="$t('table.friendList.no')" width="70" prop="$friendNumber" sortable="custom">
<el-table-column :label="t('table.friendList.no')" width="70" prop="$friendNumber" sortable="custom">
<template slot-scope="scope">
<span>{{ scope.row.$friendNumber ? scope.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="70" prop="photo">
<template slot-scope="scope">
<el-popover placement="right" height="500px" trigger="hover">
<img slot="reference" v-lazy="userImage(scope.row, true)" class="friends-list-avatar" />
@@ -127,7 +126,7 @@
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.displayName')"
:label="t('table.friendList.displayName')"
min-width="140"
prop="displayName"
sortable
@@ -138,11 +137,7 @@
}}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.rank')"
width="110"
prop="$trustSortNum"
sortable="custom">
<el-table-column :label="t('table.friendList.rank')" width="110" prop="$trustSortNum" sortable="custom">
<template slot-scope="scope">
<span
v-if="randomUserColours"
@@ -157,7 +152,7 @@
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.status')"
:label="t('table.friendList.status')"
min-width="180"
prop="status"
sortable
@@ -172,7 +167,7 @@
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.language')"
:label="t('table.friendList.language')"
width="110"
prop="$languages"
sortable
@@ -189,7 +184,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="100" prop="bioLinks">
<template slot-scope="scope">
<el-tooltip v-for="(link, index) in scope.row.bioLinks" v-if="link" :key="index">
<template slot="content">
@@ -209,52 +204,52 @@
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.joinCount')"
: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>
<el-table-column :label="t('table.friendList.timeTogether')" width="140" prop="$timeSpent" sortable>
<template slot-scope="scope">
<span v-if="scope.row.$timeSpent">{{ timeToText(scope.row.$timeSpent) }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.lastSeen')"
:label="t('table.friendList.lastSeen')"
width="170"
prop="$lastSeen"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, '$lastSeen')">
<template slot-scope="scope">
<span>{{ scope.row.$lastSeen | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.$lastSeen, 'long') }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.lastActivity')"
:label="t('table.friendList.lastActivity')"
width="170"
prop="last_activity"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'last_activity')">
<template slot-scope="scope">
<span>{{ scope.row.last_activity | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.last_activity, 'long') }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.lastLogin')"
:label="t('table.friendList.lastLogin')"
width="170"
prop="last_login"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'last_login')">
<template slot-scope="scope">
<span>{{ scope.row.last_login | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.last_login, 'long') }}</span>
</template>
</el-table-column>
<el-table-column
:label="$t('table.friendList.dateJoined')"
:label="t('table.friendList.dateJoined')"
width="120"
prop="date_joined"
sortable
:sort-method="(a, b) => sortAlphabetically(a, b, 'date_joined')"></el-table-column>
<el-table-column :label="$t('table.friendList.unfriend')" width="100" align="center">
<el-table-column :label="t('table.friendList.unfriend')" width="100" align="center">
<template slot-scope="scope">
<el-button
type="text"
@@ -269,251 +264,185 @@
</div>
</template>
<script>
<script setup>
import { storeToRefs } from 'pinia';
import { getCurrentInstance, nextTick, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { friendRequest, userRequest } from '../../api';
import utils from '../../classes/utils';
import { languageClass as _languageClass } from '../../composables/user/utils';
import removeConfusables, { removeWhitespace } from '../../service/confusables';
import { getFaviconUrl as _getFaviconUrl } from '../../composables/shared/utils';
import {
getFaviconUrl,
languageClass,
localeIncludes,
openExternalLink,
sortStatus,
statusClass,
timeToText,
userImage,
userImageFull,
formatDateFilter
} from '../../shared/utils';
import {
useAppearanceSettingsStore,
useFriendStore,
useGalleryStore,
useSearchStore,
useUiStore,
useUserStore
} from '../../stores';
export default {
name: 'FriendListTab',
inject: [
'userImage',
'userImageFull',
'showFullscreenImageDialog',
'showUserDialog',
'statusClass',
'openExternalLink'
],
props: {
friends: {
type: Map,
required: true
},
hideTooltips: Boolean,
randomUserColours: Boolean,
sortStatus: Function,
confirmDeleteFriend: Function,
friendsListSearch: String,
menuActiveIndex: String,
stringComparer: Intl.Collator
},
data() {
return {
friendsListSearchFilters: [],
friendsListTable: {
data: [],
tableProps: {
stripe: true,
size: 'mini',
defaultSort: {
prop: '$friendNumber',
order: 'descending'
}
},
pageSize: 100,
paginationProps: {
small: true,
layout: 'sizes,prev,pager,next,total',
pageSizes: [50, 100, 250, 500]
}
},
friendsListBulkUnfriendMode: false,
friendsListLoading: false,
friendsListLoadingProgress: '',
friendsListSearchFilterVIP: false,
// TODO
friendsListBulkUnfriendForceUpdate: 0
};
},
watch: {
menuActiveIndex() {
if (this.menuActiveIndex === 'friendList') {
requestAnimationFrame(() => {
this.friendsListSearchChange();
});
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const $confirm = proxy.$confirm;
const emit = defineEmits(['lookup-user']);
const { friends } = storeToRefs(useFriendStore());
const { getAllUserStats, confirmDeleteFriend, handleFriendDelete } = useFriendStore();
const { hideTooltips, randomUserColours } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { menuActiveIndex } = storeToRefs(useUiStore());
const { stringComparer, friendsListSearch } = storeToRefs(useSearchStore());
const { showFullscreenImageDialog } = useGalleryStore();
const friendsListSearchFilters = ref([]);
const friendsListTable = reactive({
data: [],
tableProps: { stripe: true, size: 'mini', defaultSort: { prop: '$friendNumber', order: 'descending' } },
pageSize: 100,
paginationProps: { small: true, layout: 'sizes,prev,pager,next,total', pageSizes: [50, 100, 250, 500] }
});
const friendsListBulkUnfriendMode = ref(false);
const friendsListLoading = ref(false);
const friendsListLoadingProgress = ref('');
const friendsListSearchFilterVIP = ref(false);
const friendsListBulkUnfriendForceUpdate = ref(0);
watch(menuActiveIndex, (val) => {
if (val === 'friendList') nextTick(friendsListSearchChange);
});
function friendsListSearchChange() {
friendsListLoading.value = true;
let query = '';
let cleanedQuery = '';
friendsListTable.data = [];
let filters = friendsListSearchFilters.value.length
? [...friendsListSearchFilters.value]
: ['Display Name', 'Rank', 'Status', 'Bio', 'Note', 'Memo'];
const results = [];
if (friendsListSearch.value) {
query = friendsListSearch.value;
cleanedQuery = removeWhitespace(query);
}
for (const ctx of friends.value.values()) {
if (!ctx.ref) continue;
ctx.ref.$selected = ctx.ref.$selected ?? false;
if (friendsListSearchFilterVIP.value && !ctx.isVIP) continue;
if (query) {
let match = false;
if (!match && filters.includes('Display Name') && ctx.ref.displayName) {
match =
localeIncludes(ctx.ref.displayName, cleanedQuery, stringComparer.value) ||
localeIncludes(removeConfusables(ctx.ref.displayName), cleanedQuery, stringComparer.value);
}
if (!match && filters.includes('Memo') && ctx.memo) {
match = localeIncludes(ctx.memo, query, stringComparer.value);
}
if (!match && filters.includes('Note') && ctx.ref.note) {
match = localeIncludes(ctx.ref.note, query, stringComparer.value);
}
if (!match && filters.includes('Bio') && ctx.ref.bio) {
match = localeIncludes(ctx.ref.bio, query, stringComparer.value);
}
if (!match && filters.includes('Status') && ctx.ref.statusDescription) {
match = localeIncludes(ctx.ref.statusDescription, query, stringComparer.value);
}
if (!match && filters.includes('Rank')) {
match = String(ctx.ref.$trustLevel).toUpperCase().includes(query.toUpperCase());
}
if (!match) continue;
}
},
methods: {
languageClass(key) {
return _languageClass(key);
},
friendsListSearchChange() {
this.friendsListLoading = true;
let query = '';
let cleanedQuery = '';
this.friendsListTable.data = [];
let filters = [...this.friendsListSearchFilters];
if (filters.length === 0) {
filters = ['Display Name', 'Rank', 'Status', 'Bio', 'Note', 'Memo'];
}
const results = [];
if (this.friendsListSearch) {
query = this.friendsListSearch;
cleanedQuery = removeWhitespace(query);
}
results.push(ctx.ref);
}
getAllUserStats();
nextTick(() => {
friendsListTable.data = results;
friendsListLoading.value = false;
});
}
for (const ctx of this.friends.values()) {
if (typeof ctx.ref === 'undefined') {
continue;
}
if (typeof ctx.ref.$selected === 'undefined') {
ctx.ref.$selected = false;
}
if (this.friendsListSearchFilterVIP && !ctx.isVIP) {
continue;
}
if (query && filters) {
let match = false;
if (!match && filters.includes('Display Name') && ctx.ref.displayName) {
match =
utils.localeIncludes(ctx.ref.displayName, cleanedQuery, this.stringComparer) ||
utils.localeIncludes(
removeConfusables(ctx.ref.displayName),
cleanedQuery,
this.stringComparer
);
}
if (!match && filters.includes('Memo') && ctx.memo) {
match = utils.localeIncludes(ctx.memo, query, this.stringComparer);
}
if (!match && filters.includes('Note') && ctx.ref.note) {
match = utils.localeIncludes(ctx.ref.note, query, this.stringComparer);
}
if (!match && filters.includes('Bio') && ctx.ref.bio) {
match = utils.localeIncludes(ctx.ref.bio, query, this.stringComparer);
}
if (!match && filters.includes('Status') && ctx.ref.statusDescription) {
match = utils.localeIncludes(ctx.ref.statusDescription, query, this.stringComparer);
}
if (!match && filters.includes('Rank')) {
match = String(ctx.ref.$trustLevel).toUpperCase().includes(query.toUpperCase());
}
if (!match) {
continue;
}
}
results.push(ctx.ref);
}
this.$emit('get-all-user-stats');
requestAnimationFrame(() => {
this.friendsListTable.data = results;
this.friendsListLoading = false;
});
},
toggleFriendsListBulkUnfriendMode() {
if (!this.friendsListBulkUnfriendMode) {
this.friendsListTable.data.forEach((ref) => {
ref.$selected = false;
});
}
},
showBulkUnfriendSelectionConfirm() {
const pendingUnfriendList = this.friendsListTable.data.reduce((acc, ctx) => {
if (ctx.$selected) {
acc.push(ctx.displayName);
}
return acc;
}, []);
const elementsTicked = pendingUnfriendList.length;
if (elementsTicked === 0) {
return;
}
this.$confirm(
`Are you sure you want to delete ${elementsTicked} friends?
function toggleFriendsListBulkUnfriendMode() {
if (!friendsListBulkUnfriendMode.value) {
friendsListTable.data.forEach((item) => (item.$selected = false));
}
}
function showBulkUnfriendSelectionConfirm() {
const pending = friendsListTable.data.filter((item) => item.$selected).map((item) => item.displayName);
if (!pending.length) return;
$confirm(
`Are you sure you want to delete ${pending.length} friends?
This can negatively affect your trust rank,
This action cannot be undone.`,
`Delete ${elementsTicked} friends?`,
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
showInput: true,
inputType: 'textarea',
inputValue: pendingUnfriendList.join('\r\n'),
callback: (action) => {
if (action === 'confirm') {
this.bulkUnfriendSelection();
}
}
}
);
},
`Delete ${pending.length} friends?`,
{
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
showInput: true,
inputType: 'textarea',
inputValue: pending.join('\r\n'),
callback: (action) => action === 'confirm' && bulkUnfriendSelection()
}
);
}
bulkUnfriendSelection() {
for (const ctx of this.friendsListTable.data) {
if (ctx.$selected) {
friendRequest.deleteFriend({
userId: ctx.id
});
}
}
},
async friendsListLoadUsers() {
this.friendsListLoading = true;
let i = 0;
const toFetch = [];
for (const ctx of this.friends.values()) {
if (ctx.ref && !ctx.ref.date_joined) {
toFetch.push(ctx.id);
}
}
const length = toFetch.length;
for (const userId of toFetch) {
if (!this.friendsListLoading) {
this.friendsListLoadingProgress = '';
return;
}
i++;
this.friendsListLoadingProgress = `${i}/${length}`;
try {
await userRequest.getUser({
userId
});
} catch (err) {
console.error(err);
}
}
this.friendsListLoadingProgress = '';
this.friendsListLoading = false;
},
selectFriendsListRow(val) {
if (val === null) {
return;
}
if (!val.id) {
this.$emit('lookup-user', val);
return;
}
this.showUserDialog(val.id);
},
sortAlphabetically(a, b, field) {
if (!a[field] || !b[field]) {
return 0;
}
return a[field].toLowerCase().localeCompare(b[field].toLowerCase());
},
sortLanguages(a, b) {
const sortedA = [];
const sortedB = [];
a.$languages.forEach((item) => {
sortedA.push(item.value);
});
b.$languages.forEach((item) => {
sortedB.push(item.value);
});
sortedA.sort();
sortedB.sort();
return JSON.stringify(sortedA).localeCompare(JSON.stringify(sortedB));
},
timeToText(val) {
return utils.timeToText(val);
},
getFaviconUrl(link) {
return _getFaviconUrl(link);
function bulkUnfriendSelection() {
friendsListTable.data.forEach((item) => {
if (item.$selected)
friendRequest.deleteFriend({ userId: item.id }).then((args) => handleFriendDelete(args));
});
}
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;
for (const userId of toFetch) {
if (!friendsListLoading.value) {
friendsListLoadingProgress.value = '';
return;
}
i++;
friendsListLoadingProgress.value = `${i}/${total}`;
try {
await userRequest.getUser({ userId });
} catch (err) {
console.error(err);
}
}
};
friendsListLoadingProgress.value = '';
friendsListLoading.value = false;
}
function selectFriendsListRow(val) {
if (!val) return;
if (!val.id) emit('lookup-user', val);
else showUserDialog(val.id);
}
function sortAlphabetically(a, b, field) {
if (!a[field] || !b[field]) return 0;
return a[field].toLowerCase().localeCompare(b[field].toLowerCase());
}
function sortLanguages(a, b) {
const as = a.$languages.map((i) => i.value).sort();
const bs = b.$languages.map((i) => i.value).sort();
return JSON.stringify(as).localeCompare(JSON.stringify(bs));
}
</script>

View File

@@ -34,9 +34,9 @@
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
@@ -87,42 +87,39 @@
</div>
</template>
<script>
export default {
name: 'FriendLogTab'
};
</script>
<script setup>
import { getCurrentInstance, inject } from 'vue';
import { storeToRefs } from 'pinia';
import { getCurrentInstance, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../classes/utils';
import configRepository from '../../service/config';
import database from '../../service/database';
import { database } from '../../service/database';
import { removeFromArray, formatDateFilter } from '../../shared/utils';
import { useAppearanceSettingsStore, useUiStore, useFriendStore, useUserStore } from '../../stores';
const { hideUnfriends } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { friendLogTable } = storeToRefs(useFriendStore());
const { shiftHeld } = storeToRefs(useUiStore());
const { menuActiveIndex } = storeToRefs(useUiStore());
watch(
() => hideUnfriends.value,
(newValue) => {
if (newValue) {
friendLogTable.value.filters[2].value = newValue;
}
},
{ immediate: true }
);
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const { $confirm } = proxy;
const showUserDialog = inject('showUserDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
default: ''
},
friendLogTable: {
type: Object,
default: () => ({})
},
shiftHeld: { type: Boolean, default: false }
});
function saveTableFilters() {
configRepository.setString('VRCX_friendLogTableFilters', JSON.stringify(props.friendLogTable.filters[0].value));
configRepository.setString('VRCX_friendLogTableFilters', JSON.stringify(friendLogTable.value.filters[0].value));
}
function deleteFriendLogPrompt(row) {
$confirm('Continue? Delete Log', 'Confirm', {
proxy.$confirm('Continue? Delete Log', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
@@ -134,7 +131,7 @@
});
}
function deleteFriendLog(row) {
utils.removeFromArray(props.friendLogTable.data, row);
removeFromArray(friendLogTable.value.data, row);
database.deleteFriendLogHistory(row.rowId);
}
</script>

View File

@@ -40,7 +40,7 @@
v-model="gameLogTable.search"
:placeholder="t('view.game_log.search_placeholder')"
clearable
style="flex: none; width: 150px; margin: 0 10px"
style="flex: none; width: 150px; margin-left: 10px"
@keyup.native.enter="gameLogTableLookup"
@change="gameLogTableLookup"></el-input>
</div>
@@ -50,9 +50,9 @@
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
@@ -99,16 +99,16 @@
<el-table-column :label="t('table.gameLog.detail')" prop="data">
<template #default="scope">
<location
<Location
v-if="scope.row.type === 'Location'"
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
<location
:grouphint="scope.row.groupName" />
<Location
v-else-if="scope.row.type === 'PortalSpawn'"
:location="scope.row.instanceId"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"></location>
:grouphint="scope.row.groupName" />
<template v-else-if="scope.row.type === 'Event'">
<span v-text="scope.row.data"></span>
</template>
@@ -117,7 +117,9 @@
</template>
<template v-else-if="scope.row.type === 'VideoPlay'">
<span v-if="scope.row.videoId" style="margin-right: 5px">{{ scope.row.videoId }}:</span>
<span v-if="scope.row.videoId === 'LSMedia'" v-text="scope.row.videoName"></span>
<span
v-if="scope.row.videoId === 'LSMedia' || scope.row.videoId === 'PopcornPalace'"
v-text="scope.row.videoName"></span>
<span
v-else-if="scope.row.videoName"
class="x-link"
@@ -189,82 +191,48 @@
</div>
</template>
<script>
export default {
name: 'GameLogTab'
};
</script>
<script setup>
import { inject, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../classes/utils';
import database from '../../service/database';
import Location from '../../components/Location.vue';
import { database } from '../../service/database';
import { removeFromArray, openExternalLink, formatDateFilter } from '../../shared/utils';
import {
useUserStore,
useUiStore,
useWorldStore,
useAppearanceSettingsStore,
useInstanceStore,
useGameLogStore
} from '../../stores';
import { useSharedFeedStore } from '../../stores';
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { showWorldDialog } = useWorldStore();
const { lookupUser } = useUserStore();
const { showPreviousInstancesInfoDialog } = useInstanceStore();
const { menuActiveIndex, shiftHeld } = storeToRefs(useUiStore());
const { gameLogIsFriend, gameLogIsFavorite, gameLogTableLookup } = useGameLogStore();
const { gameLogTable } = storeToRefs(useGameLogStore());
const { updateSharedFeed } = useSharedFeedStore();
const { t } = useI18n();
const { $confirm } = getCurrentInstance().proxy;
const { proxy } = getCurrentInstance();
const showWorldDialog = inject('showWorldDialog');
const openExternalLink = inject('openExternalLink');
const showPreviousInstancesInfoDialog = inject('showPreviousInstancesInfoDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
required: true
},
gameLogTable: {
type: Object,
required: true
},
hideTooltips: {
type: Boolean,
default: false
},
shiftHeld: {
type: Boolean,
default: false
},
gameLogIsFriend: {
type: Function,
default: () => () => false
},
gameLogIsFavorite: {
type: Function,
default: () => () => false
}
});
const emit = defineEmits([
'gameLogTableLookup',
'gameLogIsFriend',
'gameLogIsFavorite',
'lookupUser',
'updateGameLogSessionTable',
'updateSharedFeed'
]);
function gameLogTableLookup() {
emit('gameLogTableLookup');
}
function lookupUser(ref) {
emit('lookupUser', ref);
}
const emit = defineEmits(['updateGameLogSessionTable']);
function deleteGameLogEntry(row) {
utils.removeFromArray(props.gameLogTable.data, row);
removeFromArray(gameLogTable.value.data, row);
database.deleteGameLogEntry(row);
console.log('deleteGameLogEntry', row);
database.getGamelogDatabase().then((data) => {
emit('updateGameLogSessionTable', data);
emit('updateSharedFeed', true);
updateSharedFeed(true);
});
}
function deleteGameLogEntryPrompt(row) {
$confirm('Continue? Delete Log', 'Confirm', {
proxy.$confirm('Continue? Delete Log', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',

View File

@@ -28,7 +28,7 @@
ref="loginFormRef"
:model="loginForm"
:rules="loginForm.rules"
@submit.native.prevent="login()">
@submit.native.prevent="handleLogin()">
<el-form-item :label="t('view.login.field.username')" prop="username" required>
<el-input
v-model="loginForm.username"
@@ -66,7 +66,7 @@
<el-input
v-model="loginForm.endpoint"
name="endpoint"
:placeholder="API.endpointDomainVrchat"
:placeholder="AppGlobal.endpointDomainVrchat"
clearable></el-input>
</el-form-item>
<el-form-item
@@ -77,7 +77,7 @@
<el-input
v-model="loginForm.websocket"
name="websocket"
:placeholder="API.websocketDomainVrchat"
:placeholder="AppGlobal.websocketDomainVrchat"
clearable></el-input>
</el-form-item>
<el-form-item style="margin-top: 15px">
@@ -149,77 +149,38 @@
</div>
</template>
<script>
export default {
name: 'LoginPage'
};
</script>
<script setup>
import { inject, onBeforeUnmount, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { onBeforeUnmount, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import {
useAppearanceSettingsStore,
useAuthStore,
useGeneralSettingsStore,
useVRCXUpdaterStore
} from '../../stores';
import { openExternalLink, userImage } from '../../shared/utils';
import { AppGlobal } from '../../service/appConfig';
const { showVRCXUpdateDialog } = useVRCXUpdaterStore();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { loginForm, enableCustomEndpoint } = storeToRefs(useAuthStore());
const { toggleCustomEndpoint, relogin, deleteSavedLogin, login } = useAuthStore();
const { promptProxySettings } = useGeneralSettingsStore();
const { t } = useI18n();
const API = inject('API');
const openExternalLink = inject('openExternalLink');
const userImage = inject('userImage');
defineProps({
hideTooltips: {
type: Boolean,
default: false
},
loginForm: {
type: Object,
default: () => ({})
},
enableCustomEndpoint: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'showVRCXUpdateDialog',
'promptProxySettings',
'toggleCustomEndpoint',
'deleteSavedLogin',
'relogin',
'login'
]);
const loginFormRef = ref(null);
function showVRCXUpdateDialog() {
emit('showVRCXUpdateDialog');
}
function promptProxySettings() {
emit('promptProxySettings');
}
function toggleCustomEndpoint(...args) {
emit('toggleCustomEndpoint', args);
}
function deleteSavedLogin(userId) {
emit('deleteSavedLogin', userId);
}
function relogin(user) {
emit('relogin', user);
}
function login() {
function handleLogin() {
if (loginFormRef.value) {
loginFormRef.value.validate((valid) => {
valid && emit('login');
valid && login();
});
}
}
onBeforeUnmount(() => {
// Because v-if actually it is not required
if (loginFormRef.value) {
loginFormRef.value.resetFields();
}

View File

@@ -1,12 +1,12 @@
<template>
<div v-show="menuActiveIndex === 'moderation'" class="x-container">
<data-tables
:data="tableData.data"
:pageSize="tableData.pageSize"
:data="playerModerationTable.data"
:pageSize="playerModerationTable.pageSize"
:filters="filters"
:tableProps="tableProps"
:paginationProps="paginationProps"
v-loading="API.isPlayerModerationsLoading">
v-loading="isPlayerModerationsLoading">
<template slot="tool">
<div class="tool-slot">
<el-select
@@ -15,46 +15,46 @@
multiple
clearable
style="flex: 1"
:placeholder="$t('view.moderation.filter_placeholder')">
:placeholder="t('view.moderation.filter_placeholder')">
<el-option
v-for="item in moderationTypes"
:key="item"
:label="$t('view.moderation.filters.' + item)"
:label="t('view.moderation.filters.' + item)"
:value="item" />
</el-select>
<el-input
v-model="filters[1].value"
:placeholder="$t('view.moderation.search_placeholder')"
:placeholder="t('view.moderation.search_placeholder')"
class="filter-input" />
<el-tooltip
placement="bottom"
:content="$t('view.moderation.refresh_tooltip')"
:content="t('view.moderation.refresh_tooltip')"
:disabled="hideTooltips">
<el-button
type="default"
:loading="API.isPlayerModerationsLoading"
@click="API.refreshPlayerModerations()"
:loading="isPlayerModerationsLoading"
@click="refreshPlayerModerations()"
icon="el-icon-refresh"
circle />
</el-tooltip>
</div>
</template>
<el-table-column :label="$t('table.moderation.date')" prop="created" sortable="custom" width="120">
<el-table-column :label="t('table.moderation.date')" prop="created" sortable="custom" width="120">
<template slot-scope="scope">
<el-tooltip placement="right">
<template slot="content">
<span>{{ scope.row.created | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created, 'long') }}</span>
</template>
<span>{{ scope.row.created | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="$t('table.moderation.type')" prop="type" width="100">
<el-table-column :label="t('table.moderation.type')" prop="type" width="100">
<template slot-scope="scope">
<span v-text="$t('view.moderation.filters.' + scope.row.type)"></span>
<span v-text="t('view.moderation.filters.' + scope.row.type)"></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.moderation.source')" prop="sourceDisplayName">
<el-table-column :label="t('table.moderation.source')" prop="sourceDisplayName">
<template slot-scope="scope">
<span
class="x-link"
@@ -62,7 +62,7 @@
@click="showUserDialog(scope.row.sourceUserId)"></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.moderation.target')" prop="targetDisplayName">
<el-table-column :label="t('table.moderation.target')" prop="targetDisplayName">
<template slot-scope="scope">
<span
class="x-link"
@@ -70,9 +70,9 @@
@click="showUserDialog(scope.row.targetUserId)"></span>
</template>
</el-table-column>
<el-table-column :label="$t('table.moderation.action')" width="80" align="right">
<el-table-column :label="t('table.moderation.action')" width="80" align="right">
<template slot-scope="scope">
<template v-if="scope.row.sourceUserId === API.currentUser.id">
<template v-if="scope.row.sourceUserId === currentUser.id">
<el-button
v-if="shiftHeld"
style="color: #f56c6c"
@@ -93,87 +93,85 @@
</div>
</template>
<script>
<script setup>
import { getCurrentInstance, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { playerModerationRequest } from '../../api';
import configRepository from '../../service/config.js';
import { useUiStore, useModerationStore, useUserStore, useAppearanceSettingsStore } from '../../stores';
import { moderationTypes } from '../../shared/constants';
import { formatDateFilter } from '../../shared/utils';
export default {
name: 'ModerationTab',
inject: ['API', 'showUserDialog'],
props: {
menuActiveIndex: String,
tableData: Object,
shiftHeld: Boolean,
hideTooltips: Boolean
const { t } = useI18n();
const { proxy } = getCurrentInstance();
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { isPlayerModerationsLoading, playerModerationTable } = storeToRefs(useModerationStore());
const { refreshPlayerModerations, handlePlayerModerationDelete } = useModerationStore();
const { menuActiveIndex, shiftHeld } = storeToRefs(useUiStore());
const { currentUser } = storeToRefs(useUserStore());
const filters = ref([
{
prop: 'type',
value: [],
filterFn: (row, filter) => filter.value.some((v) => v === row.type)
},
created: async function () {
this.filters[0].value = JSON.parse(
await configRepository.getString('VRCX_playerModerationTableFilters', '[]')
);
},
data() {
return {
filters: [
{
prop: 'type',
value: [],
filterFn: (row, filter) => filter.value.some((v) => v === row.type)
},
{
prop: ['sourceDisplayName', 'targetDisplayName'],
value: ''
}
],
// CONSTANTS
moderationTypes: [
'block',
'unblock',
'mute',
'unmute',
'interactOn',
'interactOff',
'muteChat',
'unmuteChat'
],
tableProps: {
stripe: true,
size: 'mini',
defaultSort: {
prop: 'created',
order: 'descending'
}
},
paginationProps: {
small: true,
layout: 'sizes,prev,pager,next,total',
pageSizes: [10, 15, 20, 25, 50, 100]
}
};
},
methods: {
saveTableFilters() {
configRepository.setString('VRCX_playerModerationTableFilters', JSON.stringify(this.filters[0].value));
},
deletePlayerModeration(row) {
playerModerationRequest.deletePlayerModeration({
moderated: row.targetUserId,
type: row.type
});
},
deletePlayerModerationPrompt(row) {
this.$confirm(`Continue? Delete Moderation ${row.type}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
this.deletePlayerModeration(row);
}
}
});
}
{
prop: ['sourceDisplayName', 'targetDisplayName'],
value: ''
}
};
]);
const tableProps = ref({
stripe: true,
size: 'mini',
defaultSort: {
prop: 'created',
order: 'descending'
}
});
const paginationProps = ref({
small: true,
layout: 'sizes,prev,pager,next,total',
pageSizes: [10, 15, 20, 25, 50, 100]
});
async function init() {
filters.value[0].value = JSON.parse(
await configRepository.getString('VRCX_playerModerationTableFilters', '[]')
);
}
init();
function saveTableFilters() {
configRepository.setString('VRCX_playerModerationTableFilters', JSON.stringify(filters.value[0].value));
}
async function deletePlayerModeration(row) {
const args = await playerModerationRequest.deletePlayerModeration({
moderated: row.targetUserId,
type: row.type
});
handlePlayerModerationDelete(args);
}
function deletePlayerModerationPrompt(row) {
proxy.$confirm(`Continue? Delete Moderation ${row.type}`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deletePlayerModeration(row);
}
}
});
}
</script>
<style scoped>

View File

@@ -1,5 +1,5 @@
<template>
<div v-show="menuActiveIndex === 'notification'" v-loading="API.isNotificationsLoading" class="x-container">
<div v-show="menuActiveIndex === 'notification'" v-loading="isNotificationsLoading" class="x-container">
<data-tables v-bind="notificationTable" ref="notificationTableRef" class="notification-table">
<template #tool>
<div style="margin: 0 0 10px; display: flex; align-items: center">
@@ -45,11 +45,11 @@
:disabled="hideTooltips">
<el-button
type="default"
:loading="API.isNotificationsLoading"
:loading="isNotificationsLoading"
icon="el-icon-refresh"
circle
style="flex: none"
@click="API.refreshNotifications()" />
@click="refreshNotifications()" />
</el-tooltip>
</div>
</template>
@@ -58,9 +58,9 @@
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
@@ -76,7 +76,7 @@
v-else-if="scope.row.type === 'group.queueReady' || scope.row.type === 'instance.closed'"
placement="top">
<template #content>
<location
<Location
v-if="scope.row.location"
:location="scope.row.location"
:hint="scope.row.worldName"
@@ -169,7 +169,7 @@
<el-table-column :label="t('table.notification.message')" prop="message">
<template #default="scope">
<span v-if="scope.row.type === 'invite'" style="display: flex">
<location
<Location
v-if="scope.row.details"
:location="scope.row.details.worldId"
:hint="scope.row.details.worldName"
@@ -213,7 +213,7 @@
<el-table-column :label="t('table.notification.action')" width="100" align="right">
<template #default="scope">
<template v-if="scope.row.senderUserId !== API.currentUser.id && !scope.row.$isExpired">
<template v-if="scope.row.senderUserId !== currentUser.id && !scope.row.$isExpired">
<template v-if="scope.row.type === 'friendRequest'">
<el-tooltip placement="top" content="Accept" :disabled="hideTooltips">
<el-button
@@ -257,7 +257,11 @@
<template v-if="scope.row.responses">
<template v-for="response in scope.row.responses">
<el-tooltip placement="top" :content="response.text" :disabled="hideTooltips">
<el-tooltip
placement="top"
:content="response.text"
:disabled="hideTooltips"
:key="response.text">
<el-button
v-if="response.icon === 'check'"
type="text"
@@ -400,85 +404,62 @@
</data-tables>
<SendInviteResponseDialog
:send-invite-response-dialog="sendInviteResponseDialog"
:send-invite-response-dialog-visible.sync="sendInviteResponseDialogVisible"
:invite-response-message-table="inviteResponseMessageTable"
:upload-image="uploadImage" />
:send-invite-response-dialog-visible.sync="sendInviteResponseDialogVisible" />
<SendInviteRequestResponseDialog
:send-invite-response-dialog="sendInviteResponseDialog"
:send-invite-request-response-dialog-visible.sync="sendInviteRequestResponseDialogVisible"
:invite-request-response-message-table="inviteRequestResponseMessageTable"
:upload-image="uploadImage" />
:send-invite-request-response-dialog-visible.sync="sendInviteRequestResponseDialogVisible" />
</div>
</template>
<script>
export default {
name: 'NotificationTab'
};
</script>
<script setup>
import { getCurrentInstance, inject, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { getCurrentInstance, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { friendRequest, inviteMessagesRequest, notificationRequest, worldRequest } from '../../api';
import utils from '../../classes/utils';
import { parseLocation } from '../../composables/instance/utils';
import { convertFileUrlToImageUrl } from '../../composables/shared/utils';
import { friendRequest, notificationRequest, worldRequest } from '../../api';
import {
checkCanInvite,
convertFileUrlToImageUrl,
escapeTag,
formatDateFilter,
parseLocation,
removeFromArray
} from '../../shared/utils';
import configRepository from '../../service/config';
import database from '../../service/database';
import { database } from '../../service/database';
import {
useAppearanceSettingsStore,
useGalleryStore,
useGameStore,
useGroupStore,
useInviteStore,
useLocationStore,
useNotificationStore,
useUiStore,
useUserStore,
useWorldStore
} from '../../stores';
import SendInviteRequestResponseDialog from './dialogs/SendInviteRequestResponseDialog.vue';
import SendInviteResponseDialog from './dialogs/SendInviteResponseDialog.vue';
import Location from '../../components/Location.vue';
import Noty from 'noty';
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { showUserDialog } = useUserStore();
const { showWorldDialog } = useWorldStore();
const { showGroupDialog } = useGroupStore();
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
const { refreshInviteMessageTableData } = useInviteStore();
const { clearInviteImageUpload } = useGalleryStore();
const { notificationTable, isNotificationsLoading } = storeToRefs(useNotificationStore());
const { refreshNotifications, handleNotificationHide } = useNotificationStore();
const { menuActiveIndex, shiftHeld } = storeToRefs(useUiStore());
const { isGameRunning } = storeToRefs(useGameStore());
const { showFullscreenImageDialog } = useGalleryStore();
const { currentUser } = storeToRefs(useUserStore());
const { t } = useI18n();
const { $confirm, $message } = getCurrentInstance().proxy;
const API = inject('API');
const showWorldDialog = inject('showWorldDialog');
const showGroupDialog = inject('showGroupDialog');
const showUserDialog = inject('showUserDialog');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const clearInviteImageUpload = inject('clearInviteImageUpload');
const props = defineProps({
menuActiveIndex: {
type: String,
default: ''
},
notificationTable: {
type: Object,
default: () => ({})
},
shiftHeld: { type: Boolean, default: false },
hideTooltips: { type: Boolean, default: false },
lastLocation: { type: Object, default: () => ({}) },
inviteResponseMessageTable: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String,
default: ''
},
lastLocationDestination: {
type: String,
default: ''
},
isGameRunning: {
type: Boolean,
default: false
},
checkCanInvite: {
type: Function,
default: () => true
},
inviteRequestResponseMessageTable: {
type: Object,
default: () => ({})
}
});
const sendInviteResponseDialog = ref({
messageSlot: {},
invite: {}
@@ -491,7 +472,7 @@
function saveTableFilters() {
configRepository.setString(
'VRCX_notificationTableFilters',
JSON.stringify(props.notificationTable.filters[0].value)
JSON.stringify(notificationTable.value.filters[0].value)
);
}
@@ -536,7 +517,7 @@
function showSendInviteResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
inviteMessagesRequest.refreshInviteMessageTableData('response');
refreshInviteMessageTableData('response');
clearInviteImageUpload();
sendInviteResponseDialogVisible.value = true;
}
@@ -548,10 +529,9 @@
type: 'info',
callback: (action) => {
if (action === 'confirm') {
let currentLocation = props.lastLocation.location;
// todo
if (props.lastLocation.location === 'traveling') {
currentLocation = props.lastLocationDestination;
let currentLocation = lastLocation.value.location;
if (lastLocation.value.location === 'traveling') {
currentLocation = lastLocationDestination.value;
}
const L = parseLocation(currentLocation);
worldRequest
@@ -585,14 +565,14 @@
function showSendInviteRequestResponseDialog(invite) {
sendInviteResponseDialog.value.invite = invite;
sendInviteResponseDialog.value.messageSlot = {};
inviteMessagesRequest.refreshInviteMessageTableData('requestResponse');
refreshInviteMessageTableData('requestResponse');
clearInviteImageUpload();
sendInviteRequestResponseDialogVisible.value = true;
}
function sendNotificationResponse(notificationId, responses, responseType) {
if (!Array.isArray(responses) || responses.length === 0) {
return null;
return;
}
let responseData = '';
for (let i = 0; i < responses.length; i++) {
@@ -601,21 +581,41 @@
break;
}
}
return notificationRequest.sendNotificationResponse({
const params = {
notificationId,
responseType,
responseData
});
};
notificationRequest
.sendNotificationResponse(params)
.then((json) => {
const args = {
json,
params
};
handleNotificationHide(args);
new Noty({
type: 'success',
text: escapeTag(args.json)
}).show();
console.log('NOTIFICATION:RESPONSE', args);
})
.catch((err) => {
handleNotificationHide({ params });
notificationRequest.hideNotificationV2(params.notificationId);
throw err;
});
}
function hideNotification(row) {
async function hideNotification(row) {
if (row.type === 'ignoredFriendRequest') {
friendRequest.deleteHiddenFriendRequest(
const args = await friendRequest.deleteHiddenFriendRequest(
{
notificationId: row.id
},
row.senderUserId
);
useNotificationStore().handleNotificationHide(args);
} else {
notificationRequest.hideNotification({
notificationId: row.id
@@ -637,7 +637,7 @@
}
function deleteNotificationLog(row) {
utils.removeFromArray(props.notificationTable.data, row);
removeFromArray(notificationTable.value.data, row);
if (row.type !== 'friendRequest' && row.type !== 'ignoredFriendRequest') {
database.deleteNotification(row.id);
}

View File

@@ -30,24 +30,23 @@
</template>
<script setup>
import { getCurrentInstance, inject } from 'vue';
import { storeToRefs } from 'pinia';
import { getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { inviteMessagesRequest, notificationRequest } from '../../../api';
import { useGalleryStore } from '../../../stores';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const API = inject('API');
const galleryStore = useGalleryStore();
const { uploadImage } = storeToRefs(galleryStore);
const props = defineProps({
editAndSendInviteResponseDialog: {
type: Object,
required: true
},
uploadImage: {
type: String
},
sendInviteResponseDialog: {
type: Object,
default: () => ({})
@@ -76,7 +75,6 @@
throw err;
})
.then((args) => {
API.$emit(`INVITE:${messageType.toUpperCase()}`, args);
if (args.json[slot].message === I.messageSlot.message) {
$message({
message: "VRChat API didn't update message, try again",
@@ -93,7 +91,7 @@
responseSlot: slot,
rsvp: true
};
if (props.uploadImage) {
if (uploadImage.value) {
notificationRequest
.sendInviteResponsePhoto(params, I.invite.id)
.catch((err) => {

View File

@@ -6,7 +6,7 @@
width="800px"
append-to-body
@close="cancelSendInviteRequestResponse">
<template v-if="API.currentUser.$isVRCPlus">
<template v-if="currentUser.$isVRCPlus">
<input class="inviteImageUploadButton" type="file" accept="image/*" @change="inviteImageUpload" />
</template>
@@ -43,33 +43,36 @@
<el-button type="small" @click="cancelSendInviteRequestResponse">
{{ t('dialog.invite_request_response_message.cancel') }}
</el-button>
<el-button type="small" @click="API.refreshInviteMessageTableData('requestResponse')">
<el-button type="small" @click="refreshInviteMessageTableData('requestResponse')">
{{ t('dialog.invite_request_response_message.refresh') }}
</el-button>
</template>
<EditAndSendInviteResponseDialog
:edit-and-send-invite-response-dialog.sync="editAndSendInviteResponseDialog"
:upload-image="uploadImage"
:send-invite-response-dialog.sync="sendInviteResponseDialog"
@closeInviteDialog="closeInviteDialog" />
<SendInviteResponseConfirmDialog
:send-invite-response-dialog.sync="sendInviteResponseDialog"
:upload-image="uploadImage"
:send-invite-response-confirm-dialog="sendInviteResponseConfirmDialog"
@closeInviteDialog="closeInviteDialog" />
</safe-dialog>
</template>
<script setup>
import { inject, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { useGalleryStore, useInviteStore, useUserStore } from '../../../stores';
import EditAndSendInviteResponseDialog from './EditAndSendInviteResponseDialog.vue';
import SendInviteResponseConfirmDialog from './SendInviteResponseConfirmDialog.vue';
const { t } = useI18n();
const API = inject('API');
const inviteImageUpload = inject('inviteImageUpload');
const inviteStore = useInviteStore();
const { refreshInviteMessageTableData } = inviteStore;
const { inviteRequestResponseMessageTable } = storeToRefs(inviteStore);
const galleryStore = useGalleryStore();
const { inviteImageUpload } = galleryStore;
const { currentUser } = storeToRefs(useUserStore());
const props = defineProps({
sendInviteResponseDialog: {
@@ -79,13 +82,6 @@
sendInviteRequestResponseDialogVisible: {
type: Boolean,
default: false
},
inviteRequestResponseMessageTable: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String
}
});
@@ -117,6 +113,10 @@
cancelSendInviteRequestResponse();
}
// function refreshInviteMessageTableData(...arg) {
// inviteMessagesRequest.refreshInviteMessageTableData(arg);
// }
function cancelSendInviteRequestResponse() {
emit('update:sendInviteRequestResponseDialogVisible', false);
}

View File

@@ -22,22 +22,24 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { notificationRequest } from '../../../api';
import { useGalleryStore } from '../../../stores';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const galleryStore = useGalleryStore();
const { uploadImage } = storeToRefs(galleryStore);
const props = defineProps({
sendInviteResponseDialog: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String
},
sendInviteResponseConfirmDialog: {
type: Object,
required: true
@@ -48,7 +50,6 @@
function cancelInviteResponseConfirm() {
emit('update:sendInviteResponseConfirmDialog', { visible: false });
// TODO: temp fix to close dialog
props.sendInviteResponseConfirmDialog.visible = false;
}
@@ -58,7 +59,7 @@
responseSlot: D.messageSlot.slot,
rsvp: true
};
if (props.uploadImage) {
if (uploadImage.value) {
notificationRequest
.sendInviteResponsePhoto(params, D.invite.id, D.messageSlot.messageType)
.catch((err) => {

View File

@@ -6,7 +6,7 @@
width="800px"
append-to-body
@close="cancelSendInviteResponse">
<template v-if="API.currentUser.$isVRCPlus">
<template v-if="currentUser.$isVRCPlus">
<input class="inviteImageUploadButton" type="file" accept="image/*" @change="inviteImageUpload" />
</template>
@@ -45,33 +45,38 @@
<el-button type="small" @click="cancelSendInviteResponse">{{
t('dialog.invite_response_message.cancel')
}}</el-button>
<el-button type="small" @click="API.refreshInviteMessageTableData('response')">{{
<el-button type="small" @click="refreshInviteMessageTableData('response')">{{
t('dialog.invite_response_message.refresh')
}}</el-button>
</template>
<EditAndSendInviteResponseDialog
:edit-and-send-invite-response-dialog.sync="editAndSendInviteResponseDialog"
:upload-image="uploadImage"
:send-invite-response-dialog.sync="sendInviteResponseDialog"
@closeInviteDialog="closeInviteDialog" />
<SendInviteResponseConfirmDialog
:send-invite-response-dialog.sync="sendInviteResponseDialog"
:upload-image="uploadImage"
:send-invite-response-confirm-dialog="sendInviteResponseConfirmDialog"
@closeInviteDialog="closeInviteDialog" />
</safe-dialog>
</template>
<script setup>
import { inject, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { useGalleryStore, useInviteStore, useUserStore } from '../../../stores';
import EditAndSendInviteResponseDialog from './EditAndSendInviteResponseDialog.vue';
import SendInviteResponseConfirmDialog from './SendInviteResponseConfirmDialog.vue';
const { t } = useI18n();
const API = inject('API');
const inviteImageUpload = inject('inviteImageUpload');
const inviteStore = useInviteStore();
const { refreshInviteMessageTableData } = inviteStore;
const { inviteResponseMessageTable } = storeToRefs(inviteStore);
const galleryStore = useGalleryStore();
const { inviteImageUpload } = galleryStore;
const { currentUser } = storeToRefs(useUserStore());
const props = defineProps({
sendInviteResponseDialog: {
type: Object,
@@ -80,13 +85,6 @@
sendInviteResponseDialogVisible: {
type: Boolean,
default: false
},
inviteResponseMessageTable: {
type: Object,
default: () => ({})
},
uploadImage: {
type: String
}
});
@@ -116,7 +114,6 @@
visible: true
};
}
function showSendInviteResponseConfirmDialog(row) {
props.sendInviteResponseDialog.messageSlot = row;
sendInviteResponseConfirmDialog.value.visible = true;

View File

@@ -29,8 +29,8 @@
@click="showWorldDialog(currentInstanceWorld.ref.id)">
<i
v-show="
API.currentUser.$homeLocation &&
API.currentUser.$homeLocation.worldId === currentInstanceWorld.ref.id
currentUser.$homeLocation &&
currentUser.$homeLocation.worldId === currentInstanceWorld.ref.id
"
class="el-icon-s-home"
style="margin-right: 5px"></i>
@@ -132,16 +132,13 @@
</el-tag>
</div>
<div style="margin-top: 5px">
<location-world
:locationobject="currentInstanceLocation"
:currentuserid="API.currentUser.id"
@show-launch-dialog="showLaunchDialog"></location-world>
<LocationWorld :locationobject="currentInstanceLocation" :currentuserid="currentUser.id" />
<span v-if="lastLocation.playerList.size > 0" style="margin-left: 5px">
{{ lastLocation.playerList.size }}
<template v-if="lastLocation.friendList.size > 0"
>({{ lastLocation.friendList.size }})</template
>
&nbsp;&horbar; <timer v-if="lastLocation.date" :epoch="lastLocation.date"></timer>
&nbsp;&horbar; <Timer v-if="lastLocation.date" :epoch="lastLocation.date" />
</span>
</div>
<div style="margin-top: 5px">
@@ -175,8 +172,8 @@
<div class="detail">
<span class="name">{{ t('dialog.world.info.capacity') }}</span>
<span class="extra"
>{{ currentInstanceWorld.ref.recommendedCapacity | commaNumber }} ({{
currentInstanceWorld.ref.capacity | commaNumber
>{{ commaNumber(currentInstanceWorld.ref.recommendedCapacity) }} ({{
commaNumber(currentInstanceWorld.ref.capacity)
}})</span
>
</div>
@@ -184,13 +181,15 @@
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('dialog.world.info.last_updated') }}</span>
<span class="extra">{{ currentInstanceWorld.lastUpdated | formatDate('long') }}</span>
<span class="extra">{{ formatDateFilter(currentInstanceWorld.lastUpdated, 'long') }}</span>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('dialog.world.info.created_at') }}</span>
<span class="extra">{{ currentInstanceWorld.ref.created_at | formatDate('long') }}</span>
<span class="extra">{{
formatDateFilter(currentInstanceWorld.ref.created_at, 'long')
}}</span>
</div>
</div>
</div>
@@ -245,9 +244,9 @@
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
@@ -377,11 +376,11 @@
v-else-if="scope.row.type === 'PortalSpawn'"
class="x-link"
@click="showWorldDialog(scope.row.location, scope.row.shortName)">
<location
<Location
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"
:link="false"></location>
:link="false" />
</span>
<span
v-else-if="scope.row.type === 'ChatBoxMessage'"
@@ -445,9 +444,9 @@
<template #default="scope">
<el-tooltip placement="right">
<template #content>
<span>{{ scope.row.created_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'long') }}</span>
</template>
<span>{{ scope.row.created_at | formatDate('short') }}</span>
<span>{{ formatDateFilter(scope.row.created_at, 'short') }}</span>
</el-tooltip>
</template>
</el-table-column>
@@ -577,11 +576,11 @@
v-else-if="scope.row.type === 'PortalSpawn'"
class="x-link"
@click="showWorldDialog(scope.row.location, scope.row.shortName)">
<location
<Location
:location="scope.row.location"
:hint="scope.row.worldName"
:grouphint="scope.row.groupName"
:link="false"></location>
:link="false" />
</span>
<span
v-else-if="scope.row.type === 'ChatBoxMessage'"
@@ -665,7 +664,7 @@
</el-table-column>
<el-table-column :label="t('table.playerList.timer')" width="80" prop="timer" sortable>
<template #default="scope">
<timer :epoch="scope.row.timer"></timer>
<Timer :epoch="scope.row.timer" />
</template>
</el-table-column>
<el-table-column
@@ -824,138 +823,77 @@
</div>
<ChatboxBlacklistDialog
:chatbox-blacklist-dialog="chatboxBlacklistDialog"
:chatbox-user-blacklist="chatboxUserBlacklist"
@delete-chatbox-user-blacklist="deleteChatboxUserBlacklist" />
</div>
</template>
<script>
export default {
name: 'PlayerListTab'
};
</script>
<script setup>
import { inject, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { languageClass } from '../../composables/user/utils';
import configRepository from '../../service/config';
import {
languageClass,
getFaviconUrl,
openExternalLink,
statusClass,
userImage,
userImageFull,
commaNumber,
formatDateFilter
} from '../../shared/utils';
import {
useLocationStore,
useAppearanceSettingsStore,
usePhotonStore,
useUserStore,
useAvatarStore,
useWorldStore,
useGroupStore,
useInstanceStore,
useUiStore,
useGalleryStore,
useVrcxStore
} from '../../stores';
import ChatboxBlacklistDialog from './dialogs/ChatboxBlacklistDialog.vue';
import { getFaviconUrl } from '../../composables/shared/utils';
import { photonEventTableTypeFilterList } from '../../shared/constants';
const { hideTooltips, randomUserColours } = storeToRefs(useAppearanceSettingsStore());
const {
photonLoggingEnabled,
photonEventIcon,
photonEventTableTypeFilter,
photonEventTable,
photonEventTablePrevious,
chatboxUserBlacklist,
photonEventTableFilter
} = storeToRefs(usePhotonStore());
const { saveChatboxUserBlacklist, photonEventTableFilterChange, showUserFromPhotonId } = usePhotonStore();
const { showUserDialog, lookupUser } = useUserStore();
const { showAvatarDialog } = useAvatarStore();
const { showWorldDialog } = useWorldStore();
const { showGroupDialog } = useGroupStore();
const { lastLocation } = storeToRefs(useLocationStore());
const { currentInstanceLocation, currentInstanceWorld } = storeToRefs(useInstanceStore());
const { currentInstanceUserList, getCurrentInstanceUserList } = useInstanceStore();
const { menuActiveIndex } = storeToRefs(useUiStore());
const { showFullscreenImageDialog } = useGalleryStore();
const { ipcEnabled } = storeToRefs(useVrcxStore());
const { currentUser } = storeToRefs(useUserStore());
const { t } = useI18n();
const API = inject('API');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const showWorldDialog = inject('showWorldDialog');
const showUserDialog = inject('showUserDialog');
const showLaunchDialog = inject('showLaunchDialog');
const showAvatarDialog = inject('showAvatarDialog');
const statusClass = inject('statusClass');
const showGroupDialog = inject('showGroupDialog');
const openExternalLink = inject('openExternalLink');
const userImage = inject('userImage');
const userImageFull = inject('userImageFull');
const props = defineProps({
menuActiveIndex: {
type: String,
default: 'playerList'
},
currentInstanceWorld: {
type: Object,
default: () => ({})
},
currentInstanceLocation: {
type: Object,
default: () => ({})
},
currentInstanceWorldDescriptionExpanded: {
type: Boolean,
default: false
},
photonLoggingEnabled: {
type: Boolean,
default: false
},
photonEventTableTypeFilter: {
type: Array,
default: () => []
},
photonEventTableTypeFilterList: {
type: Array,
default: () => []
},
photonEventTableFilter: {
type: String,
default: ''
},
hideTooltips: {
type: Boolean,
default: false
},
ipcEnabled: {
type: Boolean,
default: false
},
photonEventIcon: {
type: Boolean,
default: false
},
photonEventTable: {
type: Object,
default: () => ({})
},
photonEventTablePrevious: {
type: Object,
default: () => ({})
},
currentInstanceUserList: {
type: Object,
default: () => ({})
},
chatboxUserBlacklist: {
type: Map
},
randomUserColours: {
type: Boolean,
default: false
},
lastLocation: {
type: Object,
default: () => ({})
}
});
const emit = defineEmits([
'photonEventTableFilterChange',
'getCurrentInstanceUserList',
'showUserFromPhotonId',
'lookupUser'
]);
const chatboxBlacklistDialog = ref({
visible: false,
loading: false
});
function photonEventTableFilterChange(value) {
emit('photonEventTableFilterChange', value);
}
const currentInstanceWorldDescriptionExpanded = ref(false);
function showChatboxBlacklistDialog() {
const D = chatboxBlacklistDialog.value;
D.visible = true;
}
function showUserFromPhotonId(photonId) {
emit('showUserFromPhotonId', photonId);
}
function lookupUser(user) {
emit('lookupUser', user);
}
function selectCurrentInstanceRow(val) {
if (val === null) {
return;
@@ -969,21 +907,14 @@
}
async function deleteChatboxUserBlacklist(userId) {
props.chatboxUserBlacklist.delete(userId);
chatboxUserBlacklist.value.delete(userId);
await saveChatboxUserBlacklist();
emit('getCurrentInstanceUserList');
}
async function saveChatboxUserBlacklist() {
await configRepository.setString(
'VRCX_chatboxUserBlacklist',
JSON.stringify(Object.fromEntries(props.chatboxUserBlacklist))
);
getCurrentInstanceUserList();
}
async function addChatboxUserBlacklist(user) {
props.chatboxUserBlacklist.set(user.id, user.displayName);
chatboxUserBlacklist.value.set(user.id, user.displayName);
await saveChatboxUserBlacklist();
emit('getCurrentInstanceUserList');
getCurrentInstanceUserList();
}
</script>

View File

@@ -43,19 +43,20 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
import { usePhotonStore } from '../../../stores';
const { t } = useI18n();
const { chatboxUserBlacklist } = storeToRefs(usePhotonStore());
defineProps({
chatboxBlacklistDialog: {
type: Object,
required: true
},
chatboxUserBlacklist: {
type: Map,
required: true
}
});

View File

@@ -1,28 +1,28 @@
<template>
<div v-if="menuActiveIndex === 'profile'" class="x-container">
<div v-show="menuActiveIndex === 'profile'" class="x-container">
<div class="options-container" style="margin-top: 0">
<span class="header">{{ t('view.profile.profile.header') }}</span>
<div class="x-friend-list" style="margin-top: 10px">
<div class="x-friend-item" @click="showUserDialog(API.currentUser.id)">
<div class="x-friend-item" @click="showUserDialog(currentUser.id)">
<div class="avatar">
<img v-lazy="userImage(API.currentUser, true)" />
<img v-lazy="userImage(currentUser, true)" />
</div>
<div class="detail">
<span class="name" v-text="API.currentUser.displayName"></span>
<span class="extra" v-text="API.currentUser.username"></span>
<span class="name" v-text="currentUser.displayName"></span>
<span class="extra" v-text="currentUser.username"></span>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('view.profile.profile.last_activity') }}</span>
<span class="extra">{{ API.currentUser.last_activity | formatDate('long') }}</span>
<span class="extra">{{ formatDateFilter(currentUser.last_activity, 'long') }}</span>
</div>
</div>
<div class="x-friend-item" style="cursor: default">
<div class="detail">
<span class="name">{{ t('view.profile.profile.two_factor') }}</span>
<span class="extra">{{
API.currentUser.twoFactorAuthEnabled
currentUser.twoFactorAuthEnabled
? t('view.profile.profile.two_factor_enabled')
: t('view.profile.profile.two_factor_disabled')
}}</span>
@@ -101,12 +101,12 @@
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="API.getConfig()"></el-button>
@click="getConfig"></el-button>
</el-tooltip>
</div>
<div class="x-friend-list" style="margin-top: 10px">
<div
v-for="(link, item) in API.cachedConfig.downloadUrls"
v-for="(link, item) in cachedConfig.downloadUrls"
:key="item"
class="x-friend-item"
placement="top">
@@ -150,7 +150,7 @@
style="margin-left: 5px"
@click="
inviteMessageTable.visible = true;
refreshInviteMessageTable('message');
refreshInviteMessageTableData('message');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
@@ -204,7 +204,7 @@
style="margin-left: 5px"
@click="
inviteResponseMessageTable.visible = true;
refreshInviteMessageTable('response');
refreshInviteMessageTableData('response');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
@@ -261,7 +261,7 @@
style="margin-left: 5px"
@click="
inviteRequestMessageTable.visible = true;
refreshInviteMessageTable('request');
refreshInviteMessageTableData('request');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
@@ -318,7 +318,7 @@
style="margin-left: 5px"
@click="
inviteRequestResponseMessageTable.visible = true;
refreshInviteMessageTable('requestResponse');
refreshInviteMessageTableData('requestResponse');
"></el-button>
</el-tooltip>
<el-tooltip placement="top" :content="t('view.profile.clear_results_tooltip')" :disabled="hideTooltips">
@@ -371,7 +371,7 @@
prop="updated_at"
sortable="custom">
<template #default="scope">
<span>{{ scope.row.updated_at | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.updated_at, 'long') }}</span>
</template>
</el-table-column>
<el-table-column
@@ -489,92 +489,56 @@
</div>
</template>
<script>
export default {
name: 'ProfileTab'
};
</script>
<script setup>
import { inject, ref, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { ref, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { inviteMessagesRequest, miscRequest, userRequest } from '../../api';
import utils from '../../classes/utils';
import { parseAvatarUrl } from '../../composables/avatar/utils';
import { authRequest, miscRequest, userRequest } from '../../api';
import {
parseAvatarUrl,
buildTreeData,
openExternalLink,
userImage,
parseUserUrl,
formatDateFilter
} from '../../shared/utils';
import { useAuthStore } from '../../stores';
import DiscordNamesDialog from './dialogs/DiscordNamesDialog.vue';
import ExportFriendsListDialog from './dialogs/ExportFriendsListDialog.vue';
import ExportAvatarsListDialog from './dialogs/ExportAvatarsListDialog.vue';
import {
useAppearanceSettingsStore,
useSearchStore,
useFriendStore,
useUserStore,
useAvatarStore,
useInviteStore,
useGalleryStore,
useUiStore
} from '../../stores';
const { friends } = storeToRefs(useFriendStore());
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { pastDisplayNameTable, currentUser } = storeToRefs(useUserStore());
const { showUserDialog, lookupUser, getCurrentUser } = useUserStore();
const { showAvatarDialog } = useAvatarStore();
const { showEditInviteMessageDialog, refreshInviteMessageTableData } = useInviteStore();
const {
inviteMessageTable,
inviteResponseMessageTable,
inviteRequestMessageTable,
inviteRequestResponseMessageTable
} = storeToRefs(useInviteStore());
const { showGalleryDialog } = useGalleryStore();
const { menuActiveIndex } = storeToRefs(useUiStore());
const { directAccessWorld } = useSearchStore();
const { logout } = useAuthStore();
const { cachedConfig } = storeToRefs(useAuthStore());
const { t } = useI18n();
const { $prompt, $message } = getCurrentInstance().proxy;
const API = inject('API');
const userImage = inject('userImage');
const showUserDialog = inject('showUserDialog');
const showAvatarDialog = inject('showAvatarDialog');
const showGalleryDialog = inject('showGalleryDialog');
const openExternalLink = inject('openExternalLink');
const props = defineProps({
menuActiveIndex: {
type: String,
default: 'profile'
},
hideTooltips: {
type: Boolean,
default: false
},
inviteMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
inviteResponseMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
inviteRequestMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
inviteRequestResponseMessageTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
pastDisplayNameTable: {
type: Object,
default: () => ({
visible: false,
data: []
})
},
friends: {
type: Map,
default: () => new Map()
},
directAccessWorld: {
type: Function,
default: () => {}
},
parseUserUrl: {
type: Function,
default: () => {}
}
});
const emit = defineEmits(['logout', 'lookupUser', 'showEditInviteMessageDialog']);
const vrchatCredit = ref(null);
const configTreeData = ref([]);
const currentUserTreeData = ref([]);
@@ -588,18 +552,13 @@
function getVisits() {
miscRequest.getVisits().then((args) => {
// API.$on('VISITS')
visits.value = args.json;
});
}
function getVRChatCredits() {
// API.$on('VRCCREDITS')
miscRequest.getVRChatCredits().then((args) => (vrchatCredit.value = args.json?.balance));
}
function logout() {
emit('logout');
}
function showDiscordNamesDialog() {
discordNamesDialogVisible.value = true;
@@ -621,7 +580,7 @@
inputErrorMessage: t('prompt.direct_access_username.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
emit('lookupUser', {
lookupUser({
displayName: instance.inputValue
});
}
@@ -639,7 +598,7 @@
if (action === 'confirm' && instance.inputValue) {
const testUrl = instance.inputValue.substring(0, 15);
if (testUrl === 'https://vrchat.') {
const userId = this.parseUserUrl(instance.inputValue);
const userId = parseUserUrl(instance.inputValue);
if (userId) {
showUserDialog(userId);
} else {
@@ -664,7 +623,7 @@
inputErrorMessage: t('prompt.direct_access_world_id.input_error'),
callback: (action, instance) => {
if (action === 'confirm' && instance.inputValue) {
if (!props.directAccessWorld(instance.inputValue)) {
if (!directAccessWorld(instance.inputValue)) {
$message({
message: t('prompt.direct_access_world_id.message.error'),
type: 'error'
@@ -685,7 +644,7 @@
if (action === 'confirm' && instance.inputValue) {
const testUrl = instance.inputValue.substring(0, 15);
if (testUrl === 'https://vrchat.') {
const avatarId = props.parseAvatarUrl(instance.inputValue);
const avatarId = parseAvatarUrl(instance.inputValue);
if (avatarId) {
showAvatarDialog(avatarId);
} else {
@@ -701,25 +660,21 @@
}
});
}
function showEditInviteMessageDialog(messageType, inviteMessage) {
emit('showEditInviteMessageDialog', messageType, inviteMessage);
}
function refreshInviteMessageTable(messageType) {
inviteMessagesRequest.refreshInviteMessageTableData(messageType);
async function getConfig() {
await authRequest.getConfig();
}
async function refreshConfigTreeData() {
await API.getConfig();
configTreeData.value = utils.buildTreeData(API.cachedConfig);
await getConfig();
configTreeData.value = buildTreeData(cachedConfig.value);
}
async function refreshCurrentUserTreeData() {
await API.getCurrentUser();
currentUserTreeData.value = utils.buildTreeData(API.currentUser);
await getCurrentUser();
currentUserTreeData.value = buildTreeData(currentUser.value);
}
function getCurrentUserFeedback() {
userRequest.getUserFeedback({ userId: API.currentUser.id }).then((args) => {
// API.$on('USER:FEEDBACK')
if (args.params.userId === API.currentUser.id) {
currentUserFeedbackData.value = utils.buildTreeData(args.json);
userRequest.getUserFeedback({ userId: currentUser.value.id }).then((args) => {
if (args.params.userId === currentUser.value.id) {
currentUserFeedbackData.value = buildTreeData(args.json);
}
});
}

View File

@@ -20,12 +20,13 @@
</template>
<script setup>
import { ref, watch, inject } from 'vue';
import { storeToRefs } from 'pinia';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const API = inject('API');
import { useUserStore } from '../../../stores';
const { t } = useI18n();
const { currentUser } = storeToRefs(useUserStore());
const props = defineProps({
discordNamesDialogVisible: {
@@ -52,7 +53,7 @@
const discordNamesContent = ref('');
function showDiscordNamesContent() {
const { friends } = API.currentUser;
const { friends } = currentUser.value;
if (Array.isArray(friends) === false) {
return;
}

View File

@@ -27,29 +27,23 @@
</template>
<script setup>
import { ref, watch, inject, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { getCurrentInstance, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { inviteMessagesRequest } from '../../../api';
import { useInviteStore } from '../../../stores';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const API = inject('API');
const props = defineProps({
editInviteMessageDialog: {
type: Object,
default: () => ({
visible: false,
newMessage: ''
})
}
});
const inviteStore = useInviteStore();
const { editInviteMessageDialog } = storeToRefs(inviteStore);
const message = ref('');
watch(
() => props.editInviteMessageDialog,
() => editInviteMessageDialog.value,
(newVal) => {
if (newVal && newVal.visible) {
message.value = newVal.newMessage;
@@ -58,10 +52,8 @@
{ deep: true }
);
const emit = defineEmits(['update:editInviteMessageDialog']);
function saveEditInviteMessage() {
const D = props.editInviteMessageDialog;
const D = editInviteMessageDialog.value;
D.visible = false;
if (D.inviteMessage.message !== message.value) {
const slot = D.inviteMessage.slot;
@@ -75,7 +67,6 @@
throw err;
})
.then((args) => {
API.$emit(`INVITE:${messageType.toUpperCase()}`, args);
if (args.json[slot].message === D.inviteMessage.message) {
$message({
message: "VRChat API didn't update message, try again",
@@ -91,6 +82,6 @@
}
function closeDialog() {
emit('update:editInviteMessageDialog', { ...props.editInviteMessageDialog, visible: false });
editInviteMessageDialog.value.visible = false;
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<safe-dialog :visible.sync="isVisible" :title="$t('dialog.export_own_avatars.header')" width="650px">
<safe-dialog :visible.sync="isVisible" :title="t('dialog.export_own_avatars.header')" width="650px">
<el-input
v-model="exportAvatarsListCsv"
v-loading="loading"
@@ -13,87 +13,94 @@
</safe-dialog>
</template>
<script>
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { avatarRequest } from '../../../api';
import { processBulk } from '../../../service/request';
import { useAvatarStore, useUserStore } from '../../../stores';
export default {
name: 'ExportAvatarsListDialog',
inject: ['API'],
props: {
isExportAvatarsListDialogVisible: Boolean
const { t } = useI18n();
const { cachedAvatars } = storeToRefs(useAvatarStore());
const { applyAvatar } = useAvatarStore();
const { currentUser } = storeToRefs(useUserStore());
const props = defineProps({
isExportAvatarsListDialogVisible: {
type: Boolean,
required: true
}
});
const exportAvatarsListCsv = ref('');
const loading = ref(false);
const isVisible = computed({
get() {
return props.isExportAvatarsListDialogVisible;
},
data() {
return {
exportAvatarsListCsv: '',
loading: false
};
},
computed: {
isVisible: {
get() {
return this.isExportAvatarsListDialogVisible;
},
set(value) {
this.$emit('update:is-export-avatars-list-dialog-visible', value);
}
}
},
watch: {
isExportAvatarsListDialogVisible(value) {
if (value) {
this.initExportAvatarsListDialog();
}
}
},
methods: {
initExportAvatarsListDialog() {
this.loading = true;
for (const ref of this.API.cachedAvatars.values()) {
if (ref.authorId === this.API.currentUser.id) {
this.API.cachedAvatars.delete(ref.id);
}
}
const params = {
n: 50,
offset: 0,
sort: 'updated',
order: 'descending',
releaseStatus: 'all',
user: 'me'
};
const map = new Map();
this.API.bulk({
fn: avatarRequest.getAvatars,
N: -1,
params,
handle: (args) => {
for (const json of args.json) {
const $ref = this.API.cachedAvatars.get(json.id);
if (typeof $ref !== 'undefined') {
map.set($ref.id, $ref);
}
}
},
done: () => {
const avatars = Array.from(map.values());
if (Array.isArray(avatars) === false) {
return;
}
const lines = ['AvatarID,AvatarName'];
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
for (const avatar of avatars) {
lines.push(`${_(avatar.id)},${_(avatar.name)}`);
}
this.exportAvatarsListCsv = lines.join('\n');
this.loading = false;
}
});
set(value) {
emit('update:isExportAvatarsListDialogVisible', value);
}
});
const emit = defineEmits(['update:isExportAvatarsListDialogVisible']);
watch(
() => props.isExportAvatarsListDialogVisible,
(value) => {
if (value) {
initExportAvatarsListDialog();
}
}
};
);
function initExportAvatarsListDialog() {
loading.value = true;
for (const ref of cachedAvatars.value.values()) {
if (ref.authorId === currentUser.value.id) {
cachedAvatars.value.delete(ref.id);
}
}
const params = {
n: 50,
offset: 0,
sort: 'updated',
order: 'descending',
releaseStatus: 'all',
user: 'me'
};
const map = new Map();
processBulk({
fn: avatarRequest.getAvatars,
N: -1,
params,
handle: (args) => {
for (const json of args.json) {
const ref = applyAvatar(json);
map.set(ref.id, ref);
}
},
done: () => {
const avatars = Array.from(map.values());
if (Array.isArray(avatars) === false) {
return;
}
const lines = ['AvatarID,AvatarName'];
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
for (const avatar of avatars) {
lines.push(`${_(avatar.id)},${_(avatar.name)}`);
}
exportAvatarsListCsv.value = lines.join('\n');
loading.value = false;
}
});
}
</script>

View File

@@ -1,7 +1,7 @@
<template>
<safe-dialog :title="$t('dialog.export_friends_list.header')" :visible.sync="isVisible" width="650px">
<safe-dialog :title="t('dialog.export_friends_list.header')" :visible.sync="isVisible" width="650px">
<el-tabs type="card">
<el-tab-pane :label="$t('dialog.export_friends_list.csv')">
<el-tab-pane :label="t('dialog.export_friends_list.csv')">
<el-input
v-model="exportFriendsListCsv"
type="textarea"
@@ -12,7 +12,7 @@
style="margin-top: 15px"
@click.native="$event.target.tagName === 'TEXTAREA' && $event.target.select()" />
</el-tab-pane>
<el-tab-pane :label="$t('dialog.export_friends_list.json')">
<el-tab-pane :label="t('dialog.export_friends_list.json')">
<el-input
v-model="exportFriendsListJson"
type="textarea"
@@ -27,61 +27,71 @@
</safe-dialog>
</template>
<script>
export default {
name: 'ExportFriendsListDialog',
inject: ['API'],
props: {
friends: Map,
isExportFriendsListDialogVisible: Boolean
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { storeToRefs } from 'pinia';
import { useUserStore } from '../../../stores';
const props = defineProps({
friends: {
type: Map,
required: true
},
data() {
return {
exportFriendsListCsv: '',
exportFriendsListJson: ''
};
isExportFriendsListDialogVisible: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['update:isExportFriendsListDialogVisible']);
const { currentUser } = storeToRefs(useUserStore());
const { t } = useI18n();
const exportFriendsListCsv = ref('');
const exportFriendsListJson = ref('');
const isVisible = computed({
get() {
return props.isExportFriendsListDialogVisible;
},
computed: {
isVisible: {
get() {
return this.isExportFriendsListDialogVisible;
},
set(value) {
this.$emit('update:is-export-friends-list-dialog-visible', value);
}
}
},
watch: {
isExportFriendsListDialogVisible(value) {
if (value) {
this.initExportFriendsListDialog();
}
}
},
methods: {
initExportFriendsListDialog() {
const { friends } = this.API.currentUser;
if (Array.isArray(friends) === false) {
return;
}
const lines = ['UserID,DisplayName,Memo'];
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const friendsList = [];
for (const userId of friends) {
const ref = this.friends.get(userId);
const name = (typeof ref !== 'undefined' && ref.name) || '';
const memo = (typeof ref !== 'undefined' && ref.memo.replace(/\n/g, ' ')) || '';
lines.push(`${_(userId)},${_(name)},${_(memo)}`);
friendsList.push(userId);
}
this.exportFriendsListJson = JSON.stringify({ friends: friendsList }, null, 4);
this.exportFriendsListCsv = lines.join('\n');
set(value) {
emit('update:isExportFriendsListDialogVisible', value);
}
});
watch(
() => props.isExportFriendsListDialogVisible,
(value) => {
if (value) {
initExportFriendsListDialog();
}
}
};
);
function initExportFriendsListDialog() {
const { friends } = currentUser.value;
if (Array.isArray(friends) === false) {
return;
}
const lines = ['UserID,DisplayName,Memo'];
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
const friendsList = [];
for (const userId of friends) {
const ref = props.friends.get(userId);
const name = (typeof ref !== 'undefined' && ref.name) || '';
const memo = (typeof ref !== 'undefined' && ref.memo.replace(/\n/g, ' ')) || '';
lines.push(`${_(userId)},${_(name)},${_(memo)}`);
friendsList.push(userId);
}
exportFriendsListJson.value = JSON.stringify({ friends: friendsList }, null, 4);
exportFriendsListCsv.value = lines.join('\n');
}
</script>

View File

@@ -13,7 +13,7 @@
icon="el-icon-delete"
circle
style="flex: none; margin-left: 10px"
@click="clearSearch"></el-button>
@click="handleClearSearch"></el-button>
</el-tooltip>
</div>
<el-tabs ref="searchTabRef" type="card" style="margin-top: 15px" @tab-click="searchText = ''">
@@ -55,14 +55,14 @@
:disabled="!searchUserParams.offset"
icon="el-icon-back"
size="small"
@click="moreSearchUser(-1)"
@click="handleMoreSearchUser(-1)"
>{{ t('view.search.prev_page') }}</el-button
>
<el-button
:disabled="searchUserResults.length < 10"
icon="el-icon-right"
size="small"
@click="moreSearchUser(1)"
@click="handleMoreSearchUser(1)"
>{{ t('view.search.next_page') }}</el-button
>
</el-button-group>
@@ -81,7 +81,7 @@
></el-button>
<el-dropdown-menu v-slot="dropdown">
<el-dropdown-item
v-for="row in API.cachedConfig.dynamicWorldRows"
v-for="row in cachedConfig.dynamicWorldRows"
:key="row.index"
:command="row"
v-text="row.name"></el-dropdown-item>
@@ -313,79 +313,51 @@
</div>
</template>
<script>
export default {
name: 'SearchTab'
};
</script>
<script setup>
import { inject, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { groupRequest, worldRequest } from '../../api';
import utils from '../../classes/utils';
import { convertFileUrlToImageUrl } from '../../composables/shared/utils';
import {
compareByCreatedAt,
compareByName,
compareByUpdatedAt,
convertFileUrlToImageUrl,
replaceBioSymbols,
userImage
} from '../../shared/utils';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useAuthStore,
useAvatarProviderStore,
useAvatarStore,
useGroupStore,
useSearchStore,
useUiStore,
useUserStore,
useWorldStore
} from '../../stores';
const { hideTooltips, randomUserColours } = storeToRefs(useAppearanceSettingsStore());
const { avatarRemoteDatabase } = storeToRefs(useAdvancedSettingsStore());
const { avatarRemoteDatabaseProviderList, avatarRemoteDatabaseProvider } = storeToRefs(useAvatarProviderStore());
const { setAvatarProvider } = useAvatarProviderStore();
const { userDialog } = storeToRefs(useUserStore());
const { showUserDialog, refreshUserDialogAvatars } = useUserStore();
const { showAvatarDialog, lookupAvatars } = useAvatarStore();
const { cachedAvatars } = storeToRefs(useAvatarStore());
const { cachedWorlds } = storeToRefs(useWorldStore());
const { showWorldDialog } = useWorldStore();
const { showGroupDialog, applyGroup } = useGroupStore();
const { cachedGroups } = storeToRefs(useGroupStore());
const { menuActiveIndex } = storeToRefs(useUiStore());
const { searchText, searchUserResults } = storeToRefs(useSearchStore());
const { clearSearch, moreSearchUser } = useSearchStore();
const { cachedConfig } = storeToRefs(useAuthStore());
const { t } = useI18n();
const API = inject('API');
const showUserDialog = inject('showUserDialog');
const userImage = inject('userImage');
const showWorldDialog = inject('showWorldDialog');
const showAvatarDialog = inject('showAvatarDialog');
const showGroupDialog = inject('showGroupDialog');
const props = defineProps({
menuActiveIndex: {
type: String,
default: ''
},
searchText: {
type: String,
default: ''
},
searchUserResults: {
type: Array,
default: () => []
},
randomUserColours: {
type: Boolean,
default: false
},
avatarRemoteDatabaseProviderList: {
type: Array,
default: () => []
},
avatarRemoteDatabaseProvider: {
type: String,
default: ''
},
hideTooltips: {
type: Boolean,
default: false
},
userDialog: {
type: Object,
default: () => ({})
},
lookupAvatars: {
type: Function,
default: () => () => {}
},
avatarRemoteDatabase: {
type: Boolean,
default: false
}
});
const emit = defineEmits([
'clearSearch',
'setAvatarProvider',
'refreshUserDialogAvatars',
'moreSearchUser',
'update:searchText'
]);
const searchTabRef = ref(null);
const searchUserParams = ref({});
@@ -416,7 +388,7 @@
return convertFileUrlToImageUrl(url);
}
function clearSearch() {
function handleClearSearch() {
searchUserParams.value = {};
searchWorldParams.value = {};
searchWorldResults.value = [];
@@ -425,11 +397,11 @@
searchAvatarPageNum.value = 0;
searchGroupParams.value = {};
searchGroupResults.value = [];
emit('clearSearch');
clearSearch();
}
function updateSearchText(text) {
emit('update:searchText', text);
searchText.value = text;
}
function search() {
@@ -453,16 +425,19 @@
searchUserParams.value = {
n: 10,
offset: 0,
search: props.searchText,
search: searchText.value,
customFields: searchUserByBio.value ? 'bio' : 'displayName',
sort: searchUserSortByLastLoggedIn.value ? 'last_login' : 'relevance'
};
await moreSearchUser();
await handleMoreSearchUser();
}
async function moreSearchUser(go = null) {
emit('moreSearchUser', go, searchUserParams.value);
async function handleMoreSearchUser(go = null) {
isSearchUserLoading.value = true;
await moreSearchUser(go, searchUserParams.value);
isSearchUserLoading.value = false;
}
function searchWorld(ref) {
searchWorldOption.value = '';
const params = {
@@ -508,7 +483,7 @@
break;
default:
params.sort = 'relevance';
params.search = utils.replaceBioSymbols(props.searchText);
params.search = replaceBioSymbols(searchText.value);
break;
}
params.order = ref.sortOrder || 'descending';
@@ -548,7 +523,7 @@
.then((args) => {
const map = new Map();
for (const json of args.json) {
const ref = API.cachedWorlds.get(json.id);
const ref = cachedWorlds.value.get(json.id);
if (typeof ref !== 'undefined') {
map.set(ref.id, ref);
}
@@ -558,13 +533,6 @@
});
}
function setAvatarProvider(provider) {
emit('setAvatarProvider', provider);
}
function refreshUserDialogAvatars(fileId) {
emit('refreshUserDialogAvatars', fileId);
}
async function searchAvatar() {
let ref;
isSearchAvatarLoading.value = true;
@@ -581,10 +549,10 @@
searchAvatarSort.value = 'name';
}
const avatars = new Map();
const query = props.searchText;
const query = searchText.value;
const queryUpper = query.toUpperCase();
if (!query) {
for (ref of API.cachedAvatars.values()) {
for (ref of cachedAvatars.value.values()) {
switch (searchAvatarFilter.value) {
case 'all':
avatars.set(ref.id, ref);
@@ -604,7 +572,7 @@
isSearchAvatarLoading.value = false;
} else {
if (searchAvatarFilterRemote.value === 'all' || searchAvatarFilterRemote.value === 'local') {
for (ref of API.cachedAvatars.values()) {
for (ref of cachedAvatars.value.values()) {
let match = ref.name.toUpperCase().includes(queryUpper);
if (!match && ref.description) {
match = ref.description.toUpperCase().includes(queryUpper);
@@ -633,10 +601,10 @@
}
if (
(searchAvatarFilterRemote.value === 'all' || searchAvatarFilterRemote.value === 'remote') &&
props.avatarRemoteDatabase &&
avatarRemoteDatabase.value &&
query.length >= 3
) {
const data = await props.lookupAvatars('search', query);
const data = await lookupAvatars('search', query);
if (data && typeof data === 'object') {
data.forEach((avatar) => {
avatars.set(avatar.id, avatar);
@@ -649,13 +617,13 @@
if (searchAvatarFilterRemote.value === 'local') {
switch (searchAvatarSort.value) {
case 'updated':
avatarsArray.sort(utils.compareByUpdatedAt);
avatarsArray.sort(compareByUpdatedAt);
break;
case 'created':
avatarsArray.sort(utils.compareByCreatedAt);
avatarsArray.sort(compareByCreatedAt);
break;
case 'name':
avatarsArray.sort(utils.compareByName);
avatarsArray.sort(compareByName);
break;
}
}
@@ -679,7 +647,7 @@
searchGroupParams.value = {
n: 10,
offset: 0,
query: utils.replaceBioSymbols(props.searchText)
query: replaceBioSymbols(searchText.value)
};
await moreSearchGroup();
}
@@ -698,22 +666,10 @@
isSearchGroupLoading.value = false;
})
.then((args) => {
// API.$on('GROUP:SEARCH', function (args) {
for (const json of args.json) {
API.$emit('GROUP', {
json,
params: {
groupId: json.id
}
});
}
// });
const map = new Map();
for (const json of args.json) {
const ref = API.cachedGroups.get(json.id);
if (typeof ref !== 'undefined') {
map.set(ref.id, ref);
}
const ref = applyGroup(json);
map.set(ref.id, ref);
}
searchGroupResults.value = Array.from(map.values());
return args;

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@
<el-input
v-for="(provider, index) in avatarRemoteDatabaseProviderList"
:key="index"
v-model="avatarRemoteDatabaseProviderList[index]"
:value="provider"
size="small"
style="margin-top: 5px"
@@ -25,34 +24,25 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { useAvatarProviderStore } from '../../../stores';
const { t } = useI18n();
const avatarProviderStore = useAvatarProviderStore();
const { avatarRemoteDatabaseProviderList } = storeToRefs(avatarProviderStore);
const { saveAvatarProviderList, removeAvatarProvider } = avatarProviderStore;
defineProps({
avatarRemoteDatabaseProviderList: {
type: Array,
required: true
},
isAvatarProviderDialogVisible: {
type: Boolean,
required: true
}
});
const emit = defineEmits([
'update:isAvatarProviderDialogVisible',
'update:avatarRemoteDatabaseProviderList',
'saveAvatarProviderList',
'removeAvatarProvider'
]);
function saveAvatarProviderList() {
emit('saveAvatarProviderList');
}
function removeAvatarProvider(provider) {
emit('removeAvatarProvider', provider);
}
const emit = defineEmits(['update:isAvatarProviderDialogVisible']);
function closeDialog() {
emit('update:isAvatarProviderDialogVisible', false);

View File

@@ -5,10 +5,11 @@
:title="t('dialog.change_log.header')"
width="800px"
top="5vh"
append-to-body
@close="closeDialog">
<div v-if="changeLogDialog.visible" class="changelog-dialog">
<div v-loading="!changeLogDialog.changeLog" class="changelog-dialog">
<h2 v-text="changeLogDialog.buildName"></h2>
<span>
<span v-show="changeLogDialog.buildName">
{{ t('dialog.change_log.description') }}
<a class="x-link" @click="openExternalLink('https://www.patreon.com/Natsumi_VRCX')">Patreon</a>,
<a class="x-link" @click="openExternalLink('https://ko-fi.com/natsumi_sama')">Ko-fi</a>.
@@ -33,22 +34,21 @@
</template>
<script setup>
import { inject } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { openExternalLink } from '../../../shared/utils';
import { useVRCXUpdaterStore } from '../../../stores';
const VueMarkdown = () => import('vue-markdown');
const VRCXUpdaterStore = useVRCXUpdaterStore();
const { changeLogDialog } = storeToRefs(VRCXUpdaterStore);
const { t } = useI18n();
const openExternalLink = inject('openExternalLink');
const props = defineProps({
changeLogDialog: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:changeLogDialog']);
function closeDialog() {
emit('update:changeLogDialog', { ...props.changeLogDialog, visible: false });
changeLogDialog.value.visible = false;
}
</script>

View File

@@ -28,7 +28,7 @@
</el-radio-group>
</div>
<template v-if="props.photonLoggingEnabled">
<template v-if="photonLoggingEnabled">
<br />
<div class="toggle-item">
<span class="toggle-name">Photon Event Logging</span>
@@ -59,38 +59,25 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
import { feedFiltersOptions } from '../../../composables/setting/constants/feedFiltersOptions';
import { feedFiltersOptions, sharedFeedFiltersDefaults } from '../../../shared/constants';
import { useNotificationsSettingsStore, usePhotonStore, useSharedFeedStore } from '../../../stores';
const { t } = useI18n();
const { photonLoggingEnabled } = storeToRefs(usePhotonStore());
const { notyFeedFiltersOptions, wristFeedFiltersOptions, photonFeedFiltersOptions } = feedFiltersOptions();
const { sharedFeedFilters } = storeToRefs(useNotificationsSettingsStore());
const { updateSharedFeed } = useSharedFeedStore();
const props = defineProps({
feedFiltersDialogMode: {
type: String,
required: true,
default: ''
},
photonLoggingEnabled: {
type: Boolean,
default: false
},
sharedFeedFilters: {
type: Object,
default: () => ({
noty: {},
wrist: {}
})
},
sharedFeedFiltersDefaults: {
type: Object,
default: () => ({
noty: {},
wrist: {}
})
}
});
@@ -100,8 +87,8 @@
const currentSharedFeedFilters = computed(() => {
return props.feedFiltersDialogMode === 'noty'
? props.sharedFeedFilters['noty']
: props.sharedFeedFilters['wrist'];
? sharedFeedFilters.value['noty']
: sharedFeedFilters.value['wrist'];
});
const dialogTitle = computed(() => {
@@ -116,23 +103,23 @@
return props.feedFiltersDialogMode === 'noty' ? resetNotyFeedFilters : resetWristFeedFilters;
});
const emit = defineEmits(['update:feedFiltersDialogMode', 'updateSharedFeed']);
const emit = defineEmits(['update:feedFiltersDialogMode']);
function saveSharedFeedFilters() {
configRepository.setString('sharedFeedFilters', JSON.stringify(props.sharedFeedFilters));
emit('updateSharedFeed', true);
configRepository.setString('sharedFeedFilters', JSON.stringify(sharedFeedFilters.value));
updateSharedFeed(true);
}
function resetNotyFeedFilters() {
props.sharedFeedFilters.noty = {
...props.sharedFeedFiltersDefaults.noty
sharedFeedFilters.value.noty = {
...sharedFeedFiltersDefaults.noty
};
saveSharedFeedFilters();
}
async function resetWristFeedFilters() {
props.sharedFeedFilters.wrist = {
...props.sharedFeedFiltersDefaults.wrist
sharedFeedFilters.value.wrist = {
...sharedFeedFiltersDefaults.wrist
};
saveSharedFeedFilters();
}

View File

@@ -56,33 +56,28 @@
</template>
<script setup>
import { ref, inject, getCurrentInstance } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, getCurrentInstance, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
const openExternalLink = inject('openExternalLink');
const isLinux = inject('isLinux');
import { openExternalLink } from '../../../shared/utils';
import { useLaunchStore } from '../../../stores';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
defineProps({
isLaunchOptionsDialogVisible: {
type: Boolean,
default: false,
required: true
}
});
const emit = defineEmits(['update:isLaunchOptionsDialogVisible']);
const launchStore = useLaunchStore();
const { isLaunchOptionsDialogVisible } = storeToRefs(launchStore);
const launchOptionsDialog = ref({
launchArguments: '',
vrcLaunchPathOverride: ''
});
const isLinux = computed(() => LINUX);
function init() {
configRepository
.getString('launchArguments')
@@ -125,6 +120,6 @@
}
function closeDialog() {
emit('update:isLaunchOptionsDialogVisible');
isLaunchOptionsDialogVisible.value = false;
}
</script>

View File

@@ -88,26 +88,23 @@
</template>
<script setup>
import { ref, watch, inject } from 'vue';
import { storeToRefs } from 'pinia';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../../classes/utils';
import * as workerTimers from 'worker-timers';
import { miscRequest } from '../../../api';
import { removeFromArray, userImage, userImageFull } from '../../../shared/utils';
import { useFriendStore, useGalleryStore, useUserStore } from '../../../stores';
const { t } = useI18n();
const userImage = inject('userImage');
const userImageFull = inject('userImageFull');
const showUserDialog = inject('showUserDialog');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const { friends } = storeToRefs(useFriendStore());
const { showUserDialog } = useUserStore();
const { showFullscreenImageDialog } = useGalleryStore();
const props = defineProps({
isNoteExportDialogVisible: {
type: Boolean
},
friends: {
type: Map,
default: () => new Map()
}
});
@@ -146,7 +143,7 @@
function updateNoteExportDialog() {
const data = [];
props.friends.forEach((ctx) => {
friends.value.forEach((ctx) => {
const newMemo = ctx.memo.replace(/[\r\n]/g, ' ');
if (ctx.memo && ctx.ref && ctx.ref.note !== newMemo.slice(0, 256)) {
data.push({
@@ -174,7 +171,7 @@
targetUserId: ctx.id,
note: ctx.memo.slice(0, 256)
});
utils.removeFromArray(noteExportTable.value.data, ctx);
removeFromArray(noteExportTable.value.data, ctx);
progress.value++;
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 5000);
@@ -195,7 +192,7 @@
}
function removeFromNoteExportTable(ref) {
utils.removeFromArray(noteExportTable.value.data, ref);
removeFromArray(noteExportTable.value.data, ref);
}
function closeDialog() {

View File

@@ -46,27 +46,25 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { useNotificationsSettingsStore } from '../../../stores';
const { t } = useI18n();
const notificationsSettingsStore = useNotificationsSettingsStore();
const { notificationPosition } = storeToRefs(notificationsSettingsStore);
const { changeNotificationPosition } = notificationsSettingsStore;
defineProps({
isNotificationPositionDialogVisible: {
type: Boolean,
default: false
},
notificationPosition: {
type: String,
default: 'topRight'
}
});
const emit = defineEmits(['update:isNotificationPositionDialogVisible', 'changeNotificationPosition']);
const emit = defineEmits(['update:isNotificationPositionDialogVisible']);
function closeDialog() {
emit('update:isNotificationPositionDialogVisible', false);
}
function changeNotificationPosition(value) {
emit('changeNotificationPosition', value);
}
</script>

View File

@@ -20,7 +20,7 @@
<script setup>
import { useI18n } from 'vue-i18n-bridge';
import { openSourceSoftwareLicenses } from '../../../composables/setting/constants/openSourceSoftwareLicenses';
import { openSourceSoftwareLicenses } from '../../../shared/constants';
const { t } = useI18n();

View File

@@ -32,7 +32,7 @@
enablePrimaryPasswordDialog.password.length === 0 ||
enablePrimaryPasswordDialog.password !== enablePrimaryPasswordDialog.rePassword
"
@click="setPrimaryPassword">
@click="handleSetPrimaryPassword()">
{{ t('dialog.primary_password.ok') }}
</el-button>
</template>
@@ -40,20 +40,18 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n-bridge';
import { useAuthStore } from '../../../stores';
const { t } = useI18n();
const props = defineProps({
enablePrimaryPasswordDialog: {
type: Object,
required: true
}
});
const authStore = useAuthStore();
const { enablePrimaryPasswordDialog } = storeToRefs(authStore);
const { setPrimaryPassword } = authStore;
const emit = defineEmits(['setPrimaryPassword']);
function setPrimaryPassword() {
emit('setPrimaryPassword', props.enablePrimaryPasswordDialog.password);
props.enablePrimaryPasswordDialog.visible = false;
function handleSetPrimaryPassword() {
setPrimaryPassword(enablePrimaryPasswordDialog.value.password);
enablePrimaryPasswordDialog.value.visible = false;
}
</script>

View File

@@ -9,13 +9,13 @@
<div style="margin-top: 10px">
<div style="display: flex; align-items: center; justify-content: space-between; font-size: 12px">
<span class="name" style="margin-right: 24px">{{ t('dialog.registry_backup.auto_backup') }}</span>
<el-switch v-model="vrcRegistryAutoBackup" @change="saveVrcRegistryAutoBackup"></el-switch>
<el-switch v-model="vrcRegistryAutoBackup" @change="setVrcRegistryAutoBackup"></el-switch>
</div>
<data-tables v-bind="registryBackupTable" style="margin-top: 10px">
<el-table-column :label="t('dialog.registry_backup.name')" prop="name"></el-table-column>
<el-table-column :label="t('dialog.registry_backup.date')" prop="date">
<template #default="scope">
<span>{{ scope.row.date | formatDate('long') }}</span>
<span>{{ formatDateFilter(scope.row.date, 'long') }}</span>
</template>
</el-table-column>
<el-table-column :label="t('dialog.registry_backup.action')" width="90" align="right">
@@ -71,32 +71,25 @@
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { getCurrentInstance, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../../classes/utils';
import { downloadAndSaveJson } from '../../../composables/shared/utils';
import configRepository from '../../../service/config';
import { downloadAndSaveJson, removeFromArray, formatDateFilter } from '../../../shared/utils';
import { useAppearanceSettingsStore, useVrcxStore, useAdvancedSettingsStore } from '../../../stores';
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { backupVrcRegistry } = useVrcxStore();
const { isRegistryBackupDialogVisible } = storeToRefs(useVrcxStore());
const { vrcRegistryAutoBackup } = storeToRefs(useAdvancedSettingsStore());
const { setVrcRegistryAutoBackup } = useAdvancedSettingsStore();
const { t } = useI18n();
const instance = getCurrentInstance();
const { $confirm, $message, $prompt } = instance.proxy;
const props = defineProps({
isRegistryBackupDialogVisible: {
type: Boolean
},
hideTooltips: {
type: Boolean,
default: false
},
backupVrcRegistry: {
type: Function
}
});
const emit = defineEmits(['update:isRegistryBackupDialogVisible']);
const registryBackupTable = ref({
data: [],
tableProps: {
@@ -110,10 +103,8 @@
layout: 'table'
});
const vrcRegistryAutoBackup = ref(false);
watch(
() => props.isRegistryBackupDialogVisible,
() => isRegistryBackupDialogVisible.value,
(newVal) => {
if (newVal) {
updateRegistryBackupDialog();
@@ -121,23 +112,11 @@
}
);
setVrcRegistryAutoBackup();
function setVrcRegistryAutoBackup() {
configRepository.getBool('VRCX_vrcRegistryAutoBackup', true).then((value) => {
vrcRegistryAutoBackup.value = value;
});
}
async function updateRegistryBackupDialog() {
let backupsJson = await configRepository.getString('VRCX_VRChatRegistryBackups');
const backupsJson = await configRepository.getString('VRCX_VRChatRegistryBackups');
registryBackupTable.value.data = JSON.parse(backupsJson || '[]');
}
async function saveVrcRegistryAutoBackup() {
await configRepository.setBool('VRCX_vrcRegistryAutoBackup', vrcRegistryAutoBackup.value);
}
function restoreVrcRegistryBackup(row) {
$confirm('Continue? Restore Backup', 'Confirm', {
confirmButtonText: 'Confirm',
@@ -172,7 +151,7 @@
async function deleteVrcRegistryBackup(row) {
const backups = registryBackupTable.value.data;
utils.removeFromArray(backups, row);
removeFromArray(backups, row);
await configRepository.setString('VRCX_VRChatRegistryBackups', JSON.stringify(backups));
await updateRegistryBackupDialog();
}
@@ -197,7 +176,7 @@
}
async function handleBackupVrcRegistry(name) {
await props.backupVrcRegistry(name);
await backupVrcRegistry(name);
await updateRegistryBackupDialog();
}
@@ -295,6 +274,6 @@
}
function closeDialog() {
emit('update:isRegistryBackupDialogVisible', false);
isRegistryBackupDialogVisible.value = false;
}
</script>

View File

@@ -36,7 +36,7 @@
>{{ t('dialog.screenshot_metadata.open_folder') }}</el-button
>
<el-button
v-if="API.currentUser.$isVRCPlus && screenshotMetadataDialog.metadata.filePath"
v-if="currentUser.$isVRCPlus && screenshotMetadataDialog.metadata.filePath"
size="small"
icon="el-icon-upload2"
@click="uploadScreenshotToGallery"
@@ -81,7 +81,7 @@
<br />
</template>
<span v-if="screenshotMetadataDialog.metadata.dateTime" style="margin-right: 5px">{{
screenshotMetadataDialog.metadata.dateTime | formatDate('long')
formatDateFilter(screenshotMetadataDialog.metadata.dateTime, 'long')
}}</span>
<span
v-if="screenshotMetadataDialog.metadata.fileResolution"
@@ -91,11 +91,11 @@
screenshotMetadataDialog.metadata.fileSize
}}</el-tag>
<br />
<location
<Location
v-if="screenshotMetadataDialog.metadata.world"
:location="screenshotMetadataDialog.metadata.world.instanceId"
:hint="screenshotMetadataDialog.metadata.world.name" />
<display-name
<DisplayName
v-if="screenshotMetadataDialog.metadata.author"
:userid="screenshotMetadataDialog.metadata.author.id"
:hint="screenshotMetadataDialog.metadata.author.displayName"
@@ -162,36 +162,34 @@
</template>
<script setup>
import { ref, inject, getCurrentInstance, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { getCurrentInstance, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { vrcPlusImageRequest } from '../../../api';
import Location from '../../../components/Location.vue';
import { useGalleryStore, useUserStore, useVrcxStore } from '../../../stores';
import { formatDateFilter } from '../../../shared/utils';
const API = inject('API');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const { showFullscreenImageDialog, handleGalleryImageAdd } = useGalleryStore();
const { currentlyDroppingFile } = storeToRefs(useVrcxStore());
const { currentUser } = storeToRefs(useUserStore());
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const userStore = useUserStore();
const { lookupUser } = userStore;
const { fullscreenImageDialog } = storeToRefs(useGalleryStore());
const props = defineProps({
screenshotMetadataDialog: {
type: Object,
required: true
},
currentlyDroppingFile: {
type: String,
default: null
},
fullscreenImageDialog: {
type: Object,
default: null
}
});
const emit = defineEmits(['lookupUser']);
watch(
() => props.screenshotMetadataDialog.visible,
(newVal) => {
@@ -217,13 +215,13 @@
};
function handleDrop(event) {
if (props.currentlyDroppingFile === null) {
if (currentlyDroppingFile.value === null) {
return;
}
console.log('Dropped file into viewer: ', props.currentlyDroppingFile);
console.log('Dropped file into viewer: ', currentlyDroppingFile.value);
screenshotMetadataResetSearch();
getAndDisplayScreenshot(props.currentlyDroppingFile);
getAndDisplayScreenshot(currentlyDroppingFile.value);
event.preventDefault();
}
@@ -296,9 +294,7 @@
vrcPlusImageRequest
.uploadGalleryImage(base64Body)
.then((args) => {
// about uploadGalleryImage -> emit 'GALLERYIMAGE:ADD'
// no need to add to the gallery logic here
// because it refreshes when you open the gallery
handleGalleryImageAdd(args);
$message({
message: t('message.gallery.uploaded'),
type: 'success'
@@ -393,13 +389,10 @@
screenshotMetadataCarouselRef.value.setActiveItem(1);
}
if (props.fullscreenImageDialog.visible) {
if (fullscreenImageDialog.value.visible) {
// TODO
}
}
function lookupUser(user) {
emit('lookupUser', user);
}
function screenshotMetadataResetSearch() {
const D = props.screenshotMetadataDialog;
@@ -505,7 +498,7 @@
D.metadata.dateTime = Date.parse(metadata.creationDate);
}
if (props.fullscreenImageDialog?.visible) {
if (fullscreenImageDialog.value.visible) {
showFullscreenImageDialog(D.metadata.filePath);
}
}

View File

@@ -60,6 +60,7 @@
:type="item.type ? item.type : 'text'"
:min="item.min"
:max="item.max"
@input="refreshDialogValues"
style="flex: 1; margin-top: 5px"
><el-button
v-if="item.folderBrowser"
@@ -186,13 +187,18 @@
</template>
<script setup>
import { computed, getCurrentInstance, inject, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { computed, getCurrentInstance, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import {
VRChatCameraResolutions,
VRChatScreenshotResolutions
} from '../../../composables/setting/constants/vrchatResolutions';
import { getVRChatResolution } from '../../../composables/setting/utils';
import { VRChatCameraResolutions, VRChatScreenshotResolutions } from '../../../shared/constants';
import { getVRChatResolution, openExternalLink } from '../../../shared/utils';
import { useAdvancedSettingsStore, useAppearanceSettingsStore, useGameStore } from '../../../stores';
const { hideTooltips } = storeToRefs(useAppearanceSettingsStore());
const { VRChatUsedCacheSize, VRChatTotalCacheSize, VRChatCacheSizeLoading } = storeToRefs(useGameStore());
const { sweepVRChatCache, getVRChatCacheSize } = useGameStore();
const { folderSelectorDialog } = useAdvancedSettingsStore();
const { isVRChatConfigDialogVisible } = storeToRefs(useAdvancedSettingsStore());
const { t } = useI18n();
@@ -200,37 +206,6 @@
const $confirm = instance.proxy.$confirm;
const $message = instance.proxy.$message;
const openExternalLink = inject('openExternalLink');
const props = defineProps({
isVRChatConfigDialogVisible: {
type: Boolean,
required: true
},
VRChatUsedCacheSize: {
type: [String, Number],
required: true
},
VRChatTotalCacheSize: {
type: [String, Number],
required: true
},
VRChatCacheSizeLoading: {
type: Boolean,
required: true
},
folderSelectorDialog: {
type: Function,
required: true
},
hideTooltips: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:isVRChatConfigDialogVisible', 'getVRChatCacheSize', 'sweepVRChatCache']);
const VRChatConfigFile = ref({});
// it's a object
const VRChatConfigList = ref({
@@ -281,7 +256,7 @@
const loading = ref(false);
watch(
() => props.isVRChatConfigDialogVisible,
() => isVRChatConfigDialogVisible.value,
async (newValue) => {
if (newValue) {
loading.value = true;
@@ -292,13 +267,9 @@
);
const totalCacheSize = computed(() => {
return VRChatConfigFile.value.cache_size || props.VRChatTotalCacheSize;
return VRChatConfigFile.value.cache_size || VRChatTotalCacheSize.value;
});
function getVRChatCacheSize() {
emit('getVRChatCacheSize');
}
function showDeleteAllVRChatCacheConfirm() {
$confirm(`Continue? Delete all VRChat cache`, 'Confirm', {
confirmButtonText: 'Confirm',
@@ -317,15 +288,12 @@
getVRChatCacheSize();
}
function sweepVRChatCache() {
emit('sweepVRChatCache');
}
async function openConfigFolderBrowser(value) {
const oldPath = VRChatConfigFile.value[value];
const newPath = await props.folderSelectorDialog(oldPath);
const newPath = await folderSelectorDialog(oldPath);
if (newPath) {
VRChatConfigFile.value[value] = newPath;
refreshDialogValues();
}
}
@@ -420,6 +388,6 @@
}
function closeDialog() {
emit('update:isVRChatConfigDialogVisible', false);
isVRChatConfigDialogVisible.value = false;
}
</script>

View File

@@ -8,13 +8,12 @@
<div style="font-size: 12px">{{ t('dialog.youtube_api.description') }} <br /></div>
<el-input
:value="youTubeApiKey"
v-model="youTubeApiKey"
type="textarea"
:placeholder="t('dialog.youtube_api.placeholder')"
maxlength="39"
show-word-limit
style="display: block; margin-top: 10px"
@input="updateYouTubeApiKey">
style="display: block; margin-top: 10px">
</el-input>
<template #footer>
@@ -33,52 +32,51 @@
</template>
<script setup>
import { inject, getCurrentInstance } from 'vue';
import configRepository from '../../../service/config';
import { storeToRefs } from 'pinia';
import { getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { openExternalLink } from '../../../shared/utils';
import { useAdvancedSettingsStore } from '../../../stores';
const advancedSettingsStore = useAdvancedSettingsStore();
const { youTubeApiKey } = storeToRefs(advancedSettingsStore);
const { lookupYouTubeVideo, setYouTubeApiKey } = advancedSettingsStore;
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const openExternalLink = inject('openExternalLink');
const props = defineProps({
isYouTubeApiDialogVisible: {
type: Boolean,
default: false
},
lookupYouTubeVideo: {
type: Function,
default: () => {}
},
youTubeApiKey: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:isYouTubeApiDialogVisible', 'update:youTubeApiKey']);
const emit = defineEmits(['update:isYouTubeApiDialogVisible']);
async function testYouTubeApiKey() {
if (!props.youTubeApiKey) {
const previousKey = youTubeApiKey.value;
if (!youTubeApiKey.value) {
$message({
message: 'YouTube API key removed',
type: 'success'
});
await configRepository.setString('VRCX_youtubeAPIKey', '');
closeDialog();
return;
}
const data = await props.lookupYouTubeVideo('dQw4w9WgXcQ');
const data = await lookupYouTubeVideo('dQw4w9WgXcQ');
if (!data) {
updateYouTubeApiKey('');
setYouTubeApiKey(previousKey);
$message({
message: 'Invalid YouTube API key',
type: 'error'
});
} else {
await configRepository.setString('VRCX_youtubeAPIKey', props.youTubeApiKey);
setYouTubeApiKey(youTubeApiKey.value);
$message({
message: 'YouTube API key valid!',
type: 'success'
@@ -87,10 +85,6 @@
}
}
function updateYouTubeApiKey(value) {
emit('update:youTubeApiKey', value);
}
function closeDialog() {
emit('update:isYouTubeApiDialogVisible', false);
}

View File

@@ -1,382 +0,0 @@
<template>
<div class="x-friend-list" style="padding: 10px 5px">
<div
class="x-friend-group x-link"
style="padding: 0 0 5px"
@click="
isFriendsGroupMe = !isFriendsGroupMe;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isFriendsGroupMe }"></i>
<span style="margin-left: 5px">{{ $t('side_panel.me') }}</span>
</div>
<div v-show="isFriendsGroupMe">
<div class="x-friend-item" @click="showUserDialog(API.currentUser.id)">
<div class="avatar" :class="userStatusClass(API.currentUser)">
<img v-lazy="userImage(API.currentUser)" />
</div>
<div class="detail">
<span class="name" :style="{ color: API.currentUser.$userColour }">{{
API.currentUser.displayName
}}</span>
<location
v-if="isGameRunning && !gameLogDisabled"
class="extra"
:location="lastLocation.location"
:traveling="lastLocationDestination"
:link="false"></location>
<location
v-else-if="
isRealInstance(API.currentUser.$locationTag) ||
isRealInstance(API.currentUser.$travelingToLocation)
"
class="extra"
:location="API.currentUser.$locationTag"
:traveling="API.currentUser.$travelingToLocation"
:link="false">
</location>
<span v-else class="extra">{{ API.currentUser.statusDescription }}</span>
</div>
</div>
</div>
<div
v-show="vipFriendsDisplayNumber"
class="x-friend-group x-link"
@click="
isVIPFriends = !isVIPFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isVIPFriends }"></i>
<span style="margin-left: 5px">
{{ $t('side_panel.favorite') }} &horbar;
{{ vipFriendsDisplayNumber }}
</span>
</div>
<div v-show="isVIPFriends">
<template v-if="isSidebarDivideByFriendGroup">
<div v-for="group in vipFriendsDivideByGroup" :key="group[0].key">
<transition name="el-fade-in-linear">
<div v-show="group[0].groupName !== ''" style="margin-bottom: 3px">
<span class="extra">{{ group[0].groupName }}</span>
<span class="extra" style="margin-left: 5px">{{ `(${group.length})` }}</span>
</div>
</transition>
<div v-if="group.length" style="margin-bottom: 10px">
<friend-item
v-for="friend in group"
:key="friend.id"
:friend="friend"
:hide-nicknames="hideNicknames"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)"></friend-item>
</div>
</div>
</template>
<friend-item
v-for="friend in vipFriendsByGroupStatus"
v-else
:key="friend.id"
:friend="friend"
:hide-nicknames="hideNicknames"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)">
</friend-item>
</div>
<template v-if="isSidebarGroupByInstance && friendsInSameInstance.length">
<div class="x-friend-group x-link" @click="toggleSwitchGroupByInstanceCollapsed">
<i class="el-icon-arrow-right" :class="{ rotate: !isSidebarGroupByInstanceCollapsed }"></i>
<span style="margin-left: 5px"
>{{ $t('side_panel.same_instance') }} &horbar; {{ friendsInSameInstance.length }}</span
>
</div>
<div v-show="!isSidebarGroupByInstanceCollapsed">
<div v-for="friendArr in friendsInSameInstance" :key="friendArr[0].ref.$location.tag">
<div style="margin-bottom: 3px">
<location
class="extra"
:location="getFriendsLocations(friendArr)"
style="display: inline"></location>
<span class="extra" style="margin-left: 5px">{{ `(${friendArr.length})` }}</span>
</div>
<div v-if="friendArr && friendArr.length">
<friend-item
v-for="(friend, idx) in friendArr"
:key="friend.id"
:friend="friend"
is-group-by-instance
:style="{ 'margin-bottom': idx === friendArr.length - 1 ? '5px' : undefined }"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)">
</friend-item>
</div>
</div>
</div>
</template>
<div
v-show="onlineFriendsByGroupStatus.length"
class="x-friend-group x-link"
@click="
isOnlineFriends = !isOnlineFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isOnlineFriends }"></i>
<span style="margin-left: 5px"
>{{ $t('side_panel.online') }} &horbar; {{ onlineFriendsByGroupStatus.length }}</span
>
</div>
<div v-show="isOnlineFriends">
<friend-item
v-for="friend in onlineFriendsByGroupStatus"
:key="friend.id"
:friend="friend"
:hide-nicknames="hideNicknames"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)" />
</div>
<div
v-show="activeFriends.length"
class="x-friend-group x-link"
@click="
isActiveFriends = !isActiveFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isActiveFriends }"></i>
<span style="margin-left: 5px">{{ $t('side_panel.active') }} &horbar; {{ activeFriends.length }}</span>
</div>
<div v-show="isActiveFriends">
<friend-item
v-for="friend in activeFriends"
:key="friend.id"
:friend="friend"
:hide-nicknames="hideNicknames"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)"></friend-item>
</div>
<div
v-show="offlineFriends.length"
class="x-friend-group x-link"
@click="
isOfflineFriends = !isOfflineFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isOfflineFriends }"></i>
<span style="margin-left: 5px">{{ $t('side_panel.offline') }} &horbar; {{ offlineFriends.length }}</span>
</div>
<div v-show="isOfflineFriends">
<friend-item
v-for="friend in offlineFriends"
:key="friend.id"
:friend="friend"
:hide-nicknames="hideNicknames"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)"></friend-item>
</div>
</div>
</template>
<script>
import FriendItem from '../../../components/FriendItem.vue';
import Location from '../../../components/Location.vue';
import { isRealInstance as _isRealInstance, parseLocation } from '../../../composables/instance/utils';
import configRepository from '../../../service/config';
export default {
name: 'FriendsSidebar',
components: {
FriendItem,
Location
},
inject: ['API', 'showUserDialog', 'userImage', 'userStatusClass'],
props: {
// settings
isGameRunning: Boolean,
isSidebarDivideByFriendGroup: Boolean,
isSidebarGroupByInstance: Boolean,
gameLogDisabled: Boolean,
hideNicknames: Boolean,
isHideFriendsInSameInstance: Boolean,
lastLocation: Object,
lastLocationDestination: String,
activeFriends: Array,
offlineFriends: Array,
vipFriends: Array,
onlineFriends: Array,
groupedByGroupKeyFavoriteFriends: Object
},
data() {
return {
isFriendsGroupMe: true,
isVIPFriends: true,
isOnlineFriends: true,
isActiveFriends: true,
isOfflineFriends: true,
isSidebarGroupByInstanceCollapsed: false
};
},
computed: {
friendsInSameInstance() {
const friendsList = {};
const allFriends = [...this.vipFriends, ...this.onlineFriends];
allFriends.forEach((friend) => {
if (!friend.ref?.$location) {
return;
}
let locationTag = friend.ref.$location.tag;
if (!friend.ref.$location.isRealInstance && this.lastLocation.friendList.has(friend.id)) {
locationTag = this.lastLocation.location;
}
const isRealInstance = this.isRealInstance(locationTag);
if (!isRealInstance) {
return;
}
if (!friendsList[locationTag]) {
friendsList[locationTag] = [];
}
friendsList[locationTag].push(friend);
});
const sortedFriendsList = [];
for (const group of Object.values(friendsList)) {
if (group.length > 1) {
sortedFriendsList.push(group.sort((a, b) => a.ref?.$location_at - b.ref?.$location_at));
}
}
return sortedFriendsList.sort((a, b) => b.length - a.length);
},
sameInstanceFriendId() {
const sameInstanceFriendId = new Set();
for (const item of this.friendsInSameInstance) {
for (const friend of item) {
if (this.isRealInstance(friend.ref?.$location.tag)) {
sameInstanceFriendId.add(friend.id);
}
}
}
return sameInstanceFriendId;
},
onlineFriendsByGroupStatus() {
if (!this.isSidebarGroupByInstance || !this.isHideFriendsInSameInstance) {
return this.onlineFriends;
}
return this.onlineFriends.filter((item) => !this.sameInstanceFriendId.has(item.id));
},
vipFriendsByGroupStatus() {
if (!this.isSidebarGroupByInstance || !this.isHideFriendsInSameInstance) {
return this.vipFriends;
}
return this.vipFriends.filter((item) => !this.sameInstanceFriendId.has(item.id));
},
// VIP friends divide by group
vipFriendsDivideByGroup() {
const vipFriendsByGroup = { ...this.groupedByGroupKeyFavoriteFriends };
const result = [];
for (const key in vipFriendsByGroup) {
if (Object.hasOwn(vipFriendsByGroup, key)) {
const groupFriends = vipFriendsByGroup[key];
// sort groupFriends using the order of vipFriends
// avoid unnecessary sorting
const filteredFriends = this.vipFriends.filter((friend) =>
groupFriends.some((item) => {
if (this.isSidebarGroupByInstance && this.isHideFriendsInSameInstance) {
return item.id === friend.id && !this.sameInstanceFriendId.has(item.id);
}
return item.id === friend.id;
})
);
if (filteredFriends.length > 0) {
const groupName =
this.API.favoriteFriendGroups.find((item) => item.key === key)?.displayName || '';
result.push(filteredFriends.map((item) => ({ groupName, key, ...item })));
}
}
}
return result.sort((a, b) => a[0].key.localeCompare(b[0].key));
},
vipFriendsDisplayNumber() {
return this.isSidebarDivideByFriendGroup
? this.vipFriendsDivideByGroup.length
: this.vipFriendsByGroupStatus.length;
}
},
created() {
this.loadFriendsGroupStates();
},
methods: {
saveFriendsGroupStates() {
configRepository.setBool('VRCX_isFriendsGroupMe', this.isFriendsGroupMe);
configRepository.setBool('VRCX_isFriendsGroupFavorites', this.isVIPFriends);
configRepository.setBool('VRCX_isFriendsGroupOnline', this.isOnlineFriends);
configRepository.setBool('VRCX_isFriendsGroupActive', this.isActiveFriends);
configRepository.setBool('VRCX_isFriendsGroupOffline', this.isOfflineFriends);
},
async loadFriendsGroupStates() {
this.isFriendsGroupMe = await configRepository.getBool('VRCX_isFriendsGroupMe', true);
this.isVIPFriends = await configRepository.getBool('VRCX_isFriendsGroupFavorites', true);
this.isOnlineFriends = await configRepository.getBool('VRCX_isFriendsGroupOnline', true);
this.isActiveFriends = await configRepository.getBool('VRCX_isFriendsGroupActive', false);
this.isOfflineFriends = await configRepository.getBool('VRCX_isFriendsGroupOffline', false);
this.isSidebarGroupByInstanceCollapsed = await configRepository.getBool(
'VRCX_sidebarGroupByInstanceCollapsed',
false
);
},
isRealInstance(locationTag) {
return _isRealInstance(locationTag);
},
toggleSwitchGroupByInstanceCollapsed() {
this.isSidebarGroupByInstanceCollapsed = !this.isSidebarGroupByInstanceCollapsed;
configRepository.setBool(
'VRCX_sidebarGroupByInstanceCollapsed',
this.isSidebarGroupByInstanceCollapsed
);
},
getFriendsLocations(friendsArr) {
// prevent the instance title display as "Traveling".
if (!friendsArr?.length) {
return '';
}
for (const friend of friendsArr) {
if (friend.ref?.location !== 'traveling') {
return friend.ref.location;
}
}
for (const friend of friendsArr) {
if (this.isRealInstance(friend.ref?.travelingToLocation)) {
return friend.ref.travelingToLocation;
}
}
for (const friend of friendsArr) {
if (this.lastLocation.friendList.has(friend.id)) {
return this.lastLocation.location;
}
}
return friendsArr[0].ref?.location;
}
}
};
</script>
<style scoped>
.x-link:hover {
text-decoration: none;
}
.x-link:hover span {
text-decoration: underline;
}
</style>

View File

@@ -1,136 +0,0 @@
<template>
<div class="x-friend-list" style="padding: 10px 5px">
<template v-for="(group, index) in groupedGroupInstances">
<div
:key="getGroupId(group)"
class="x-friend-group x-link"
:style="{ paddingTop: index === 0 ? '0px' : '10px' }">
<div @click="toggleGroupSidebarCollapse(getGroupId(group))" style="display: flex; align-items: center">
<i
class="el-icon-arrow-right"
:style="{
transform: groupInstancesCfg[getGroupId(group)].isCollapsed ? '' : 'rotate(90deg)',
transition: 'transform 0.3s'
}"></i>
<span style="margin-left: 5px">{{ group[0].group.name }} {{ group.length }}</span>
</div>
</div>
<template v-if="!groupInstancesCfg[getGroupId(group)].isCollapsed">
<div
v-for="ref in group"
:key="ref.instance.id"
class="x-friend-item"
@click="$emit('show-group-dialog', ref.instance.ownerId)">
<template v-if="isAgeGatedInstancesVisible || !(ref.ageGate || ref.location?.includes('~ageGate'))">
<div class="avatar">
<img v-lazy="getSmallGroupIconUrl(ref.group.iconUrl)" />
</div>
<div class="detail">
<span class="name">
<span v-text="ref.group.name"></span>
<span style="font-weight: normal; margin-left: 5px"
>({{ ref.instance.userCount }}/{{ ref.instance.capacity }})</span
>
</span>
<location class="extra" :location="ref.instance.location" :link="false" />
</div>
</template>
</div>
</template>
</template>
</div>
</template>
<script>
import Location from '../../../components/Location.vue';
import { convertFileUrlToImageUrl } from '../../../composables/shared/utils';
export default {
name: 'GroupsSidebar',
components: {
Location
},
props: {
groupInstances: {
type: Array,
default: () => []
},
groupOrder: {
type: Array,
default: () => []
},
isAgeGatedInstancesVisible: {
type: Boolean,
default: false
}
},
data() {
return {
// temporary, sort feat not yet done
// may be the data structure to be changed
groupInstancesCfg: []
};
},
computed: {
groupedGroupInstances() {
const groupMap = new Map();
this.groupInstances.forEach((ref) => {
const groupId = ref.group.groupId;
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId).push(ref);
if (!this.groupInstancesCfg[ref.group?.groupId]) {
this.groupInstancesCfg = {
[ref.group.groupId]: {
isCollapsed: false
},
...this.groupInstancesCfg
};
}
});
return Array.from(groupMap.values()).sort(this.sortGroupInstancesByInGame);
}
},
methods: {
getSmallGroupIconUrl(url) {
return convertFileUrlToImageUrl(url);
},
toggleGroupSidebarCollapse(groupId) {
this.groupInstancesCfg[groupId].isCollapsed = !this.groupInstancesCfg[groupId].isCollapsed;
},
showGroupDialog(ownerId) {
this.$emit('show-group-dialog', ownerId);
},
getGroupId(group) {
return group[0]?.group?.groupId || '';
},
sortGroupInstancesByInGame(a, b) {
var aIndex = this.groupOrder.indexOf(a[0]?.group?.id);
var bIndex = this.groupOrder.indexOf(b[0]?.group?.id);
if (aIndex === -1 && bIndex === -1) {
return 0;
}
if (aIndex === -1) {
return 1;
}
if (bIndex === -1) {
return -1;
}
return aIndex - bIndex;
}
}
};
</script>
<style scoped>
.x-link:hover {
text-decoration: none;
}
.x-link:hover span {
text-decoration: underline;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div v-show="isSideBarTabShow" id="aside" class="x-aside-container">
<div v-show="isSideBarTabShow" id="aside" class="x-aside-container" :style="{ width: `${asideWidth}px` }">
<div style="display: flex; align-items: baseline">
<el-select
value=""
@@ -10,7 +10,7 @@
:remote-method="quickSearchRemoteMethod"
popper-class="x-quick-search"
style="flex: 1; padding: 10px"
@change="$emit('quick-search-change', $event)">
@change="quickSearchChange">
<el-option v-for="item in quickSearchItems" :key="item.value" :value="item.value" :label="item.label">
<div class="x-friend-item">
<template v-if="item.ref">
@@ -25,12 +25,12 @@
<span v-else-if="item.ref.state === 'active'" class="extra">{{
$t('side_panel.search_result_offline')
}}</span>
<location
<Location
v-else
class="extra"
:location="item.ref.location"
:traveling="item.ref.travelingToLocation"
:link="false"></location>
:link="false" />
</div>
<img v-lazy="userImage(item.ref)" class="avatar" />
</template>
@@ -47,17 +47,17 @@
size="mini"
icon="el-icon-discover"
circle
@click="$emit('direct-access-paste')"></el-button>
@click="directAccessPaste"></el-button>
</el-tooltip>
<el-tooltip placement="bottom" :content="$t('side_panel.refresh_tooltip')" :disabled="hideTooltips">
<el-button
type="default"
:loading="API.isRefreshFriendsLoading"
:loading="isRefreshFriendsLoading"
size="mini"
icon="el-icon-refresh"
circle
style="margin-right: 10px"
@click="$emit('refresh-friends-list')"></el-button>
@click="refreshFriendsList" />
</el-tooltip>
</div>
<el-tabs class="zero-margin-tabs" stretch style="height: calc(100% - 60px); margin-top: 5px">
@@ -69,21 +69,7 @@
</span>
</template>
<el-backtop target=".zero-margin-tabs .el-tabs__content" :bottom="20" :right="20"></el-backtop>
<FriendsSidebar
:hide-nicknames="hideNicknames"
:is-game-running="isGameRunning"
:is-sidebar-divide-by-friend-group="isSidebarDivideByFriendGroup"
:is-sidebar-group-by-instance="isSidebarGroupByInstance"
:game-log-disabled="gameLogDisabled"
:last-location="lastLocation"
:last-location-destination="lastLocationDestination"
:active-friends="activeFriends"
:offline-friends="offlineFriends"
:online-friends="onlineFriends"
:vip-friends="vipFriends"
:is-hide-friends-in-same-instance="isHideFriendsInSameInstance"
:grouped-by-group-key-favorite-friends="groupedByGroupKeyFavoriteFriends"
@confirm-delete-friend="$emit('confirm-delete-friend', $event)" />
<FriendsSidebar @confirm-delete-friend="confirmDeleteFriend" />
</el-tab-pane>
<el-tab-pane lazy>
<template slot="label">
@@ -92,61 +78,35 @@
({{ groupInstances.length }})
</span>
</template>
<GroupsSidebar
:group-instances="groupInstances"
:group-order="inGameGroupOrder"
:is-age-gated-instances-visible="isAgeGatedInstancesVisible"
@show-group-dialog="$emit('show-group-dialog', $event)" />
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
<script setup>
import { storeToRefs } from 'pinia';
import { computed } from 'vue';
import { userImage } from '../../shared/utils';
import {
useAppearanceSettingsStore,
useFriendStore,
useGroupStore,
useSearchStore,
useUiStore
} from '../../stores';
import FriendsSidebar from './components/FriendsSidebar.vue';
import GroupsSidebar from './components/GroupsSidebar.vue';
import Location from '../../components/Location.vue';
export default {
name: 'SideBar',
components: {
FriendsSidebar,
GroupsSidebar,
Location
},
inject: ['API', 'userImage'],
props: {
// settings
// remove these props when have a state manager.
hideTooltips: Boolean,
isGameRunning: Boolean,
isSidebarDivideByFriendGroup: Boolean,
isSidebarGroupByInstance: Boolean,
gameLogDisabled: Boolean,
hideNicknames: Boolean,
isHideFriendsInSameInstance: Boolean,
isAgeGatedInstancesVisible: Boolean,
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
const { refreshFriendsList, confirmDeleteFriend } = useFriendStore();
const { hideTooltips, asideWidth } = storeToRefs(useAppearanceSettingsStore());
const { menuActiveIndex } = storeToRefs(useUiStore());
const { quickSearchRemoteMethod, quickSearchChange, directAccessPaste } = useSearchStore();
const { quickSearchItems } = storeToRefs(useSearchStore());
const { inGameGroupOrder, groupInstances } = storeToRefs(useGroupStore());
isSideBarTabShow: Boolean,
quickSearchRemoteMethod: Function,
quickSearchItems: Array,
onlineFriendCount: Number,
friends: Map,
lastLocation: Object,
lastLocationDestination: String,
// friends
vipFriends: Array,
onlineFriends: Array,
// no
activeFriends: Array,
offlineFriends: Array,
groupInstances: Array,
inGameGroupOrder: Array,
groupedByGroupKeyFavoriteFriends: Object
}
};
const isSideBarTabShow = computed(() => {
return !(menuActiveIndex.value === 'friendList' || menuActiveIndex.value === 'charts');
});
</script>

View File

@@ -0,0 +1,362 @@
<template>
<div class="x-friend-list" style="padding: 10px 5px">
<div
class="x-friend-group x-link"
style="padding: 0 0 5px"
@click="
isFriendsGroupMe = !isFriendsGroupMe;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isFriendsGroupMe }"></i>
<span style="margin-left: 5px">{{ $t('side_panel.me') }}</span>
</div>
<div v-show="isFriendsGroupMe">
<div class="x-friend-item" @click="showUserDialog(currentUser.id)">
<div class="avatar" :class="userStatusClass(currentUser)">
<img v-lazy="userImage(currentUser)" />
</div>
<div class="detail">
<span class="name" :style="{ color: currentUser.$userColour }">{{ currentUser.displayName }}</span>
<Location
v-if="isGameRunning && !gameLogDisabled"
class="extra"
:location="lastLocation.location"
:traveling="lastLocationDestination"
:link="false" />
<Location
v-else-if="
isRealInstance(currentUser.$locationTag) || isRealInstance(currentUser.$travelingToLocation)
"
class="extra"
:location="currentUser.$locationTag"
:traveling="currentUser.$travelingToLocation"
:link="false" />
<span v-else class="extra">{{ currentUser.statusDescription }}</span>
</div>
</div>
</div>
<div
v-show="vipFriendsDisplayNumber"
class="x-friend-group x-link"
@click="
isVIPFriends = !isVIPFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isVIPFriends }"></i>
<span style="margin-left: 5px">
{{ $t('side_panel.favorite') }} &horbar;
{{ vipFriendsDisplayNumber }}
</span>
</div>
<div v-show="isVIPFriends">
<template v-if="isSidebarDivideByFriendGroup">
<div v-for="group in vipFriendsDivideByGroup" :key="group[0].key">
<transition name="el-fade-in-linear">
<div v-show="group[0].groupName !== ''" style="margin-bottom: 3px">
<span class="extra">{{ group[0].groupName }}</span>
<span class="extra" style="margin-left: 5px">{{ `(${group.length})` }}</span>
</div>
</transition>
<div v-if="group.length" style="margin-bottom: 10px">
<friend-item
v-for="friend in group"
:key="friend.id"
:friend="friend"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="confirmDeleteFriend"></friend-item>
</div>
</div>
</template>
<template v-else>
<friend-item
v-for="friend in vipFriendsByGroupStatus"
:key="friend.id"
:friend="friend"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="confirmDeleteFriend">
</friend-item>
</template>
</div>
<template v-if="isSidebarGroupByInstance && friendsInSameInstance.length">
<div class="x-friend-group x-link" @click="toggleSwitchGroupByInstanceCollapsed">
<i class="el-icon-arrow-right" :class="{ rotate: !isSidebarGroupByInstanceCollapsed }"></i>
<span style="margin-left: 5px"
>{{ $t('side_panel.same_instance') }} &horbar; {{ friendsInSameInstance.length }}</span
>
</div>
<div v-show="!isSidebarGroupByInstanceCollapsed">
<div v-for="friendArr in friendsInSameInstance" :key="friendArr[0].ref.$location.tag">
<div style="margin-bottom: 3px">
<Location class="extra" :location="getFriendsLocations(friendArr)" style="display: inline" />
<span class="extra" style="margin-left: 5px">{{ `(${friendArr.length})` }}</span>
</div>
<div v-if="friendArr && friendArr.length">
<friend-item
v-for="(friend, idx) in friendArr"
:key="friend.id"
:friend="friend"
is-group-by-instance
:style="{ 'margin-bottom': idx === friendArr.length - 1 ? '5px' : undefined }"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="confirmDeleteFriend">
</friend-item>
</div>
</div>
</div>
</template>
<div
v-show="onlineFriendsByGroupStatus.length"
class="x-friend-group x-link"
@click="
isOnlineFriends = !isOnlineFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isOnlineFriends }"></i>
<span style="margin-left: 5px"
>{{ $t('side_panel.online') }} &horbar; {{ onlineFriendsByGroupStatus.length }}</span
>
</div>
<div v-show="isOnlineFriends">
<friend-item
v-for="friend in onlineFriendsByGroupStatus"
:key="friend.id"
:friend="friend"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="confirmDeleteFriend" />
</div>
<div
v-show="activeFriends.length"
class="x-friend-group x-link"
@click="
isActiveFriends = !isActiveFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isActiveFriends }"></i>
<span style="margin-left: 5px">{{ $t('side_panel.active') }} &horbar; {{ activeFriends.length }}</span>
</div>
<div v-show="isActiveFriends">
<friend-item
v-for="friend in activeFriends"
:key="friend.id"
:friend="friend"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="confirmDeleteFriend"></friend-item>
</div>
<div
v-show="offlineFriends.length"
class="x-friend-group x-link"
@click="
isOfflineFriends = !isOfflineFriends;
saveFriendsGroupStates();
">
<i class="el-icon-arrow-right" :class="{ rotate: isOfflineFriends }"></i>
<span style="margin-left: 5px">{{ $t('side_panel.offline') }} &horbar; {{ offlineFriends.length }}</span>
</div>
<div v-show="isOfflineFriends">
<friend-item
v-for="friend in offlineFriends"
:key="friend.id"
:friend="friend"
@click="showUserDialog(friend.id)"
@confirm-delete-friend="confirmDeleteFriend"></friend-item>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import FriendItem from '../../../components/FriendItem.vue';
import configRepository from '../../../service/config';
import { isRealInstance, userImage, userStatusClass } from '../../../shared/utils';
import {
useAdvancedSettingsStore,
useAppearanceSettingsStore,
useFavoriteStore,
useFriendStore,
useGameStore,
useLocationStore,
useUserStore
} from '../../../stores';
const emit = defineEmits(['confirm-delete-friend']);
const { vipFriends, onlineFriends, activeFriends, offlineFriends } = storeToRefs(useFriendStore());
const { isSidebarGroupByInstance, isHideFriendsInSameInstance, isSidebarDivideByFriendGroup } =
storeToRefs(useAppearanceSettingsStore());
const { gameLogDisabled } = storeToRefs(useAdvancedSettingsStore());
const { showUserDialog } = useUserStore();
const { favoriteFriendGroups, groupedByGroupKeyFavoriteFriends } = storeToRefs(useFavoriteStore());
const { lastLocation, lastLocationDestination } = storeToRefs(useLocationStore());
const { isGameRunning } = storeToRefs(useGameStore());
const { currentUser } = storeToRefs(useUserStore());
const isFriendsGroupMe = ref(true);
const isVIPFriends = ref(true);
const isOnlineFriends = ref(true);
const isActiveFriends = ref(false);
const isOfflineFriends = ref(false);
const isSidebarGroupByInstanceCollapsed = ref(false);
loadFriendsGroupStates();
const friendsInSameInstance = computed(() => {
const friendsList = {};
const allFriends = [...vipFriends.value, ...onlineFriends.value];
allFriends.forEach((friend) => {
if (!friend.ref?.$location) {
return;
}
let locationTag = friend.ref.$location.tag;
if (!friend.ref.$location.isRealInstance && lastLocation.value.friendList.has(friend.id)) {
locationTag = lastLocation.value.location;
}
const isReal = isRealInstance(locationTag);
if (!isReal) {
return;
}
if (!friendsList[locationTag]) {
friendsList[locationTag] = [];
}
friendsList[locationTag].push(friend);
});
const sortedFriendsList = [];
for (const group of Object.values(friendsList)) {
if (group.length > 1) {
sortedFriendsList.push(group.sort((a, b) => a.ref?.$location_at - b.ref?.$location_at));
}
}
return sortedFriendsList.sort((a, b) => b.length - a.length);
});
const sameInstanceFriendId = computed(() => {
const sameInstanceFriendId = new Set();
for (const item of friendsInSameInstance.value) {
for (const friend of item) {
if (isRealInstance(friend.ref?.$location.tag) || lastLocation.value.friendList.has(friend.id)) {
sameInstanceFriendId.add(friend.id);
}
}
}
return sameInstanceFriendId;
});
const onlineFriendsByGroupStatus = computed(() => {
if (!isSidebarGroupByInstance.value || !isHideFriendsInSameInstance.value) {
return onlineFriends.value;
}
return onlineFriends.value.filter((item) => !sameInstanceFriendId.value.has(item.id));
});
const vipFriendsByGroupStatus = computed(() => {
if (!isSidebarGroupByInstance.value || !isHideFriendsInSameInstance.value) {
return vipFriends.value;
}
return vipFriends.value.filter((item) => !sameInstanceFriendId.value.has(item.id));
});
// VIP friends divide by group
const vipFriendsDivideByGroup = computed(() => {
const vipFriendsByGroup = { ...groupedByGroupKeyFavoriteFriends.value };
const result = [];
for (const key in vipFriendsByGroup) {
if (Object.hasOwn(vipFriendsByGroup, key)) {
const groupFriends = vipFriendsByGroup[key];
// sort groupFriends using the order of vipFriends
// avoid unnecessary sorting
const filteredFriends = vipFriends.value.filter((friend) =>
groupFriends.some((item) => {
if (isSidebarGroupByInstance.value && isHideFriendsInSameInstance.value) {
return item.id === friend.id && !sameInstanceFriendId.value.has(item.id);
}
return item.id === friend.id;
})
);
if (filteredFriends.length > 0) {
const groupName = favoriteFriendGroups.value.find((item) => item.key === key)?.displayName || '';
result.push(filteredFriends.map((item) => ({ groupName, key, ...item })));
}
}
}
return result.sort((a, b) => a[0].key.localeCompare(b[0].key));
});
const vipFriendsDisplayNumber = computed(() => {
return isSidebarDivideByFriendGroup.value
? vipFriendsDivideByGroup.value.length
: vipFriendsByGroupStatus.value.length;
});
function saveFriendsGroupStates() {
configRepository.setBool('VRCX_isFriendsGroupMe', isFriendsGroupMe.value);
configRepository.setBool('VRCX_isFriendsGroupFavorites', isVIPFriends.value);
configRepository.setBool('VRCX_isFriendsGroupOnline', isOnlineFriends.value);
configRepository.setBool('VRCX_isFriendsGroupActive', isActiveFriends.value);
configRepository.setBool('VRCX_isFriendsGroupOffline', isOfflineFriends.value);
}
async function loadFriendsGroupStates() {
isFriendsGroupMe.value = await configRepository.getBool('VRCX_isFriendsGroupMe', true);
isVIPFriends.value = await configRepository.getBool('VRCX_isFriendsGroupFavorites', true);
isOnlineFriends.value = await configRepository.getBool('VRCX_isFriendsGroupOnline', true);
isActiveFriends.value = await configRepository.getBool('VRCX_isFriendsGroupActive', false);
isOfflineFriends.value = await configRepository.getBool('VRCX_isFriendsGroupOffline', false);
isSidebarGroupByInstanceCollapsed.value = await configRepository.getBool(
'VRCX_sidebarGroupByInstanceCollapsed',
false
);
}
function toggleSwitchGroupByInstanceCollapsed() {
isSidebarGroupByInstanceCollapsed.value = !isSidebarGroupByInstanceCollapsed.value;
configRepository.setBool('VRCX_sidebarGroupByInstanceCollapsed', isSidebarGroupByInstanceCollapsed.value);
}
function getFriendsLocations(friendsArr) {
// prevent the instance title display as "Traveling".
if (!friendsArr?.length) {
return '';
}
for (const friend of friendsArr) {
if (isRealInstance(friend.ref?.location)) {
return friend.ref.location;
}
}
for (const friend of friendsArr) {
if (isRealInstance(friend.ref?.travelingToLocation)) {
return friend.ref.travelingToLocation;
}
}
for (const friend of friendsArr) {
if (lastLocation.value.friendList.has(friend.id)) {
return lastLocation.value.location;
}
}
return friendsArr[0].ref?.location;
}
function confirmDeleteFriend(friend) {
emit('confirm-delete-friend', friend);
}
</script>
<style scoped>
.x-link:hover {
text-decoration: none;
}
.x-link:hover span {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="x-friend-list" style="padding: 10px 5px">
<template v-for="(group, index) in groupedGroupInstances">
<div
:key="getGroupId(group)"
class="x-friend-group x-link"
:style="{ paddingTop: index === 0 ? '0px' : '10px' }">
<div @click="toggleGroupSidebarCollapse(getGroupId(group))" style="display: flex; align-items: center">
<i
class="el-icon-arrow-right"
:style="{
transform: groupInstancesCfg[getGroupId(group)].isCollapsed ? '' : 'rotate(90deg)',
transition: 'transform 0.3s'
}"></i>
<span style="margin-left: 5px">{{ group[0].group.name }} {{ group.length }}</span>
</div>
</div>
<template v-if="!groupInstancesCfg[getGroupId(group)].isCollapsed">
<div
v-for="ref in group"
:key="ref.instance.id"
class="x-friend-item"
@click="showGroupDialog(ref.instance.ownerId)">
<template v-if="isAgeGatedInstancesVisible || !(ref.ageGate || ref.location?.includes('~ageGate'))">
<div class="avatar">
<img v-lazy="getSmallGroupIconUrl(ref.group.iconUrl)" />
</div>
<div class="detail">
<span class="name">
<span v-text="ref.group.name"></span>
<span style="font-weight: normal; margin-left: 5px"
>({{ ref.instance.userCount }}/{{ ref.instance.capacity }})</span
>
</span>
<Location class="extra" :location="ref.instance.location" :link="false" />
</div>
</template>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
import { convertFileUrlToImageUrl } from '../../../shared/utils';
import { useAppearanceSettingsStore, useGroupStore } from '../../../stores';
const { isAgeGatedInstancesVisible } = storeToRefs(useAppearanceSettingsStore());
const { showGroupDialog, sortGroupInstancesByInGame } = useGroupStore();
const { groupInstances } = storeToRefs(useGroupStore());
defineProps({
groupOrder: {
type: Array,
default: () => []
}
});
const groupInstancesCfg = ref({});
const groupedGroupInstances = computed(() => {
const groupMap = new Map();
groupInstances.value.forEach((ref) => {
const groupId = ref.group.groupId;
if (!groupMap.has(groupId)) {
groupMap.set(groupId, []);
}
groupMap.get(groupId).push(ref);
if (!groupInstancesCfg.value[ref.group?.groupId]) {
groupInstancesCfg.value = {
[ref.group.groupId]: {
isCollapsed: false
},
...groupInstancesCfg.value
};
}
});
return Array.from(groupMap.values()).sort(sortGroupInstancesByInGame);
});
function getSmallGroupIconUrl(url) {
return convertFileUrlToImageUrl(url);
}
function toggleGroupSidebarCollapse(groupId) {
groupInstancesCfg.value[groupId].isCollapsed = !groupInstancesCfg.value[groupId].isCollapsed;
}
function getGroupId(group) {
return group[0]?.group?.groupId || '';
}
</script>
<style scoped>
.x-link:hover {
text-decoration: none;
}
.x-link:hover span {
text-decoration: underline;
}
</style>