Tweak UI
This commit is contained in:
		
							parent
							
								
									b5c8dc0fe3
								
							
						
					
					
						commit
						e825d3be83
					
				
					 8 changed files with 186 additions and 271 deletions
				
			
		|  | @ -9,9 +9,9 @@ | |||
| 		<slot :result="result"></slot> | ||||
| 	</div> | ||||
| 	<div class="_formItem" v-else> | ||||
| 		<div class="_formPanel"> | ||||
| 			error! | ||||
| 			<button @click="retry">retry</button> | ||||
| 		<div class="_formPanel eiurkvay"> | ||||
| 			<div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div> | ||||
| 			<MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </transition> | ||||
|  | @ -20,8 +20,13 @@ | |||
| <script lang="ts"> | ||||
| import { defineComponent, PropType, ref, watch } from 'vue'; | ||||
| import './form.scss'; | ||||
| import MkButton from '@client/components/ui/button.vue'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		MkButton | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		p: { | ||||
| 			type: Function as PropType<() => Promise<any>>, | ||||
|  | @ -84,4 +89,13 @@ export default defineComponent({ | |||
| .fade-leave-to { | ||||
| 	opacity: 0; | ||||
| } | ||||
| 
 | ||||
| .eiurkvay { | ||||
| 	padding: 16px; | ||||
| 	text-align: center; | ||||
| 
 | ||||
| 	> .retry { | ||||
| 		margin-top: 16px; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -82,7 +82,7 @@ export default defineComponent({ | |||
| 		}, | ||||
| 
 | ||||
| 		showUser() { | ||||
| 			os.pageWindow(`/instance/user/${this.file.userId}`); | ||||
| 			os.pageWindow(`/user-info/${this.file.userId}`); | ||||
| 		}, | ||||
| 
 | ||||
| 		async del() { | ||||
|  |  | |||
|  | @ -1,229 +0,0 @@ | |||
| <template> | ||||
| <FormBase> | ||||
| 	<FormSuspense :p="init"> | ||||
| 		<div class="_formItem aeakzknw"> | ||||
| 			<MkAvatar class="avatar" :user="user" :show-indicator="true"/> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<FormLink :to="userPage(user)">Profile</FormLink> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormKeyValueView> | ||||
| 				<template #key>Acct</template> | ||||
| 				<template #value><span class="_monospace">{{ acct(user) }}</span></template> | ||||
| 			</FormKeyValueView> | ||||
| 
 | ||||
| 			<FormKeyValueView> | ||||
| 				<template #key>ID</template> | ||||
| 				<template #value><span class="_monospace">{{ user.id }}</span></template> | ||||
| 			</FormKeyValueView> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $ts.moderator }}</FormSwitch> | ||||
| 			<FormSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $ts.silence }}</FormSwitch> | ||||
| 			<FormSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $ts.suspend }}</FormSwitch> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> | ||||
| 			<FormButton v-if="user.host == null" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> | ||||
| 
 | ||||
| 			<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink> | ||||
| 			<FormKeyValueView v-else> | ||||
| 				<template #key>{{ $ts.instanceInfo }}</template> | ||||
| 				<template #value>(Local user)</template> | ||||
| 			</FormKeyValueView> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormKeyValueView> | ||||
| 				<template #key>{{ $ts.updatedAt }}</template> | ||||
| 				<template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> | ||||
| 			</FormKeyValueView> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormObjectView tall :value="user"> | ||||
| 			<span>Raw</span> | ||||
| 		</FormObjectView> | ||||
| 	</FormSuspense> | ||||
| </FormBase> | ||||
| </template> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| import { computed, defineAsyncComponent, defineComponent } from 'vue'; | ||||
| import FormObjectView from '@client/components/form/object-view.vue'; | ||||
| import FormSwitch from '@client/components/form/switch.vue'; | ||||
| import FormLink from '@client/components/form/link.vue'; | ||||
| import FormBase from '@client/components/form/base.vue'; | ||||
| import FormGroup from '@client/components/form/group.vue'; | ||||
| import FormButton from '@client/components/form/button.vue'; | ||||
| import FormKeyValueView from '@client/components/form/key-value-view.vue'; | ||||
| import FormSuspense from '@client/components/form/suspense.vue'; | ||||
| import * as os from '@client/os'; | ||||
| import number from '@client/filters/number'; | ||||
| import bytes from '@client/filters/bytes'; | ||||
| import * as symbols from '@client/symbols'; | ||||
| import { url } from '@client/config'; | ||||
| import { userPage, acct } from '@client/filters/user'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		FormBase, | ||||
| 		FormSwitch, | ||||
| 		FormObjectView, | ||||
| 		FormButton, | ||||
| 		FormLink, | ||||
| 		FormGroup, | ||||
| 		FormKeyValueView, | ||||
| 		FormSuspense, | ||||
| 	}, | ||||
| 
 | ||||
