feat(youtube): playlist support + URL parsing in youtube_video block
Deploy / verify (push) Successful in 42s
Deploy / deploy (push) Successful in 1m5s

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:
Peter Meier
2026-04-25 13:15:11 +02:00
parent 942b9feefa
commit 354d3f09d3
3 changed files with 87 additions and 11 deletions
+7
View File
@@ -155,6 +155,13 @@ export interface YoutubeVideoBlockData {
_type?: "youtube_video";
_slug?: 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;
description?: string;
params?: string;
@@ -176,7 +176,7 @@
<li class="m-0 p-0">
<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)}
aria-label={label || t(T.image_gallery_open_aria)}
>
@@ -185,15 +185,15 @@
focal={item.focal}
width={400}
aspect="1/1"
fit="cover"
quality={75}
fit="contain"
quality={80}
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"}
/>
{#if label}
<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}
</span>
@@ -7,16 +7,85 @@
const t = useTranslate();
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 embedUrl = $derived(
block.youtubeId
? `https://www.youtube-nocookie.com/embed/${encodeURIComponent(block.youtubeId)}?rel=0${block.params ? `&${String(block.params).replace(/^&/, "")}` : ""}`
: null,
);
const embedUrl = $derived.by(() => {
const { videoId, playlistId } = parsed;
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>
<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">
<iframe
title={block.title ?? t(T.youtube_title_fallback)}