mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-07-01 00:28:30 +00:00
Make some changes to how the directory stuff is implemented. There's too much code duplication right now. There's also code leaking from hosts to the main following page. There needs to be a way to detect if a live-channel-card is in the hosts section or not. Communities and Games use the same component, so there's a ton duplicated there.
This commit is contained in:
parent
6a7a19bf83
commit
4cfd76b89e
6 changed files with 129 additions and 131 deletions
|
@ -64,7 +64,7 @@ export default class Community extends SiteModule {
|
|||
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;
|
||||
|
@ -135,7 +135,7 @@ export default class Community extends SiteModule {
|
|||
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}`);
|
||||
|
@ -148,7 +148,7 @@ export default class Community extends SiteModule {
|
|||
),
|
||||
inst.uptimeElementSpan
|
||||
]);
|
||||
|
||||
|
||||
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
|
||||
} else {
|
||||
inst.uptimeElementSpan.textContent = `${uptimeText}`;
|
||||
|
@ -166,11 +166,11 @@ export default class Community extends SiteModule {
|
|||
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();
|
||||
|
||||
|
@ -192,7 +192,7 @@ export default class Community extends SiteModule {
|
|||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
}, e('img', {
|
||||
|
@ -202,7 +202,7 @@ export default class Community extends SiteModule {
|
|||
}));
|
||||
|
||||
const cardDivParent = cardDiv.parentElement;
|
||||
|
||||
|
||||
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
|
||||
cardDiv.classList.add('hide');
|
||||
|
||||
|
@ -218,7 +218,7 @@ export default class Community extends SiteModule {
|
|||
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',
|
||||
|
@ -241,10 +241,10 @@ export default class Community extends SiteModule {
|
|||
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') || [];
|
||||
|
|
|
@ -160,7 +160,7 @@ export default class Following extends SiteModule {
|
|||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
|
||||
this.apollo.registerModifier('FollowingHosts_CurrentUser', `query {
|
||||
currentUser {
|
||||
followedHosts {
|
||||
|
@ -223,7 +223,7 @@ export default class Following extends SiteModule {
|
|||
|
||||
modifyLiveHosts(res) { // eslint-disable-line class-methods-use-this
|
||||
if (!this.isRouteAcceptable()) return res;
|
||||
|
||||
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const blockedGames = this.settings.provider.get('directory.game.blocked-games') || [];
|
||||
|
||||
|
@ -295,8 +295,7 @@ export default class Following extends SiteModule {
|
|||
});
|
||||
|
||||
this.ChannelCard.on('mount', inst => this.updateChannelCard(inst), this);
|
||||
|
||||
this.ChannelCard.on('unmount', inst => this.updateUptime(inst), this);
|
||||
this.ChannelCard.on('unmount', inst => this.parent.clearUptime(inst), this);
|
||||
|
||||
document.body.addEventListener('click', this.destroyHostMenu.bind(this));
|
||||
}
|
||||
|
@ -309,61 +308,6 @@ export default class Following extends SiteModule {
|
|||
}
|
||||
}
|
||||
|
||||
updateUptime(inst) {
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const card = container && container.querySelector && container.querySelector('.tw-card .tw-aspect > div');
|
||||
|
||||
// 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.viewerCount.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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showHostMenu(inst, { channels }, event) {
|
||||
if (this.settings.get('directory.following.host-menus') === 0 || this.settings.get('directory.following.host-menus') === 1 && channels.length < 2) return;
|
||||
|
||||
|
@ -393,7 +337,7 @@ export default class Following extends SiteModule {
|
|||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.linkTo.pathname.substring(1)});
|
||||
}
|
||||
}, e('div', 'align-items-center flex flex-row flex-nowrap mg-x-1 mg-y-05',
|
||||
|
@ -483,27 +427,29 @@ export default class Following extends SiteModule {
|
|||
}
|
||||
|
||||
updateChannelCard(inst) {
|
||||
this.updateUptime(inst);
|
||||
this.parent.updateUptime(inst, 'props.viewerCount.createdAt', '.tw-card .tw-aspect > div');
|
||||
|
||||
const container = this.fine.getHostNode(inst);
|
||||
const card = container && container.querySelector && container.querySelector('.tw-card');
|
||||
|
||||
if (container === null || card === null) return;
|
||||
const container = this.fine.getHostNode(inst),
|
||||
card = container && container.querySelector && container.querySelector('.tw-card');
|
||||
|
||||
if ( container === null || card === null )
|
||||
return;
|
||||
|
||||
const channelCardTitle = card.querySelector('.live-channel-card__title');
|
||||
|
||||
if (channelCardTitle === null) return;
|
||||
|
||||
if ( channelCardTitle === 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();
|
||||
|
||||
|
||||
if (inst.props.viewerCount.profileImageURL) {
|
||||
const hosting = inst.props.viewerCount.hostData;
|
||||
let channel, displayName;
|
||||
|
@ -511,13 +457,13 @@ export default class Following extends SiteModule {
|
|||
channel = inst.props.viewerCount.hostData.channel;
|
||||
displayName = inst.props.viewerCount.hostData.displayName;
|
||||
}
|
||||
|
||||
|
||||
const avatarSetting = this.settings.get('directory.following.show-channel-avatar');
|
||||
const cardDiv = card.querySelector('.tw-card-body');
|
||||
const modifiedDiv = e('div', {
|
||||
innerHTML: cardDiv.innerHTML
|
||||
});
|
||||
|
||||
|
||||
let avatarDiv;
|
||||
if (avatarSetting === 1) {
|
||||
avatarDiv = e('a', {
|
||||
|
@ -536,7 +482,7 @@ export default class Following extends SiteModule {
|
|||
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',
|
||||
|
@ -569,16 +515,16 @@ 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"]');
|
||||
|
||||
|
||||
if (hostObj.channels.length > 1) {
|
||||
const textContent = `${hostObj.channels.length} hosting ${displayName}`;
|
||||
channelCardTitle.textContent
|
||||
= channelCardTitle.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);
|
||||
}
|
||||
|
|
|
@ -67,7 +67,7 @@ export default class Game extends SiteModule {
|
|||
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;
|
||||
|
@ -140,7 +140,7 @@ export default class Game extends SiteModule {
|
|||
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}`);
|
||||
|
@ -153,7 +153,7 @@ export default class Game extends SiteModule {
|
|||
),
|
||||
inst.uptimeElementSpan
|
||||
]);
|
||||
|
||||
|
||||
if (card.querySelector('.ffz-uptime') === null) card.appendChild(inst.uptimeElement);
|
||||
} else {
|
||||
inst.uptimeElementSpan.textContent = `${uptimeText}`;
|
||||
|
@ -167,15 +167,15 @@ export default class Game extends SiteModule {
|
|||
|
||||
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();
|
||||
|
||||
|
@ -184,14 +184,13 @@ export default class Game extends SiteModule {
|
|||
|
||||
const hiddenThumbnails = this.settings.provider.get('directory.game.hidden-thumbnails') || [];
|
||||
const thumbnailBlocked = hiddenThumbnails.includes(inst.props.directoryName);
|
||||
this.log.warn(inst);
|
||||
|
||||
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) {
|
||||
|
@ -207,7 +206,7 @@ export default class Game extends SiteModule {
|
|||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
||||
this.router.navigate('user', { userName: inst.props.streamNode.broadcaster.login});
|
||||
}
|
||||
}, e('img', {
|
||||
|
@ -217,7 +216,7 @@ export default class Game extends SiteModule {
|
|||
}));
|
||||
|
||||
const cardDivParent = cardDiv.parentElement;
|
||||
|
||||
|
||||
if (cardDivParent.querySelector('.ffz-channel-data') === null) {
|
||||
cardDiv.classList.add('hide');
|
||||
|
||||
|
@ -233,7 +232,7 @@ export default class Game extends SiteModule {
|
|||
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',
|
||||
|
@ -256,10 +255,10 @@ export default class Game extends SiteModule {
|
|||
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') || [];
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Directory
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
|
||||
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(Following);
|
||||
this.inject(Game);
|
||||
this.inject(Community);
|
||||
}
|
||||
}
|
77
src/sites/twitch-twilight/modules/directory/index.off
Normal file
77
src/sites/twitch-twilight/modules/directory/index.off
Normal file
|
@ -0,0 +1,77 @@
|
|||
'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();
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Following Button Modification
|
||||
// Following Button Modification
|
||||
// ============================================================================
|
||||
|
||||
import {SiteModule} from 'utilities/module';
|
||||
|
@ -16,7 +16,6 @@ export default class FollowingText extends SiteModule {
|
|||
|
||||
this.should_enable = true;
|
||||
|
||||
// this.inject('site');
|
||||
this.inject('settings');
|
||||
this.inject('site.router');
|
||||
this.inject('site.apollo');
|
||||
|
@ -71,12 +70,12 @@ export default class FollowingText extends SiteModule {
|
|||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
|
||||
|
||||
if (blockedGames.includes(node.stream.game.name)) {
|
||||
filtered += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
c += 1;
|
||||
if (c > max_lines) {
|
||||
const div = e('div', {
|
||||
|
@ -89,7 +88,7 @@ export default class FollowingText extends SiteModule {
|
|||
innerContent.push(div);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
const up_since = new Date(node.stream.createdAt);
|
||||
const uptime = up_since && Math.floor((Date.now() - up_since) / 1000) || 0;
|
||||
const uptimeText = duration_to_string(uptime, false, false, false, true);
|
||||
|
@ -100,7 +99,7 @@ export default class FollowingText extends SiteModule {
|
|||
}, [
|
||||
e('div', {
|
||||
className: 'top-stream-info',
|
||||
style: 'padding-bottom: 16px;'
|
||||
style: 'padding-bottom: 16px;'
|
||||
}, [
|
||||
// Username
|
||||
e('a', {
|
||||
|
@ -111,7 +110,7 @@ export default class FollowingText extends SiteModule {
|
|||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
||||
this.router.navigate('user', { userName: node.login });
|
||||
}
|
||||
}),
|
||||
|
@ -194,7 +193,7 @@ export default class FollowingText extends SiteModule {
|
|||
onclick: event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
||||
this.router.navigate('dir-following');
|
||||
}
|
||||
}),
|
||||
|
@ -205,7 +204,7 @@ export default class FollowingText extends SiteModule {
|
|||
}),
|
||||
tipDiv
|
||||
]);
|
||||
|
||||
|
||||
topNavContainer.insertBefore(newFollowing, followingText);
|
||||
followingText.classList.add('hide');
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue