136bcce7db
Backend CI / build-and-test (push) Successful in 57s
Frontend CI / build-and-check (push) Failing after 26s
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 11s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 2m33s
🚀 Create and publish a Docker image / Build & publish frontend image (push) Successful in 33s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 8s
313 lines
9.7 KiB
Vue
313 lines
9.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, inject, onMounted, ref } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { usersApi } from '@/api'
|
||
import { downloadFile } from '@/utils/downloadFile'
|
||
import { useLecturesStore } from '@/stores/lectures'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import GlassCard from '@/components/ui/GlassCard.vue'
|
||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||
import ModalDialog from '@/components/ui/ModalDialog.vue'
|
||
import EmptyState from '@/components/ui/EmptyState.vue'
|
||
|
||
const lecturesStore = useLecturesStore()
|
||
const auth = useAuthStore()
|
||
const router = useRouter()
|
||
const activeTab = ref<'upcoming' | 'history'>('upcoming')
|
||
const cancelModal = ref(false)
|
||
const selectedId = ref<string | null>(null)
|
||
const calendarSubscriptionUrl = ref<string | null>(null)
|
||
const calendarActionPending = ref(false)
|
||
const addToast = inject('addToast') as
|
||
| ((message: string, type?: 'success' | 'error' | 'info') => void)
|
||
| undefined
|
||
|
||
const upcoming = computed(() =>
|
||
lecturesStore.registeredLectures.map((l) => ({ ...l, status: 'registered' })),
|
||
)
|
||
|
||
const history = computed(() => lecturesStore.all.filter((l) => l.status === 'completed'))
|
||
|
||
onMounted(async () => {
|
||
if (!lecturesStore.all.length) await lecturesStore.fetchLectures()
|
||
if (auth.user) await lecturesStore.fetchRegisteredForCurrentUser()
|
||
})
|
||
|
||
async function downloadLectureIcs(id: string) {
|
||
try {
|
||
const blob = await usersApi.downloadEnrollmentIcs(id)
|
||
downloadFile(blob, `lecture-${id}.ics`)
|
||
} catch (err) {
|
||
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
|
||
}
|
||
}
|
||
|
||
async function downloadAllIcs() {
|
||
try {
|
||
const blob = await usersApi.downloadMyEnrollmentsIcs()
|
||
downloadFile(blob, 'my-lectures.ics')
|
||
} catch (err) {
|
||
addToast?.(err instanceof Error ? err.message : 'Не удалось скачать .ics', 'error')
|
||
}
|
||
}
|
||
|
||
async function getCalendarSubscriptionUrl() {
|
||
if (calendarSubscriptionUrl.value) return calendarSubscriptionUrl.value
|
||
const subscription = await usersApi.getCalendarSubscription()
|
||
calendarSubscriptionUrl.value = subscription.feedUrl
|
||
return subscription.feedUrl
|
||
}
|
||
|
||
async function copyText(value: string) {
|
||
if (navigator.clipboard?.writeText) {
|
||
try {
|
||
await navigator.clipboard.writeText(value)
|
||
return true
|
||
} catch {
|
||
// Browser may block async clipboard writes after awaiting the subscription request.
|
||
}
|
||
}
|
||
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = value
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.left = '-9999px'
|
||
document.body.appendChild(textarea)
|
||
textarea.focus()
|
||
textarea.select()
|
||
const copied = document.execCommand('copy')
|
||
textarea.remove()
|
||
return copied
|
||
}
|
||
|
||
async function copyCalendarSubscriptionUrl() {
|
||
try {
|
||
calendarActionPending.value = true
|
||
const feedUrl = await getCalendarSubscriptionUrl()
|
||
if (!await copyText(feedUrl)) throw new Error('Браузер заблокировал копирование ссылки')
|
||
addToast?.('ICS-ссылка скопирована', 'success')
|
||
} catch (err) {
|
||
addToast?.(err instanceof Error ? err.message : 'Не удалось скопировать ссылку', 'error')
|
||
} finally {
|
||
calendarActionPending.value = false
|
||
}
|
||
}
|
||
|
||
async function syncWithGoogleCalendar() {
|
||
const googleWindow = window.open('about:blank', '_blank')
|
||
|
||
try {
|
||
calendarActionPending.value = true
|
||
const feedUrl = await getCalendarSubscriptionUrl()
|
||
const copied = await copyText(feedUrl)
|
||
|
||
const googleUrl = `https://calendar.google.com/calendar/r?cid=${encodeURIComponent(feedUrl)}`
|
||
if (googleWindow) {
|
||
googleWindow.opener = null
|
||
googleWindow.location.href = googleUrl
|
||
} else {
|
||
window.open(googleUrl, '_blank', 'noopener,noreferrer')
|
||
}
|
||
addToast?.(
|
||
copied
|
||
? 'ICS-ссылка скопирована. Google Calendar открыт в новой вкладке.'
|
||
: 'Google Calendar открыт. Если ссылка не подставилась, скопируйте ICS-ссылку отдельно.',
|
||
copied ? 'success' : 'info',
|
||
)
|
||
} catch (err) {
|
||
googleWindow?.close()
|
||
addToast?.(
|
||
err instanceof Error ? err.message : 'Не удалось подготовить ссылку для Google Calendar',
|
||
'error',
|
||
)
|
||
} finally {
|
||
calendarActionPending.value = false
|
||
}
|
||
}
|
||
|
||
function openCancel(id: string) {
|
||
selectedId.value = id
|
||
cancelModal.value = true
|
||
}
|
||
|
||
async function confirmCancel() {
|
||
if (selectedId.value) await lecturesStore.unregister(selectedId.value)
|
||
cancelModal.value = false
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="my-lectures page-content">
|
||
<div class="header">
|
||
<div>
|
||
<h1 class="page-title">Мои записи</h1>
|
||
<p class="text-secondary">
|
||
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
|
||
</p>
|
||
</div>
|
||
<div class="calendar-actions">
|
||
<button
|
||
class="btn-primary"
|
||
:disabled="calendarActionPending"
|
||
@click="syncWithGoogleCalendar"
|
||
>
|
||
Синхронизировать с Google Calendar
|
||
</button>
|
||
<button
|
||
class="btn-secondary"
|
||
:disabled="calendarActionPending"
|
||
@click="copyCalendarSubscriptionUrl"
|
||
>
|
||
Скопировать ICS-ссылку
|
||
</button>
|
||
<button class="btn-secondary" @click="downloadAllIcs">
|
||
Скачать все мои лекции (.ics)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs">
|
||
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">
|
||
Предстоящие
|
||
</button>
|
||
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">
|
||
История
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="activeTab === 'upcoming'" class="list">
|
||
<EmptyState
|
||
v-if="!upcoming.length"
|
||
title="У вас нет предстоящих лекций"
|
||
subtitle="Выберите лекцию в каталоге и запишитесь на неё."
|
||
/>
|
||
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
|
||
<div>
|
||
<div class="lecture-title">{{ item.title }}</div>
|
||
<div class="lecture-meta">
|
||
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
|
||
</div>
|
||
<div class="lecture-meta">
|
||
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
|
||
</div>
|
||
</div>
|
||
<div class="lecture-actions">
|
||
<StatusBadge status="registered" />
|
||
<button class="btn-secondary btn-sm" @click="downloadLectureIcs(item.id)">Скачать .ics</button>
|
||
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
|
||
</div>
|
||
</GlassCard>
|
||
</div>
|
||
|
||
<div v-else class="list">
|
||
<EmptyState
|
||
v-if="!history.length"
|
||
title="История пока пуста"
|
||
subtitle="Завершённые лекции появятся здесь после посещения."
|
||
/>
|
||
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
|
||
<div>
|
||
<div class="lecture-title">{{ item.title }}</div>
|
||
<div class="lecture-meta">
|
||
{{ new Date(item.date).toLocaleDateString('ru-RU') }} {{ item.time }}
|
||
</div>
|
||
<div class="lecture-meta">
|
||
{{ item.building }} {{ item.room ? `ауд. ${item.room}` : '' }}
|
||
</div>
|
||
</div>
|
||
<div class="lecture-actions">
|
||
<StatusBadge :status="item.status ?? 'completed'" />
|
||
<button class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
|
||
Оставить отзыв
|
||
</button>
|
||
</div>
|
||
</GlassCard>
|
||
</div>
|
||
|
||
<ModalDialog v-model="cancelModal" title="Отменить запись?" icon="alert-triangle" size="sm">
|
||
<p>
|
||
Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других
|
||
студентов.
|
||
</p>
|
||
<template #footer>
|
||
<button class="btn-secondary" type="button" @click="cancelModal = false">Нет</button>
|
||
<button class="btn-danger" type="button" @click="confirmCancel">Да, отменить</button>
|
||
</template>
|
||
</ModalDialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.my-lectures {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 18px;
|
||
}
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.calendar-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.tabs {
|
||
display: inline-flex;
|
||
width: fit-content;
|
||
border: 1px solid var(--color-border-glass);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.tabs button {
|
||
background: rgba(255, 255, 255, 0.7);
|
||
border: none;
|
||
padding: 8px 18px;
|
||
font-size: 13px;
|
||
min-width: 110px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.tabs button.active {
|
||
background: rgba(34, 197, 94, 0.18);
|
||
color: var(--color-primary-dark);
|
||
font-weight: 600;
|
||
}
|
||
.list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.lecture-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.lecture-title {
|
||
font-weight: 700;
|
||
margin-bottom: 4px;
|
||
}
|
||
.lecture-meta {
|
||
font-size: 13px;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.lecture-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.btn-sm {
|
||
padding: 6px 12px;
|
||
font-size: 12px;
|
||
}
|
||
</style>
|