Jump to content

User:DannyS712/VueNPP.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by DannyS712 (talk | contribs) at 05:36, 29 May 2022 (fix classes). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
// <nowiki>
// Script to experiment with a Vue version of Special:NewPagesFeed
// @author DannyS712
/* jshint maxerr: 999 */
$(() => {
const VueNPP = {};
window.VueNPP = VueNPP;

VueNPP.init = function () {
	mw.loader.using(
		[
			'vue',
			'@vue/composition-api',
			'wvui',
			'mediawiki.util',
			'mediawiki.api',
			'es6-polyfills',
			'moment',
			// So that messages and styles are loaded
			'ext.pageTriage.views.list'
		],
		VueNPP.run
	);
};

VueNPP.run = function () {
	const VueCompositionAPI = mw.loader.require( '@vue/composition-api' );
	// Exposed globally for simplicity
	window.VueCompositionAPI = VueCompositionAPI;
	Vue.use( VueCompositionAPI );

	const wvuiComponents = mw.loader.require( 'wvui' );
	VueNPP.listItemComponent.components = wvuiComponents;
	// Object.assign for the components that have other non-wvui components
	Object.assign( VueNPP.feedControlMenuComponent.components, wvuiComponents );
	Object.assign( VueNPP.NPPFeedMenu.components, wvuiComponents );
	VueNPP.addStyle();
	VueNPP.renderInterface();
};

/**
 * Add styles for our interface.
 */
VueNPP.addStyle = function () {
	mw.util.addCSS(`
		.mwe-pt-metadata-warning:before {
			color: initial;
			content: " · ";
		}
		.mwe-vue-pt-button-green {
		    /* From core and vector styles for green ui buttons */
		    border-color: #294 !important;
		    background: #295 !important;
		    background: linear-gradient( to bottom, #3c8 0%, #295 90%) !important;
		    border-radius: 4px;
		    box-shadow: 0 1px 3px;
		}
		.mwe-vue-pt-button-green:disabled {
		    opacity: .35;
		}
		.mwe-vue-pt-navigation-bar {
			border: 1px solid #ccc;
		}
		.mwe-vue-pt-control-gradient {
			background: #c9c9c9;
		}
		#mwe-vue-pt-list-control-nav-content {
			padding: 0.5em 1em 1em 1em;
		}
		.stickyTop #mwe-vue-pt-list-control-nav {
			position: fixed;
			top: 0;
			z-index: 1;
			box-shadow: 0 7px 10px rgba( 0, 0, 0, 0.4 );
		}
		.mwe-vue-pt-control-section {
			min-width: 200px;
			margin: 0.4em 0.4em 0 0.4em;
			z-index: 51;
		}
		.mwe-vue-pt-control-label-right {
			float: right;
		}
		.mwe-vue-pt-control-options {
			margin-left: 1em;
			margin-right: 0.5em;
			white-space: nowrap;
		}
		.mwe-vue-pt-control-buttons {
			margin: 0.2em 0 0 -0.4em;
		}
		#mwe-vue-pt-control-dropdown {
			top: 23px;
			left: 48px;
			border: 1px solid #aaa;
			padding: 0.5em 1em 0.2em 1em;
			color: #000;
			cursor: default;
			box-shadow: 0 7px 10px rgba( 0, 0, 0, 0.4 );
			width: min-content;
		}
		.mwe-vue-pt-control-section__row1 {
			display: flex;
			flex-direction: row;
		}
		#mwe-vue-pt-filter-user {
			width: 100px;
		}
		#mwe-vue-pt-sort-buttons {
			margin-right: 0.3em;
		}
		#mwe-vue-pt-radio-afc {
			margin-left: 10px;
		}
	`);
};

/**
 * Component for rendering a list item.
 */
VueNPP.listItemComponent = {
	// wvuiButton is added later, once it has been loaded
	// defaults for props are from [[FDP Hamburg]] for now
    props: {
        position: { type: Number, default: 1 },
        afdStatus: { type: Boolean, default: false },
        blpProdStatus: { type: Boolean, default: false },
        csdStatus: { type: Boolean, default: false },
        prodStatus: { type: Boolean, default: false },
        patrolStatus: { type: Number, default: 0 },
        title: { type: String, default: 'FDP Hamburg' },
        isRedirect: { type: Boolean, default: false },
        categoryCount: { type: Number, default: 3 },
        linkCount: { type: Number, default: 0 },
        referenceCount: { type: Number, default: 1 },
        recreated: { type: Boolean, default: false },
        pageLen: { type: Number, default: 3760 },
        revCount: { type: Number, default: 2 },
        creationDateUTC: { type: String, default: '20220523131514' },
        creatorName: { type: String, default: 'Wanquanbiantai' },
        creatorAutoConfirmed: { type: Boolean, default: true },
        creatorRegistrationUTC: { type: String, default: '20220317205608' },
        creatorUserId: { type: Number, default: 43566998 },
        creatorEditCount: { type: Number, default: 66 },
        creatorIsBot: { type: Boolean, default: false },
        creatorBlocked: { type: Boolean, default: false },
        creatorUserPageExists: { type: Boolean, default: true },
        creatorTalkPageExists: { type: Boolean, default: true },
        afcState: { type: Number, default: 1 },
        reviewedUpdatedUTC: { type: String, default: '20220523131514' },
        snippet: { type: String, default: 'Chair Logo Michael Kruse FDP LV Hamburg Basis data Established: September 20, 1945 Place of establishment: Hamburg Chairman: Michael Kruse Vice chairmen: Katarina BlumeRia SchröderAndreas MoringSonja Jacobsen Treasurer: Ron Schumacher Executive direct...' },
        oresArticleQuality: { type: String, default: 'Start' },
        oresDraftQuality: { type: String, default: '' },
        copyvio: { type: Number, default: 0 },
    },
    data: function () {
        return {
            showOres: true || mw.config.get( 'wgShowOresFilters' ),
            showCopyvio: true || mw.config.get( 'wgShowCopyvio' ),
            enableReviewButton: true || mw.config.get( 'wgPageTriageEnableReviewButton' ),
            draftNamespaceId: mw.config.get( 'wgPageTriageDraftNamespaceId' ),
            timeOffset: parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] )
        };
    },
    methods: {
        prettyTimestamp: function ( utcTimestamp ) {
            const parsedTimestamp = moment.utc( utcTimestamp, 'YYYYMMDDHHmmss' );
            return parsedTimestamp.utcOffset( this.timeOffset ).format(
                mw.msg( 'pagetriage-creation-dateformat' )
            );
        },
        getjQueryLink: function ( url, text, exists ) {
            // Needed to be able to embed links in the byline
            const $link = $( '<a>' );
            if ( !exists ) {
                const uri = new mw.Uri( url );
                uri.query.action = 'edit';
                uri.query.redlink = 1;
                url = uri.toString();
                $link.addClass( 'new' );
            }
            $link.attr( 'href', url );
            $link.text( text );
            return $link;
        }
    },
    computed: {
        oddEvenClass: function () { return this.position % 2 == 0 ? 'mwe-pt-article-row-even' : 'mwe-pt-article-row-odd'; },
        isDraft: function () {
        	const pageNamespaceId = ( new mw.Title( this.title ) ).getNamespaceId();
        	return pageNamespaceId === this.draftNamespaceId;
        },
        iconImageSrc: function () {
            const imageBase = mw.config.get( 'wgExtensionAssetsPath' ) + '/PageTriage/modules/ext.pageTriage.views.list/images/';
            if ( this.isDraft ) {
                return imageBase + 'icon_not_reviewed.png';
            } else if ( this.afdStatus || this.blpProdStatus || this.csdStatus || this.prodStatus ) {
                return imageBase + 'icon_marked_for_deletion.png';
            } else if ( this.patrolStatus !== 0 ) {
                return imageBase + 'icon_reviewed.png';
            } else {
                return imageBase + 'icon_not_reviewed.png';
            }
        },
        titleUrl: function () {
            const params = {};
            if ( this.isRedirect ) {
                params.redirect = 'no';
            }
            return mw.util.getUrl( this.title, params );
        },
        titleUrlFormat: function () { return mw.util.wikiUrlencode( this.title ); },
        historyUrl: function () { return mw.config.get('wgScriptPath') + '/index.php?title=' + this.titleUrlFormat + '&action=history'; },
        creationDatePretty: function () {
            return this.prettyTimestamp( this.creationDateUTC );
        },
        creatorBylineHtml: function () {
            const bylineMessage = ( this.creatorUserId > 0 && !this.creatorAutoconfirmed )
                ? 'pagetriage-byline-new-editor'
                : 'pagetriage-byline';
            const creatorUserPageUrl = mw.util.getUrl( 'User:' + this.creatorName );
            const creatorTalkPageUrl = mw.util.getUrl( 'User talk:' + this.creatorName );
            const contribsUrl = mw.util.getUrl( 'Special:Contributions/' + this.creatorName );
            return mw.message(
                bylineMessage,
                this.getjQueryLink(
                    creatorUserPageUrl,
                    this.creatorName,
                    this.creatorUserPageExists
                ),
                this.getjQueryLink(
                    creatorTalkPageUrl,
                    mw.msg( 'sp-contributions-talk' ),
                    this.creatorTalkPageExists
                ),
                mw.msg( 'pipe-separator' ),
                this.getjQueryLink(
                    contribsUrl,
                    mw.msg( 'contribslink' ),
                    true
                )
            ).parse();
            
        },
        creatorRegistrationPretty: function () {
            return this.prettyTimestamp( this.creatorRegistrationUTC );
        },
        reviewedUpdatedPretty: function () {
            return this.prettyTimestamp( this.reviewedUpdatedUTC );
        },
        reviewRightHelpText: function () {
            if ( this.enableReviewButton ) {
                return '';
            }
            return this.$i18n( 'pagetriage-no-patrol-right' );
        },
        copyvioLink: function () {
            if ( this.copyvio === 0 ) {
                // Shouldn't be used
                return '';
            }
            return 'https://tools.wmflabs.org/copypatrol/en?filter=all&searchCriteria=page_exact'
                + '&searchText=' + ( new mw.Title( this.title ) ).getMainText()
                + '&drafts=' + ( this.isDraft ? '1' : '0' )
                + '&revision=' + this.copyvio;
        }
    },
    template: `<div class="mwe-pt-article-row" :class="oddEvenClass">
	<div class="mwe-pt-status-icon">
	    <img :src="iconImageSrc" width="21" height="21" />
	</div>
	<div class="mwe-pt-info-pane">
		<div class="mwe-pt-info-row">
			<div class="mwe-pt-article">
				<span class="mwe-pt-page-title"><a :href="titleUrl" target="_blank">{{ title }}</a></span>
				<span class="mwe-pt-histlink">
					(<a :href="historyUrl">{{ $i18n( "pagetriage-hist" ) }}</a>)
				</span>
				<span class="mwe-pt-metadata">
					·
					{{ $i18n( "pagetriage-bytes", pageLen ) }}
					·
					{{ $i18n( "pagetriage-edits", revCount ) }}
					<span v-if="!isDraft">
						<span v-if="categoryCount === 0 && !isRedirect" class="mwe-pt-metadata-warning">{{ $i18n( "pagetriage-no-categories" ) }}</span>
					    <template v-if="categoryCount !== 0">
							· {{ $i18n( "pagetriage-categories", categoryCount ) }}
					    </template>
					    <span v-if="linkCount === 0 && !isRedirect" class="mwe-pt-metadata-warning">{{ $i18n("pagetriage-orphan") }}</span>
					    <span v-if="recreated" class="mwe-pt-metadata-warning">{{ $i18n("pagetriage-recreated") }}</span>
					</span>
					<span v-if="referenceCount === 0 && !isRedirect" class="mwe-pt-metadata-warning">{{ $i18n( "pagetriage-no-reference" ) }}</span>
				</span>
			</div>
			<span class="mwe-pt-creation-date">{{ creationDatePretty }}</span>
		</div>
		<div class="mwe-pt-info-row">
			<div class="mwe-pt-author">
		    <span v-if="creatorName">
		        <!-- Using v-html because the messages used embed links within them -->
		        <span v-if="creatorBylineHtml" v-html="creatorBylineHtml"></span>
		        <span v-if="creatorUserId > 0">
					·
					{{ $i18n( 'pagetriage-editcount', creatorEditCount, creatorRegistrationPretty ) }}
					<span v-if="creatorIsBot">
						·
						{{ $i18n( 'pagetriage-author-bot' ) }}
					</span>
				</span>
				<span v-if="creatorBlocked" class="mwe-pt-metadata-warning">{{ $i18n( 'pagetriage-author-blocked' ) }}</span>
			</span>
			<span v-else>
			    {{ $i18n('pagetriage-no-author') }}
			</span>
			</div>
			<div class="mwe-pt-updated-date">
			    <span v-if="lastAfcAction">
			        <span>{{ $i18n( lastAfcAction ) }}</span>
			        <span>{{ reviewedUpdatedPretty }}</span>
			    </span>
			</div>
		</div>
		<div class="mwe-pt-info-row">
			<div class="mwe-pt-snippet">{{ snippet }}</div>
			<div class="mwe-pt-review">
				<a class="mwe-pt-list-triage-button" :href="titleUrl" target="_blank" :title="reviewRightHelpText">
				    <wvui-button action="progressive" type="primary">Review</wvui-button>
				</a>
			</div>
		</div>
		<div v-if="showOres" class="mwe-pt-info-row">
			<div class="mwe-pt-predicted-class">
				<span class="mwe-pt-article-ores-predicted-class-label">
					{{ $i18n( 'pagetriage-filter-predicted-class-heading' ) }}
				</span>
				<span class="mwe-pt-article-ores-predicted-class-value">{{ oresArticleQuality }}</span>
			</div>
			<div class="mwe-pt-potential-issues">
				<span>{{ $i18n( 'pagetriage-filter-predicted-issues-heading' ) }}</span>
				<span v-if="!oresDraftQuality && !( copyvio && showCopyvio )">
					{{ $i18n( 'pagetriage-filter-stat-predicted-issues-none' ) }}
				</span>
				<span v-if="oresDraftQuality" class="mwe-pt-issue">{{ oresDraftQuality }}</span>
				<span v-if="copyvio && showCopyvio">
				<span v-if="oresDraftQuality">·</span>
				<span class="mw-parser-output mwe-pt-issue">
					<a :href="copyvioLink" target="_blank" class="external">
						{{ $i18n( 'pagetriage-filter-stat-predicted-issues-copyvio' ) }}
					</a>
				</span>
				</span>
			</div>
		</div>
	</div>
</div>`
};

