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 => (
+
+
+
+ ),
+}
+
+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 => (
-
-
-
- ),
-}
-
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"