enhance: improve avatar decoration
This commit is contained in:
parent
69795e74bf
commit
4eaa02d25f
13 changed files with 230 additions and 26 deletions
4
locales/index.d.ts
vendored
4
locales/index.d.ts
vendored
|
@ -1147,6 +1147,10 @@ export interface Locale {
|
||||||
"privacyPolicyUrl": string;
|
"privacyPolicyUrl": string;
|
||||||
"tosAndPrivacyPolicy": string;
|
"tosAndPrivacyPolicy": string;
|
||||||
"avatarDecorations": string;
|
"avatarDecorations": string;
|
||||||
|
"attach": string;
|
||||||
|
"detach": string;
|
||||||
|
"angle": string;
|
||||||
|
"flip": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
|
|
@ -1144,6 +1144,10 @@ privacyPolicy: "プライバシーポリシー"
|
||||||
privacyPolicyUrl: "プライバシーポリシーURL"
|
privacyPolicyUrl: "プライバシーポリシーURL"
|
||||||
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
||||||
avatarDecorations: "アイコンデコレーション"
|
avatarDecorations: "アイコンデコレーション"
|
||||||
|
attach: "付ける"
|
||||||
|
detach: "外す"
|
||||||
|
angle: "角度"
|
||||||
|
flip: "反転"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AvatarDecoration21697941908548 {
|
||||||
|
name = 'AvatarDecoration21697941908548'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" jsonb NOT NULL DEFAULT '[]'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -338,9 +338,11 @@ export class UserEntityService implements OnModuleInit {
|
||||||
host: user.host,
|
host: user.host,
|
||||||
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
||||||
avatarBlurhash: user.avatarBlurhash,
|
avatarBlurhash: user.avatarBlurhash,
|
||||||
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({
|
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
|
||||||
id: decoration.id,
|
id: ud.id,
|
||||||
url: decoration.url,
|
angle: ud.angle || undefined,
|
||||||
|
flipH: ud.flipH || undefined,
|
||||||
|
url: decorations.find(d => d.id === ud.id)!.url,
|
||||||
}))) : [],
|
}))) : [],
|
||||||
isBot: user.isBot,
|
isBot: user.isBot,
|
||||||
isCat: user.isCat,
|
isCat: user.isCat,
|
||||||
|
|
|
@ -138,10 +138,14 @@ export class MiUser {
|
||||||
})
|
})
|
||||||
public bannerBlurhash: string | null;
|
public bannerBlurhash: string | null;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('jsonb', {
|
||||||
length: 512, array: true, default: '{}',
|
default: [],
|
||||||
})
|
})
|
||||||
public avatarDecorations: string[];
|
public avatarDecorations: {
|
||||||
|
id: string;
|
||||||
|
angle: number;
|
||||||
|
flipH: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
|
|
|
@ -54,6 +54,14 @@ export const packedUserLiteSchema = {
|
||||||
format: 'url',
|
format: 'url',
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
},
|
},
|
||||||
|
angle: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: false, optional: true,
|
||||||
|
},
|
||||||
|
flipH: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: false, optional: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -133,7 +133,13 @@ export const paramDef = {
|
||||||
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
|
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
|
||||||
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
|
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||||
avatarDecorations: { type: 'array', maxItems: 1, items: {
|
avatarDecorations: { type: 'array', maxItems: 1, items: {
|
||||||
type: 'string',
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'misskey:id' },
|
||||||
|
angle: { type: 'number', nullable: true, maximum: 0.5, minimum: -0.5 },
|
||||||
|
flipH: { type: 'boolean', nullable: true },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
} },
|
} },
|
||||||
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
|
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||||
fields: {
|
fields: {
|
||||||
|
@ -309,7 +315,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
|
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
|
|
||||||
updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id));
|
updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
angle: d.angle ?? 0,
|
||||||
|
flipH: d.flipH ?? false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.pinnedPageId) {
|
if (ps.pinnedPageId) {
|
||||||
|
|
|
@ -34,6 +34,7 @@ const props = withDefaults(defineProps<{
|
||||||
textConverter?: (value: number) => string,
|
textConverter?: (value: number) => string,
|
||||||
showTicks?: boolean;
|
showTicks?: boolean;
|
||||||
easing?: boolean;
|
easing?: boolean;
|
||||||
|
continuousUpdate?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
step: 1,
|
step: 1,
|
||||||
textConverter: (v) => v.toString(),
|
textConverter: (v) => v.toString(),
|
||||||
|
@ -123,6 +124,10 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
|
||||||
const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
|
const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
|
||||||
const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
|
const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
|
||||||
rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
|
rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
|
||||||
|
|
||||||
|
if (props.continuousUpdate) {
|
||||||
|
emit('update:modelValue', finalValue.value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let beforeValue = finalValue.value;
|
let beforeValue = finalValue.value;
|
||||||
|
|
|
@ -23,7 +23,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img v-if="decoration || user.avatarDecorations.length > 0" :class="[$style.decoration]" :src="decoration ?? user.avatarDecorations[0].url" alt="">
|
<img
|
||||||
|
v-if="decoration || user.avatarDecorations.length > 0"
|
||||||
|
:class="[$style.decoration]"
|
||||||
|
:src="decoration?.url ?? user.avatarDecorations[0].url"
|
||||||
|
:style="{
|
||||||
|
rotate: getDecorationAngle(),
|
||||||
|
scale: getDecorationScale(),
|
||||||
|
}"
|
||||||
|
alt=""
|
||||||
|
>
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -48,12 +57,18 @@ const props = withDefaults(defineProps<{
|
||||||
link?: boolean;
|
link?: boolean;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
indicator?: boolean;
|
indicator?: boolean;
|
||||||
decoration?: string;
|
decoration?: {
|
||||||
|
url: string;
|
||||||
|
angle?: number;
|
||||||
|
flipH?: boolean;
|
||||||
|
flipV?: boolean;
|
||||||
|
};
|
||||||
}>(), {
|
}>(), {
|
||||||
target: null,
|
target: null,
|
||||||
link: false,
|
link: false,
|
||||||
preview: false,
|
preview: false,
|
||||||
indicator: false,
|
indicator: false,
|
||||||
|
decoration: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -73,6 +88,30 @@ function onClick(ev: MouseEvent): void {
|
||||||
emit('click', ev);
|
emit('click', ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDecorationAngle() {
|
||||||
|
let angle;
|
||||||
|
if (props.decoration) {
|
||||||
|
angle = props.decoration.angle ?? 0;
|
||||||
|
} else if (props.user.avatarDecorations.length > 0) {
|
||||||
|
angle = props.user.avatarDecorations[0].angle ?? 0;
|
||||||
|
} else {
|
||||||
|
angle = 0;
|
||||||
|
}
|
||||||
|
return angle === 0 ? undefined : `${angle * 360}deg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecorationScale() {
|
||||||
|
let scaleX;
|
||||||
|
if (props.decoration) {
|
||||||
|
scaleX = props.decoration.flipH ? -1 : 1;
|
||||||
|
} else if (props.user.avatarDecorations.length > 0) {
|
||||||
|
scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1;
|
||||||
|
} else {
|
||||||
|
scaleX = 1;
|
||||||
|
}
|
||||||
|
return scaleX === 1 ? undefined : `${scaleX} 1`;
|
||||||
|
}
|
||||||
|
|
||||||
let color = $ref<string | undefined>();
|
let color = $ref<string | undefined>();
|
||||||
|
|
||||||
watch(() => props.user.avatarBlurhash, () => {
|
watch(() => props.user.avatarBlurhash, () => {
|
||||||
|
|
|
@ -0,0 +1,114 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkModalWindow
|
||||||
|
ref="dialog"
|
||||||
|
:width="400"
|
||||||
|
:height="450"
|
||||||
|
@close="cancel"
|
||||||
|
@closed="emit('closed')"
|
||||||
|
>
|
||||||
|
<template #header>{{ i18n.ts.avatarDecorations }}</template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MkSpacer :marginMin="20" :marginMax="28">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div :class="$style.name">{{ decoration.name }}</div>
|
||||||
|
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH }"/>
|
||||||
|
</div>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
|
||||||
|
<template #label>{{ i18n.ts.angle }}</template>
|
||||||
|
</MkRange>
|
||||||
|
<MkSwitch v-model="flipH">
|
||||||
|
<template #label>{{ i18n.ts.flip }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
|
||||||
|
<div :class="$style.footer" class="_buttonsCenter">
|
||||||
|
<MkButton v-if="using" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
|
||||||
|
<MkButton v-if="using" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
|
||||||
|
<MkButton v-else primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkModalWindow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { shallowRef, ref, computed } from 'vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
import MkRange from '@/components/MkRange.vue';
|
||||||
|
import { $i } from '@/account.js';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
decoration: {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'closed'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||||
|
const using = computed(() => $i.avatarDecorations.some(x => x.id === props.decoration.id));
|
||||||
|
const angle = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).angle ?? 0 : 0);
|
||||||
|
const flipH = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).flipH ?? false : false);
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach() {
|
||||||
|
const decoration = {
|
||||||
|
id: props.decoration.id,
|
||||||
|
angle: angle.value,
|
||||||
|
flipH: flipH.value,
|
||||||
|
};
|
||||||
|
await os.apiWithDialog('i/update', {
|
||||||
|
avatarDecorations: [decoration],
|
||||||
|
});
|
||||||
|
$i.avatarDecorations = [decoration];
|
||||||
|
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detach() {
|
||||||
|
await os.apiWithDialog('i/update', {
|
||||||
|
avatarDecorations: [],
|
||||||
|
});
|
||||||
|
$i.avatarDecorations = [];
|
||||||
|
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.name {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: solid 0.5px var(--divider);
|
||||||
|
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||||
|
backdrop-filter: var(--blur, blur(15px));
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -92,10 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
v-for="avatarDecoration in avatarDecorations"
|
v-for="avatarDecoration in avatarDecorations"
|
||||||
:key="avatarDecoration.id"
|
:key="avatarDecoration.id"
|
||||||
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
|
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
|
||||||
@click="toggleDecoration(avatarDecoration)"
|
@click="openDecoration(avatarDecoration)"
|
||||||
>
|
>
|
||||||
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
|
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
|
||||||
<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="avatarDecoration.url"/>
|
<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="{ url: avatarDecoration.url }"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
@ -266,18 +266,10 @@ function changeBanner(ev) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDecoration(avatarDecoration) {
|
function openDecoration(avatarDecoration) {
|
||||||
if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) {
|
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
|
||||||
os.apiWithDialog('i/update', {
|
decoration: avatarDecoration,
|
||||||
avatarDecorations: [],
|
}, {}, 'closed');
|
||||||
});
|
|
||||||
$i.avatarDecorations = [];
|
|
||||||
} else {
|
|
||||||
os.apiWithDialog('i/update', {
|
|
||||||
avatarDecorations: [avatarDecoration.id],
|
|
||||||
});
|
|
||||||
$i.avatarDecorations.push(avatarDecoration);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions = $computed(() => []);
|
const headerActions = $computed(() => []);
|
||||||
|
|
|
@ -2996,6 +2996,8 @@ type UserLite = {
|
||||||
avatarDecorations: {
|
avatarDecorations: {
|
||||||
id: ID;
|
id: ID;
|
||||||
url: string;
|
url: string;
|
||||||
|
angle?: number;
|
||||||
|
flipH?: boolean;
|
||||||
}[];
|
}[];
|
||||||
emojis: {
|
emojis: {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -3021,8 +3023,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
|
||||||
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
|
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
|
||||||
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
||||||
// src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
// src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
||||||
// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
|
// src/entities.ts:115:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
|
||||||
// src/entities.ts:609:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
// src/entities.ts:611:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||||
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
||||||
|
|
||||||
// (No @packageDocumentation comment for this package)
|
// (No @packageDocumentation comment for this package)
|
||||||
|
|
|
@ -19,6 +19,8 @@ export type UserLite = {
|
||||||
avatarDecorations: {
|
avatarDecorations: {
|
||||||
id: ID;
|
id: ID;
|
||||||
url: string;
|
url: string;
|
||||||
|
angle?: number;
|
||||||
|
flipH?: boolean;
|
||||||
}[];
|
}[];
|
||||||
emojis: {
|
emojis: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
Loading…
Reference in a new issue