Recommendation home page (#2571)

This commit is contained in:
cj
2022-05-09 20:51:20 -05:00
committed by GitHub
parent 1e7b85fbe5
commit bc85614ff9
9 changed files with 716 additions and 2 deletions

View File

@@ -64,8 +64,10 @@
"react-router-dom": "^5.2.0",
"react-router-hash-link": "^2.3.1",
"react-select": "^4.0.2",
"react-slick": "^0.29.0",
"remark-gfm": "^1.0.0",
"sass": "^1.32.5",
"slick-carousel": "^1.8.1",
"string.prototype.replaceall": "^1.0.4",
"subscriptions-transport-ws": "^0.9.18",
"thehandy": "^1.0.3",
@@ -99,6 +101,7 @@
"@types/react-router-bootstrap": "^0.24.5",
"@types/react-router-dom": "5.1.7",
"@types/react-router-hash-link": "^1.2.1",
"@types/react-slick": "^0.23.8",
"@types/video.js": "^7.3.28",
"@types/videojs-seek-buttons": "^2.1.0",
"@typescript-eslint/eslint-plugin": "^4.33.0",

View File

@@ -19,6 +19,7 @@ import Galleries from "./components/Galleries/Galleries";
import { MainNavbar } from "./components/MainNavbar";
import { PageNotFound } from "./components/PageNotFound";
import Performers from "./components/Performers/Performers";
import Recommendations from "./components/Recommendations/Recommendations";
import Scenes from "./components/Scenes/Scenes";
import { Settings } from "./components/Settings/Settings";
import { Stats } from "./components/Stats";
@@ -117,7 +118,7 @@ export const App: React.FC = () => {
return (
<Switch>
<Route exact path="/" component={Stats} />
<Route exact path="/" component={Recommendations} />
<Route path="/scenes" component={Scenes} />
<Route path="/images" component={Images} />
<Route path="/galleries" component={Galleries} />
@@ -125,6 +126,7 @@ export const App: React.FC = () => {
<Route path="/tags" component={Tags} />
<Route path="/studios" component={Studios} />
<Route path="/movies" component={Movies} />
<Route path="/stats" component={Stats} />
<Route path="/settings" component={Settings} />
<Route path="/sceneFilenameParser" component={SceneFilenameParser} />
<Route

View File

@@ -1,4 +1,5 @@
### ✨ New Features
* Added recommendations to home page. ([#2571](https://github.com/stashapp/stash/pull/2571))
* Add support for VTT and SRT captions for scenes. ([#2462](https://github.com/stashapp/stash/pull/2462))
* Added option to require a number of scroll attempts before navigating to next/previous image in Lightbox. ([#2544](https://github.com/stashapp/stash/pull/2544))

View File

@@ -66,6 +66,10 @@ const messages = defineMessages({
id: "donate",
defaultMessage: "Donate",
},
statistics: {
id: "statistics",
defaultMessage: "Statistics",
},
});
const allMenuItems: IMenuItem[] = [
@@ -259,6 +263,19 @@ export const MainNavbar: React.FC = () => {
</span>
</Button>
</Nav.Link>
<NavLink
className="nav-utility"
exact
to="/stats"
onClick={handleDismiss}
>
<Button
className="minimal d-flex align-items-center h-100"
title={intl.formatMessage({ id: "statistics" })}
>
<Icon icon="chart-bar" />
</Button>
</NavLink>
<NavLink
className="nav-utility"
exact

View File

@@ -0,0 +1,263 @@
import * as GQL from "src/core/generated-graphql";
import { defineMessages, useIntl } from "react-intl";
import React from "react";
import {
useFindScenes,
useFindMovies,
useFindStudios,
useFindGalleries,
useFindPerformers,
} from "src/core/StashService";
import { SceneCard } from "src/components/Scenes/SceneCard";
import { StudioCard } from "src/components/Studios/StudioCard";
import { MovieCard } from "src/components/Movies/MovieCard";
import { PerformerCard } from "src/components/Performers/PerformerCard";
import { GalleryCard } from "src/components/Galleries/GalleryCard";
import { SceneQueue } from "src/models/sceneQueue";
import { ListFilterModel } from "src/models/list-filter/filter";
import Slider from "react-slick";
const Recommendations: React.FC = () => {
function isTouchEnabled() {
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
}
const isTouch = isTouchEnabled();
const intl = useIntl();
const itemsPerPage = 25;
const scenefilter = new ListFilterModel(GQL.FilterMode.Scenes);
scenefilter.sortBy = "date";
scenefilter.sortDirection = GQL.SortDirectionEnum.Desc;
scenefilter.itemsPerPage = itemsPerPage;
const sceneResult = useFindScenes(scenefilter);
const hasScenes =
sceneResult.data &&
sceneResult.data.findScenes &&
sceneResult.data.findScenes.count > 0;
const studiofilter = new ListFilterModel(GQL.FilterMode.Studios);
studiofilter.sortBy = "scenes_count";
studiofilter.sortDirection = GQL.SortDirectionEnum.Desc;
studiofilter.itemsPerPage = itemsPerPage;
const studioResult = useFindStudios(studiofilter);
const hasStudios =
studioResult.data &&
studioResult.data.findStudios &&
studioResult.data.findStudios.count > 0;
const moviefilter = new ListFilterModel(GQL.FilterMode.Movies);
moviefilter.sortBy = "date";
moviefilter.sortDirection = GQL.SortDirectionEnum.Desc;
moviefilter.itemsPerPage = itemsPerPage;
const movieResult = useFindMovies(moviefilter);
const hasMovies =
movieResult.data &&
movieResult.data.findMovies &&
movieResult.data.findMovies.count > 0;
const performerfilter = new ListFilterModel(GQL.FilterMode.Performers);
performerfilter.sortBy = "created_at";
performerfilter.sortDirection = GQL.SortDirectionEnum.Desc;
performerfilter.itemsPerPage = itemsPerPage;
const performerResult = useFindPerformers(performerfilter);
const hasPerformers =
performerResult.data &&
performerResult.data.findPerformers &&
performerResult.data.findPerformers.count > 0;
const galleryfilter = new ListFilterModel(GQL.FilterMode.Galleries);
galleryfilter.sortBy = "date";
galleryfilter.sortDirection = GQL.SortDirectionEnum.Desc;
galleryfilter.itemsPerPage = itemsPerPage;
const galleryResult = useFindGalleries(galleryfilter);
const hasGalleries =
galleryResult.data &&
galleryResult.data.findGalleries &&
galleryResult.data.findGalleries.count > 0;
const messages = defineMessages({
emptyServer: {
id: "empty_server",
defaultMessage:
"Add some scenes to your server to view recommendations on this page.",
},
latestScenes: {
id: "latest_scenes",
defaultMessage: "Latest Scenes",
},
mostActiveStudios: {
id: "most_active_studios",
defaultMessage: "Most Active Studios",
},
latestMovies: {
id: "latest_movies",
defaultMessage: "Latest Movies",
},
latestPerformers: {
id: "latest_performers",
defaultMessage: "Latest Performers",
},
latestGalleries: {
id: "latest_galleries",
defaultMessage: "Latest Galleries",
},
});
var settings = {
dots: !isTouch,
arrows: !isTouch,
infinite: !isTouch,
speed: 300,
variableWidth: true,
swipeToSlide: true,
slidesToShow: 5,
slidesToScroll: !isTouch ? 5 : 1,
responsive: [
{
breakpoint: 1909,
settings: {
slidesToShow: 4,
slidesToScroll: !isTouch ? 4 : 1,
},
},
{
breakpoint: 1542,
settings: {
slidesToShow: 3,
slidesToScroll: !isTouch ? 3 : 1,
},
},
{
breakpoint: 1170,
settings: {
slidesToShow: 2,
slidesToScroll: !isTouch ? 2 : 1,
},
},
{
breakpoint: 801,
settings: {
slidesToShow: 1,
slidesToScroll: 1,
dots: false,
},
},
],
};
const queue = SceneQueue.fromListFilterModel(scenefilter);
return (
<div className="recommendations-container">
{!hasScenes &&
!hasStudios &&
!hasMovies &&
!hasPerformers &&
!hasGalleries ? (
<div className="no-recommendations">
{intl.formatMessage(messages.emptyServer)}
</div>
) : (
<div>
{hasScenes && (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{intl.formatMessage(messages.latestScenes)}</h2>
</div>
<a href="/scenes?sortby=date&sortdir=desc">View all</a>
</div>
<Slider {...settings}>
{sceneResult.data?.findScenes.scenes.map((scene, index) => (
<SceneCard
key={scene.id}
scene={scene}
queue={queue}
index={index}
zoomIndex={1}
/>
))}
</Slider>
</div>
)}
{hasStudios && (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{intl.formatMessage(messages.mostActiveStudios)}</h2>
</div>
<a href="/studios?sortby=scenes_count&sortdir=desc">View all</a>
</div>
<Slider {...settings}>
{studioResult.data?.findStudios.studios.map((studio) => (
<StudioCard
key={studio.id}
studio={studio}
hideParent={true}
/>
))}
</Slider>
</div>
)}
{hasMovies && (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{intl.formatMessage(messages.latestMovies)}</h2>
</div>
<a href="/movies?sortby=date&sortdir=desc">View all</a>
</div>
<Slider {...settings}>
{movieResult.data?.findMovies.movies.map((p) => (
<MovieCard key={p.id} movie={p} />
))}
</Slider>
</div>
)}
{hasPerformers && (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{intl.formatMessage(messages.latestPerformers)}</h2>
</div>
<a href="/performers?sortby=created_at&sortdir=desc">
View all
</a>
</div>
<Slider {...settings}>
{performerResult.data?.findPerformers.performers.map((p) => (
<PerformerCard key={p.id} performer={p} />
))}
</Slider>
</div>
)}
{hasGalleries && (
<div className="recommendation-row">
<div className="recommendation-row-head">
<div>
<h2>{intl.formatMessage(messages.latestGalleries)}</h2>
</div>
<a href="/galleries?sortby=date&sortdir=desc">View all</a>
</div>
<Slider {...settings}>
{galleryResult.data?.findGalleries.galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
gallery={gallery}
zoomIndex={1}
/>
))}
</Slider>
</div>
)}
</div>
)}
</div>
);
};
export default Recommendations;

View File

@@ -0,0 +1,370 @@
.recommendations-container {
padding-left: 20px;
padding-right: 20px;
@media (max-width: 576px) {
padding-left: 0;
padding-right: 0;
}
}
.no-recommendations {
font-size: 1.5rem;
padding-top: 2rem;
text-align: center;
}
.recommendation-row-head {
align-items: center;
border-radius: 0;
-webkit-box-align: center;
-webkit-box-pack: justify;
display: flex;
justify-content: space-between;
padding: 15px 0;
}
.recommendation-row-head h2 {
display: inline-flex;
font-size: 1.25rem;
font-weight: 600;
text-transform: uppercase;
white-space: normal;
}
.recommendation-row-head a {
display: inline-flex;
font-size: 1.2rem;
white-space: normal;
}
.recommendations-container .studio-card hr,
.recommendations-container .movie-card hr,
.recommendations-container .gallery-card hr {
margin-top: auto;
}
/* Slider */
.slick-slider {
box-sizing: border-box;
display: block;
position: relative;
-webkit-tap-highlight-color: transparent;
-ms-touch-action: pan-y;
touch-action: pan-y;
-webkit-touch-callout: none;
-khtml-user-select: none;
-ms-user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
user-select: none;
}
.slick-list {
display: block;
margin: 0;
overflow: hidden;
padding: 0;
position: relative;
}
.slick-list:focus {
outline: none;
}
.slick-list.dragging {
cursor: pointer;
cursor: hand;
}
.slick-slider .slick-track,
.slick-slider .slick-list {
-moz-transform: translate3d(0, 0, 0);
-ms-transform: translate3d(0, 0, 0);
-o-transform: translate3d(0, 0, 0);
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
.slick-track {
display: block;
left: 0;
margin-left: auto;
margin-right: auto;
position: relative;
top: 0;
}
.slick-track::before,
.slick-track::after {
content: "";
display: table;
}
.slick-track::after {
clear: both;
}
.slick-loading .slick-track {
visibility: hidden;
}
.slick-slide {
display: none;
float: left;
height: 100%;
min-height: 1px;
}
[dir="rtl"] .slick-slide {
float: right;
}
.slick-slide img {
display: block;
}
.slick-slide.slick-loading img {
display: none;
}
.slick-slide.dragging img {
pointer-events: none;
}
.slick-initialized .slick-slide {
display: block;
}
.slick-loading .slick-slide {
visibility: hidden;
}
.slick-vertical .slick-slide {
border: 1px solid transparent;
display: block;
height: auto;
}
.slick-arrow.slick-hidden {
display: none;
}
.slick-loading .slick-list {
background: #fff url("slick-carousel/slick/ajax-loader.gif") center center
no-repeat;
}
.slick-list .card-check {
display: none;
}
.container-fluid .slick-track {
display: flex;
}
.container-fluid .slick-slide {
display: flex;
height: auto;
}
.slick-slide .card {
height: 100%;
}
.slick-slide .studio-card-image {
height: 150px;
}
@media (max-width: 576px) {
.slick-list .scene-card,
.slick-list .studio-card,
.slick-list .gallery-card {
width: 20rem;
}
.slick-list .movie-card {
width: 16rem;
}
.slick-list .performer-card {
width: 16rem;
}
.slick-list .performer-card-image {
height: 24rem;
}
}
/* Icons */
@font-face {
font-family: slick;
font-style: normal;
font-weight: normal;
src: url("slick-carousel/slick/fonts/slick.eot");
src: url("slick-carousel/slick/fonts/slick.eot?#iefix")
format("embedded-opentype"),
url("slick-carousel/slick/fonts/slick.woff") format("woff"),
url("slick-carousel/slick/fonts/slick.ttf") format("truetype"),
url("slick-carousel/slick/fonts/slick.svg#slick") format("svg");
}
/* Arrows */
.slick-prev,
.slick-next {
background: transparent;
border: none;
color: transparent;
cursor: pointer;
display: block;
font-size: 0;
height: 100%;
line-height: 0;
outline: none;
padding: 0;
position: absolute;
top: 50%;
-webkit-transform: translate(0, -50%);
-ms-transform: translate(0, -50%);
transform: translate(0, -50%);
width: 20px;
}
.slick-prev:hover,
.slick-prev:focus,
.slick-next:hover,
.slick-next:focus {
background: transparent;
color: transparent;
outline: none;
}
.slick-prev:hover::before,
.slick-prev:focus::before,
.slick-next:hover::before,
.slick-next:focus::before {
opacity: 1;
}
.slick-prev.slick-disabled::before,
.slick-next.slick-disabled::before {
opacity: 0.25;
}
.slick-prev::before,
.slick-next::before {
color: white;
font-family: slick;
font-size: 20px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
line-height: 1;
opacity: 0.75;
}
.slick-prev {
left: -20px;
}
[dir="rtl"] .slick-prev {
left: auto;
right: -20px;
}
.slick-prev::before {
content: "";
}
[dir="rtl"] .slick-prev::before {
content: "";
}
.slick-next {
right: -25px;
}
[dir="rtl"] .slick-next {
left: -25px;
right: auto;
}
.slick-next::before {
content: "";
}
[dir="rtl"] .slick-next::before {
content: "";
}
/* Dots */
.slick-dotted.slick-slider {
margin-bottom: 30px;
}
.slick-dots {
bottom: -25px;
display: block;
list-style: none;
margin: 0;
padding: 0;
position: absolute;
text-align: center;
width: 100%;
}
.slick-dots li {
cursor: pointer;
display: inline-block;
height: 20px;
margin: 0 5px;
padding: 0;
position: relative;
width: 20px;
}
.slick-dots li button {
background: transparent;
border: 0;
color: transparent;
cursor: pointer;
display: block;
font-size: 0;
height: 20px;
line-height: 0;
outline: none;
padding: 5px;
width: 20px;
}
.slick-dots li button:hover,
.slick-dots li button:focus {
outline: none;
}
.slick-dots li button:hover::before,
.slick-dots li button:focus::before {
opacity: 1;
}
.slick-dots li button::before {
color: white;
content: "-";
font-family: slick;
font-size: 50px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 20px;
left: 0;
opacity: 0.25;
position: absolute;
text-align: center;
top: 0;
width: 20px;
}
.slick-dots li.slick-active button::before {
color: white;
opacity: 0.75;
}

View File

@@ -8,6 +8,7 @@
@import "src/components/List/styles.scss";
@import "src/components/Movies/styles.scss";
@import "src/components/Performers/styles.scss";
@import "src/components/Recommendations/styles.scss";
@import "src/components/Scenes/styles.scss";
@import "src/components/SceneDuplicateChecker/styles.scss";
@import "src/components/SceneFilenameParser/styles.scss";

View File

@@ -121,6 +121,7 @@
"birth_year": "Birth Year",
"birthdate": "Birthdate",
"bitrate": "Bit Rate",
"captions": "Captions",
"career_length": "Career Length",
"component_tagger": {
"config": {
@@ -705,6 +706,7 @@
"scale": "Scale",
"warmth": "Warmth"
},
"empty_server": "Add some scenes to your server to view recommendations on this page.",
"ethnicity": "Ethnicity",
"existing_value": "existing value",
"eye_color": "Eye Colour",
@@ -747,8 +749,11 @@
"instagram": "Instagram",
"interactive": "Interactive",
"interactive_speed": "Interactive speed",
"captions": "Captions",
"isMissing": "Is Missing",
"latest_galleries": "Latest Galleries",
"latest_movies": "Latest Movies",
"latest_performers": "Latest Performers",
"latest_scenes": "Latest Scenes",
"library": "Library",
"loading": {
"generic": "Loading…"
@@ -772,6 +777,7 @@
},
"megabits_per_second": "{value} megabits per second",
"metadata": "Metadata",
"most_active_studios": "Most Active Studios",
"movie": "Movie",
"movie_scene_number": "Movie Scene Number",
"movies": "Movies",
@@ -902,6 +908,7 @@
},
"stash_id": "Stash ID",
"stash_ids": "Stash IDs",
"statistics": "Statistics",
"stats": {
"image_size": "Images size",
"scenes_duration": "Scenes duration",

View File

@@ -1505,6 +1505,13 @@
"@types/react-dom" "*"
"@types/react-transition-group" "*"
"@types/react-slick@^0.23.8":
version "0.23.8"
resolved "https://registry.npmjs.org/@types/react-slick/-/react-slick-0.23.8.tgz"
integrity sha512-SfzSg++/3uyftVZaCgHpW+2fnJFsyJEQ/YdsuqfOWQ5lqUYV/gY/UwAnkw4qksCj5jalto/T5rKXJ8zeFldQeA==
dependencies:
"@types/react" "*"
"@types/react-transition-group@*", "@types/react-transition-group@^4.4.0":
version "4.4.1"
resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.1.tgz"
@@ -2906,6 +2913,11 @@ end-of-stream@^1.1.0:
dependencies:
once "^1.4.0"
enquire.js@^2.1.6:
version "2.1.6"
resolved "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz"
integrity sha1-PoeAybi4NQhMP2DhZtvDwqPImBQ=
enquirer@^2.3.5:
version "2.3.6"
resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz"
@@ -4664,6 +4676,13 @@ json-to-pretty-yaml@^1.2.2:
remedial "^1.0.7"
remove-trailing-spaces "^1.0.6"
json2mq@^0.2.0:
version "0.2.0"
resolved "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz"
integrity sha1-tje9O6nqvhIsg+lyBIOusQ0skEo=
dependencies:
string-convert "^0.2.0"
json5@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
@@ -4895,6 +4914,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.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz"
integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
lodash.get@^4:
version "4.4.2"
resolved "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz"
@@ -6524,6 +6548,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.29.0:
version "0.29.0"
resolved "https://registry.npmjs.org/react-slick/-/react-slick-0.29.0.tgz"
integrity sha512-TGdOKE+ZkJHHeC4aaoH85m8RnFyWqdqRfAGkhd6dirmATXMZWAxOpTLmw2Ll/jPTQ3eEG7ercFr/sbzdeYCJXA==
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, react-transition-group@^4.4.1:
version "4.4.1"
resolved "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz"
@@ -6786,6 +6821,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.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz"
integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==
resolve-from@5.0.0, resolve-from@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz"
@@ -7043,6 +7083,11 @@ slice-ansi@^4.0.0:
astral-regex "^2.0.0"
is-fullwidth-code-point "^3.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==
snake-case@^3.0.4:
version "3.0.4"
resolved "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz"
@@ -7169,6 +7214,11 @@ strict-uri-encode@^2.0.0:
resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz"
integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
string-convert@^0.2.0:
version "0.2.1"
resolved "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz"
integrity sha1-aYLMMEn7tM2F+LJFaLnZvznu/5c=
string-env-interpolation@1.0.1, string-env-interpolation@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/string-env-interpolation/-/string-env-interpolation-1.0.1.tgz"