kompisn90 / Streamate Vue UI test

// ==UserScript==
// @name            Streamate Vue UI test
// @description     Test of new UI that lets the user do proper filtering, sorting, etc. on Streamate.
// @version         0.0.1
// @grant           none
// @include         https://www.streamate.com/?*
// @include         https://www.streamate.com/
// @require         https://cdn.jsdelivr.net/npm/vue/dist/vue.js
// @require         https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.10/lodash.js
// @copyright       2018, kompisn90 (https://openuserjs.org/users/kompisn90)
// @license         MIT
// ==/UserScript==

/**
    For a long time I've been wanting to try to write a new UI for a website as a userscript using Vue.
    This is the result of a two day experiment. I chose Streamate for this project, as it's the website
    where I miss certain features the most.

    I wanted to be able to remove some countries from the model lists, as I don't want to see models
    from countries with high numbers of fake profiles, and I wanted to remove trans models from the
    model lists. The rest of the features are convenience functions and/or added as a test.

    I plan to add more features later, but the current state of this project is good enough for me
    right now, and I'm publishing it in order to be able to use it on other computers.
**/

const style = /*html*/`
<style scoped>

.content-wrapper {
    display: flex;
}

.model-list {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    justify-content: space-evenly;
    align-content: flex-start;
    align-items: flex-start;
}

.model-wrapper {
    flex: 8;
    align-self: auto;
}

.filter-wrapper {
    flex: 1;
    align-self: auto;
}

.strikeout label {
    text-decoration: line-through;
    color: gray;
}

.model-status {
    padding: 4px 7px 0px;
}

.heart {
    color: red;
}

.online {
    background-color: limegreen;
}
.gold {
    background-color: gold;
}

.model-info {
    position: relative;
    margin-top: -30px;
    margin-bottom: 15px;
    color: black;
}

.model-info-overlay {
    background-color: rgba(0, 0, 0, 0.5);
    color: white;
    padding: 4px 7px 0px;
    display: flex;
    justify-content: space-between;
}

a:hover .model-info-overlay {
    background-color: rgba(0, 0, 0, 0.8);
    color: white;
}

.model-name {
    flex: 8;
    text-align: left;
    text-overflow: ellipsis;
    white-space: nowrap;
    overflow: hidden;
}
.model-country {
    flex: 1;
    text-align: center;
    white-space: nowrap;
}
.model-age {
    flex: 1;
    text-align: right;
}

.filter-wrapper button {
    width: 100%;
    white-space: nowrap;
}

.model {
    box-shadow: 0px 0px 2px black;
}

.model:hover {
    box-shadow: 0px 0px 5px black;
}

.rotate-180 {
    transform: rotate(180deg);
}

.pull-right {
    float: right;
}

.pull-left {
    float: left;
}

h1 {
    text-align: center;
}
</style>
`
const template = /*html*/`
    <div>
        <button @click="fetchNextPage">Load more models</button>
        <button @click="reload">Reload</button>
        <label>
            <input type="checkbox" v-model="favoritesFirst">
            Favorites first
        </label>
        <p>
            {{ models.length }} models loaded. {{ filteredAndSortedModels.length }} models shown.
        </p>
    </div>
    <div>
    </div>
    <div class="content-wrapper">
        <div class="model-wrapper" v-if="filteredAndSortedModels.length === 0 && !loaded">
            <h1>Loading…</h1>
        </div>
        <div class="model-wrapper" v-else-if="filteredAndSortedModels.length > 0 && loaded">
            <ul class="model-list">
                <a v-for="model in filteredAndSortedModels" :href="'https://www.streamate.com/cam/' + model.Nickname">
                    <li class="model">
                        <img :src="'//m1.nsimg.net/biopic/320x240/' + model.PerformerId" :alt="model.Nickname">
                        <div class="model-info">
                            <div class="model-info-overlay">
                                <div class="model-name">
                                    {{ model.Nickname }}
                                    <i class="fa fa-heart heart" v-if="model.Favorite"></i>
                                </div>
                                <div class="model-country">
                                    {{ countryCodes[model.Country] }}
                                </div>
                                <div class="model-age">
                                    {{ model.Age }}
                                </div>
                            </div>
                            <div class="model-status" :class="{
                                'online': model.LiveStatus === 'live',
                                'gold': model.GoldShow || model.PreGoldShow,
                            }">
                                {{ model.StatusString }}
                                <span v-if="model.PreGoldShow" class="small">(not started)</span>
                            </div>
                        </div>
                    </li>
                </a>
            </ul>
        </div>
        <div class="model-wrapper" v-else-if="loaded">
            Too specific filter
        </div>
        <div class="filter-wrapper">
            <div class="age-filter-wrapper">
                <button @click="showAgeFilter = !showAgeFilter">
                    Age filter
                    <i class="fa fa-caret-down" :class="{ 'rotate-180': showAgeFilter }"></i>
                </button>
                <div class="age-filter" v-show="showAgeFilter">
                    <select class="pull-left" v-model="ageFrom">
                        <option v-for="age in ages" :value="age">
                            {{ age }}
                        </option>
                    </select>
                    <select class="pull-right" v-model="ageTo">
                        <option v-for="age in ages" :value="age">
                            {{ age }}
                        </option>
                    </select>
                </div>
            </div>
            <div class="gender-filter-wrapper">
                <button @click="showGenderFilter = !showGenderFilter">
                    Gender filter
                    <i class="fa fa-caret-down" :class="{ 'rotate-180': showGenderFilter }"></i>
                </button>
                <ul class="gender-filter" v-show="showGenderFilter">
                    <li v-for="(status, gender) in genders" :class="{ 'strikeout': !genders[gender] }">
                        <label>
                            <input type="checkbox" v-model="genders[gender]">
                            {{ genderNames[gender] }}
                        </label>
                    </li>
                </ul>
            </div>
            <div class="country-filter-wrapper">
                <button @click="showCountryFilter = !showCountryFilter">
                    Country filter
                    <i class="fa fa-caret-down" :class="{ 'rotate-180': showCountryFilter }"></i>
                </button>
                <ul class="country-filter" v-show="showCountryFilter">
                    <button @click="toggleCountries">Toggle all countries</button>
                    <li v-for="(value, code) in countryFilter" :key="code" :class="{ 'strikeout': !excludeCountries[code] }">
                        <label>
                            <input type="checkbox" v-model="excludeCountries[code]">
                            {{ countryCodes[code] }}
                        </label>
                    </li>
                </ul>
            </div>
            <div class="feature-filter-wrapper">
                <button @click="showFeatureFilter = !showFeatureFilter">
                    Feature filter
                    <i class="fa fa-caret-down" :class="{ 'rotate-180': showFeatureFilter }"></i>
                </button>
                <ul class="feature-filter" v-show="showFeatureFilter">
                    <li v-for="(status, feature) in features">
                        <b>
                            {{ feature }}
                        </b>
                        <br>
                        <label>
                            Enable
                            <input type="checkbox" v-model="features[feature].enable">
                        </label>
                        <label :class="{ 'fade': !features[feature].enable}">
                            Status
                            <input :disabled="!features[feature].enable" type="checkbox" v-model="features[feature].status">
                        </label>
                    </li>
                </ul>
            </div>
            <div class="sorting-wrapper">
                <button @click="showSort = !showSort">
                    Select sort
                    <i class="fa fa-caret-down" :class="{ 'rotate-180': showSort }"></i>
                </button>
                <ul class="feature-filter" v-show="showSort">
                    <li>
                        <label>
                            <input type="radio" value="asc" v-model="sortDirection">
                            Sort ascending
                        </label>
                    </li>
                    <li>
                        <label>
                            <input type="radio" value="desc" v-model="sortDirection">
                            Sort descending
                        </label>
                    </li>
                    <hr>
                    <li v-for="sort in sorts">
                        <label>
                            <input type="radio" :value="sort" v-model="sortName">
                            {{ sort }}
                        </label>
                    </li>
                </ul>
            </div>
        </div>
    </div>
`

