refactor: dialogs (#1216)

This commit is contained in:
pa
2025-04-18 20:11:07 +09:00
committed by GitHub
parent 30d54a74dd
commit ef7f33e131
34 changed files with 3227 additions and 2716 deletions

View File

@@ -0,0 +1,98 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible.sync="chatboxBlacklistDialog.visible"
:title="t('dialog.chatbox_blacklist.header')"
width="600px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div v-if="chatboxBlacklistDialog.visible" v-loading="chatboxBlacklistDialog.loading">
<h2>{{ t('dialog.chatbox_blacklist.keyword_blacklist') }}</h2>
<el-input
v-for="(item, index) in chatboxBlacklist"
:key="index"
v-model="chatboxBlacklist[index]"
size="small"
style="margin-top: 5px"
@change="saveChatboxBlacklist">
<template #append>
<el-button
icon="el-icon-delete"
@click="
chatboxBlacklist.splice(index, 1);
saveChatboxBlacklist();
">
</el-button>
</template>
</el-input>
<el-button size="mini" style="margin-top: 5px" @click="chatboxBlacklist.push('')">
{{ t('dialog.chatbox_blacklist.add_item') }}
</el-button>
<br />
<h2>{{ t('dialog.chatbox_blacklist.user_blacklist') }}</h2>
<el-tag
v-for="user in chatboxUserBlacklist"
:key="user[0]"
type="info"
disable-transitions
style="margin-right: 5px; margin-top: 5px"
closable
@close="deleteChatboxUserBlacklist(user[0])">
<span>{{ user[1] }}</span>
</el-tag>
</div>
</el-dialog>
</template>
<script setup>
// TODO: untested
import { inject, ref } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
defineProps({
chatboxBlacklistDialog: {
type: Object,
required: true
},
chatboxUserBlacklist: {
type: Map,
required: true
}
});
const chatboxBlacklist = ref([
'NP: ',
'Now Playing',
'Now playing',
"▶️ '",
'( ▶️ ',
"' - '",
"' by '",
'[Spotify] '
]);
const emit = defineEmits(['deleteChatboxUserBlacklist']);
initChatboxBlacklist();
async function initChatboxBlacklist() {
if (await configRepository.getString('VRCX_chatboxBlacklist')) {
chatboxBlacklist.value = JSON.parse(await configRepository.getString('VRCX_chatboxBlacklist'));
}
}
async function saveChatboxBlacklist() {
await configRepository.setString('VRCX_chatboxBlacklist', JSON.stringify(chatboxBlacklist.value));
}
function deleteChatboxUserBlacklist(userId) {
emit('deleteChatboxUserBlacklist', userId);
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="discordNamesDialogVisible"
:title="t('dialog.discord_names.header')"
width="650px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div style="font-size: 12px">
{{ t('dialog.discord_names.description') }}
</div>
<el-input
v-model="discordNamesContent"
type="textarea"
size="mini"
rows="15"
resize="none"
readonly
style="margin-top: 15px" />
</el-dialog>
</template>
<script setup>
import { ref, watch, inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const API = inject('API');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const { t } = useI18n();
const props = defineProps({
discordNamesDialogVisible: {
type: Boolean,
default: false
},
friends: {
type: Map,
default: () => new Map()
}
});
watch(
() => props.discordNamesDialogVisible,
(newVal) => {
if (newVal) {
showDiscordNamesContent();
}
}
);
const emit = defineEmits(['update:discordNamesDialogVisible']);
const discordNamesContent = ref('');
function showDiscordNamesContent() {
const { friends } = API.currentUser;
if (Array.isArray(friends) === false) {
return;
}
const lines = ['DisplayName,DiscordName'];
const _ = function (str) {
if (/[\x00-\x1f,"]/.test(str) === true) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
};
for (const userId of friends) {
const { ref } = props.friends.get(userId);
let discord = '';
if (typeof ref === 'undefined') {
continue;
}
const name = ref.displayName;
if (ref.statusDescription) {
const statusRegex = /(?:discord|dc|dis)(?: |=|:|˸|;)(.*)/gi.exec(ref.statusDescription);
if (statusRegex) {
discord = statusRegex[1];
}
}
if (!discord && ref.bio) {
const bioRegex = /(?:discord|dc|dis)(?: |=|:|˸|;)(.*)/gi.exec(ref.bio);
if (bioRegex) {
discord = bioRegex[1];
}
}
if (!discord) {
continue;
}
discord = discord.trim();
lines.push(`${_(name)},${_(discord)}`);
}
discordNamesContent.value = lines.join('\n');
}
function closeDialog() {
emit('update:discordNamesDialogVisible', false);
}
</script>
<style scoped></style>

View File

@@ -0,0 +1,102 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="editInviteMessageDialog.visible"
:title="t('dialog.edit_invite_message.header')"
width="400px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div style="font-size: 12px">
<span>{{ t('dialog.edit_invite_message.description') }}</span>
<el-input
v-model="message"
type="textarea"
size="mini"
maxlength="64"
show-word-limit
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder=""
style="margin-top: 10px"></el-input>
</div>
<template #footer>
<el-button type="small" @click="closeDialog">{{ $t('dialog.edit_invite_message.cancel') }}</el-button>
<el-button type="primary" size="small" @click="saveEditInviteMessage">{{
$t('dialog.edit_invite_message.save')
}}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, inject, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { inviteMessagesRequest } from '../../../api';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const API = inject('API');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const props = defineProps({
editInviteMessageDialog: {
type: Object,
default: () => ({
visible: false,
newMessage: ''
})
}
});
const message = ref('');
watch(
() => props.editInviteMessageDialog,
(newVal) => {
if (newVal && newVal.visible) {
message.value = newVal.newMessage;
}
},
{ deep: true }
);
const emit = defineEmits(['update:editInviteMessageDialog']);
function saveEditInviteMessage() {
const D = props.editInviteMessageDialog;
D.visible = false;
if (D.inviteMessage.message !== message.value) {
const slot = D.inviteMessage.slot;
const messageType = D.messageType;
const params = {
message: message.value
};
inviteMessagesRequest
.editInviteMessage(params, messageType, slot)
.catch((err) => {
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",
type: 'error'
});
throw new Error("VRChat API didn't update message, try again");
} else {
$message.success('Invite message updated');
}
return args;
});
}
}
function closeDialog() {
emit('update:editInviteMessageDialog', { ...props.editInviteMessageDialog, visible: false });
}
</script>

View File

@@ -0,0 +1,68 @@
<template>
<el-dialog
class="x-dialog"
:visible="isAvatarProviderDialogVisible"
:title="t('dialog.avatar_database_provider.header')"
width="600px"
:before-close="beforeDialogClose"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div>
<el-input
v-for="(provider, index) in avatarRemoteDatabaseProviderList"
:key="index"
v-model="avatarRemoteDatabaseProviderList[index]"
:value="provider"
size="small"
style="margin-top: 5px"
@change="saveAvatarProviderList">
<el-button slot="append" icon="el-icon-delete" @click="removeAvatarProvider(provider)"></el-button>
</el-input>
<el-button size="mini" style="margin-top: 5px" @click="avatarRemoteDatabaseProviderList.push('')">
{{ t('dialog.avatar_database_provider.add_provider') }}
</el-button>
</div>
</el-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
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);
}
function closeDialog() {
emit('update:isAvatarProviderDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="changeLogDialog.visible"
:title="t('dialog.change_log.header')"
width="800px"
top="5vh"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp"
@close="closeDialog">
<div v-if="changeLogDialog.visible" class="changelog-dialog">
<h2 v-text="changeLogDialog.buildName"></h2>
<span>
{{ 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>.
</span>
<vue-markdown
:source="changeLogDialog.changeLog"
:linkify="false"
style="height: 62vh; overflow-y: auto; margin-top: 10px"></vue-markdown>
</div>
<template #footer>
<el-button type="small" @click="openExternalLink('https://github.com/vrcx-team/VRCX/releases')">
{{ t('dialog.change_log.github') }}
</el-button>
<el-button type="small" @click="openExternalLink('https://patreon.com/Natsumi_VRCX')">
{{ t('dialog.change_log.donate') }}
</el-button>
<el-button type="small" @click="closeDialog">
{{ t('dialog.change_log.close') }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const openExternalLink = inject('openExternalLink');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const props = defineProps({
changeLogDialog: {
type: Object,
required: true
}
});
const emit = defineEmits(['update:changeLogDialog']);
function closeDialog() {
emit('update:changeLogDialog', { ...props.changeLogDialog, visible: false });
}
</script>
<style>
.changelog-dialog img {
width: 100%;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<el-dialog
:before-close="beforeDialogClose"
:visible="!!feedFiltersDialogMode"
:title="dialogTitle"
width="550px"
top="5vh"
destroy-on-close
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp"
@close="handleDialogClose">
<div class="toggle-list" style="height: 75vh; overflow-y: auto">
<div v-for="setting in currentOptions" :key="setting.key" class="toggle-item">
<span class="toggle-name"
>{{ setting.name
}}<el-tooltip
v-if="setting.tooltip"
placement="top"
style="margin-left: 5px"
:content="setting.tooltip">
<i :class="setting.tooltipIcon || 'el-icon-info'"></i> </el-tooltip
></span>
<el-radio-group
v-model="currentSharedFeedFilters[setting.key]"
size="mini"
@change="saveSharedFeedFilters">
<el-radio-button v-for="option in setting.options" :key="option.label" :label="option.label">
{{ t(option.textKey) }}
</el-radio-button>
</el-radio-group>
</div>
<template v-if="props.photonLoggingEnabled">
<br />
<div class="toggle-item">
<span class="toggle-name">Photon Event Logging</span>
</div>
<div v-for="setting in photonFeedFiltersOptions" :key="setting.key" class="toggle-item">
<span class="toggle-name">{{ setting.name }}</span>
<el-radio-group
v-model="currentSharedFeedFilters[setting.key]"
size="mini"
@change="saveSharedFeedFilters">
<el-radio-button v-for="option in setting.options" :key="option.label" :label="option.label">
{{ t(option.textKey) }}
</el-radio-button>
</el-radio-group>
</div>
</template>
</div>
<template #footer>
<el-button size="small" @click="currentResetFunction">{{
t('dialog.shared_feed_filters.reset')
}}</el-button>
<el-button size="small" type="primary" style="margin-left: 10px" @click="handleDialogClose">{{
t('dialog.shared_feed_filters.close')
}}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
import { feedFiltersOptions } from '../../../composables/settings/constants/feedFiltersOptions';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const { t } = useI18n();
const { notyFeedFiltersOptions, wristFeedFiltersOptions, photonFeedFiltersOptions } = feedFiltersOptions();
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: {}
})
}
});
const currentOptions = computed(() => {
return props.feedFiltersDialogMode === 'noty' ? notyFeedFiltersOptions : wristFeedFiltersOptions;
});
const currentSharedFeedFilters = computed(() => {
return props.feedFiltersDialogMode === 'noty'
? props.sharedFeedFilters['noty']
: props.sharedFeedFilters['wrist'];
});
const dialogTitle = computed(() => {
const key =
props.feedFiltersDialogMode === 'noty'
? 'dialog.shared_feed_filters.notification'
: 'dialog.shared_feed_filters.wrist';
return t(key);
});
const currentResetFunction = computed(() => {
return props.feedFiltersDialogMode === 'noty' ? resetNotyFeedFilters : resetWristFeedFilters;
});
const emit = defineEmits(['update:feedFiltersDialogMode', 'updateSharedFeed']);
function saveSharedFeedFilters() {
configRepository.setString('sharedFeedFilters', JSON.stringify(props.sharedFeedFilters));
emit('updateSharedFeed', true);
}
function resetNotyFeedFilters() {
props.sharedFeedFilters.noty = {
...props.sharedFeedFiltersDefaults.noty
};
saveSharedFeedFilters();
}
async function resetWristFeedFilters() {
props.sharedFeedFilters.wrist = {
...props.sharedFeedFiltersDefaults.wrist
};
saveSharedFeedFilters();
}
function handleDialogClose() {
emit('update:feedFiltersDialogMode', '');
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isLaunchOptionsDialogVisible"
:title="t('dialog.launch_options.header')"
width="600px"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp"
@close="closeDialog">
<div style="font-size: 12px">
{{ t('dialog.launch_options.description') }} <br />
{{ t('dialog.launch_options.example') }} <el-tag size="mini">--fps=144</el-tag>
</div>
<el-input
v-model="launchOptionsDialog.launchArguments"
type="textarea"
size="mini"
show-word-limit
:autosize="{ minRows: 2, maxRows: 5 }"
placeholder=""
style="margin-top: 10px">
</el-input>
<div style="font-size: 12px; margin-top: 10px">
{{ t('dialog.launch_options.path_override') }}
</div>
<el-input
v-model="launchOptionsDialog.vrcLaunchPathOverride"
type="textarea"
placeholder="C:\\Program Files (x86)\\Steam\\steamapps\\common\\VRChat"
:rows="1"
style="display: block; margin-top: 10px">
</el-input>
<template #footer>
<div style="display: flex">
<el-button size="small" @click="openExternalLink('https://docs.vrchat.com/docs/launch-options')">
{{ t('dialog.launch_options.vrchat_docs') }}
</el-button>
<el-button
size="small"
@click="openExternalLink('https://docs.unity3d.com/Manual/CommandLineArguments.html')">
{{ t('dialog.launch_options.unity_manual') }}
</el-button>
<el-button type="primary" size="small" style="margin-left: auto" @click="updateLaunchOptions">
{{ t('dialog.launch_options.save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, inject, getCurrentInstance } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const openExternalLink = inject('openExternalLink');
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
defineProps({
isLaunchOptionsDialogVisible: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['update:isLaunchOptionsDialogVisible']);
const launchOptionsDialog = ref({
launchArguments: '',
vrcLaunchPathOverride: ''
});
function init() {
configRepository
.getString('launchArguments')
.then((launchArguments) => (launchOptionsDialog.value.launchArguments = launchArguments));
configRepository.getString('vrcLaunchPathOverride').then((vrcLaunchPathOverride) => {
if (vrcLaunchPathOverride === null || vrcLaunchPathOverride === 'null') {
launchOptionsDialog.value.vrcLaunchPathOverride = '';
configRepository.setString('vrcLaunchPathOverride', '');
} else {
launchOptionsDialog.value.vrcLaunchPathOverride = vrcLaunchPathOverride;
}
});
}
// created
init();
function updateLaunchOptions() {
const D = launchOptionsDialog.value;
D.launchArguments = String(D.launchArguments).replace(/\s+/g, ' ').trim();
configRepository.setString('launchArguments', D.launchArguments);
if (
D.vrcLaunchPathOverride &&
D.vrcLaunchPathOverride.endsWith('.exe') &&
!D.vrcLaunchPathOverride.endsWith('launch.exe')
) {
$message({
message: 'Invalid path, you must enter VRChat folder or launch.exe',
type: 'error'
});
return;
}
configRepository.setString('vrcLaunchPathOverride', D.vrcLaunchPathOverride);
$message({
message: 'Updated launch options',
type: 'success'
});
closeDialog();
}
function closeDialog() {
emit('update:isLaunchOptionsDialogVisible');
}
</script>

View File

@@ -0,0 +1,210 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isNoteExportDialogVisible"
:title="t('dialog.note_export.header')"
width="1000px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div style="font-size: 12px">
{{ t('dialog.note_export.description1') }} <br />
{{ t('dialog.note_export.description2') }} <br />
{{ t('dialog.note_export.description3') }} <br />
{{ t('dialog.note_export.description4') }} <br />
{{ t('dialog.note_export.description5') }} <br />
{{ t('dialog.note_export.description6') }} <br />
{{ t('dialog.note_export.description7') }} <br />
{{ t('dialog.note_export.description8') }} <br />
</div>
<el-button size="small" :disabled="loading" style="margin-top: 10px" @click="updateNoteExportDialog">
{{ t('dialog.note_export.refresh') }}
</el-button>
<el-button size="small" :disabled="loading" style="margin-top: 10px" @click="exportNoteExport">
{{ t('dialog.note_export.export') }}
</el-button>
<el-button v-if="loading" size="small" style="margin-top: 10px" @click="cancelNoteExport">
{{ t('dialog.note_export.cancel') }}
</el-button>
<span v-if="loading" style="margin: 10px">
<i class="el-icon-loading" style="margin-right: 5px"></i>
{{ t('dialog.note_export.progress') }} {{ progress }}/{{ progressTotal }}
</span>
<template v-if="errors">
<el-button size="small" @click="errors = ''">
{{ t('dialog.note_export.clear_errors') }}
</el-button>
<h2 style="font-weight: bold; margin: 0">
{{ t('dialog.note_export.errors') }}
</h2>
<pre style="white-space: pre-wrap; font-size: 12px" v-text="errors"></pre>
</template>
<data-tables v-loading="loading" v-bind="noteExportTable" style="margin-top: 10px">
<el-table-column :label="t('table.import.image')" width="70" prop="currentAvatarThumbnailImageUrl">
<template slot-scope="scope">
<el-popover placement="right" height="500px" trigger="hover">
<img slot="reference" v-lazy="userImage(scope.row.ref)" class="friends-list-avatar" />
<img
v-lazy="userImageFull(scope.row.ref)"
class="friends-list-avatar"
style="height: 500px; cursor: pointer"
@click="showFullscreenImageDialog(userImageFull(scope.row.ref))" />
</el-popover>
</template>
</el-table-column>
<el-table-column :label="t('table.import.name')" width="170" prop="name">
<template slot-scope="scope">
<span class="x-link" @click="showUserDialog(scope.row.id)" v-text="scope.row.name"></span>
</template>
</el-table-column>
<el-table-column :label="t('table.import.note')" prop="memo">
<template slot-scope="scope">
<el-input
v-model="scope.row.memo"
type="textarea"
maxlength="256"
show-word-limit
:rows="2"
:autosize="{ minRows: 1, maxRows: 10 }"
size="mini"
resize="none"></el-input>
</template>
</el-table-column>
<el-table-column :label="t('table.import.skip_export')" width="90" align="right">
<template slot-scope="scope">
<el-button
type="text"
icon="el-icon-close"
size="mini"
@click="removeFromNoteExportTable(scope.row)"></el-button>
</template>
</el-table-column>
</data-tables>
</el-dialog>
</template>
<script setup>
import { ref, watch, inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import utils from '../../../classes/utils';
import * as workerTimers from 'worker-timers';
import { miscRequest } from '../../../api';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const userImage = inject('userImage');
const userImageFull = inject('userImageFull');
const showUserDialog = inject('showUserDialog');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const props = defineProps({
isNoteExportDialogVisible: {
type: Boolean
},
friends: {
type: Map,
default: () => new Map()
}
});
const noteExportTable = ref({
data: [],
tableProps: {
stripe: true,
size: 'mini'
},
layout: 'table'
});
const progress = ref(0);
const progressTotal = ref(0);
const loading = ref(false);
const errors = ref('');
watch(
() => props.isNoteExportDialogVisible,
(newVal) => {
if (newVal) {
initData();
}
}
);
function initData() {
noteExportTable.value.data = [];
progress.value = 0;
progressTotal.value = 0;
loading.value = false;
errors.value = '';
}
const emit = defineEmits(['update:isNoteExportDialogVisible']);
function updateNoteExportDialog() {
const data = [];
props.friends.forEach((ctx) => {
const newMemo = ctx.memo.replace(/[\r\n]/g, ' ');
if (ctx.memo && ctx.ref && ctx.ref.note !== newMemo.slice(0, 256)) {
data.push({
id: ctx.id,
name: ctx.name,
memo: newMemo,
ref: ctx.ref
});
}
});
noteExportTable.value.data = data;
}
async function exportNoteExport() {
let ctx;
loading.value = true;
const data = [...noteExportTable.value.data].reverse();
progressTotal.value = data.length;
try {
for (let i = data.length - 1; i >= 0; i--) {
if (props.isNoteExportDialogVisible && loading.value) {
ctx = data[i];
await miscRequest.saveNote({
targetUserId: ctx.id,
note: ctx.memo.slice(0, 256)
});
utils.removeFromArray(noteExportTable.value.data, ctx);
progress.value++;
await new Promise((resolve) => {
workerTimers.setTimeout(resolve, 5000);
});
}
}
} catch (err) {
errors.value = `Name: ${ctx?.name}\n${err}\n\n`;
} finally {
progress.value = 0;
progressTotal.value = 0;
loading.value = false;
}
}
function cancelNoteExport() {
loading.value = false;
}
function removeFromNoteExportTable(ref) {
utils.removeFromArray(noteExportTable.value.data, ref);
}
function closeDialog() {
emit('update:isNoteExportDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,80 @@
<template>
<el-dialog
class="x-dialog"
:visible="isNotificationPositionDialogVisible"
:title="t('dialog.notification_position.header')"
width="400px"
:before-close="beforeDialogClose"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div style="font-size: 12px">
{{ t('dialog.notification_position.description') }}
</div>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 300 200"
style="margin-top: 15px"
xml:space="preserve"
class="notification-position">
<path
style="fill: black"
d="M291.89,5A3.11,3.11,0,0,1,295,8.11V160.64a3.11,3.11,0,0,1-3.11,3.11H8.11A3.11,3.11,0,0,1,5,160.64V8.11A3.11,3.11,0,0,1,8.11,5H291.89m0-5H8.11A8.11,8.11,0,0,0,0,8.11V160.64a8.11,8.11,0,0,0,8.11,8.11H291.89a8.11,8.11,0,0,0,8.11-8.11V8.11A8.11,8.11,0,0,0,291.89,0Z" />
<rect style="fill: #c4c4c4" x="5" y="5" width="290" height="158.75" rx="2.5" />
</svg>
<el-radio-group :value="notificationPosition" size="mini" @input="changeNotificationPosition">
<el-radio label="topLeft" style="margin: 0; position: absolute; left: 35px; top: 120px"></el-radio>
<el-radio label="top" style="margin: 0; position: absolute; left: 195px; top: 120px"></el-radio>
<el-radio label="topRight" style="margin: 0; position: absolute; right: 25px; top: 120px"></el-radio>
<el-radio label="centerLeft" style="margin: 0; position: absolute; left: 35px; top: 200px"></el-radio>
<el-radio label="center" style="margin: 0; position: absolute; left: 195px; top: 200px"></el-radio>
<el-radio label="centerRight" style="margin: 0; position: absolute; right: 25px; top: 200px"></el-radio>
<el-radio label="bottomLeft" style="margin: 0; position: absolute; left: 35px; top: 280px"></el-radio>
<el-radio label="bottom" style="margin: 0; position: absolute; left: 195px; top: 280px"></el-radio>
<el-radio label="bottomRight" style="margin: 0; position: absolute; right: 25px; top: 280px"></el-radio>
</el-radio-group>
<template #footer>
<div style="display: flex">
<el-button type="primary" size="small" style="margin-left: auto" @click="closeDialog">
{{ t('dialog.notification_position.ok') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
defineProps({
isNotificationPositionDialogVisible: {
type: Boolean,
default: false
},
notificationPosition: {
type: String,
default: 'topRight'
}
});
const emit = defineEmits(['update:isNotificationPositionDialogVisible', 'changeNotificationPosition']);
function closeDialog() {
emit('update:isNotificationPositionDialogVisible', false);
}
function changeNotificationPosition(value) {
emit('changeNotificationPosition', value);
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="ossDialog"
:title="t('dialog.open_source.header')"
width="650px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div v-once style="height: 350px; overflow: hidden scroll; word-break: break-all">
<div>
<span>{{ t('dialog.open_source.description') }} }}</span>
</div>
<div v-for="lib in openSourceSoftwareLicenses" :key="lib.name" style="margin-top: 15px">
<p style="font-weight: bold">{{ lib.name }}</p>
<pre style="font-size: 12px; white-space: pre-line">{{ lib.licenseText }}</pre>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { inject } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { openSourceSoftwareLicenses } from '../../../composables/settings/constants/openSourceSoftwareLicenses';
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const { t } = useI18n();
defineProps({
ossDialog: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:ossDialog']);
function closeDialog() {
emit('update:ossDialog', false);
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<el-dialog
class="x-dialog"
:visible.sync="enablePrimaryPasswordDialog.visible"
:before-close="enablePrimaryPasswordDialog.beforeClose"
:close-on-click-modal="false"
:title="t('dialog.primary_password.header')"
width="400px">
<el-input
v-model="enablePrimaryPasswordDialog.password"
:placeholder="t('dialog.primary_password.password_placeholder')"
type="password"
size="mini"
maxlength="32"
show-password
autofocus>
</el-input>
<el-input
v-model="enablePrimaryPasswordDialog.rePassword"
:placeholder="t('dialog.primary_password.re_input_placeholder')"
type="password"
style="margin-top: 5px"
size="mini"
maxlength="32"
show-password>
</el-input>
<template #footer>
<el-button
type="primary"
size="small"
:disabled="
enablePrimaryPasswordDialog.password.length === 0 ||
enablePrimaryPasswordDialog.password !== enablePrimaryPasswordDialog.rePassword
"
@click="setPrimaryPassword">
{{ t('dialog.primary_password.ok') }}
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const props = defineProps({
enablePrimaryPasswordDialog: {
type: Object,
required: true
}
});
const emit = defineEmits(['setPrimaryPassword']);
function setPrimaryPassword() {
emit('setPrimaryPassword', props.enablePrimaryPasswordDialog.password);
props.enablePrimaryPasswordDialog.visible = false;
}
</script>

View File

@@ -0,0 +1,305 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isRegistryBackupDialogVisible"
:title="t('dialog.registry_backup.header')"
width="600px"
@close="closeDialog"
@closed="clearVrcRegistryDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<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>
</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>
</template>
</el-table-column>
<el-table-column :label="t('dialog.registry_backup.action')" width="90" align="right">
<template #default="scope">
<el-tooltip
placement="top"
:content="t('dialog.registry_backup.restore')"
:disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-upload2"
size="mini"
@click="restoreVrcRegistryBackup(scope.row)"></el-button>
</el-tooltip>
<el-tooltip
placement="top"
:content="t('dialog.registry_backup.save_to_file')"
:disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-download"
size="mini"
@click="saveVrcRegistryBackupToFile(scope.row)"></el-button>
</el-tooltip>
<el-tooltip
placement="top"
:content="t('dialog.registry_backup.delete')"
:disabled="hideTooltips">
<el-button
type="text"
icon="el-icon-delete"
size="mini"
@click="deleteVrcRegistryBackup(scope.row)"></el-button>
</el-tooltip>
</template>
</el-table-column>
</data-tables>
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 10px">
<el-button type="danger" size="small" @click="deleteVrcRegistry">{{
t('dialog.registry_backup.reset')
}}</el-button>
<div>
<el-button size="small" @click="promptVrcRegistryBackupName">{{
t('dialog.registry_backup.backup')
}}</el-button>
<el-button size="small" @click="restoreVrcRegistryFromFile">{{
t('dialog.registry_backup.restore_from_file')
}}</el-button>
</div>
</div>
</div>
</el-dialog>
</template>
<script setup>
import { getCurrentInstance, inject, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import configRepository from '../../../service/config';
import utils from '../../../classes/utils';
const { t } = useI18n();
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
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: {
stripe: true,
size: 'mini',
defaultSort: {
prop: 'date',
order: 'descending'
}
},
layout: 'table'
});
const vrcRegistryAutoBackup = ref(false);
watch(
() => props.isRegistryBackupDialogVisible,
(newVal) => {
if (newVal) {
updateRegistryBackupDialog();
}
}
);
setVrcRegistryAutoBackup();
function setVrcRegistryAutoBackup() {
configRepository.getBool('VRCX_vrcRegistryAutoBackup', true).then((value) => {
vrcRegistryAutoBackup.value = value;
});
}
async function updateRegistryBackupDialog() {
let 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',
cancelButtonText: 'Cancel',
type: 'warning',
callback: (action) => {
if (action !== 'confirm') {
return;
}
const data = JSON.stringify(row.data);
AppApi.SetVRChatRegistry(data)
.then(() => {
$message({
message: 'VRC registry settings restored',
type: 'success'
});
})
.catch((e) => {
console.error(e);
$message({
message: `Failed to restore VRC registry settings, check console for full error: ${e}`,
type: 'error'
});
});
}
});
}
function saveVrcRegistryBackupToFile(row) {
utils.downloadAndSaveJson(row.name, row.data);
}
async function deleteVrcRegistryBackup(row) {
const backups = registryBackupTable.value.data;
utils.removeFromArray(backups, row);
await configRepository.setString('VRCX_VRChatRegistryBackups', JSON.stringify(backups));
await updateRegistryBackupDialog();
}
function deleteVrcRegistry() {
$confirm('Continue? Delete VRC Registry Settings', 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
callback: (action) => {
if (action !== 'confirm') {
return;
}
AppApi.DeleteVRChatRegistryFolder().then(() => {
$message({
message: 'VRC registry settings deleted',
type: 'success'
});
});
}
});
}
async function handleBackupVrcRegistry(name) {
await props.backupVrcRegistry(name);
await updateRegistryBackupDialog();
}
async function promptVrcRegistryBackupName() {
const name = await $prompt('Enter a name for the backup', 'Backup Name', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
inputPattern: /\S+/,
inputErrorMessage: 'Name is required',
inputValue: 'Backup'
});
if (name.action === 'confirm') {
await handleBackupVrcRegistry(name.value);
}
}
async function openJsonFileSelectorDialogElectron() {
return new Promise((resolve) => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.json';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
fileInput.onchange = function (event) {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function () {
fileInput.remove();
resolve(reader.result);
};
reader.readAsText(file);
} else {
fileInput.remove();
resolve(null);
}
};
fileInput.click();
});
}
async function restoreVrcRegistryFromFile() {
const filePath = await AppApi.OpenFileSelectorDialog(null, '.json', 'JSON Files (*.json)|*.json');
if (WINDOWS) {
if (filePath === '') {
return;
}
}
let json;
if (LINUX) {
json = await openJsonFileSelectorDialogElectron();
} else {
json = await AppApi.ReadVrcRegJsonFile(filePath);
}
try {
const data = JSON.parse(json);
if (!data || typeof data !== 'object') {
throw new Error('Invalid JSON');
}
// quick check to make sure it's a valid registry backup
for (const key in data) {
const value = data[key];
if (typeof value !== 'object' || typeof value.type !== 'number' || typeof value.data === 'undefined') {
throw new Error('Invalid JSON');
}
}
AppApi.SetVRChatRegistry(json)
.then(() => {
$message({
message: 'VRC registry settings restored',
type: 'success'
});
})
.catch((e) => {
console.error(e);
$message({
message: `Failed to restore VRC registry settings, check console for full error: ${e}`,
type: 'error'
});
});
} catch {
$message({
message: 'Invalid JSON',
type: 'error'
});
}
}
function clearVrcRegistryDialog() {
registryBackupTable.value.data = [];
}
function closeDialog() {
emit('update:isRegistryBackupDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,517 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible.sync="screenshotMetadataDialog.visible"
:title="t('dialog.screenshot_metadata.header')"
width="1050px"
top="10vh"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div
v-if="screenshotMetadataDialog.visible"
v-loading="screenshotMetadataDialog.loading"
style="-webkit-app-region: drag"
@dragover.prevent
@dragenter.prevent
@drop="handleDrop">
<span style="margin-left: 5px; color: #909399; font-family: monospace">{{
t('dialog.screenshot_metadata.drag')
}}</span>
<br />
<br />
<el-button size="small" icon="el-icon-folder-opened" @click="getAndDisplayScreenshotFromFile">{{
t('dialog.screenshot_metadata.browse')
}}</el-button>
<el-button size="small" icon="el-icon-picture-outline" @click="getAndDisplayLastScreenshot">{{
t('dialog.screenshot_metadata.last_screenshot')
}}</el-button>
<el-button
size="small"
icon="el-icon-copy-document"
@click="copyImageToClipboard(screenshotMetadataDialog.metadata.filePath)"
>{{ t('dialog.screenshot_metadata.copy_image') }}</el-button
>
<el-button
size="small"
icon="el-icon-folder"
@click="openImageFolder(screenshotMetadataDialog.metadata.filePath)"
>{{ t('dialog.screenshot_metadata.open_folder') }}</el-button
>
<el-button
v-if="API.currentUser.$isVRCPlus && screenshotMetadataDialog.metadata.filePath"
size="small"
icon="el-icon-upload2"
@click="uploadScreenshotToGallery"
>{{ t('dialog.screenshot_metadata.upload') }}</el-button
>
<br />
<br />
<!-- Search bar input -->
<el-input
v-model="screenshotMetadataDialog.search"
size="small"
placeholder="Search"
clearable
style="width: 200px"
@input="screenshotMetadataSearch" />
<!-- Search type dropdown -->
<el-select
v-model="screenshotMetadataDialog.searchType"
size="small"
placeholder="Search Type"
style="width: 150px; margin-left: 10px"
@change="screenshotMetadataSearch">
<el-option
v-for="type in screenshotMetadataDialog.searchTypes"
:key="type"
:label="type"
:value="type" />
</el-select>
<!-- Search index/total label -->
<template v-if="screenshotMetadataDialog.searchIndex !== null">
<span style="white-space: pre-wrap; font-size: 12px; margin-left: 10px">{{
screenshotMetadataDialog.searchIndex + 1 + '/' + screenshotMetadataDialog.searchResults.length
}}</span>
</template>
<br />
<br />
<span v-text="screenshotMetadataDialog.metadata.fileName"></span>
<br />
<template v-if="screenshotMetadataDialog.metadata.note">
<span v-text="screenshotMetadataDialog.metadata.note"></span>
<br />
</template>
<span v-if="screenshotMetadataDialog.metadata.dateTime" style="margin-right: 5px">{{
screenshotMetadataDialog.metadata.dateTime | formatDate('long')
}}</span>
<span
v-if="screenshotMetadataDialog.metadata.fileResolution"
style="margin-right: 5px"
v-text="screenshotMetadataDialog.metadata.fileResolution"></span>
<el-tag v-if="screenshotMetadataDialog.metadata.fileSize" type="info" effect="plain" size="mini">{{
screenshotMetadataDialog.metadata.fileSize
}}</el-tag>
<br />
<location
v-if="screenshotMetadataDialog.metadata.world"
:location="screenshotMetadataDialog.metadata.world.instanceId"
:hint="screenshotMetadataDialog.metadata.world.name" />
<br />
<display-name
v-if="screenshotMetadataDialog.metadata.author"
:userid="screenshotMetadataDialog.metadata.author.id"
:hint="screenshotMetadataDialog.metadata.author.displayName"
style="color: #909399; font-family: monospace" />
<br />
<el-carousel
ref="screenshotMetadataCarouselRef"
:interval="0"
:initial-index="1"
indicator-position="none"
arrow="always"
height="600px"
style="margin-top: 10px"
@change="screenshotMetadataCarouselChange">
<el-carousel-item>
<span placement="top" width="700px" trigger="click">
<img
slot="reference"
class="x-link"
:src="screenshotMetadataDialog.metadata.previousFilePath"
style="width: 100%; height: 100%; object-fit: contain" />
</span>
</el-carousel-item>
<el-carousel-item>
<span
placement="top"
width="700px"
trigger="click"
@click="showFullscreenImageDialog(screenshotMetadataDialog.metadata.filePath)">
<img
slot="reference"
class="x-link"
:src="screenshotMetadataDialog.metadata.filePath"
style="width: 100%; height: 100%; object-fit: contain" />
</span>
</el-carousel-item>
<el-carousel-item>
<span placement="top" width="700px" trigger="click">
<img
slot="reference"
class="x-link"
:src="screenshotMetadataDialog.metadata.nextFilePath"
style="width: 100%; height: 100%; object-fit: contain" />
</span>
</el-carousel-item>
</el-carousel>
<br />
<template v-if="screenshotMetadataDialog.metadata.error">
<pre
style="white-space: pre-wrap; font-size: 12px"
v-text="screenshotMetadataDialog.metadata.error"></pre>
<br />
</template>
<span v-for="user in screenshotMetadataDialog.metadata.players" :key="user.id" style="margin-top: 5px">
<span class="x-link" @click="lookupUser(user)" v-text="user.displayName"></span>
<span
v-if="user.pos"
style="margin-left: 5px; color: #909399; font-family: monospace"
v-text="'(' + user.pos.x + ', ' + user.pos.y + ', ' + user.pos.z + ')'"></span>
<br />
<br />
</span>
</div>
</el-dialog>
</template>
<script setup>
import { ref, inject, computed, getCurrentInstance, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { vrcPlusImageRequest } from '../../../api';
import Location from '../../../components/Location.vue';
const API = inject('API');
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
const showFullscreenImageDialog = inject('showFullscreenImageDialog');
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
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) => {
if (newVal) {
if (!props.screenshotMetadataDialog.metadata.filePath) {
getAndDisplayLastScreenshot();
}
window.addEventListener('keyup', handleComponentKeyup);
} else {
window.removeEventListener('keyup', handleComponentKeyup);
}
}
);
const screenshotMetadataSearchInputs = ref(0);
const screenshotMetadataCarouselRef = ref(null);
const handleComponentKeyup = (event) => {
const carouselNavigation = { ArrowLeft: 0, ArrowRight: 2 }[event.key];
if (typeof carouselNavigation !== 'undefined' && props.screenshotMetadataDialog?.visible) {
screenshotMetadataCarouselChange(carouselNavigation);
}
};
function handleDrop(event) {
if (props.currentlyDroppingFile === null) {
return;
}
console.log('Dropped file into viewer: ', props.currentlyDroppingFile);
screenshotMetadataResetSearch();
getAndDisplayScreenshot(props.currentlyDroppingFile);
event.preventDefault();
}
async function getAndDisplayScreenshotFromFile() {
let filePath = '';
// eslint-disable-next-line no-undef
if (LINUX) {
filePath = await window.electron.openFileDialog(); // PNG filter is applied in main.js
} else {
filePath = await AppApi.OpenFileSelectorDialog(
await AppApi.GetVRChatPhotosLocation(),
'.png',
'PNG Files (*.png)|*.png'
);
}
if (filePath === '') {
return;
}
screenshotMetadataResetSearch();
getAndDisplayScreenshot(filePath);
}
function getAndDisplayLastScreenshot() {
screenshotMetadataResetSearch();
AppApi.GetLastScreenshot().then((path) => {
if (!path) {
return;
}
getAndDisplayScreenshot(path);
});
}
function copyImageToClipboard(path) {
if (!path) {
return;
}
AppApi.CopyImageToClipboard(path).then(() => {
$message({
message: 'Image copied to clipboard',
type: 'success'
});
});
}
function openImageFolder(path) {
if (!path) {
return;
}
AppApi.OpenFolderAndSelectItem(path).then(() => {
$message({
message: 'Opened image folder',
type: 'success'
});
});
}
function uploadScreenshotToGallery() {
const D = props.screenshotMetadataDialog;
if (D.metadata.fileSizeBytes > 10000000) {
$message({
message: t('message.file.too_large'),
type: 'error'
});
return;
}
D.isUploading = true;
AppApi.GetFileBase64(D.metadata.filePath)
.then((base64Body) => {
vrcPlusImageRequest
.uploadGalleryImage(base64Body)
.then((args) => {
$message({
message: t('message.gallery.uploaded'),
type: 'success'
});
return args;
})
.finally(() => {
D.isUploading = false;
});
})
.catch((err) => {
$message({
message: t('message.gallery.failed'),
type: 'error'
});
console.error(err);
D.isUploading = false;
});
}
function screenshotMetadataSearch() {
const D = props.screenshotMetadataDialog;
// Don't search if user is still typing
screenshotMetadataSearchInputs.value++;
let current = screenshotMetadataSearchInputs.value;
setTimeout(() => {
if (current !== screenshotMetadataSearchInputs.value) {
return;
}
screenshotMetadataSearchInputs.value = 0;
if (D.search === '') {
screenshotMetadataResetSearch();
if (D.metadata.filePath !== null) {
// Re-retrieve the current screenshot metadata and get previous/next files for regular carousel directory navigation
getAndDisplayScreenshot(D.metadata.filePath, true);
}
return;
}
const searchType = D.searchTypes.indexOf(D.searchType); // Matches the search type enum in .NET
D.loading = true;
AppApi.FindScreenshotsBySearch(D.search, searchType)
.then((json) => {
const results = JSON.parse(json);
if (results.length === 0) {
D.metadata = {};
D.metadata.error = 'No results found';
D.searchIndex = null;
D.searchResults = null;
return;
}
D.searchIndex = 0;
D.searchResults = results;
// console.log("Search results", results)
getAndDisplayScreenshot(results[0], false);
})
.finally(() => {
D.loading = false;
});
}, 500);
}
function screenshotMetadataCarouselChange(index) {
const D = props.screenshotMetadataDialog;
const searchIndex = D.searchIndex;
if (searchIndex !== null) {
screenshotMetadataCarouselChangeSearch(index);
return;
}
if (index === 0) {
if (D.metadata.previousFilePath) {
getAndDisplayScreenshot(D.metadata.previousFilePath);
} else {
getAndDisplayScreenshot(D.metadata.filePath);
}
}
if (index === 2) {
if (D.metadata.nextFilePath) {
getAndDisplayScreenshot(D.metadata.nextFilePath);
} else {
getAndDisplayScreenshot(D.metadata.filePath);
}
}
if (typeof screenshotMetadataCarouselRef.value !== 'undefined') {
screenshotMetadataCarouselRef.value.setActiveItem(1);
}
if (props.fullscreenImageDialog.visible) {
// TODO
}
}
function lookupUser(user) {
emit('lookupUser', user);
}
function screenshotMetadataResetSearch() {
const D = props.screenshotMetadataDialog;
D.search = '';
D.searchIndex = null;
D.searchResults = null;
}
function screenshotMetadataCarouselChangeSearch(index) {
const D = props.screenshotMetadataDialog;
let searchIndex = D.searchIndex;
const filesArr = D.searchResults;
if (searchIndex === null) {
return;
}
if (index === 0) {
if (searchIndex > 0) {
getAndDisplayScreenshot(filesArr[searchIndex - 1], false);
searchIndex--;
} else {
getAndDisplayScreenshot(filesArr[filesArr.length - 1], false);
searchIndex = filesArr.length - 1;
}
} else if (index === 2) {
if (searchIndex < filesArr.length - 1) {
getAndDisplayScreenshot(filesArr[searchIndex + 1], false);
searchIndex++;
} else {
getAndDisplayScreenshot(filesArr[0], false);
searchIndex = 0;
}
}
if (typeof screenshotMetadataCarouselRef.value !== 'undefined') {
screenshotMetadataCarouselRef.value.setActiveItem(1);
}
D.searchIndex = searchIndex;
}
function getAndDisplayScreenshot(path, needsCarouselFiles = true) {
AppApi.GetScreenshotMetadata(path).then((metadata) => displayScreenshotMetadata(metadata, needsCarouselFiles));
}
/**
* Function receives an unmodified json string grabbed from the screenshot file
* Error checking and and verification of data is done in .NET already; In the case that the data/file is invalid, a JSON object with the token "error" will be returned containing a description of the problem.
* Example: {"error":"Invalid file selected. Please select a valid VRChat screenshot."}
* See docs/screenshotMetadata.json for schema
* @param {string} metadata - JSON string grabbed from PNG file
* @param {string} needsCarouselFiles - Whether or not to get the last/next files for the carousel
* @returns {void}
*/
async function displayScreenshotMetadata(json, needsCarouselFiles = true) {
let time;
let date;
const D = props.screenshotMetadataDialog;
const metadata = JSON.parse(json);
if (!metadata?.sourceFile) {
D.metadata = {};
D.metadata.error = 'Invalid file selected. Please select a valid VRChat screenshot.';
return;
}
// Get extra data for display dialog like resolution, file size, etc
D.loading = true;
const extraData = await AppApi.GetExtraScreenshotData(metadata.sourceFile, needsCarouselFiles);
D.loading = false;
const extraDataObj = JSON.parse(extraData);
Object.assign(metadata, extraDataObj);
// console.log("Displaying screenshot metadata", json, "extra data", extraDataObj, "path", json.filePath)
D.metadata = metadata;
const regex = metadata.fileName.match(
/VRChat_((\d{3,})x(\d{3,})_(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.(\d{1,})|(\d{4})-(\d{2})-(\d{2})_(\d{2})-(\d{2})-(\d{2})\.(\d{3})_(\d{3,})x(\d{3,}))/
);
if (regex) {
if (typeof regex[2] !== 'undefined' && regex[4].length === 4) {
// old format
// VRChat_3840x2160_2022-02-02_03-21-39.771
date = `${regex[4]}-${regex[5]}-${regex[6]}`;
time = `${regex[7]}:${regex[8]}:${regex[9]}`;
D.metadata.dateTime = Date.parse(`${date} ${time}`);
// D.metadata.resolution = `${regex[2]}x${regex[3]}`;
} else if (typeof regex[11] !== 'undefined' && regex[11].length === 4) {
// new format
// VRChat_2023-02-16_10-39-25.274_3840x2160
date = `${regex[11]}-${regex[12]}-${regex[13]}`;
time = `${regex[14]}:${regex[15]}:${regex[16]}`;
D.metadata.dateTime = Date.parse(`${date} ${time}`);
// D.metadata.resolution = `${regex[18]}x${regex[19]}`;
}
}
if (metadata.timestamp) {
D.metadata.dateTime = Date.parse(metadata.timestamp);
}
if (!D.metadata.dateTime) {
D.metadata.dateTime = Date.parse(metadata.creationDate);
}
if (props.fullscreenImageDialog?.visible) {
showFullscreenImageDialog(D.metadata.filePath);
}
}
</script>

View File

@@ -0,0 +1,418 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isVRChatConfigDialogVisible"
:title="t('dialog.config_json.header')"
width="420px"
top="10vh"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div v-loading="loading">
<div style="font-size: 12px; word-break: keep-all">
{{ t('dialog.config_json.description1') }} <br />
{{ t('dialog.config_json.description2') }}
</div>
<br />
<span style="margin-right: 5px">{{ t('dialog.config_json.cache_size') }}</span>
<span v-text="VRChatUsedCacheSize"></span>
<span>/</span>
<span v-text="totalCacheSize"></span>
<span>GB</span>
<el-tooltip placement="top" :content="t('dialog.config_json.refresh')" :disabled="hideTooltips">
<el-button
type="default"
:loading="VRChatCacheSizeLoading"
size="small"
icon="el-icon-refresh"
circle
style="margin-left: 5px"
@click="getVRChatCacheSize"></el-button>
</el-tooltip>
<div style="margin-top: 10px">
<span style="margin-right: 5px">{{ t('dialog.config_json.delete_all_cache') }}</span>
<el-button
size="small"
style="margin-left: 5px"
icon="el-icon-delete"
@click="showDeleteAllVRChatCacheConfirm"
>{{ t('dialog.config_json.delete_cache') }}</el-button
>
</div>
<div style="margin-top: 10px">
<span style="margin-right: 5px">{{ t('dialog.config_json.delete_old_cache') }}</span>
<el-button
size="small"
style="margin-left: 5px"
icon="el-icon-folder-delete"
@click="sweepVRChatCache"
>{{ t('dialog.config_json.sweep_cache') }}</el-button
>
</div>
<div v-for="(item, value) in VRChatConfigList" :key="value" style="display: block; margin-top: 10px">
<span style="word-break: keep-all">{{ item.name }}:</span>
<div style="display: flex">
<el-input
v-model="VRChatConfigFile[value]"
:placeholder="item.default"
size="mini"
:type="item.type ? item.type : 'text'"
:min="item.min"
:max="item.max"
style="flex: 1; margin-top: 5px"
><el-button
v-if="item.folderBrowser"
slot="append"
size="mini"
icon="el-icon-folder-opened"
@click="openConfigFolderBrowser(value)"></el-button
></el-input>
</div>
</div>
<div style="display: inline-block; margin-top: 10px">
<span>{{ t('dialog.config_json.camera_resolution') }}</span>
<br />
<el-dropdown
size="small"
trigger="click"
style="margin-top: 5px"
@command="(command) => setVRChatCameraResolution(command)">
<el-button size="small">
<span>
<span v-text="getVRChatCameraResolution()"></span>
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="row in VRChatCameraResolutions" :key="row.index" :command="row">{{
row.name
}}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<br />
<div style="display: inline-block; margin-top: 10px">
<span>{{ t('dialog.config_json.spout_resolution') }}</span>
<br />
<el-dropdown
size="small"
trigger="click"
style="margin-top: 5px"
@command="(command) => setVRChatSpoutResolution(command)">
<el-button size="small">
<span>
<span v-text="getVRChatSpoutResolution()"></span>
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="row in VRChatScreenshotResolutions"
:key="row.index"
:command="row"
>{{ row.name }}</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<br />
<div style="display: inline-block; margin-top: 10px">
<span>{{ t('dialog.config_json.screenshot_resolution') }}</span>
<br />
<el-dropdown
size="small"
trigger="click"
style="margin-top: 5px"
@command="(command) => setVRChatScreenshotResolution(command)">
<el-button size="small">
<span>
<span v-text="getVRChatScreenshotResolution()"></span>
<i class="el-icon-arrow-down el-icon--right"></i>
</span>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="row in VRChatScreenshotResolutions"
:key="row.index"
:command="row"
>{{ row.name }}</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<br />
<el-checkbox
v-model="VRChatConfigFile.picture_output_split_by_date"
style="margin-top: 5px; display: block">
{{ t('dialog.config_json.picture_sort_by_date') }}
</el-checkbox>
<el-checkbox v-model="VRChatConfigFile.disableRichPresence" style="margin-top: 5px; display: block">
{{ t('dialog.config_json.disable_discord_presence') }}
</el-checkbox>
</div>
<template #footer>
<div style="display: flex; align-items: center; justify-content: space-between">
<div>
<el-button
size="small"
@click="openExternalLink('https://docs.vrchat.com/docs/configuration-file')"
>{{ t('dialog.config_json.vrchat_docs') }}</el-button
>
</div>
<div>
<el-button size="small" @click="closeDialog">{{ t('dialog.config_json.cancel') }}</el-button>
<el-button size="small" type="primary" :disabled="loading" @click="saveVRChatConfigFile">{{
t('dialog.config_json.save')
}}</el-button>
</div>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch, inject, getCurrentInstance, computed } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import {
getVRChatResolution,
VRChatScreenshotResolutions,
VRChatCameraResolutions
} from '../../../composables/settings/constants/vrchatResolutions';
const { t } = useI18n();
const instance = getCurrentInstance();
const $confirm = instance.proxy.$confirm;
const $message = instance.proxy.$message;
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
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({
cache_size: {
name: t('dialog.config_json.max_cache_size'),
default: '30',
type: 'number',
min: 30
},
cache_expiry_delay: {
name: t('dialog.config_json.cache_expiry_delay'),
default: '30',
type: 'number',
min: 30
},
cache_directory: {
name: t('dialog.config_json.cache_directory'),
default: '%AppData%\\..\\LocalLow\\VRChat\\VRChat',
folderBrowser: true
},
picture_output_folder: {
name: t('dialog.config_json.picture_directory'),
// my pictures folder
default: `%UserProfile%\\Pictures\\VRChat`,
folderBrowser: true
},
// dynamic_bone_max_affected_transform_count: {
// name: 'Dynamic Bones Limit Max Transforms (0 disable all transforms)',
// default: '32',
// type: 'number',
// min: 0
// },
// dynamic_bone_max_collider_check_count: {
// name: 'Dynamic Bones Limit Max Collider Collisions (0 disable all colliders)',
// default: '8',
// type: 'number',
// min: 0
// },
fpv_steadycam_fov: {
name: t('dialog.config_json.fpv_steadycam_fov'),
default: '50',
type: 'number',
min: 30,
max: 110
}
});
const loading = ref(false);
watch(
() => props.isVRChatConfigDialogVisible,
async (newValue) => {
if (newValue) {
loading.value = true;
await readVRChatConfigFile();
loading.value = false;
}
}
);
const totalCacheSize = computed(() => {
return VRChatConfigFile.value.cache_size || props.VRChatTotalCacheSize;
});
function getVRChatCacheSize() {
emit('getVRChatCacheSize');
}
function showDeleteAllVRChatCacheConfirm() {
$confirm(`Continue? Delete all VRChat cache`, 'Confirm', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'info',
callback: (action) => {
if (action === 'confirm') {
deleteAllVRChatCache();
}
}
});
}
async function deleteAllVRChatCache() {
await AssetBundleManager.DeleteAllCache();
getVRChatCacheSize();
}
function sweepVRChatCache() {
emit('sweepVRChatCache');
}
async function openConfigFolderBrowser(value) {
const oldPath = VRChatConfigFile.value[value];
const newPath = await props.folderSelectorDialog(oldPath);
if (newPath) {
VRChatConfigFile.value[value] = newPath;
}
}
function setVRChatSpoutResolution(res) {
VRChatConfigFile.value.camera_spout_res_height = res.height;
VRChatConfigFile.value.camera_spout_res_width = res.width;
}
function setVRChatCameraResolution(res) {
VRChatConfigFile.value.camera_res_height = res.height;
VRChatConfigFile.value.camera_res_width = res.width;
}
function setVRChatScreenshotResolution(res) {
VRChatConfigFile.value.screenshot_res_height = res.height;
VRChatConfigFile.value.screenshot_res_width = res.width;
}
function getVRChatCameraResolution() {
if (VRChatConfigFile.value.camera_res_height && VRChatConfigFile.value.camera_res_width) {
const res = `${VRChatConfigFile.value.camera_res_width}x${VRChatConfigFile.value.camera_res_height}`;
return getVRChatResolution(res);
}
return '1920x1080 (1080p)';
}
function getVRChatSpoutResolution() {
if (VRChatConfigFile.value.camera_spout_res_height && VRChatConfigFile.value.camera_spout_res_width) {
const res = `${VRChatConfigFile.value.camera_spout_res_width}x${VRChatConfigFile.value.camera_spout_res_height}`;
return getVRChatResolution(res);
}
return '1920x1080 (1080p)';
}
function getVRChatScreenshotResolution() {
if (VRChatConfigFile.value.screenshot_res_height && VRChatConfigFile.value.screenshot_res_width) {
const res = `${VRChatConfigFile.value.screenshot_res_width}x${VRChatConfigFile.value.screenshot_res_height}`;
return getVRChatResolution(res);
}
return '1920x1080 (1080p)';
}
function saveVRChatConfigFile() {
for (const item in VRChatConfigFile.value) {
if (item === 'picture_output_split_by_date') {
// this one is default true, it's special
if (VRChatConfigFile.value[item]) {
delete VRChatConfigFile.value[item];
}
} else if (VRChatConfigFile.value[item] === '') {
delete VRChatConfigFile.value[item];
} else if (typeof VRChatConfigFile.value[item] === 'boolean' && VRChatConfigFile.value[item] === false) {
delete VRChatConfigFile.value[item];
} else if (typeof VRChatConfigFile.value[item] === 'string' && !isNaN(VRChatConfigFile.value[item])) {
VRChatConfigFile.value[item] = parseInt(VRChatConfigFile.value[item], 10);
}
}
WriteVRChatConfigFile();
closeDialog();
}
function WriteVRChatConfigFile() {
const json = JSON.stringify(VRChatConfigFile.value, null, '\t');
AppApi.WriteConfigFile(json);
}
async function readVRChatConfigFile() {
const config = await AppApi.ReadConfigFile();
if (config) {
try {
const parsedConfig = JSON.parse(config);
if (parsedConfig.picture_output_split_by_date === undefined) {
parsedConfig.picture_output_split_by_date = true;
}
VRChatConfigFile.value = { ...VRChatConfigFile.value, ...parsedConfig };
} catch {
$message({
message: 'Invalid JSON in config.json',
type: 'error'
});
throw new Error('Invalid JSON in config.json');
}
}
}
function closeDialog() {
emit('update:isVRChatConfigDialogVisible', false);
}
</script>

View File

@@ -0,0 +1,103 @@
<template>
<el-dialog
class="x-dialog"
:before-close="beforeDialogClose"
:visible="isYouTubeApiDialogVisible"
:title="t('dialog.youtube_api.header')"
width="400px"
@close="closeDialog"
@mousedown.native="dialogMouseDown"
@mouseup.native="dialogMouseUp">
<div style="font-size: 12px">{{ t('dialog.youtube_api.description') }} <br /></div>
<el-input
:value="youTubeApiKey"
type="textarea"
:placeholder="t('dialog.youtube_api.placeholder')"
maxlength="39"
show-word-limit
style="display: block; margin-top: 10px"
@input="updateYouTubeApiKey">
</el-input>
<template #footer>
<div style="display: flex">
<el-button
size="small"
@click="openExternalLink('https://rapidapi.com/blog/how-to-get-youtube-api-key/')">
{{ t('dialog.youtube_api.guide') }}
</el-button>
<el-button type="primary" size="small" style="margin-left: auto" @click="testYouTubeApiKey">
{{ t('dialog.youtube_api.save') }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { inject, getCurrentInstance } from 'vue';
import configRepository from '../../../service/config';
import { useI18n } from 'vue-i18n-bridge';
const { t } = useI18n();
const instance = getCurrentInstance();
const $message = instance.proxy.$message;
const beforeDialogClose = inject('beforeDialogClose');
const dialogMouseDown = inject('dialogMouseDown');
const dialogMouseUp = inject('dialogMouseUp');
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']);
async function testYouTubeApiKey() {
if (!props.youTubeApiKey) {
$message({
message: 'YouTube API key removed',
type: 'success'
});
await configRepository.setString('VRCX_youtubeAPIKey', '');
closeDialog();
return;
}
const data = await props.lookupYouTubeVideo('dQw4w9WgXcQ');
if (!data) {
updateYouTubeApiKey('');
$message({
message: 'Invalid YouTube API key',
type: 'error'
});
} else {
await configRepository.setString('VRCX_youtubeAPIKey', props.youTubeApiKey);
$message({
message: 'YouTube API key valid!',
type: 'success'
});
closeDialog();
}
}
function updateYouTubeApiKey(value) {
emit('update:youTubeApiKey', value);
}
function closeDialog() {
emit('update:isYouTubeApiDialogVisible', false);
}
</script>