Jump to content

User:DannyS712/VueNPP.js

From Wikipedia, the free encyclopedia
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, esversion: 9, esnext: false */
$(() => {
const VueNPP = {};
window.VueNPP = VueNPP;

VueNPP.init = function () {
	mw.loader.using(
		[
			'vue',
			'@vue/composition-api',
			'wvui',
			'mediawiki.util',
			'mediawiki.api',
			'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;
	VueNPP.loadMoreBarComponent.components = wvuiComponents;
	// Object.assign for the components that have other non-wvui components
	Object.assign( VueNPP.feedControlMenuComponent.components, wvuiComponents );
	Object.assign( VueNPP.feedContentsComponent.components, wvuiComponents );
	VueNPP.addStyle();
	VueNPP.renderInterface();
};

/**
 * Add styles for our interface.
 */
VueNPP.addStyle = function () {
	mw.util.addCSS(`
.mwe-vue-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-menu-heading {
	padding: 0.5em 1em 1em 1em;
	position: sticky;
	top: 0;
	z-index: 10;
	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,
#mwe-vue-pt-refresh-button {
	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 {
	position: absolute;
	z-index: 50;
	border: 1px solid #aaa;
	padding: 0.5em 1em 0.2em 1em;
	margin-left: 48px;
	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;
}
#mwe-vue-pt-control-menu-toggle {
	color: #0645ad;
	cursor: pointer;
}
#mwe-vue-pt-feed-load-more {
	text-align: center;
	font-size: 17px;
	background-color: #e8f2f8;
	margin: 0;
	padding: 0.4em;
	border: 1px solid #ccc;
	border-top: 0;
}
#mwe-vue-pt-feed-load-more .wvui-progress-bar {
    margin: auto;
    /* Override WVUI styles to make a quieter version */
    background-color: inherit;
    border: none;
}

.mwe-vue-pt-article-row-even {
	background-color: #f1f1f1;
}
.mwe-vue-pt-article-row-odd {
	background-color: #fff;
}
.mwe-vue-pt-info-pane {
	padding: 0.5em 0.6em 0.6em 2.7em;
	min-height: 4.8em;
	display: table;
	box-sizing: border-box;
	width: 100%;
}
.mwe-vue-pt-info-row {
	display: table-row;
	vertical-align: top;
}
.mwe-vue-pt-info-row > div {
    display: table-cell;
}
.mwe-vue-pt-status-icon {
	position: absolute;
	top: 5px;
	left: 5px;
}
.mwe-vue-pt-article-row {
	position: relative;
	border: 1px solid #ccc;
	border-top: 0;
}
/* info about the article */
.mwe-vue-pt-article {
	font-size: 1.1em;
	line-height: 1.6em;
}
.mwe-vue-pt-bold {
    font-weight: bold;
}
.mwe-vue-pt-metadata-warning,
.mwe-vue-pt-issue {
	color: #c00;
	font-weight: bold;
}
/* Info on the right hand side: creation date, updated date, potential isues, etc. */
.mwe-vue-pt-article-col-right {
	text-align: right;
	white-space: nowrap;
}
/* the article snippet */
.mwe-vue-pt-snippet {
	color: #808080;
	padding-right: 1em;
	vertical-align: top;
}

/* Navigation bar at the bottom */
#mwe-vue-pt-stats-navigation {
	min-height: 50px;
	border-top: 1px solid #ccc;
	position: sticky;
	bottom: 0;
	z-index: 1;
	box-shadow: 0 -7px 10px rgba( 0, 0, 0, 0.4 );
}
#mwe-vue-pt-stats-navigation-content {
	padding: 0.5em 1em;
}
	`);
};

//const VueNPP = {};

//#region devCode
/**
 * 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-vue-pt-article-row-even' : 'mwe-vue-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 );
        },
        lastAfcActionLabel: function () {
            if ( this.afcState === 2 ) {
                return 'pagetriage-afc-date-label-submission';
            } else if ( this.afcState === 3 ) {
                return 'pagetriage-afc-date-label-review';
            } else if ( this.afcState === 4 ) {
                return 'pagetriage-afc-date-label-declined';
            }
            return '';
        },
        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-vue-pt-article-row" :class="oddEvenClass">
	<div class="mwe-vue-pt-status-icon">
	    <img :src="iconImageSrc" width="21" height="21" />
	</div>
	<div class="mwe-vue-pt-info-pane">
		<div class="mwe-vue-pt-info-row">
			<div class="mwe-vue-pt-article">
				<span class="mwe-vue-pt-bold"><a :href="titleUrl" target="_blank">{{ title }}</a></span>
				<span>
					(<a :href="historyUrl">{{ $i18n( "pagetriage-hist" ) }}</a>)
				</span>
				<span>
					·
					{{ $i18n( "pagetriage-bytes", pageLen ) }}
					·
					{{ $i18n( "pagetriage-edits", revCount ) }}
					<span v-if="!isDraft">
						<span v-if="categoryCount === 0 && !isRedirect" class="mwe-vue-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-vue-pt-metadata-warning">{{ $i18n("pagetriage-orphan") }}</span>
					    <span v-if="recreated" class="mwe-vue-pt-metadata-warning">{{ $i18n("pagetriage-recreated") }}</span>
					</span>
					<span v-if="referenceCount === 0 && !isRedirect" class="mwe-vue-pt-metadata-warning">{{ $i18n( "pagetriage-no-reference" ) }}</span>
				</span>
			</div>
			<div class="mwe-vue-pt-article-col-right mwe-vue-pt-bold">{{ creationDatePretty }}</div>
		</div>
		<div class="mwe-vue-pt-info-row">
			<div>
		    <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-vue-pt-metadata-warning">{{ $i18n( 'pagetriage-author-blocked' ) }}</span>
			</span>
			<span v-else>
			    {{ $i18n('pagetriage-no-author') }}
			</span>
			</div>
			<div class="mwe-vue-pt-article-col-right">
			    <span v-if="lastAfcActionLabel">
			        <span>{{ $i18n( lastAfcActionLabel ) }}</span>
			        <span>{{ reviewedUpdatedPretty }}</span>
			    </span>
			</div>
		</div>
		<div class="mwe-vue-pt-info-row">
			<div class="mwe-vue-pt-snippet">{{ snippet }}</div>
			<div class="mwe-vue-pt-article-col-right">
				<a :href="titleUrl" target="_blank" :title="reviewRightHelpText">
				    <wvui-button action="progressive" type="primary">Review</wvui-button>
				</a>
			</div>
		</div>
		<div v-if="showOres" class="mwe-vue-pt-info-row">
			<div>
				<span>{{ $i18n( 'pagetriage-filter-predicted-class-heading' ) }}</span>
				<span>{{ oresArticleQuality }}</span>
			</div>
			<div class="mwe-vue-pt-article-col-right">
				<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-vue-pt-issue">{{ oresDraftQuality }}</span>
				<span v-if="copyvio && showCopyvio">
				<span v-if="oresDraftQuality">·</span>
				<span class="mw-parser-output mwe-vue-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>`
};

VueNPP.lastGeneratedIdNum = 0;
VueNPP.labeledInputComponent = {
    // For some reason things break if I try to just use v-model, though v-model:model-value
    // works (in the places where this component is used), but since it needs to be named
    // anyway its called input-model to make it clear that its used for the <input>
    props: {
        // id is used to associated input with the <label> via `for`, if not
        // provided auto generate one
        inputId: {
            type: String,
            default: () => `mwe-vue-pt-generated-${++VueNPP.lastGeneratedIdNum}`
        },
        inputModel: { type: [ String, Boolean ], required: true },
        labelMsg: { type: String, required: true },
        type: { type: String, required: true },
        // only needed for radios, not checkboxes
        value: { type: String, default: '' },
        noBreak: { type: Boolean, default: false }
    },
    emits: [ 'update:inputModel' ],
    setup( props, { emit } ) {
        const isChecked = VueCompositionAPI.computed( () => {
            if ( props.type === 'radio' ) {
                return ( props.inputModel === props.value );
            } else if ( props.type === 'checkbox' ) {
                return ( props.inputModel === true );
            } else {
                return false;
            }
        } );
        const onChange = function ( event ) {
            const newValue = ( props.type === 'radio' ? event.target.value : event.target.checked );
            emit( 'update:inputModel', newValue );
        };
        const haveBreak = VueCompositionAPI.computed( () => !props.noBreak );
        return { isChecked, onChange, haveBreak };
    },
    template: `
<input :type="type" :id="inputId" :value="value" :checked="isChecked" @change="onChange" />
<label :for="inputId">{{ $i18n( labelMsg ) }}</label> <br v-if="haveBreak" />
`
};

/**
 * Convert afc submission state name to api value
 * PageTriage extension uses literal 'all' with breaks things, use `false` so
 * that mw.Api() filters it out, T304574
 */
VueNPP.getAfcStateForApi = function ( stateName ) {
    const submissionNumbers = [ '~invalid~', 'unsubmitted', 'pending', 'reviewing', 'declined' ];
    const stateIndex = submissionNumbers.indexOf( stateName );
    return ( stateIndex <= 0 ? false : stateIndex.toString() );
};
/**
 * Menu for controlling the filters for the pages feed
 */
VueNPP.feedControlMenuComponent = {
    components: {
    	controlSection: VueNPP.controlSectionComponent,
    	dateControlSection: VueNPP.dateControlSectionComponent,
    	labeledInput: VueNPP.labeledInputComponent
    },
    props: {
        currentlyShowingText: { type: String, default: 'currentlyShowingText-value' },
        currentFilteredCount: { type: Number, default: -1 },
        // Some form elements, sorting direction and which view we are in, trigger
        // updates to the feed immediately, others need to be submitted. Regardless,
        // we initialize the references with the current prop value, and then either
        // when the property changes or the menu is submitted, we emit the overall
        // updated object
        startOptions: {
            type: Object,
            default: () => ( {
                currentView: 'npp',
                nppSortDir: 'newestfirst',
                nppNamespace: 0,
                nppIncludeUnreviewed: true,
                nppIncludeReviewed: true,
                nppIncludeNominated: true,
                nppIncludeRedirects: false,
                nppIncludeOthers: true,
				nppFilter: 'all',
				nppFilterUser: '',
				nppPredictedRating: {
                    stub: false,
                    start: false,
                    c: false,
                    b: false,
                    good: false,
                    featured: false
				},
				nppPossibleIssues: {
				    vandalism: false,
                    spam: false,
                    attack: false,
                    copyvio: false,
                    none: false
				},
				nppDateFrom: '',
				nppDateTo: '',
				afcSortDir: 'newestfirst',
				afcSubmissionState: 'all',
				afcPredictedRating: {
                    stub: false,
                    start: false,
                    c: false,
                    b: false,
                    good: false,
                    featured: false
				},
				afcPossibleIssues: {
				    vandalism: false,
                    spam: false,
                    attack: false,
                    copyvio: false,
                    none: false
				},
				afcDateFrom: '',
				afcDateTo: '',
            } )
        }
    },
    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, { emit } ) {
        // Shortcuts
        const ref = VueCompositionAPI.ref;
        const computed = VueCompositionAPI.computed;
        const watch = VueCompositionAPI.watch;

        //#region startValues
        const currentView = ref( props.startOptions.currentView );
        const nppSortDir = ref( props.startOptions.nppSortDir );
        const nppNamespace = ref( props.startOptions.nppNamespace );
        const nppIncludeUnreviewed = ref( props.startOptions.nppIncludeUnreviewed );
        const nppIncludeReviewed = ref( props.startOptions.nppIncludeReviewed );
        const nppIncludeNominated = ref( props.startOptions.nppIncludeNominated );
        const nppIncludeRedirects = ref( props.startOptions.nppIncludeRedirects );
        const nppIncludeOthers = ref( props.startOptions.nppIncludeOthers );
        const nppFilter = ref( props.startOptions.nppFilter );
        const nppFilterUser = ref( props.startOptions.nppFilterUser );
        const nppPredictedRating = ref( { ...props.startOptions.nppPredictedRating } );
        const nppPossibleIssues = ref( { ...props.startOptions.nppPossibleIssues } );
        const nppDateFrom = ref( props.startOptions.nppDateFrom );
        const nppDateTo = ref( props.startOptions.nppDateTo );
        const afcSortDir = ref( props.startOptions.afcSortDir );
        const afcSubmissionState = ref( props.startOptions.afcSubmissionState );
        const afcPredictedRating = ref( { ...props.startOptions.afcPredictedRating } );
        const afcPossibleIssues = ref( { ...props.startOptions.afcPossibleIssues } );
        const afcDateFrom = ref( props.startOptions.afcDateFrom );
        const afcDateTo = ref( props.startOptions.afcDateTo );
        //#endregion
        // 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';
            }
            return false;
        } );
        // Make sure that afcSortDir isn't invalid
        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';
                }
            }
        );
        // 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
                )
            );
        } );
        // Whether the control menu is even shown at all
        const controlMenuOpen = ref( false );
        const doSaveSettings = function () {
            // need to convert the objects to raw (ores filters)
            const toRaw = VueCompositionAPI.toRaw;
            const settings = {
        		currentView: currentView.value,
        		nppSortDir: nppSortDir.value,
        		nppNamespace: nppNamespace.value,
        		nppIncludeUnreviewed: nppIncludeUnreviewed.value,
        		nppIncludeReviewed: nppIncludeReviewed.value,
        		nppIncludeNominated: nppIncludeNominated.value,
        		nppIncludeRedirects: nppIncludeRedirects.value,
        		nppIncludeOthers: nppIncludeOthers.value,
        		nppFilter: nppFilter.value,
        		nppFilterUser: nppFilterUser.value,
        		nppPredictedRating: toRaw( nppPredictedRating.value ),
        		nppPossibleIssues: toRaw( nppPossibleIssues.value ),
        		nppDateFrom: nppDateFrom.value,
        		nppDateTo: nppDateTo.value,
        		afcSortDir: afcSortDir.value,
        		afcSubmissionState: afcSubmissionState.value,
        		afcPredictedRating: toRaw( afcPredictedRating.value ),
        		afcPossibleIssues: toRaw( afcPossibleIssues.value ),
        		afcDateFrom: afcDateFrom.value,
        		afcDateTo: afcDateTo.value,
            };
            emit( 'update-settings', settings );
            // manually hide, next time that its opened the start options will
            // be updated
            controlMenuOpen.value = false;
        };

        const toggleControlMenuIndicator = computed(
            () => ( controlMenuOpen.value ? '▾' : '▸' )
        );
        // On open, restore the start options to account for any prior changes,
        // on close, restore them because the current changes are being discarded
        const toggleControlMenu = () => {
            // note that when closing due to an immediatelly handled change
            // (view or sort direction) this method is not called, but rather
            // the open status is changed manually, which is why start options
            // are also restored on open
            reapplyStartOptions();
            controlMenuOpen.value = !controlMenuOpen.value;
        };
        // don't trigger watchers when these are reapplied
        const currentlyInReset = ref( false );
        const reapplyStartOptions = () => {
            currentlyInReset.value = true;
        	currentView.value = props.startOptions.currentView;
        	nppSortDir.value = props.startOptions.nppSortDir;
        	nppNamespace.value = props.startOptions.nppNamespace;
        	nppIncludeUnreviewed.value = props.startOptions.nppIncludeUnreviewed;
        	nppIncludeReviewed.value = props.startOptions.nppIncludeReviewed;
        	nppIncludeNominated.value = props.startOptions.nppIncludeNominated;
        	nppIncludeRedirects.value = props.startOptions.nppIncludeRedirects;
        	nppIncludeOthers.value = props.startOptions.nppIncludeOthers;
        	nppFilter.value = props.startOptions.nppFilter;
        	nppFilterUser.value = props.startOptions.nppFilterUser;
        	nppPredictedRating.value = { ...props.startOptions.nppPredictedRating };
        	nppPossibleIssues.value = { ...props.startOptions.nppPossibleIssues };
        	nppDateFrom.value = props.startOptions.nppDateFrom;
        	nppDateTo.value = props.startOptions.nppDateTo;
        	afcSortDir.value = props.startOptions.afcSortDir;
        	afcSubmissionState.value = props.startOptions.afcSubmissionState;
        	afcPredictedRating.value = { ...props.startOptions.afcPredictedRating };
        	afcPossibleIssues.value = { ...props.startOptions.afcPossibleIssues };
        	afcDateFrom.value = props.startOptions.afcDateFrom;
        	afcDateTo.value = props.startOptions.afcDateTo;
            currentlyInReset.value = false;
        };
        // When the sort dir or the view changes, we want to immediately
        // update the settings to use that, ignoring any other changes made.
        // Close the menu so that when it is reopened, the start options are
        // reused, cancelling out the changes in the local state
        const handleImmediateChange = function ( changeName, changeVal ) {
            if ( currentlyInReset.value ) {
                // ignore
                return;
            }
            // Make a *deep copy* of the start options
            const updatedSettings = { ...props.startOptions };
            updatedSettings.nppPredictedRating = { ...props.startOptions.nppPredictedRating };
            updatedSettings.nppPossibleIssues = { ...props.startOptions.nppPossibleIssues };
            updatedSettings.afcPredictedRating = { ...props.startOptions.afcPredictedRating };
            updatedSettings.afcPossibleIssues = { ...props.startOptions.afcPossibleIssues };
            // changeName should be 'currentView', 'nppSortDir', or 'afcSortDir'
            updatedSettings[ changeName ] = changeVal;
            emit( 'update-settings', updatedSettings );
            controlMenuOpen.value = false;
        };
        watch( currentView, ( newVal ) => handleImmediateChange( 'currentView', newVal ) );
        watch( nppSortDir, ( newVal ) => handleImmediateChange( 'nppSortDir', newVal ) );
        watch( afcSortDir, ( newVal ) => handleImmediateChange( 'afcSortDir', newVal ) );
        return {
            controlMenuOpen, toggleControlMenu, toggleControlMenuIndicator,
            currentView,
            // NPP
            nppSortDir,
            nppNamespace,
            nppIncludeUnreviewed, nppIncludeReviewed,
            nppIncludeNominated, nppIncludeRedirects, nppIncludeOthers,
            nppFilter, nppFilterUser,
            nppPredictedRating, nppPossibleIssues,
            nppDateFrom, nppDateTo,
            // AFC
            afcSortDir, afcSortUpdated,
            afcSubmissionState,
            afcPredictedRating, afcPossibleIssues,
            afcDateFrom, afcDateTo,
            // settings
            canSaveSettings, doSaveSettings
        };
    },
    //#region template
    template: `<div id="mwe-vue-pt-menu-heading" class="mwe-vue-pt-control-gradient">
<p v-if="haveDraftNamespace">
    <labeled-input type="radio" input-id="mwe-vue-pt-radio-npp" v-model:inputModel="currentView" label-msg="pagetriage-new-page-patrol" value="npp" :no-break="true" />
    <labeled-input type="radio" input-id="mwe-vue-pt-radio-afc" v-model:inputModel="currentView" label-msg="pagetriage-articles-for-creation" value="afc" :no-break="true" />
</p>
<span class="mwe-vue-pt-control-label"><b>{{ $i18n( 'pagetriage-showing' ) }}</b> {{ currentlyShowingText }}</span>
<span class="mwe-vue-pt-control-label-right" v-show="currentFilteredCount !== -1">
    {{ $i18n( 'pagetriage-stats-filter-page-count', currentFilteredCount ) }}
</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">
		<labeled-input type="radio" v-model:inputModel="nppSortDir" label-msg="pagetriage-newest" value="newestfirst" :no-break="true" />
		<labeled-input type="radio" v-model:inputModel="nppSortDir" label-msg="pagetriage-oldest" value="oldestfirst" :no-break="true" />
	</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-menu-toggle">
    <b @click="toggleControlMenu">Set filters {{ toggleControlMenuIndicator }}</b>
    <!-- Dropdown goes within the toggle with absolute position to overlay the feed -->
	<div id="mwe-vue-pt-control-dropdown" class="mwe-vue-pt-control-gradient" v-show="controlMenuOpen">
		<div v-show="currentView === '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">
				        <labeled-input type="checkbox" v-model:inputModel="nppIncludeUnreviewed" label-msg="pagetriage-filter-unreviewed-edits" />
				        <labeled-input type="checkbox" v-model:inputModel="nppIncludeReviewed" label-msg="pagetriage-filter-reviewed-edits" />
				    </control-section>
				    <control-section label="pagetriage-filter-type-show-heading">
						<labeled-input type="checkbox" v-model:inputModel="nppIncludeNominated" label-msg="pagetriage-filter-nominated-for-deletion" />
						<labeled-input type="checkbox" v-model:inputModel="nppIncludeRedirects" label-msg="pagetriage-filter-redirects" />
						<labeled-input type="checkbox" v-model:inputModel="nppIncludeOthers" label-msg="pagetriage-filter-others" />
				    </control-section>
				</div>
				<template v-if="showOresFilters">
					<div class="mwe-vue-pt-control-section__col2">
					    <control-section label="pagetriage-filter-predicted-class-heading">
						    <labeled-input v-for="(_, ratingName) in nppPredictedRating" :key="ratingName" type="checkbox" v-model:inputModel="nppPredictedRating[ ratingName ]" :label-msg="'pagetriage-filter-predicted-class-' + ratingName" />
					    </control-section>
					</div>
					<div class="mwe-vue-pt-control-section__col3">
					    <control-section label="pagetriage-filter-predicted-issues-heading">
		    				<labeled-input v-for="(_, issueName) in nppPossibleIssues" :key="issueName" type="checkbox" v-model:inputModel="nppPossibleIssues[ issueName ]" :label-msg="'pagetriage-filter-predicted-issues-' + issueName" />
					    </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">
			    <labeled-input v-for="filter in nppFilters" :key="filter" type="radio" :value="filter" v-model:inputModel="nppFilter" :label-msg="'pagetriage-filter-' + filter" />
				<labeled-input type="radio" v-model:inputModel="nppFilter" label-msg="pagetriage-filter-user-heading" value="username" :no-break="true" />
				<input type="text" id="mwe-vue-pt-filter-user" :placeholder="$i18n( 'pagetriage-filter-username' )" v-model="nppFilterUser"/> <br/>
				<labeled-input type="radio" v-model:inputModel="nppFilter" label-msg="pagetriage-filter-all" value="all" />
			</control-section>
		</div>
		<div v-show="currentView === 'afc'">
			<div class="mwe-vue-pt-control-section__row1">
				<div class="mwe-vue-pt-control-section__col1">
				    <control-section label="pagetriage-filter-show-heading">
				        <labeled-input v-for="state in afcSubmissionStates" :key="state" type="radio" :value="state" v-model:inputModel="afcSubmissionState" :label-msg="'pagetriage-afc-state-' + state" />
				    </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">
						    <labeled-input v-for="(_, ratingName) in afcPredictedRating" :key="ratingName" type="checkbox" v-model:inputModel="afcPredictedRating[ ratingName ]" :label-msg="'pagetriage-filter-predicted-class-' + ratingName" />
					    </control-section>
					</div>
					<div class="mwe-vue-pt-control-section__col3">
					    <control-section label="pagetriage-filter-predicted-issues-heading">
	    					<labeled-input v-for="(_, issueName) in afcPossibleIssues" :key="issueName" type="checkbox" v-model:inputModel="afcPossibleIssues[ issueName ]" :label-msg="'pagetriage-filter-predicted-issues-' + issueName" />
					    </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 class="mwe-vue-pt-button-green" action="progressive" type="primary" :disabled="!canSaveSettings" @click="doSaveSettings">{{ $i18n( 'pagetriage-filter-set-button' ) }}</wvui-button>
		</div>
	</div>
</div>
</div>`
};
//#endregion

