react-youtube-playlist-grid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +32 -0
- package/src/components/PlaylistsExplorer.tsx +129 -0
- package/src/components/VideoCard.tsx +37 -0
- package/src/index.ts +4 -0
- package/src/types/index.ts +23 -0
- package/src/utils/youtube.ts +82 -0
- package/tsconfig.json +26 -0
- package/tsup.config.ts +13 -0
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface PlaylistConfig {
|
|
4
|
+
id: string;
|
|
5
|
+
showDescription: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
interface Video {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
thumbnailUrl: string;
|
|
12
|
+
videoUrl: string;
|
|
13
|
+
}
|
|
14
|
+
interface Playlist {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
videos: Video[];
|
|
19
|
+
nextPageToken?: string;
|
|
20
|
+
config?: PlaylistConfig;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PlaylistsExplorerProps {
|
|
24
|
+
initialPlaylists: Playlist[];
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
onLoadMore?: (playlistId: string, pageToken: string) => Promise<{
|
|
27
|
+
videos: Video[];
|
|
28
|
+
nextPageToken?: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
declare const PlaylistsExplorer: React.FC<PlaylistsExplorerProps>;
|
|
32
|
+
|
|
33
|
+
interface VideoCardProps {
|
|
34
|
+
title: string;
|
|
35
|
+
thumbnailUrl: string;
|
|
36
|
+
videoUrl: string;
|
|
37
|
+
}
|
|
38
|
+
declare const VideoCard: React.FC<VideoCardProps>;
|
|
39
|
+
|
|
40
|
+
interface FetchVideosResult {
|
|
41
|
+
videos?: Video[];
|
|
42
|
+
nextPageToken?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
declare function fetchPlaylistVideos(playlistId: string, apiKey: string, pageToken?: string): Promise<FetchVideosResult>;
|
|
46
|
+
declare function getPlaylist(playlistId: string, apiKey: string): Promise<Playlist | null>;
|
|
47
|
+
|
|
48
|
+
export { type Playlist, type PlaylistConfig, PlaylistsExplorer, type Video, VideoCard, fetchPlaylistVideos, getPlaylist };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface PlaylistConfig {
|
|
4
|
+
id: string;
|
|
5
|
+
showDescription: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
interface Video {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
thumbnailUrl: string;
|
|
12
|
+
videoUrl: string;
|
|
13
|
+
}
|
|
14
|
+
interface Playlist {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
videos: Video[];
|
|
19
|
+
nextPageToken?: string;
|
|
20
|
+
config?: PlaylistConfig;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface PlaylistsExplorerProps {
|
|
24
|
+
initialPlaylists: Playlist[];
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
onLoadMore?: (playlistId: string, pageToken: string) => Promise<{
|
|
27
|
+
videos: Video[];
|
|
28
|
+
nextPageToken?: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
declare const PlaylistsExplorer: React.FC<PlaylistsExplorerProps>;
|
|
32
|
+
|
|
33
|
+
interface VideoCardProps {
|
|
34
|
+
title: string;
|
|
35
|
+
thumbnailUrl: string;
|
|
36
|
+
videoUrl: string;
|
|
37
|
+
}
|
|
38
|
+
declare const VideoCard: React.FC<VideoCardProps>;
|
|
39
|
+
|
|
40
|
+
interface FetchVideosResult {
|
|
41
|
+
videos?: Video[];
|
|
42
|
+
nextPageToken?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
declare function fetchPlaylistVideos(playlistId: string, apiKey: string, pageToken?: string): Promise<FetchVideosResult>;
|
|
46
|
+
declare function getPlaylist(playlistId: string, apiKey: string): Promise<Playlist | null>;
|
|
47
|
+
|
|
48
|
+
export { type Playlist, type PlaylistConfig, PlaylistsExplorer, type Video, VideoCard, fetchPlaylistVideos, getPlaylist };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
'use strict';var react=require('react'),jsxRuntime=require('react/jsx-runtime');var y=({title:t,thumbnailUrl:n,videoUrl:l})=>jsxRuntime.jsxs("a",{href:l,target:"_blank",rel:"noopener noreferrer",className:"group block relative overflow-hidden rounded-lg bg-slate-800 ring-1 ring-white/10 hover:ring-brand-500/50 transition-all duration-300 hover:shadow-[0_0_20px_rgba(139,92,246,0.15)] transform hover:-translate-y-1",children:[jsxRuntime.jsxs("div",{className:"aspect-video w-full overflow-hidden relative",children:[jsxRuntime.jsx("img",{src:n,alt:t,className:"w-full h-full object-cover transition-transform duration-500 group-hover:scale-105",loading:"lazy"}),jsxRuntime.jsx("div",{className:"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center",children:jsxRuntime.jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",className:"w-12 h-12 text-white drop-shadow-lg",children:jsxRuntime.jsx("path",{fillRule:"evenodd",d:"M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z",clipRule:"evenodd"})})})]}),jsxRuntime.jsx("div",{className:"p-4",children:jsxRuntime.jsx("h3",{className:"text-sm font-semibold text-white group-hover:text-brand-300 transition-colors line-clamp-2 leading-snug",children:t})})]});var b="https://www.googleapis.com/youtube/v3";async function f(t,n,l){if(!n)return {error:"API Key is missing"};try{let r=`${b}/playlistItems?part=snippet,contentDetails&playlistId=${t}&maxResults=10&key=${n}`;l&&(r+=`&pageToken=${l}`);let o=await(await fetch(r)).json();return o.items?{videos:o.items.map(a=>({id:a.snippet.resourceId.videoId,title:a.snippet.title,thumbnailUrl:a.snippet.thumbnails.medium?.url||a.snippet.thumbnails.default?.url,videoUrl:`https://www.youtube.com/watch?v=${a.snippet.resourceId.videoId}`})).filter(a=>a.title!=="Private video"&&a.title!=="Deleted video"),nextPageToken:o.nextPageToken}:(console.error(`Failed to fetch items for playlist: ${t}`,o),{error:`YouTube API Error: ${o.error?.message||JSON.stringify(o)}`})}catch(r){return console.error(`Error fetching playlist items ${t}:`,r),{error:`Fetch Exception: ${r}`}}}async function T(t,n){if(!n)return console.warn("API Key is not set."),null;try{let r=await(await fetch(`${b}/playlists?part=snippet&id=${t}&key=${n}`)).json();if(!r.items||r.items.length===0)return console.error(`Playlist not found: ${t}`),null;let p=r.items[0].snippet,o=await f(t,n);return !o||o.error||!o.videos?null:{id:t,title:p.title,description:p.description,videos:o.videos,nextPageToken:o.nextPageToken}}catch(l){return console.error(`Error fetching playlist ${t}:`,l),null}}var A=({initialPlaylists:t,apiKey:n,onLoadMore:l})=>{let[r,p]=react.useState(t[0]?.id||""),[o,h]=react.useState(t),[a,x]=react.useState(false),i=o.find(e=>e.id===r),P=async()=>{if(!(!i||!i.nextPageToken)){x(true);try{let e=[],u;if(l){let d=await l(i.id,i.nextPageToken);e=d.videos,u=d.nextPageToken;}else if(n){let d=await f(i.id,n,i.nextPageToken);d.videos&&(e=d.videos,u=d.nextPageToken);}else {console.error("No apiKey or onLoadMore handler provided");return}e.length>0&&h(d=>d.map(g=>g.id===i.id?{...g,videos:[...g.videos,...e],nextPageToken:u}:g));}catch(e){console.error("Failed to load more videos",e);}finally{x(false);}}};return i?jsxRuntime.jsxs("div",{children:[jsxRuntime.jsx("div",{className:"flex flex-col sm:flex-row justify-between items-center mb-8 gap-4",children:jsxRuntime.jsx("div",{className:"flex space-x-2 overflow-x-auto pb-2 sm:pb-0 w-full sm:w-auto",children:o.map(e=>jsxRuntime.jsx("button",{onClick:()=>p(e.id),className:`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${r===e.id?"bg-brand-600 text-white shadow-lg shadow-brand-500/30":"bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700"}`,children:e.config?.title||e.title},e.id))})}),jsxRuntime.jsxs("div",{className:"animate-fade-in",children:[jsxRuntime.jsxs("div",{className:"mb-6",children:[jsxRuntime.jsx("h2",{className:"text-xl font-bold text-white mb-2",children:i.title}),i.config?.showDescription&&jsxRuntime.jsx("p",{className:"text-slate-400 max-w-3xl",children:i.description})]}),jsxRuntime.jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6",children:i.videos.map(e=>jsxRuntime.jsx(y,{title:e.title,thumbnailUrl:e.thumbnailUrl,videoUrl:e.videoUrl},e.id))}),i.nextPageToken&&jsxRuntime.jsx("div",{className:"mt-12 text-center",children:jsxRuntime.jsx("button",{onClick:P,disabled:a,className:"inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-slate-700 hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",children:a?jsxRuntime.jsxs(jsxRuntime.Fragment,{children:[jsxRuntime.jsxs("svg",{className:"animate-spin -ml-1 mr-3 h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",children:[jsxRuntime.jsx("circle",{className:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor",strokeWidth:"4"}),jsxRuntime.jsx("path",{className:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})]}),"Loading..."]}):"Load More Videos"})})]})]}):jsxRuntime.jsx("div",{children:"No playlists available."})};
|
|
2
|
+
exports.PlaylistsExplorer=A;exports.VideoCard=y;exports.fetchPlaylistVideos=f;exports.getPlaylist=T;//# sourceMappingURL=index.js.map
|
|
3
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/components/VideoCard.tsx","../src/utils/youtube.ts","../src/components/PlaylistsExplorer.tsx"],"names":["VideoCard","title","thumbnailUrl","videoUrl","jsxs","jsx","YOUTUBE_API_BASE","fetchPlaylistVideos","playlistId","apiKey","pageToken","url","itemsData","item","v","error","getPlaylist","playlistData","playlistSnippet","videosData","PlaylistsExplorer","initialPlaylists","onLoadMore","activePlaylistId","setActivePlaylistId","useState","playlists","setPlaylists","loadingMore","setLoadingMore","activePlaylist","p","handleLoadMore","newVideos","newNextPageToken","result","prevPlaylists","playlist","video","Fragment"],"mappings":"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","file":"index.js","sourcesContent":["import React from 'react';\n\ninterface VideoCardProps {\n title: string;\n thumbnailUrl: string;\n videoUrl: string;\n}\n\nexport const VideoCard: React.FC<VideoCardProps> = ({ title, thumbnailUrl, videoUrl }) => {\n return (\n <a\n href={videoUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"group block relative overflow-hidden rounded-lg bg-slate-800 ring-1 ring-white/10 hover:ring-brand-500/50 transition-all duration-300 hover:shadow-[0_0_20px_rgba(139,92,246,0.15)] transform hover:-translate-y-1\"\n >\n <div className=\"aspect-video w-full overflow-hidden relative\">\n <img\n src={thumbnailUrl}\n alt={title}\n className=\"w-full h-full object-cover transition-transform duration-500 group-hover:scale-105\"\n loading=\"lazy\"\n />\n <div className=\"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" className=\"w-12 h-12 text-white drop-shadow-lg\">\n <path fillRule=\"evenodd\" d=\"M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z\" clipRule=\"evenodd\" />\n </svg>\n </div>\n </div>\n <div className=\"p-4\">\n <h3 className=\"text-sm font-semibold text-white group-hover:text-brand-300 transition-colors line-clamp-2 leading-snug\">\n {title}\n </h3>\n </div>\n </a>\n );\n};\n","import type { Playlist, Video } from \"../types\";\n\nconst YOUTUBE_API_BASE = \"https://www.googleapis.com/youtube/v3\";\n\ninterface FetchVideosResult {\n videos?: Video[];\n nextPageToken?: string;\n error?: string;\n}\n\nexport async function fetchPlaylistVideos(playlistId: string, apiKey: string, pageToken?: string): Promise<FetchVideosResult> {\n if (!apiKey) return { error: \"API Key is missing\" };\n\n try {\n let url = `${YOUTUBE_API_BASE}/playlistItems?part=snippet,contentDetails&playlistId=${playlistId}&maxResults=10&key=${apiKey}`;\n if (pageToken) {\n url += `&pageToken=${pageToken}`;\n }\n\n const itemsResponse = await fetch(url);\n const itemsData = await itemsResponse.json();\n\n if (!itemsData.items) {\n console.error(`Failed to fetch items for playlist: ${playlistId}`, itemsData);\n return { error: `YouTube API Error: ${itemsData.error?.message || JSON.stringify(itemsData)}` };\n }\n\n const videos: Video[] = itemsData.items.map((item: any) => ({\n id: item.snippet.resourceId.videoId,\n title: item.snippet.title,\n thumbnailUrl: item.snippet.thumbnails.medium?.url || item.snippet.thumbnails.default?.url,\n videoUrl: `https://www.youtube.com/watch?v=${item.snippet.resourceId.videoId}`,\n })).filter((v: Video) => v.title !== \"Private video\" && v.title !== \"Deleted video\");\n\n return {\n videos,\n nextPageToken: itemsData.nextPageToken\n };\n } catch (error) {\n console.error(`Error fetching playlist items ${playlistId}:`, error);\n return { error: `Fetch Exception: ${error}` };\n }\n}\n\nexport async function getPlaylist(playlistId: string, apiKey: string): Promise<Playlist | null> {\n if (!apiKey) {\n console.warn(\"API Key is not set.\");\n return null;\n }\n\n try {\n // 1. Get Playlist Details\n const playlistResponse = await fetch(\n `${YOUTUBE_API_BASE}/playlists?part=snippet&id=${playlistId}&key=${apiKey}`\n );\n const playlistData = await playlistResponse.json();\n\n if (!playlistData.items || playlistData.items.length === 0) {\n console.error(`Playlist not found: ${playlistId}`);\n return null;\n }\n\n const playlistSnippet = playlistData.items[0].snippet;\n\n // 2. Get First Batch of Videos\n const videosData = await fetchPlaylistVideos(playlistId, apiKey);\n\n if (!videosData || videosData.error || !videosData.videos) return null;\n\n return {\n id: playlistId,\n title: playlistSnippet.title,\n description: playlistSnippet.description,\n videos: videosData.videos,\n nextPageToken: videosData.nextPageToken\n };\n\n } catch (error) {\n console.error(`Error fetching playlist ${playlistId}:`, error);\n return null;\n }\n}\n","import React, { useState } from 'react';\nimport type { Playlist, Video } from '../types';\nimport { VideoCard } from './VideoCard';\nimport { fetchPlaylistVideos } from '../utils/youtube';\n\ninterface PlaylistsExplorerProps {\n initialPlaylists: Playlist[];\n apiKey?: string;\n onLoadMore?: (playlistId: string, pageToken: string) => Promise<{ videos: Video[], nextPageToken?: string }>;\n}\n\nexport const PlaylistsExplorer: React.FC<PlaylistsExplorerProps> = ({ initialPlaylists, apiKey, onLoadMore }) => {\n const [activePlaylistId, setActivePlaylistId] = useState<string>(initialPlaylists[0]?.id || \"\");\n const [playlists, setPlaylists] = useState<Playlist[]>(initialPlaylists);\n const [loadingMore, setLoadingMore] = useState<boolean>(false);\n\n const activePlaylist = playlists.find(p => p.id === activePlaylistId);\n\n const handleLoadMore = async () => {\n if (!activePlaylist || !activePlaylist.nextPageToken) return;\n\n setLoadingMore(true);\n try {\n let newVideos: Video[] = [];\n let newNextPageToken: string | undefined;\n\n if (onLoadMore) {\n const result = await onLoadMore(activePlaylist.id, activePlaylist.nextPageToken);\n newVideos = result.videos;\n newNextPageToken = result.nextPageToken;\n } else if (apiKey) {\n const result = await fetchPlaylistVideos(activePlaylist.id, apiKey, activePlaylist.nextPageToken);\n if (result.videos) {\n newVideos = result.videos;\n newNextPageToken = result.nextPageToken;\n }\n } else {\n console.error(\"No apiKey or onLoadMore handler provided\");\n return;\n }\n\n if (newVideos.length > 0) {\n setPlaylists(prevPlaylists => prevPlaylists.map(p => {\n if (p.id === activePlaylist.id) {\n return {\n ...p,\n videos: [...p.videos, ...newVideos],\n nextPageToken: newNextPageToken\n };\n }\n return p;\n }));\n }\n } catch (error) {\n console.error(\"Failed to load more videos\", error);\n } finally {\n setLoadingMore(false);\n }\n };\n\n if (!activePlaylist) return <div>No playlists available.</div>;\n\n return (\n <div>\n {/* Navigation & Tabs */}\n <div className=\"flex flex-col sm:flex-row justify-between items-center mb-8 gap-4\">\n <div className=\"flex space-x-2 overflow-x-auto pb-2 sm:pb-0 w-full sm:w-auto\">\n {playlists.map(playlist => (\n <button\n key={playlist.id}\n onClick={() => setActivePlaylistId(playlist.id)}\n className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${activePlaylistId === playlist.id\n ? 'bg-brand-600 text-white shadow-lg shadow-brand-500/30'\n : 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'\n }`}\n >\n {playlist.config?.title || playlist.title}\n </button>\n ))}\n </div>\n </div>\n\n {/* Playlist Content */}\n <div className=\"animate-fade-in\">\n {/* Always show title and description if enabled */}\n <div className=\"mb-6\">\n <h2 className=\"text-xl font-bold text-white mb-2\">{activePlaylist.title}</h2>\n {activePlaylist.config?.showDescription && (\n <p className=\"text-slate-400 max-w-3xl\">{activePlaylist.description}</p>\n )}\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6\">\n {activePlaylist.videos.map((video) => (\n <VideoCard\n key={video.id}\n title={video.title}\n thumbnailUrl={video.thumbnailUrl}\n videoUrl={video.videoUrl}\n />\n ))}\n </div>\n\n {/* Load More */}\n {activePlaylist.nextPageToken && (\n <div className=\"mt-12 text-center\">\n <button\n onClick={handleLoadMore}\n disabled={loadingMore}\n className=\"inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-slate-700 hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {loadingMore ? (\n <>\n <svg className=\"animate-spin -ml-1 mr-3 h-5 w-5 text-white\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\n <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n Loading...\n </>\n ) : (\n 'Load More Videos'\n )}\n </button>\n </div>\n )}\n </div>\n </div>\n );\n};\n"]}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import {useState}from'react';import {jsxs,jsx,Fragment}from'react/jsx-runtime';var y=({title:t,thumbnailUrl:n,videoUrl:l})=>jsxs("a",{href:l,target:"_blank",rel:"noopener noreferrer",className:"group block relative overflow-hidden rounded-lg bg-slate-800 ring-1 ring-white/10 hover:ring-brand-500/50 transition-all duration-300 hover:shadow-[0_0_20px_rgba(139,92,246,0.15)] transform hover:-translate-y-1",children:[jsxs("div",{className:"aspect-video w-full overflow-hidden relative",children:[jsx("img",{src:n,alt:t,className:"w-full h-full object-cover transition-transform duration-500 group-hover:scale-105",loading:"lazy"}),jsx("div",{className:"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center",children:jsx("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",fill:"currentColor",className:"w-12 h-12 text-white drop-shadow-lg",children:jsx("path",{fillRule:"evenodd",d:"M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z",clipRule:"evenodd"})})})]}),jsx("div",{className:"p-4",children:jsx("h3",{className:"text-sm font-semibold text-white group-hover:text-brand-300 transition-colors line-clamp-2 leading-snug",children:t})})]});var b="https://www.googleapis.com/youtube/v3";async function f(t,n,l){if(!n)return {error:"API Key is missing"};try{let r=`${b}/playlistItems?part=snippet,contentDetails&playlistId=${t}&maxResults=10&key=${n}`;l&&(r+=`&pageToken=${l}`);let o=await(await fetch(r)).json();return o.items?{videos:o.items.map(a=>({id:a.snippet.resourceId.videoId,title:a.snippet.title,thumbnailUrl:a.snippet.thumbnails.medium?.url||a.snippet.thumbnails.default?.url,videoUrl:`https://www.youtube.com/watch?v=${a.snippet.resourceId.videoId}`})).filter(a=>a.title!=="Private video"&&a.title!=="Deleted video"),nextPageToken:o.nextPageToken}:(console.error(`Failed to fetch items for playlist: ${t}`,o),{error:`YouTube API Error: ${o.error?.message||JSON.stringify(o)}`})}catch(r){return console.error(`Error fetching playlist items ${t}:`,r),{error:`Fetch Exception: ${r}`}}}async function T(t,n){if(!n)return console.warn("API Key is not set."),null;try{let r=await(await fetch(`${b}/playlists?part=snippet&id=${t}&key=${n}`)).json();if(!r.items||r.items.length===0)return console.error(`Playlist not found: ${t}`),null;let p=r.items[0].snippet,o=await f(t,n);return !o||o.error||!o.videos?null:{id:t,title:p.title,description:p.description,videos:o.videos,nextPageToken:o.nextPageToken}}catch(l){return console.error(`Error fetching playlist ${t}:`,l),null}}var A=({initialPlaylists:t,apiKey:n,onLoadMore:l})=>{let[r,p]=useState(t[0]?.id||""),[o,h]=useState(t),[a,x]=useState(false),i=o.find(e=>e.id===r),P=async()=>{if(!(!i||!i.nextPageToken)){x(true);try{let e=[],u;if(l){let d=await l(i.id,i.nextPageToken);e=d.videos,u=d.nextPageToken;}else if(n){let d=await f(i.id,n,i.nextPageToken);d.videos&&(e=d.videos,u=d.nextPageToken);}else {console.error("No apiKey or onLoadMore handler provided");return}e.length>0&&h(d=>d.map(g=>g.id===i.id?{...g,videos:[...g.videos,...e],nextPageToken:u}:g));}catch(e){console.error("Failed to load more videos",e);}finally{x(false);}}};return i?jsxs("div",{children:[jsx("div",{className:"flex flex-col sm:flex-row justify-between items-center mb-8 gap-4",children:jsx("div",{className:"flex space-x-2 overflow-x-auto pb-2 sm:pb-0 w-full sm:w-auto",children:o.map(e=>jsx("button",{onClick:()=>p(e.id),className:`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${r===e.id?"bg-brand-600 text-white shadow-lg shadow-brand-500/30":"bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700"}`,children:e.config?.title||e.title},e.id))})}),jsxs("div",{className:"animate-fade-in",children:[jsxs("div",{className:"mb-6",children:[jsx("h2",{className:"text-xl font-bold text-white mb-2",children:i.title}),i.config?.showDescription&&jsx("p",{className:"text-slate-400 max-w-3xl",children:i.description})]}),jsx("div",{className:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6",children:i.videos.map(e=>jsx(y,{title:e.title,thumbnailUrl:e.thumbnailUrl,videoUrl:e.videoUrl},e.id))}),i.nextPageToken&&jsx("div",{className:"mt-12 text-center",children:jsx("button",{onClick:P,disabled:a,className:"inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-slate-700 hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors",children:a?jsxs(Fragment,{children:[jsxs("svg",{className:"animate-spin -ml-1 mr-3 h-5 w-5 text-white",xmlns:"http://www.w3.org/2000/svg",fill:"none",viewBox:"0 0 24 24",children:[jsx("circle",{className:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor",strokeWidth:"4"}),jsx("path",{className:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"})]}),"Loading..."]}):"Load More Videos"})})]})]}):jsx("div",{children:"No playlists available."})};
|
|
2
|
+
export{A as PlaylistsExplorer,y as VideoCard,f as fetchPlaylistVideos,T as getPlaylist};//# sourceMappingURL=index.mjs.map
|
|
3
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/components/VideoCard.tsx","../src/utils/youtube.ts","../src/components/PlaylistsExplorer.tsx"],"names":["VideoCard","title","thumbnailUrl","videoUrl","jsxs","jsx","YOUTUBE_API_BASE","fetchPlaylistVideos","playlistId","apiKey","pageToken","url","itemsData","item","v","error","getPlaylist","playlistData","playlistSnippet","videosData","PlaylistsExplorer","initialPlaylists","onLoadMore","activePlaylistId","setActivePlaylistId","useState","playlists","setPlaylists","loadingMore","setLoadingMore","activePlaylist","p","handleLoadMore","newVideos","newNextPageToken","result","prevPlaylists","playlist","video","Fragment"],"mappings":"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","file":"index.mjs","sourcesContent":["import React from 'react';\n\ninterface VideoCardProps {\n title: string;\n thumbnailUrl: string;\n videoUrl: string;\n}\n\nexport const VideoCard: React.FC<VideoCardProps> = ({ title, thumbnailUrl, videoUrl }) => {\n return (\n <a\n href={videoUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"group block relative overflow-hidden rounded-lg bg-slate-800 ring-1 ring-white/10 hover:ring-brand-500/50 transition-all duration-300 hover:shadow-[0_0_20px_rgba(139,92,246,0.15)] transform hover:-translate-y-1\"\n >\n <div className=\"aspect-video w-full overflow-hidden relative\">\n <img\n src={thumbnailUrl}\n alt={title}\n className=\"w-full h-full object-cover transition-transform duration-500 group-hover:scale-105\"\n loading=\"lazy\"\n />\n <div className=\"absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" className=\"w-12 h-12 text-white drop-shadow-lg\">\n <path fillRule=\"evenodd\" d=\"M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z\" clipRule=\"evenodd\" />\n </svg>\n </div>\n </div>\n <div className=\"p-4\">\n <h3 className=\"text-sm font-semibold text-white group-hover:text-brand-300 transition-colors line-clamp-2 leading-snug\">\n {title}\n </h3>\n </div>\n </a>\n );\n};\n","import type { Playlist, Video } from \"../types\";\n\nconst YOUTUBE_API_BASE = \"https://www.googleapis.com/youtube/v3\";\n\ninterface FetchVideosResult {\n videos?: Video[];\n nextPageToken?: string;\n error?: string;\n}\n\nexport async function fetchPlaylistVideos(playlistId: string, apiKey: string, pageToken?: string): Promise<FetchVideosResult> {\n if (!apiKey) return { error: \"API Key is missing\" };\n\n try {\n let url = `${YOUTUBE_API_BASE}/playlistItems?part=snippet,contentDetails&playlistId=${playlistId}&maxResults=10&key=${apiKey}`;\n if (pageToken) {\n url += `&pageToken=${pageToken}`;\n }\n\n const itemsResponse = await fetch(url);\n const itemsData = await itemsResponse.json();\n\n if (!itemsData.items) {\n console.error(`Failed to fetch items for playlist: ${playlistId}`, itemsData);\n return { error: `YouTube API Error: ${itemsData.error?.message || JSON.stringify(itemsData)}` };\n }\n\n const videos: Video[] = itemsData.items.map((item: any) => ({\n id: item.snippet.resourceId.videoId,\n title: item.snippet.title,\n thumbnailUrl: item.snippet.thumbnails.medium?.url || item.snippet.thumbnails.default?.url,\n videoUrl: `https://www.youtube.com/watch?v=${item.snippet.resourceId.videoId}`,\n })).filter((v: Video) => v.title !== \"Private video\" && v.title !== \"Deleted video\");\n\n return {\n videos,\n nextPageToken: itemsData.nextPageToken\n };\n } catch (error) {\n console.error(`Error fetching playlist items ${playlistId}:`, error);\n return { error: `Fetch Exception: ${error}` };\n }\n}\n\nexport async function getPlaylist(playlistId: string, apiKey: string): Promise<Playlist | null> {\n if (!apiKey) {\n console.warn(\"API Key is not set.\");\n return null;\n }\n\n try {\n // 1. Get Playlist Details\n const playlistResponse = await fetch(\n `${YOUTUBE_API_BASE}/playlists?part=snippet&id=${playlistId}&key=${apiKey}`\n );\n const playlistData = await playlistResponse.json();\n\n if (!playlistData.items || playlistData.items.length === 0) {\n console.error(`Playlist not found: ${playlistId}`);\n return null;\n }\n\n const playlistSnippet = playlistData.items[0].snippet;\n\n // 2. Get First Batch of Videos\n const videosData = await fetchPlaylistVideos(playlistId, apiKey);\n\n if (!videosData || videosData.error || !videosData.videos) return null;\n\n return {\n id: playlistId,\n title: playlistSnippet.title,\n description: playlistSnippet.description,\n videos: videosData.videos,\n nextPageToken: videosData.nextPageToken\n };\n\n } catch (error) {\n console.error(`Error fetching playlist ${playlistId}:`, error);\n return null;\n }\n}\n","import React, { useState } from 'react';\nimport type { Playlist, Video } from '../types';\nimport { VideoCard } from './VideoCard';\nimport { fetchPlaylistVideos } from '../utils/youtube';\n\ninterface PlaylistsExplorerProps {\n initialPlaylists: Playlist[];\n apiKey?: string;\n onLoadMore?: (playlistId: string, pageToken: string) => Promise<{ videos: Video[], nextPageToken?: string }>;\n}\n\nexport const PlaylistsExplorer: React.FC<PlaylistsExplorerProps> = ({ initialPlaylists, apiKey, onLoadMore }) => {\n const [activePlaylistId, setActivePlaylistId] = useState<string>(initialPlaylists[0]?.id || \"\");\n const [playlists, setPlaylists] = useState<Playlist[]>(initialPlaylists);\n const [loadingMore, setLoadingMore] = useState<boolean>(false);\n\n const activePlaylist = playlists.find(p => p.id === activePlaylistId);\n\n const handleLoadMore = async () => {\n if (!activePlaylist || !activePlaylist.nextPageToken) return;\n\n setLoadingMore(true);\n try {\n let newVideos: Video[] = [];\n let newNextPageToken: string | undefined;\n\n if (onLoadMore) {\n const result = await onLoadMore(activePlaylist.id, activePlaylist.nextPageToken);\n newVideos = result.videos;\n newNextPageToken = result.nextPageToken;\n } else if (apiKey) {\n const result = await fetchPlaylistVideos(activePlaylist.id, apiKey, activePlaylist.nextPageToken);\n if (result.videos) {\n newVideos = result.videos;\n newNextPageToken = result.nextPageToken;\n }\n } else {\n console.error(\"No apiKey or onLoadMore handler provided\");\n return;\n }\n\n if (newVideos.length > 0) {\n setPlaylists(prevPlaylists => prevPlaylists.map(p => {\n if (p.id === activePlaylist.id) {\n return {\n ...p,\n videos: [...p.videos, ...newVideos],\n nextPageToken: newNextPageToken\n };\n }\n return p;\n }));\n }\n } catch (error) {\n console.error(\"Failed to load more videos\", error);\n } finally {\n setLoadingMore(false);\n }\n };\n\n if (!activePlaylist) return <div>No playlists available.</div>;\n\n return (\n <div>\n {/* Navigation & Tabs */}\n <div className=\"flex flex-col sm:flex-row justify-between items-center mb-8 gap-4\">\n <div className=\"flex space-x-2 overflow-x-auto pb-2 sm:pb-0 w-full sm:w-auto\">\n {playlists.map(playlist => (\n <button\n key={playlist.id}\n onClick={() => setActivePlaylistId(playlist.id)}\n className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${activePlaylistId === playlist.id\n ? 'bg-brand-600 text-white shadow-lg shadow-brand-500/30'\n : 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'\n }`}\n >\n {playlist.config?.title || playlist.title}\n </button>\n ))}\n </div>\n </div>\n\n {/* Playlist Content */}\n <div className=\"animate-fade-in\">\n {/* Always show title and description if enabled */}\n <div className=\"mb-6\">\n <h2 className=\"text-xl font-bold text-white mb-2\">{activePlaylist.title}</h2>\n {activePlaylist.config?.showDescription && (\n <p className=\"text-slate-400 max-w-3xl\">{activePlaylist.description}</p>\n )}\n </div>\n\n <div className=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6\">\n {activePlaylist.videos.map((video) => (\n <VideoCard\n key={video.id}\n title={video.title}\n thumbnailUrl={video.thumbnailUrl}\n videoUrl={video.videoUrl}\n />\n ))}\n </div>\n\n {/* Load More */}\n {activePlaylist.nextPageToken && (\n <div className=\"mt-12 text-center\">\n <button\n onClick={handleLoadMore}\n disabled={loadingMore}\n className=\"inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-slate-700 hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors\"\n >\n {loadingMore ? (\n <>\n <svg className=\"animate-spin -ml-1 mr-3 h-5 w-5 text-white\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\">\n <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\n <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\n </svg>\n Loading...\n </>\n ) : (\n 'Load More Videos'\n )}\n </button>\n </div>\n )}\n </div>\n </div>\n );\n};\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-youtube-playlist-grid",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A React component for displaying YouTube playlists in a grid with load more functionality.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsup",
|
|
10
|
+
"dev": "tsup --watch"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"youtube",
|
|
15
|
+
"playlist",
|
|
16
|
+
"grid"
|
|
17
|
+
],
|
|
18
|
+
"author": "",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"react": ">=18.0.0",
|
|
22
|
+
"react-dom": ">=18.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/react": "^19.0.0",
|
|
26
|
+
"@types/react-dom": "^19.0.0",
|
|
27
|
+
"react": "^19.0.0",
|
|
28
|
+
"react-dom": "^19.0.0",
|
|
29
|
+
"typescript": "^5.0.0",
|
|
30
|
+
"tsup": "^8.0.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import type { Playlist, Video } from '../types';
|
|
3
|
+
import { VideoCard } from './VideoCard';
|
|
4
|
+
import { fetchPlaylistVideos } from '../utils/youtube';
|
|
5
|
+
|
|
6
|
+
interface PlaylistsExplorerProps {
|
|
7
|
+
initialPlaylists: Playlist[];
|
|
8
|
+
apiKey?: string;
|
|
9
|
+
onLoadMore?: (playlistId: string, pageToken: string) => Promise<{ videos: Video[], nextPageToken?: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PlaylistsExplorer: React.FC<PlaylistsExplorerProps> = ({ initialPlaylists, apiKey, onLoadMore }) => {
|
|
13
|
+
const [activePlaylistId, setActivePlaylistId] = useState<string>(initialPlaylists[0]?.id || "");
|
|
14
|
+
const [playlists, setPlaylists] = useState<Playlist[]>(initialPlaylists);
|
|
15
|
+
const [loadingMore, setLoadingMore] = useState<boolean>(false);
|
|
16
|
+
|
|
17
|
+
const activePlaylist = playlists.find(p => p.id === activePlaylistId);
|
|
18
|
+
|
|
19
|
+
const handleLoadMore = async () => {
|
|
20
|
+
if (!activePlaylist || !activePlaylist.nextPageToken) return;
|
|
21
|
+
|
|
22
|
+
setLoadingMore(true);
|
|
23
|
+
try {
|
|
24
|
+
let newVideos: Video[] = [];
|
|
25
|
+
let newNextPageToken: string | undefined;
|
|
26
|
+
|
|
27
|
+
if (onLoadMore) {
|
|
28
|
+
const result = await onLoadMore(activePlaylist.id, activePlaylist.nextPageToken);
|
|
29
|
+
newVideos = result.videos;
|
|
30
|
+
newNextPageToken = result.nextPageToken;
|
|
31
|
+
} else if (apiKey) {
|
|
32
|
+
const result = await fetchPlaylistVideos(activePlaylist.id, apiKey, activePlaylist.nextPageToken);
|
|
33
|
+
if (result.videos) {
|
|
34
|
+
newVideos = result.videos;
|
|
35
|
+
newNextPageToken = result.nextPageToken;
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
console.error("No apiKey or onLoadMore handler provided");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (newVideos.length > 0) {
|
|
43
|
+
setPlaylists(prevPlaylists => prevPlaylists.map(p => {
|
|
44
|
+
if (p.id === activePlaylist.id) {
|
|
45
|
+
return {
|
|
46
|
+
...p,
|
|
47
|
+
videos: [...p.videos, ...newVideos],
|
|
48
|
+
nextPageToken: newNextPageToken
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return p;
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error("Failed to load more videos", error);
|
|
56
|
+
} finally {
|
|
57
|
+
setLoadingMore(false);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!activePlaylist) return <div>No playlists available.</div>;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div>
|
|
65
|
+
{/* Navigation & Tabs */}
|
|
66
|
+
<div className="flex flex-col sm:flex-row justify-between items-center mb-8 gap-4">
|
|
67
|
+
<div className="flex space-x-2 overflow-x-auto pb-2 sm:pb-0 w-full sm:w-auto">
|
|
68
|
+
{playlists.map(playlist => (
|
|
69
|
+
<button
|
|
70
|
+
key={playlist.id}
|
|
71
|
+
onClick={() => setActivePlaylistId(playlist.id)}
|
|
72
|
+
className={`px-4 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${activePlaylistId === playlist.id
|
|
73
|
+
? 'bg-brand-600 text-white shadow-lg shadow-brand-500/30'
|
|
74
|
+
: 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'
|
|
75
|
+
}`}
|
|
76
|
+
>
|
|
77
|
+
{playlist.config?.title || playlist.title}
|
|
78
|
+
</button>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Playlist Content */}
|
|
84
|
+
<div className="animate-fade-in">
|
|
85
|
+
{/* Always show title and description if enabled */}
|
|
86
|
+
<div className="mb-6">
|
|
87
|
+
<h2 className="text-xl font-bold text-white mb-2">{activePlaylist.title}</h2>
|
|
88
|
+
{activePlaylist.config?.showDescription && (
|
|
89
|
+
<p className="text-slate-400 max-w-3xl">{activePlaylist.description}</p>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
94
|
+
{activePlaylist.videos.map((video) => (
|
|
95
|
+
<VideoCard
|
|
96
|
+
key={video.id}
|
|
97
|
+
title={video.title}
|
|
98
|
+
thumbnailUrl={video.thumbnailUrl}
|
|
99
|
+
videoUrl={video.videoUrl}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Load More */}
|
|
105
|
+
{activePlaylist.nextPageToken && (
|
|
106
|
+
<div className="mt-12 text-center">
|
|
107
|
+
<button
|
|
108
|
+
onClick={handleLoadMore}
|
|
109
|
+
disabled={loadingMore}
|
|
110
|
+
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-slate-700 hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
111
|
+
>
|
|
112
|
+
{loadingMore ? (
|
|
113
|
+
<>
|
|
114
|
+
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
115
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
116
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
117
|
+
</svg>
|
|
118
|
+
Loading...
|
|
119
|
+
</>
|
|
120
|
+
) : (
|
|
121
|
+
'Load More Videos'
|
|
122
|
+
)}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface VideoCardProps {
|
|
4
|
+
title: string;
|
|
5
|
+
thumbnailUrl: string;
|
|
6
|
+
videoUrl: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const VideoCard: React.FC<VideoCardProps> = ({ title, thumbnailUrl, videoUrl }) => {
|
|
10
|
+
return (
|
|
11
|
+
<a
|
|
12
|
+
href={videoUrl}
|
|
13
|
+
target="_blank"
|
|
14
|
+
rel="noopener noreferrer"
|
|
15
|
+
className="group block relative overflow-hidden rounded-lg bg-slate-800 ring-1 ring-white/10 hover:ring-brand-500/50 transition-all duration-300 hover:shadow-[0_0_20px_rgba(139,92,246,0.15)] transform hover:-translate-y-1"
|
|
16
|
+
>
|
|
17
|
+
<div className="aspect-video w-full overflow-hidden relative">
|
|
18
|
+
<img
|
|
19
|
+
src={thumbnailUrl}
|
|
20
|
+
alt={title}
|
|
21
|
+
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
22
|
+
loading="lazy"
|
|
23
|
+
/>
|
|
24
|
+
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
|
25
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-12 h-12 text-white drop-shadow-lg">
|
|
26
|
+
<path fillRule="evenodd" d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z" clipRule="evenodd" />
|
|
27
|
+
</svg>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="p-4">
|
|
31
|
+
<h3 className="text-sm font-semibold text-white group-hover:text-brand-300 transition-colors line-clamp-2 leading-snug">
|
|
32
|
+
{title}
|
|
33
|
+
</h3>
|
|
34
|
+
</div>
|
|
35
|
+
</a>
|
|
36
|
+
);
|
|
37
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface PlaylistConfig {
|
|
2
|
+
id: string;
|
|
3
|
+
showDescription: boolean;
|
|
4
|
+
title: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export interface Video {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
thumbnailUrl: string;
|
|
13
|
+
videoUrl: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Playlist {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
videos: Video[];
|
|
21
|
+
nextPageToken?: string;
|
|
22
|
+
config?: PlaylistConfig;
|
|
23
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Playlist, Video } from "../types";
|
|
2
|
+
|
|
3
|
+
const YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3";
|
|
4
|
+
|
|
5
|
+
interface FetchVideosResult {
|
|
6
|
+
videos?: Video[];
|
|
7
|
+
nextPageToken?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function fetchPlaylistVideos(playlistId: string, apiKey: string, pageToken?: string): Promise<FetchVideosResult> {
|
|
12
|
+
if (!apiKey) return { error: "API Key is missing" };
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
let url = `${YOUTUBE_API_BASE}/playlistItems?part=snippet,contentDetails&playlistId=${playlistId}&maxResults=10&key=${apiKey}`;
|
|
16
|
+
if (pageToken) {
|
|
17
|
+
url += `&pageToken=${pageToken}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const itemsResponse = await fetch(url);
|
|
21
|
+
const itemsData = await itemsResponse.json();
|
|
22
|
+
|
|
23
|
+
if (!itemsData.items) {
|
|
24
|
+
console.error(`Failed to fetch items for playlist: ${playlistId}`, itemsData);
|
|
25
|
+
return { error: `YouTube API Error: ${itemsData.error?.message || JSON.stringify(itemsData)}` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const videos: Video[] = itemsData.items.map((item: any) => ({
|
|
29
|
+
id: item.snippet.resourceId.videoId,
|
|
30
|
+
title: item.snippet.title,
|
|
31
|
+
thumbnailUrl: item.snippet.thumbnails.medium?.url || item.snippet.thumbnails.default?.url,
|
|
32
|
+
videoUrl: `https://www.youtube.com/watch?v=${item.snippet.resourceId.videoId}`,
|
|
33
|
+
})).filter((v: Video) => v.title !== "Private video" && v.title !== "Deleted video");
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
videos,
|
|
37
|
+
nextPageToken: itemsData.nextPageToken
|
|
38
|
+
};
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error(`Error fetching playlist items ${playlistId}:`, error);
|
|
41
|
+
return { error: `Fetch Exception: ${error}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function getPlaylist(playlistId: string, apiKey: string): Promise<Playlist | null> {
|
|
46
|
+
if (!apiKey) {
|
|
47
|
+
console.warn("API Key is not set.");
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
// 1. Get Playlist Details
|
|
53
|
+
const playlistResponse = await fetch(
|
|
54
|
+
`${YOUTUBE_API_BASE}/playlists?part=snippet&id=${playlistId}&key=${apiKey}`
|
|
55
|
+
);
|
|
56
|
+
const playlistData = await playlistResponse.json();
|
|
57
|
+
|
|
58
|
+
if (!playlistData.items || playlistData.items.length === 0) {
|
|
59
|
+
console.error(`Playlist not found: ${playlistId}`);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const playlistSnippet = playlistData.items[0].snippet;
|
|
64
|
+
|
|
65
|
+
// 2. Get First Batch of Videos
|
|
66
|
+
const videosData = await fetchPlaylistVideos(playlistId, apiKey);
|
|
67
|
+
|
|
68
|
+
if (!videosData || videosData.error || !videosData.videos) return null;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: playlistId,
|
|
72
|
+
title: playlistSnippet.title,
|
|
73
|
+
description: playlistSnippet.description,
|
|
74
|
+
videos: videosData.videos,
|
|
75
|
+
nextPageToken: videosData.nextPageToken
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error(`Error fetching playlist ${playlistId}:`, error);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": [
|
|
6
|
+
"ES2020",
|
|
7
|
+
"DOM",
|
|
8
|
+
"DOM.Iterable"
|
|
9
|
+
],
|
|
10
|
+
"module": "ESNext",
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true
|
|
22
|
+
},
|
|
23
|
+
"include": [
|
|
24
|
+
"src"
|
|
25
|
+
]
|
|
26
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.ts'],
|
|
5
|
+
format: ['cjs', 'esm'],
|
|
6
|
+
dts: true,
|
|
7
|
+
splitting: false,
|
|
8
|
+
sourcemap: true,
|
|
9
|
+
clean: true,
|
|
10
|
+
treeshake: true,
|
|
11
|
+
minify: true,
|
|
12
|
+
external: ['react', 'react-dom'],
|
|
13
|
+
})
|