const appWrapper = document.createElement('div')
appWrapper.innerHTML = style

const appEl = document.createElement('div')
appEl.innerHTML = template
appEl.style.display = 'none'
appEl.id = 'theApp'

appWrapper.appendChild(appEl)

document.querySelector('.catbus').insertBefore(appWrapper, document.querySelector('.catbus__container'))
document.querySelector('.catbus').removeChild(document.querySelector('.catbus__container'))

var app = new Vue({
    el: '#theApp',
    data: {
        ageFrom: 18,
        ageTo: 30,
        countryCodes: {AF: 'Afghanistan', AX: 'Aland Islands', AL: 'Albania', DZ: 'Algeria', AS: 'American Samoa', AD: 'Andorra', AO: 'Angola', AI: 'Anguilla', AQ: 'Antarctica', AG: 'Antigua and Barbuda', AR: 'Argentina', AM: 'Armenia', AW: 'Aruba', AU: 'Australia', AT: 'Austria', AZ: 'Azerbaijan', BS: 'Bahamas', BH: 'Bahrain', BD: 'Bangladesh', BB: 'Barbados', BY: 'Belarus', BE: 'Belgium', BZ: 'Belize', BJ: 'Benin', BM: 'Bermuda', BT: 'Bhutan', BO: 'Bolivia', BA: 'Bosnia and Herzegovina', BW: 'Botswana', BV: 'Bouvet Island', BR: 'Brazil', VG: 'British Virgin Islands', IO: 'British Indian Ocean Territory', BN: 'Brunei Darussalam', BG: 'Bulgaria', BF: 'Burkina Faso', BI: 'Burundi', KH: 'Cambodia', CM: 'Cameroon', CA: 'Canada', CV: 'Cape Verde', KY: 'Cayman Islands', CF: 'Central African Republic', TD: 'Chad', CL: 'Chile', CN: 'China', HK: 'Hong Kong, SAR China', MO: 'Macao, SAR China', CX: 'Christmas Island', CC: 'Cocos (Keeling) Islands', CO: 'Colombia', KM: 'Comoros', CG: 'Congo (Brazzaville', CD: 'Congo, (Kinshasa', CK: 'Cook Islands', CR: 'Costa Rica', CI: 'Côte d\'Ivoire', HR: 'Croatia', CU: 'Cuba', CY: 'Cyprus', CZ: 'Czech Republic', DK: 'Denmark', DJ: 'Djibouti', DM: 'Dominica', DO: 'Dominican Republic', EC: 'Ecuador', EG: 'Egypt', SV: 'El Salvador', GQ: 'Equatorial Guinea', ER: 'Eritrea', EE: 'Estonia', ET: 'Ethiopia', FK: 'Falkland Islands (Malvinas', FO: 'Faroe Islands', FJ: 'Fiji', FI: 'Finland', FR: 'France', GF: 'French Guiana', PF: 'French Polynesia', TF: 'French Southern Territories', GA: 'Gabon', GM: 'Gambia', GE: 'Georgia', DE: 'Germany', GH: 'Ghana', GI: 'Gibraltar', GR: 'Greece', GL: 'Greenland', GD: 'Grenada', GP: 'Guadeloupe', GU: 'Guam', GT: 'Guatemala', GG: 'Guernsey', GN: 'Guinea', GW: 'Guinea-Bissau', GY: 'Guyana', HT: 'Haiti', HM: 'Heard and Mcdonald Islands', VA: 'Holy See (Vatican City State', HN: 'Honduras', HU: 'Hungary', IS: 'Iceland', IN: 'India', ID: 'Indonesia', IR: 'Iran, Islamic Republic of', IQ: 'Iraq', IE: 'Ireland', IM: 'Isle of Man', IL: 'Israel', IT: 'Italy', JM: 'Jamaica', JP: 'Japan', JE: 'Jersey', JO: 'Jordan', KZ: 'Kazakhstan', KE: 'Kenya', KI: 'Kiribati', KP: 'Korea (North', KR: 'Korea (South', KW: 'Kuwait', KG: 'Kyrgyzstan', LA: 'Lao PDR', LV: 'Latvia', LB: 'Lebanon', LS: 'Lesotho', LR: 'Liberia', LY: 'Libya', LI: 'Liechtenstein', LT: 'Lithuania', LU: 'Luxembourg', MK: 'Macedonia, Republic of', MG: 'Madagascar', MW: 'Malawi', MY: 'Malaysia', MV: 'Maldives', ML: 'Mali', MT: 'Malta', MH: 'Marshall Islands', MQ: 'Martinique', MR: 'Mauritania', MU: 'Mauritius', YT: 'Mayotte', MX: 'Mexico', FM: 'Micronesia, Federated States of', MD: 'Moldova', MC: 'Monaco', MN: 'Mongolia', ME: 'Montenegro', MS: 'Montserrat', MA: 'Morocco', MZ: 'Mozambique', MM: 'Myanmar', NA: 'Namibia', NR: 'Nauru', NP: 'Nepal', NL: 'Netherlands', AN: 'Netherlands Antilles', NC: 'New Caledonia', NZ: 'New Zealand', NI: 'Nicaragua', NE: 'Niger', NG: 'Nigeria', NU: 'Niue', NF: 'Norfolk Island', MP: 'Northern Mariana Islands', NO: 'Norway', OM: 'Oman', PK: 'Pakistan', PW: 'Palau', PS: 'Palestinian Territory', PA: 'Panama', PG: 'Papua New Guinea', PY: 'Paraguay', PE: 'Peru', PH: 'Philippines', PN: 'Pitcairn', PL: 'Poland', PT: 'Portugal', PR: 'Puerto Rico', QA: 'Qatar', RE: 'Réunion', RO: 'Romania', RU: 'Russia', RW: 'Rwanda', BL: 'Saint-Barthélemy', SH: 'Saint Helena', KN: 'Saint Kitts and Nevis', LC: 'Saint Lucia', MF: 'Saint-Martin (French part', PM: 'Saint Pierre and Miquelon', VC: 'Saint Vincent and Grenadines', WS: 'Samoa', SM: 'San Marino', ST: 'Sao Tome and Principe', SA: 'Saudi Arabia', SN: 'Senegal', RS: 'Serbia', SC: 'Seychelles', SL: 'Sierra Leone', SG: 'Singapore', SK: 'Slovakia', SI: 'Slovenia', SB: 'Solomon Islands', SO: 'Somalia', ZA: 'South Africa', GS: 'South Georgia and the South Sandwich Islands', SS: 'South Sudan', ES: 'Spain', LK: 'Sri Lanka', SD: 'Sudan', SR: 'Suriname', SJ: 'Svalbard and Jan Mayen Islands', SZ: 'Swaziland', SE: 'Sweden', CH: 'Switzerland', SY: 'Syrian Arab Republic (Syria', TW: 'Taiwan, Republic of China', TJ: 'Tajikistan', TZ: 'Tanzania, United Republic of', TH: 'Thailand', TL: 'Timor-Leste', TG: 'Togo', TK: 'Tokelau', TO: 'Tonga', TT: 'Trinidad and Tobago', TN: 'Tunisia', TR: 'Turkey', TM: 'Turkmenistan', TC: 'Turks and Caicos Islands', TV: 'Tuvalu', UG: 'Uganda', UA: 'Ukraine', AE: 'United Arab Emirates', GB: 'United Kingdom', US: 'USA', UM: 'US Minor Outlying Islands', UY: 'Uruguay', UZ: 'Uzbekistan', VU: 'Vanuatu', VE: 'Venezuela', VN: 'Viet Nam', VI: 'Virgin Islands, US', WF: 'Wallis and Futuna Islands', EH: 'Western Sahara', YE: 'Yemen', ZM: 'Zambia', ZW: 'Zimbabwe'},
        excludeCountries: {},
        favoritesFirst: false,
        features: {
            PreGoldShow: {
                enable: false,
                status: true,
            },
            GoldShow: {
                enable: false,
                status: true,
            },
            PartyChat: {
                enable: false,
                status: true,
            },
            SpecialShow: {
                enable: false,
                status: true,
            },
            Phone: {
                enable: false,
                status: true,
            },
            InExclusiveShow: {
                enable: false,
                status: true,
            },
            OnBreak: {
                enable: false,
                status: true,
            },
        },
        genderNames: {
            f: 'Female',
            m: 'Male',
            mf: 'Male and female',
            tm2f: 'Trans MTF',
            tf2m: 'Trans FTM',
        },
        genders: {
            f: true,
            m: true,
            mf: true,
            tm2f: true,
            tf2m: true,
        },
        lastPageLoaded: 0,
        loaded: false,
        models: [],
        seen: false,
        showAgeFilter: false,
        showCountryFilter: false,
        showFeatureFilter: false,
        showGenderFilter: false,
        showSort: false,
        sorts: [
            'Age',
            'Country',
            'Nickname',
            'Rating',
        ],
        sortDirection: 'asc',
        sortName: 'Age',
    },
    created () {
        this.fetchNextPage()
        this.excludeCountries = Object.keys(this.countryCodes).reduce((filter, code) => {
            filter[code] = true
            return filter
        }, {})
    },
    mounted () {
        this.$el.style.display = ''
        console.log('hello world from vue')
    },
    methods: {
        fetchNextPage () {
            this.lastPageLoaded += 1
            console.log('loading page ' + this.lastPageLoaded)
            fetch('https://www.streamate.com/?pagenum=' + this.lastPageLoaded + '&sssjson=1')
            .then(res => res.json())
            .then(res => {
                console.log('loaded page ' + this.lastPageLoaded)
                this.loaded = !!(this.models = this.models.concat(res.Results))

                this.models.forEach(model => {
                    if (this.countryCodes[model.Country] === undefined) {
                        this.countryCodes[model.Country] = true
                        this.excludeCountries[model.Country] = model.Country
                    }
                })
            })
            .catch(error => console.error(error))
        },
        filterModels (model) {
            return this.countryFilter[model.Country]
            && model.Age >= this.ageFrom
            && model.Age <= this.ageTo
            && this.genders[model.Gender]
            && Object.keys(this.features).reduce((show, feature) => {
                if (this.features[feature].enable) {
                    return show && this.features[feature].status === model[feature]
                }
                return show
            }, true)

        },
        reload () {
            this.models = []
            this.lastPageLoaded = 0
            this.fetchNextPage()
        },
        uniqueModels (model, i, arr) {
            return arr.map(mapModel => mapModel.PerformerId).indexOf(model.PerformerId) === i
        },
        toggleCountries () {
            this.excludeCountries = Object.keys(this.excludeCountries).reduce((filter, code) => {
                filter[code] = !this.excludeCountries[code]
                return filter
            }, {})
        }
    },
    computed: {
        ages () {
            return Array.from(Array(100).keys()).filter(n => n >= 18)
        },
        filteredAndSortedModels () {
            if (this.favoritesFirst) {
                let models = this.models.filter(this.uniqueModels).filter(this.filterModels)
                return _.orderBy(
                    models.filter(model => model.Favorite),
                    this.sortName,
                    this.sortDirection
                ).concat(
                    _.orderBy(
                        models.filter(model => !model.Favorite),
                        this.sortName,
                        this.sortDirection
                    )
                )
            }
            return _.orderBy(
                this.models.filter(this.uniqueModels).filter(this.filterModels),
                this.sortName,
                this.sortDirection
            )
        },
        countryFilter () {
            return this.models
            .filter(model => !!model.Country)
            .sort((a, b) => this.countryCodes[a.Country].localeCompare(this.countryCodes[b.Country]))
            .reduce((filter, model) => {
                if (filter[model.Country] !== undefined) {
                    return filter
                }

                filter[model.Country] = this.excludeCountries[model.Country]
                return filter
            }, {})
        },
        modelIds () {
            return this.models.map(model => model.PerformerId)
        },
    },
})