Merge pull request #8984 from MiroslavDionisiev/UEPR-116

feat: [UEPR-116] add youtube video playlists and open videos in modal
This commit is contained in:
Miroslav Dionisiev 2024-12-04 15:14:53 +02:00 committed by GitHub
commit 9210e3700d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 454 additions and 13 deletions

View file

@ -0,0 +1,61 @@
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import api from '../../lib/api';
import {YoutubeVideoButton} from '../youtube-video-button/youtube-video-button.jsx';
import Spinner from '../spinner/spinner.jsx';
import './youtube-playlist-item.scss';
export const YoutubePlaylistItem = ({playlistRequestUri, playlistTitleId, onSelectedVideo}) => {
const [loading, setLoading] = useState(true);
const [playlistVideos, setPlaylistVideos] = useState([]);
useEffect(() => {
api({
host: process.env.ROOT_URL,
method: 'GET',
uri: playlistRequestUri,
headers: {
'Content-Type': 'application/json'
}
}, (_err, body, res) => {
setLoading(false);
if (res.statusCode === 200) {
setPlaylistVideos(body);
}
});
}, []);
return (
<div className="playlist">
<div className="playlist-title">
<FormattedMessage id={playlistTitleId} />
</div>
{loading ? (
<Spinner
className="spinner"
color="transparent-gray"
/>
) : (
<section className="playlist-videos">
{playlistVideos
.map(video => (
<YoutubeVideoButton
key={video.videoId}
onSelectedVideo={onSelectedVideo}
{...video}
/>
))}
</section>
)}
</div>
);
};
YoutubePlaylistItem.propTypes = {
playlistRequestUri: PropTypes.string,
playlistTitleId: PropTypes.string,
onSelectedVideo: PropTypes.func
};

View file

@ -0,0 +1,26 @@
.playlist {
display: flex;
flex-direction: column;
gap: 1rem;
.playlist-title {
display: flex;
justify-content: start;
font-size: 2rem;
font-weight: 700;
line-height: 2.5rem;
}
.spinner {
width: 100%;
height: 50px;
}
.playlist-videos {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(13.125rem, auto));
justify-content: space-around;
align-items: start;
gap: 1rem;
}
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import './youtube-video-button.scss';
const parseViewCount = viewCount =>
parseInt(viewCount, 10).toLocaleString('en-US', {
notation: 'compact',
compactDisplay: 'short'
});
export const YoutubeVideoButton = ({onSelectedVideo, ...videoData}) => (
<div
className="youtbe-video-button"
// eslint-disable-next-line react/jsx-no-bind
onClick={() => onSelectedVideo(videoData.videoId)}
>
<div className="thumbnail">
<img src={videoData.thumbnail} />
<div className="duration">{videoData.length}</div>
</div>
<div className="video-info">
<div className="video-title">{videoData.title}</div>
<div className="video-metadata">
<div className="channel-name">{videoData.channel}</div>
<div className="video-statistics">
{`${parseViewCount(videoData.views)} · ${videoData.uploadTime}`}
</div>
</div>
{videoData.hasCC && <img src="/images/ideas/video-cc-label.svg" />}
</div>
</div>
);
YoutubeVideoButton.propTypes = {
videoId: PropTypes.string,
title: PropTypes.string,
thumbnail: PropTypes.string,
channel: PropTypes.string,
uploadTime: PropTypes.string,
length: PropTypes.string,
views: PropTypes.string,
hasCC: PropTypes.bool,
onSelectedVideo: PropTypes.func
};

View file

