feat: add StreamEmbed widget, move course progress to footer corner
- Add StreamEmbed component supporting Twitch and YouTube embeds with live badge, platform icon, aspect-ratio 16:9, external link - Replace AudioPlayer on AdminHome with StreamEmbed (configure channel name in streamConfig ref) - Move course progress % to bottom-right footer corner alongside price/cert badge; remove separate progress row above footer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -84,7 +84,7 @@
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-2 flex-wrap">
|
||||
<div class="flex items-center gap-3 text-xs text-ink-gray-5 mb-3 flex-wrap">
|
||||
<span v-if="course.lessons" class="flex items-center gap-1">
|
||||
<BookOpen class="h-3.5 w-3.5" />
|
||||
{{ course.lessons }} {{ __('lessons') }}
|
||||
@@ -99,11 +99,6 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress text -->
|
||||
<div v-if="user && course.membership" class="text-xs text-ink-gray-5 mb-2">
|
||||
{{ Math.ceil(course.membership.progress) }}% {{ __('completed') }}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between pt-3 border-t border-outline-gray-1">
|
||||
<!-- Instructors -->
|
||||
@@ -123,9 +118,15 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right side: price + cert -->
|
||||
<!-- Right side: progress % / price / cert -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="course.paid_course" class="text-sm font-semibold text-ink-gray-9">
|
||||
<span
|
||||
v-if="user && course.membership"
|
||||
class="text-xs font-medium text-ink-green-3"
|
||||
>
|
||||
{{ Math.ceil(course.membership.progress) }}%
|
||||
</span>
|
||||
<span v-else-if="course.paid_course" class="text-sm font-semibold text-ink-gray-9">
|
||||
{{ course.price }}
|
||||
</span>
|
||||
<Tooltip
|
||||
|
||||
113
frontend/src/components/StreamEmbed.vue
Normal file
113
frontend/src/components/StreamEmbed.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="rounded-xl overflow-hidden border border-outline-gray-2 bg-surface-gray-9">
|
||||
<!-- Stream header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-surface-gray-9 border-b border-outline-gray-7">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<!-- Live badge -->
|
||||
<span
|
||||
v-if="isLive"
|
||||
class="flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wide bg-red-600 text-white"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-white animate-pulse" />
|
||||
LIVE
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-semibold uppercase tracking-wide bg-surface-gray-7 text-ink-gray-4"
|
||||
>
|
||||
OFFLINE
|
||||
</span>
|
||||
|
||||
<!-- Platform icon + channel -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Twitch icon -->
|
||||
<svg v-if="platform === 'twitch'" class="w-4 h-4 text-purple-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714z"/>
|
||||
</svg>
|
||||
<!-- YouTube icon -->
|
||||
<svg v-else-if="platform === 'youtube'" class="w-4 h-4 text-red-500" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
||||
</svg>
|
||||
|
||||
<span class="text-sm font-semibold text-white">{{ channelDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Open in new tab -->
|
||||
<a
|
||||
:href="externalUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center gap-1 text-xs text-ink-gray-4 hover:text-white transition-colors"
|
||||
>
|
||||
<ExternalLink class="w-3.5 h-3.5" />
|
||||
<span class="hidden sm:inline">{{ __('Open') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Embed iframe -->
|
||||
<div class="relative w-full" style="aspect-ratio: 16/9">
|
||||
<iframe
|
||||
v-if="embedUrl"
|
||||
:src="embedUrl"
|
||||
class="absolute inset-0 w-full h-full"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
allow="autoplay; fullscreen"
|
||||
:title="channelDisplay"
|
||||
/>
|
||||
<!-- Placeholder when no channel configured -->
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-surface-gray-9"
|
||||
>
|
||||
<Radio class="w-12 h-12 text-ink-gray-6 stroke-1" />
|
||||
<p class="text-sm text-ink-gray-5">{{ __('Stream channel not configured') }}</p>
|
||||
<p class="text-xs text-ink-gray-4">{{ __('Set channel prop to a Twitch or YouTube channel') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ExternalLink, Radio } from 'lucide-vue-next'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 'twitch' or 'youtube' */
|
||||
platform?: 'twitch' | 'youtube'
|
||||
/** Twitch: channel name. YouTube: video/stream ID or channel handle */
|
||||
channel?: string
|
||||
/** Show as live */
|
||||
isLive?: boolean
|
||||
}>(), {
|
||||
platform: 'twitch',
|
||||
channel: '',
|
||||
isLive: true,
|
||||
})
|
||||
|
||||
const channelDisplay = computed(() => props.channel || '—')
|
||||
|
||||
const embedUrl = computed(() => {
|
||||
if (!props.channel) return ''
|
||||
|
||||
if (props.platform === 'twitch') {
|
||||
const parent = window.location.hostname
|
||||
return `https://player.twitch.tv/?channel=${props.channel}&parent=${parent}&autoplay=false`
|
||||
}
|
||||
|
||||
if (props.platform === 'youtube') {
|
||||
// Supports both video ID and live stream
|
||||
return `https://www.youtube.com/embed/${props.channel}?autoplay=0&rel=0`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const externalUrl = computed(() => {
|
||||
if (!props.channel) return '#'
|
||||
if (props.platform === 'twitch') return `https://twitch.tv/${props.channel}`
|
||||
if (props.platform === 'youtube') return `https://youtube.com/watch?v=${props.channel}`
|
||||
return '#'
|
||||
})
|
||||
</script>
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Audio Player -->
|
||||
<!-- Stream embed -->
|
||||
<div class="mt-10">
|
||||
<div class="font-semibold text-lg text-ink-gray-9 mb-3">
|
||||
{{ __('Audio') }}
|
||||
</div>
|
||||
<div class="max-w-sm">
|
||||
<AudioPlayer :tracks="audioTracks" />
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="font-semibold text-lg text-ink-gray-9">{{ __('Live') }}</span>
|
||||
</div>
|
||||
<StreamEmbed
|
||||
:platform="streamConfig.platform"
|
||||
:channel="streamConfig.channel"
|
||||
:is-live="streamConfig.isLive"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="createdBatches.data?.length" class="mt-10">
|
||||
@@ -193,17 +195,14 @@ import {
|
||||
import { formatTime } from '@/utils'
|
||||
import CourseCard from '@/components/CourseCard.vue'
|
||||
import BatchCard from '@/pages/Batches/components/BatchCard.vue'
|
||||
import AudioPlayer from '@/components/AudioPlayer.vue'
|
||||
import StreamEmbed from '@/components/StreamEmbed.vue'
|
||||
|
||||
// Плейлист — замените src на реальные ссылки на аудиофайлы
|
||||
const audioTracks = ref([
|
||||
{
|
||||
title: 'Добро пожаловать',
|
||||
artist: 'ИМО Online',
|
||||
src: '',
|
||||
durationLabel: '',
|
||||
},
|
||||
])
|
||||
// Настройки стрима — укажи platform и channel
|
||||
const streamConfig = ref({
|
||||
platform: 'twitch' as 'twitch' | 'youtube',
|
||||
channel: '', // например: 'imo_online' для Twitch или video ID для YouTube
|
||||
isLive: false,
|
||||
})
|
||||
|
||||
const user = inject<any>('$user')
|
||||
const dayjs = inject<any>('$dayjs')
|
||||
|
||||
Reference in New Issue
Block a user