/**
 * Helper for controls form, contains a specific section with a message label
 * and slot content
 */
VueNPP.controlSectionComponent = {
    props: { label: { type: String, required: true } },
    template: `
<div class="mwe-vue-pt-control-section">
    <span class="mwe-vue-pt-control-label">
        <b>{{ $i18n( label ) }}</b>
    </span>
    <div class="mwe-vue-pt-control-options">
        <slot></slot>
    </div>
</div>`
};

/**
 * Helper for controls form, contains controls for date ranges
 */
VueNPP.dateControlSectionComponent = {
    props: {
        type: { type: String, required: true },
        fromModel: { type: String, required: true },
        toModel: { type: String, required: true }
    },
    components: { controlSection: VueNPP.controlSectionComponent },
    methods: {
        updateFrom: function ( newValue ) {
            this.$emit( 'update:fromModel', newValue.target.value );
        },
        updateTo: function ( newValue ) {
            this.$emit( 'update:toModel', newValue.target.value );
        }
    },
    template: `
<control-section label="pagetriage-filter-date-range-heading">
    <label :for="'mwe-vue-pt-filter-' + type + '-date-range-from'">{{ $i18n( 'pagetriage-filter-date-range-from' ) }}</label>
	<input type="date" :value="fromModel" @input="updateFrom" :id="'mwe-vue-pt-filter-' + type + '-date-range-from'" :placeholder="$i18n( 'pagetriage-filter-date-range-format-placeholder' )" /> <br/>
	<label :for="'mwe-vue-pt-filter-' + type + '-date-range-to'">{{ $i18n( 'pagetriage-filter-date-range-to' ) }}</label>
	<input type="date" :value="toModel" @input="updateTo" :id="'mwe-vue-pt-filter-' + type + '-date-range-to'" :placeholder="$i18n( 'pagetriage-filter-date-range-format-placeholder' )" />
</control-section>`
};

