diff --git a/js/privacy/eclipsefdn.videos.js b/js/privacy/eclipsefdn.videos.js index 34b1981ccfc1ed66e0132f4488cebf7e5c510733..c577a3421acf1c46f0bd3b1e98c8ade256b6dad8 100644 --- a/js/privacy/eclipsefdn.videos.js +++ b/js/privacy/eclipsefdn.videos.js @@ -11,6 +11,35 @@ * * SPDX-License-Identifier: EPL-2.0 */ + +/** + * Checks if a given YouTube ID is likely a playlist ID. + * This determination is based on common prefixes (PL, UU, FL, RD) and length patterns, + * distinguishing them from typical 11-character video IDs. + * Assumes the input 'id' is the cleaned ID string, without URL parameters. + * + * @param {string} id The YouTube ID string to check. + * @returns {boolean} True if the ID is likely a playlist ID, false otherwise. + */ +export const isPlaylistId = (id) => { + if (typeof id !== 'string' || id.trim() === '') { + return false; // Not a valid ID format + } + + if ( + (id.startsWith('PL') && id.length > 11) || + (id.startsWith('UU') && id.length === 24) || + (id.startsWith('FL') && id.length === 24) || + (id.startsWith('RD') && id.length > 11) + ) { + return true; + } + + // If none of the above conditions for playlists are met, it's not considered a playlist. + // This includes typical 11-character video IDs (e.g., "PL3DQBsESiQ" or "dQw4w9WgXcQ"). + return false; +} + const eclipseFdnVideos = (function (window, document) { (function (root, factory) { if (typeof define === 'function' && define.amd) { @@ -106,7 +135,7 @@ const eclipseFdnVideos = (function (window, document) { .then(response => response.json()) .then(data => data.thumbnail_url); - const isYoutubeLink = (link) => /^(https?:)?(\/\/)?(www\.)?(youtube\.com|youtu\.be)\//.test(link) + const isYoutubeLink = link => /^(https?:)?(\/\/)?(www\.)?(youtube\.com|youtu\.be)\//.test(link); /** * Adds a video thumbnail to the given item element. @@ -140,7 +169,31 @@ const eclipseFdnVideos = (function (window, document) { } }; - const buildLinkFromId = (id) => id.startsWith('PL') ? `//www.youtube.com/embed/playlist?list=${id}` : `//www.youtube.com/embed/${id}`; + /** + * If the element is an anchor tag, it checks if it has href and replaces the href with the link. + * If the element is not a link, it wraps the item with an anchor tag. + * + * @param {string} link + * @param {HTMLElement} item + * @returns void + */ + const wrapWithLink = (item, link) => { + const isLink = item.tagName === 'A'; + const anchor = isLink ? item : document.createElement('a'); + + anchor.setAttribute('href', link); + anchor.setAttribute('target', '_blank'); + + if (!isLink) { + anchor.appendChild(item.cloneNode(true)); + item.replaceWith(anchor); + } + }; + + const buildLinkFromId = id => + isPlaylistId(id) + ? `//www.youtube.com/embed/playlist?list=${id}` + : `//www.youtube.com/embed/${id}`; /** * Processes video elements in the document, replacing them with iframes or setting thumbnails. @@ -182,6 +235,7 @@ const eclipseFdnVideos = (function (window, document) { item.replaceWith(createVideoContainer(link, resolution)); } else { addVideoThumbnail(item, link); + wrapWithLink(item, link); } }); }; diff --git a/js/solstice/eclipsefdn.paginated-video-list.js b/js/solstice/eclipsefdn.paginated-video-list.js new file mode 100644 index 0000000000000000000000000000000000000000..c29cad76c216fa8b42ffa3858cfd1cdfcb23cbc9 --- /dev/null +++ b/js/solstice/eclipsefdn.paginated-video-list.js @@ -0,0 +1,190 @@ +/** + * Copyright (c) Eclipse Foundation + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import defaultTemplate from './templates/paginated-video-list.mustache'; +import renderTemplate from './templates/render-template'; +import { isPlaylistId, renderVideos } from '../privacy/eclipsefdn.videos'; + +const defaultOptions = { + count: 5, + filterable: true, +}; + +/** + * Initializes a paginated video list with category filtering capabilities. + * @param {HTMLElement} element - The container element for the video list. + * @description This function creates a paginated list of video items with category filtering. + * @remarks This function expects to find a list of video items within the provided element. @see {@link stories/widgets/eclipsefdn-paginated-video-list.stories.ts} + */ +const initializePaginatedVideoList = element => { + const { count: datasetcount, filterable: datasetFilterable } = element?.dataset; + + const options = { + ...defaultOptions, + ...{ + count: datasetcount && parseInt(datasetcount), + filterable: datasetFilterable && datasetFilterable === 'true', + }, + }; + + // Parse the minimal markup into an internal data structure + const providedVideoItems = Array.from(element.querySelectorAll('.video-item')).map(item => ({ + id: item.dataset.id, + title: item.dataset.title, + date: item.dataset.date, + description: item.dataset.description, + category: item.dataset.category, + url: isPlaylistId(item.dataset.id) + ? `https://www.youtube.com/playlist?list=${item.dataset.id}` + : `https://www.youtube.com/watch?v=${item.dataset.id}`, + })); + + // Force uniquety of categories by using a Set + const categories = [...new Set(providedVideoItems.map(item => item.category))]; + + const state = { + currentPage: 1, + filteredItems: providedVideoItems, + selectedCategory: 'none', + }; + + /** + * Calculates pagination information based on the current state and options + * @returns {Object} Pagination object containing: + * @property {Array<Object>} pages - Array of page objects with number and active status + * @property {boolean} showPrevious - Whether to show previous page button + * @property {boolean} showNext - Whether to show next page button + * @property {number} currentPage - Current active page number + * @property {boolean} hasMultiplePages - Whether there are multiple pages + */ + const computePagination = () => { + const pageCount = Math.ceil(state.filteredItems.length / options.count); + const isFirstPage = state.currentPage === 1; + const isLastPage = state.currentPage === pageCount; + + const pages = Array.from({ length: pageCount }, (_, i) => ({ + number: i + 1, + active: i + 1 === state.currentPage, + })); + + return { + pages, + showPrevious: !isFirstPage, + showNext: !isLastPage, + currentPage: state.currentPage, + showPagination: pages.length > 1, + }; + }; + + /** + * Calculates and returns a subset of filtered items based on the current page and items per page. + * @returns {Array} A slice of filteredItems corresponding to the current page. + */ + const computePageItems = () => { + const start = (state.currentPage - 1) * options.count; + const end = start + options.count; + + return state.filteredItems.slice(start, end); + }; + + /** + * Maps through the categories array and returns an array of objects containing + * the category value, label, and selected state. + * @returns {Array<{value: string, label: string, selected: boolean}>} Array of category objects + */ + const computeCategories = () => + categories.map(cat => ({ + value: cat, + label: cat, + selected: cat === state.selectedCategory, + })); + + /** + * Updates the display of the paginated video list by rendering the template with current state + * and attaching necessary event listeners. + * @async + * @returns {Promise<void>} A promise that resolves when the display has been updated + */ + const updateDisplay = async () => { + await renderTemplate({ + element, + templates: { default: defaultTemplate }, + templateId: 'default', + data: { + items: computePageItems(), + categories: computeCategories(), + noCategory: state.selectedCategory === 'none', + showCategories: options.filterable && categories.length > 1, + pagination: computePagination(), + }, + }); + + attachEventListeners(); + + // Re-render the videos given that the template has been updated asynchronously + renderVideos(); + }; + + /** + * Updates the current category filter and refreshes the video list display + * @param {string} category - The category to filter by. Use 'none' to show all videos + */ + const triggerCategoryUpdate = category => { + state.selectedCategory = category; + state.filteredItems = + category === 'none' + ? providedVideoItems + : providedVideoItems.filter(item => item.category === category); + state.currentPage = 1; + updateDisplay(); + }; + + const attachEventListeners = () => { + const categorySelect = element.querySelector('.video-categories'); + const paginationEl = element.querySelector('.pagination-videos'); + + if (categorySelect) { + categorySelect.addEventListener('change', e => { + triggerCategoryUpdate(e.target.value); + }); + } + + if (paginationEl) { + paginationEl.addEventListener('click', e => { + e.preventDefault(); + const target = e.target.closest('a'); + if (!target) return; + + if (target.getAttribute('aria-label') === 'Previous') { + state.currentPage--; + } else if (target.getAttribute('aria-label') === 'Next') { + state.currentPage++; + } else { + state.currentPage = parseInt(target.textContent); + } + + updateDisplay(); + }); + } + }; + + updateDisplay(); +}; + +const render = () => { + const allPaginatedVideoLists = document.querySelectorAll('.eclipsefdn-paginated-video-list'); + Array.from(allPaginatedVideoLists).forEach(initializePaginatedVideoList); +}; + +const eclipsefdnPaginatedVideoList = { + render, +}; + +export default eclipsefdnPaginatedVideoList; diff --git a/js/solstice/index.js b/js/solstice/index.js index 61a9bfb4364a1f1c813e464d599358272a539ef9..a5e5e84b46f3a561b0f3a74543bdc8d89f5d7d4e 100644 --- a/js/solstice/index.js +++ b/js/solstice/index.js @@ -35,6 +35,7 @@ import eclipsefdnSolsticeSlider from './eclipsefdn.solstice-slider'; import eclipsefdnVideoList from './eclipsefdn.video-list.js'; import eclipsefdnWeightedCollaborations from './eclipsefdn.weighted-collaborations'; import eclipsefdnWorkingGroupsList from './eclipsefdn.wgs-list.js' +import eclipsefdnPaginatedVideoList from './eclipsefdn.paginated-video-list.js'; document.addEventListener('DOMContentLoaded', () => { @@ -49,6 +50,7 @@ document.addEventListener('DOMContentLoaded', () => { eclipsefdnPromotion.render(); eclipsefdnSolsticeSlider.render(); eclipsefdnVideoList.render(); + eclipsefdnPaginatedVideoList.render(); eclipsefdnWeightedCollaborations.render(); eclipsefdnWorkingGroupsList.render(); }); diff --git a/js/solstice/templates/paginated-video-list.mustache b/js/solstice/templates/paginated-video-list.mustache new file mode 100644 index 0000000000000000000000000000000000000000..7b3b016f69cf93b4cfde0cfa9169aff1991bca6a --- /dev/null +++ b/js/solstice/templates/paginated-video-list.mustache @@ -0,0 +1,50 @@ +{{#showCategories}} +<div class="form-group row"> + <div class="col-sm-6"> + <select class="search form-control video-categories" aria-label="Video category filter"> + <option value="none" {{#noCategory}}selected{{/noCategory}}>Choose a category</option> + {{#categories}} + <option value="{{value}}" {{#selected}}selected{{/selected}}>{{label}}</option> + {{/categories}} + </select> + </div> +</div> +{{/showCategories}} + +<ul class="list list-unstyled video-items"> + {{#items}} + <li class="row featured-section-resources-video-item margin-bottom-60"> + <div class="col-sm-11"> + <a class="eclipsefdn-video" data-id="{{id}}"></a> + </div> + <div class="col-sm-11 col-sm-offset-1 padding-top-30 padding-bottom-30"> + <p class="featured-section-resources-video-date text-secondary">{{date}}</p> + <h2 class="h3 featured-section-resources-video-heading big-text"> + <a href="{{url}}" class="link-unstyled" target="_blank">{{title}}</a> + </h2> + <p class="featured-section-resources-video-description margin-bottom-20">{{description}}</p> + <p><a class="btn btn-primary" href="{{url}}" target="_blank">Watch on Youtube</a></p> + </div> + </li> + {{/items}} +</ul> + +{{#pagination.showPagination}} +<div class="text-center"> + <ul class="pagination pagination-videos text-center"> + {{#pagination.showPrevious}} + <li><a href="#" aria-label="Previous"><i class="fa-solid fa-chevron-left" aria-hidden></i></a></li> + {{/pagination.showPrevious}} + + {{#pagination.pages}} + <li {{#active}}class="active"{{/active}}> + <a href="#">{{number}}</a> + </li> + {{/pagination.pages}} + + {{#pagination.showNext}} + <li><a href="#" aria-label="Next"><i class="fa-solid fa-chevron-right" aria-hidden></i></a></li> + {{/pagination.showNext}} + </ul> +</div> +{{/pagination.showPagination}} \ No newline at end of file diff --git a/stories/widgets/eclipsefdn-paginated-video-list.stories.ts b/stories/widgets/eclipsefdn-paginated-video-list.stories.ts new file mode 100644 index 0000000000000000000000000000000000000000..18536e471263868f796b31c00a9bac98a086cc07 --- /dev/null +++ b/stories/widgets/eclipsefdn-paginated-video-list.stories.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2024 Eclipse Foundation, Inc. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * SPDX-License-Identifier: EPL-2.0 + */ + +import '../../less/astro/main.less'; + +import type { StoryObj } from '@storybook/html'; +import { container, afterStoryRender } from '../utils'; +import eclipsefdnPaginatedVideoList from '../../js/solstice/eclipsefdn.paginated-video-list'; + +export default { + title: 'Widgets/Paginated Video List', + decorators: [container()], +}; + +type Story = StoryObj<any>; + +const commonOptions: Partial<Story> = { + argTypes: { + filterable: { control: 'boolean' }, + count: { control: 'number' }, + }, + args: { + filterable: true, + count: 3, + }, +}; + +export const PaginatedVideoList: Story = { + ...commonOptions, + render: ({ count, filterable }) => { + afterStoryRender(eclipsefdnPaginatedVideoList.render); + + return ` + <div class="eclipsefdn-paginated-video-list" data-count="${count}" data-filterable="${filterable}"> + <div class="video-item" + data-id="wrJUam_De6w" + data-title="Sven Erik Jeroschewski, Open Source Developer at Bosch.io, Project Lead Eclipse Kuksa" + data-date="November 30, 2022" + data-description="Sven Erik from Bosch.io explains how the Eclipse Kuksa project was combined with other Eclipse SDV projects for the first software-defined vehicle hackathon challenge that happened last month." + data-category="sdv-community-interviews-sep-2022"> + </div> + <div class="video-item" + data-id="s8GdAt40AJ0" + data-title="Interview with Fillipe Prezado and Audrey Colle during the SDV Hackathon at BCX" + data-date="November 30, 2022" + data-description="During the SDV Hackathon at BCX, Sara Gallian, our Software Defined Vehicle Working Group Program Manager caught up with Fillipe who is also the project lead of Eclipse Chariott." + data-category="sdv-community-interviews-sep-2022"> + </div> + <div class="video-item" + data-id="G6BhLZsPztk" + data-title="Industrial IoT Security: Best Practice for Authentication" + data-date="June 2, 2022" + data-description="Henrike Gerbothe and Jürgen Fitschen speak at IoT & Edge Days 2022" + data-category="virtual-iot-2022"> + </div> + <div class="video-item" + data-id="8a9xRfejmFI" + data-title="High Performance Network Programming in Rust" + data-date="June 2, 2022" + data-description="Angelo Corsaro speaks at IoT & Edge Days 2022. The Eclipse Zenoh team has been using Rust for quite a few years to build the entire Zenoh stack." + data-category="virtual-iot-2022"> + </div> + <div class="video-item" + data-id="mChxRJ23Rdw" + data-title="Eclipse IoT: The Next Ten Years" + data-date="June 2, 2022" + data-description="Frédéric Desbiens speaks at IoT & Edge Days 2022. Eclipse IoT celebrated its 10th anniversary in October 2021." + data-category="virtual-iot-2022"> + </div> + </div> + `; + }, +};