feat: add tools tab

This commit is contained in:
pa
2025-08-27 13:04:37 +09:00
committed by Natsumi
parent d92818edea
commit 1b9fd75f97
16 changed files with 439 additions and 118 deletions

View File

@@ -529,7 +529,6 @@
inviteRequestMessageTable,
inviteRequestResponseMessageTable
} = storeToRefs(useInviteStore());
const { showGalleryDialog } = useGalleryStore();
const { menuActiveIndex } = storeToRefs(useUiStore());
const { directAccessWorld } = useSearchStore();
const { logout } = useAuthStore();
@@ -550,6 +549,11 @@
const visits = ref(0);
// redirect to tools tab
function showGalleryDialog() {
menuActiveIndex.value = 'tools';
}
function getVisits() {
miscRequest.getVisits().then((args) => {
visits.value = args.json;

View File

@@ -628,6 +628,7 @@
:label="t('view.settings.appearance.user_dialog.vrcx_memos')"
:value="!hideUserMemos"
@change="setHideUserMemos" />
<!-- redirect to tools tab -->
<div class="options-container-item">
<span class="name">{{
t('view.settings.appearance.user_dialog.export_vrcx_memos_into_vrchat_notes')
@@ -641,6 +642,7 @@
>{{ t('view.settings.appearance.user_dialog.export_notes') }}</el-button
>
</div>
<!-- redirect to tools tab end -->
</div>
<!--//- Appearance | Friend Log-->
<div class="options-container">
@@ -1264,6 +1266,7 @@
<!--//- "Pictures" Tab-->
<el-tab-pane lazy :label="t('view.settings.category.pictures')">
<!-- redirect to tools tab -->
<div class="options-container" style="margin-top: 0">
<span class="header">{{ t('view.settings.category.pictures') }}</span>
<div class="options-container-item" style="margin-top: 15px">
@@ -1274,6 +1277,7 @@
</el-button-group>
</div>
</div>
<!-- redirect to tools tab end -->
<div class="options-container">
<span class="header">{{ t('view.settings.pictures.pictures.open_folder') }}</span>
@@ -1845,9 +1849,7 @@
</el-tab-pane>
</el-tabs>
<OpenSourceSoftwareNoticeDialog :ossDialog.sync="ossDialog" />
<NoteExportDialog :isNoteExportDialogVisible.sync="isNoteExportDialogVisible" />
<NotificationPositionDialog :isNotificationPositionDialogVisible.sync="isNotificationPositionDialogVisible" />
<ScreenshotMetadataDialog :screenshotMetadataDialog="screenshotMetadataDialog" />
<RegistryBackupDialog />
<YouTubeApiDialog :isYouTubeApiDialogVisible.sync="isYouTubeApiDialogVisible" />
<FeedFiltersDialog :feedFiltersDialogMode.sync="feedFiltersDialogMode" />
@@ -1886,9 +1888,7 @@
} from '../../stores';
import { photonEventTableTypeFilterList } from '../../shared/constants';
import OpenSourceSoftwareNoticeDialog from './dialogs/OpenSourceSoftwareNoticeDialog.vue';
import NoteExportDialog from './dialogs/NoteExportDialog.vue';
import NotificationPositionDialog from './dialogs/NotificationPositionDialog.vue';
import ScreenshotMetadataDialog from './dialogs/ScreenshotMetadataDialog.vue';
import RegistryBackupDialog from './dialogs/RegistryBackupDialog.vue';
import YouTubeApiDialog from './dialogs/YouTubeApiDialog.vue';
import ChangelogDialog from './dialogs/ChangelogDialog.vue';
@@ -2179,20 +2179,10 @@
]);
const ossDialog = ref(false);
const isNoteExportDialogVisible = ref(false);
const feedFiltersDialogMode = ref('');
const isNotificationPositionDialogVisible = ref(false);
const isYouTubeApiDialogVisible = ref(false);
const screenshotMetadataDialog = ref({
visible: false,
loading: false,
search: '',
searchType: 'Player Name',
searchTypes: ['Player Name', 'Player ID', 'World Name', 'World ID'],
metadata: {},
isUploading: false
});
const zoomLevel = ref(100);
@@ -2224,16 +2214,18 @@
feedFiltersDialogMode.value = 'wrist';
}
// redirect to tools tab
function showNoteExportDialog() {
isNoteExportDialogVisible.value = true;
menuActiveIndex.value = 'tools';
}
function showNotificationPositionDialog() {
isNotificationPositionDialogVisible.value = true;
}
// redirect to tools tab
function showScreenshotMetadataDialog() {
screenshotMetadataDialog.value.visible = true;
menuActiveIndex.value = 'tools';
}
function openVrcxAppDataFolder() {

View File

@@ -78,11 +78,9 @@
({{ groupInstances.length }})
</span>
</template>
<el-button class="group-calendar-button" icon="el-icon-date" circle @click="openGroupCalendarDialog" />
<GroupsSidebar :group-instances="groupInstances" :group-order="inGameGroupOrder" />
</el-tab-pane>
</el-tabs>
<GroupCalendarDialog :visible="isGroupCalendarDialogVisible" @close="isGroupCalendarDialogVisible = false" />
</div>
</template>
@@ -100,7 +98,6 @@
} from '../../stores';
import FriendsSidebar from './components/FriendsSidebar.vue';
import GroupsSidebar from './components/GroupsSidebar.vue';
import GroupCalendarDialog from '../../components/dialogs/GroupDialog/GroupCalendarDialog.vue';
const { friends, isRefreshFriendsLoading, onlineFriendCount } = storeToRefs(useFriendStore());
const { refreshFriendsList, confirmDeleteFriend } = useFriendStore();
@@ -110,15 +107,9 @@
const { quickSearchItems } = storeToRefs(useSearchStore());
const { inGameGroupOrder, groupInstances } = storeToRefs(useGroupStore());
const isGroupCalendarDialogVisible = ref(false);
const isSideBarTabShow = computed(() => {
return !(menuActiveIndex.value === 'friendList' || menuActiveIndex.value === 'charts');
});
function openGroupCalendarDialog() {
isGroupCalendarDialogVisible.value = true;
}
</script>
<style scoped>

242
src/views/Tools/Tools.vue Normal file
View 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>

View 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>

View 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>

View File

@@ -139,7 +139,7 @@
errors.value = '';
}
const emit = defineEmits(['update:isNoteExportDialogVisible']);
const emit = defineEmits(['close']);
function updateNoteExportDialog() {
const data = [];
@@ -196,6 +196,6 @@
}
function closeDialog() {
emit('update:isNoteExportDialogVisible', false);
emit('close');
}
</script>

