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
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -83,6 +83,10 @@ export interface AdminDashboardStatsDto {
|
||||
pendingReviewsCount: number
|
||||
}
|
||||
|
||||
export interface CalendarSubscriptionDto {
|
||||
feedUrl: string
|
||||
}
|
||||
|
||||
export interface EnrollmentSlotRuleDto {
|
||||
level: number
|
||||
slots: number
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user