Merge pull request #53 from jedmund/youtube

Implement Youtube embeds
This commit is contained in:
Justin Edmund 2022-12-26 09:49:10 -08:00 committed by GitHub
commit 3a94049595
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 315 additions and 126 deletions

View file

@ -1,120 +1,197 @@
.PartyDetails {
display: none; // This breaks transition, find a workaround
opacity: 0;
margin: $unit-4x auto 0;
max-width: $unit * 94;
position: relative;
.DetailsWrapper {
display: flex;
flex-direction: column;
&.Editable {
top: $unit;
height: 0;
z-index: 2;
transition: opacity 0.2s ease-in-out, top 0.2s ease-in-out;
.PartyDetails {
display: none;
margin: 0 auto;
max-width: $unit * 94;
overflow: hidden;
width: 100%;
&.Visible {
display: flex;
flex-direction: column;
gap: $unit;
height: auto;
opacity: 1;
top: 0;
margin-bottom: $unit-12x;
}
fieldset {
display: block;
width: 100%;
&.Editable {
gap: $unit;
textarea {
min-height: $unit * 20;
&.Visible {
display: grid;
}
fieldset {
display: block;
width: 100%;
textarea {
min-height: $unit * 20;
width: 100%;
}
}
.SelectTrigger {
width: 100%;
}
}
.bottom {
display: flex;
flex-direction: row;
gap: $unit;
margin-bottom: $unit-12x;
.left {
flex-grow: 1;
}
.right {
.bottom {
display: flex;
flex-direction: row;
gap: $unit;
}
}
}
&.ReadOnly {
top: $unit * -1;
transition: opacity 0.2s ease-in-out, top 0.2s ease-in-out;
.left {
flex-grow: 1;
}
&.Visible {
display: block;
height: auto;
opacity: 1;
top: 0;
}
a:hover {
text-decoration: underline;
}
p {
font-size: $font-regular;
line-height: $font-regular * 1.2;
white-space: pre-line;
}
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
margin-bottom: $unit;
}
.info {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
margin-bottom: $unit * 2;
.left {
flex-grow: 1;
h1 {
color: var(--text-primary);
&.empty {
color: var(--text-secondary);
}
.right {
display: flex;
flex-direction: row;
gap: $unit;
}
}
}
.attribution {
align-items: center;
display: flex;
flex-direction: row;
&.ReadOnly {
&.Visible {
display: block;
}
& > div {
a:hover {
text-decoration: underline;
}
p {
font-size: $font-regular;
line-height: $font-regular * 1.2;
white-space: pre-line;
}
.YoutubeWrapper {
background-color: var(--card-bg);
border-radius: $card-corner;
margin: $unit 0;
position: relative;
display: block;
contain: content;
background-position: center center;
background-size: cover;
cursor: pointer;
width: 60%;
height: 60%;
/* gradient */
&::before {
content: '';
display: block;
position: absolute;
top: 0;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAADGCAYAAAAT+OqFAAAAdklEQVQoz42QQQ7AIAgEF/T/D+kbq/RWAlnQyyazA4aoAB4FsBSA/bFjuF1EOL7VbrIrBuusmrt4ZZORfb6ehbWdnRHEIiITaEUKa5EJqUakRSaEYBJSCY2dEstQY7AuxahwXFrvZmWl2rh4JZ07z9dLtesfNj5q0FU3A5ObbwAAAABJRU5ErkJggg==);
background-position: top;
background-repeat: repeat-x;
height: 60px;
padding-bottom: 50px;
width: 100%;
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
}
/* responsive iframe with a 16:9 aspect ratio
thanks https://css-tricks.com/responsive-iframes/
*/
&::after {
content: '';
display: block;
padding-bottom: calc(100% / (16 / 9));
}
&:hover > .PlayerButton {
opacity: 1;
}
& > iframe {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
/* Play button */
& > .PlayerButton {
background: none;
border: none;
background-image: url('/icons/youtube.svg');
width: 68px;
height: 68px;
opacity: 0.8;
transition: all 0.2s cubic-bezier(0, 0, 0.2, 1);
}
& > .PlayerButton,
& > .PlayerButton:before {
position: absolute;
top: 50%;
left: 50%;
transform: translate3d(-50%, -50%, 0);
}
/* Post-click styles */
&.lyt-activated {
cursor: unset;
}
&.lyt-activated::before,
&.lyt-activated > .PlayerButton {
opacity: 0;
pointer-events: none;
}
}
}
}
.PartyInfo {
align-items: center;
display: flex;
flex-direction: row;
gap: $unit;
margin: 0 auto;
margin-bottom: $unit * 2;
max-width: $unit * 94;
width: 100%;
.Left {
flex-grow: 1;
h1 {
font-size: $font-xlarge;
font-weight: $normal;
text-align: left;
margin-bottom: $unit;
color: var(--text-primary);
&.empty {
color: var(--text-secondary);
}
}
.attribution {
align-items: center;
display: inline-flex;
font-size: $font-small;
height: 26px;
}
display: flex;
flex-direction: row;
time {
font-size: $font-small;
}
& > div {
align-items: center;
display: inline-flex;
font-size: $font-small;
height: 26px;
}
& > *:not(:last-child):after {
content: ' · ';
margin: 0 calc($unit / 2);
time {
font-size: $font-small;
}
& > *:not(:last-child):after {
content: ' · ';
margin: 0 calc($unit / 2);
}
}
}
@ -147,13 +224,3 @@
}
}
}
.EmptyDetails {
display: none;
justify-content: center;
margin: $unit-4x 0 $unit-10x;
&.Visible {
display: flex;
}
}

View file

@ -1,11 +1,13 @@
import React, { useState } from 'react'
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useSnapshot } from 'valtio'
import { useTranslation } from 'next-i18next'
import Linkify from 'react-linkify'
import LiteYouTubeEmbed from 'react-lite-youtube-embed'
import classNames from 'classnames'
import reactStringReplace from 'react-string-replace'
import * as AlertDialog from '@radix-ui/react-alert-dialog'
@ -23,6 +25,7 @@ import CrossIcon from '~public/icons/Cross.svg'
import EditIcon from '~public/icons/Edit.svg'
import './index.scss'
import { youtube } from '~utils/youtube'
// Props
interface Props {
@ -47,11 +50,13 @@ const PartyDetails = (props: Props) => {
const [open, setOpen] = useState(false)
const [raidSlug, setRaidSlug] = useState('')
const [embeddedDescription, setEmbeddedDescription] =
useState<React.ReactNode>()
const readOnlyClasses = classNames({
PartyDetails: true,
ReadOnly: true,
Visible: true,
Visible: !open,
})
const editableClasses = classNames({
@ -102,6 +107,45 @@ const PartyDetails = (props: Props) => {
setErrors(newErrors)
}
useEffect(() => {
// Extract the video IDs from the description
if (party.description) {
const videoIds = extractYoutubeVideoIds(party.description)
// Fetch the video titles for each ID
const fetchPromises = videoIds.map(({ id }) => fetchYoutubeData(id))
// Wait for all the video titles to be fetched
Promise.all(fetchPromises).then((videoTitles) => {
// YouTube regex
const youtubeUrlRegex =
/https:\/\/www\.youtube\.com\/watch\?v=([\w-]+)/g
// Replace the video URLs in the description with LiteYoutubeEmbed elements
const newDescription = reactStringReplace(
party.description,
youtubeUrlRegex,
(match, i) => (
<LiteYouTubeEmbed
id={match}
title={videoTitles[i]}
wrapperClass="YoutubeWrapper"
playerClass="PlayerButton"
/>
)
)
// Update the state with the new description
setEmbeddedDescription(newDescription)
})
}
}, [party.description])
async function fetchYoutubeData(videoId: string) {
return await youtube
.getVideoById(videoId, { maxResults: 1 })
.then((data) => data.items[0].snippet.localized.title)
}
function toggleDetails() {
setOpen(!open)
}
@ -119,6 +163,31 @@ const PartyDetails = (props: Props) => {
toggleDetails()
}
function extractYoutubeVideoIds(text: string) {
// Create a regular expression to match Youtube URLs in the text
const youtubeUrlRegex = /https:\/\/www\.youtube\.com\/watch\?v=([\w-]+)/g
// Initialize an array to store the video IDs
const videoIds = []
// Use the regular expression to find all the Youtube URLs in the text
let match
while ((match = youtubeUrlRegex.exec(text)) !== null) {
// Extract the video ID from the URL
const videoId = match[1]
// Add the video ID to the array, along with the character position of the URL
videoIds.push({
id: videoId,
url: match[0],
position: match.index,
})
}
// Return the array of video IDs
return videoIds
}
const userImage = (picture?: string, element?: string) => {
if (picture && element)
return (
@ -268,9 +337,13 @@ const PartyDetails = (props: Props) => {
)
const readOnly = (
<section className={readOnlyClasses}>
<div className="info">
<div className="left">
<section className={readOnlyClasses}>{embeddedDescription}</section>
)
return (
<section className="DetailsWrapper">
<div className="PartyInfo">
<div className="Left">
<h1 className={!party.name ? 'empty' : ''}>
{party.name ? party.name : 'Untitled'}
</h1>
@ -289,7 +362,7 @@ const PartyDetails = (props: Props) => {
)}
</div>
</div>
<div className="right">
<div className="Right">
{party.editable ? (
<Button
accessoryIcon={<EditIcon />}
@ -301,21 +374,9 @@ const PartyDetails = (props: Props) => {
)}
</div>
</div>
{party.description ? (
<p>
<Linkify>{party.description}</Linkify>
</p>
) : (
''
)}
</section>
)
return (
<React.Fragment>
{readOnly}
{editable}
</React.Fragment>
</section>
)
}

43
package-lock.json generated
View file

@ -33,9 +33,12 @@
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",
"react-linkify": "^1.0.0-alpha",
"react-lite-youtube-embed": "^2.3.52",
"react-scroll": "^1.8.5",
"react-string-replace": "^1.1.0",
"sass": "^1.49.0",
"valtio": "^1.3.0"
"valtio": "^1.3.0",
"youtube-api-v3-wrapper": "^2.3.0"
},
"devDependencies": {
"@types/lodash.clonedeep": "^4.5.6",
@ -6902,6 +6905,15 @@
"tlds": "^1.199.0"
}
},
"node_modules/react-lite-youtube-embed": {
"version": "2.3.52",
"resolved": "https://registry.npmjs.org/react-lite-youtube-embed/-/react-lite-youtube-embed-2.3.52.tgz",
"integrity": "sha512-G010PvCavA4EqL8mZ/Sv9XXiHnjMfONW+lmNeCRnSEPluPdptv2lZ0cNlngrj7K9j7luc8pbpyrmNpKbD9VMmw==",
"peerDependencies": {
"react": ">=16.0.8",
"react-dom": ">=16.0.8"
}
},
"node_modules/react-refresh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
@ -6978,6 +6990,14 @@
"react-dom": "^15.5.4 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/react-string-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.0.tgz",
"integrity": "sha512-N6RalSDFGbOHs0IJi1H611WbZsvk3ZT47Jl2JEXFbiS3kTwsdCYij70Keo/tWtLy7sfhDsYm7CwNM/WmjXIaMw==",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
@ -7889,6 +7909,11 @@
"engines": {
"node": ">= 6"
}
},
"node_modules/youtube-api-v3-wrapper": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/youtube-api-v3-wrapper/-/youtube-api-v3-wrapper-2.3.0.tgz",
"integrity": "sha512-/c4B0BSe2BEElGHO/VJt4KCqrScl7R7xG44BugvuGsBciP+fF03JN7gS/X0jnaGHOnng7GP1n320hDjwquZOgA=="
}
},
"dependencies": {
@ -12843,6 +12868,12 @@
"tlds": "^1.199.0"
}
},
"react-lite-youtube-embed": {
"version": "2.3.52",
"resolved": "https://registry.npmjs.org/react-lite-youtube-embed/-/react-lite-youtube-embed-2.3.52.tgz",
"integrity": "sha512-G010PvCavA4EqL8mZ/Sv9XXiHnjMfONW+lmNeCRnSEPluPdptv2lZ0cNlngrj7K9j7luc8pbpyrmNpKbD9VMmw==",
"requires": {}
},
"react-refresh": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.8.3.tgz",
@ -12892,6 +12923,11 @@
"prop-types": "^15.7.2"
}
},
"react-string-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/react-string-replace/-/react-string-replace-1.1.0.tgz",
"integrity": "sha512-N6RalSDFGbOHs0IJi1H611WbZsvk3ZT47Jl2JEXFbiS3kTwsdCYij70Keo/tWtLy7sfhDsYm7CwNM/WmjXIaMw=="
},
"react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
@ -13524,6 +13560,11 @@
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
},
"youtube-api-v3-wrapper": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/youtube-api-v3-wrapper/-/youtube-api-v3-wrapper-2.3.0.tgz",
"integrity": "sha512-/c4B0BSe2BEElGHO/VJt4KCqrScl7R7xG44BugvuGsBciP+fF03JN7gS/X0jnaGHOnng7GP1n320hDjwquZOgA=="
}
}
}