View File

@@ -1,12 +1,12 @@
<template>
<safe-dialog
class="x-dialog"
:visible.sync="screenshotMetadataDialog.visible"
:visible="isScreenshotMetadataDialogVisible"
:title="t('dialog.screenshot_metadata.header')"
width="1050px"
top="10vh">
top="10vh"
@close="closeDialog">
<div
v-if="screenshotMetadataDialog.visible"
v-loading="screenshotMetadataDialog.loading"
style="-webkit-app-region: drag"
@dragover.prevent
@@ -170,7 +170,7 @@
<script setup>
import { storeToRefs } from 'pinia';
import { getCurrentInstance, ref, watch } from 'vue';
import { getCurrentInstance, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n-bridge';
import { vrcPlusImageRequest } from '../../../api';
import { useGalleryStore, useUserStore, useVrcxStore } from '../../../stores';
@@ -191,17 +191,19 @@
const { fullscreenImageDialog } = storeToRefs(useGalleryStore());
const props = defineProps({
screenshotMetadataDialog: {
type: Object,
isScreenshotMetadataDialogVisible: {
type: Boolean,
required: true
}
});
const emit = defineEmits(['close']);
watch(
() => props.screenshotMetadataDialog.visible,
() => props.isScreenshotMetadataDialogVisible,
(newVal) => {
if (newVal) {
if (!props.screenshotMetadataDialog.metadata.filePath) {
if (!screenshotMetadataDialog.metadata.filePath) {
getAndDisplayLastScreenshot();
}
window.addEventListener('keyup', handleComponentKeyup);
@@ -211,16 +213,32 @@
}
);
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.screenshotMetadataDialog?.visible) {
if (typeof carouselNavigation !== 'undefined' && props.isScreenshotMetadataDialogVisible) {
screenshotMetadataCarouselChange(carouselNavigation);
}
};
function closeDialog() {
emit('close');
}
function handleDrop(event) {
if (currentlyDroppingFile.value === null) {
return;
@@ -302,12 +320,12 @@
message: t('message.screenshot_metadata.deleted'),
type: 'success'
});
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
getAndDisplayScreenshot(D.metadata.filePath, true);
});
}
function uploadScreenshotToGallery() {
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
if (D.metadata.fileSizeBytes > 10000000) {
$message({
message: t('message.file.too_large'),
@@ -342,7 +360,7 @@
});
}
function screenshotMetadataSearch() {
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
// Don't search if user is still typing
screenshotMetadataSearchInputs.value++;
@@ -390,7 +408,7 @@
}
function screenshotMetadataCarouselChange(index) {
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
const searchIndex = D.searchIndex;
if (searchIndex !== null) {
@@ -422,7 +440,7 @@
}
function screenshotMetadataResetSearch() {
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
D.search = '';
D.searchIndex = null;
@@ -430,7 +448,7 @@
}
function screenshotMetadataCarouselChangeSearch(index) {
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
let searchIndex = D.searchIndex;
const filesArr = D.searchResults;
@@ -480,7 +498,7 @@
async function displayScreenshotMetadata(json, needsCarouselFiles = true) {
let time;
let date;
const D = props.screenshotMetadataDialog;
const D = screenshotMetadataDialog;
D.metadata.author = {};
D.metadata.world = {};
D.metadata.players = [];