Misplaced Pages

User:DannyS712/VueNPP.js

Article snapshot taken from Wikipedia with creative commons attribution-sharealike license. Give it a read and then ask your questions in the chat. We can research this topic together.
< User:DannyS712
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump.
This code will be executed when previewing this page.
Documentation for this user script can be added at User:DannyS712/VueNPP.
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 Misplaced Pages: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 ] 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( '|' ) )
        };
    },
    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: , 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: ,
    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 = ;
    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 = 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" :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" :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" :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" :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;
            // 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
			) {
				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 );
			}
			// offset with the last
			const lastPage = allPages;
			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 ] 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( '|' ) );
        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 = '1';
                }
            };
    	    const addOresFilters = function ( optionsObj, paramPrefix ) {
                for ( var optionName in optionsObj ) {
                    addIfToggled( paramPrefix + optionName, optionsObj );
                }
            };
            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 !== undefined ) {
                    params ] = '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 ) {
                        showingMessageObj.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.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;
                    if ( groupShowing.length === 0 ) {
                        return '';
                    }
                    if ( group == 'top' || ( currentSV.currentView === 'afc' && group === 'state' ) ) {
                        return groupShowing;
                    }
                    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>