mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-04-24 09:13:50 +02:00
631 lines
23 KiB
Vue
631 lines
23 KiB
Vue
<template>
|
|
<Dialog :open="visible" @update:open="(open) => (open ? null : closeDialog())">
|
|
<DialogContent class="x-dialog sm:max-w-[50vw] h-[70vh] overflow-hidden">
|
|
<DialogHeader>
|
|
<div class="dialog-title-container">
|
|
<DialogTitle>{{ t('dialog.group_calendar.header') }}</DialogTitle>
|
|
</div>
|
|
<div class="featured-switch">
|
|
<span class="featured-switch-text">{{ t('dialog.group_calendar.featured_events') }}</span>
|
|
<Switch v-model="showFeaturedEvents" @update:modelValue="toggleFeaturedEvents" class="mr-2" />
|
|
<Button size="sm" variant="outline" @click="toggleViewMode" class="view-toggle-btn">
|
|
{{
|
|
viewMode === 'timeline'
|
|
? t('dialog.group_calendar.list_view')
|
|
: t('dialog.group_calendar.calendar_view')
|
|
}}
|
|
</Button>
|
|
</div>
|
|
</DialogHeader>
|
|
<div class="top-content">
|
|
<div v-if="viewMode === 'timeline'" key="timeline" class="timeline-view">
|
|
<div class="timeline-container">
|
|
<div v-if="groupedTimelineEvents.length" class="timeline-list">
|
|
<div v-for="(timeGroup, key) of groupedTimelineEvents" :key="key" class="timeline-group">
|
|
<div class="timeline-timestamp">
|
|
{{ dayjs(timeGroup.startsAt).format('MM-DD ddd') }} {{ timeGroup.startTime }}
|
|
</div>
|
|
<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 }"
|
|
@update-following-calendar-data="updateFollowingCalendarData"
|
|
@click-action="showGroupDialog(value.ownerId)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="timeline-empty">{{ t('dialog.group_calendar.no_events') }}</div>
|
|
</div>
|
|
|
|
<div class="calendar-container">
|
|
<GroupCalendarMonth
|
|
v-model="selectedDay"
|
|
:is-loading="isLoading"
|
|
:events-by-date="filteredCalendar"
|
|
:following-by-date="followingCalendarDate" />
|
|
</div>
|
|
</div>
|
|
<div v-else key="grid" class="grid-view">
|
|
<div class="search-container">
|
|
<InputGroupSearch
|
|
v-model="searchQuery"
|
|
size="sm"
|
|
:placeholder="t('dialog.group_calendar.search_placeholder')"
|
|
class="search-input" />
|
|
</div>
|
|
|
|
<div class="groups-grid">
|
|
<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)">
|
|
<ChevronDown
|
|
class="rotation-transition"
|
|
:class="{ 'is-rotated': groupCollapsed[group.groupId] }" />
|
|
{{ 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)"
|
|
@update-following-calendar-data="updateFollowingCalendarData"
|
|
@click-action="showGroupDialog(event.ownerId)"
|
|
card-class="grid-card" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="no-events">
|
|
{{
|
|
searchQuery
|
|
? t('dialog.group_calendar.search_no_matching')
|
|
: t('dialog.group_calendar.search_no_this_month')
|
|
}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ChevronDown } from 'lucide-vue-next';
|
|
import { InputGroupSearch } from '@/components/ui/input-group';
|
|
import { useI18n } from 'vue-i18n';
|
|
|
|
import dayjs from 'dayjs';
|
|
|
|
import { formatDateFilter, getGroupName, replaceBioSymbols } from '../../../shared/utils';
|
|
import { Switch } from '../../../components/ui/switch';
|
|
import { groupRequest } from '../../../api';
|
|
import { processBulk } from '../../../service/request';
|
|
import { useGroupStore } from '../../../stores';
|
|
|
|
import GroupCalendarEventCard from '../components/GroupCalendarEventCard.vue';
|
|
import GroupCalendarMonth from '../components/GroupCalendarMonth.vue';
|
|
import configRepository from '../../../service/config';
|
|
|
|
const { applyGroupEvent, showGroupDialog } = useGroupStore();
|
|
|
|
const { t } = useI18n();
|
|
|
|
const props = defineProps({
|
|
visible: {
|
|
type: Boolean,
|
|
required: true
|
|
}
|
|
});
|
|
|
|
const emit = defineEmits(['close']);
|
|
|
|
const calendar = ref([]);
|
|
const followingCalendar = ref([]);
|
|
const featuredCalendar = ref([]);
|
|
const selectedDay = ref(new Date());
|
|
const isLoading = ref(false);
|
|
const viewMode = ref('timeline');
|
|
const searchQuery = ref('');
|
|
const groupCollapsed = ref({});
|
|
const showFeaturedEvents = ref(false);
|
|
const groupNamesCache = new Map();
|
|
|
|
onMounted(async () => {
|
|
showFeaturedEvents.value = await configRepository.getBool('VRCX_groupCalendarShowFeaturedEvents', false);
|
|
});
|
|
|
|
function toggleFeaturedEvents() {
|
|
configRepository.setBool('VRCX_groupCalendarShowFeaturedEvents', showFeaturedEvents.value);
|
|
updateCalenderData();
|
|
}
|
|
|
|
watch(
|
|
() => props.visible,
|
|
async (newVisible) => {
|
|
if (newVisible) {
|
|
selectedDay.value = new Date();
|
|
updateCalenderData();
|
|
}
|
|
}
|
|
);
|
|
|
|
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) {
|
|
updateCalenderData();
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
async function updateCalenderData() {
|
|
isLoading.value = true;
|
|
let fetchPromises = [getCalendarData(), getFollowingCalendarData()];
|
|
if (showFeaturedEvents.value) {
|
|
fetchPromises.push(getFeaturedCalendarData());
|
|
}
|
|
await Promise.all(fetchPromises)
|
|
.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();
|
|
|
|
let currentMonthEvents = calendar.value.filter((event) => {
|
|
const eventDate = dayjs(event.startsAt);
|
|
return eventDate.month() === currentMonth && eventDate.year() === currentYear;
|
|
});
|
|
if (showFeaturedEvents.value) {
|
|
const featuredMonthEvents = featuredCalendar.value.filter((event) => {
|
|
const eventDate = dayjs(event.startsAt);
|
|
return eventDate.month() === currentMonth && eventDate.year() === currentYear;
|
|
});
|
|
currentMonthEvents = currentMonthEvents.concat(featuredMonthEvents);
|
|
}
|
|
|
|
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: groupNamesCache.get(groupId),
|
|
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);
|
|
});
|
|
if (showFeaturedEvents.value) {
|
|
featuredCalendar.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 = formatDateFilter(event.startsAt, 'time');
|
|
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)));
|
|
});
|
|
|
|
// Use a stable key for calendar maps (independent of locale/appearance date formatting).
|
|
const formatDateKey = (date) => dayjs(date).format('YYYY-MM-DD');
|
|
|
|
async function getGroupNameFromCache(groupId) {
|
|
if (!groupNamesCache.has(groupId)) {
|
|
groupNamesCache.set(groupId, await getGroupName(groupId));
|
|
}
|
|
}
|
|
|
|
async function getCalendarData() {
|
|
calendar.value = [];
|
|
try {
|
|
await processBulk({
|
|
fn: groupRequest.getGroupCalendars,
|
|
N: -1,
|
|
params: {
|
|
n: 100,
|
|
offset: 0,
|
|
date: dayjs(selectedDay.value).format('YYYY-MM-DDTHH:mm:ss[Z]') // this need to be local time because UTC time may cause month shift
|
|
},
|
|
async handle(args) {
|
|
for (const event of args.results) {
|
|
event.title = replaceBioSymbols(event.title);
|
|
event.description = replaceBioSymbols(event.description);
|
|
applyGroupEvent(event);
|
|
await getGroupNameFromCache(event.ownerId);
|
|
}
|
|
calendar.value.push(...args.results);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching calendars:', error);
|
|
}
|
|
}
|
|
|
|
async function getFollowingCalendarData() {
|
|
followingCalendar.value = [];
|
|
try {
|
|
await processBulk({
|
|
fn: groupRequest.getFollowingGroupCalendars,
|
|
N: -1,
|
|
params: {
|
|
n: 100,
|
|
offset: 0,
|
|
date: dayjs(selectedDay.value).format('YYYY-MM-DDTHH:mm:ss[Z]')
|
|
},
|
|
async handle(args) {
|
|
for (const event of args.results) {
|
|
applyGroupEvent(event);
|
|
await getGroupNameFromCache(event.ownerId);
|
|
}
|
|
followingCalendar.value.push(...args.results);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching following calendars:', error);
|
|
}
|
|
}
|
|
|
|
async function getFeaturedCalendarData() {
|
|
featuredCalendar.value = [];
|
|
try {
|
|
await processBulk({
|
|
fn: groupRequest.getFeaturedGroupCalendars,
|
|
N: -1,
|
|
params: {
|
|
n: 100,
|
|
offset: 0,
|
|
date: dayjs(selectedDay.value).format('YYYY-MM-DDTHH:mm:ss[Z]')
|
|
},
|
|
async handle(args) {
|
|
for (const event of args.results) {
|
|
applyGroupEvent(event);
|
|
await getGroupNameFromCache(event.ownerId);
|
|
}
|
|
featuredCalendar.value.push(...args.results);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching featured calendars:', error);
|
|
}
|
|
}
|
|
|
|
function updateFollowingCalendarData(updatedEvent) {
|
|
const index = followingCalendar.value.findIndex((item) => item.id === updatedEvent.id);
|
|
if (index !== -1) {
|
|
followingCalendar.value.splice(index, 1);
|
|
}
|
|
if (updatedEvent.userInterest?.isFollowing) {
|
|
followingCalendar.value.push(updatedEvent);
|
|
}
|
|
}
|
|
|
|
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 scoped>
|
|
.x-dialog {
|
|
.top-content {
|
|
height: 640px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
.timeline-view {
|
|
.timeline-container {
|
|
min-width: 200px;
|
|
padding-left: 4px;
|
|
padding-right: 16px;
|
|
margin-left: 8px;
|
|
margin-right: 8px;
|
|
overflow: auto;
|
|
height: 50vh;
|
|
|
|
.timeline-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
}
|
|
|
|
.timeline-group {
|
|
padding: 0 20px 8px 8px;
|
|
}
|
|
|
|
.timeline-timestamp {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.timeline-empty {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.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: var(--radius-xl);
|
|
font-size: 18px;
|
|
position: relative;
|
|
|
|
&.has-events {
|
|
background-color: var(--group-calendar-event-bg,);
|
|
}
|
|
.calendar-event-badge {
|
|
position: absolute;
|
|
top: 2px;
|
|
right: 2px;
|
|
min-width: 16px;
|
|
height: 16px;
|
|
border-radius: var(--radius-xl);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
z-index: 10;
|
|
padding: 0 4px;
|
|
line-height: 16px;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.dialog-title-container {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
width: 100%;
|
|
.view-toggle-btn {
|
|
font-size: 12px;
|
|
padding: 8px 12px;
|
|
}
|
|
}
|
|
|
|
.featured-switch {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
margin-top: 8px;
|
|
.featured-switch-text {
|
|
font-size: 13px;
|
|
margin-right: 6px;
|
|
}
|
|
}
|
|
|
|
.timeline-view {
|
|
display: flex;
|
|
align-items: center;
|
|
.timeline-container {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
.grid-view {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
.search-container {
|
|
padding: 2px 20px 12px 20px;
|
|
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;
|
|
padding: 4px 12px 8px 12px;
|
|
cursor: pointer;
|
|
border-radius: var(--radius-md);
|
|
margin: 0 -12px 8px -12px;
|
|
display: flex;
|
|
align-items: center;
|
|
|
|
.rotation-transition {
|
|
font-size: 14px;
|
|
margin-right: 8px;
|
|
transition: transform 0.3s;
|
|
}
|
|
}
|
|
.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;
|
|
}
|
|
}
|
|
}
|
|
|
|
.is-rotated {
|
|
transform: rotate(-90deg);
|
|
}
|
|
|
|
.rotation-transition {
|
|
transition: transform 0.2s ease-in-out;
|
|
}
|
|
</style>
|