/**
 * Menu for controlling the filters for the pages feed
 */
VueNPP.feedControlMenuComponent = {
    components: {
    	controlSection: VueNPP.controlSectionComponent,
    	dateControlSection: VueNPP.dateControlSectionComponent
    },
    data: function () {
        return {
            haveDraftNamespace: true || !!mw.config.get( 'wgPageTriageDraftNamespaceId' ),
            showOresFilters: true || mw.config.get( 'wgShowOresFilters' ),
            showCopyvio: true || mw.config.get( 'wgShowCopyvio' ),
            // pure data, not needed in setup()
			nppFilters: [
                'no-categories',
				'unreferenced',
				'orphan',
				'recreated',
				'non-autoconfirmed',
				'learners',
				'blocked',
				'bot-edits'
				// user specific filter, and then show all, handled individually
			],
            afcSubmissionStates: [
                'unsubmitted',
				'pending',
				'reviewing',
				'declined',
				'all'
			]
        };
    },
    setup( props ) {
        // Shortcuts
        const ref = VueCompositionAPI.ref;
        const computed = VueCompositionAPI.computed;

        const currentView = ref( 'npp' );
        //#region NPP
        const nppSortDir = ref( 'newestfirst' );
        const nppNamespace = ref( 0 );
        // Review status
        const nppIncludeUnreviewed = ref( true );
        const nppIncludeReviewed = ref( true );
        // Nominated for deletion / redirects / normal article status
        const nppIncludeNominated = ref( true );
        const nppIncludeRedirects = ref( false );
        const nppIncludeOthers = ref( true );
        // Filters for article creator or problems
        const nppFilter = ref( 'all' );
        const nppFilterUser = ref( '' );
        // Filters for rating and issues
        const nppPredictedRating = ref( {
            stub: false,
			start: false,
			c: false,
			b: false,
			good: false,
			featured: false
	    } );
        const nppPossibleIssues = ref( {
            vandalism: false,
            spam: false,
            attack: false,
            copyvio: false,
            none: false
        } );
        const nppDateFrom = ref( '' );
        const nppDateTo = ref( '' );
        //#endregion
        //#region AFC
        const afcSortDir = ref( 'newestfirst' );
        // Filter for submission state
        const afcSubmissionState = ref( 'all' );
        // if the submitted/declined sort options should be included, the end of
        // the message key to use (pagetriage-afc-(old|new)est-*), or false to
        // not include as options
        const afcSortUpdated = computed( () => {
            if ( afcSubmissionState.value === 'declined' ) {
                return 'declined';
            } else if (
                afcSubmissionState.value === 'pending'
                || afcSubmissionState.value === 'reviewing'
            ) {
                return 'submitted';
            } else {
                return false;
            }
        } );
        // Make sure that afcSortDir isn't invalid
        VueCompositionAPI.watch(
            afcSubmissionState,
            ( newState ) => {
                if ( newState !== 'unsubmitted' && newState !== 'all' ) {
                    // oldest/newest submitted/declined are valid
                    return;
                }
                if ( afcSortDir.value === 'newestreview' ) {
                    afcSortDir.value = 'newestfirst';
                } else if ( afcSortDir.value === 'oldestreview' ) {
                    afcSortDir.value = 'oldestfirst';
                }
            }
        );
        // convert to integer used by api
        const afcSubmissionStateApi = computed( () => {
            // PageTriage extension uses literal 'all' which breaks things,
            // use `false` so that mw.Api() filters it out, T304574
            const submissionNumbers = [ '~invalid~', 'unsubmitted', 'pending', 'reviewing', 'declined' ];
            const stateIndex = submissionNumbers.indexOf( afcSubmissionState.value );
            return ( stateIndex <= 0 ? false : stateIndex.toString() );
        } );
        // Filters for rating and issues
        const afcPredictedRating = ref( {
            stub: false,
			start: false,
			c: false,
			b: false,
			good: false,
			featured: false
	    } );
        const afcPossibleIssues = ref( {
            vandalism: false,
            spam: false,
            attack: false,
            copyvio: false,
            none: false
        } );
        const afcDateFrom = ref( '' );
        const afcDateTo = ref( '' );
        //#endregion
        // Need to include at least one of reviewed/unreviewed, and at least
        // one of nominated for deletion/redirects/normal articles
        const canSaveSettings = computed( () => {
            return (
                ( nppIncludeUnreviewed.value || nppIncludeReviewed.value )
                && (
                    nppIncludeNominated.value
                    || nppIncludeRedirects.value
                    || nppIncludeOthers.value
                )
            );
        } );
        const addOresFilters = function ( paramsObj, optionsRef, paramPrefix ) {
            for ( var optionName in optionsRef.value ) {
                if ( optionsRef.value[ optionName ] ) {
                    paramsObj[ paramPrefix + optionName ] = '1';
                }
            }
        };
        const addIfToggled = function ( paramsObj, paramName, optionRef ) {
            if ( optionRef.value ) {
                paramsObj[ paramName ] = '1';
            }
        };
        const addNppFilter = function ( paramsObj ) {
            const filtersToParams = {
                'no-categories': 'no_category',
				'unreferenced': 'unreferenced',
				'orphan': 'no_inbound_links',
				'recreated': 'recreated',
				'non-autoconfirmed': 'non_autoconfirmed_users',
				'learners': 'learners',
				'blocked': 'blocked_users',
				'bot-edits': 'showbots'
            };
            const chosenFilter = nppFilter.value;
            if ( nppFilter.value === 'username' && nppFilterUser.value ) {
                paramsObj.username = nppFilterUser.value;
                // if username is chosen with no filter, or 'all'
            } else if ( filtersToParams[ chosenFilter ] !== undefined ) {
                paramsObj[ filtersToParams[ chosenFilter ] ] = '1';
            }
        };
        const offset = parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] );
        const addDateParams = function ( paramsObj, fromRef, toRef ) {
            if ( fromRef.value ) {
                const fromDate = moment.utc( fromRef.value ).subtract( offset, 'minutes' );
                paramsObj.date_range_from = fromDate.toISOString();
            }
            if ( toRef.value ) {
                let toDate = moment.utc( toRef.value ).subtract( offset, 'minutes' );
                // end of the day
                toDate.add( 1, 'day' ).subtract( 1, 'second' );
                paramsObj.date_range_to = toDate.toISOString();
            }
        };
        const apiOptions = computed( () => {
            const params = {
                mode: currentView.value,
                nppDir: nppSortDir.value,
                afcDir: afcSortDir.value,
                limit: 20
            };
            if ( currentView.value === 'npp' ) {
                addIfToggled( params, 'showreviewed', nppIncludeReviewed );
                addIfToggled( params, 'showunreviewed', nppIncludeUnreviewed );
                addIfToggled( params, 'showdeleted', nppIncludeNominated );
                addIfToggled( params, 'showredirs', nppIncludeRedirects );
                addIfToggled( params, 'showothers', nppIncludeOthers );
                addNppFilter( params );
                addOresFilters( params, nppPredictedRating, 'show_predicted_class_' );
                addOresFilters( params, nppPossibleIssues, 'show_predicted_issues_' );
                params.namespace = nppNamespace.value;
                params.dir = params.nppDir;
                addDateParams( params, nppDateFrom, nppDateTo );
            } else {
                addOresFilters( params, afcPredictedRating, 'show_predicted_class_' );
                addOresFilters( params, afcPossibleIssues, 'show_predicted_issues_' );
                params.showreviewed = '1';
                params.showunreviewed = '1';
                params.namespace = 118 || mw.config.get( 'wgNamespaceIds' ).draft;
                params.dir = params.afcDir;
                if ( afcSubmissionStateApi.value !== false ) {
                    params.afc_state = afcSubmissionStateApi.value;
                }
                addDateParams( params, afcDateFrom, afcDateTo );
            }
            return params;
        } );
        return {
            currentView,
            apiOptions,
            // NPP
            nppSortDir,
            nppNamespace,
            nppIncludeUnreviewed, nppIncludeReviewed,
            nppIncludeNominated, nppIncludeRedirects, nppIncludeOthers,
            nppFilter, nppFilterUser,
            nppPredictedRating, nppPossibleIssues,
            nppDateFrom, nppDateTo,
            // AFC
            afcSortDir, afcSortUpdated,
            afcSubmissionState,
            afcPredictedRating, afcPossibleIssues,
            afcDateFrom, afcDateTo,
            canSaveSettings
        };
    },
    template: `<div>
    {{ apiOptions }}
<p v-if="haveDraftNamespace">
	<input type="radio" id="mwe-vue-pt-radio-npp" value="npp" v-model="currentView"/>
	<label for="mwe-vue-pt-radio-npp">{{ $i18n( 'pagetriage-new-page-patrol' ) }}</label>
	<input type="radio" id="mwe-vue-pt-radio-afc" value="afc" v-model="currentView"/>
	<label for="mwe-vue-pt-radio-afc">{{ $i18n( 'pagetriage-articles-for-creation' ) }}</label>
</p>
<span class="mwe-vue-pt-control-label">
	<b>{{ $i18n( 'pagetriage-showing' ) }} </b>
	<span id="mwe-vue-pt-filter-status"></span>
</span>
<span class="mwe-vue-pt-control-label-right" id="mwe-vue-pt-control-stats"></span><br/>
<span v-show="currentView === 'npp'" class="mwe-vue-pt-control-label-right"><b>{{ $i18n( 'pagetriage-sort-by' ) }} </b>
	<span id="mwe-vue-pt-sort-buttons">
		<input type="radio" id="mwe-vue-pt-sort-newest" value="newestfirst" v-model="nppSortDir"/>
		<label for="mwe-vue-pt-sort-newest">{{ $i18n( 'pagetriage-newest' ) }}</label>
		<input type="radio" id="mwe-vue-pt-sort-oldest" value="oldestfirst" v-model="nppSortDir"/>
		<label for="mwe-vue-pt-sort-oldest">{{ $i18n( 'pagetriage-oldest' ) }}</label>
	</span>
</span>
<span v-show="currentView === 'afc'" class="mwe-vue-pt-control-label-right">
	<label for="mwe-vue-pt-sort-afc">
		<b>{{ $i18n( 'pagetriage-sort-by' ) }}</b>
	</label>
	<select v-model="afcSortDir" id="mwe-vue-pt-sort-afc">
		<option value="newestfirst">{{ $i18n( 'pagetriage-afc-newest' ) }}</option>
		<option value="oldestfirst">{{ $i18n( 'pagetriage-afc-oldest' ) }}</option>
		<!--
		    'newestreview' and 'oldestreview' are used for both newest/oldest submitted and newest/oldest declined,
		    PageTriage adds one or the other, we just change the label - only shown when filtering for submitted, under review, or declined
		-->
		<option v-if="afcSortUpdated" value="newestreview">{{ $i18n( 'pagetriage-afc-newest-' + afcSortUpdated ) }}</option>
		<option v-if="afcSortUpdated" value="oldestreview">{{ $i18n( 'pagetriage-afc-oldest-' + afcSortUpdated ) }}</option>
	</select>
</span>
<div id="mwe-vue-pt-control-dropdown" class="mwe-vue-pt-control-gradient">
	<div v-show="currentView === 'npp'" class="mwe-vue-pt-control-section__npp">
		<div class="mwe-vue-pt-control-section__row1">
			<div class="mwe-vue-pt-control-section__col1">
			    <control-section label="pagetriage-filter-namespace-heading">
			        <select v-model="nppNamespace">
			            <option value="0">Article</option>
			            <option value="2">User</option>
			        </select>
			    </control-section>
			    <control-section label="pagetriage-filter-show-heading">
					<input type="checkbox" id="mwe-vue-pt-filter-unreviewed-edits" v-model="nppIncludeUnreviewed" />
					<label for="mwe-vue-pt-filter-unreviewed-edits">{{ $i18n( 'pagetriage-filter-unreviewed-edits' ) }}</label> <br/>
					<input type="checkbox" id="mwe-vue-pt-filter-reviewed-edits" v-model="nppIncludeReviewed" />
					<label for="mwe-vue-pt-filter-reviewed-edits">{{ $i18n( 'pagetriage-filter-reviewed-edits' ) }}</label> <br/>
			    </control-section>
			    <control-section label="pagetriage-filter-type-show-heading">
					<input type="checkbox" id="mwe-vue-pt-filter-nominated-for-deletion" v-model="nppIncludeNominated" />
					<label for="mwe-vue-pt-filter-nominated-for-deletion">{{ $i18n( 'pagetriage-filter-nominated-for-deletion' ) }}</label> <br/>
					<input type="checkbox" id="mwe-vue-pt-filter-redirects" v-model="nppIncludeRedirects" />
					<label for="mwe-vue-pt-filter-redirects">{{ $i18n( 'pagetriage-filter-redirects' ) }}</label> <br/>
					<input type="checkbox" id="mwe-vue-pt-filter-others" v-model="nppIncludeOthers" />
					<label for="mwe-vue-pt-filter-others">{{ $i18n( 'pagetriage-filter-others' ) }}</label> <br/>
			    </control-section>
			</div>
			<template v-if="showOresFilters">
				<div class="mwe-vue-pt-control-section__col2">
				    <control-section label="pagetriage-filter-predicted-class-heading">
						<template v-for="(_, ratingName) in nppPredictedRating" :key="ratingName">
						    <input type="checkbox" :id="'mwe-vue-pt-filter-npp-predicted-class-' + ratingName" v-model="nppPredictedRating[ ratingName ]" />
						    <label :for="'mwe-vue-pt-filter-npp-predicted-class-' + ratingName">{{ $i18n( 'pagetriage-filter-predicted-class-' + ratingName ) }}</label> <br/>
						</template>
				    </control-section>
				</div>
				<div class="mwe-vue-pt-control-section__col3">
				    <control-section label="pagetriage-filter-predicted-issues-heading">
						<template v-for="(_, issueName) in nppPossibleIssues" :key="issueName">
		    				<input type="checkbox" :id="'mwe-vue-pt-filter-npp-predicted-issues-' + issueName" v-model="nppPossibleIssues[ issueName ]" />
    						<label :for="'mwe-vue-pt-filter-npp-predicted-issues-' + issueName">{{ $i18n( 'pagetriage-filter-predicted-issues-' + issueName ) }}</label> <br/>
						</template>
				    </control-section>
				    <date-control-section type="npp" v-model:fromModel="nppDateFrom" v-model:toModel="nppDateTo"></date-control-section>
				</div>
			</template>
			<template v-else>
				<div class="mwe-vue-pt-control-section__col2">
				    <date-control-section type="npp" v-model:fromModel="nppDateFrom" v-model:toModel="nppDateTo"></date-control-section>
				</div>
			</template>
		</div>
		<control-section label="pagetriage-filter-second-show-heading">
		    <template v-for="filter in nppFilters" :key="filter">
			    <input type="radio" name="mwe-vue-pt-filter-radio" :id="'mwe-vue-pt-filter-' + filter" :value="filter" v-model="nppFilter"/>
			    <label :for="'mwe-vue-pt-filter-' + filter">{{ $i18n( 'pagetriage-filter-' + filter ) }}</label> <br/>
		    </template>
			<input type="radio" name="mwe-vue-pt-filter-radio" id="mwe-vue-pt-filter-user-selected" value="username" v-model="nppFilter"/>
			<label for="mwe-vue-pt-filter-user-selected">{{ $i18n( 'pagetriage-filter-user-heading' ) }}</label>
			<input type="text" id="mwe-vue-pt-filter-user" :placeholder="$i18n( 'pagetriage-filter-username' )" v-model="nppFilterUser"/> <br/>
			<input type="radio" name="mwe-vue-pt-filter-radio" id="mwe-vue-pt-filter-all" value="all" v-model="nppFilter"/>
			<label for="mwe-vue-pt-filter-all">{{ $i18n( 'pagetriage-filter-all' ) }}</label>
		</control-section>
	</div>
	<div v-show="currentView === 'afc'" class="mwe-vue-pt-control-section__afc">
		<div class="mwe-vue-pt-control-section__row1">
			<div class="mwe-vue-pt-control-section__col1">
			    <control-section label="pagetriage-filter-show-heading">
				    <template v-for="state in afcSubmissionStates" :key="state">
				        <input type="radio" name="mwe-vue-pt-filter-afc-radio" :id="'mwe-vue-pt-filter-afc-' + state" :value="state" v-model="afcSubmissionState" />
						<label :for="'mwe-vue-pt-filter-afc-' + state"> {{ $i18n( 'pagetriage-afc-state-' + state ) }} </label> <br/>
				    </template>
			    </control-section>
				<template v-if="showOresFilters">
				    <date-control-section type="afc" v-model:fromModel="afcDateFrom" v-model:toModel="afcDateTo"></date-control-section>
				</template>
			</div>
			<template v-if="showOresFilters">
				<div class="mwe-vue-pt-control-section__col2">
				    <control-section label="pagetriage-filter-predicted-class-heading">
					    <template v-for="(_, ratingName) in afcPredictedRating" :key="ratingName">
						    <input type="checkbox" :id="'mwe-vue-pt-filter-afc-predicted-class-' + ratingName" v-model="afcPredictedRating[ ratingName ]" />
						    <label :for="'mwe-vue-pt-filter-afc-predicted-class-' + ratingName">{{ $i18n( 'pagetriage-filter-predicted-class-' + ratingName ) }}</label> <br/>
					    </template>
				    </control-section>
				</div>
				<div class="mwe-vue-pt-control-section__col3">
				    <control-section label="pagetriage-filter-predicted-issues-heading">
					    <template v-for="(_, issueName) in afcPossibleIssues" :key="issueName">
	    					<input type="checkbox" :id="'mwe-vue-pt-filter-afc-predicted-issues-' + issueName" v-model="afcPossibleIssues[ issueName ]" />
    							<label :for="'mwe-vue-pt-filter-afc-predicted-issues-' + issueName">{{ $i18n( 'pagetriage-filter-predicted-issues-' + issueName ) }}</label> <br/>
					    </template>
				    </control-section>
				</div>
			</template>
			<template v-else>
				<div class="mwe-vue-pt-control-section__col2">
				    <date-control-section type="afc" v-model:fromModel="afcDateFrom" v-model:toModel="afcDateTo"></date-control-section>
				</div>	
			</template>
		</div>
	</div>
	
	<div class="mwe-vue-pt-control-buttons">
	    <wvui-button id="mwe-vue-pt-filter-set-button" class="mwe-vue-pt-button-green" action="progressive" type="primary" :disabled="!canSaveSettings">{{ $i18n( 'pagetriage-filter-set-button' ) }}</wvui-button>
	</div>
</div>
</div>`
};

