mirror of
https://github.com/StarCitizenTools/mediawiki-extensions-TabberNeue.git
synced 2024-11-23 16:06:45 +00:00
feat: render all HTML server-side
This commit is contained in:
parent
de2ced5a36
commit
1e1d3de9cd
|
@ -108,8 +108,9 @@ class Tabber {
|
|||
}
|
||||
|
||||
return "<div id='tabber-$count' class='tabber tabber--init'>" .
|
||||
'<header class="tabber__header"></header>' .
|
||||
// '<header class="tabber__header"><nav class="tabber__tabs" role="tablist">' . $tabs . '</nav></header>' .
|
||||
'<header class="tabber__header"><button class="tabber__header__prev" aria-hidden="true"></button>' .
|
||||
'<nav class="tabber__tabs" role="tablist">' . $tabs . '</nav>' .
|
||||
'<button class="tabber__header__next" aria-hidden="true"></button></header>' .
|
||||
'<section class="tabber__section">' . $tabpanels . '</section></div>';
|
||||
}
|
||||
|
||||
|
@ -215,11 +216,13 @@ class Tabber {
|
|||
* @return string HTML
|
||||
*/
|
||||
private static function getTabHTML( array $tabData ): string {
|
||||
$tabpanelId = "tabber-tabpanel-{$tabData['id']}";
|
||||
return Html::rawElement( 'a', [
|
||||
'class' => 'tabber__tab',
|
||||
'id' => "tabber-tab-{$tabData['id']}",
|
||||
'href' => "#tabber-tabpanel-{$tabData['id']}",
|
||||
'href' => "#$tabpanelId",
|
||||
'role' => 'tab',
|
||||
'aria-controls' => $tabpanelId
|
||||
], $tabData['label'] );
|
||||
}
|
||||
|
||||
|
@ -239,8 +242,10 @@ class Tabber {
|
|||
}
|
||||
return Html::rawElement( 'article', [
|
||||
'class' => 'tabber__panel',
|
||||
// 'id' => "tabber-tabpanel-{$tabData['id']}",
|
||||
'data-mw-tabber-title' => $tabData['label'],
|
||||
'id' => "tabber-tabpanel-{$tabData['id']}",
|
||||
'role' => 'tabpanel',
|
||||
'tabindex' => 0,
|
||||
'aria-labelledby' => "tabber-tab-{$tabData['id']}"
|
||||
], $content );
|
||||
}
|
||||
|
||||
|
|
|
@ -43,14 +43,14 @@ function main() {
|
|||
tabbers.forEach( initApp );
|
||||
}
|
||||
|
||||
mw.hook( 'wikipage.content' ).add( function () {
|
||||
mw.hook( 'wikipage.content' ).add( () => {
|
||||
main();
|
||||
} );
|
||||
|
||||
/*
|
||||
* Add hooks for Tabber when Visual Editor is used.
|
||||
*/
|
||||
mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init', function () {
|
||||
mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init', () => {
|
||||
// After saving edits
|
||||
mw.hook( 'postEdit.afterRemoval' ).add( () => {
|
||||
main();
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
.tabber {
|
||||
&__header {
|
||||
box-shadow: inset 0 -1px 0 0 var( --border-color-base, #a2a9b1 );
|
||||
|
||||
&__prev,
|
||||
&__next {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__tabs {
|
||||
|
|
|
@ -49,6 +49,7 @@ class Transclude {
|
|||
*/
|
||||
async fetchDataFromUrl() {
|
||||
try {
|
||||
// eslint-disable-next-line n/no-unsupported-features/node-builtins
|
||||
const response = await fetch( this.url, { method: 'GET', timeout: 5000, credentials: 'same-origin' } );
|
||||
if ( !response.ok ) {
|
||||
throw new Error( `Network response was not ok: ${ response.status } - ${ response.statusText }` );
|
||||
|
|
|
@ -87,28 +87,6 @@ class TabberAction {
|
|||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Animate and update the indicator position and width based on the active tab.
|
||||
*
|
||||
* @param {Element} indicator - The indicator element (optional, defaults to the first '.tabber__indicator' found in the parent).
|
||||
* @param {Element} activeTab - The currently active tab.
|
||||
* @param {Element} tablist - The parent element containing the tabs.
|
||||
*/
|
||||
static animateIndicator( indicator, activeTab, tablist ) {
|
||||
const tablistScrollLeft = Util.roundScrollLeft( tablist.scrollLeft );
|
||||
const width = Util.getElementSize( activeTab, 'width' );
|
||||
const transformValue = activeTab.offsetLeft - tablistScrollLeft;
|
||||
|
||||
indicator.classList.add( 'tabber__indicator--visible' );
|
||||
tablist.classList.add( 'tabber__tabs--animate' );
|
||||
indicator.style.width = width + 'px';
|
||||
indicator.style.transform = `translateX(${ transformValue }px)`;
|
||||
setTimeout( () => {
|
||||
indicator.classList.remove( 'tabber__indicator--visible' );
|
||||
tablist.classList.remove( 'tabber__tabs--animate' );
|
||||
}, 250 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the tabpanel element based on the tab element
|
||||
*
|
||||
|
@ -136,12 +114,13 @@ class TabberAction {
|
|||
transclude.loadPage();
|
||||
}
|
||||
|
||||
const activeTabpanelHeight = Util.getElementSize(
|
||||
activeTabpanel,
|
||||
'height'
|
||||
);
|
||||
section.style.height = activeTabpanelHeight + 'px';
|
||||
|
||||
window.requestAnimationFrame( () => {
|
||||
const activeTabpanelHeight = Util.getElementSize(
|
||||
activeTabpanel,
|
||||
'height'
|
||||
);
|
||||
section.style.height = activeTabpanelHeight + 'px';
|
||||
// Scroll to tab
|
||||
section.scrollLeft = activeTabpanel.offsetLeft;
|
||||
} );
|
||||
|
@ -155,7 +134,6 @@ class TabberAction {
|
|||
/**
|
||||
* Sets the active tab in the tabber element.
|
||||
* Updates the attributes of tabs and tab panels to reflect the active state.
|
||||
* Animates the indicator to the active tab and sets the active tab panel.
|
||||
*
|
||||
* @param {Element} activeTab - The tab element to set as active.
|
||||
* @return {Promise} - A promise that resolves once the active tab is set.
|
||||
|
@ -164,9 +142,6 @@ class TabberAction {
|
|||
return new Promise( ( resolve ) => {
|
||||
const activeTabpanel = TabberAction.getTabpanel( activeTab );
|
||||
const tabberEl = activeTabpanel.closest( '.tabber' );
|
||||
const indicator = tabberEl.querySelector(
|
||||
':scope > .tabber__header > .tabber__indicator'
|
||||
);
|
||||
|
||||
const currentActiveTab = tabberEl.querySelector( ':scope > .tabber__header > .tabber__tabs > .tabber__tab[aria-selected="true"]' );
|
||||
let currentActiveTabpanel;
|
||||
|
@ -204,12 +179,6 @@ class TabberAction {
|
|||
|
||||
Util.setAttributes( activeTab, activeTabAttributes );
|
||||
Util.setAttributes( activeTabpanel, activeTabpanelAttributes );
|
||||
|
||||
TabberAction.animateIndicator(
|
||||
indicator,
|
||||
activeTab,
|
||||
activeTab.parentElement
|
||||
);
|
||||
TabberAction.setActiveTabpanel( activeTabpanel, currentActiveTabpanel );
|
||||
|
||||
resolve();
|
||||
|
@ -286,9 +255,6 @@ class TabberEvent {
|
|||
this.tabs = this.tablist.querySelectorAll( ':scope > .tabber__tab' );
|
||||
this.activeTab = this.tablist.querySelector( '[aria-selected="true"]' );
|
||||
this.activeTabpanel = TabberAction.getTabpanel( this.activeTab );
|
||||
this.indicator = this.tabber.querySelector(
|
||||
':scope > .tabber__header > .tabber__indicator'
|
||||
);
|
||||
this.tabFocus = 0;
|
||||
this.debouncedUpdateHeaderOverflow = mw.util.debounce(
|
||||
() => TabberAction.updateHeaderOverflow( this.tablist ),
|
||||
|
@ -454,187 +420,31 @@ class TabberEvent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Class responsible for creating tabs, headers, and indicators for a tabber element.
|
||||
* Class responsible for initalizing a tabber element.
|
||||
*
|
||||
* @class TabberBuilder
|
||||
*/
|
||||
class TabberBuilder {
|
||||
constructor( tabber ) {
|
||||
this.tabber = tabber;
|
||||
this.header = this.tabber.querySelector( ':scope > .tabber__header' );
|
||||
this.tablist = document.createElement( 'nav' );
|
||||
this.indicator = document.createElement( 'div' );
|
||||
this.tablist = tabber.querySelector( ':scope > .tabber__header > .tabber__tabs' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the attributes of a tab element.
|
||||
*
|
||||
* @param {Element} tab - The tab element to set attributes for.
|
||||
* @param {string} tabId - The ID of the tab element.
|
||||
*/
|
||||
setTabAttributes( tab, tabId ) {
|
||||
setTabsAttributes() {
|
||||
const tabAttributes = {
|
||||
class: 'tabber__tab',
|
||||
href: '#' + tabId,
|
||||
id: 'tab-' + tabId,
|
||||
role: 'tab',
|
||||
tabindex: '-1',
|
||||
'aria-selected': false,
|
||||
'aria-controls': tabId
|
||||
'aria-selected': false
|
||||
};
|
||||
|
||||
Util.setAttributes( tab, tabAttributes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tab element with the given title attribute and tab ID.
|
||||
*
|
||||
* @param {string} titleAttr - The title attribute for the tab element.
|
||||
* @param {string} tabId - The ID of the tab element.
|
||||
* @return {Element} The created tab element.
|
||||
*/
|
||||
createTab( titleAttr, tabId ) {
|
||||
const tab = document.createElement( 'a' );
|
||||
|
||||
if ( config.parseTabName ) {
|
||||
tab.innerHTML = titleAttr;
|
||||
} else {
|
||||
tab.textContent = titleAttr;
|
||||
for ( const tab of this.tablist.children ) {
|
||||
Util.setAttributes( tab, tabAttributes );
|
||||
}
|
||||
|
||||
this.setTabAttributes( tab, tabId );
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the attributes of a tab panel element.
|
||||
*
|
||||
* @param {Element} tabpanel - The tab panel element to set attributes for.
|
||||
* @param {string} tabId - The ID of the tab panel element.
|
||||
*/
|
||||
setTabpanelAttributes( tabpanel, tabId ) {
|
||||
const tabpanelAttributes = {
|
||||
id: tabId,
|
||||
role: 'tabpanel',
|
||||
tabindex: '-1',
|
||||
'aria-hidden': 'true',
|
||||
'aria-labelledby': `tab-${ tabId }`
|
||||
};
|
||||
|
||||
Util.setAttributes( tabpanel, tabpanelAttributes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a tab element based on the provided tab panel.
|
||||
*
|
||||
* @param {Element} tabpanel - The tab panel element to create a tab element for.
|
||||
* @return {Element|false} The created tab element, or false if the title attribute is missing
|
||||
* or malformed.
|
||||
*/
|
||||
createTabElement( tabpanel ) {
|
||||
const titleAttr = tabpanel.dataset.mwTabberTitle;
|
||||
|
||||
if ( !titleAttr ) {
|
||||
mw.log.error(
|
||||
'[TabberNeue] Missing or malformed `data-mw-tabber-title` attribute'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
let tabId;
|
||||
if ( config.parseTabName ) {
|
||||
tabId = Util.extractTextFromHtml( titleAttr );
|
||||
} else {
|
||||
tabId = titleAttr;
|
||||
}
|
||||
|
||||
tabId = Hash.build( tabId, config.useLegacyTabIds );
|
||||
|
||||
this.setTabpanelAttributes( tabpanel, tabId );
|
||||
|
||||
return this.createTab( titleAttr, tabId );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates tabs for the tabber.
|
||||
*
|
||||
* This method creates tab elements for each tab panel in the tabber.
|
||||
* It appends the created tabs to the tablist element, adds necessary attributes,
|
||||
* and sets the role attribute for accessibility.
|
||||
*
|
||||
* @return {Promise} A promise that resolves once all tabs are created and appended to the tablist.
|
||||
*/
|
||||
createTabs() {
|
||||
return new Promise( ( resolve ) => {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const tabpanels = this.tabber.querySelectorAll(
|
||||
':scope > .tabber__section > .tabber__panel'
|
||||
);
|
||||
tabpanels.forEach( ( tabpanel ) => {
|
||||
fragment.append( this.createTabElement( tabpanel ) );
|
||||
} );
|
||||
|
||||
this.tablist.append( fragment );
|
||||
this.tablist.classList.add( 'tabber__tabs' );
|
||||
this.tablist.setAttribute( 'role', 'tablist' );
|
||||
resolve();
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the indicator element for the tabber.
|
||||
*
|
||||
* This method creates a div element to serve as the indicator for the active tab.
|
||||
* The indicator element is given a specific CSS class for styling and is appended to the tabber header.
|
||||
*
|
||||
* @return {Promise} A promise that resolves once the indicator element is created.
|
||||
*/
|
||||
createIndicator() {
|
||||
return new Promise( ( resolve ) => {
|
||||
const indicator = document.createElement( 'div' );
|
||||
indicator.classList.add( 'tabber__indicator' );
|
||||
this.header.append( indicator );
|
||||
resolve();
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the header for the tabber.
|
||||
*
|
||||
* This method creates two button elements, one for navigating to the previous tab and one for navigating to the next tab.
|
||||
* Each button element is created with the specified class and aria-label attributes.
|
||||
* The created buttons are appended to the header of the tabber.
|
||||
*
|
||||
* @return {Promise} A promise that resolves once the header is created.
|
||||
*/
|
||||
createHeader() {
|
||||
return new Promise( ( resolve ) => {
|
||||
/**
|
||||
* Creates a button element with the specified class and aria-label.
|
||||
*
|
||||
* @param {string} className - The class name for the button element.
|
||||
* @param {string} ariaLabel - The aria-label attribute for the button element.
|
||||
* @return {Element} The created button element.
|
||||
*/
|
||||
const createButton = ( className, ariaLabel ) => {
|
||||
const button = document.createElement( 'button' );
|
||||
// eslint-disable-next-line mediawiki/class-doc
|
||||
button.classList.add( className );
|
||||
button.setAttribute( 'aria-label', ariaLabel );
|
||||
return button;
|
||||
};
|
||||
|
||||
const prevButton = createButton( 'tabber__header__prev', mw.message( 'tabberneue-button-prev' ).text() );
|
||||
const nextButton = createButton( 'tabber__header__next', mw.message( 'tabberneue-button-next' ).text() );
|
||||
|
||||
this.header.append( prevButton, this.tablist, nextButton );
|
||||
resolve();
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the tabber by creating tabs, header, and indicator elements sequentially.
|
||||
* Sets the tabs attributes
|
||||
* Sets the active tab based on the URL hash, and updates the header overflow.
|
||||
* Attaches event listeners for tabber interaction.
|
||||
*
|
||||
|
@ -642,19 +452,14 @@ class TabberBuilder {
|
|||
* @return {void}
|
||||
*/
|
||||
async init( urlHash ) {
|
||||
// Create tabs, header, and indicator elements sequentially
|
||||
await this.createTabs();
|
||||
await this.createHeader();
|
||||
await this.createIndicator();
|
||||
|
||||
const activeTab = this.tablist.querySelector( `#tab-${ CSS.escape( urlHash ) }` ) || this.tablist.firstElementChild;
|
||||
const activeTab = this.tablist.querySelector( `#tabber-tab-${ CSS.escape( urlHash ) }` ) || this.tablist.firstElementChild;
|
||||
this.setTabsAttributes();
|
||||
await TabberAction.setActiveTab( activeTab );
|
||||
TabberAction.updateHeaderOverflow( this.tablist );
|
||||
|
||||
// Start attaching event
|
||||
const tabberEvent = new TabberEvent( this.tabber, this.tablist );
|
||||
tabberEvent.init();
|
||||
|
||||
this.tabber.classList.remove( 'tabber--init' );
|
||||
this.tabber.classList.add( 'tabber--live' );
|
||||
}
|
||||
|
@ -686,7 +491,7 @@ async function load( tabberEls ) {
|
|||
TabberAction.toggleAnimation( true );
|
||||
window.addEventListener( 'hashchange', ( event ) => {
|
||||
const newHash = window.location.hash.slice( 1 );
|
||||
const tab = document.getElementById( `tab-${ CSS.escape( newHash ) }` );
|
||||
const tab = document.getElementById( `tabber-tab-${ CSS.escape( newHash ) }` );
|
||||
if ( tab ) {
|
||||
event.preventDefault();
|
||||
tab.click();
|
||||
|
@ -702,7 +507,7 @@ async function load( tabberEls ) {
|
|||
* each tabber element.
|
||||
*/
|
||||
function main() {
|
||||
const tabberEls = document.querySelectorAll( '.tabber:not(.tabber--live)' );
|
||||
const tabberEls = document.querySelectorAll( '.tabber--init' );
|
||||
|
||||
if ( tabberEls.length === 0 ) {
|
||||
return;
|
||||
|
|
|
@ -9,12 +9,6 @@
|
|||
--tabber-background-color-button-quiet--active: var( --background-color-button-quiet--active, rgba( 0, 24, 73, 0.082 ) ); // @background-color-button-quiet--active
|
||||
--tabber-border-color-base: var( --border-color-base, #a2a9b1 ); // @border-color-base
|
||||
--tabber-height-indicator: 2px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
/* establish primary containing box */
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
|
||||
&__header {
|
||||
position: relative;
|
||||
|
@ -43,10 +37,10 @@
|
|||
& &__prev,
|
||||
& &__next {
|
||||
position: absolute;
|
||||
display: none; // Required as all:unset also unset display:none
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
width: 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
|
@ -83,18 +77,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
display: none;
|
||||
margin-top: ~'calc( var( --tabber-height-indicator ) * -1 )';
|
||||
background: var( --tabber-color-progressive );
|
||||
block-size: var( --tabber-height-indicator );
|
||||
inline-size: 0;
|
||||
|
||||
&--visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__tab {
|
||||
&[ aria-selected='true' ] {
|
||||
box-shadow: 0 -2px 0 var( --color-progressive, #36c ) inset;
|
||||
|
|
|
@ -64,49 +64,18 @@ ve.ce.MWTabberNode.prototype.onSetup = function () {
|
|||
*/
|
||||
ve.ce.MWTabberNode.prototype.renderHeader = function ( tabber ) {
|
||||
const nestedTabbers = tabber.querySelectorAll( '.tabber__panel:first-child .tabber' );
|
||||
|
||||
const renderSingleHeader = function ( element ) {
|
||||
const
|
||||
tabPanels = element.querySelectorAll( ':scope > .tabber__section > .tabber__panel' ),
|
||||
header = element.querySelector( ':scope > .tabber__header' ),
|
||||
tabList = document.createElement( 'nav' ),
|
||||
indicator = document.createElement( 'div' ),
|
||||
fragment = new DocumentFragment();
|
||||
|
||||
Array.prototype.forEach.call( tabPanels, function ( tabPanel, index ) {
|
||||
const tab = document.createElement( 'a' );
|
||||
|
||||
tab.innerText = tabPanel.getAttribute( 'data-mw-tabber-title' );
|
||||
tab.classList.add( 'tabber__tab' );
|
||||
|
||||
// Make first tab active
|
||||
if ( index === 0 ) {
|
||||
tab.setAttribute( 'aria-selected', true );
|
||||
}
|
||||
|
||||
fragment.append( tab );
|
||||
} );
|
||||
|
||||
tabList.append( fragment );
|
||||
|
||||
tabList.classList.add( 'tabber__tabs' );
|
||||
indicator.classList.add( 'tabber__indicator' );
|
||||
|
||||
header.append( tabList, indicator );
|
||||
|
||||
indicator.style.width = tabList.firstElementChild.offsetWidth + 'px';
|
||||
|
||||
element.classList.add( 'tabber--live' );
|
||||
const firstTab = element.querySelector( ':scope > .tabber__header > .tabber__tabs > .tabber__tab' );
|
||||
firstTab.setAttribute( 'aria-selected', true );
|
||||
};
|
||||
|
||||
if ( nestedTabbers.length > 0 ) {
|
||||
Array.prototype.forEach.call( nestedTabbers, function ( nestedTabber ) {
|
||||
Array.prototype.forEach.call( nestedTabbers, ( nestedTabber ) => {
|
||||
renderSingleHeader( nestedTabber );
|
||||
} );
|
||||
}
|
||||
|
||||
renderSingleHeader( tabber );
|
||||
|
||||
lastHeader = tabber.firstElementChild;
|
||||
};
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ ve.ce.MWTabberTranscludeNode.prototype.renderHeader = function ( tabber ) {
|
|||
indicator = document.createElement( 'div' ),
|
||||
fragment = new DocumentFragment();
|
||||
|
||||
Array.prototype.forEach.call( tabPanels, function ( tabPanel, index ) {
|
||||
Array.prototype.forEach.call( tabPanels, ( tabPanel, index ) => {
|
||||
const tab = document.createElement( 'a' );
|
||||
|
||||
tab.innerText = tabPanel.getAttribute( 'data-mw-tabber-title' );
|
||||
|
|
11
package-lock.json
generated
11
package-lock.json
generated
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "mediawiki-extensions-TabberNeue",
|
||||
"name": "TabberNeue",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
@ -1003,9 +1003,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001621",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001621.tgz",
|
||||
"integrity": "sha512-+NLXZiviFFKX0fk8Piwv3PfLPGtRqJeq2TiNoUff/qB5KJgwecJTvCXDpmlyP/eCI/GUEmp/h/y5j0yckiiZrA==",
|
||||
"version": "1.0.30001680",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz",
|
||||
"integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
@ -1020,7 +1020,8 @@
|
|||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
]
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
|
|
Loading…
Reference in a new issue