/**
 * Convert the page information retrieved from the api into the properties
 * that listItemComponent expects.
 */
VueNPP.listItemPropFormatter = function ( pageInfo ) {
    // the `position` prop is handled by the list
    const listItemProps = {};
	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 || 0;
	return listItemProps;
};

/**
 * Nav bar at the bottom with statistics and a refresh button.
 */
VueNPP.statsBarComponent = {
    props: {
        currentView: { type: String, default: 'npp' },
        apiResult: {
            type: Object,
            default: () => ( {} )
        }
    },
    setup( props, { emit } ) {
        const triggerRefresh = () => {
            emit( 'refresh-feed' );
        };
        const unreviewedCount = VueCompositionAPI.computed( () => {
            if ( props.apiResult.result === 'success'
                && props.apiResult.stats
                && props.apiResult.stats.unreviewedarticle
            ) {
                return props.apiResult.stats.unreviewedarticle.count;
            }
            // Should not be shown
            return -1;
        } );
        const unreviewedOldest = VueCompositionAPI.computed( () => {
            if ( props.apiResult.result === 'success'
                && props.apiResult.stats
                && props.apiResult.stats.unreviewedarticle
            ) {
                const rawOldest = props.apiResult.stats.unreviewedarticle.oldest;
                // convert to number of days based on formatDaysFromNow in
                // pagetriage
                if ( !rawOldest ) {
                    return '';
                }
                var now = new Date();
                now = new Date(
                    now.getUTCFullYear(),
                    now.getUTCMonth(),
                    now.getUTCDate(),
                    now.getUTCHours(),
                    now.getUTCMinutes(),
                    now.getUTCSeconds()
                );
                var begin = moment.utc( rawOldest, 'YYYYMMDDHHmmss' );
                var diff = Math.round( ( now.getTime() - begin.valueOf() ) / ( 1000 * 60 * 60 * 24 ) );
                if ( diff ) {
                    return mw.msg( 'days', diff );
                }
				return mw.msg( 'pagetriage-stats-less-than-a-day', diff );
            }
            // Should not be shown
            return '?';
        } );
        const reviewedCount = VueCompositionAPI.computed( () => {
            if ( props.apiResult.result === 'success'
                && props.apiResult.stats
                && props.apiResult.stats.reviewedarticle
            ) {
                return props.apiResult.stats.reviewedarticle.reviewed_count;
            }
            // Should not be shown
            return -1;
        } );
        const showStats = VueCompositionAPI.computed( () => {
            // make sure all the values were computed
            return props.currentView === 'npp'
                && unreviewedCount.value !== -1
                && unreviewedOldest.value !== '?'
                && reviewedCount.value !== -1
        } );
        return {
            triggerRefresh,
            showStats,
            unreviewedCount,
            unreviewedOldest,
            reviewedCount
        };
    },
    template: `<div id="mwe-vue-pt-stats-navigation" class="mwe-vue-pt-navigation-bar mwe-vue-pt-control-gradient">
<div id="mwe-vue-pt-stats-navigation-content">
<button id="mwe-vue-pt-refresh-button" class="ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only" @click="triggerRefresh">
<span class="ui-button-text">{{ $i18n( 'pagetriage-refresh-list' ) }}</span>
</button>

<div v-show="showStats">
<div>{{ $i18n( 'pagetriage-unreviewed-article-count', unreviewedCount, unreviewedOldest ) }}</div>
<div>{{ $i18n( 'pagetriage-reviewed-article-count-past-week', reviewedCount ) }}</div>
</div>

</div>
</div>`
};