| 	props: { | ||||
| 		userId: { | ||||
| 			type: String, | ||||
| 			required: true | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	data() { | ||||
| 		return { | ||||
| 			[symbols.PAGE_INFO]: computed(() => ({ | ||||
| 				title: this.$ts.userInfo, | ||||
| 				icon: 'fas fa-info-circle', | ||||
| 				actions: this.user ? [this.user.url ? { | ||||
| 					text: this.user.url, | ||||
| 					icon: 'fas fa-external-link-alt', | ||||
| 					handler: () => { | ||||
| 						window.open(this.user.url, '_blank'); | ||||
| 					} | ||||
| 				} : undefined].filter(x => x !== undefined) : [], | ||||
| 			})), | ||||
| 			init: null, | ||||
| 			user: null, | ||||
| 			info: null, | ||||
| 			moderator: false, | ||||
| 			silenced: false, | ||||
| 			suspended: false, | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	watch: { | ||||
| 		userId: { | ||||
| 			handler() { | ||||
| 				this.init = this.createFetcher(); | ||||
| 			}, | ||||
| 			immediate: true | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	methods: { | ||||
| 		number, | ||||
| 		bytes, | ||||
| 		userPage, | ||||
| 		acct, | ||||
| 
 | ||||
| 		createFetcher() { | ||||
| 			return () => Promise.all([os.api('users/show', { | ||||
| 				userId: this.userId | ||||
| 			}), os.api('admin/show-user', { | ||||
| 				userId: this.userId | ||||
| 			})]).then(([user, info]) => { | ||||
| 				this.user = user; | ||||
| 				this.info = info; | ||||
| 				this.moderator = this.info.isModerator; | ||||
| 				this.silenced = this.info.isSilenced; | ||||
| 				this.suspended = this.info.isSuspended; | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		refreshUser() { | ||||
| 			this.init = this.createFetcher(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async updateRemoteUser() { | ||||
| 			await os.apiWithDialog('admin/update-remote-user', { userId: this.user.id }); | ||||
| 			this.refreshUser(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async resetPassword() { | ||||
| 			os.apiWithDialog('admin/reset-password', { | ||||
| 				userId: this.user.id, | ||||
| 			}, undefined, ({ password }) => { | ||||
| 				os.dialog({ | ||||
| 					type: 'success', | ||||
| 					text: this.$t('newPasswordIs', { password }) | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		async toggleSilence(v) { | ||||
| 			const confirm = await os.dialog({ | ||||
| 				type: 'warning', | ||||
| 				showCancelButton: true, | ||||
| 				text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, | ||||
| 			}); | ||||
| 			if (confirm.canceled) { | ||||
| 				this.silenced = !v; | ||||
| 			} else { | ||||
| 				await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); | ||||
| 				await this.refreshUser(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		async toggleSuspend(v) { | ||||
| 			const confirm = await os.dialog({ | ||||
| 				type: 'warning', | ||||
| 				showCancelButton: true, | ||||
| 				text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, | ||||
| 			}); | ||||
| 			if (confirm.canceled) { | ||||
| 				this.suspended = !v; | ||||
| 			} else { | ||||
| 				await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); | ||||
| 				await this.refreshUser(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		async toggleModerator(v) { | ||||
| 			await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); | ||||
| 			await this.refreshUser(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async deleteAllFiles() { | ||||
| 			const confirm = await os.dialog({ | ||||
| 				type: 'warning', | ||||
| 				showCancelButton: true, | ||||
| 				text: this.$ts.deleteAllFilesConfirm, | ||||
| 			}); | ||||
| 			if (confirm.canceled) return; | ||||
| 			const process = async () => { | ||||
| 				await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); | ||||
| 				os.success(); | ||||
| 			}; | ||||
| 			await process().catch(e => { | ||||
| 				os.dialog({ | ||||
| 					type: 'error', | ||||
| 					text: e.toString() | ||||
| 				}); | ||||
| 			}); | ||||
| 			await this.refreshUser(); | ||||
| 		}, | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .aeakzknw { | ||||
| 	> .avatar { | ||||
| 		display: block; | ||||
| 		margin: 0 auto; | ||||
| 		width: 64px; | ||||
| 		height: 64px; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  | @ -162,7 +162,7 @@ export default defineComponent({ | |||
| 		}, | ||||
| 
 | ||||
| 		show(user) { | ||||
| 			os.pageWindow(`/instance/user/${user.id}`); | ||||
| 			os.pageWindow(`/user-info/${user.id}`); | ||||
| 		}, | ||||
| 
 | ||||
| 		acct | ||||
|  |  | |||
|  | @ -1,13 +1,34 @@ | |||
| <template> | ||||
| <FormBase> | ||||
| 	<FormSuspense :p="init"> | ||||
| 		<div class="_formItem aeakzknw"> | ||||
| 			<MkAvatar class="avatar" :user="user" :show-indicator="true"/> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<FormLink :to="userPage(user)">Profile</FormLink> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<template #label><MkAcct :user="user"/></template> | ||||
| 			<FormKeyValueView> | ||||
| 				<template #key>Acct</template> | ||||
| 				<template #value><span class="_monospace">{{ acct(user) }}</span></template> | ||||
| 			</FormKeyValueView> | ||||
| 
 | ||||
| 			<FormKeyValueView> | ||||
| 				<template #key>ID</template> | ||||
| 				<template #value><span class="_monospace">{{ user.id }}</span></template> | ||||
| 			</FormKeyValueView> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup v-if="iAmModerator"> | ||||
| 			<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:value="toggleModerator" v-model:value="moderator">{{ $ts.moderator }}</FormSwitch> | ||||
| 			<FormSwitch @update:value="toggleSilence" v-model:value="silenced">{{ $ts.silence }}</FormSwitch> | ||||
| 			<FormSwitch @update:value="toggleSuspend" v-model:value="suspended">{{ $ts.suspend }}</FormSwitch> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> | ||||
| 			<FormButton v-if="user.host == null && iAmModerator" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> | ||||
| 		</FormGroup> | ||||
| 
 | ||||
| 		<FormGroup> | ||||
| 			<FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink> | ||||
|  | @ -29,7 +50,6 @@ | |||
| 		<FormObjectView tall :value="user"> | ||||
| 			<span>Raw</span> | ||||
| 		</FormObjectView> | ||||
| 		</FormGroup> | ||||
| 	</FormSuspense> | ||||
| </FormBase> | ||||
| </template> | ||||
|  | @ -38,6 +58,7 @@ | |||
| import { computed, defineAsyncComponent, defineComponent } from 'vue'; | ||||
| import FormObjectView from '@client/components/form/object-view.vue'; | ||||
| import FormTextarea from '@client/components/form/textarea.vue'; | ||||
| import FormSwitch from '@client/components/form/switch.vue'; | ||||
| import FormLink from '@client/components/form/link.vue'; | ||||
| import FormBase from '@client/components/form/base.vue'; | ||||
| import FormGroup from '@client/components/form/group.vue'; | ||||
|  | @ -49,11 +70,13 @@ import number from '@client/filters/number'; | |||
| import bytes from '@client/filters/bytes'; | ||||
| import * as symbols from '@client/symbols'; | ||||
| import { url } from '@client/config'; | ||||
| import { userPage, acct } from '@client/filters/user'; | ||||
| 
 | ||||
| export default defineComponent({ | ||||
| 	components: { | ||||
| 		FormBase, | ||||
| 		FormTextarea, | ||||
| 		FormSwitch, | ||||
| 		FormObjectView, | ||||
| 		FormButton, | ||||
| 		FormLink, | ||||
|  | @ -72,7 +95,7 @@ export default defineComponent({ | |||
| 	data() { | ||||
| 		return { | ||||
| 			[symbols.PAGE_INFO]: computed(() => ({ | ||||
| 				title: this.$ts.userInfo, | ||||
| 				title: this.user ? acct(this.user) : this.$ts.userInfo, | ||||
| 				icon: 'fas fa-info-circle', | ||||
| 				actions: this.user ? [this.user.url ? { | ||||
| 					text: this.user.url, | ||||
|  | @ -84,17 +107,23 @@ export default defineComponent({ | |||
| 			})), | ||||
| 			init: null, | ||||
| 			user: null, | ||||
| 			info: null, | ||||
| 			moderator: false, | ||||
| 			silenced: false, | ||||
| 			suspended: false, | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	computed: { | ||||
| 		iAmModerator(): boolean { | ||||
| 			return this.$i && (this.$i.isAdmin || this.$i.isModerator); | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	watch: { | ||||
| 		userId: { | ||||
| 			handler() { | ||||
| 				this.init = () => os.api('users/show', { | ||||
| 					userId: this.userId | ||||
| 				}).then(user => { | ||||
| 					this.user = user; | ||||
| 				}); | ||||
| 				this.init = this.createFetcher(); | ||||
| 			}, | ||||
| 			immediate: true | ||||
| 		} | ||||
|  | @ -103,6 +132,114 @@ export default defineComponent({ | |||
| 	methods: { | ||||
| 		number, | ||||
| 		bytes, | ||||
| 		userPage, | ||||
| 		acct, | ||||
| 
 | ||||
| 		createFetcher() { | ||||
| 			if (this.iAmModerator) { | ||||
| 				return () => Promise.all([os.api('users/show', { | ||||
| 					userId: this.userId | ||||
| 				}), os.api('admin/show-user', { | ||||
| 					userId: this.userId | ||||
| 				})]).then(([user, info]) => { | ||||
| 					this.user = user; | ||||
| 					this.info = info; | ||||
| 					this.moderator = this.info.isModerator; | ||||
| 					this.silenced = this.info.isSilenced; | ||||
| 					this.suspended = this.info.isSuspended; | ||||
| 				}); | ||||
| 			} else { | ||||
| 				return () => os.api('users/show', { | ||||
| 					userId: this.userId | ||||
| 				}).then((user) => { | ||||
| 					this.user = user; | ||||
| 				}); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		refreshUser() { | ||||
| 			this.init = this.createFetcher(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async updateRemoteUser() { | ||||
| 			await os.apiWithDialog('admin/update-remote-user', { userId: this.user.id }); | ||||
| 			this.refreshUser(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async resetPassword() { | ||||
| 			os.apiWithDialog('admin/reset-password', { | ||||
| 				userId: this.user.id, | ||||
| 			}, undefined, ({ password }) => { | ||||
| 				os.dialog({ | ||||
| 					type: 'success', | ||||
| 					text: this.$t('newPasswordIs', { password }) | ||||
| 				}); | ||||
| 			}); | ||||
| 		}, | ||||
| 
 | ||||
| 		async toggleSilence(v) { | ||||
| 			const confirm = await os.dialog({ | ||||
| 				type: 'warning', | ||||
| 				showCancelButton: true, | ||||
| 				text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, | ||||
| 			}); | ||||
| 			if (confirm.canceled) { | ||||
| 				this.silenced = !v; | ||||
| 			} else { | ||||
| 				await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); | ||||
| 				await this.refreshUser(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		async toggleSuspend(v) { | ||||
| 			const confirm = await os.dialog({ | ||||
| 				type: 'warning', | ||||
| 				showCancelButton: true, | ||||
| 				text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, | ||||
| 			}); | ||||
| 			if (confirm.canceled) { | ||||
| 				this.suspended = !v; | ||||
| 			} else { | ||||
| 				await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); | ||||
| 				await this.refreshUser(); | ||||
| 			} | ||||
| 		}, | ||||
| 
 | ||||
| 		async toggleModerator(v) { | ||||
| 			await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); | ||||
| 			await this.refreshUser(); | ||||
| 		}, | ||||
| 
 | ||||
| 		async deleteAllFiles() { | ||||
| 			const confirm = await os.dialog({ | ||||
| 				type: 'warning', | ||||
| 				showCancelButton: true, | ||||
| 				text: this.$ts.deleteAllFilesConfirm, | ||||
| 			}); | ||||
| 			if (confirm.canceled) return; | ||||
| 			const process = async () => { | ||||
| 				await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); | ||||
| 				os.success(); | ||||
| 			}; | ||||
| 			await process().catch(e => { | ||||
| 				os.dialog({ | ||||
| 					type: 'error', | ||||
| 					text: e.toString() | ||||
| 				}); | ||||
| 			}); | ||||
| 			await this.refreshUser(); | ||||
| 		}, | ||||
| 	} | ||||
| }); | ||||
| </script> | ||||
| 
 | ||||
| <style lang="scss" scoped> | ||||
| .aeakzknw { | ||||
| 	> .avatar { | ||||
| 		display: block; | ||||
| 		margin: 0 auto; | ||||
| 		width: 64px; | ||||
| 		height: 64px; | ||||
| 	} | ||||
| } | ||||
| </style> | ||||
|  |  | |||
|  | @ -59,7 +59,6 @@ export const router = createRouter({ | |||
| 		{ path: '/my/antennas', component: page('my-antennas/index') }, | ||||
| 		{ path: '/my/clips', component: page('my-clips/index') }, | ||||
| 		{ path: '/scratchpad', component: page('scratchpad') }, | ||||
| 		{ path: '/instance/user/:user', component: page('instance/user'), props: route => ({ userId: route.params.user }) }, | ||||
| 		{ path: '/instance/:page(.*)?', component: page('instance/index'), props: route => ({ initialPage: route.params.page || null }) }, | ||||
| 		{ path: '/instance', component: page('instance/index') }, | ||||
| 		{ path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, | ||||
|  |  | |||
|  | @ -124,13 +124,7 @@ export function getUserMenu(user) { | |||
| 		action: () => { | ||||
| 			copyToClipboard(`@${user.username}@${user.host || host}`); | ||||
| 		} | ||||
| 	}, ($i && ($i.isAdmin || $i.isModerator)) ? { | ||||
| 		icon: 'fas fa-info-circle', | ||||
| 		text: i18n.locale.info, | ||||
| 		action: () => { | ||||
| 			os.pageWindow(`/instance/user/${user.id}`); | ||||
| 		} | ||||
| 	} : { | ||||
| 	}, { | ||||
| 		icon: 'fas fa-info-circle', | ||||
| 		text: i18n.locale.info, | ||||
| 		action: () => { | ||||
|  |  | |||
|  | @ -10,7 +10,7 @@ export async function lookupUser() { | |||
| 	if (canceled) return; | ||||
| 
 | ||||
| 	const show = (user) => { | ||||
| 		os.pageWindow(`/instance/user/${user.id}`); | ||||
| 		os.pageWindow(`/user-info/${user.id}`); | ||||
| 	}; | ||||
| 
 | ||||
| 	const usernamePromise = os.api('users/show', parseAcct(result)); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue