From 8139bef1d4e72fbbff51b2d18b3401b4945ed187 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Wed, 28 Jan 2026 17:08:37 +0100 Subject: [PATCH] feat: multi-channel support in admin dashboard UI - Dashboard now fetches and displays all configured channels - Each channel shows its own header, live status, and videos - Channels are separated by visual dividers - Backward compatible with single-channel config - Version bump to 1.2.0 Co-Authored-By: Claude Opus 4.5 --- lib/controllers/dashboard.js | 119 ++++++++++++++++++++++------------- package.json | 2 +- views/youtube.njk | 110 ++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+), 43 deletions(-) diff --git a/lib/controllers/dashboard.js b/lib/controllers/dashboard.js index ddd0ec0..c994c98 100644 --- a/lib/controllers/dashboard.js +++ b/lib/controllers/dashboard.js @@ -1,24 +1,37 @@ import { YouTubeClient } from "../youtube-client.js"; +/** + * Get all channels from config (supports both single and multi-channel modes) + * @param {object} config - YouTube configuration + * @returns {Array<{id?: string, handle?: string, name?: string}>} + */ +function getAllChannels(config) { + const { channelId, channelHandle, channels } = config; + + // Multi-channel mode + if (channels && Array.isArray(channels) && channels.length > 0) { + return channels.map((ch) => ({ + id: ch.id, + handle: ch.handle, + name: ch.name, + })); + } + + // Single channel mode (backward compatible) + if (channelId || channelHandle) { + return [{ id: channelId, handle: channelHandle }]; + } + + return []; +} + /** * Get primary channel from config (for backward compatibility) * Multi-channel mode uses first channel for dashboard */ function getPrimaryChannel(config) { - const { channelId, channelHandle, channels } = config; - - // Multi-channel mode: use first channel - if (channels && Array.isArray(channels) && channels.length > 0) { - const first = channels[0]; - return { id: first.id, handle: first.handle }; - } - - // Single channel mode (backward compatible) - if (channelId || channelHandle) { - return { id: channelId, handle: channelHandle }; - } - - return null; + const channels = getAllChannels(config); + return channels.length > 0 ? channels[0] : null; } /** @@ -49,38 +62,53 @@ export const dashboardController = { }); } - const primaryChannel = getPrimaryChannel(youtubeConfig); + const allChannels = getAllChannels(youtubeConfig); - if (!primaryChannel) { + if (allChannels.length === 0) { return response.render("youtube", { title: response.locals.__("youtube.title"), error: { message: response.locals.__("youtube.error.noChannel") }, }); } - const client = new YouTubeClient({ - apiKey, - channelId: primaryChannel.id, - channelHandle: primaryChannel.handle, - cacheTtl, - }); + // Fetch data for all configured channels + const channelsData = []; - let channel = null; - let videos = []; - let liveStatus = null; - - try { - [channel, videos, liveStatus] = await Promise.all([ - client.getChannelInfo(), - client.getLatestVideos(limits?.videos || 6), - client.getLiveStatusEfficient(), - ]); - } catch (apiError) { - console.error("[YouTube] API error:", apiError.message); - return response.render("youtube", { - title: response.locals.__("youtube.title"), - error: { message: response.locals.__("youtube.error.connection") }, + for (const channelConfig of allChannels) { + const client = new YouTubeClient({ + apiKey, + channelId: channelConfig.id, + channelHandle: channelConfig.handle, + cacheTtl, }); + + try { + const [channel, videos, liveStatus] = await Promise.all([ + client.getChannelInfo(), + client.getLatestVideos(limits?.videos || 6), + client.getLiveStatusEfficient(), + ]); + + channelsData.push({ + name: channelConfig.name || channel.title, + channel, + videos: videos.slice(0, limits?.videos || 6), + liveStatus, + isLive: liveStatus?.isLive || false, + isUpcoming: liveStatus?.isUpcoming || false, + }); + } catch (apiError) { + console.error( + `[YouTube] API error for channel ${channelConfig.name || channelConfig.id || channelConfig.handle}:`, + apiError.message, + ); + // Continue with other channels even if one fails + channelsData.push({ + name: + channelConfig.name || channelConfig.id || channelConfig.handle, + error: apiError.message, + }); + } } // Determine public frontend URL @@ -88,13 +116,20 @@ export const dashboardController = { ? youtubeEndpoint.replace(/api$/, "") : "/youtube"; + // For backward compatibility, also expose first channel's data at top level + const primaryData = channelsData[0] || {}; + response.render("youtube", { title: response.locals.__("youtube.title"), - channel, - videos: videos.slice(0, limits?.videos || 6), - liveStatus, - isLive: liveStatus?.isLive || false, - isUpcoming: liveStatus?.isUpcoming || false, + // Multi-channel data + channelsData, + isMultiChannel: allChannels.length > 1, + // Backward compatible single-channel data (first channel) + channel: primaryData.channel, + videos: primaryData.videos, + liveStatus: primaryData.liveStatus, + isLive: primaryData.isLive || false, + isUpcoming: primaryData.isUpcoming || false, publicUrl, mountPath: request.baseUrl, }); diff --git a/package.json b/package.json index f681f22..bb99bf7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-youtube", - "version": "1.1.1", + "version": "1.2.0", "description": "YouTube channel endpoint for Indiekit. Display latest videos and live status from any YouTube channel.", "keywords": [ "indiekit", diff --git a/views/youtube.njk b/views/youtube.njk index c8b0874..c098dbe 100644 --- a/views/youtube.njk +++ b/views/youtube.njk @@ -178,7 +178,117 @@ {% if error %} {{ prose({ text: error.message }) }} + {% elif isMultiChannel and channelsData %} + {# Multi-channel mode: show all channels #} + {% for chData in channelsData %} +
+ {# Channel Header #} + {% if chData.error %} +
+
+

{{ chData.name }}

+

Error: {{ chData.error }}

+
+
+ {% elif chData.channel %} +
+ {% if chData.channel.thumbnail %} + + {% endif %} +
+

{{ chData.channel.title }}

+
+ {{ chData.channel.subscriberCount }} {{ __("youtube.subscribers") }} + {{ chData.channel.videoCount }} videos +
+
+ {% if chData.isLive %} + + + {{ __("youtube.live") }} + + {% elif chData.isUpcoming %} + + {{ __("youtube.upcoming") }} + + {% else %} + + {{ __("youtube.offline") }} + + {% endif %} +
+ + {# Live Stream (if live) #} + {% if chData.liveStatus and (chData.liveStatus.isLive or chData.liveStatus.isUpcoming) %} +
+

{% if chData.liveStatus.isLive %}{{ __("youtube.live") }}{% else %}{{ __("youtube.upcoming") }}{% endif %}

+
+ {% if chData.liveStatus.thumbnail %} + + {% endif %} +
+

+ + {{ chData.liveStatus.title }} + +

+ {{ button({ + href: "https://www.youtube.com/watch?v=" + chData.liveStatus.videoId, + text: __("youtube.watchNow"), + target: "_blank" + }) }} +
+
+
+ {% endif %} + + {# Latest Videos #} +
+

{{ __("youtube.videos") }}

+ {% if chData.videos and chData.videos.length > 0 %} +
    + {% for video in chData.videos %} +
  • +
    + + + + {% if video.durationFormatted and not video.isLive %} + {{ video.durationFormatted }} + {% elif video.isLive %} + LIVE + {% endif %} +
    +
    +

    + {{ video.title }} +

    +
    + {{ video.viewCount }} {{ __("youtube.views") }} +
    +
    +
  • + {% endfor %} +
+ {% else %} + {{ prose({ text: __("youtube.noVideos") }) }} + {% endif %} +
+ + {# Link to YouTube channel #} + + {% endif %} +
+ {% endfor %} {% else %} + {# Single channel mode (backward compatible) #} {# Channel Header #} {% if channel %}