/**
 * Component for the bar after the last entry that allows loading more when
 * scrolled into view. Whether to show or not is based on a prop instead of
 * being controlled in the calling code so that the intersection observer
 * does not need to be recreated each time.
 */
VueNPP.loadMoreBarComponent = {
    props: {
        haveMore: { type: Boolean, required: true }
    },
    setup( props, { emit } ) {
        const emitLoadMore = function () {
            // check that we should try to load
            if ( props.haveMore ) {
                emit( 'trigger-load' );
            }
        };
        const barRef = VueCompositionAPI.ref();
        const observerCallback = function ( entries, observer ) {
            const observerEntry = entries[0];
            // whether we scrolled to see it or away from it
            const nowSeen = observerEntry.isIntersecting;
            if ( !nowSeen ) {
                return;
            }
            // console.log( observerEntry );
            emitLoadMore();
        };
        const observer = new IntersectionObserver( observerCallback );
        Vue.onMounted( () => {
            observer.observe( barRef.value );
        } );
        return {
            barRef,
            emitLoadMore
        };
    },
    template: `<div v-show="haveMore" ref="barRef">
<div id="mwe-vue-pt-feed-load-more">
<wvui-progress-bar></wvui-progress-bar>
<wvui-button action="progressive" type="quiet" @click="emitLoadMore">Load more</wvui-button>
</div>
</div>`
};

