diff --git a/frontend/src/components/home/HomeVotingPickerPane.vue b/frontend/src/components/home/HomeVotingPickerPane.vue
index e932007..5e92db5 100644
--- a/frontend/src/components/home/HomeVotingPickerPane.vue
+++ b/frontend/src/components/home/HomeVotingPickerPane.vue
@@ -49,19 +49,35 @@ const props = defineProps<{
-
-
{{ nom.clipPlatform }}
-
- {{ nom.clipTitle }}
-
-
Kein geprüfter Clip für diese Kategorie hinterlegt.
+
+
+
+
+
+
+ Dieser Clip kann nicht eingebettet werden, ist aber über den Link erreichbar.
+
diff --git a/frontend/src/components/home/homeLandingExperience.css b/frontend/src/components/home/homeLandingExperience.css
index d6de400..4977582 100644
--- a/frontend/src/components/home/homeLandingExperience.css
+++ b/frontend/src/components/home/homeLandingExperience.css
@@ -194,8 +194,8 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-vote-card{
display:grid;
- grid-template-columns:minmax(210px,1.1fr) minmax(230px,1fr) auto;
- align-items:center;
+ grid-template-columns:minmax(190px,.8fr) minmax(360px,1.4fr) auto;
+ align-items:start;
gap:16px;
padding:16px 18px;
border:1.5px solid #eadff8;
@@ -290,8 +290,9 @@ html,body{margin:0;padding:0;background:#160a26;}
.home-vote-card__clip{
min-width:0;
display:flex;
- align-items:center;
- gap:10px;
+ flex-direction:column;
+ align-items:stretch;
+ gap:9px;
padding:10px 12px;
border-radius:16px;
background:#fbf8ff;
@@ -301,7 +302,14 @@ html,body{margin:0;padding:0;background:#160a26;}
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;
overflow:hidden;
text-overflow:ellipsis;
@@ -310,11 +318,27 @@ html,body{margin:0;padding:0;background:#160a26;}
text-decoration:none;
}
-.home-vote-card__clip a:hover{
+.home-vote-card__clip-header a:hover{
color:#4f32a2;
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{
background:#fffaf0;
border-color:#f2dfb6;
@@ -805,6 +829,10 @@ html,body{margin:0;padding:0;background:#160a26;}
flex-direction:column!important;
}
+ .home-vote-card__clip-header{
+ width:100%!important;
+ }
+
.home-vote-card__pick{
width:100%!important;
}
diff --git a/frontend/src/components/home/homeModalTypes.ts b/frontend/src/components/home/homeModalTypes.ts
index 39b6e2b..9fb0036 100644
--- a/frontend/src/components/home/homeModalTypes.ts
+++ b/frontend/src/components/home/homeModalTypes.ts
@@ -17,6 +17,8 @@ export interface HomeNomineeListItem {
clipUrl: string | null
clipTitle: string
clipPlatform: string
+ clipEmbedUrl: string | null
+ clipEmbedTitle: string
idx: number
selected: boolean
hasClip: boolean
diff --git a/frontend/src/components/home/useHomeLandingPresentation.ts b/frontend/src/components/home/useHomeLandingPresentation.ts
index f3348a7..1a77755 100644
--- a/frontend/src/components/home/useHomeLandingPresentation.ts
+++ b/frontend/src/components/home/useHomeLandingPresentation.ts
@@ -1,5 +1,6 @@
import { computed, type ComputedRef, type Ref } from 'vue'
+import { buildClipEmbed } from '../../lib/clipEmbeds'
import { useAuthStore } from '../../stores/auth'
import { useAwardsStore } from '../../stores/awards'
import type { HomeDisplayCategory, HomeInteractionModalKind } from './homeLandingTypes'
@@ -117,6 +118,7 @@ export function useHomeModalCandidatePresentation(params: {
const clipUrl = candidate.clipUrl?.trim() || null
const clipTitle = candidate.clipTitle?.trim() || 'Highlight-Clip ansehen'
const clipPlatform = clipUrl ? candidate.clipPlatform?.trim() || candidate.platform : 'Clip fehlt'
+ const clipEmbed = clipUrl ? buildClipEmbed(clipUrl) : null
return {
name: candidate.displayName,
handle: candidate.channelSlug,
@@ -125,6 +127,8 @@ export function useHomeModalCandidatePresentation(params: {
clipUrl,
clipTitle,
clipPlatform,
+ clipEmbedUrl: clipEmbed?.src ?? null,
+ clipEmbedTitle: clipEmbed?.title ?? clipTitle,
idx,
selected,
hasClip: Boolean(clipUrl),
diff --git a/frontend/src/lib/clipEmbeds.ts b/frontend/src/lib/clipEmbeds.ts
new file mode 100644
index 0000000..0f5a184
--- /dev/null
+++ b/frontend/src/lib/clipEmbeds.ts
@@ -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
+}