feat: добавил поддержку подписки на календарь и экспорт расписания лекций в формате .ics
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

This commit is contained in:
2026-06-02 21:26:48 +03:00
parent 7050851bd4
commit 136bcce7db
16 changed files with 639 additions and 8 deletions
+27
View File
@@ -81,3 +81,30 @@ export function extractItems<T>(payload: T[] | { items?: T[] } | undefined): T[]
if (Array.isArray(payload)) return payload
return payload?.items ?? []
}
export async function apiRequestBlob(
path: string,
options: RequestInit & { query?: Record<string, unknown> } = {},
): Promise<Blob> {
const headers = new Headers(options.headers)
if (!headers.has('Accept')) headers.set('Accept', 'text/calendar')
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
const response = await fetch(makeUrl(path, options.query), {
...options,
headers,
credentials: 'include',
})
if (!response.ok) {
const body = await parseResponse(response)
const message =
typeof body === 'object' && body && 'message' in body
? String((body as { message: unknown }).message)
: `API request failed with status ${response.status}`
throw new ApiError(message, response.status, body)
}
return response.blob()
}
+7 -1
View File
@@ -1,4 +1,4 @@
import { apiRequest, extractItems } from './client'
import { apiRequest, apiRequestBlob, extractItems } from './client'
import type {
AchievementDto,
AuthResponse,
@@ -19,6 +19,7 @@ import type {
UpdateReviewPromptRequest,
UserAchievementDto,
AdminDashboardStatsDto,
CalendarSubscriptionDto,
CurrentUserDto,
UserDto,
UserQuery,
@@ -76,6 +77,11 @@ export const usersApi = {
)
return extractItems(payload)
},
downloadMyEnrollmentsIcs: () => apiRequestBlob('/users/me/enrollments.ics'),
downloadEnrollmentIcs: (lectureId: string | number) =>
apiRequestBlob(`/users/me/enrollments/${lectureId}.ics`),
getCalendarSubscription: () =>
apiRequest<CalendarSubscriptionDto>('/users/me/enrollments/calendar-subscription'),
async myAchievements() {
const payload = await apiRequest<
PagedResult<UserAchievementDto> | UserAchievementDto[] | AchievementDto[]
+4
View File
@@ -83,6 +83,10 @@ export interface AdminDashboardStatsDto {
pendingReviewsCount: number
}
export interface CalendarSubscriptionDto {
feedUrl: string
}
export interface EnrollmentSlotRuleDto {
level: number
slots: number
+10
View File
@@ -0,0 +1,10 @@
export function downloadFile(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
+13 -1
View File
@@ -2,6 +2,8 @@
import { computed, inject, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
@@ -66,6 +68,16 @@ const levelProgressText = computed(() =>
: `${userXp.value} XP`,
)
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
addToast?.("Файл календаря скачан", "success")
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
}
}
onMounted(async () => {
await Promise.all([
lectures.all.length ? Promise.resolve() : lectures.fetchLectures(),
@@ -125,7 +137,7 @@ async function registerLecture(id: string) {
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">
Открыть
</button>
<button class="btn-secondary">Добавить в календарь</button>
<button class="btn-secondary" @click="downloadLectureIcs(nextLecture.id)">Скачать .ics</button>
</div>
</div>
</GlassCard>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, inject, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usersApi } from '@/api'
import { downloadFile } from '@/utils/downloadFile'
import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
@@ -37,6 +39,16 @@ onMounted(async () => {
await lecturesStore.fetchLecture(lectureId.value)
})
async function downloadLectureIcs(id: string) {
try {
const blob = await usersApi.downloadEnrollmentIcs(id)
downloadFile(blob, `lecture-${id}.ics`)
addToast?.("Файл календаря скачан", "success")
} catch (err) {
addToast?.(err instanceof Error ? err.message : "Не удалось скачать .ics", "error")
}
}
async function registerLecture() {
if (!lecture.value) return
if (slotRegistrationDisabled.value) {
@@ -90,7 +102,7 @@ async function registerLecture() {
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">
Отменить запись
</button>
<button class="btn-secondary">Добавить в календарь</button>
<button class="btn-secondary" @click="downloadLectureIcs(lecture.id)">Скачать .ics</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">
Оставить отзыв
</button>
+127 -3
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
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'
@@ -14,6 +16,11 @@ 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' })),
@@ -26,6 +33,98 @@ onMounted(async () => {
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
@@ -46,7 +145,25 @@ async function confirmCancel() {
Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.
</p>
</div>
<button class="btn-secondary">Экспорт в календарь</button>
<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">
@@ -76,7 +193,7 @@ async function confirmCancel() {
</div>
<div class="lecture-actions">
<StatusBadge status="registered" />
<button class="btn-secondary btn-sm">Добавить в календарь</button>
<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>
@@ -133,6 +250,13 @@ async function confirmCancel() {
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;