/**
 * Component for the overall list contents, is given the api properties to
 * query with and generates the items to show.
 */
VueNPP.feedContentsComponent = {
	// wvui components are added separately
	components: {
		listItem: VueNPP.listItemComponent,
		loadMoreBar: VueNPP.loadMoreBarComponent,
		statsBar: VueNPP.statsBarComponent
	},
	props: {
	    params: { type: Object, required: true }
	},
	data: function () {
	    return {
    	    // Enable adding by specific page id for debugging
	        manualDebug: false
	    };
	},
	setup( props, { emit } ) {
	    const API_PAGE_LIMIT = 20;
        const ref = VueCompositionAPI.ref;

	    const api = new mw.ForeignApi( '//en.wikipedia.org/w/api.php' );
	    const apiError = ref( false );
	    const feedEntries = ref( [] );
	    // incremented before being used
	    const latestPosition = ref( 0 );
	    // 0 is ignored; `offset` and `pageoffset` parameters
	    const apiOffsets = ref( { normal: 0, page: 0 } );
	    const haveMoreToLoad = ref( true );

		const alreadyLoading = ref( false );
        const onApiFailure = function ( res, shouldRender ) {
            console.log( res );
            if ( shouldRender ) {
                apiError.value = true;
            }
            alreadyLoading.value = false;
        };
        const addPageToFeed = function ( pageInfo ) {
            const propData = VueNPP.listItemPropFormatter( pageInfo );
            propData.position = ( ++latestPosition.value );
            feedEntries.value.push( propData );
        };
		const processResult = function ( res ) {
			// console.log( res );
			if ( !res || !res.pagetriagelist || !res.pagetriagelist.pages
				|| !res.pagetriagelist.pages[0]
			) {
				onApiFailure( res, true );
				return;
			}
			haveMoreToLoad.value = false;
			const allPages = res.pagetriagelist.pages;
			if ( allPages.length > API_PAGE_LIMIT ) {
			    // Have more to load
			    allPages.pop();
			    haveMoreToLoad.value = true;
			}
			for ( var iii = 0; iii < allPages.length; iii++ ) {
			    addPageToFeed( allPages[iii] );
			}
			// offset with the last
			const lastPage = allPages[ allPages.length - 1 ];
			apiOffsets.value.normal = lastPage.creation_date_utc;
			apiOffsets.value.page = lastPage.pageid;
            alreadyLoading.value = false;
		};
		const addFromApi = function ( apiParams ) {
		    apiParams.action = 'pagetriagelist';
		    apiParams.format = 'json';
		    apiParams.formatversion = 2;
		    apiParams.limit = API_PAGE_LIMIT;
		    apiParams.offset = apiOffsets.value.normal;
		    apiParams.pageoffset = apiOffsets.value.page;
		    // console.log( apiParams );
		    api.get( apiParams ).then(
		        ( res ) => processResult( res ),
		        ( res ) => onApiFailure( res, true )
		    );
		};

    	// Default is [[FDP Hamburg]] for now (for manualDebug)
	    const targetPageId = ref( 70853005 );
	    const updatePageId = ( newPageId ) => targetPageId.value = newPageId;
	    const addFromPageId = function () {
	        addFromApi( { page_id: targetPageId.value } );
		};
		const loadFromFilters = function () {
		    if ( alreadyLoading.value === true ) {
		        // race condition
		        return;
		    }
		    alreadyLoading.value = true;
		    console.log( 'Loading from filters' );
		    // make a copy, and remove unknown param
		    const paramsFromProps = { ...props.params };
		    delete paramsFromProps.mode;
		    addFromApi( paramsFromProps );
		};
		// Passed to stats bar
		const currentView = VueCompositionAPI.computed( () => {
		    return props.params.mode;
		} );
		const clearCurrentData = function () {
		    feedEntries.value = [];
    	    latestPosition.value = 0;
	        haveMoreToLoad.value = true;
	        apiOffsets.value.normal = 0;
	        apiOffsets.value.page = 0;
		};
		const feedStats = ref( {} );
		const processNewStats = function ( newStats ) {
		    // console.log( newStats );
		    feedStats.value = newStats.pagetriagestats;
		    // hack - the number of pages in the filtered list is used in a
		    // different component (the menu bar at the top) and its easier
		    // to fetch the stats here than to fetch in the parent, send the
		    // data up via events
		    emit( 'new-filtered-count', newStats.pagetriagestats.stats.filteredarticle );
		};
		const updateStats = function () {
		    // make a copy, and remove unknown params
		    const apiParams = { ...props.params };
		    delete apiParams.mode;
		    delete apiParams.dir;
		    apiParams.action = 'pagetriagestats';
		    apiParams.format = 'json';
		    apiParams.formatversion = 2;
		    // console.log( apiParams );
		    api.get( apiParams ).then(
		        ( res ) => processNewStats( res ),
		        ( res ) => onApiFailure( res, false )
		    );
		}
	    const refreshFeed = function () {
	        console.log( 'Should refresh feed' );
		    clearCurrentData();
		    loadFromFilters();
		    updateStats();
	    }
		VueCompositionAPI.watch(
		    VueCompositionAPI.toRef( props, 'params' ),
		    refreshFeed
	    );
	    Vue.onMounted( () => refreshFeed() );

        return {
            targetPageId, updatePageId,
            addFromPageId,
            apiError, feedEntries,
            haveMoreToLoad, loadFromFilters,
            refreshFeed, currentView, feedStats
        };
	},
	template: `<div>
<div v-if="manualDebug">
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="addFromPageId">Add entry</wvui-button>
<br>
</div>

<div v-show="apiError">
Api error, see console
<br>
</div>

<template v-if="feedEntries">
<list-item v-for="feedEntry in feedEntries" :key="feedEntry.position" v-bind="feedEntry"></list-item>
</template>
<load-more-bar :have-more="haveMoreToLoad" @trigger-load="loadFromFilters"></load-more-bar>

<stats-bar :current-view="currentView" :api-result="feedStats" @refresh-feed=refreshFeed></stats-bar>
</div>`
};

