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:
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user