@ -0,0 +1,70 @@
@import "../../colors";
.youtbe-video-button {
display: flex;
flex-direction: column;
gap: 0.75rem;
width: 13.125rem;
.thumbnail {
position: relative;
width: 100%;
img {
position: relative;
width: 100%;
border-radius: 12px;
}
.duration {
position: absolute;
z-index: 1;
bottom : 0.5rem;
right: 0;
margin: 0.5rem;
padding: 0.25rem;
border-radius: 4px;
background-color: $overlay-gray;
color: $type-white;
font-size: 0.75rem;
font-weight: 500;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
}
.video-info {
margin-right: 1.5rem;
text-align: start;
.video-title {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-height: 1.2rem;
max-height: 2.4rem;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-weight: 600;
font-size: 0.875rem;
color: black;
}
.video-metadata {
margin: 0.5rem 4rem 0.25rem 0;
color: $ui-dark-gray;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.125rem;
}
}
&:hover {
cursor: pointer;
}
}

View file

@ -0,0 +1,47 @@
import React from 'react';
import ReactModal from 'react-modal';
import PropTypes from 'prop-types';
import Button from '../forms/button.jsx';
const classNames = require('classnames');
import './youtube-video-modal.scss';
export const YoutubeVideoModal = ({videoId, onClose = () => {}, className}) => {
if (!videoId) return null;
return (
<ReactModal
isOpen={!!videoId}
onRequestClose={onClose}
className="youtube-video-modal-container"
overlayClassName="youtube-video-modal-overlay"
>
<div
className={classNames('cards-modal-header', className)}
>
<Button
className="close-button"
isCloseType
onClick={onClose}
/>
</div>
<iframe
className="youtube-player"
type="text/html"
width="640"
height="360"
src={`https://www.youtube.com/embed/${videoId}?rel=0&cc_load_policy=1&autoplay=1`}
allow="autoplay"
/>
</ReactModal>
);
};
YoutubeVideoModal.defaultProps = {
className: 'mint-green'
};
YoutubeVideoModal.propTypes = {
videoId: PropTypes.string,
onClose: PropTypes.func,
className: PropTypes.string
};

View file

@ -0,0 +1,60 @@
@import "../../colors";
.youtube-video-modal-overlay {
display: flex;
justify-content: center;
align-items: center;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 510;
background-color: $box-shadow-light-gray;
border-color: unset;
}
.youtube-video-modal-container {
display: flex;
flex-direction: column;
background: white;
border-radius: 8px;
width: 640px;
&:focus {
outline: none;
border: none;
}
.cards-modal-header {
display: flex;
justify-content: end;
align-items: center;
padding: 10px;
border-radius: 8px 8px 0 0;
.close-button {
position: unset;
margin: 0;
}
&.mint-green {
background-color: $ui-mint-green;
}
}
.youtube-player {
border: 0;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
.youtube-video-modal-container:focus {
outline: none;
border: none;
}

View file

@ -1,5 +1,6 @@
const FormattedMessage = require('react-intl').FormattedMessage; const FormattedMessage = require('react-intl').FormattedMessage;
const React = require('react'); const React = require('react');
const {useState, useCallback} = require('react');
const Button = require('../../components/forms/button.jsx'); const Button = require('../../components/forms/button.jsx');
const FlexRow = require('../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../components/flex-row/flex-row.jsx');
@ -8,6 +9,10 @@ const Page = require('../../components/page/www/page.jsx');
const render = require('../../lib/render.jsx'); const render = require('../../lib/render.jsx');
const {useIntl} = require('react-intl'); const {useIntl} = require('react-intl');
const {
YoutubeVideoModal
} = require('../../components/youtube-video-modal/youtube-video-modal.jsx');
const {YoutubePlaylistItem} = require('../../components/youtube-playlist-item/youtube-playlist-item.jsx');
require('./ideas.scss'); require('./ideas.scss');
@ -84,8 +89,21 @@ const physicalIdeasData = [
} }
]; ];
const playlists = {
'sprites-and-vectors': 'ideas.spritesAndVector',
'tips-and-tricks': 'ideas.tipsAndTricks',
'advanced-topics': 'ideas.advancedTopics'
};
const Ideas = () => { const Ideas = () => {
const intl = useIntl(); const intl = useIntl();
const [youtubeVideoId, setYoutubeVideoId] = useState('');
const onCloseVideoModal = useCallback(() => setYoutubeVideoId(''), [setYoutubeVideoId]);
const onSelectedVideo = useCallback(
videoId => setYoutubeVideoId(videoId),
[setYoutubeVideoId]
);
return ( return (
<div> <div>
@ -111,10 +129,7 @@ const Ideas = () => {
<div className="section-header"> <div className="section-header">
<FormattedMessage id="ideas.startHereText" /> <FormattedMessage id="ideas.startHereText" />
</div> </div>
<FlexRow <section className="tips-section">
as="section"
className="tips-section"
>
{tipsSectionData.map((tipData, index) => ( {tipsSectionData.map((tipData, index) => (
<div <div
key={index} key={index}
@ -148,7 +163,45 @@ const Ideas = () => {
</a> </a>
</div> </div>
))} ))}
</FlexRow> </section>
</div>
</div>
<div className="youtube-videos">
<div className="inner">
<div className="section-header">
<img src="/images/ideas/youtube-icon.svg" />
<div>
<div className="section-title">
<FormattedMessage id="ideas.scratchYouTubeChannel" />
</div>
<div className="section-description">
<FormattedMessage
id="ideas.scratchYouTubeChannelDescription"
values={{
a: chunks => (
<a href="https://www.youtube.com/@ScratchTeam">
{chunks}
</a>
)
}}
/>
</div>
</div>
</div>
<section className="playlists">
{Object.keys(playlists).map(playlistKey => (
<YoutubePlaylistItem
key={playlistKey}
playlistRequestUri={`/ideas/videos/${playlistKey}`}
playlistTitleId={playlists[playlistKey]}
onSelectedVideo={onSelectedVideo}
/>
))}
<YoutubeVideoModal
videoId={youtubeVideoId}
onClose={onCloseVideoModal}
/>
</section>
</div> </div>
</div> </div>
<div className="physical-ideas"> <div className="physical-ideas">
@ -194,7 +247,9 @@ const Ideas = () => {
} }
/> />
<FormattedMessage <FormattedMessage
id={physicalIdea.physicalIdeasDescription.buttonTextId} id={
physicalIdea.physicalIdeasDescription.buttonTextId
}
/> />
</Button> </Button>
</a> </a>

View file

@ -52,8 +52,10 @@ $base-bg: $ui-white;
gap: 0.75rem; gap: 0.75rem;
.tips-section { .tips-section {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: repeat(auto-fill, minmax($cols3, auto));
justify-content: space-around;
align-items: center;
.tip { .tip {
display: flex; display: flex;
@ -68,6 +70,56 @@ $base-bg: $ui-white;
} }
} }
.youtube-videos {
.inner {
display: flex;
flex-direction: column;
justify-content: center;
.section-header {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 2.5rem;
padding: 4rem 0;
div {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 25rem;
.section-title {
font-size: 2rem;
font-weight: 700;
line-height: 2.5rem;
text-align: start;
}
.section-description {
font-size: 1rem;
font-weight: 400;
line-height: 1.5rem;
text-align: start;
a {
font-weight: 400;
text-decoration: underline;
}
}
}
}
.playlists {
display: flex;
flex-direction: column;
gap: 3rem;
margin-bottom: 3rem;
}
}
}
.physical-ideas { .physical-ideas {
.inner { .inner {
display: flex; display: flex;
@ -152,10 +204,14 @@ $base-bg: $ui-white;
} }
} }
.tips, .physical-ideas {
.section-header { .section-header {
display: flex;
justify-content: start;
font-size: 2rem; font-size: 2rem;
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
font-weight: 700; font-weight: 700;
line-height: 2.5rem;
}
} }
.tips-button { .tips-button {
@ -164,7 +220,6 @@ $base-bg: $ui-white;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-family: "Helvetica Neue", "Helvetica", Arial, sans-serif;
font-weight: 700; font-weight: 700;
line-height: 1.25rem; line-height: 1.25rem;

View file

@ -32,6 +32,11 @@
"ideas.tryTheTutorial": "Try the tutorial", "ideas.tryTheTutorial": "Try the tutorial",
"ideas.codingCards": "Coding Cards", "ideas.codingCards": "Coding Cards",
"ideas.educatorGuide": "Educator Guide", "ideas.educatorGuide": "Educator Guide",
"ideas.scratchYouTubeChannel": "ScratchTeam channel",
"ideas.scratchYouTubeChannelDescription": "This is the official <a>Youtube Channel</a> of Scratch. We share resources, tutorials, and stories about Scratch.",
"ideas.spritesAndVector": "Sprites & Vector Drawing",
"ideas.tipsAndTricks": "Tips & Tricks",
"ideas.advancedTopics": "Advanced Topics",
"ideas.physicalPlayIdeas": "Physical Play Ideas", "ideas.physicalPlayIdeas": "Physical Play Ideas",
"ideas.microBitHeader": "Have a micro:bit?", "ideas.microBitHeader": "Have a micro:bit?",
"ideas.microBitBody": "Connect your Scratch project to the real world.", "ideas.microBitBody": "Connect your Scratch project to the real world.",

View file

@ -0,0 +1,4 @@
<svg width="18" height="17" viewBox="0 0 18 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="0.0300293" width="15" height="16" fill="#606060"/>
<path d="M15 7.03003H13.5V6.53003H11.5V9.53003H13.5V9.03003H15V10.03C15 10.2952 14.8946 10.5496 14.7071 10.7371C14.5196 10.9247 14.2652 11.03 14 11.03H11C10.7348 11.03 10.4804 10.9247 10.2929 10.7371C10.1054 10.5496 10 10.2952 10 10.03V6.03003C10 5.76481 10.1054 5.51046 10.2929 5.32292C10.4804 5.13539 10.7348 5.03003 11 5.03003H14C14.2652 5.03003 14.5196 5.13539 14.7071 5.32292C14.8946 5.51046 15 5.76481 15 6.03003M8 7.03003H6.5V6.53003H4.5V9.53003H6.5V9.03003H8V10.03C8 10.2952 7.89464 10.5496 7.70711 10.7371C7.51957 10.9247 7.26522 11.03 7 11.03H4C3.73478 11.03 3.48043 10.9247 3.29289 10.7371C3.10536 10.5496 3 10.2952 3 10.03V6.03003C3 5.76481 3.10536 5.51046 3.29289 5.32292C3.48043 5.13539 3.73478 5.03003 4 5.03003H7C7.26522 5.03003 7.51957 5.13539 7.70711 5.32292C7.89464 5.51046 8 5.76481 8 6.03003M16 0.0300293H2C0.89 0.0300293 0 0.920029 0 2.03003V14.03C0 14.5605 0.210714 15.0692 0.585786 15.4442C0.960859 15.8193 1.46957 16.03 2 16.03H16C16.5304 16.03 17.0391 15.8193 17.4142 15.4442C17.7893 15.0692 18 14.5605 18 14.03V2.03003C18 1.4996 17.7893 0.990888 17.4142 0.615816C17.0391 0.240743 16.5304 0.0300293 16 0.0300293Z" fill="#F4F4F4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,12 @@
<svg width="140" height="110" viewBox="0 0 140 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_749)">
<path opacity="0.15" fill-rule="evenodd" clip-rule="evenodd" d="M55 102.36C23.62 102.06 -6.70002 64.7 2.64998 39.56C12.01 14.43 29.31 -0.300021 60.69 -2.09711e-05C92.07 0.299979 118.24 30.85 123.1 63.38C127.97 95.91 86.38 102.65 55 102.35V102.36ZM22.16 108.18C17.61 111.53 10.57 104.29 6.26998 99.23C1.96998 94.17 3.61998 88.8 7.47998 88.84C10.12 88.87 12.94 91.03 15.54 93.07L15.79 93.27C16.89 94.13 17.95 94.96 18.93 95.57C22.3 97.67 26.71 104.83 22.16 108.18Z" fill="#628EC9"/>
<path d="M139.01 57.4C138.99 65.5 133.84 71.28 126.28 73.12C118.11 75.11 111.03 76.27 93.01 78.16C74.39 80.12 67.76 80.36 59.32 80.15C51.29 79.95 45.15 75.44 43.6 67.42C43.28 65.76 41.85 60.53 40.91 51.64C40.24 45.22 40.47 38.9 40.44 37.34C40.3 29.32 45.38 23.67 53.17 21.62C61.33 19.47 67.42 18.32 86.51 16.31C105.55 14.31 111.72 14.38 120.13 14.58C128.33 14.78 134.34 19.13 135.85 27.31C136.14 28.9 137.59 34.09 138.44 42.16C139.31 50.45 139.01 55.76 139.01 57.39V57.4Z" fill="#FF0000"/>
<path d="M84.33 59.9L104.45 45.28L81.73 35.16L84.33 59.9Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_2_749">
<rect width="138.14" height="109.03" fill="white" transform="translate(0.929993)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -271,6 +271,7 @@ module.exports = {
new webpack.DefinePlugin({ new webpack.DefinePlugin({
'process.env.NODE_ENV': `"${process.env.NODE_ENV || 'development'}"`, 'process.env.NODE_ENV': `"${process.env.NODE_ENV || 'development'}"`,
'process.env.API_HOST': `"${process.env.API_HOST || 'https://api.scratch.mit.edu'}"`, 'process.env.API_HOST': `"${process.env.API_HOST || 'https://api.scratch.mit.edu'}"`,
'process.env.ROOT_URL': `"${process.env.ROOT_URL || 'https://scratch.mit.edu'}"`,
'process.env.RECAPTCHA_SITE_KEY': `"${ 'process.env.RECAPTCHA_SITE_KEY': `"${
process.env.RECAPTCHA_SITE_KEY || '6Lf6kK4UAAAAABKTyvdSqgcSVASEnMrCquiAkjVW'}"`, process.env.RECAPTCHA_SITE_KEY || '6Lf6kK4UAAAAABKTyvdSqgcSVASEnMrCquiAkjVW'}"`,
'process.env.ASSET_HOST': `"${process.env.ASSET_HOST || 'https://assets.scratch.mit.edu'}"`, 'process.env.ASSET_HOST': `"${process.env.ASSET_HOST || 'https://assets.scratch.mit.edu'}"`,