/**
 * Interface for user to choose an article
 */
VueNPP.NPPFeedMenu = {
	// wvui components are added separately
	components: {
		feedControlMenu: VueNPP.feedControlMenuComponent,
		feedContents: VueNPP.feedContentsComponent
	},
	setup( props ) {
	    const ref = VueCompositionAPI.ref;
	    const computed = VueCompositionAPI.computed;

	    const currentSettings = ref ( {
            currentView: 'npp',
            nppSortDir: 'newestfirst',
            nppNamespace: 0,
            nppIncludeUnreviewed: true,
            nppIncludeReviewed: true,
            nppIncludeNominated: true,
            nppIncludeRedirects: false,
            nppIncludeOthers: true,
			nppFilter: 'all',
			nppFilterUser: '',
			nppPredictedRating: {
                stub: false,
                start: false,
                c: false,
                b: false,
                good: false,
                featured: false
			},
			nppPossibleIssues: {
			    vandalism: false,
                spam: false,
                attack: false,
                copyvio: false,
                none: false
			},
			nppDateFrom: '',
			nppDateTo: '',
			afcSortDir: 'newestfirst',
			afcSubmissionState: 'all',
			afcPredictedRating: {
                stub: false,
                start: false,
                c: false,
                b: false,
                good: false,
                featured: false
			},
			afcPossibleIssues: {
			    vandalism: false,
                spam: false,
                attack: false,
                copyvio: false,
                none: false
			},
			afcDateFrom: '',
			afcDateTo: '',
        } );
        const updateSettings = function ( newVal ) {
            // deep copy 
            currentSettings.value = newVal;
            currentSettings.value.nppPredictedRating = { ...newVal.nppPredictedRating };
            currentSettings.value.nppPossibleIssues = { ...newVal.nppPossibleIssues };
            currentSettings.value.afcPredictedRating = { ...newVal.afcPredictedRating };
            currentSettings.value.afcPossibleIssues = { ...newVal.afcPossibleIssues };
        };
        const offset = parseInt( mw.user.options.get( 'timecorrection' ).split( '|' )[ 1 ] );
        const apiOptions = computed( () => {
            // shortcut
            const currentSV = currentSettings.value;
            // limit is added by feedContentsComponent
            const params = {
                mode: currentSV.currentView
            };
            const addIfToggled = function ( paramName, optionToggle ) {
                if ( optionToggle ) {
                    params[ paramName ] = '1';
                }
            };
    	    const addOresFilters = function ( optionsObj, paramPrefix ) {
                for ( var optionName in optionsObj ) {
                    addIfToggled( paramPrefix + optionName, optionsObj[ optionName ] );
                }
            };
            const addNppFilter = function () {
                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 = currentSV.nppFilter;
                if ( chosenFilter === 'username' && currentSV.nppFilterUser ) {
                    params.username = currentSV.nppFilterUser;
                    // if username is chosen with no filter, or 'all'
                } else if ( filtersToParams[ chosenFilter ] !== undefined ) {
                    params[ filtersToParams[ chosenFilter ] ] = '1';
                }
            };
            const addDateParams = function ( fromVal, toVal ) {
                if ( fromVal ) {
                    const fromDate = moment.utc( fromVal ).subtract( offset, 'minutes' );
                    params.date_range_from = fromDate.toISOString();
                }
                if ( toVal ) {
                    let toDate = moment.utc( toVal ).subtract( offset, 'minutes' );
                    // move to the end of the given day
                    toDate.add( 1, 'day' ).subtract( 1, 'second' );
                    params.date_range_to = toDate.toISOString();
                }
            };
            if ( currentSV.currentView === 'npp' ) {
                addIfToggled( 'showreviewed', currentSV.nppIncludeReviewed );
                addIfToggled( 'showunreviewed', currentSV.nppIncludeUnreviewed );
                addIfToggled( 'showdeleted', currentSV.nppIncludeNominated );
                addIfToggled( 'showredirs', currentSV.nppIncludeRedirects );
                addIfToggled( 'showothers', currentSV.nppIncludeOthers );
                addNppFilter();
                addOresFilters( currentSV.nppPredictedRating, 'show_predicted_class_' );
                addOresFilters( currentSV.nppPossibleIssues, 'show_predicted_issues_' );
                params.namespace = currentSV.nppNamespace;
                params.dir = currentSV.nppSortDir;
                addDateParams( currentSV.nppDateFrom, currentSV.nppDateTo );
            } else {
                addOresFilters( currentSV.afcPredictedRating, 'show_predicted_class_' );
                addOresFilters( currentSV.afcPossibleIssues, 'show_predicted_issues_' );
                params.showreviewed = '1';
                params.showunreviewed = '1';
                params.namespace = 118 || mw.config.get( 'wgNamespaceIds' ).draft;
                params.dir = currentSV.afcSortDir;
                const afcSubmissionStateApi = VueNPP.getAfcStateForApi( currentSV.afcSubmissionState );
                if ( afcSubmissionStateApi !== false ) {
                    params.afc_state = afcSubmissionStateApi;
                }
                addDateParams( currentSV.afcDateFrom, currentSV.afcDateTo );
            }
            return params;
        } );
        const showingText = computed( () => {
            const showingMessageObj = {
                namespace: [],
                state: [],
                type: [],
                'predicted-class': [],
                'predicted-issues': [],
                top: [],
                date_range: []
            };
            const addOresShowing = function ( settingsObj, msgPrefix ) {
                for ( var settingsOption in settingsObj ) {
                    if ( settingsObj[ settingsOption ] ) {
                        showingMessageObj[ msgPrefix ].push(
                            mw.msg( `pagetriage-filter-stat-${msgPrefix}-${settingsOption}` )
                        );
                    }
                }
            };
            const addDateShowing = function ( dateFrom, dateTo ) {
                if ( dateFrom ) {
                    const forFormattingFrom = moment( dateFrom );
                    const formattedFrom = forFormattingFrom.utcOffset( offset )
                        .format( mw.msg( 'pagetriage-filter-date-range-format-showing' ) );
                    showingMessageObj.date_range.push(
                        mw.msg( 'pagetriage-filter-stat-date_range_from', formattedFrom )
                    );
                }
                if ( dateTo ) {
                    const forFormattingTo = moment( dateTo );
                    const formattedTo = forFormattingTo.utcOffset( offset )
                        .format( mw.msg( 'pagetriage-filter-date-range-format-showing' ) );
                    showingMessageObj.date_range.push(
                        mw.msg( 'pagetriage-filter-stat-date_range_to', formattedTo )
                    );
                }
            };
            const addShowingIf = function ( isApplicable, msgSuffix, msgGroup ) {
                if ( isApplicable ) {
                    showingMessageObj[ msgGroup ].push(
                        mw.msg( `pagetriage-filter-stat-${msgSuffix}` )
                    );
                }
            };
            const currentSV = currentSettings.value;
            if ( currentSV.currentView === 'npp' ) {
                showingMessageObj.namespace.push(
                    currentSV.nppNamespace === 0 ? 'Article' : 'User'
                );
                const showingNPPFilter = currentSV.nppFilter;
                if ( showingNPPFilter === 'username' ) {
                    if ( currentSV.nppFilterUser ) {
                        showingMessageObj.top.push(
                            mw.msg( 'pagetriage-filter-stat-username', currentSV.nppFilterUser )
                        );
                    }
                } else if ( showingNPPFilter === 'bot-edits' ) {
                    // Need a different message key (not -bot-edits)
                    showingMessageObj.top.push( mw.msg( 'pagetriage-filter-stat-bots' ) );
                } else if ( showingNPPFilter !== 'all' ) {
                    showingMessageObj.top.push( mw.msg( `pagetriage-filter-stat-${showingNPPFilter}` ) );
                }
                addShowingIf( currentSV.nppIncludeReviewed, 'reviewed', 'state' );
                addShowingIf( currentSV.nppIncludeUnreviewed, 'unreviewed', 'state' );
                addShowingIf( currentSV.nppIncludeNominated, 'nominated-for-deletion', 'type' );
                addShowingIf( currentSV.nppIncludeRedirects, 'redirects', 'type' );
                addShowingIf( currentSV.nppIncludeOthers, 'others', 'type' );
                addOresShowing( currentSV.nppPredictedRating, 'predicted-class' );
                addOresShowing( currentSV.nppPossibleIssues, 'predicted-issues' );
                addDateShowing( currentSV.nppDateFrom, currentSV.nppDateTo );
            } else {
                addOresShowing( currentSV.afcPredictedRating, 'predicted-class' );
                addOresShowing( currentSV.afcPossibleIssues, 'predicted-issues' );
                addDateShowing( currentSV.afcDateFrom, currentSV.afcDateTo );
                showingMessageObj.state.push(
                    mw.msg( `pagetriage-afc-state-${currentSV.afcSubmissionState}` )
                );
            }
            return Object.keys( showingMessageObj )
                .map( function ( group ) {
                    const groupShowing = showingMessageObj[ group ];
                    if ( groupShowing.length === 0 ) {
                        return '';
                    }
                    if ( group == 'top' || ( currentSV.currentView === 'afc' && group === 'state' ) ) {
                        return groupShowing[ 0 ];
                    }
                    return mw.msg( `pagetriage-filter-stat-${group}` ) + ' '
                        + mw.msg( 'parentheses', groupShowing.join( mw.msg( 'comma-separator' ) ) );
                } )
                .filter( ( msg ) => msg !== '' )
                .join( mw.msg( 'comma-separator' ) );
        } );

        // start as -1 until fetched the first time; fetched with the rest of
        // the statistics in the nav bar within feed contents, and then
        // passed up via an event to all knowing it here to pass to the menu
        const currentFilteredCount = ref( -1 );
        const updateFilteredCount = function ( val ) {
            currentFilteredCount.value = val;
        };
	    return {
	        currentSettings, updateSettings,
	        apiOptions, showingText,
	        currentFilteredCount, updateFilteredCount
	    };
	},
	template: `<div>
Settings: {{ apiOptions }}
<feed-control-menu :start-options="currentSettings" @update-settings="updateSettings" :currently-showing-text="showingText" :current-filtered-count="currentFilteredCount"></feed-control-menu>

<br>
<feed-contents :params="apiOptions" @new-filtered-count="updateFilteredCount"></feed-contents>
</div>`
};

//#endregion

//module.exports = VueNPP.NPPFeedMenu;

/**
 * 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>