View file

@ -38,9 +38,12 @@
"react-i18next": "^11.15.5",
"react-infinite-scroll-component": "^6.1.0",
"react-linkify": "^1.0.0-alpha",
"react-lite-youtube-embed": "^2.3.52",
"react-scroll": "^1.8.5",
"react-string-replace": "^1.1.0",
"sass": "^1.49.0",
"valtio": "^1.3.0"
"valtio": "^1.3.0",
"youtube-api-v3-wrapper": "^2.3.0"
},
"devDependencies": {
"@types/lodash.clonedeep": "^4.5.6",

11
public/icons/youtube.svg Normal file
View file

@ -0,0 +1,11 @@
<svg width="68" height="68" viewBox="0 0 68 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1384_2987)">
<path d="M66.52 17.74C65.74 14.81 64.03 12.33 61.1 11.55C55.79 10.13 34 10 34 10C34 10 12.21 10.13 6.9 11.55C3.97 12.33 2.27 14.81 1.48 17.74C0.0600001 23.05 0 34 0 34C0 34 0.0600001 44.95 1.48 50.26C2.26 53.19 3.97 55.67 6.9 56.45C12.21 57.87 34 58 34 58C34 58 55.79 57.87 61.1 56.45C64.03 55.67 65.74 53.19 66.52 50.26C67.94 44.95 68 34 68 34C68 34 67.94 23.05 66.52 17.74Z" fill="#FF0000"/>
<path d="M45 34L27 24V44" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1384_2987">
<rect width="68" height="68" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 684 B

6
utils/youtube.tsx Normal file
View file

@ -0,0 +1,6 @@
import { YoutubeAPIClient } from 'youtube-api-v3-wrapper'
export const youtube = new YoutubeAPIClient(
'key',
process.env.NEXT_PUBLIC_YOUTUBE_API_KEY
)