Files
VRCX/src/views/Tools/components/GroupCalendarEventCard.vue
2026-03-09 20:50:15 +09:00

391 lines
12 KiB
Vue

<template>
<Popover :open="eventPopoverOpen">
<PopoverTrigger as-child>
<Card
class="event-card x-hover-card p-0 gap-0 hover:bg-accent hover:shadow-sm"
:class="cardClass"
@mouseenter="openEventPopover"
@mouseleave="scheduleCloseEventPopover">
<img :src="bannerUrl" @click="showFullscreenImageDialog(bannerUrl)" class="banner" />
<div class="event-content">
<div class="event-title">
<div v-if="showGroupName" class="event-group-name" @click="onGroupClick">
{{ groupName }}
</div>
<div class="event-title-content" @click="onGroupClick">
{{ event.title }}
</div>
</div>
<div class="event-info">
<div :class="timeClass">
{{ formattedTime }}
</div>
<div>
{{ capitalizeFirst(event.accessType) }}
</div>
</div>
</div>
<div class="badges">
<Button @click="copyEventLink(event)" size="icon" variant="secondary" class="rounded-full badge">
<Share2 />
</Button>
<Button
@click="toggleEventFollow(event)"
size="icon"
:variant="isFollowing ? 'default' : 'secondary'"
class="rounded-full badge">
<Star />
</Button>
</div>
</Card>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
class="w-125 p-3"
@mouseenter="openEventPopover"
@mouseleave="scheduleCloseEventPopover">
<div class="flex items-baseline justify-between gap-3 text-xs">
<div class="text-[13px] font-semibold">{{ event.title }}</div>
<div class="whitespace-nowrap">
{{ formatTimeRange(event.startsAt, event.endsAt) }}
</div>
</div>
<div class="mt-2 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div class="flex min-w-0 flex-col gap-1">
<Button variant="outline" size="sm" @click="openCalendarEvent(event)">
<Calendar />
{{ t('dialog.group_calendar.event_card.export_to_calendar') }}
</Button>
</div>
<div class="flex min-w-0 flex-col gap-1">
<Button variant="outline" size="sm" @click="downloadEventIcs(event)">
<Download />
{{ t('dialog.group_calendar.event_card.download_ics') }}
</Button>
</div>
<div class="flex min-w-0 flex-col gap-1">
<div>
{{ t('dialog.group_calendar.event_card.category') }}
</div>
<div class="font-medium">{{ capitalizeFirst(event.category) }}</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<div>
{{ t('dialog.group_calendar.event_card.interested_user') }}
</div>
<div class="font-medium">{{ event.interestedUserCount }}</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<div>
{{ t('dialog.group_calendar.event_card.close_time') }}
</div>
<div class="font-medium">
{{ event.closeInstanceAfterEndMinutes + ' min' }}
</div>
</div>
<div class="flex min-w-0 flex-col gap-1">
<div>
{{ t('dialog.group_calendar.event_card.created') }}
</div>
<div class="font-medium">
{{ formatDateFilter(event.createdAt, 'long') }}
</div>
</div>
<div class="col-span-2 flex min-w-0 flex-col gap-1">
<div>
{{ t('dialog.group_calendar.event_card.description') }}
</div>
<div class="whitespace-pre-wrap break-words font-normal leading-snug">
{{ event.description }}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</template>
<script setup>
import { Calendar, Download, Share2, Star } from 'lucide-vue-next';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { computed, ref } from 'vue';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import { useGalleryStore, useGroupStore } from '../../../stores';
import { AppDebug } from '../../../service/appConfig';
import { formatDateFilter } from '../../../shared/utils';
import { groupRequest } from '../../../api';
const { showFullscreenImageDialog } = useGalleryStore();
const { t } = useI18n();
const { cachedGroups } = 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 emit = defineEmits(['update-following-calendar-data', 'click-action']);
const showGroupName = computed(() => props.mode === 'timeline');
const timeClass = computed(() => (props.mode === 'grid' ? 'event-time' : ''));
const eventPopoverOpen = ref(false);
let eventPopoverCloseTimer = null;
const bannerUrl = computed(() => {
if (!props.event) return '';
if (props.event.imageUrl) {
return props.event.imageUrl;
} else {
return cachedGroups.get(props.event.ownerId)?.bannerUrl || '';
}
});
const groupName = computed(() => {
if (!props.event) return '';
return cachedGroups.get(props.event.ownerId)?.name || '';
});
const formattedTime = computed(() => {
if (props.mode === 'timeline') {
return formatTimeRange(props.event.startsAt, props.event.endsAt);
} else {
return `${formatDateFilter(props.event.startsAt, 'short')} - ${formatDateFilter(props.event.endsAt, 'short')}`;
}
});
/**
*
* @param event
*/
async function openCalendarEvent(event) {
const content = await getCalendarIcs(event);
if (!content) return;
await AppApi.OpenCalendarFile(content);
}
/**
*
* @param event
*/
async function getCalendarIcs(event) {
const url = `${AppDebug.endpointDomain}/calendar/${event.ownerId}/${event.id}.ics`;
try {
const response = await webApiService.execute({
url,
method: 'GET'
});
if (response.status !== 200) {
throw new Error(`Error: ${response.data}`);
}
return response.data;
} catch (error) {
toast.error(`Failed to download .ics file, ${error.message}`);
console.error('Failed to download .ics file:', error);
}
}
/**
*
* @param event
*/
async function downloadEventIcs(event) {
const content = await getCalendarIcs(event);
if (!content) return;
const blob = new Blob([content], { type: 'text/calendar' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${event.id}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
}
/**
*
* @param event
*/
async function toggleEventFollow(event) {
const args = await groupRequest.followGroupEvent({
groupId: event.ownerId,
eventId: event.id,
isFollowing: !props.isFollowing
});
emit('update-following-calendar-data', args.json);
}
/**
*
* @param event
*/
function copyEventLink(event) {
const eventLink = `https://vrchat.com/home/group/${event.ownerId}/calendar/${event.id}`;
navigator.clipboard.writeText(eventLink);
toast.success(t('dialog.group_calendar.event_card.copied_event_link'));
}
const formatTimeRange = (startsAt, endsAt) =>
`${formatDateFilter(startsAt, 'time')} - ${formatDateFilter(endsAt, 'time')}`;
const capitalizeFirst = (str) => str?.charAt(0).toUpperCase() + str?.slice(1);
const onGroupClick = () => {
emit('click-action');
};
const openEventPopover = () => {
if (eventPopoverCloseTimer) {
clearTimeout(eventPopoverCloseTimer);
eventPopoverCloseTimer = null;
}
eventPopoverOpen.value = true;
};
const scheduleCloseEventPopover = () => {
if (eventPopoverCloseTimer) {
clearTimeout(eventPopoverCloseTimer);
}
eventPopoverCloseTimer = setTimeout(() => {
eventPopoverOpen.value = false;
}, 100);
};
</script>
<style scoped>
.event-card {
position: relative;
overflow: visible;
border-radius: var(--radius-lg);
width: 100%;
}
.event-card.grouped-card {
margin-bottom: 0;
}
.event-card.grid-card {
flex: 0 0 280px;
max-width: 280px;
}
.event-card.group-dialog-grid-card {
flex: 0 0 320px;
max-width: 320px;
}
.event-card .banner {
cursor: pointer;
width: 100%;
object-fit: cover;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.timeline-view .event-card .banner {
height: 125px;
}
.grid-view .event-card .banner {
height: 100px;
}
.event-card .badges {
display: inline-flex;
position: absolute;
top: -8px;
right: -9px;
z-index: 10;
font-size: 15px;
}
.event-card .badges .badge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
gap: 4px;
cursor: pointer;
margin-left: 6px;
}
.event-card .event-content {
font-size: 12px;
}
.timeline-view .event-card .event-content {
padding: 4px 12px 12px 12px;
}
.grid-view .event-card .event-content {
padding: 8px 12px 12px 12px;
}
.event-card .event-title {
display: flex;
flex-direction: column;
}
.grid-view .event-card .event-title {
margin-bottom: 8px;
}
.event-card .event-group-name {
cursor: pointer;
}
.grid-view .event-card .event-group-name {
display: none;
}
.event-card .event-title-content {
font-size: 14px;
font-weight: bold;
line-height: 1.2;
cursor: pointer;
}
.timeline-view .event-card .event-title-content {
margin-bottom: 2px;
}
.event-card .event-info {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-view .event-card .event-info > :first-child {
font-size: 14px;
}
.grid-view .event-card .event-info {
font-size: 11px;
}
.event-card .event-time {
font-weight: 500;
}
</style>