Compare commits
6 commits
main
...
fix-canvas
Author | SHA1 | Date | |
---|---|---|---|
cc310d61df | |||
7bc9858736 | |||
a2714cfae7 | |||
c7ef63d2bd | |||
ed38340249 | |||
25501147ae |
15 changed files with 77 additions and 303 deletions
|
@ -8,6 +8,7 @@ steps:
|
||||||
- pnpm build
|
- pnpm build
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
- name: container-build-and-publish
|
- name: container-build-and-publish
|
||||||
image: docker
|
image: docker
|
||||||
|
|
|
@ -3,8 +3,10 @@
|
||||||
class="display-avatar"
|
class="display-avatar"
|
||||||
:src="displayAvatar"
|
:src="displayAvatar"
|
||||||
:alt="displayName" />
|
:alt="displayName" />
|
||||||
<Icon v-else
|
<DefaultIcon v-else-if="user"
|
||||||
name="lucide:user"
|
class="display-avatar"
|
||||||
|
:name="displayName"
|
||||||
|
:seed="user.uuid"
|
||||||
:alt="displayName" />
|
:alt="displayName" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -28,10 +30,6 @@ if (user) {
|
||||||
|
|
||||||
if (user.avatar) {
|
if (user.avatar) {
|
||||||
displayAvatar = user.avatar
|
displayAvatar = user.avatar
|
||||||
} else if (!isCanvasBlocked()){
|
|
||||||
displayAvatar = generateDefaultIcon(displayName, user.uuid)
|
|
||||||
} else {
|
|
||||||
displayAvatar = null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
48
components/DefaultIcon.vue
Normal file
48
components/DefaultIcon.vue
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<template>
|
||||||
|
<div :style="`background-color: ${generateIrcColor(seed, 50)}`"
|
||||||
|
class="default-icon">
|
||||||
|
<span class="default-icon-text">
|
||||||
|
{{ previewName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
seed: string,
|
||||||
|
name: string
|
||||||
|
}>();
|
||||||
|
|
||||||
|
let previewName = "";
|
||||||
|
if (props.name.length > 3) {
|
||||||
|
let guildName: string[] = props.name.split(' ')
|
||||||
|
for (let i = 0; i < 3; i ++) {
|
||||||
|
if (guildName.length > i) {
|
||||||
|
previewName += guildName[i].charAt(0)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
previewName = props.name
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.default-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-icon-text {
|
||||||
|
/* helps centre the icon, yes, this is NOT perfect */
|
||||||
|
margin-top: -0.15em;
|
||||||
|
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
color: var(--secondary-text-color)
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -30,4 +30,11 @@ const hidePopup = () => {
|
||||||
.member-item {
|
.member-item {
|
||||||
position: relative; /* Set the position to relative for absolute positioning of the popup */
|
position: relative; /* Set the position to relative for absolute positioning of the popup */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
min-height: 2.3em;
|
||||||
|
max-height: 2.3em;
|
||||||
|
min-width: 2.3em;
|
||||||
|
max-width: 2.3em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -220,7 +220,10 @@ function getDayDifference(date1: Date, date2: Date) {
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-author-avatar {
|
.message-author-avatar {
|
||||||
width: 100%;
|
min-height: 2em;
|
||||||
|
max-height: 2em;
|
||||||
|
min-width: 2em;
|
||||||
|
max-width: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-column {
|
.left-column {
|
||||||
|
|
|
@ -10,7 +10,6 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Cropper from 'cropperjs';
|
import Cropper from 'cropperjs';
|
||||||
import Button from '../UserInterface/Button.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
imageSrc: String,
|
imageSrc: String,
|
||||||
|
|
|
@ -33,7 +33,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import CropPopup from '~/components/Popups/CropPopup.vue';
|
|
||||||
import UserPopup from '~/components/User/UserPopup.vue';
|
import UserPopup from '~/components/User/UserPopup.vue';
|
||||||
import Button from '~/components/UserInterface/Button.vue';
|
import Button from '~/components/UserInterface/Button.vue';
|
||||||
|
|
||||||
|
@ -42,7 +41,7 @@ import type { UserResponse } from '~/types/interfaces';
|
||||||
let newPfpFile: File;
|
let newPfpFile: File;
|
||||||
const isCropPopupVisible = ref(false);
|
const isCropPopupVisible = ref(false);
|
||||||
const cropImageSrc = ref("")
|
const cropImageSrc = ref("")
|
||||||
|
;
|
||||||
const { fetchUser } = useAuth();
|
const { fetchUser } = useAuth();
|
||||||
|
|
||||||
const user: UserResponse | undefined = await fetchUser()
|
const user: UserResponse | undefined = await fetchUser()
|
||||||
|
|
|
@ -36,7 +36,7 @@ const props = defineProps<{
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
min-width: 2.3em;
|
min-width: 2.3em;
|
||||||
max-width: 2.3em;
|
max-width: 2.3em;
|
||||||
min-width: 2.3em;
|
min-height: 2.3em;
|
||||||
max-height: 2.3em;
|
max-height: 2.3em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -94,10 +94,6 @@ export const useApi = () => {
|
||||||
await fetchWithApi("/auth/reset-password", { method: "POST", body: { password, token } });
|
await fetchWithApi("/auth/reset-password", { method: "POST", body: { password, token } });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchInvite(id: string): Promise<GuildResponse | undefined> {
|
|
||||||
return await fetchWithApi(`/invites/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchGuilds,
|
fetchGuilds,
|
||||||
fetchGuild,
|
fetchGuild,
|
||||||
|
@ -119,7 +115,6 @@ export const useApi = () => {
|
||||||
fetchInstanceStats,
|
fetchInstanceStats,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
resetPassword,
|
resetPassword
|
||||||
fetchInvite
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,7 +41,7 @@ export const useAuth = () => {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
console.log("access:", accessToken.value);
|
console.log("access:", accessToken.value);
|
||||||
|
|
||||||
await fetchWithApi("/auth/logout", { method: "DELETE", credentials: "include" });
|
await fetchWithApi("/auth/logout", { method: "GET", credentials: "include" });
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
|
||||||
return await navigateTo("/login");
|
return await navigateTo("/login");
|
||||||
|
|
|
@ -17,19 +17,15 @@
|
||||||
</div>
|
</div>
|
||||||
<VerticalSpacer />
|
<VerticalSpacer />
|
||||||
<div class="left-column-segment" id="left-column-middle">
|
<div class="left-column-segment" id="left-column-middle">
|
||||||
<NuxtLink v-for="guild of guilds" :href="`/servers/${guild.uuid}`">
|
<NuxtLink v-for="guild of guilds" :href="`/servers/${guild.uuid}`" id="guild-icon-container">
|
||||||
<NuxtImg v-if="guild.icon"
|
<NuxtImg v-if="guild.icon"
|
||||||
class="sidebar-icon guild-icon"
|
class="sidebar-icon guild-icon"
|
||||||
:alt="guild.name"
|
:alt="guild.name"
|
||||||
:src="guild.icon" />
|
:src="guild.icon" />
|
||||||
<NuxtImg v-else-if="!blockedCanvas"
|
<DefaultIcon v-else
|
||||||
class="sidebar-icon guild-icon"
|
class="sidebar-icon guild-icon"
|
||||||
:alt="guild.name"
|
:alt="guild.name"
|
||||||
:src="generateDefaultIcon(guild.name, guild.uuid)" />
|
:name="guild.name" :seed="guild.uuid"/>
|
||||||
<Icon v-else name="lucide:server"
|
|
||||||
:style="`color: ${generateIrcColor(guild.uuid, 50)}`"
|
|
||||||
class="sidebar-icon guild-icon"
|
|
||||||
:alt="guild.name" />
|
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<VerticalSpacer />
|
<VerticalSpacer />
|
||||||
|
@ -52,6 +48,7 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ModalBase } from '#components';
|
import { ModalBase } from '#components';
|
||||||
import { render } from 'vue';
|
import { render } from 'vue';
|
||||||
|
import DefaultIcon from '~/components/DefaultIcon.vue';
|
||||||
import GuildDropdown from '~/components/Guild/GuildDropdown.vue';
|
import GuildDropdown from '~/components/Guild/GuildDropdown.vue';
|
||||||
import Button from '~/components/UserInterface/Button.vue';
|
import Button from '~/components/UserInterface/Button.vue';
|
||||||
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
|
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
|
||||||
|
@ -63,8 +60,6 @@ const createButtonContainer = ref<HTMLButtonElement>();
|
||||||
|
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
|
|
||||||
const blockedCanvas = isCanvasBlocked()
|
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ name: "Join", value: "join", callback: async () => {
|
{ name: "Join", value: "join", callback: async () => {
|
||||||
console.log("join guild!");
|
console.log("join guild!");
|
||||||
|
@ -249,6 +244,10 @@ function createDropdown() {
|
||||||
height: var(--sidebar-icon-width);
|
height: var(--sidebar-icon-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#guild-icon-container {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.guild-icon {
|
.guild-icon {
|
||||||
border-radius: var(--guild-icon-radius);
|
border-radius: var(--guild-icon-radius);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,180 +0,0 @@
|
||||||
<template>
|
|
||||||
<div id="invite-root">
|
|
||||||
<div id="invite-container">
|
|
||||||
<div id="guild-container" v-if="guild">
|
|
||||||
<h1>You have been invited to {{ guild.name }}!</h1>
|
|
||||||
<div id="guild-card">
|
|
||||||
<div id="card-grid">
|
|
||||||
<div id="guild-details">
|
|
||||||
<div id="guild-name" title="Server name">
|
|
||||||
<span>{{ guild.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div id="guild-member-count" :title="`${guild.member_count} members`">
|
|
||||||
<Icon name="lucide:users" />
|
|
||||||
<span>{{ guild.member_count }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<VerticalSpacer id="space" />
|
|
||||||
<div id="guild-description">
|
|
||||||
<span>{{ guild.description }}</span>
|
|
||||||
</div>
|
|
||||||
<div id="guild-icon">
|
|
||||||
<NuxtImg v-if="guild.icon" id="guild-icon-img" :src="guild.icon" :alt="`${guild.name} server icon`" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button :text="isMember ? 'Joined' : 'Join'" variant="normal" :callback="acceptInvite" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="errorMessage">
|
|
||||||
<h1>{{ errorMessage }}</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
import Button from '~/components/UserInterface/Button.vue';
|
|
||||||
import VerticalSpacer from '~/components/UserInterface/VerticalSpacer.vue';
|
|
||||||
import type { GuildResponse } from '~/types/interfaces';
|
|
||||||
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const { fetchInvite, joinGuild, fetchMembers } = useApi();
|
|
||||||
const { getUser } = useAuth();
|
|
||||||
|
|
||||||
const inviteId = route.params.inviteId as string;
|
|
||||||
|
|
||||||
const guild = ref<GuildResponse>();
|
|
||||||
const errorMessage = ref<string>();
|
|
||||||
const isMember = ref(false);
|
|
||||||
|
|
||||||
const accessToken = useCookie("access_token");
|
|
||||||
|
|
||||||
if (inviteId) {
|
|
||||||
try {
|
|
||||||
guild.value = await fetchInvite(inviteId);
|
|
||||||
console.log("invite guild:", guild.value);
|
|
||||||
if (accessToken.value && guild.value) {
|
|
||||||
const members = await fetchMembers(guild.value.uuid);
|
|
||||||
const me = await getUser();
|
|
||||||
if (me && members.find(member => member.user.uuid == me.uuid)) {
|
|
||||||
isMember.value = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response) {
|
|
||||||
if (error.status == 404) {
|
|
||||||
errorMessage.value = "That invite doesn't exist or has expired.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function acceptInvite() {
|
|
||||||
if (accessToken.value && guild.value) {
|
|
||||||
await joinGuild(inviteId);
|
|
||||||
return await navigateTo(`/servers/${guild.value.uuid}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await navigateTo(`/login?redirect_to=${route.fullPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
#invite-root {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100dvh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#invite-container {
|
|
||||||
border: .5rem solid var(--chat-highlighted-background-color);
|
|
||||||
border-radius: var(--standard-radius);
|
|
||||||
height: 50%;
|
|
||||||
width: 50%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 50%;
|
|
||||||
height: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 50%;
|
|
||||||
height: 60%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
background-color: var(--sidebar-highlighted-background-color);
|
|
||||||
border: .5rem solid black;
|
|
||||||
border-radius: var(--standard-radius);
|
|
||||||
padding: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#card-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
grid-template-rows: 5rem auto 1fr;
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-details {
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: span 2;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-name {
|
|
||||||
font-size: 2rem;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-member-count {
|
|
||||||
gap: .3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#space {
|
|
||||||
grid-row: 2;
|
|
||||||
grid-column: span 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-description {
|
|
||||||
grid-row: 3;
|
|
||||||
grid-column: span 3;
|
|
||||||
word-break: break-all;
|
|
||||||
padding: .3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-name, #guild-member-count {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#guild-icon-img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: scale-down;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
|
@ -133,13 +133,6 @@ function handleMemberClick(member: GuildMemberResponse) {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-avatar {
|
|
||||||
min-width: 2.3em;
|
|
||||||
max-width: 2.3em;
|
|
||||||
min-width: 2.3em;
|
|
||||||
max-height: 2.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-display-name {
|
.member-display-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
export default (name: string, seed: string): string => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
if (canvas && ctx) {
|
|
||||||
canvas.width = 256;
|
|
||||||
canvas.height = 256;
|
|
||||||
|
|
||||||
// get the first char from every word in the guild name
|
|
||||||
let previewName = "";
|
|
||||||
if (name.length > 3) {
|
|
||||||
let guildName: string[] = name.split(' ')
|
|
||||||
for (let i = 0; i < 3; i ++) {
|
|
||||||
if (guildName.length > i) {
|
|
||||||
previewName += guildName[i].charAt(0)
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
previewName = name
|
|
||||||
}
|
|
||||||
|
|
||||||
// fill background using seeded colour
|
|
||||||
ctx.fillStyle = generateIrcColor(seed, 50)
|
|
||||||
ctx.fillRect(0, 0, 256, 256)
|
|
||||||
|
|
||||||
ctx.fillStyle = 'white'
|
|
||||||
ctx.textAlign = 'center'
|
|
||||||
ctx.textBaseline = 'middle'
|
|
||||||
ctx.font = `bold 96px Arial, Helvetica, sans-serif`
|
|
||||||
// 136 isn't actually centered, but it *looks* centered
|
|
||||||
ctx.fillText(previewName, 128, 136)
|
|
||||||
|
|
||||||
return canvas.toDataURL("image/png");
|
|
||||||
}
|
|
||||||
|
|
||||||
return "https://tenor.com/view/dame-da-ne-guy-kiryukazuma-kiryu-yakuza-yakuza-0-gif-14355451116903905918"
|
|
||||||
}
|
|
|
@ -1,50 +0,0 @@
|
||||||
//
|
|
||||||
// Canvas Blocker &
|
|
||||||
// Firefox privacy.resistFingerprinting Detector.
|
|
||||||
// (c) 2018 // JOHN OZBAY // CRYPT.EE
|
|
||||||
// MIT License
|
|
||||||
//
|
|
||||||
export default () => {
|
|
||||||
// create a 1px image data
|
|
||||||
var blocked = false;
|
|
||||||
var canvas = document.createElement("canvas");
|
|
||||||
var ctx = canvas.getContext("2d");
|
|
||||||
|
|
||||||
// some blockers just return an undefined ctx. So let's check that first.
|
|
||||||
if (ctx) {
|
|
||||||
var imageData = ctx.createImageData(1,1);
|
|
||||||
var originalImageData = imageData.data;
|
|
||||||
|
|
||||||
// set pixels to RGB 128
|
|
||||||
originalImageData[0]=128;
|
|
||||||
originalImageData[1]=128;
|
|
||||||
originalImageData[2]=128;
|
|
||||||
originalImageData[3]=255;
|
|
||||||
|
|
||||||
// set this to canvas
|
|
||||||
ctx.putImageData(imageData,1,1);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// now get the data back from canvas.
|
|
||||||
var checkData = ctx.getImageData(1, 1, 1, 1).data;
|
|
||||||
|
|
||||||
// If this is firefox, and privacy.resistFingerprinting is enabled,
|
|
||||||
// OR a browser extension blocking the canvas,
|
|
||||||
// This will return RGB all white (255,255,255) instead of the (128,128,128) we put.
|
|
||||||
|
|
||||||
// so let's check the R and G to see if they're 255 or 128 (matching what we've initially set)
|
|
||||||
if (originalImageData[0] !== checkData[0] && originalImageData[1] !== checkData[1]) {
|
|
||||||
blocked = true;
|
|
||||||
console.log("Canvas is blocked. Will display warning.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// some extensions will return getImageData null. this is to account for that.
|
|
||||||
blocked = true;
|
|
||||||
console.log("Canvas is blocked. Will display warning.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
blocked = true;
|
|
||||||
console.log("Canvas is blocked. Will display warning.");
|
|
||||||
}
|
|
||||||
return blocked;
|
|
||||||
}
|
|
Loading…
Add table
Add a link
Reference in a new issue