Merge remote-tracking branch 'mi-dev/master' into report
This commit is contained in:
commit
cb1586658e
567 changed files with 10281 additions and 3996 deletions
|
|
@ -47,6 +47,7 @@ export type AsUiMfm = AsUiComponentBase & {
|
|||
bold?: boolean;
|
||||
color?: string;
|
||||
font?: 'serif' | 'sans-serif' | 'monospace';
|
||||
onClickEv?: (evId: string) => void
|
||||
};
|
||||
|
||||
export type AsUiButton = AsUiComponentBase & {
|
||||
|
|
@ -230,6 +231,8 @@ function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'typ
|
|||
if (color) utils.assertString(color);
|
||||
const font = def.value.get('font');
|
||||
if (font) utils.assertString(font);
|
||||
const onClickEv = def.value.get('onClickEv');
|
||||
if (onClickEv) utils.assertFunction(onClickEv);
|
||||
|
||||
return {
|
||||
text: text?.value,
|
||||
|
|
@ -237,6 +240,9 @@ function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'typ
|
|||
bold: bold?.value,
|
||||
color: color?.value,
|
||||
font: font?.value,
|
||||
onClickEv: (evId: string) => {
|
||||
if (onClickEv) call(onClickEv, values.STR(evId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import getCaretCoordinates from 'textarea-caret';
|
|||
import { toASCII } from 'punycode/';
|
||||
import { popup } from '@/os.js';
|
||||
|
||||
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag';
|
||||
|
||||
export class Autocomplete {
|
||||
private suggestion: {
|
||||
x: Ref<number>;
|
||||
|
|
@ -19,6 +21,7 @@ export class Autocomplete {
|
|||
private currentType: string;
|
||||
private textRef: Ref<string>;
|
||||
private opening: boolean;
|
||||
private onlyType: SuggestionType[];
|
||||
|
||||
private get text(): string {
|
||||
// Use raw .value to get the latest value
|
||||
|
|
@ -35,7 +38,7 @@ export class Autocomplete {
|
|||
/**
|
||||
* 対象のテキストエリアを与えてインスタンスを初期化します。
|
||||
*/
|
||||
constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
|
||||
constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, onlyType?: SuggestionType[]) {
|
||||
//#region BIND
|
||||
this.onInput = this.onInput.bind(this);
|
||||
this.complete = this.complete.bind(this);
|
||||
|
|
@ -46,6 +49,7 @@ export class Autocomplete {
|
|||
this.textarea = textarea;
|
||||
this.textRef = textRef;
|
||||
this.opening = false;
|
||||
this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag'];
|
||||
|
||||
this.attach();
|
||||
}
|
||||
|
|
@ -95,7 +99,7 @@ export class Autocomplete {
|
|||
|
||||
let opened = false;
|
||||
|
||||
if (isMention) {
|
||||
if (isMention && this.onlyType.includes('user')) {
|
||||
const username = text.substring(mentionIndex + 1);
|
||||
if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) {
|
||||
this.open('user', username);
|
||||
|
|
@ -106,7 +110,7 @@ export class Autocomplete {
|
|||
}
|
||||
}
|
||||
|
||||
if (isHashtag && !opened) {
|
||||
if (isHashtag && !opened && this.onlyType.includes('hashtag')) {
|
||||
const hashtag = text.substring(hashtagIndex + 1);
|
||||
if (!hashtag.includes(' ')) {
|
||||
this.open('hashtag', hashtag);
|
||||
|
|
@ -114,7 +118,7 @@ export class Autocomplete {
|
|||
}
|
||||
}
|
||||
|
||||
if (isEmoji && !opened) {
|
||||
if (isEmoji && !opened && this.onlyType.includes('emoji')) {
|
||||
const emoji = text.substring(emojiIndex + 1);
|
||||
if (!emoji.includes(' ')) {
|
||||
this.open('emoji', emoji);
|
||||
|
|
@ -122,7 +126,7 @@ export class Autocomplete {
|
|||
}
|
||||
}
|
||||
|
||||
if (isMfmTag && !opened) {
|
||||
if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
|
||||
const mfmTag = text.substring(mfmTagIndex + 1);
|
||||
if (!mfmTag.includes(' ')) {
|
||||
this.open('mfmTag', mfmTag.replace('[', ''));
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { fetchCustomEmojis } from '@/custom-emojis.js';
|
|||
export async function clearCache() {
|
||||
os.waiting();
|
||||
miLocalStorage.removeItem('locale');
|
||||
miLocalStorage.removeItem('localeVersion');
|
||||
miLocalStorage.removeItem('theme');
|
||||
miLocalStorage.removeItem('emojis');
|
||||
miLocalStorage.removeItem('lastEmojisFetchedAt');
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { defineAsyncComponent, Ref, ref } from 'vue';
|
||||
import { popup } from '@/os.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
/**
|
||||
* 絵文字ピッカーを表示する。
|
||||
|
|
@ -23,8 +24,10 @@ class EmojiPicker {
|
|||
}
|
||||
|
||||
public async init() {
|
||||
const emojisRef = defaultStore.reactiveState.pinnedEmojis;
|
||||
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
|
||||
src: this.src,
|
||||
pinnedEmojis: emojisRef,
|
||||
asReactionPicker: false,
|
||||
manualShowing: this.manualShowing,
|
||||
choseAndClose: false,
|
||||
|
|
@ -44,8 +47,8 @@ class EmojiPicker {
|
|||
|
||||
public show(
|
||||
src: HTMLElement,
|
||||
onChosen: EmojiPicker['onChosen'],
|
||||
onClosed: EmojiPicker['onClosed'],
|
||||
onChosen?: EmojiPicker['onChosen'],
|
||||
onClosed?: EmojiPicker['onClosed'],
|
||||
) {
|
||||
this.src.value = src;
|
||||
this.manualShowing.value = true;
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
|
|||
to: `/my/drive/file/${file.id}`,
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
}, null, {
|
||||
}, { type: 'divider' }, {
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ti ti-forms',
|
||||
action: () => rename(file),
|
||||
|
|
@ -101,7 +101,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
|
|||
aspectRatio: NaN,
|
||||
uploadFolder: folder ? folder.id : folder,
|
||||
}),
|
||||
}] : [], null, {
|
||||
}] : [], { type: 'divider' }, {
|
||||
text: i18n.ts.createNoteFromTheFile,
|
||||
icon: 'ti ti-pencil',
|
||||
action: () => os.post({
|
||||
|
|
@ -118,7 +118,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
|
|||
text: i18n.ts.download,
|
||||
icon: 'ti ti-download',
|
||||
download: file.name,
|
||||
}, null, {
|
||||
}, { type: 'divider' }, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
|
|
@ -126,7 +126,7 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
|
|||
}];
|
||||
|
||||
if (defaultStore.state.devMode) {
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: 'divider' }, {
|
||||
icon: 'ti ti-id',
|
||||
text: i18n.ts.copyFileId,
|
||||
action: () => {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export async function getNoteClipMenu(props: {
|
|||
},
|
||||
);
|
||||
},
|
||||
})), null, {
|
||||
})), { type: 'divider' }, {
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.createNew,
|
||||
action: async () => {
|
||||
|
|
@ -94,7 +94,7 @@ export async function getNoteClipMenu(props: {
|
|||
}];
|
||||
}
|
||||
|
||||
export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): MenuItem {
|
||||
export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem {
|
||||
return {
|
||||
icon: 'ti ti-exclamation-circle',
|
||||
text,
|
||||
|
|
@ -108,7 +108,7 @@ export function getAbuseNoteMenu(note: misskey.entities.Note, text: string): Men
|
|||
};
|
||||
}
|
||||
|
||||
export function getCopyNoteLinkMenu(note: misskey.entities.Note, text: string): MenuItem {
|
||||
export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem {
|
||||
return {
|
||||
icon: 'ti ti-link',
|
||||
text,
|
||||
|
|
@ -122,7 +122,7 @@ export function getCopyNoteLinkMenu(note: misskey.entities.Note, text: string):
|
|||
export function getNoteMenu(props: {
|
||||
note: Misskey.entities.Note;
|
||||
menuButton: Ref<HTMLElement>;
|
||||
translation: Ref<any>;
|
||||
translation: Ref<Misskey.entities.NotesTranslateResponse | null>;
|
||||
translating: Ref<boolean>;
|
||||
isDeleted: Ref<boolean>;
|
||||
currentClip?: Misskey.entities.Clip;
|
||||
|
|
@ -264,7 +264,7 @@ export function getNoteMenu(props: {
|
|||
text: i18n.ts.unclip,
|
||||
danger: true,
|
||||
action: unclip,
|
||||
}, null] : []
|
||||
}, { type: 'divider' }] : []
|
||||
), {
|
||||
icon: 'ti ti-info-circle',
|
||||
text: i18n.ts.details,
|
||||
|
|
@ -291,7 +291,7 @@ export function getNoteMenu(props: {
|
|||
text: i18n.ts.translate,
|
||||
action: translate,
|
||||
} : undefined,
|
||||
null,
|
||||
{ type: 'divider' },
|
||||
statePromise.then(state => state.isFavorited ? {
|
||||
icon: 'ti ti-star-off',
|
||||
text: i18n.ts.unfavorite,
|
||||
|
|
@ -338,7 +338,7 @@ export function getNoteMenu(props: {
|
|||
},
|
||||
/*
|
||||
...($i.isModerator || $i.isAdmin ? [
|
||||
null,
|
||||
{ type: 'divider' },
|
||||
{
|
||||
icon: 'ti ti-speakerphone',
|
||||
text: i18n.ts.promote,
|
||||
|
|
@ -347,13 +347,13 @@ export function getNoteMenu(props: {
|
|||
: []
|
||||
),*/
|
||||
...(appearNote.userId !== $i.id ? [
|
||||
null,
|
||||
{ type: 'divider' },
|
||||
appearNote.userId !== $i.id ? getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse) : undefined,
|
||||
]
|
||||
: []
|
||||
),
|
||||
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
|
||||
null,
|
||||
{ type: 'divider' },
|
||||
appearNote.userId === $i.id ? {
|
||||
icon: 'ti ti-edit',
|
||||
text: i18n.ts.deleteAndEdit,
|
||||
|
|
@ -389,7 +389,7 @@ export function getNoteMenu(props: {
|
|||
}
|
||||
|
||||
if (noteActions.length > 0) {
|
||||
menu = menu.concat([null, ...noteActions.map(action => ({
|
||||
menu = menu.concat([{ type: "divider" }, ...noteActions.map(action => ({
|
||||
icon: 'ti ti-plug',
|
||||
text: action.title,
|
||||
action: () => {
|
||||
|
|
@ -399,7 +399,7 @@ export function getNoteMenu(props: {
|
|||
}
|
||||
|
||||
if (defaultStore.state.devMode) {
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: "divider" }, {
|
||||
icon: 'ti ti-id',
|
||||
text: i18n.ts.copyNoteId,
|
||||
action: () => {
|
||||
|
|
@ -528,10 +528,9 @@ export function getRenoteMenu(props: {
|
|||
}]);
|
||||
}
|
||||
|
||||
// nullを挟むことで区切り線を出せる
|
||||
const renoteItems = [
|
||||
...normalRenoteItems,
|
||||
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [null] : [],
|
||||
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] : [],
|
||||
...channelRenoteItems,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function invalidateFollow() {
|
||||
if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return;
|
||||
|
||||
|
|
@ -183,7 +183,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
|
||||
os.post({ specified: user, initialText: `${canonical} ` });
|
||||
},
|
||||
}, null, {
|
||||
}, { type: 'divider' }, {
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.editMemo,
|
||||
action: () => {
|
||||
|
|
@ -307,7 +307,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
}]);
|
||||
//}
|
||||
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: 'divider' }, {
|
||||
icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off',
|
||||
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
|
||||
action: toggleMute,
|
||||
|
|
@ -329,7 +329,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
}]);
|
||||
}
|
||||
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: 'divider' }, {
|
||||
icon: 'ti ti-exclamation-circle',
|
||||
text: i18n.ts.reportAbuse,
|
||||
action: reportAbuse,
|
||||
|
|
@ -337,15 +337,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
}
|
||||
|
||||
if (user.host !== null) {
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: 'divider' }, {
|
||||
icon: 'ti ti-refresh',
|
||||
text: i18n.ts.updateRemoteUser,
|
||||
action: userInfoUpdate,
|
||||
}]);
|
||||
}
|
||||
|
||||
|
||||
if (defaultStore.state.devMode) {
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: 'divider' }, {
|
||||
icon: 'ti ti-id',
|
||||
text: i18n.ts.copyUserId,
|
||||
action: () => {
|
||||
|
|
@ -355,7 +355,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
}
|
||||
|
||||
if ($i && meId === user.id) {
|
||||
menu = menu.concat([null, {
|
||||
menu = menu.concat([{ type: 'divider' }, {
|
||||
icon: 'ti ti-pencil',
|
||||
text: i18n.ts.editProfile,
|
||||
action: () => {
|
||||
|
|
@ -365,7 +365,7 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
}
|
||||
|
||||
if (userActions.length > 0) {
|
||||
menu = menu.concat([null, ...userActions.map(action => ({
|
||||
menu = menu.concat([{ type: 'divider' }, ...userActions.map(action => ({
|
||||
icon: 'ti ti-plug',
|
||||
text: action.title,
|
||||
action: () => {
|
||||
|
|
|
|||
|
|
@ -6,11 +6,19 @@
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
export function isFfVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
|
||||
export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
|
||||
if ($i && $i.id === user.id) return true;
|
||||
|
||||
if (user.ffVisibility === 'private') return false;
|
||||
if (user.ffVisibility === 'followers' && !user.isFollowing) return false;
|
||||
if (user.followingVisibility === 'private') return false;
|
||||
if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
|
||||
if ($i && $i.id === user.id) return true;
|
||||
|
||||
if (user.followersVisibility === 'private') return false;
|
||||
if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
61
packages/frontend/src/scripts/mfm-function-picker.ts
Normal file
61
packages/frontend/src/scripts/mfm-function-picker.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Ref, nextTick } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { MFM_TAGS } from '@/const.js';
|
||||
|
||||
/**
|
||||
* MFMの装飾のリストを表示する
|
||||
*/
|
||||
export function mfmFunctionPicker(src: any, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) {
|
||||
return new Promise((res, rej) => {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.addMfmFunction,
|
||||
type: 'label',
|
||||
}, ...getFunctionList(textArea, textRef)], src);
|
||||
});
|
||||
}
|
||||
|
||||
function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) : object[] {
|
||||
const ret: object[] = [];
|
||||
MFM_TAGS.forEach(tag => {
|
||||
ret.push({
|
||||
text: tag,
|
||||
icon: 'ti ti-icons',
|
||||
action: () => add(textArea, textRef, tag),
|
||||
});
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) {
|
||||
const caretStart: number = textArea.selectionStart as number;
|
||||
const caretEnd: number = textArea.selectionEnd as number;
|
||||
|
||||
MFM_TAGS.forEach(tag => {
|
||||
if (type === tag) {
|
||||
if (caretStart === caretEnd) {
|
||||
// 単純にFunctionを追加
|
||||
const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`;
|
||||
textRef.value = trimmedText;
|
||||
} else {
|
||||
// 選択範囲を囲むようにFunctionを追加
|
||||
const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`;
|
||||
textRef.value = trimmedText;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const nextCaretStart: number = caretStart + 3 + type.length;
|
||||
const nextCaretEnd: number = caretEnd + 3 + type.length;
|
||||
|
||||
// キャレットを戻す
|
||||
nextTick(() => {
|
||||
textArea.focus();
|
||||
textArea.setSelectionRange(nextCaretStart, nextCaretEnd);
|
||||
});
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { defineAsyncComponent, Ref, ref } from 'vue';
|
||||
import { popup } from '@/os.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
class ReactionPicker {
|
||||
private src: Ref<HTMLElement | null> = ref(null);
|
||||
|
|
@ -17,25 +18,27 @@ class ReactionPicker {
|
|||
}
|
||||
|
||||
public async init() {
|
||||
const reactionsRef = defaultStore.reactiveState.reactions;
|
||||
await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), {
|
||||
src: this.src,
|
||||
pinnedEmojis: reactionsRef,
|
||||
asReactionPicker: true,
|
||||
manualShowing: this.manualShowing,
|
||||
}, {
|
||||
done: reaction => {
|
||||
this.onChosen!(reaction);
|
||||
if (this.onChosen) this.onChosen(reaction);
|
||||
},
|
||||
close: () => {
|
||||
this.manualShowing.value = false;
|
||||
},
|
||||
closed: () => {
|
||||
this.src.value = null;
|
||||
this.onClosed!();
|
||||
if (this.onClosed) this.onClosed();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) {
|
||||
public show(src: HTMLElement, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) {
|
||||
this.src.value = src;
|
||||
this.manualShowing.value = true;
|
||||
this.onChosen = onChosen;
|
||||
|
|
|
|||
476
packages/frontend/src/scripts/snowfall-effect.ts
Normal file
476
packages/frontend/src/scripts/snowfall-effect.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import isAnimated from 'is-file-animated';
|
||||
import { isWebpSupported } from './isWebpSupported';
|
||||
import { isWebpSupported } from './isWebpSupported.js';
|
||||
import type { BrowserImageResizerConfig } from 'browser-image-resizer';
|
||||
|
||||
const compressTypeMap = {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,12 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio
|
|||
const tooltipShowing = ref(false);
|
||||
const tooltipX = ref(0);
|
||||
const tooltipY = ref(0);
|
||||
const tooltipTitle = ref(null);
|
||||
const tooltipSeries = ref(null);
|
||||
const tooltipTitle = ref<string | null>(null);
|
||||
const tooltipSeries = ref<{
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
text: string;
|
||||
}[] | null>(null);
|
||||
let disposeTooltipComponent;
|
||||
|
||||
os.popup(MkChartTooltip, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue