MM-64483 YouTube Preview UI Update (#31288)
* fix youtube thumbnails * fix lint issues, update snapshots * Update _videos.scss * Update _videos.scss * revert change to 'YouTubeVideo' to 'YoutubeVideo' * switch back to Youtube instead of YouTube * accessibility fixes -add focus state * fix lint issues * Update youtube_video.test.tsx.snap * remove logic for delaying different aspect ratios. always use 16:9 * Update _videos.scss * fix rounded corners * use css variables for radius * restore attributes from original, remove bottom margin from player to prevent vertical shift * fix issue with margin and nested 'video-div' elements * update class to 'video-playing' * fix linter issues * address copilot review feedback and remove unused css * Update youtube_video.test.tsx * fixed failing test * lint issue fix * Update youtube_video.test.tsx * review feedback changes * Update _videos.scss * update test and snapshot * fix lint issues * add scaling button on hover state back in * feat: Make YouTube video component strings translatable - Add useIntl import and formatMessage usage - Create YouTubePrefix component for internationalized prefix - Update YouTubeThumbnail component to use translatable strings - Add translation IDs: - youtube_video.play.aria_label - youtube_video.thumbnail.alt_text - youtube_video.play_button.aria_label - youtube_video.type Addresses reviewer feedback to make hardcoded strings translatable using React Intl's useIntl hook. Co-authored-by: Matthew Birtch <matthewbirtch@users.noreply.github.com> * fix lint issues * Update en.json * Update youtube_video.test.tsx.snap * remove youtube prefix * fix lint issues * Update youtube_video.test.tsx.snap * fix lint issue --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Matthew Birtch <matthewbirtch@users.noreply.github.com>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
47a7e62b44
Коммит
b42f4fbffd
@@ -37,7 +37,7 @@ describe('YouTube Video', () => {
|
|||||||
cy.get('.play-button', {timeout: TIMEOUTS.TEN_SEC}).click();
|
cy.get('.play-button', {timeout: TIMEOUTS.TEN_SEC}).click();
|
||||||
|
|
||||||
// * Video should be loaded in the iframe
|
// * Video should be loaded in the iframe
|
||||||
cy.get('.video-div > iframe').should('exist');
|
cy.get('.video-playing iframe').should('exist');
|
||||||
|
|
||||||
// # Collapse video
|
// # Collapse video
|
||||||
cy.get('.post__embed-visibility').click();
|
cy.get('.post__embed-visibility').click();
|
||||||
@@ -52,7 +52,7 @@ describe('YouTube Video', () => {
|
|||||||
cy.get('.play-button').should('exist');
|
cy.get('.play-button').should('exist');
|
||||||
|
|
||||||
// * Video should not be played in the iframe
|
// * Video should not be played in the iframe
|
||||||
cy.get('.video-div > iframe').should('not.exist');
|
cy.get('.video-playing iframe').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,41 +63,51 @@ exports[`YoutubeVideo should match init snapshot 1`] = `
|
|||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="video-div embed-responsive-item"
|
className="video-div embed-responsive-item"
|
||||||
onClick={[Function]}
|
|
||||||
>
|
>
|
||||||
<div
|
<YouTubeThumbnail
|
||||||
className="embed-responsive video-div__placeholder"
|
onError={[Function]}
|
||||||
|
play={[Function]}
|
||||||
|
thumbnailUrl="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
|
||||||
|
useMaxResThumbnail={true}
|
||||||
|
videoTitle="Youtube title"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
aria-label="Play Youtube title on YouTube"
|
||||||
className="video-thumbnail__container"
|
className="video-thumbnail__container"
|
||||||
|
onClick={[Function]}
|
||||||
|
onKeyDown={[Function]}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<Connect(Component)
|
<Connect(Component)
|
||||||
src="linkForThumbnail"
|
src="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
|
||||||
>
|
>
|
||||||
<Memo(ExternalImage)
|
<Memo(ExternalImage)
|
||||||
dispatch={[Function]}
|
dispatch={[Function]}
|
||||||
enableSVGs={false}
|
enableSVGs={false}
|
||||||
hasImageProxy={false}
|
hasImageProxy={false}
|
||||||
src="linkForThumbnail"
|
src="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="youtube video thumbnail"
|
alt="Thumbnail for Youtube title on YouTube"
|
||||||
className="video-thumbnail"
|
className="video-thumbnail"
|
||||||
src="linkForThumbnail"
|
onError={[Function]}
|
||||||
|
src="https://img.youtube.com/vi/xqCoNej8Zxo/maxresdefault.jpg"
|
||||||
/>
|
/>
|
||||||
</Memo(ExternalImage)>
|
</Memo(ExternalImage)>
|
||||||
</Connect(Component)>
|
</Connect(Component)>
|
||||||
<div
|
<div
|
||||||
className="block"
|
aria-label="Play video"
|
||||||
|
className="play-button"
|
||||||
|
role="presentation"
|
||||||
>
|
>
|
||||||
<span
|
<i
|
||||||
className="play-button"
|
aria-hidden="true"
|
||||||
>
|
className="icon-play"
|
||||||
<span />
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</YouTubeThumbnail>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,19 +139,21 @@ exports[`YoutubeVideo should match snapshot for playing state 1`] = `
|
|||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="video-div embed-responsive-item"
|
className="video-div embed-responsive-item"
|
||||||
onClick={[Function]}
|
|
||||||
>
|
>
|
||||||
<iframe
|
<div
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
className="video-playing"
|
||||||
allowFullScreen={true}
|
>
|
||||||
frameBorder="0"
|
<iframe
|
||||||
height="360px"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
referrerPolicy="no-referrer"
|
allowFullScreen={true}
|
||||||
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
frameBorder="0"
|
||||||
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1"
|
height="100%"
|
||||||
title="Youtube title"
|
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
||||||
width="480px"
|
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&rel=0&fs=1&enablejsapi=1"
|
||||||
/>
|
title="Youtube title"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,19 +183,22 @@ exports[`YoutubeVideo should match snapshot for playing state and \`youtubeRefer
|
|||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="video-div embed-responsive-item"
|
className="video-div embed-responsive-item"
|
||||||
onClick={[Function]}
|
|
||||||
>
|
>
|
||||||
<iframe
|
<div
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
className="video-playing"
|
||||||
allowFullScreen={true}
|
>
|
||||||
frameBorder="0"
|
<iframe
|
||||||
height="360px"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
referrerPolicy="strict-origin-when-cross-origin"
|
allowFullScreen={true}
|
||||||
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
frameBorder="0"
|
||||||
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1"
|
height="100%"
|
||||||
title="Youtube title"
|
referrerPolicy="origin"
|
||||||
width="480px"
|
sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"
|
||||||
/>
|
src="https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&rel=0&fs=1&enablejsapi=1"
|
||||||
|
title="Youtube title"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import {Provider} from 'react-redux';
|
|||||||
|
|
||||||
import type {DeepPartial} from '@mattermost/types/utilities';
|
import type {DeepPartial} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
import ExternalImage from 'components/external_image';
|
|
||||||
|
|
||||||
import mockStore from 'tests/test_store';
|
import mockStore from 'tests/test_store';
|
||||||
|
|
||||||
import type {GlobalState} from 'types/store';
|
import type {GlobalState} from 'types/store';
|
||||||
@@ -56,8 +54,10 @@ describe('YoutubeVideo', () => {
|
|||||||
</Provider>,
|
</Provider>,
|
||||||
);
|
);
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
expect(wrapper.find(ExternalImage).prop('src')).toEqual('linkForThumbnail');
|
|
||||||
expect(wrapper.find('a').text()).toEqual('Youtube title');
|
// Verify that useMaxResThumbnail is true by default
|
||||||
|
expect((wrapper.find('YoutubeVideo').instance() as YoutubeVideo).state.useMaxResThumbnail).toBe(true);
|
||||||
|
expect(wrapper.find('h4').text()).toEqual('YouTube - Youtube title');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should match snapshot for playing state', () => {
|
test('should match snapshot for playing state', () => {
|
||||||
@@ -75,6 +75,12 @@ describe('YoutubeVideo', () => {
|
|||||||
);
|
);
|
||||||
wrapper.setState({playing: true});
|
wrapper.setState({playing: true});
|
||||||
expect(wrapper).toMatchSnapshot();
|
expect(wrapper).toMatchSnapshot();
|
||||||
|
|
||||||
|
// Verify that the iframe has a referrerPolicy attribute (set to 'origin') when youtubeReferrerPolicy is true.
|
||||||
|
expect(wrapper.find('.video-playing iframe').prop('referrerPolicy')).toEqual('origin');
|
||||||
|
|
||||||
|
// Verify that the iframe src includes the new parameters
|
||||||
|
expect(wrapper.find('.video-playing iframe').prop('src')).toEqual('https://www.youtube.com/embed/xqCoNej8Zxo?autoplay=1&rel=0&fs=1&enablejsapi=1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should use url if secure_url is not present', () => {
|
test('should use url if secure_url is not present', () => {
|
||||||
@@ -89,6 +95,26 @@ describe('YoutubeVideo', () => {
|
|||||||
};
|
};
|
||||||
const wrapper = shallow(<YoutubeVideo {...props}/>);
|
const wrapper = shallow(<YoutubeVideo {...props}/>);
|
||||||
|
|
||||||
expect(wrapper.find(ExternalImage).prop('src')).toEqual('linkUrl');
|
// Verify that useMaxResThumbnail is true by default
|
||||||
|
expect(wrapper.state('useMaxResThumbnail')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('thumbnail fallback', () => {
|
||||||
|
it('should fallback to hqdefault.jpg on image error', () => {
|
||||||
|
const wrapper = shallow(<YoutubeVideo {...baseProps}/>);
|
||||||
|
|
||||||
|
// Simulate an image error by calling handleImageError.
|
||||||
|
(wrapper.instance() as YoutubeVideo).handleImageError();
|
||||||
|
|
||||||
|
// Verify that useMaxResThumbnail is now false (will use hqdefault.jpg).
|
||||||
|
expect(wrapper.state('useMaxResThumbnail')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with useMaxResThumbnail set to true', () => {
|
||||||
|
const wrapper = shallow(<YoutubeVideo {...baseProps}/>);
|
||||||
|
|
||||||
|
// Verify that the component initializes with useMaxResThumbnail = true
|
||||||
|
expect(wrapper.state('useMaxResThumbnail')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
|
||||||
import type {OpenGraphMetadata} from '@mattermost/types/posts';
|
import type {OpenGraphMetadata} from '@mattermost/types/posts';
|
||||||
|
|
||||||
@@ -20,24 +21,113 @@ type Props = {
|
|||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
|
useMaxResThumbnail: boolean;
|
||||||
|
prevLink: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type YouTubeThumbnailProps = {
|
||||||
|
play: () => void;
|
||||||
|
videoTitle: string;
|
||||||
|
onError: () => void;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
useMaxResThumbnail: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function YouTubeThumbnail({play, videoTitle, onError, thumbnailUrl}: YouTubeThumbnailProps) {
|
||||||
|
const {formatMessage} = useIntl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className='video-thumbnail__container'
|
||||||
|
onClick={play}
|
||||||
|
role='button'
|
||||||
|
aria-label={formatMessage({
|
||||||
|
id: 'youtube_video.play.aria_label',
|
||||||
|
defaultMessage: 'Play {videoTitle} on YouTube',
|
||||||
|
}, {
|
||||||
|
videoTitle,
|
||||||
|
})}
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
play();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalImage src={thumbnailUrl}>
|
||||||
|
{(src) => (
|
||||||
|
<img
|
||||||
|
className='video-thumbnail'
|
||||||
|
src={src}
|
||||||
|
alt={formatMessage({
|
||||||
|
id: 'youtube_video.thumbnail.alt_text',
|
||||||
|
defaultMessage: 'Thumbnail for {videoTitle} on YouTube',
|
||||||
|
}, {
|
||||||
|
videoTitle,
|
||||||
|
})}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ExternalImage>
|
||||||
|
<div
|
||||||
|
className='play-button'
|
||||||
|
role='presentation'
|
||||||
|
aria-label={formatMessage({
|
||||||
|
id: 'youtube_video.play_button.aria_label',
|
||||||
|
defaultMessage: 'Play video',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className='icon-play'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class YoutubeVideo extends React.PureComponent<Props, State> {
|
export default class YoutubeVideo extends React.PureComponent<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
playing: false,
|
playing: false,
|
||||||
|
useMaxResThumbnail: true,
|
||||||
|
prevLink: props.link,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromProps(props: Props, state: State): State | null {
|
static getDerivedStateFromProps(props: Props, state: State): Partial<State> | null {
|
||||||
|
const nextState: Partial<State> = {};
|
||||||
|
|
||||||
if (!props.show && state.playing) {
|
if (!props.show && state.playing) {
|
||||||
return {playing: false};
|
nextState.playing = false;
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
if (props.link !== state.prevLink) {
|
||||||
|
nextState.useMaxResThumbnail = true;
|
||||||
|
nextState.prevLink = props.link;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(nextState).length > 0 ? nextState : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMaxResUrl(link: string) {
|
||||||
|
const videoId = getVideoId(link);
|
||||||
|
return `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getHQUrl(link: string) {
|
||||||
|
const videoId = getVideoId(link);
|
||||||
|
return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageError = () => {
|
||||||
|
this.setState({
|
||||||
|
useMaxResThumbnail: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
play = () => {
|
play = () => {
|
||||||
this.setState({playing: true});
|
this.setState({playing: true});
|
||||||
};
|
};
|
||||||
@@ -48,11 +138,12 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {metadata, link} = this.props;
|
const {metadata, link} = this.props;
|
||||||
|
|
||||||
const videoId = getVideoId(link);
|
const videoId = getVideoId(link);
|
||||||
const videoTitle = metadata?.title || 'unknown';
|
const videoTitle = metadata?.title || 'unknown';
|
||||||
const time = handleYoutubeTime(link);
|
const time = handleYoutubeTime(link);
|
||||||
|
|
||||||
|
const thumbnailUrl = this.state.useMaxResThumbnail ? this.getMaxResUrl(link) : this.getHQUrl(link);
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
<h4>
|
<h4>
|
||||||
<span className='video-type'>{'YouTube - '}</span>
|
<span className='video-type'>{'YouTube - '}</span>
|
||||||
@@ -71,51 +162,37 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
|
|||||||
|
|
||||||
if (this.state.playing) {
|
if (this.state.playing) {
|
||||||
content = (
|
content = (
|
||||||
<iframe
|
<div className='video-playing'>
|
||||||
src={'https://www.youtube.com/embed/' + videoId + '?autoplay=1&autohide=1&border=0&wmode=opaque&fs=1&enablejsapi=1' + time}
|
<iframe
|
||||||
width='480px'
|
src={`https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0&fs=1&enablejsapi=1${time}`}
|
||||||
height='360px'
|
title={videoTitle}
|
||||||
frameBorder='0'
|
width='100%'
|
||||||
allowFullScreen={true}
|
height='100%'
|
||||||
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share'
|
frameBorder='0'
|
||||||
referrerPolicy={this.props.youtubeReferrerPolicy ? 'strict-origin-when-cross-origin' : 'no-referrer'}
|
allow='accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
|
||||||
title={videoTitle}
|
allowFullScreen={true}
|
||||||
sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'
|
referrerPolicy={this.props.youtubeReferrerPolicy ? 'origin' : undefined}
|
||||||
/>
|
sandbox='allow-scripts allow-same-origin allow-popups allow-presentation'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const image = metadata?.images[0];
|
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
<div className='embed-responsive video-div__placeholder'>
|
<YouTubeThumbnail
|
||||||
<div className='video-thumbnail__container'>
|
play={this.play}
|
||||||
<ExternalImage src={image?.secure_url || image?.url || ''}>
|
videoTitle={videoTitle}
|
||||||
{(safeUrl) => (
|
onError={this.handleImageError}
|
||||||
<img
|
thumbnailUrl={thumbnailUrl}
|
||||||
src={safeUrl}
|
useMaxResThumbnail={this.state.useMaxResThumbnail}
|
||||||
alt='youtube video thumbnail'
|
/>
|
||||||
className='video-thumbnail'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ExternalImage>
|
|
||||||
<div className='block'>
|
|
||||||
<span className='play-button'><span/></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className='post__embed-container'>
|
||||||
className='post__embed-container'
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
{header}
|
{header}
|
||||||
<div
|
<div className='video-div embed-responsive-item'>
|
||||||
className='video-div embed-responsive-item'
|
|
||||||
onClick={this.play}
|
|
||||||
>
|
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +200,7 @@ export default class YoutubeVideo extends React.PureComponent<Props, State> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static isYoutubeLink(link: string): boolean {
|
static isYoutubeLink(link: string): boolean {
|
||||||
return Boolean(link.trim().match(ytRegex));
|
return Boolean(link.trim().match(ytRegex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6449,5 +6449,8 @@
|
|||||||
"workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options",
|
"workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options",
|
||||||
"workspace_limits.upgrade": "Upgrade to avoid {planName} data limits",
|
"workspace_limits.upgrade": "Upgrade to avoid {planName} data limits",
|
||||||
"workspace_limits.upgrade_reasons.free": "{planName} is restricted to {messagesLimit} message history and {storageLimit} file storage. You can delete items to free up space or upgrade to a paid plan.",
|
"workspace_limits.upgrade_reasons.free": "{planName} is restricted to {messagesLimit} message history and {storageLimit} file storage. You can delete items to free up space or upgrade to a paid plan.",
|
||||||
"yourcomputer": "Your computer"
|
"yourcomputer": "Your computer",
|
||||||
|
"youtube_video.play_button.aria_label": "Play video",
|
||||||
|
"youtube_video.play.aria_label": "Play {videoTitle} on YouTube",
|
||||||
|
"youtube_video.thumbnail.alt_text": "Thumbnail for {videoTitle} on YouTube"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,117 @@
|
|||||||
@use "utils/functions";
|
@use "utils/functions";
|
||||||
@use "utils/variables";
|
@use "utils/variables";
|
||||||
|
@use "utils/mixins";
|
||||||
|
|
||||||
.post {
|
.post {
|
||||||
|
// Container for both video and thumbnail
|
||||||
.video-div {
|
.video-div {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden; // Ensure hover effects stay contained
|
||||||
|
width: 100%;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
height: 360px;
|
max-height: 270px;
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|
||||||
iframe,
|
iframe {
|
||||||
video {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-thumbnail {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 0;
|
||||||
left: 50%;
|
left: 0;
|
||||||
width: 200px;
|
display: block;
|
||||||
height: 150px;
|
overflow: hidden;
|
||||||
border-radius: 10px;
|
width: 100%;
|
||||||
margin: -75px 0 0 -100px;
|
height: 100%;
|
||||||
background-color: functions.alpha-color(variables.$black, 0.5);
|
border: none;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
aspect-ratio: 16/9; // Always use 16:9
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container for thumbnails
|
||||||
|
.video-thumbnail__container {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-s);
|
||||||
|
aspect-ratio: 16/9; // Always use 16:9
|
||||||
|
background-color: #000;
|
||||||
|
outline: none; // Remove default focus outline
|
||||||
|
|
||||||
|
// Use existing button focus mixin
|
||||||
|
@include mixins.button-focus;
|
||||||
|
|
||||||
|
// Darkening overlay on hover
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
content: '';
|
||||||
|
pointer-events: none;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
&::after {
|
||||||
|
background-color: rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
background-color: #FF0033; //youtube red
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing state container
|
||||||
|
.video-playing {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16/9; // Always use 16:9
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thumbnail image
|
||||||
|
.video-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play button container
|
||||||
|
.play-button {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 28px);
|
||||||
|
left: calc(50% - 40px);
|
||||||
|
display: flex;
|
||||||
|
width: 80px;
|
||||||
|
height: 56px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-m);
|
||||||
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
i.icon-play {
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
left: -1px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video title and type
|
||||||
.video-type {
|
.video-type {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
opacity: 0.8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-title {
|
.video-title {
|
||||||
@@ -46,28 +119,6 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.play-button {
|
|
||||||
position: absolute;
|
|
||||||
top: 26px;
|
|
||||||
right: 51px;
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
border: 4px solid functions.alpha-color(variables.$white, 0.4);
|
|
||||||
border-radius: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
span {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 20px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-top: 36px solid transparent;
|
|
||||||
border-bottom: 36px solid transparent;
|
|
||||||
border-left: 60px solid functions.alpha-color(variables.$white, 0.4);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.img-div {
|
.img-div {
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
@@ -93,11 +144,4 @@
|
|||||||
.video-loading {
|
.video-loading {
|
||||||
height: 368px;
|
height: 368px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-div__placeholder {
|
|
||||||
display: flex;
|
|
||||||
height: 360px;
|
|
||||||
align-items: center;
|
|
||||||
background-color: black;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user