feat(youtube): playlist support + URL parsing in youtube_video block
YoutubeVideoBlockData gains an optional `playlistId`. The block now accepts either a video, a playlist, or both, and either field can be filled with a raw ID *or* a full youtube.com / youtu.be URL — a helper extracts the ?v= and ?list= parameters automatically. Embed routing: - video only → /embed/<id>?rel=0 - playlist only → /embed/videoseries?list=<plid>&rel=0 - video + playlist → /embed/<id>?list=<plid>&rel=0 - block.params merged via URLSearchParams Also tweaks ImageGalleryBlock (grid variant): green padding/contain fit, darker caption gradient, smaller left-aligned label that expands on hover. Hides post_overview block when no posts and no intro text (prevents a bare heading on /mediathek).
This commit is contained in:
@@ -155,6 +155,13 @@ export interface YoutubeVideoBlockData {
|
|||||||
_type?: "youtube_video";
|
_type?: "youtube_video";
|
||||||
_slug?: string;
|
_slug?: string;
|
||||||
youtubeId?: string;
|
youtubeId?: string;
|
||||||
|
/**
|
||||||
|
* Playlist-ID (PLxxxxx). Wenn ohne youtubeId gesetzt, lädt die Playlist
|
||||||
|
* von vorne; mit youtubeId startet das Video in der Playlist.
|
||||||
|
* Akzeptiert auch direkte URLs wie
|
||||||
|
* https://www.youtube.com/watch?v=...&list=PL... (wird parsed).
|
||||||
|
*/
|
||||||
|
playlistId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
params?: string;
|
params?: string;
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
<li class="m-0 p-0">
|
<li class="m-0 p-0">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative block w-full aspect-square overflow-hidden rounded-sm bg-stein-100 cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-wald-500"
|
class="group relative block w-full aspect-square overflow-hidden rounded-sm bg-wald-100 p-2 cursor-zoom-in focus:outline-none focus:ring-2 focus:ring-wald-500"
|
||||||
onclick={() => openModal(i)}
|
onclick={() => openModal(i)}
|
||||||
aria-label={label || t(T.image_gallery_open_aria)}
|
aria-label={label || t(T.image_gallery_open_aria)}
|
||||||
>
|
>
|
||||||
@@ -185,15 +185,15 @@
|
|||||||
focal={item.focal}
|
focal={item.focal}
|
||||||
width={400}
|
width={400}
|
||||||
aspect="1/1"
|
aspect="1/1"
|
||||||
fit="cover"
|
fit="contain"
|
||||||
quality={75}
|
quality={80}
|
||||||
alt={label}
|
alt={label}
|
||||||
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
class="w-full h-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||||
loading={i < 4 ? "eager" : "lazy"}
|
loading={i < 4 ? "eager" : "lazy"}
|
||||||
/>
|
/>
|
||||||
{#if label}
|
{#if label}
|
||||||
<span
|
<span
|
||||||
class="absolute inset-x-0 bottom-0 px-2 py-1.5 text-xs text-white bg-gradient-to-t from-black/70 via-black/40 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus:opacity-100 line-clamp-2"
|
class="absolute inset-x-0 bottom-0 max-h-full overflow-y-auto px-2 py-1.5 text-left text-[10px] leading-tight text-white bg-gradient-to-t from-black/95 via-black/80 to-black/30 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus:opacity-100 line-clamp-2 group-hover:line-clamp-none group-focus:line-clamp-none"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,16 +7,85 @@
|
|||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
let { block }: { block: YoutubeVideoBlockData } = $props();
|
let { block }: { block: YoutubeVideoBlockData } = $props();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert (videoId, playlistId) — beide Felder akzeptieren auch volle
|
||||||
|
* youtube.com-URLs (watch?v=…&list=…, /playlist?list=…) statt reiner IDs.
|
||||||
|
*/
|
||||||
|
function parseYouTube(input: string | undefined): {
|
||||||
|
videoId: string | null;
|
||||||
|
playlistId: string | null;
|
||||||
|
} {
|
||||||
|
if (!input) return { videoId: null, playlistId: null };
|
||||||
|
const raw = input.trim();
|
||||||
|
if (!raw) return { videoId: null, playlistId: null };
|
||||||
|
// Direkte URL erkennen.
|
||||||
|
if (raw.startsWith("http")) {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
const v = u.searchParams.get("v");
|
||||||
|
const list = u.searchParams.get("list");
|
||||||
|
// youtu.be/<id> → path
|
||||||
|
let pathVid: string | null = null;
|
||||||
|
if (u.hostname.includes("youtu.be")) {
|
||||||
|
pathVid = u.pathname.replace(/^\/+/, "").split("/")[0] || null;
|
||||||
|
}
|
||||||
|
// /embed/<id>
|
||||||
|
const embedMatch = u.pathname.match(/\/embed\/([^/?]+)/);
|
||||||
|
const embedVid = embedMatch?.[1] ?? null;
|
||||||
|
return {
|
||||||
|
videoId: v ?? pathVid ?? embedVid,
|
||||||
|
playlistId: list,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { videoId: null, playlistId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reine ID: PL... → Playlist, sonst Video.
|
||||||
|
if (/^PL[A-Za-z0-9_-]+$/.test(raw)) {
|
||||||
|
return { videoId: null, playlistId: raw };
|
||||||
|
}
|
||||||
|
return { videoId: raw, playlistId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = $derived.by(() => {
|
||||||
|
const fromVid = parseYouTube(block.youtubeId);
|
||||||
|
const fromList = parseYouTube(block.playlistId);
|
||||||
|
return {
|
||||||
|
videoId: fromVid.videoId ?? fromList.videoId,
|
||||||
|
// Explizites playlistId-Feld gewinnt; sonst aus youtubeId-URL.
|
||||||
|
playlistId: fromList.playlistId ?? fromVid.playlistId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const embedUrl = $derived(
|
|
||||||
block.youtubeId
|
const embedUrl = $derived.by(() => {
|
||||||
? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(block.youtubeId)}?rel=0${block.params ? `&${String(block.params).replace(/^&/, "")}` : ""}`
|
const { videoId, playlistId } = parsed;
|
||||||
: null,
|
if (!videoId && !playlistId) return null;
|
||||||
);
|
const base = "https://www.youtube-nocookie.com/embed";
|
||||||
|
const path = videoId
|
||||||
|
? `/${encodeURIComponent(videoId)}`
|
||||||
|
: "/videoseries";
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("rel", "0");
|
||||||
|
if (playlistId) params.set("list", playlistId);
|
||||||
|
// Block-eigene `params` als Query-String mergen (z.B. "start=42&autoplay=0").
|
||||||
|
if (block.params) {
|
||||||
|
try {
|
||||||
|
const extra = new URLSearchParams(String(block.params).replace(/^&/, ""));
|
||||||
|
for (const [k, v] of extra.entries()) params.set(k, v);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed params */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${base}${path}?${params.toString()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasEmbed = $derived(embedUrl != null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
|
<div class={layoutClasses} data-block-type="youtube_video" data-block-slug={block._slug}>
|
||||||
{#if block.youtubeId}
|
{#if hasEmbed}
|
||||||
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
|
<div class="aspect-video w-full overflow-hidden rounded-sm bg-stein-100">
|
||||||
<iframe
|
<iframe
|
||||||
title={block.title ?? t(T.youtube_title_fallback)}
|
title={block.title ?? t(T.youtube_title_fallback)}
|
||||||
|
|||||||
Reference in New Issue
Block a user