/**
 * Interface for user to choose an article
 */
VueNPP.NPPFeedMenu = {
	// wvui components are added separately
	components: {
		listItem: VueNPP.listItemComponent,
		feedControlMenu: VueNPP.feedControlMenuComponent
	},
    data: function () {
        return {
        	// Default example is [[FDP Hamburg]] for now
            targetPageId: 70853005,
            api: new mw.Api(),
            apiError: false,
            showListItem: false,
            listItemProps: {}
        };
    },
	methods: {
		updatePageId: function ( newPageId ) {
		    this.targetPageId = newPageId;
		},
		updateEntry: function () {
		    console.log( this.targetPageId );
		    this.apiError = false;
		    this.showListItem = false;
		    this.listItemProps = {};
		    this.api.get( {
		        action: 'pagetriagelist',
		        page_id: this.targetPageId,
		        format: 'json',
		        formatversion: 2
		    } ).then(
		        ( res ) => this.processEntry( res ),
		        ( res ) => this.onApiFailure( res )
		    );
		},
		onApiFailure: function ( res ) {
			console.log( res );
			this.apiError = true;
		},
		processEntry: function ( res ) {
			console.log( res );
			if ( !res || !res.pagetriagelist || !res.pagetriagelist.pages
				|| !res.pagetriagelist.pages[0]
			) {
				this.onApiFailure( res );
				return;
			}
			const pageInfo = res.pagetriagelist.pages[0];
			// Properties for <list-item>
			const listItemProps = {};
			listItemProps.position = 1;
			listItemProps.afdStatus = pageInfo.afd_status === '1';
			listItemProps.blpProdStatus = pageInfo.blp_prod_status === '1';
			listItemProps.csdStatus = pageInfo.csd_status === '1';
			listItemProps.prodStatus = pageInfo.prod_status === '1';
			listItemProps.patrolStatus = parseInt( pageInfo.patrol_status );
			listItemProps.title = pageInfo.title;
			listItemProps.isRedirect = pageInfo.is_redirect === '1';
			listItemProps.categoryCount = parseInt( pageInfo.category_count );
			listItemProps.linkCount = parseInt( pageInfo.linkcount );
			listItemProps.referenceCount = parseInt( pageInfo.reference );
			listItemProps.recreated = !!pageInfo.recreated;
			listItemProps.pageLen = parseInt( pageInfo.page_len );
			listItemProps.revCount = parseInt( pageInfo.rev_count );
			listItemProps.creationDateUTC = pageInfo.creation_date_utc;
			listItemProps.creatorName = pageInfo.user_name;
			listItemProps.creatorAutoConfirmed = pageInfo.user_autoconfirmed === '1';
			listItemProps.creatorRegistrationUTC = pageInfo.user_creation_date;
			listItemProps.creatorUserId = parseInt( pageInfo.user_id );
			listItemProps.creatorEditCount = parseInt( pageInfo.user_editcount );
			listItemProps.creatorIsBot = pageInfo.user_bot === '1';
			listItemProps.creatorBlocked = pageInfo.user_block_status === '1';
			listItemProps.creatorUserPageExists = pageInfo.creator_user_page_exist;
			listItemProps.creatorTalkPageExists = pageInfo.creator_user_talk_page_exist;
			listItemProps.afcState = parseInt( pageInfo.afc_state );
			listItemProps.reviewedUpdatedUTC = pageInfo.ptrp_reviewed_updated;
			listItemProps.snippet = pageInfo.snippet;
			listItemProps.oresArticleQuality = pageInfo.ores_articlequality;
			listItemProps.oresDraftQuality = pageInfo.ores_draftquality;
			listItemProps.copyvio = !!pageInfo.copyvio;
			this.listItemProps = listItemProps;

			this.showListItem = true;
		}
	},
	template: `<div>
Feed controls:
<feed-control-menu></feed-control-menu>

<br>
Specific page entry, by page id: <wvui-input :value="targetPageId" v-on:input="updatePageId"></wvui-input>
<br>
<wvui-button action="progressive" type="primary" v-on:click="updateEntry">View entry</wvui-button>
<p v-show="apiError">Api error, see console</p>
<br>
<list-item v-show="showListItem" v-bind="listItemProps"></list-item>
</div>`
};

/**
 * Render VueNPP interface
 */
VueNPP.renderInterface = function () {
	Vue.createMwApp( VueNPP.NPPFeedMenu )
		.mount( '#mw-content-text' );
};

});

$( document ).ready( () => {
	if (
		mw.config.get( 'wgPageName' ) === 'Special:BlankPage/VueNPP'
	) {
		window.VueNPP.init();
	}
});

// </nowiki>