mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-03 00:18:31 +00:00
Address issues with Directory support (#342)
* Address issues with Directory support De-duplicate `modifyStreams` for Game and Community * More de-duplication! PogChamp * Early return for following.js * Rely on `props.directoryType` instead of the router * Move a method around to properly group them * Fix for game buttons not properly showing when switching tabs * More fixes!
This commit is contained in:
parent
fcc3cab35f
commit
60f86033e8
5 changed files with 263 additions and 597 deletions
|
@ -5,24 +5,12 @@
|
|||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import {createElement as e} from 'utilities/dom';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
|
||||
export default class Community extends SiteModule {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('site.fine');
|
||||
this.inject('site.router');
|
||||
this.inject('site.apollo');
|
||||
this.inject('site.css_tweaks');
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.GameHeader = this.fine.define(
|
||||
'game-header',
|
||||
n => n.renderFollowButton && n.renderGameDetailsTab
|
||||
);
|
||||
|
||||
this.apollo.registerModifier('GamePage_Game', `query {
|
||||
directory {
|
||||
|
@ -41,265 +29,5 @@ export default class Community extends SiteModule {
|
|||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
this.ChannelCard = this.fine.define(
|
||||
'community-channel-card',
|
||||
n => n.props && n.props.streamNode
|
||||
);
|
||||
|
||||
this.apollo.registerModifier('GamePage_Game', res => this.router.current.name === 'dir-community' && this.modifyStreams(res), false);
|
||||
|
||||
this.on('settings:changed:show-channel-avatar', value => {
|
||||
this.css_tweaks.toggleHide('profile-hover-game', value === 2);
|
||||
this.router.current.name === 'dir-community' && this.ChannelCard.forceUpdate();
|
||||
});
|
||||
|
||||
this.on('settings:changed:directory.following.uptime', () => this.router.current.name === 'dir-community' && this.ChannelCard.forceUpdate());
|
||||
}
|
||||
|
||||
modifyStreams(res) { // eslint-disable-line class-methods-use-this
|
||||
const newStreams = [];
|
||||
|
||||
const edges = res.data.directory.streams.edges;
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const node = edge.node;
|
||||
|
||||
const s = node.viewersCount = new Number(node.viewersCount || 0);
|
||||
s.profileImageURL = node.broadcaster.profileImageURL;
|
||||
s.createdAt = node.createdAt;
|
||||
|
||||
newStreams.push(edge);
|
||||
}
|
||||
res.data.directory.streams.edges = newStreams;
|
||||
return res;
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
this.GameHeader.ready((cls, instances) => {
|
||||
if (this.router.current.name === 'dir-community') {
|
||||
for(const inst of instances) this.updateButtons(inst);
|
||||
}
|
||||
});
|
||||
|
||||
this.ChannelCard.ready((cls, instances) => {
|
||||
if (this.router.current.name === 'dir-community') {
|
||||
this.apollo.ensureQuery(
|
||||
'GamePage_Game',
|
||||
'data.directory.streams.edges.0.node.createdAt'
|
||||
);
|
||||
|
||||
for(const inst of instances) this.updateChannelCard(inst);
|
||||
}
|
||||
});
|
||||
|
||||
this.ChannelCard.on('update', inst => this.router.current.name === 'dir-community' && this.updateChannelCard(inst), this);
|
||||
|
||||
this.ChannelCard.on('mount', inst => this.router.current.name === 'dir-community' && this.updateChannelCard(inst), this);
|
||||
|
||||
this.ChannelCard.on('unmount', inst => this.router.current.name === 'dir-community' && this.updateUptime(inst), this);
|
||||
|
||||
this.css_tweaks.toggleHide('profile-hover-game', this.settings.get('directory.following.show-channel-avatar') === 2);
|
||||
}
|
||||
|
||||
updateUptime(inst) {
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const card = container && container.querySelector && container.querySelector('.tw-card');
|
||||
|
||||
if (container === null || card === null) {
|
||||
if (inst.updateTimer !== undefined) {
|
||||
clearInterval(inst.updateTimer);
|
||||
inst.updateTimer = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.get('directory.following.uptime') === 0) {
|
||||
if (inst.updateTimer !== undefined) {
|
||||
clearInterval(inst.updateTimer);
|
||||
inst.updateTimer = undefined;
|
||||
}
|
||||
|
||||
if (inst.uptimeElement !== undefined) {
|
||||
inst.uptimeElement.remove();
|
||||
inst.uptimeElementSpan = inst.uptimeElement = undefined;
|
||||
}
|
||||
} else {
|
||||
if (inst.updateTimer === undefined) {
|
||||
inst.updateTimer = setInterval(
|
||||
this.updateUptime.bind(this, inst),
|
||||
1000
|
||||
);
|
||||
}
|
||||
|
||||
const up_since = new Date(inst.props.streamNode.viewersCount.createdAt);
|
||||
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
|
||||
const uptimeText = duration_to_string(uptime, false, false, false, this.settings.get('directory.following.uptime') === 1);
|
||||
|
||||
if (uptime > 0) {
|
||||
if (inst.uptimeElement === undefined) {
|
||||
inst.uptimeElementSpan = e('span', 'tw-stat__value ffz-uptime', `${uptimeText}`);
|
||||
inst.uptimeElement = e('div', {
|
||||
className: 'c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
|
||||
style: 'padding-left: 4px; padding-right: 4px;'
|
||||
}, [
|
||||
e('span', 'tw-stat__icon',
|
||||
e('figure', 'ffz-i-clock')
|
||||
),
|
||||
inst.uptimeElementSpan
|
||||
]);
|
||||
|
||||
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
|
||||
} else {
|
||||
inst.uptimeElementSpan.textContent = `${uptimeText}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateChannelCard(inst) {
|
||||
this.updateUptime(inst);
|
||||
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const card = container && container.querySelector && container.querySelector('.tw-card');
|
||||
|
||||
if (container === null || card === null) return;
|
||||
|
||||
if (!inst.props.streamNode.viewersCount.createdAt) return;
|
||||
|
||||
// Remove old elements
|
||||
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
|
||||
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
|
||||
|
||||
const ffzChannelData = card.querySelector('.ffz-channel-data');
|
||||
if (ffzChannelData !== null) ffzChannelData.remove();
|
||||
|
||||
const channelAvatar = card.querySelector('.channel-avatar');
|
||||
if (channelAvatar !== null) channelAvatar.remove();
|
||||
|
||||
if (inst.props.streamNode.viewersCount.profileImageURL) {
|
||||
const avatarSetting = this.settings.get('directory.following.show-channel-avatar');
|
||||
if (avatarSetting === 1) {
|
||||
const cardDiv = card.querySelector('.tw-card-body');
|
||||
const modifiedDiv = e('div', {
|
||||
innerHTML: cardDiv.innerHTML
|
||||
});
|
||||
|
||||
const avatarDiv = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: `/${inst.props.streamNode.broadcaster.login}`,
|
||||
style: 'margin-right: 8px; min-width: 4rem; margin-top: 0.5rem;',
|
||||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
}, e('img', {
|
||||
title: inst.props.streamNode.broadcaster.displayName,
|
||||
src: inst.props.streamNode.viewersCount.profileImageURL,
|
||||
style: 'height: 4rem;'
|
||||
}));
|
||||
|
||||
const cardDivParent = cardDiv.parentElement;
|
||||
|
||||
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
|
||||
cardDiv.classList.add('hide');
|
||||
|
||||
const newCardDiv = e('div', 'ffz-channel-data flex flex-nowrap', [
|
||||
avatarDiv, modifiedDiv
|
||||
]);
|
||||
cardDivParent.appendChild(newCardDiv);
|
||||
}
|
||||
} else if (avatarSetting === 2 || avatarSetting === 3) {
|
||||
const avatarElement = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: `/${inst.props.streamNode.broadcaster.login}`,
|
||||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
|
||||
e('figure', 'tw-aspect tw-aspect--align-top',
|
||||
e('img', {
|
||||
title: inst.props.streamNode.broadcaster.displayName,
|
||||
src: inst.props.streamNode.viewersCount.profileImageURL
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const divToAppend = card.querySelector('figure.tw-aspect');
|
||||
if (divToAppend.querySelector('.channel-avatar') === null) divToAppend.appendChild(avatarElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateButtons(inst) {
|
||||
const container = this.fine.getHostNode(inst);
|
||||
// We can't get the buttons through querySelector('button ...') so this has to do for now...
|
||||
const buttons = container && container.querySelector && container.querySelector('div > div.align-items-center');
|
||||
|
||||
const ffzButtons = buttons.querySelector('.ffz-buttons');
|
||||
if (ffzButtons !== null) ffzButtons.remove();
|
||||
|
||||
if (buttons.querySelector('.ffz-buttons') === null) {
|
||||
// Block / Unblock Games
|
||||
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
|
||||
const gameBlocked = blockedGames.includes(inst.props.directoryName);
|
||||
|
||||
const blockButton = e('button', {
|
||||
className: 'mg-l-1 tw-button ffz-toggle-game-block',
|
||||
style: `background-color: ${gameBlocked ? '#228B22' : '#B22222'};`
|
||||
}, e('span', {
|
||||
className: 'tw-button__text',
|
||||
textContent: `${gameBlocked ? 'Unblock' : 'Block'}`
|
||||
})
|
||||
);
|
||||
|
||||
blockButton.addEventListener('click', () => {
|
||||
const gameName = inst.props.directoryName;
|
||||
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
|
||||
if (blockedGames.includes(gameName)) blockedGames.splice(blockedGames.indexOf(gameName), 1);
|
||||
else blockedGames.push(gameName);
|
||||
|
||||
this.settings.provider.set('directory.game.blocked-games', blockedGames);
|
||||
|
||||
this.updateButtons(inst);
|
||||
});
|
||||
|
||||
// Hide / Unhide Thumbnails
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const thumbnailBlocked = hiddenThumbnails.includes(inst.props.directoryName);
|
||||
|
||||
const hideThumbnailButton = e('button', {
|
||||
className: 'mg-l-1 tw-button ffz-toggle-thumbnail',
|
||||
style: `background-color: ${thumbnailBlocked ? '#228B22' : '#B22222'};`
|
||||
}, e('span', {
|
||||
className: 'tw-button__text',
|
||||
textContent: `${thumbnailBlocked ? 'Unhide Thumbnails' : 'Hide Thumbnails'}`
|
||||
})
|
||||
);
|
||||
|
||||
hideThumbnailButton.addEventListener('click', () => {
|
||||
const gameName = inst.props.directoryName;
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
if (hiddenThumbnails.includes(gameName)) hiddenThumbnails.splice(hiddenThumbnails.indexOf(gameName), 1);
|
||||
else hiddenThumbnails.push(gameName);
|
||||
|
||||
this.settings.provider.set('directory.game.hidden-thumbnails', hiddenThumbnails);
|
||||
|
||||
this.updateButtons(inst);
|
||||
});
|
||||
|
||||
const ffzButtons = e('div', 'ffz-buttons', [
|
||||
blockButton,
|
||||
hideThumbnailButton
|
||||
]);
|
||||
|
||||
buttons.appendChild(ffzButtons);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,7 +7,6 @@
|
|||
import {SiteModule} from 'utilities/module';
|
||||
import {createElement as e} from 'utilities/dom';
|
||||
import {get} from 'utilities/object';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
|
||||
import Popper from 'popper.js';
|
||||
|
||||
|
@ -178,29 +177,25 @@ export default class Following extends SiteModule {
|
|||
}`);
|
||||
|
||||
this.ChannelCard = this.fine.define(
|
||||
'channel-card',
|
||||
'following-channel-card',
|
||||
n => n.renderGameBoxArt && n.renderContentType
|
||||
);
|
||||
|
||||
this.apollo.registerModifier('FollowedIndex_CurrentUser', res => {
|
||||
res = this.modifyLiveUsers(res);
|
||||
res = this.modifyLiveHosts(res);
|
||||
return res;
|
||||
this.modifyLiveUsers(res);
|
||||
this.modifyLiveHosts(res);
|
||||
}, false);
|
||||
|
||||
this.apollo.registerModifier('FollowingLive_CurrentUser', res => this.modifyLiveUsers(res), false);
|
||||
|
||||
this.apollo.registerModifier('FollowingHosts_CurrentUser', res => this.modifyLiveHosts(res), false);
|
||||
}
|
||||
|
||||
isRouteAcceptable() {
|
||||
return this.router.current.name === 'dir-following-index'
|
||||
return this.router.current.name === 'dir-following'
|
||||
|| this.router.current.name === 'dir-category' && this.router.match[1] === 'following';
|
||||
}
|
||||
|
||||
modifyLiveUsers(res) { // eslint-disable-line class-methods-use-this
|
||||
if (!this.isRouteAcceptable()) return res;
|
||||
|
||||
modifyLiveUsers(res) {
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
|
||||
|
||||
|
@ -221,9 +216,7 @@ export default class Following extends SiteModule {
|
|||
return res;
|
||||
}
|
||||
|
||||
modifyLiveHosts(res) { // eslint-disable-line class-methods-use-this
|
||||
if (!this.isRouteAcceptable()) return res;
|
||||
|
||||
modifyLiveHosts(res) {
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
|
||||
|
||||
|
@ -266,8 +259,6 @@ export default class Following extends SiteModule {
|
|||
this.css_tweaks.toggleHide('boxart-hide', this.settings.get('directory.following.hide-boxart') === 2);
|
||||
this.css_tweaks.toggleHide('profile-hover-following', this.settings.get('directory.following.show-channel-avatar') === 2);
|
||||
|
||||
this.ChannelCard.on('update', inst => this.updateChannelCard(inst), this);
|
||||
|
||||
this.ChannelCard.ready((cls, instances) => {
|
||||
if (this.router && this.router.match) {
|
||||
if (this.router.match[1] === 'following') {
|
||||
|
@ -294,6 +285,7 @@ export default class Following extends SiteModule {
|
|||
for(const inst of instances) this.updateChannelCard(inst);
|
||||
});
|
||||
|
||||
this.ChannelCard.on('update', inst => this.updateChannelCard(inst), this);
|
||||
this.ChannelCard.on('mount', inst => this.updateChannelCard(inst), this);
|
||||
this.ChannelCard.on('unmount', inst => this.parent.clearUptime(inst), this);
|
||||
|
||||
|
@ -301,7 +293,7 @@ export default class Following extends SiteModule {
|
|||
}
|
||||
|
||||
destroyHostMenu(event) {
|
||||
if (event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) {
|
||||
if (!event || event && event.target && event.target.closest('.ffz-channel-selector-outer') === null && Date.now() > this.hostMenuBuffer) {
|
||||
this.hostMenuPopper && this.hostMenuPopper.destroy();
|
||||
this.hostMenu && this.hostMenu.remove();
|
||||
this.hostMenuPopper = this.hostMenu = undefined;
|
||||
|
@ -332,14 +324,9 @@ export default class Following extends SiteModule {
|
|||
simplebarContentChildren.push(
|
||||
e('a', {
|
||||
className: 'tw-interactable',
|
||||
href: inst.props.linkTo.pathname,
|
||||
href: `/${inst.props.viewerCount.hostData.channel}`,
|
||||
style: 'padding-top: 0.1rem; padding-bottom: 0.1rem;',
|
||||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.linkTo.pathname.substring(1)});
|
||||
}
|
||||
onclick: event => this.parent.hijackUserClick(event, inst.props.viewerCount.hostData.channel, this.destroyHostMenu.bind(this))
|
||||
}, e('div', 'align-items-center flex flex-row flex-nowrap mg-x-1 mg-y-05',
|
||||
[
|
||||
e('div', {
|
||||
|
@ -374,6 +361,7 @@ export default class Following extends SiteModule {
|
|||
className: 'tw-interactable',
|
||||
href: `/${node.login}`,
|
||||
style: 'padding-top: 0.1rem; padding-bottom: 0.1rem;',
|
||||
onclick: event => this.parent.hijackUserClick(event, node.login, this.destroyHostMenu.bind(this))
|
||||
}, e('div', 'align-items-center flex flex-row flex-nowrap mg-x-1 mg-y-05',
|
||||
[
|
||||
e('div', {
|
||||
|
@ -427,6 +415,8 @@ export default class Following extends SiteModule {
|
|||
}
|
||||
|
||||
updateChannelCard(inst) {
|
||||
if (!this.isRouteAcceptable()) return;
|
||||
|
||||
this.parent.updateUptime(inst, 'props.viewerCount.createdAt', '.tw-card .tw-aspect > div');
|
||||
|
||||
const container = this.fine.getHostNode(inst),
|
||||
|
@ -435,11 +425,6 @@ export default class Following extends SiteModule {
|
|||
if ( container === null || card === null )
|
||||
return;
|
||||
|
||||
const channelCardTitle = card.querySelector('.live-channel-card__title');
|
||||
|
||||
if ( channelCardTitle === null )
|
||||
return;
|
||||
|
||||
// Remove old elements
|
||||
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
|
||||
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
|
||||
|
@ -451,7 +436,7 @@ export default class Following extends SiteModule {
|
|||
if (channelAvatar !== null) channelAvatar.remove();
|
||||
|
||||
if (inst.props.viewerCount.profileImageURL) {
|
||||
const hosting = inst.props.viewerCount.hostData;
|
||||
const hosting = inst.props.channelNameLinkTo.state.content === 'live_host' && inst.props.viewerCount.hostData;
|
||||
let channel, displayName;
|
||||
if (hosting) {
|
||||
channel = inst.props.viewerCount.hostData.channel;
|
||||
|
@ -479,12 +464,7 @@ export default class Following extends SiteModule {
|
|||
const avatarElement = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: hosting ? `/${channel}` : inst.props.linkTo.pathname,
|
||||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
onclick: event => this.parent.hijackUserClick(event, inst.props.streamNode.broadcaster.login)
|
||||
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
|
||||
e('figure', 'tw-aspect tw-aspect--align-top',
|
||||
e('img', {
|
||||
|
@ -515,16 +495,17 @@ export default class Following extends SiteModule {
|
|||
if (this.settings.get('directory.following.group-hosts')) {
|
||||
const titleLink = card.querySelector('.ffz-channel-data a[data-a-target="live-channel-card-title-link"]');
|
||||
const thumbnailLink = card.querySelector('a[data-a-target="live-channel-card-thumbnail-link"]');
|
||||
const channelCardTitle = card.querySelector('.ffz-channel-data .live-channel-card__title');
|
||||
|
||||
if (hostObj.channels.length > 1) {
|
||||
const textContent = `${hostObj.channels.length} hosting ${displayName}`;
|
||||
const textContent = hostObj.channels.length > 1 ? `${hostObj.channels.length} hosting ${displayName}` : inst.props.title;
|
||||
if (channelCardTitle !== null) {
|
||||
channelCardTitle.textContent
|
||||
= channelCardTitle.title
|
||||
= textContent;
|
||||
|
||||
if (thumbnailLink !== null) thumbnailLink.title = textContent;
|
||||
}
|
||||
|
||||
if (thumbnailLink !== null) thumbnailLink.title = textContent;
|
||||
|
||||
if (titleLink !== null) titleLink.onclick = this.showHostMenu.bind(this, inst, hostObj);
|
||||
if (thumbnailLink !== null) thumbnailLink.onclick = this.showHostMenu.bind(this, inst, hostObj);
|
||||
}
|
||||
|
|
|
@ -6,16 +6,13 @@
|
|||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import {createElement as e} from 'utilities/dom';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
|
||||
export default class Game extends SiteModule {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.inject('site.fine');
|
||||
this.inject('site.router');
|
||||
this.inject('site.apollo');
|
||||
this.inject('site.css_tweaks');
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
|
@ -41,223 +38,26 @@ export default class Game extends SiteModule {
|
|||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
this.ChannelCard = this.fine.define(
|
||||
'game-channel-card',
|
||||
n => n.props && n.props.streamNode
|
||||
);
|
||||
|
||||
this.apollo.registerModifier('GamePage_Game', res => this.router.current.name === 'dir-game-index' && this.modifyStreams(res), false);
|
||||
|
||||
this.on('site.directory.following:update-show-channel-avatar', value => {
|
||||
this.css_tweaks.toggleHide('profile-hover-game', value === 2);
|
||||
this.router.current.name === 'dir-game-index' && this.ChannelCard.forceUpdate();
|
||||
});
|
||||
|
||||
this.on('site.directory.following:update-uptime', () => this.router.current.name === 'dir-game-index' && this.ChannelCard.forceUpdate());
|
||||
}
|
||||
|
||||
modifyStreams(res) { // eslint-disable-line class-methods-use-this
|
||||
const newStreams = [];
|
||||
|
||||
// const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
// const thumbnailBlocked = hiddenThumbnails.includes(res.data.directory.displayName);
|
||||
|
||||
const edges = res.data.directory.streams.edges;
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const node = edge.node;
|
||||
|
||||
const s = node.viewersCount = new Number(node.viewersCount || 0);
|
||||
s.profileImageURL = node.broadcaster.profileImageURL;
|
||||
s.createdAt = node.createdAt;
|
||||
|
||||
// if (thumbnailBlocked) edge.node.previewImageURL = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg';
|
||||
|
||||
newStreams.push(edge);
|
||||
}
|
||||
res.data.directory.streams.edges = newStreams;
|
||||
return res;
|
||||
}
|
||||
|
||||
onEnable() {
|
||||
this.GameHeader.ready((cls, instances) => {
|
||||
if (this.router.current.name === 'dir-game-index') {
|
||||
for(const inst of instances) this.updateButtons(inst);
|
||||
}
|
||||
for(const inst of instances) this.updateButtons(inst);
|
||||
});
|
||||
|
||||
this.ChannelCard.ready((cls, instances) => {
|
||||
if (this.router.current.name === 'dir-game-index') {
|
||||
this.apollo.ensureQuery(
|
||||
'GamePage_Game',
|
||||
'data.directory.streams.edges.0.node.createdAt'
|
||||
);
|
||||
|
||||
for(const inst of instances) this.updateChannelCard(inst);
|
||||
}
|
||||
});
|
||||
|
||||
this.ChannelCard.on('update', inst => this.router.current.name === 'dir-game-index' && this.updateChannelCard(inst), this);
|
||||
|
||||
this.ChannelCard.on('mount', inst => this.router.current.name === 'dir-game-index' && this.updateChannelCard(inst), this);
|
||||
|
||||
this.ChannelCard.on('unmount', inst => this.router.current.name === 'dir-game-index' && this.updateUptime(inst), this);
|
||||
|
||||
this.css_tweaks.toggleHide('profile-hover-game', this.settings.get('directory.following.show-channel-avatar') === 2);
|
||||
this.GameHeader.on('update', inst => this.updateButtons(inst));
|
||||
}
|
||||
|
||||
updateUptime(inst) {
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const card = container && container.querySelector && container.querySelector('.tw-thumbnail-card');
|
||||
updateButtons(inst, update = false) {
|
||||
if (inst.props.directoryType !== 'GAMES') return;
|
||||
|
||||
if (container === null || card === null) {
|
||||
if (inst.updateTimer !== undefined) {
|
||||
clearInterval(inst.updateTimer);
|
||||
inst.updateTimer = undefined;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.settings.get('directory.following.uptime') === 0) {
|
||||
if (inst.updateTimer !== undefined) {
|
||||
clearInterval(inst.updateTimer);
|
||||
inst.updateTimer = undefined;
|
||||
}
|
||||
|
||||
if (inst.uptimeElement !== undefined) {
|
||||
inst.uptimeElement.remove();
|
||||
inst.uptimeElementSpan = inst.uptimeElement = undefined;
|
||||
}
|
||||
} else {
|
||||
if (inst.updateTimer === undefined) {
|
||||
inst.updateTimer = setInterval(
|
||||
this.updateUptime.bind(this, inst),
|
||||
1000
|
||||
);
|
||||
}
|
||||
|
||||
const up_since = new Date(inst.props.streamNode.viewersCount.createdAt);
|
||||
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
|
||||
const uptimeText = duration_to_string(uptime, false, false, false, this.settings.get('directory.following.uptime') === 1);
|
||||
|
||||
if (uptime > 0) {
|
||||
if (inst.uptimeElement === undefined) {
|
||||
inst.uptimeElementSpan = e('span', 'tw-stat__value ffz-uptime', `${uptimeText}`);
|
||||
inst.uptimeElement = e('div', {
|
||||
className: 'c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
|
||||
style: 'padding-left: 4px; padding-right: 4px;'
|
||||
}, [
|
||||
e('span', 'tw-stat__icon',
|
||||
e('figure', 'ffz-i-clock')
|
||||
),
|
||||
inst.uptimeElementSpan
|
||||
]);
|
||||
|
||||
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
|
||||
} else {
|
||||
inst.uptimeElementSpan.textContent = `${uptimeText}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateChannelCard(inst) {
|
||||
this.updateUptime(inst);
|
||||
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const card = container && container.querySelector && container.querySelector('.tw-thumbnail-card');
|
||||
|
||||
if (container === null || card === null) return;
|
||||
|
||||
if (!inst.props.streamNode.viewersCount.createdAt || container === null || card === null) return;
|
||||
|
||||
// Remove old elements
|
||||
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
|
||||
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
|
||||
|
||||
const ffzChannelData = card.querySelector('.ffz-channel-data');
|
||||
if (ffzChannelData !== null) ffzChannelData.remove();
|
||||
|
||||
const channelAvatar = card.querySelector('.channel-avatar');
|
||||
if (channelAvatar !== null) channelAvatar.remove();
|
||||
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const thumbnailBlocked = hiddenThumbnails.includes(inst.props.directoryName);
|
||||
|
||||
if (thumbnailBlocked) {
|
||||
card.classList.add('ffz-thumbnail-hidden');
|
||||
} else {
|
||||
card.classList.remove('ffz-thumbnail-hidden');
|
||||
}
|
||||
|
||||
if (inst.props.streamNode.viewersCount.profileImageURL) {
|
||||
const avatarSetting = this.settings.get('directory.following.show-channel-avatar');
|
||||
if (avatarSetting === 1) {
|
||||
const cardDiv = card.querySelector('.tw-card-body');
|
||||
const modifiedDiv = e('div', {
|
||||
innerHTML: cardDiv.innerHTML
|
||||
});
|
||||
|
||||
const avatarDiv = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: `/${inst.props.streamNode.broadcaster.login}`,
|
||||
style: 'margin-right: 8px; min-width: 4rem; margin-top: 0.5rem;',
|
||||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
}, e('img', {
|
||||
title: inst.props.streamNode.broadcaster.displayName,
|
||||
src: inst.props.streamNode.viewersCount.profileImageURL,
|
||||
style: 'height: 4rem;'
|
||||
}));
|
||||
|
||||
const cardDivParent = cardDiv.parentElement;
|
||||
|
||||
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
|
||||
cardDiv.classList.add('hide');
|
||||
|
||||
const newCardDiv = e('div', 'ffz-channel-data flex flex-nowrap', [
|
||||
avatarDiv, modifiedDiv
|
||||
]);
|
||||
cardDivParent.appendChild(newCardDiv);
|
||||
}
|
||||
} else if (avatarSetting === 2 || avatarSetting === 3) {
|
||||
const avatarElement = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: `/${inst.props.streamNode.broadcaster.login}`,
|
||||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
|
||||
e('figure', 'tw-aspect tw-aspect--align-top',
|
||||
e('img', {
|
||||
title: inst.props.streamNode.broadcaster.displayName,
|
||||
src: inst.props.streamNode.viewersCount.profileImageURL
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const divToAppend = card.querySelector('figure.tw-aspect');
|
||||
if (divToAppend.querySelector('.channel-avatar') === null) divToAppend.appendChild(avatarElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateButtons(inst) {
|
||||
const container = this.fine.getHostNode(inst);
|
||||
// We can't get the buttons through querySelector('button ...') so this has to do for now...
|
||||
const buttons = container && container.querySelector && container.querySelector('div > div.align-items-center');
|
||||
|
||||
const ffzButtons = buttons.querySelector('.ffz-buttons');
|
||||
if (ffzButtons !== null) ffzButtons.remove();
|
||||
if (ffzButtons !== null && !update) return;
|
||||
else if (ffzButtons) ffzButtons.remove();
|
||||
|
||||
if (buttons.querySelector('.ffz-buttons') === null) {
|
||||
// Block / Unblock Games
|
||||
|
@ -281,7 +81,7 @@ export default class Game extends SiteModule {
|
|||
|
||||
this.settings.provider.set('directory.game.blocked-games', blockedGames);
|
||||
|
||||
this.updateButtons(inst);
|
||||
this.updateButtons(inst, true);
|
||||
});
|
||||
|
||||
// Hide / Unhide Thumbnails
|
||||
|
@ -305,8 +105,8 @@ export default class Game extends SiteModule {
|
|||
|
||||
this.settings.provider.set('directory.game.hidden-thumbnails', hiddenThumbnails);
|
||||
|
||||
this.updateButtons(inst);
|
||||
this.ChannelCard.forceUpdate();
|
||||
this.parent.ChannelCard.forceUpdate();
|
||||
this.updateButtons(inst, true);
|
||||
});
|
||||
|
||||
const ffzButtons = e('div', 'ffz-buttons', [
|
||||
|
|
234
src/sites/twitch-twilight/modules/directory/index.js
Normal file
234
src/sites/twitch-twilight/modules/directory/index.js
Normal file
|
@ -0,0 +1,234 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Directory
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
import {createElement as e} from 'utilities/dom';
|
||||
import {get} from 'utilities/object';
|
||||
|
||||
import Following from './following';
|
||||
import Game from './game';
|
||||
import Community from './community';
|
||||
|
||||
export default class Directory extends SiteModule {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('site.fine');
|
||||
this.inject('site.router');
|
||||
this.inject('site.apollo');
|
||||
this.inject('site.css_tweaks');
|
||||
|
||||
this.inject('settings');
|
||||
|
||||
this.inject(Following);
|
||||
this.inject(Game);
|
||||
this.inject(Community);
|
||||
|
||||
this.apollo.registerModifier('GamePage_Game', res => this.modifyStreams(res), false);
|
||||
|
||||
this.ChannelCard = this.fine.define(
|
||||
'channel-card',
|
||||
n => n.props && n.props.streamNode
|
||||
);
|
||||
|
||||
this.on('settings:changed:directory.following.show-channel-avatar', value => {
|
||||
this.css_tweaks.toggleHide('profile-hover-game', value === 2);
|
||||
this.ChannelCard.forceUpdate();
|
||||
});
|
||||
|
||||
this.on('settings:changed:directory.following.uptime', () => this.ChannelCard.forceUpdate());
|
||||
}
|
||||
|
||||
|
||||
onEnable() {
|
||||
this.css_tweaks.toggleHide('profile-hover-game', this.settings.get('directory.following.show-channel-avatar') === 2);
|
||||
|
||||
this.ChannelCard.ready((cls, instances) => {
|
||||
this.apollo.ensureQuery(
|
||||
'GamePage_Game',
|
||||
'data.directory.streams.edges.0.node.createdAt'
|
||||
);
|
||||
|
||||
for(const inst of instances) this.updateChannelCard(inst);
|
||||
});
|
||||
|
||||
this.ChannelCard.on('update', inst => this.updateChannelCard(inst), this);
|
||||
this.ChannelCard.on('mount', inst => this.updateChannelCard(inst), this);
|
||||
this.ChannelCard.on('unmount', inst => this.clearUptime(inst), this);
|
||||
}
|
||||
|
||||
|
||||
updateChannelCard(inst) {
|
||||
const uptimeSel = inst.props.directoryType === 'GAMES' ? '.tw-thumbnail-card .tw-card-img' : '.tw-card .tw-aspect > div';
|
||||
const avatarSel = inst.props.directoryType === 'GAMES' ? '.tw-thumbnail-card' : '.tw-card';
|
||||
|
||||
this.updateUptime(inst, 'props.streamNode.viewersCount.createdAt', uptimeSel);
|
||||
this.addCardAvatar(inst, avatarSel);
|
||||
|
||||
const type = inst.props.directoryType;
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const hiddenPreview = 'https://static-cdn.jtvnw.net/ttv-static/404_preview-320x180.jpg';
|
||||
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const img = container && container.querySelector && container.querySelector(`${uptimeSel} img`);
|
||||
if (img === null) return;
|
||||
|
||||
if (type === 'GAMES' && hiddenThumbnails.includes(inst.props.directoryName) ||
|
||||
type === 'COMMUNITIES' && hiddenThumbnails.includes(inst.props.streamNode.game.name)) {
|
||||
img.src = hiddenPreview;
|
||||
} else {
|
||||
img.src = inst.props.streamNode.previewImageURL;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
modifyStreams(res) { // eslint-disable-line class-methods-use-this
|
||||
const newStreams = [];
|
||||
|
||||
const edges = res.data.directory.streams.edges;
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const edge = edges[i];
|
||||
const node = edge.node;
|
||||
|
||||
const s = node.viewersCount = new Number(node.viewersCount || 0);
|
||||
s.profileImageURL = node.broadcaster.profileImageURL;
|
||||
s.createdAt = node.createdAt;
|
||||
|
||||
newStreams.push(edge);
|
||||
}
|
||||
res.data.directory.streams.edges = newStreams;
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
clearUptime(inst) { // eslint-disable-line class-methods-use-this
|
||||
if ( inst.ffz_update_timer ) {
|
||||
clearInterval(inst.ffz_update_timer);
|
||||
inst.ffz_update_timer = null;
|
||||
}
|
||||
|
||||
if ( inst.ffz_uptime_el ) {
|
||||
inst.ffz_uptime_el.parentElement.removeChild(inst.ffz_uptime_el);
|
||||
inst.ffz_uptime_el = null;
|
||||
inst.ffz_uptime_span = null;
|
||||
inst.ffz_uptime_tt = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateUptime(inst, created_path, selector) {
|
||||
const container = this.fine.getHostNode(inst),
|
||||
card = container && container.querySelector && container.querySelector(selector),
|
||||
setting = this.settings.get('directory.following.uptime'),
|
||||
created_at = get(created_path, inst),
|
||||
up_since = created_at && new Date(created_at),
|
||||
uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
|
||||
|
||||
if ( ! card || setting === 0 || uptime < 1 )
|
||||
return this.clearUptime(inst);
|
||||
|
||||
const up_text = duration_to_string(uptime, false, false, false, setting === 1);
|
||||
|
||||
if ( ! inst.ffz_uptime_el )
|
||||
card.appendChild(inst.ffz_uptime_el = e('div',
|
||||
'video-preview-card__preview-overlay-stat c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
|
||||
e('div', 'tw-tooltip-wrapper inline-flex', [
|
||||
e('div', 'tw-stat', [
|
||||
e('span', 'c-text-live tw-stat__icon', e('figure', 'ffz-i-clock')),
|
||||
inst.ffz_uptime_span = e('span', 'tw-stat__value')
|
||||
]),
|
||||
inst.ffz_uptime_tt = e('div', 'tw-tooltip tw-tooltip--down tw-tooltip--align-center')
|
||||
])));
|
||||
|
||||
if ( ! inst.ffz_update_timer )
|
||||
inst.ffz_update_timer = setInterval(this.updateUptime.bind(this, inst, created_path, selector), 1000);
|
||||
|
||||
inst.ffz_uptime_span.textContent = up_text;
|
||||
inst.ffz_uptime_tt.textContent = up_since.toLocaleString();
|
||||
|
||||
}
|
||||
|
||||
|
||||
addCardAvatar(inst, selector) {
|
||||
const container = this.fine.getHostNode(inst),
|
||||
card = container && container.querySelector && container.querySelector(selector),
|
||||
setting = this.settings.get('directory.following.show-channel-avatar');
|
||||
|
||||
// Remove old elements
|
||||
const hiddenBodyCard = card.querySelector('.tw-card-body.hide');
|
||||
if (hiddenBodyCard !== null) hiddenBodyCard.classList.remove('hide');
|
||||
|
||||
const ffzChannelData = card.querySelector('.ffz-channel-data');
|
||||
if (ffzChannelData !== null) ffzChannelData.remove();
|
||||
|
||||
const channelAvatar = card.querySelector('.channel-avatar');
|
||||
if (channelAvatar !== null) channelAvatar.remove();
|
||||
|
||||
if ( ! card || setting === 0 )
|
||||
return;
|
||||
|
||||
if (inst.props.streamNode.viewersCount.profileImageURL) {
|
||||
if (setting === 1) {
|
||||
const cardDiv = card.querySelector('.tw-card-body');
|
||||
const modifiedDiv = e('div', {
|
||||
innerHTML: cardDiv.innerHTML
|
||||
});
|
||||
|
||||
const avatarDiv = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: `/${inst.props.streamNode.broadcaster.login}`,
|
||||
style: 'margin-right: 8px; min-width: 4rem; margin-top: 0.5rem;',
|
||||
onclick: event => this.hijackUserClick(event, inst.props.streamNode.broadcaster.login)
|
||||
}, e('img', {
|
||||
title: inst.props.streamNode.broadcaster.displayName,
|
||||
src: inst.props.streamNode.viewersCount.profileImageURL,
|
||||
style: 'height: 4rem;'
|
||||
}));
|
||||
|
||||
const cardDivParent = cardDiv.parentElement;
|
||||
|
||||
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
|
||||
cardDiv.classList.add('hide');
|
||||
|
||||
const newCardDiv = e('div', 'ffz-channel-data flex flex-nowrap', [
|
||||
avatarDiv, modifiedDiv
|
||||
]);
|
||||
cardDivParent.appendChild(newCardDiv);
|
||||
}
|
||||
} else if (setting === 2 || setting === 3) {
|
||||
const avatarElement = e('a', {
|
||||
className: 'channel-avatar',
|
||||
href: `/${inst.props.streamNode.broadcaster.login}`,
|
||||
onclick: event => this.hijackUserClick(event, inst.props.streamNode.broadcaster.login)
|
||||
}, e('div', 'live-channel-card__boxart bottom-0 absolute',
|
||||
e('figure', 'tw-aspect tw-aspect--align-top',
|
||||
e('img', {
|
||||
title: inst.props.streamNode.broadcaster.displayName,
|
||||
src: inst.props.streamNode.viewersCount.profileImageURL
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const divToAppend = card.querySelector('figure.tw-aspect');
|
||||
if (divToAppend.querySelector('.channel-avatar') === null) divToAppend.appendChild(avatarElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
hijackUserClick(event, user, optionalFn = null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (optionalFn) optionalFn();
|
||||
|
||||
this.router.navigate('user', { userName: user });
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Directory
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
import {duration_to_string} from 'utilities/time';
|
||||
import {createElement as e} from 'utilities/dom';
|
||||
import {get} from 'utilities/object';
|
||||
|
||||
import Following from './following';
|
||||
import Game from './game';
|
||||
import Community from './community';
|
||||
|
||||
export default class Directory extends SiteModule {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('i18n');
|
||||
this.inject('settings');
|
||||
this.inject('site.fine');
|
||||
|
||||
this.inject(Following);
|
||||
this.inject(Game);
|
||||
this.inject(Community);
|
||||
}
|
||||
|
||||
|
||||
clearUptime(inst) { // eslint-disable-line class-methods-use-this
|
||||
if ( inst.ffz_update_timer ) {
|
||||
clearInterval(inst.ffz_update_timer);
|
||||
inst.ffz_update_timer = null;
|
||||
}
|
||||
|
||||
if ( inst.ffz_uptime_el ) {
|
||||
inst.ffz_uptime_el.parentElement.removeChild(inst.ffz_uptime_el);
|
||||
inst.ffz_uptime_el = null;
|
||||
inst.ffz_uptime_span = null;
|
||||
inst.ffz_uptime_tt = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
updateUptime(inst, created_path, selector) {
|
||||
const container = this.fine.getHostNode(inst),
|
||||
card = container && container.querySelector && container.querySelector(selector),
|
||||
setting = this.settings.get('directory.following.uptime'),
|
||||
created_at = get(created_path, inst),
|
||||
up_since = created_at && new Date(created_at),
|
||||
uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
|
||||
|
||||
if ( ! card || setting === 0 || uptime < 1 )
|
||||
return this.clearUptime(inst);
|
||||
|
||||
const up_text = duration_to_string(uptime, false, false, false, setting === 1);
|
||||
|
||||
if ( ! inst.ffz_uptime_el )
|
||||
card.appendChild(inst.ffz_uptime_el = e('div',
|
||||
'video-preview-card__preview-overlay-stat c-background-overlay c-text-overlay font-size-6 top-0 right-0 z-default inline-flex absolute mg-05',
|
||||
e('div', 'tw-tooltip-wrapper inline-flex', [
|
||||
e('div', 'tw-stat', [
|
||||
e('span', 'c-text-live tw-stat__icon', e('figure', 'ffz-i-clock')),
|
||||
inst.ffz_uptime_span = e('span', 'tw-stat__value')
|
||||
]),
|
||||
inst.ffz_uptime_tt = e('div', 'tw-tooltip tw-tooltip--down tw-tooltip--align-center')
|
||||
])));
|
||||
|
||||
if ( ! inst.ffz_update_timer )
|
||||
inst.ffz_update_timer = setInterval(this.updateUptime.bind(this, inst, created_path, selector), 1000);
|
||||
|
||||
inst.ffz_uptime_span.textContent = up_text;
|
||||
inst.ffz_uptime_tt.textContent = up_since.toLocaleString();
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue