- Add skip-to-main-content link and main content ID target - Add prefers-reduced-motion media queries for all animations - Enhance visible focus indicators (2px offset, high-contrast ring) - Replace ~160 text-surface-500 instances with text-surface-600/dark:text-surface-400 for 4.5:1+ contrast ratio compliance - Add aria-hidden="true" to ~30+ decorative SVG icons across sidebars/widgets - Convert facepile containers from div to semantic ul/li with role="list" - Add aria-label to icon-only buttons (share, sort controls) - Add sr-only labels to form inputs (webmention, search) - Add aria-live="polite" to dynamically loaded webmentions - Add aria-label with relative+absolute date to time-difference component - Add keyboard handlers (Enter/Space) to custom interactive elements - Add aria-label to nav landmarks (table of contents) - Fix modal focus trap and dialog accessibility - Fix lightbox keyboard navigation and screen reader announcements Confab-Link: http://localhost:8080/sessions/edb1b7b0-da66-4486-bd9c-d1cfa7553b88
265 lines
14 KiB
Plaintext
265 lines
14 KiB
Plaintext
---
|
|
layout: layouts/base.njk
|
|
title: YouTube Channels
|
|
permalink: /youtube/
|
|
withSidebar: true
|
|
---
|
|
<div class="youtube-page" x-data="{ activeChannel: 0 }">
|
|
<header class="mb-6 sm:mb-8">
|
|
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">YouTube Channels</h1>
|
|
<p class="text-surface-600 dark:text-surface-400">
|
|
Latest videos and live streams from my YouTube channels.
|
|
</p>
|
|
</header>
|
|
|
|
{# Multi-channel tabs #}
|
|
{% if youtubeChannel.isMultiChannel and youtubeChannel.channels.length > 1 %}
|
|
<div class="mb-6">
|
|
<div class="flex flex-wrap gap-2 border-b border-surface-200 dark:border-surface-700" role="tablist" aria-label="YouTube channels">
|
|
{% for channel in youtubeChannel.channels %}
|
|
<button
|
|
@click="activeChannel = {{ loop.index0 }}"
|
|
:class="activeChannel === {{ loop.index0 }} ? 'border-red-500 text-red-600 dark:text-red-400' : 'border-transparent text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100'"
|
|
:aria-selected="(activeChannel === {{ loop.index0 }}).toString()"
|
|
role="tab" id="yt-tab-{{ loop.index0 }}" aria-controls="yt-panel-{{ loop.index0 }}"
|
|
class="px-4 py-2 font-medium border-b-2 -mb-px transition-colors"
|
|
>
|
|
{{ channel.configName or channel.title }}
|
|
</button>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Channel sections #}
|
|
{% for channel in youtubeChannel.channels %}
|
|
<div x-show="activeChannel === {{ loop.index0 }}" {% if not loop.first %}x-cloak{% endif %} role="tabpanel" id="yt-panel-{{ loop.index0 }}" aria-labelledby="yt-tab-{{ loop.index0 }}">
|
|
{# Channel Header #}
|
|
<section class="mb-8">
|
|
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-5 p-4 sm:p-6 bg-gradient-to-br from-red-500/10 to-red-600/5 dark:from-red-900/20 dark:to-red-800/10 rounded-xl sm:rounded-2xl border border-red-500/20">
|
|
{% if channel.thumbnail %}
|
|
<img
|
|
src="{{ channel.thumbnail }}"
|
|
alt=""
|
|
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full shadow-lg object-cover flex-shrink-0"
|
|
loading="lazy"
|
|
>
|
|
{% else %}
|
|
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
|
<svg class="w-8 h-8 sm:w-10 sm:h-10 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
|
</svg>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="flex-1 min-w-0 w-full sm:w-auto">
|
|
<h2 class="text-lg sm:text-xl font-bold text-surface-900 dark:text-surface-100 truncate">
|
|
<a href="{{ channel.url }}" class="hover:text-red-600 dark:hover:text-red-400" target="_blank" rel="noopener">
|
|
{{ channel.title }}
|
|
</a>
|
|
</h2>
|
|
{% if channel.customUrl %}
|
|
<p class="text-sm text-surface-600 dark:text-surface-400">{{ channel.customUrl }}</p>
|
|
{% endif %}
|
|
<div class="flex flex-wrap items-center gap-4 mt-2 text-sm text-surface-600 dark:text-surface-400">
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
</svg>
|
|
{{ channel.subscriberCountFormatted }} subscribers
|
|
</span>
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
{{ channel.videoCountFormatted }} videos
|
|
</span>
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
|
</svg>
|
|
{{ channel.viewCountFormatted }} views
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# Live Status Badge for this channel #}
|
|
{% set channelLiveStatus = youtubeChannel.liveStatuses | selectattr("channelConfigName", "equalto", channel.configName) | first %}
|
|
<div class="flex-shrink-0">
|
|
{% if channelLiveStatus and channelLiveStatus.isLive %}
|
|
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-red-500 text-white text-sm font-semibold rounded-full animate-pulse">
|
|
<span class="w-2 h-2 bg-surface-50 rounded-full"></span>
|
|
LIVE
|
|
</span>
|
|
{% elif channelLiveStatus and channelLiveStatus.isUpcoming %}
|
|
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-blue-500 text-white text-sm font-semibold rounded-full">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Upcoming
|
|
</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center gap-2 px-3 py-1.5 bg-surface-200 dark:bg-surface-700 text-surface-600 dark:text-surface-400 text-sm font-medium rounded-full">
|
|
Offline
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{# Live Stream Section for this channel #}
|
|
{% set channelLiveStatus = youtubeChannel.liveStatuses | selectattr("channelConfigName", "equalto", channel.configName) | first %}
|
|
{% if channelLiveStatus and channelLiveStatus.stream and (channelLiveStatus.isLive or channelLiveStatus.isUpcoming) %}
|
|
<section class="mb-12">
|
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
|
{% if channelLiveStatus.isLive %}
|
|
<span class="flex items-center gap-2 text-red-500">
|
|
<span class="w-3 h-3 bg-red-500 rounded-full animate-pulse"></span>
|
|
Live Now
|
|
</span>
|
|
{% else %}
|
|
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
Upcoming Stream
|
|
{% endif %}
|
|
</h2>
|
|
|
|
<a href="{{ channelLiveStatus.stream.url }}" class="block group" target="_blank" rel="noopener">
|
|
<div class="relative rounded-xl overflow-hidden {% if channelLiveStatus.isLive %}ring-2 ring-red-500{% else %}ring-1 ring-blue-500/50{% endif %}">
|
|
{% if channelLiveStatus.stream.thumbnail %}
|
|
<img
|
|
src="{{ channelLiveStatus.stream.thumbnail }}"
|
|
alt=""
|
|
class="w-full aspect-video object-cover"
|
|
>
|
|
{% endif %}
|
|
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
|
|
<div class="p-6">
|
|
<h3 class="text-xl font-bold text-white group-hover:text-red-300 transition-colors">
|
|
{{ channelLiveStatus.stream.title }}
|
|
</h3>
|
|
<p class="text-white/80 mt-2 flex items-center gap-2">
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
Watch Now
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{% if channelLiveStatus.isLive %}
|
|
<div class="absolute top-4 left-4">
|
|
<span class="inline-flex items-center gap-1.5 px-2 py-1 bg-red-500 text-white text-xs font-bold rounded">
|
|
<span class="w-1.5 h-1.5 bg-surface-50 rounded-full animate-pulse"></span>
|
|
LIVE
|
|
</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</a>
|
|
</section>
|
|
{% endif %}
|
|
|
|
{# Videos Grid for this channel #}
|
|
<section class="mb-12">
|
|
<h2 class="text-xl sm:text-2xl font-bold text-surface-900 dark:text-surface-100 mb-4 sm:mb-6 flex items-center gap-2">
|
|
<svg class="w-6 h-6 text-red-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
|
</svg>
|
|
Latest Videos
|
|
</h2>
|
|
|
|
{% set channelName = channel.configName or channel.title %}
|
|
{% set channelVideos = youtubeChannel.videosByChannel[channelName] %}
|
|
{% if channelVideos and channelVideos.length %}
|
|
<div class="grid gap-4 sm:gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
|
{% for video in channelVideos | head(9) %}
|
|
<article class="group bg-surface-50 dark:bg-surface-800 rounded-xl overflow-hidden border border-surface-200 dark:border-surface-700 hover:border-red-400 dark:hover:border-red-600 transition-colors shadow-sm">
|
|
<a href="{{ video.url }}" class="block" target="_blank" rel="noopener">
|
|
<div class="relative aspect-video">
|
|
{% if video.thumbnail %}
|
|
<img
|
|
src="{{ video.thumbnail }}"
|
|
alt=""
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
>
|
|
{% else %}
|
|
<div class="w-full h-full bg-surface-200 dark:bg-surface-700 flex items-center justify-center">
|
|
<svg class="w-12 h-12 text-surface-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Duration badge #}
|
|
{% if video.durationFormatted and not video.isLive %}
|
|
<span class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-black/80 text-white text-xs font-medium rounded">
|
|
{{ video.durationFormatted }}
|
|
</span>
|
|
{% elif video.isLive %}
|
|
<span class="absolute bottom-2 right-2 px-1.5 py-0.5 bg-red-500 text-white text-xs font-bold rounded flex items-center gap-1">
|
|
<span class="w-1.5 h-1.5 bg-surface-50 rounded-full animate-pulse"></span>
|
|
LIVE
|
|
</span>
|
|
{% endif %}
|
|
|
|
{# Play overlay on hover #}
|
|
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
|
|
<div class="w-12 h-12 rounded-full bg-red-500/90 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<svg class="w-6 h-6 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
|
|
<div class="p-4">
|
|
<h3 class="font-semibold text-surface-900 dark:text-surface-100 line-clamp-2 mb-2 group-hover:text-red-600 dark:group-hover:text-red-400">
|
|
<a href="{{ video.url }}" target="_blank" rel="noopener">
|
|
{{ video.title }}
|
|
</a>
|
|
</h3>
|
|
<div class="flex items-center gap-3 text-sm text-surface-600 dark:text-surface-400">
|
|
<span class="flex items-center gap-1">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
|
</svg>
|
|
{{ video.viewCountFormatted }}
|
|
</span>
|
|
<span>{{ video.relativeTime }}</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<div class="mt-8 text-center">
|
|
<a
|
|
href="{{ channel.url }}"
|
|
class="inline-flex items-center gap-2 px-6 py-3 bg-red-500 hover:bg-red-600 text-white font-semibold rounded-lg transition-colors"
|
|
target="_blank"
|
|
rel="noopener"
|
|
>
|
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
|
|
</svg>
|
|
View All on YouTube
|
|
</a>
|
|
</div>
|
|
{% else %}
|
|
<p class="text-surface-600 dark:text-surface-400">No videos available yet.</p>
|
|
{% endif %}
|
|
</section>
|
|
</div>
|
|
{% endfor %}
|
|
|
|
{# Fallback for no channels #}
|
|
{% if not youtubeChannel.channels.length %}
|
|
<p class="text-surface-600 dark:text-surface-400">No YouTube channels configured.</p>
|
|
{% endif %}
|
|
</div>
|