diff --git a/gatsby-node.js b/gatsby-node.js index f594eb6..7fdba39 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -3,7 +3,7 @@ const fs = require(`fs`) const { slugify } = require("./utils/slugify") const { getGithubStats } = require("./utils/githubStats") const { getScreenshot } = require("./utils/screenshot") - +const { getThumbnailString } = require("./utils/thumbnail") // generate pages for slugs exports.createPages = async ({ graphql, actions }) => { const { createPage } = actions @@ -152,5 +152,14 @@ exports.onCreateNode = async ({ node, actions }) => { value: screenshot, }) } + + const thumbnailString = await getThumbnailString(node.resources) + if (thumbnailString) { + createNodeField({ + node, + name: `thumbnail_string`, + value: thumbnailString, + }) + } } } diff --git a/package.json b/package.json index 72101ab..456cb5d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "gatsby-transformer-remark": "^2.8.7", "gatsby-transformer-yaml": "^2.4.1", "gatsby-transformer-yaml-full": "^0.3.1", + "image-downloader": "^4.0.1", "node-fetch": "^2.6.0", "react": "^16.12.0", "react-dom": "^16.12.0", @@ -41,7 +42,9 @@ "react-multi-carousel": "^2.5.5", "react-player": "^2.6.2", "react-select": "^3.1.0", + "react-slick": "0.26", "react-typography": "^0.16.19", + "slick-carousel": "^1.8.1", "styled-components": "^5.1.0", "tailwindcss": "^1.4.6", "twin.macro": "^1.1.0", diff --git a/src/components/tool/main-media-util-helper.js b/src/components/tool/main-media-util-helper.js new file mode 100644 index 0000000..8e25cd7 --- /dev/null +++ b/src/components/tool/main-media-util-helper.js @@ -0,0 +1,68 @@ +import { css } from "twin.macro" +export const SliderContentImageStyle = css` + height: inherit; + margin-left: auto; + margin-right: auto; + ` +export const SliderStyle = css` + .slick-prev:before, + .slick-next:before { + color: #222425!important; + } + .slick-slide { + padding: 0rem 1rem 0rem 1rem; + } + ` +export const SliderContentStyle = css` + margin: 1rem 0rem 1rem 0.1rem; + transition: transform .2s; + height: 8rem; + background: #f7f7f7; + text-align: center; + cursor: pointer; + border-radius: 0.5rem; + + &:hover { + -ms-transform: scale(1.005); + -webkit-transform: scale(1.005); + transform: scale(1.005); + } + + &:focus { + outline: none; + -webkit-box-shadow: 0px 0px 4px 0px rgba(173,173,173,1); + -moz-box-shadow: 0px 0px 4px 0px rgba(173,173,173,1); + box-shadow: 0px 0px 4px 0px rgba(173,173,173,1); + } + ` +export const getSliderSetting = noOfElements => { + const maxSlidesToShow = noOfElements < 3 ? 2 : 3; + return { + focusOnSelect: true, + dots: true, + infinite: false, + speed: 500, + slidesToShow: maxSlidesToShow, + slidesToScroll: 1, + responsive: [{ + breakpoint: 1200, + settings: { + slidesToShow: maxSlidesToShow, + slidesToScroll: 1 + } + }, { + breakpoint: 600, + settings: { + slidesToShow: 2, + slidesToScroll: 1 + } + }, { + breakpoint: 480, + settings: { + slidesToShow: 1, + slidesToScroll: 1 + } + } + ] + } +} diff --git a/src/components/tool/main-media-util.js b/src/components/tool/main-media-util.js new file mode 100644 index 0000000..78d2f7e --- /dev/null +++ b/src/components/tool/main-media-util.js @@ -0,0 +1,130 @@ +import React, { useState, useEffect } from "react" +import tw, { css } from "twin.macro" +import ReactPlayer from "react-player/lazy" +import "slick-carousel/slick/slick.css" +import "slick-carousel/slick/slick-theme.css" +import Slider from "react-slick" +import { + SliderContentImageStyle, + SliderStyle, + SliderContentStyle, + getSliderSetting +} from "./main-media-util-helper" + + +const renderMainMediaDisplayElement = { + video: video => { + return ( +
+ +
+ ) + }, + image: image => ( + + {`Screenshot + + ), +} + +const renderMainMediaSliderElement = { + video: video => sliderThumbnail(video), + image: image => sliderThumbnail(image) +} + +const sliderThumbnail = props => { + props = props || {} + const src = props.src || props.thumbnailSrc || "#" + const alt = props.name || "Thumbnail" + return ( + + ) +} + +export const MainMediaUtil = ({data}) => { + const [items] = useState(data) + const [index, setIndex] = useState(0) + const [isRendered, setIsRendered] = useState(false) + const [hasThumbnails, setHasThumbnails] = useState(false) + const sliderSetting = getSliderSetting(items.length) + + const toggleDisplayStatusOfElement = options => { + options = options || {} + const idForElementToDisplay = "#main_media_util_in_display_" + index + const elementToDisplay = document.querySelector(idForElementToDisplay) + elementToDisplay.setAttribute('style', options.style || 'display:block') + + if (isRendered) return + const idForElementToFocus = "#main_media_util_" + index + const elementToFocus = document.querySelector(idForElementToFocus) + elementToFocus.focus({ preventScroll: true }) + setIsRendered(true) + } + + const populateVideoThumbnails = async () => { + items.map(async item => { + if (item.type !== "video") return + const url = item.thumbnail[item.source.url] + const target = document.querySelector("#" + item.source.key + "_img") + target.setAttribute("src", url) + }) + setHasThumbnails(true) + } + + useEffect(() => { + if (items.length > 1) toggleDisplayStatusOfElement() + if (!hasThumbnails) populateVideoThumbnails() + }) + + return items && items.length > 1 ? ( + <> +
+ {items.map((item, itemIndex) => { + item.source.key = "main_media_util_in_display_" + itemIndex + item.source.css = css`display:none;` + return renderMainMediaDisplayElement[item.type](item.source) + })} +
+
+ + {items.map((item, itemIndex) => { + item.source.key = "main_media_util_" + itemIndex + item.source.onClick = () => { + if (itemIndex === index) return + toggleDisplayStatusOfElement({style : 'display:none' }) + setIndex(itemIndex) + } + return renderMainMediaSliderElement[item.type](item.source) + })} + +
+ + ) : ( +
+ {items && items.map(item => { + item.source.key = "main_media_util_in_display_0" + return renderMainMediaDisplayElement[item.type](item.source) + })} +
+ ) +} \ No newline at end of file diff --git a/src/components/tool/main-media.js b/src/components/tool/main-media.js index d3341ae..a5c3abb 100644 --- a/src/components/tool/main-media.js +++ b/src/components/tool/main-media.js @@ -1,7 +1,6 @@ import React from "react" import "twin.macro" -import ReactPlayer from "react-player/lazy" -import Carousel from "react-multi-carousel" +import { MainMediaUtil } from "./main-media-util" const getVideo = resources => { if (!resources) { @@ -15,22 +14,6 @@ const getVideo = resources => { } } -const renders = { - video: video => ( - - ), - image: image => ( - - {`Screenshot - - ), -} - const MainMedia = ({ tool }) => { const { name, homepage, resources } = tool let screenshot = { name, url: homepage, src: tool.fields.screenshot } @@ -42,35 +25,15 @@ const MainMedia = ({ tool }) => { } if (video) { - items.push({ type: "video", source: video }) - } + const thumbJson = (tool.fields.thumbnail_string) ? + JSON.parse(tool.fields.thumbnail_string) : {} - const carouselProps = { - responsive: { - all: { - breakpoint: { max: 3000, min: 0 }, - items: 1, - }, - }, - infinite: true, - showDots: true, + items.push({ type: "video", source: video, thumbnail: thumbJson}) } return (
- {items.length > 1 ? ( - - {items.map(item => ( -
- {renders[item.type](item.source)} -
- ))} -
- ) : ( -
- {renders[items[0].type](items[0].source)} -
- )} +
) } diff --git a/src/templates/tool.js b/src/templates/tool.js index 807f286..0cf0467 100644 --- a/src/templates/tool.js +++ b/src/templates/tool.js @@ -307,6 +307,7 @@ export const query = graphql` fields { slug screenshot + thumbnail_string githubStats { stargazers_count created_at(formatString: "YYYY") diff --git a/utils/thumbnail.js b/utils/thumbnail.js new file mode 100644 index 0000000..d8770de --- /dev/null +++ b/utils/thumbnail.js @@ -0,0 +1,132 @@ +// code copied from screenshot.js with additional changes +// for downloading thumbnail images + +const download = require('image-downloader') +const Bottleneck = require("bottleneck/es5") +const limiter = new Bottleneck({ + maxConcurrent: 4, + minTime: 500, +}) +const throttledThumbnailDownload = limiter.wrap(download.image) +const { slugify } = require("./slugify") +const fs = require("fs") +const fetch = require('node-fetch'); +const throttledFetch = limiter.wrap(fetch) +/** + * + * **Credits** + * Author : yangshun + * Gist link : https://gist.github.com/yangshun/9892961 + */ +const parseVideo = url => { + if (!url) { + return + } + + url.match(/(http:|https:|)\/\/(player.|www.)?(vimeo\.com|youtu(be\.com|\.be|be\.googleapis\.com))\/(video\/|embed\/|watch\?v=|v\/)?([A-Za-z0-9._%-]*)(&\S+)?/); + + const type = RegExp.$3.indexOf("youtu") > -1 ? "youtube" : + RegExp.$3.indexOf("vimeo") > -1 ? "vimeo" + : undefined + + return { + type: type, + id: RegExp.$6 + } +} + +const getThumbnailUrl = async videoUrl => { + if (!videoUrl) { + return + } + + console.log(`Getting thumbnail link for video URL : ${videoUrl}`) + const video = parseVideo(videoUrl) + if (video.type === "youtube") + return video.id ? "https://img.youtube.com/vi/" + video.id + "/maxresdefault.jpg" : undefined + if (video.type === "vimeo") { + const fetched = (async videoId => { + let result = {} + try { + const response = await throttledFetch("https://vimeo.com/api/v2/video/" + videoId + ".json") + result = await response.json() + return result[0].thumbnail_large + } catch (e) { + console.error("Error while fetching Vimeo video data", e) + return + } + }) + return fetched(video.id) + } +} + +const handleResource = async urlArg => { + if(!urlArg) { + return + } + + console.log(`Fetching thumbnail for ${urlArg}`) + try { + + let thumbnailUrl = await getThumbnailUrl(urlArg) + if (!thumbnailUrl) { + return + } + + const outDir = `static/thumbnails` + + + fs.mkdirSync(outDir, { recursive: true }) + + const urlClean = thumbnailUrl.replace(/(^\w+:|^)\/\/(www)?/, "") + const outPath = `${outDir}/${slugify(urlClean)}.jpg` + + let thumbnailDownloadOptions = { + url: thumbnailUrl, + dest: outPath, + extractFilename: false + } + + if (!fs.existsSync(outPath)) { + await throttledThumbnailDownload(thumbnailDownloadOptions) + } + + console.log(`Downloaded and saved thumbnail image at ${outPath}`) + return outPath.replace(/^static/, "") + } catch (err) { + console.log(err) + return + } +} + +module.exports.getThumbnailString = async resources => { + if(!resources) { + return + } + + let resource = {}; + for (let i = 0; i < resources.length; i++) { + if(!resources[i]) + continue + let item = resources[i] + if (item.url.includes("youtube.com") || item.url.includes("vimeo.com")) { + let thumbnailLink = undefined + try { + const outPath = await handleResource(item.url) + if(outPath) { + thumbnailLink = outPath.replace(/^static/, "") + } + } catch (err) { + console.log(err) + } + + if(!thumbnailLink) { + thumbnailLink = await getThumbnailUrl(item.url) + } + resource[item.url] = thumbnailLink + + } + } + + return JSON.stringify(resource).replace('"','\"') +} diff --git a/yarn.lock b/yarn.lock index 83d335a..68530ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4886,6 +4886,11 @@ enhanced-resolve@^4.3.0: memory-fs "^0.5.0" tapable "^1.0.0" +enquire.js@^2.1.6: + version "2.1.6" + resolved "https://registry.yarnpkg.com/enquire.js/-/enquire.js-2.1.6.tgz#3e8780c9b8b835084c3f60e166dbc3c2a3c89814" + integrity sha1-PoeAybi4NQhMP2DhZtvDwqPImBQ= + entities@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" @@ -7266,6 +7271,11 @@ ignore@^5.1.1, ignore@^5.1.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57" integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw== +image-downloader@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/image-downloader/-/image-downloader-4.0.1.tgz#2cf211107050595ed1f99af4531ebea4aeea3dfc" + integrity sha512-VfjlMHx1DDPN27x6IC15+6oejeG0A1S955r7oF4rsxqAh8+ygJ3avK8yi8pJztz5qir/x1dFV+eCIy3155byhg== + import-cwd@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9" @@ -8126,6 +8136,13 @@ json-stringify-safe@^5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= +json2mq@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/json2mq/-/json2mq-0.2.0.tgz#b637bd3ba9eabe122c83e9720483aeb10d2c904a" + integrity sha1-tje9O6nqvhIsg+lyBIOusQ0skEo= + dependencies: + string-convert "^0.2.0" + json3@^3.3.2: version "3.3.3" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" @@ -8352,6 +8369,11 @@ lodash.clonedeep@4.5.0: resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + lodash.deburr@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b" @@ -10852,6 +10874,17 @@ react-side-effect@^2.1.0: resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.1.tgz#66c5701c3e7560ab4822a4ee2742dee215d72eb3" integrity sha512-2FoTQzRNTncBVtnzxFOk2mCpcfxQpenBMbk5kSVBg5UcPqV9fRbgY2zhb7GTWWOlpFmAxhClBDlIq8Rsubz1yQ== +react-slick@0.26: + version "0.26.1" + resolved "https://registry.yarnpkg.com/react-slick/-/react-slick-0.26.1.tgz#42d6b9bfdf3a16e4e4609a6c6536957f8acde7d9" + integrity sha512-IQVRSkikG2w5bkz+m9Ing5zZIuM9cI+qJyXG2o6PXHKj8GFcsMCJoTBADwyLSsVT8dHcZ8MZ0dsxq0i0CKIq+Q== + dependencies: + classnames "^2.2.5" + enquire.js "^2.1.6" + json2mq "^0.2.0" + lodash.debounce "^4.0.8" + resize-observer-polyfill "^1.5.0" + react-transition-group@^4.3.0: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -11254,6 +11287,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= +resize-observer-polyfill@^1.5.0: + version "1.5.1" + resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" + integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== + resolve-cwd@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a" @@ -11747,6 +11785,11 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +slick-carousel@^1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/slick-carousel/-/slick-carousel-1.8.1.tgz#a4bfb29014887bb66ce528b90bd0cda262cc8f8d" + integrity sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA== + slugify@^1.4.4: version "1.4.6" resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.4.6.tgz#ef288d920a47fb01c2be56b3487b6722f5e34ace" @@ -12107,6 +12150,11 @@ strict-uri-encode@^2.0.0: resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY= +string-convert@^0.2.0: + version "0.2.1" + resolved "https://registry.yarnpkg.com/string-convert/-/string-convert-0.2.1.tgz#6982cc3049fbb4cd85f8b24568b9d9bf39eeff97" + integrity sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c= + string-env-interpolation@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz#ad4397ae4ac53fe6c91d1402ad6f6a52862c7152"