mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-24 09:13:50 +02:00
feat: add tools tab
This commit is contained in:
242
src/views/Tools/Tools.vue
Normal file
242
src/views/Tools/Tools.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div id="chart" class="x-container" v-show="menuActiveIndex === 'tools'">
|
||||
<div class="options-container" style="margin-top: 0">
|
||||
<span class="header">Tools</span>
|
||||
|
||||
<div class="tool-categories">
|
||||
<div class="tool-category">
|
||||
<div class="category-header" @click="toggleCategory('group')">
|
||||
<i class="el-icon-arrow-right" :class="{ rotate: !categoryCollapsed['group'] }"></i>
|
||||
<span class="category-title">Group</span>
|
||||
</div>
|
||||
<div class="tools-grid" v-show="!categoryCollapsed['group']">
|
||||
<el-card :body-style="{ padding: '0px' }" class="tool-card">
|
||||
<div class="tool-content" @click="showGroupCalendarDialog">
|
||||
<div class="tool-icon">
|
||||
<i class="ri-calendar-event-line"></i>
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">Calendar</div>
|
||||
<div class="tool-description">Group Events Calendar</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-category">
|
||||
<div class="category-header" @click="toggleCategory('image')">
|
||||
<i class="el-icon-arrow-right" :class="{ rotate: !categoryCollapsed['image'] }"></i>
|
||||
<span class="category-title">Image</span>
|
||||
</div>
|
||||
<div class="tools-grid" v-show="!categoryCollapsed['image']">
|
||||
<el-card :body-style="{ padding: '0px' }" class="tool-card">
|
||||
<div class="tool-content" @click="showScreenshotMetadataDialog">
|
||||
<div class="tool-icon">
|
||||
<i class="ri-screenshot-2-line"></i>
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">Screenshot Management</div>
|
||||
<div class="tool-description">Manage screenshots and view metadata</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card :body-style="{ padding: '0px' }" class="tool-card">
|
||||
<div class="tool-content" @click="showGalleryDialog">
|
||||
<div class="tool-icon">
|
||||
<i class="ri-multi-image-line"></i>
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">VRC+ Images & Inventory Management</div>
|
||||
<div class="tool-description">Manage VRC+ Images & Inventory</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-category">
|
||||
<div class="category-header" @click="toggleCategory('user')">
|
||||
<i class="el-icon-arrow-right" :class="{ rotate: !categoryCollapsed['user'] }"></i>
|
||||
<span class="category-title">User</span>
|
||||
</div>
|
||||
<div class="tools-grid" v-show="!categoryCollapsed['user']">
|
||||
<el-card :body-style="{ padding: '0px' }" class="tool-card">
|
||||
<div class="tool-content" @click="showNoteExportDialog">
|
||||
<div class="tool-icon">
|
||||
<i class="ri-user-shared-line"></i>
|
||||
</div>
|
||||
<div class="tool-info">
|
||||
<div class="tool-name">Export User Notes</div>
|
||||
<div class="tool-description">Export VRCX user memos to VRChat notes</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GroupCalendarDialog :visible="isGroupCalendarDialogVisible" @close="isGroupCalendarDialogVisible = false" />
|
||||
<ScreenshotMetadataDialog
|
||||
:isScreenshotMetadataDialogVisible="isScreenshotMetadataDialogVisible"
|
||||
@close="isScreenshotMetadataDialogVisible = false" />
|
||||
<NoteExportDialog
|
||||
:isNoteExportDialogVisible="isNoteExportDialogVisible"
|
||||
@close="isNoteExportDialogVisible = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n-bridge';
|
||||
import { useUiStore, useGalleryStore } from '../../stores';
|
||||
import GroupCalendarDialog from './dialogs/GroupCalendarDialog.vue';
|
||||
import ScreenshotMetadataDialog from './dialogs/ScreenshotMetadataDialog.vue';
|
||||
import NoteExportDialog from './dialogs/NoteExportDialog.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const uiStore = useUiStore();
|
||||
const { showGalleryDialog } = useGalleryStore();
|
||||
const { menuActiveIndex } = storeToRefs(uiStore);
|
||||
|
||||
const categoryCollapsed = ref({
|
||||
group: false,
|
||||
image: false,
|
||||
user: false
|
||||
});
|
||||
|
||||
const isGroupCalendarDialogVisible = ref(false);
|
||||
const isScreenshotMetadataDialogVisible = ref(false);
|
||||
const isNoteExportDialogVisible = ref(false);
|
||||
|
||||
const showGroupCalendarDialog = () => {
|
||||
isGroupCalendarDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const showScreenshotMetadataDialog = () => {
|
||||
isScreenshotMetadataDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const showNoteExportDialog = () => {
|
||||
isNoteExportDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleCategory = (category) => {
|
||||
categoryCollapsed.value[category] = !categoryCollapsed.value[category];
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tool-categories {
|
||||
margin-top: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.tool-category {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.category-header {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
.el-icon-arrow-right {
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
transition: transform 0.3s;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
::v-deep .el-card__body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px 16px;
|
||||
|
||||
.tool-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 12px;
|
||||
margin-right: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
i {
|
||||
font-size: 28px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
flex: 1;
|
||||
|
||||
.tool-name {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-description {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .el-card {
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
221
src/views/Tools/components/GroupCalendarEventCard.vue
Normal file
221
src/views/Tools/components/GroupCalendarEventCard.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<el-card :body-style="{ padding: '0px' }" class="event-card" :class="cardClass">
|
||||
<img :src="bannerUrl" class="banner" />
|
||||
<div class="event-content">
|
||||
<div class="event-title">
|
||||
<div v-if="showGroupName" class="event-group-name" @click="onGroupClick">
|
||||
{{ groupName }}
|
||||
</div>
|
||||
<el-popover placement="right" width="500" trigger="hover">
|
||||
<el-descriptions :title="event.title" size="small" :column="2" class="event-title-popover">
|
||||
<template #extra>
|
||||
<div>
|
||||
{{ formatTimeRange(event.startsAt, event.endsAt) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-descriptions-item label="Category">{{
|
||||
capitalizeFirst(event.category)
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="Interested User">
|
||||
{{ event.interestedUserCount }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Close Instance After End">
|
||||
{{ event.closeInstanceAfterEndMinutes + ' min' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Created Date">{{
|
||||
dayjs(event.createdAt).format('YYYY-MM-DD HH:mm')
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="Description">{{ event.description }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="event-title-content" slot="reference" @click="onGroupClick">
|
||||
{{ event.title }}
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<div :class="timeClass">
|
||||
{{ formattedTime }}
|
||||
</div>
|
||||
<div>
|
||||
{{ capitalizeFirst(event.accessType) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isFollowing" class="following-badge">
|
||||
<i class="el-icon-check"></i>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useGroupStore } from '../../../stores';
|
||||
|
||||
const { cachedGroups } = storeToRefs(useGroupStore());
|
||||
const { showGroupDialog } = useGroupStore();
|
||||
|
||||
const props = defineProps({
|
||||
event: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
// @ts-ignore
|
||||
validator: (value) => ['timeline', 'grid'].includes(value)
|
||||
},
|
||||
isFollowing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
cardClass: {
|
||||
type: [String, Object, Array],
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const showGroupName = computed(() => props.mode === 'timeline');
|
||||
|
||||
const timeClass = computed(() => (props.mode === 'grid' ? 'event-time' : ''));
|
||||
|
||||
const bannerUrl = computed(() => {
|
||||
if (!props.event) return '';
|
||||
if (props.event.imageUrl) {
|
||||
return props.event.imageUrl;
|
||||
} else {
|
||||
return cachedGroups.value.get(props.event.ownerId)?.bannerUrl || '';
|
||||
}
|
||||
});
|
||||
|
||||
const groupName = computed(() => {
|
||||
if (!props.event) return '';
|
||||
return cachedGroups.value.get(props.event.ownerId)?.name || '';
|
||||
});
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (props.mode === 'timeline') {
|
||||
return formatTimeRange(props.event.startsAt, props.event.endsAt);
|
||||
} else {
|
||||
return `${dayjs(props.event.startsAt).format('MM-DD ddd HH:mm')} - ${dayjs(props.event.endsAt).format('HH:mm')}`;
|
||||
}
|
||||
});
|
||||
|
||||
const formatTimeRange = (startsAt, endsAt) =>
|
||||
`${dayjs(startsAt).format('HH:mm')} - ${dayjs(endsAt).format('HH:mm')}`;
|
||||
|
||||
const capitalizeFirst = (str) => str?.charAt(0).toUpperCase() + str?.slice(1);
|
||||
|
||||
const onGroupClick = () => {
|
||||
showGroupDialog(props.event.ownerId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.event-card {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
&.grouped-card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&.grid-card {
|
||||
flex: 0 0 280px;
|
||||
max-width: 280px;
|
||||
}
|
||||
::v-deep .el-card__body {
|
||||
overflow: visible;
|
||||
}
|
||||
.banner {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 8px 8px 0 0;
|
||||
.timeline-view & {
|
||||
height: 125px;
|
||||
}
|
||||
.grid-view & {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
.following-badge {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -9px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--group-calendar-badge-following, #67c23a);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
.event-content {
|
||||
font-size: 12px;
|
||||
.timeline-view & {
|
||||
padding: 4px 12px 12px 12px;
|
||||
}
|
||||
.grid-view & {
|
||||
padding: 8px 12px 12px 12px;
|
||||
}
|
||||
.event-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.grid-view & {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-group-name {
|
||||
cursor: pointer;
|
||||
.grid-view & {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.event-title-content {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
.timeline-view & {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
.event-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.timeline-view & > :first-child {
|
||||
font-size: 14px;
|
||||
}
|
||||
.grid-view & {
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
.event-time {
|
||||
font-weight: 500;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
::v-deep .el-card {
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
544
src/views/Tools/dialogs/GroupCalendarDialog.vue
Normal file
544
src/views/Tools/dialogs/GroupCalendarDialog.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<safe-dialog
|
||||
class="x-dialog"
|
||||
:visible="visible"
|
||||
:title="t('dialog.group_calendar.header')"
|
||||
:show-close="false"
|
||||
top="10vh"
|
||||
width="90vw"
|
||||
height="80vh"
|
||||
@close="closeDialog">
|
||||
<template #title>
|
||||
<div class="dialog-title-container">
|
||||
<span>{{ t('dialog.group_calendar.header') }}</span>
|
||||
<el-button @click="toggleViewMode" type="primary" size="small" class="view-toggle-btn">
|
||||
{{ viewMode === 'timeline' ? 'List View' : 'Calendar View' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="top-content">
|
||||
<transition name="el-fade-in-linear" mode="out-in">
|
||||
<div v-if="viewMode === 'timeline'" key="timeline" class="timeline-view">
|
||||
<div class="timeline-container">
|
||||
<el-timeline v-if="groupedTimelineEvents.length">
|
||||
<el-timeline-item
|
||||
v-for="(timeGroup, key) of groupedTimelineEvents"
|
||||
:key="key"
|
||||
:timestamp="dayjs(timeGroup.startsAt).format('MM-DD ddd') + ' ' + timeGroup.startTime"
|
||||
placement="top">
|
||||
<div class="time-group-container">
|
||||
<GroupCalendarEventCard
|
||||
v-for="value in timeGroup.events"
|
||||
:key="value.id"
|
||||
:event="value"
|
||||
mode="timeline"
|
||||
:is-following="isEventFollowing(value.id)"
|
||||
:card-class="{ 'grouped-card': timeGroup.events.length > 1 }" />
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<div v-else>No events found</div>
|
||||
</div>
|
||||
|
||||
<div class="calendar-container">
|
||||
<el-calendar v-model="selectedDay" v-loading="isLoading">
|
||||
<template #dateCell="{ date }">
|
||||
<div class="date">
|
||||
<div
|
||||
class="calendar-date-content"
|
||||
:class="{
|
||||
'has-events': filteredCalendar[formatDateKey(date)]?.length
|
||||
}">
|
||||
{{ dayjs(date).local().format('D') }}
|
||||
<div
|
||||
v-if="filteredCalendar[formatDateKey(date)]?.length"
|
||||
class="calendar-event-badge"
|
||||
:class="
|
||||
followingCalendarDate[formatDateKey(date)]
|
||||
? 'has-following'
|
||||
: 'no-following'
|
||||
">
|
||||
{{ filteredCalendar[formatDateKey(date)]?.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-calendar>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else key="grid" class="grid-view">
|
||||
<div class="search-container">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="Search groups or events..."
|
||||
clearable
|
||||
size="small"
|
||||
prefix-icon="el-icon-search"
|
||||
class="search-input" />
|
||||
</div>
|
||||
|
||||
<div class="groups-grid" v-loading="isLoading">
|
||||
<div v-if="filteredGroupEvents.length" class="groups-container">
|
||||
<div v-for="group in filteredGroupEvents" :key="group.groupId" class="group-row">
|
||||
<div class="group-header" @click="toggleGroup(group.groupId)">
|
||||
<i
|
||||
class="el-icon-arrow-right"
|
||||
:class="{ rotate: !groupCollapsed[group.groupId] }"></i>
|
||||
{{ group.groupName }}
|
||||
</div>
|
||||
<div class="events-row" v-show="!groupCollapsed[group.groupId]">
|
||||
<GroupCalendarEventCard
|
||||
v-for="event in group.events"
|
||||
:key="event.id"
|
||||
:event="event"
|
||||
mode="grid"
|
||||
:is-following="isEventFollowing(event.id)"
|
||||
card-class="grid-card" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-events">
|
||||
{{ searchQuery ? 'No matching events found' : 'No events this month' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</safe-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n-bridge';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import dayjs from 'dayjs';
|
||||
import { groupRequest } from '../../../api';
|
||||
import { useGroupStore } from '../../../stores';
|
||||
import GroupCalendarEventCard from '../components/GroupCalendarEventCard.vue';
|
||||
import { replaceBioSymbols } from '../../../shared/utils';
|
||||
|
||||
const { cachedGroups } = storeToRefs(useGroupStore());
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const calendar = ref([]);
|
||||
const followingCalendar = ref([]);
|
||||
const selectedDay = ref(new Date());
|
||||
const isLoading = ref(false);
|
||||
const viewMode = ref('timeline');
|
||||
const searchQuery = ref('');
|
||||
const groupCollapsed = ref({});
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (newVisible) => {
|
||||
if (newVisible) {
|
||||
selectedDay.value = new Date();
|
||||
isLoading.value = true;
|
||||
await Promise.all([getCalendarData(), getFollowingCalendarData()])
|
||||
.catch((error) => {
|
||||
console.error('Error fetching calendar data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => selectedDay.value,
|
||||
async (newDate, oldDate) => {
|
||||
if (props.visible && oldDate) {
|
||||
const newMonth = dayjs(newDate).format('YYYY-MM');
|
||||
const oldMonth = dayjs(oldDate).format('YYYY-MM');
|
||||
|
||||
if (newMonth !== oldMonth) {
|
||||
isLoading.value = true;
|
||||
await Promise.all([getCalendarData(), getFollowingCalendarData()])
|
||||
.catch((error) => {
|
||||
console.error('Error fetching calendar data:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const groupedByGroupEvents = computed(() => {
|
||||
const currentMonth = dayjs(selectedDay.value).month();
|
||||
const currentYear = dayjs(selectedDay.value).year();
|
||||
|
||||
const currentMonthEvents = calendar.value.filter((event) => {
|
||||
const eventDate = dayjs(event.startsAt);
|
||||
return eventDate.month() === currentMonth && eventDate.year() === currentYear;
|
||||
});
|
||||
|
||||
const groupMap = new Map();
|
||||
currentMonthEvents.forEach((event) => {
|
||||
const groupId = event.ownerId;
|
||||
if (!groupMap.has(groupId)) {
|
||||
groupMap.set(groupId, []);
|
||||
}
|
||||
groupMap.get(groupId).push(event);
|
||||
});
|
||||
|
||||
Array.from(groupMap.values()).forEach((events) => {
|
||||
events.sort((a, b) => (dayjs(a.startsAt).isBefore(dayjs(b.startsAt)) ? -1 : 1));
|
||||
});
|
||||
|
||||
return Array.from(groupMap.entries()).map(([groupId, events]) => ({
|
||||
groupId,
|
||||
groupName: getGroupName(events[0]),
|
||||
events: events
|
||||
}));
|
||||
});
|
||||
|
||||
const filteredGroupEvents = computed(() => {
|
||||
const hasSearch = searchQuery.value.trim();
|
||||
return !hasSearch
|
||||
? groupedByGroupEvents.value
|
||||
: groupedByGroupEvents.value.filter((group) => {
|
||||
if (group.groupName.toLowerCase().includes(searchQuery.value.toLowerCase())) return true;
|
||||
|
||||
return group.events.some(
|
||||
(event) =>
|
||||
event.title?.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
|
||||
event.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
[filteredGroupEvents, searchQuery],
|
||||
([groups, search]) => {
|
||||
const newCollapsed = { ...groupCollapsed.value };
|
||||
let hasChanged = false;
|
||||
const hasSearch = search.trim();
|
||||
|
||||
groups.forEach((group) => {
|
||||
if (!(group.groupId in newCollapsed)) {
|
||||
newCollapsed[group.groupId] = false;
|
||||
hasChanged = true;
|
||||
} else if (hasSearch) {
|
||||
newCollapsed[group.groupId] = false;
|
||||
hasChanged = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanged) {
|
||||
groupCollapsed.value = newCollapsed;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const filteredCalendar = computed(() => {
|
||||
const result = {};
|
||||
calendar.value.forEach((item) => {
|
||||
const currentDate = formatDateKey(item.startsAt);
|
||||
if (!Array.isArray(result[currentDate])) {
|
||||
result[currentDate] = [];
|
||||
}
|
||||
result[currentDate].push(item);
|
||||
});
|
||||
|
||||
Object.values(result).forEach((events) => {
|
||||
events.sort((a, b) => dayjs(a.startsAt).diff(dayjs(b.startsAt)));
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
const followingCalendarDate = computed(() => {
|
||||
const result = {};
|
||||
|
||||
const followingIds = new Set(followingCalendar.value.map((item) => item.id));
|
||||
|
||||
calendar.value.forEach((event) => {
|
||||
if (followingIds.has(event.id)) {
|
||||
const dateKey = formatDateKey(event.startsAt);
|
||||
if (!result[dateKey]) {
|
||||
result[dateKey] = [];
|
||||
}
|
||||
result[dateKey].push(event.id);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
const formattedSelectedDay = computed(() => {
|
||||
return formatDateKey(selectedDay.value);
|
||||
});
|
||||
|
||||
const groupedTimelineEvents = computed(() => {
|
||||
const eventsForDay = filteredCalendar.value[formattedSelectedDay.value] || [];
|
||||
const timeGroups = {};
|
||||
|
||||
eventsForDay.forEach((event) => {
|
||||
const startTimeKey = dayjs(event.startsAt).format('HH:mm');
|
||||
if (!timeGroups[startTimeKey]) {
|
||||
timeGroups[startTimeKey] = [];
|
||||
}
|
||||
timeGroups[startTimeKey].push(event);
|
||||
});
|
||||
|
||||
return Object.entries(timeGroups)
|
||||
.map(([startTime, events]) => ({
|
||||
startTime,
|
||||
events,
|
||||
startsAt: events[0].startsAt,
|
||||
hasFollowing: events.some((event) => isEventFollowing(event.id))
|
||||
}))
|
||||
.sort((a, b) => dayjs(a.startsAt).diff(dayjs(b.startsAt)));
|
||||
});
|
||||
|
||||
const formatDateKey = (date) => dayjs(date).format('YYYY-MM-DD');
|
||||
|
||||
function getGroupName(event) {
|
||||
if (!event) return '';
|
||||
return cachedGroups.value.get(event.ownerId)?.name || '';
|
||||
}
|
||||
|
||||
async function getCalendarData() {
|
||||
try {
|
||||
const response = await groupRequest.getGroupCalendars(dayjs(selectedDay.value).toISOString());
|
||||
response.results.forEach((event) => {
|
||||
event.title = replaceBioSymbols(event.title);
|
||||
event.description = replaceBioSymbols(event.description);
|
||||
});
|
||||
calendar.value = response.results;
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendars:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function getFollowingCalendarData() {
|
||||
try {
|
||||
const response = await groupRequest.getFollowingGroupCalendars(dayjs(selectedDay.value).toISOString());
|
||||
response.results.forEach((event) => {
|
||||
event.title = replaceBioSymbols(event.title);
|
||||
event.description = replaceBioSymbols(event.description);
|
||||
});
|
||||
followingCalendar.value = response.results;
|
||||
} catch (error) {
|
||||
console.error('Error fetching following calendars:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function isEventFollowing(eventId) {
|
||||
return followingCalendar.value.some((item) => item.id === eventId);
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
viewMode.value = viewMode.value === 'timeline' ? 'grid' : 'timeline';
|
||||
}
|
||||
|
||||
function toggleGroup(groupId) {
|
||||
groupCollapsed.value = {
|
||||
...groupCollapsed.value,
|
||||
[groupId]: !groupCollapsed.value[groupId]
|
||||
};
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.x-dialog {
|
||||
::v-deep .el-dialog {
|
||||
max-height: 750px;
|
||||
.el-dialog__body {
|
||||
height: 680px;
|
||||
}
|
||||
}
|
||||
.top-content {
|
||||
height: 640px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
.timeline-view {
|
||||
.timeline-container {
|
||||
::v-deep .el-timeline {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 200px;
|
||||
padding-left: 4px;
|
||||
padding-right: 16px;
|
||||
margin-left: 10px;
|
||||
margin-right: 6px;
|
||||
overflow: auto;
|
||||
.el-timeline-item {
|
||||
padding: 0 20px 20px 10px;
|
||||
}
|
||||
}
|
||||
.time-group-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
.calendar-container {
|
||||
.date {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.calendar-date-content {
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
position: relative;
|
||||
|
||||
&.has-events {
|
||||
background-color: var(--group-calendar-event-bg, rgba(25, 102, 154, 0.05));
|
||||
}
|
||||
.calendar-event-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
z-index: 10;
|
||||
padding: 0 4px;
|
||||
line-height: 16px;
|
||||
&.has-following {
|
||||
background-color: var(--group-calendar-badge-following, #67c23a);
|
||||
}
|
||||
&.no-following {
|
||||
background-color: var(--group-calendar-badge-normal, #409eff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-title-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
.view-toggle-btn {
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-view {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.timeline-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.calendar-container {
|
||||
width: 609px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-view {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.search-container {
|
||||
padding: 2px 20px 12px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
.search-input {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.groups-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
.groups-container {
|
||||
overflow: visible;
|
||||
.group-row {
|
||||
margin-bottom: 18px;
|
||||
overflow: visible;
|
||||
.group-header {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--el-text-color-primary);
|
||||
padding: 4px 12px 10px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
margin: 0 -12px 10px -12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.el-icon-arrow-right {
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
transition: transform 0.3s;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
.events-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
.no-events {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.rotate {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
</style>
|
||||
201
src/views/Tools/dialogs/NoteExportDialog.vue
Normal file
201
src/views/Tools/dialogs/NoteExportDialog.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<safe-dialog
|
||||
class="x-dialog"
|
||||
:visible="isNoteExportDialogVisible"
|
||||
:title="t('dialog.note_export.header')"
|
||||
width="1000px"
|
||||
@close="closeDialog">
|
||||
<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>
|
||||
</safe-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n-bridge';
|
||||
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 { friends } = storeToRefs(useFriendStore());
|
||||
const { showUserDialog } = useUserStore();
|
||||
const { showFullscreenImageDialog } = useGalleryStore();
|
||||
|
||||
const props = defineProps({
|
||||
isNoteExportDialogVisible: {
|
||||
type: Boolean
|
||||
}
|
||||
});
|
||||
|
||||
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(['close']);
|
||||
|
||||
function updateNoteExportDialog() {
|
||||
const data = [];
|
||||
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({
|
||||
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)
|
||||
});
|
||||
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) {
|
||||
removeFromArray(noteExportTable.value.data, ref);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
557
src/views/Tools/dialogs/ScreenshotMetadataDialog.vue
Normal file
557
src/views/Tools/dialogs/ScreenshotMetadataDialog.vue
Normal file
@@ -0,0 +1,557 @@
|
||||
<template>
|
||||
<safe-dialog
|
||||
class="x-dialog"
|
||||
:visible="isScreenshotMetadataDialogVisible"
|
||||
:title="t('dialog.screenshot_metadata.header')"
|
||||
width="1050px"
|
||||
top="10vh"
|
||||
@close="closeDialog">
|
||||
<div
|
||||
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="currentUser.$isVRCPlus && screenshotMetadataDialog.metadata.filePath"
|
||||
size="small"
|
||||
icon="el-icon-upload2"
|
||||
@click="uploadScreenshotToGallery"
|
||||
>{{ t('dialog.screenshot_metadata.upload') }}</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="screenshotMetadataDialog.metadata.filePath"
|
||||
size="small"
|
||||
icon="el-icon-delete"
|
||||
@click="deleteMetadata(screenshotMetadataDialog.metadata.filePath)"
|
||||
>{{ t('dialog.screenshot_metadata.delete_metadata') }}</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">{{
|
||||
formatDateFilter(screenshotMetadataDialog.metadata.dateTime, '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" />
|
||||
<DisplayName
|
||||
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 />
|
||||
</span>
|
||||
</div>
|
||||
</safe-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { getCurrentInstance, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n-bridge';
|
||||
import { vrcPlusImageRequest } from '../../../api';
|
||||
import { useGalleryStore, useUserStore, useVrcxStore } from '../../../stores';
|
||||
import { formatDateFilter } from '../../../shared/utils';
|
||||
|
||||
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({
|
||||
isScreenshotMetadataDialogVisible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
watch(
|
||||
() => props.isScreenshotMetadataDialogVisible,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (!screenshotMetadataDialog.metadata.filePath) {
|
||||
getAndDisplayLastScreenshot();
|
||||
}
|
||||
window.addEventListener('keyup', handleComponentKeyup);
|
||||
} else {
|
||||
window.removeEventListener('keyup', handleComponentKeyup);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const screenshotMetadataDialog = reactive({
|
||||
// visible: false,
|
||||
loading: false,
|
||||
search: '',
|
||||
searchType: 'Player Name',
|
||||
searchTypes: ['Player Name', 'Player ID', 'World Name', 'World ID'],
|
||||
searchIndex: null,
|
||||
searchResults: null,
|
||||
metadata: {},
|
||||
isUploading: false
|
||||
});
|
||||
|
||||
const screenshotMetadataSearchInputs = ref(0);
|
||||
const screenshotMetadataCarouselRef = ref(null);
|
||||
|
||||
const handleComponentKeyup = (event) => {
|
||||
const carouselNavigation = { ArrowLeft: 0, ArrowRight: 2 }[event.key];
|
||||
if (typeof carouselNavigation !== 'undefined' && props.isScreenshotMetadataDialogVisible) {
|
||||
screenshotMetadataCarouselChange(carouselNavigation);
|
||||
}
|
||||
};
|
||||
|
||||
function closeDialog() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
if (currentlyDroppingFile.value === null) {
|
||||
return;
|
||||
}
|
||||
console.log('Dropped file into viewer: ', currentlyDroppingFile.value);
|
||||
|
||||
screenshotMetadataResetSearch();
|
||||
getAndDisplayScreenshot(currentlyDroppingFile.value);
|
||||
|
||||
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 deleteMetadata(path) {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
AppApi.DeleteScreenshotMetadata(path).then((result) => {
|
||||
if (!result) {
|
||||
$message({
|
||||
message: t('message.screenshot_metadata.delete_failed'),
|
||||
type: 'error'
|
||||
});
|
||||
return;
|
||||
}
|
||||
$message({
|
||||
message: t('message.screenshot_metadata.deleted'),
|
||||
type: 'success'
|
||||
});
|
||||
const D = screenshotMetadataDialog;
|
||||
getAndDisplayScreenshot(D.metadata.filePath, true);
|
||||
});
|
||||
}
|
||||
function uploadScreenshotToGallery() {
|
||||
const D = 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) => {
|
||||
handleGalleryImageAdd(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 = 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 = 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 (fullscreenImageDialog.value.visible) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
function screenshotMetadataResetSearch() {
|
||||
const D = screenshotMetadataDialog;
|
||||
|
||||
D.search = '';
|
||||
D.searchIndex = null;
|
||||
D.searchResults = null;
|
||||
}
|
||||
|
||||
function screenshotMetadataCarouselChangeSearch(index) {
|
||||
const D = 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;
|
||||
}
|
||||
|
||||
async function getAndDisplayScreenshot(path, needsCarouselFiles = true) {
|
||||
const metadata = await AppApi.GetScreenshotMetadata(path);
|
||||
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} json - JSON string grabbed from PNG file
|
||||
* @param {boolean} needsCarouselFiles - Whether or not to get the last/next files for the carousel
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function displayScreenshotMetadata(json, needsCarouselFiles = true) {
|
||||
let time;
|
||||
let date;
|
||||
const D = screenshotMetadataDialog;
|
||||
D.metadata.author = {};
|
||||
D.metadata.world = {};
|
||||
D.metadata.players = [];
|
||||
D.metadata.creationDate = '';
|
||||
D.metadata.application = '';
|
||||
|
||||
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 (fullscreenImageDialog.value.visible) {
|
||||
showFullscreenImageDialog(D.metadata.filePath);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user