Embed voting clips on landing page

This commit is contained in:
AzuTear
2026-06-25 09:54:22 +02:00
parent f851e500f7
commit fd81c968f6
5 changed files with 169 additions and 19 deletions
@@ -49,19 +49,35 @@ const props = defineProps<{
</div> </div>
</div> </div>
<div class="home-vote-card__clip"> <div class="home-vote-card__clip" @click.stop>
<span class="home-vote-card__clip-platform">{{ nom.clipPlatform }}</span> <div class="home-vote-card__clip-header">
<a <span class="home-vote-card__clip-platform">{{ nom.clipPlatform }}</span>
v-if="nom.clipUrl" <a
:href="nom.clipUrl" v-if="nom.clipUrl"
target="_blank" :href="nom.clipUrl"
rel="noopener noreferrer" target="_blank"
referrerpolicy="no-referrer" rel="noopener noreferrer"
@click.stop referrerpolicy="no-referrer"
> >
{{ nom.clipTitle }} {{ nom.clipTitle }}
</a> </a>
<span v-else>Kein geprüfter Clip für diese Kategorie hinterlegt.</span> <span v-else>Kein geprüfter Clip für diese Kategorie hinterlegt.</span>
</div>
<iframe
v-if="nom.clipEmbedUrl"
class="home-vote-card__clip-player"
:src="nom.clipEmbedUrl"
:title="nom.clipEmbedTitle"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="strict-origin-when-cross-origin"
/>
<p v-else-if="nom.clipUrl" class="home-vote-card__clip-fallback">
Dieser Clip kann nicht eingebettet werden, ist aber über den Link erreichbar.
</p>
</div> </div>
<template v-if="nom.showPick"> <template v-if="nom.showPick">
@@ -194,8 +194,8 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-vote-card{ .home-vote-card{
display:grid; display:grid;
grid-template-columns:minmax(210px,1.1fr) minmax(230px,1fr) auto; grid-template-columns:minmax(190px,.8fr) minmax(360px,1.4fr) auto;
align-items:center; align-items:start;
gap:16px; gap:16px;
padding:16px 18px; padding:16px 18px;
border:1.5px solid #eadff8; border:1.5px solid #eadff8;
@@ -290,8 +290,9 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-vote-card__clip{ .home-vote-card__clip{
min-width:0; min-width:0;
display:flex; display:flex;
align-items:center; flex-direction:column;
gap:10px; align-items:stretch;
gap:9px;
padding:10px 12px; padding:10px 12px;
border-radius:16px; border-radius:16px;
background:#fbf8ff; background:#fbf8ff;
@@ -301,7 +302,14 @@ html,body{margin:0;padding:0;background:#160a26;}
font-weight:700; font-weight:700;
} }
.home-vote-card__clip a{ .home-vote-card__clip-header{
min-width:0;
display:flex;
align-items:center;
gap:10px;
}
.home-vote-card__clip-header a{
min-width:0; min-width:0;
overflow:hidden; overflow:hidden;
text-overflow:ellipsis; text-overflow:ellipsis;
@@ -310,11 +318,27 @@ html,body{margin:0;padding:0;background:#160a26;}
text-decoration:none; text-decoration:none;
} }
.home-vote-card__clip a:hover{ .home-vote-card__clip-header a:hover{
color:#4f32a2; color:#4f32a2;
text-decoration:underline; text-decoration:underline;
} }
.home-vote-card__clip-player{
width:100%;
aspect-ratio:16/9;
border:0;
border-radius:12px;
background:#181226;
box-shadow:0 10px 26px rgba(63,53,86,.15);
}
.home-vote-card__clip-fallback{
margin:0;
color:#8b779f;
font-size:12px;
line-height:1.45;
}
.home-vote-card--missing-clip .home-vote-card__clip{ .home-vote-card--missing-clip .home-vote-card__clip{
background:#fffaf0; background:#fffaf0;
border-color:#f2dfb6; border-color:#f2dfb6;
@@ -805,6 +829,10 @@ html,body{margin:0;padding:0;background:#160a26;}
flex-direction:column!important; flex-direction:column!important;
} }
.home-vote-card__clip-header{
width:100%!important;
}
.home-vote-card__pick{ .home-vote-card__pick{
width:100%!important; width:100%!important;
} }
@@ -17,6 +17,8 @@ export interface HomeNomineeListItem {
clipUrl: string | null clipUrl: string | null
clipTitle: string clipTitle: string
clipPlatform: string clipPlatform: string
clipEmbedUrl: string | null
clipEmbedTitle: string
idx: number idx: number
selected: boolean selected: boolean
hasClip: boolean hasClip: boolean
@@ -1,5 +1,6 @@
import { computed, type ComputedRef, type Ref } from 'vue' import { computed, type ComputedRef, type Ref } from 'vue'
import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAuthStore } from '../../stores/auth' import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards' import { useAwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes' import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes'
@@ -117,6 +118,7 @@ export function useHomeModalCandidatePresentation(params: {
const clipUrl = candidate.clipUrl?.trim() || null const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen' const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt' const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
const clipEmbed = clipUrl ? buildClipEmbed(clipUrl) : null
return { return {
name: candidate.displayName, name: candidate.displayName,
handle: candidate.channelSlug, handle: candidate.channelSlug,
@@ -125,6 +127,8 @@ export function useHomeModalCandidatePresentation(params: {
clipUrl, clipUrl,
clipTitle, clipTitle,
clipPlatform, clipPlatform,
clipEmbedUrl: clipEmbed?.src ?? null,
clipEmbedTitle: clipEmbed?.title ?? clipTitle,
idx, idx,
selected, selected,
hasClip: Boolean(clipUrl), hasClip: Boolean(clipUrl),
+100
View File
@@ -0,0 +1,100 @@
export type ClipEmbedKind = 'twitch' | 'youtube'
export interface ClipEmbed {
kind: ClipEmbedKind
src: string
title: string
}
export function buildClipEmbed(clipUrl: string, parentHost = currentParentHost()): ClipEmbed | null {
const url = parseUrl(clipUrl)
if (!url) return null
const twitchSlug = parseTwitchClipSlug(url)
if (twitchSlug && parentHost) {
const params = new URLSearchParams({ clip: twitchSlug, parent: parentHost })
return {
kind: 'twitch',
src: `https://clips.twitch.tv/embed?${params.toString()}`,
title: 'Twitch Clip Player',
}
}
const youtubeId = parseYoutubeVideoId(url)
if (youtubeId) {
return {
kind: 'youtube',
src: `https://www.youtube-nocookie.com/embed/${encodeURIComponent(youtubeId)}`,
title: 'YouTube Video Player',
}
}
return null
}
function currentParentHost() {
if (typeof window === 'undefined') return ''
return window.location.hostname
}
function parseUrl(value: string) {
try {
return new URL(value)
} catch {
return null
}
}
function parseTwitchClipSlug(url: URL) {
const host = normalizeHost(url.hostname)
const segments = pathSegments(url.pathname)
if (host === 'clips.twitch.tv') {
return sanitizeSlug(segments[0])
}
if (host === 'twitch.tv' && segments.length >= 3 && segments[1] === 'clip') {
return sanitizeSlug(segments[2])
}
return null
}
function parseYoutubeVideoId(url: URL) {
const host = normalizeHost(url.hostname)
const segments = pathSegments(url.pathname)
if (host === 'youtu.be') {
return sanitizeYoutubeId(segments[0])
}
if (host === 'youtube.com') {
if (segments[0] === 'watch') {
return sanitizeYoutubeId(url.searchParams.get('v'))
}
if (segments[0] === 'embed' || segments[0] === 'shorts' || segments[0] === 'live') {
return sanitizeYoutubeId(segments[1])
}
}
return null
}
function normalizeHost(hostname: string) {
return hostname.toLowerCase().replace(/^www\./, '').replace(/^m\./, '')
}
function pathSegments(pathname: string) {
return pathname.split('/').map((item) => item.trim()).filter(Boolean)
}
function sanitizeSlug(value: string | null | undefined) {
const slug = value?.trim()
return slug && /^[A-Za-z0-9_-]+$/.test(slug) ? slug : null
}
function sanitizeYoutubeId(value: string | null | undefined) {
const id = value?.trim()
return id && /^[A-Za-z0-9_-]{6,}$/.test(id) ? id : null
}