summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-12-13 16:56:19 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2023-12-13 16:56:19 +0900
commit5472f4b934c8ca8c702152a4a927b4ac94cf3fdb (patch)
tree072cf72f4af1fd8da0fd4c05a32052622f78864f
parentfix(frontend): ノート中の絵文字をタップして「リアクショ... (diff)
downloadsharkey-5472f4b934c8ca8c702152a4a927b4ac94cf3fdb.tar.gz
sharkey-5472f4b934c8ca8c702152a4a927b4ac94cf3fdb.tar.bz2
sharkey-5472f4b934c8ca8c702152a4a927b4ac94cf3fdb.zip
enhance: アイコンデコレーションを複数設定できるように
-rw-r--r--CHANGELOG.md1
-rw-r--r--locales/index.d.ts5
-rw-r--r--locales/ja-JP.yml5
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/models/json-schema/role.ts1
-rw-r--r--packages/backend/src/models/json-schema/user.ts4
-rw-r--r--packages/backend/src/server/api/endpoints/i/update.ts10
-rw-r--r--packages/frontend/src/account.ts2
-rw-r--r--packages/frontend/src/components/MkDrive.folder.vue9
-rw-r--r--packages/frontend/src/components/MkWindow.vue2
-rw-r--r--packages/frontend/src/components/global/MkA.vue2
-rw-r--r--packages/frontend/src/components/global/MkAvatar.vue53
-rw-r--r--packages/frontend/src/const.ts1
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue22
-rw-r--r--packages/frontend/src/pages/admin/roles.vue7
-rw-r--r--packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue11
-rw-r--r--packages/frontend/src/pages/settings/profile.vue39
17 files changed, 115 insertions, 62 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d1434cde4..e5ff09edec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
- Feat: メールアドレスの認証にverifymail.ioを使えるように (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/971ba07a44550f68d2ba31c62066db2d43a0caed)
- Feat: モデレーターがユーザーのアイコンもしくはバナー画像を未設定状態にできる機能を追加 (cherry-pick from https://github.com/TeamNijimiss/misskey/commit/e0eb5a752f6e5616d6312bb7c9790302f9dbff83)
- Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加
+- Enhance: アイコンデコレーションを複数設定できるように
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 846a6d503d..d32023f5ac 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -264,6 +264,7 @@ export interface Locale {
"removeAreYouSure": string;
"deleteAreYouSure": string;
"resetAreYouSure": string;
+ "areYouSure": string;
"saved": string;
"messaging": string;
"upload": string;
@@ -1160,6 +1161,7 @@ export interface Locale {
"avatarDecorations": string;
"attach": string;
"detach": string;
+ "detachAll": string;
"angle": string;
"flip": string;
"showAvatarDecorations": string;
@@ -1173,6 +1175,7 @@ export interface Locale {
"doReaction": string;
"code": string;
"reloadRequiredToApplySettings": string;
+ "remainingN": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
@@ -1701,6 +1704,7 @@ export interface Locale {
"canHideAds": string;
"canSearchNotes": string;
"canUseTranslator": string;
+ "avatarDecorationLimit": string;
};
"_condition": {
"isLocal": string;
@@ -2181,6 +2185,7 @@ export interface Locale {
"changeAvatar": string;
"changeBanner": string;
"verifiedLinkDescription": string;
+ "avatarDecorationMax": string;
};
"_exportOrImport": {
"allNotes": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 0d84440bc8..2ac57fd311 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -261,6 +261,7 @@ removed: "削除しました"
removeAreYouSure: "「{x}」を削除しますか?"
deleteAreYouSure: "「{x}」を削除しますか?"
resetAreYouSure: "リセットしますか?"
+areYouSure: "よろしいですか?"
saved: "保存しました"
messaging: "チャット"
upload: "アップロード"
@@ -1157,6 +1158,7 @@ tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
avatarDecorations: "アイコンデコレーション"
attach: "付ける"
detach: "外す"
+detachAll: "全て外す"
angle: "角度"
flip: "反転"
showAvatarDecorations: "アイコンのデコレーションを表示"
@@ -1170,6 +1172,7 @@ cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述
doReaction: "リアクションする"
code: "コード"
reloadRequiredToApplySettings: "設定の反映にはリロードが必要です。"
+remainingN: "残り: {n}"
_announcement:
forExistingUsers: "既存ユーザーのみ"
@@ -1610,6 +1613,7 @@ _role:
canHideAds: "広告の非表示"
canSearchNotes: "ノート検索の利用"
canUseTranslator: "翻訳機能の利用"
+ avatarDecorationLimit: "アイコンデコレーションの最大取付個数"
_condition:
isLocal: "ローカルユーザー"
isRemote: "リモートユーザー"
@@ -2084,6 +2088,7 @@ _profile:
changeAvatar: "アイコン画像を変更"
changeBanner: "バナー画像を変更"
verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。"
+ avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。"
_exportOrImport:
allNotes: "全てのノート"
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 29e48aa8ca..4de719d6a0 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -47,6 +47,7 @@ export type RolePolicies = {
userListLimit: number;
userEachUserListsLimit: number;
rateLimitFactor: number;
+ avatarDecorationLimit: number;
};
export const DEFAULT_POLICIES: RolePolicies = {
@@ -73,6 +74,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
userListLimit: 10,
userEachUserListsLimit: 50,
rateLimitFactor: 1,
+ avatarDecorationLimit: 1,
};
@Injectable()
@@ -326,6 +328,7 @@ export class RoleService implements OnApplicationShutdown {
userListLimit: calc('userListLimit', vs => Math.max(...vs)),
userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)),
rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)),
+ avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)),
};
}
diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts
index dd2f32b14d..b0c6804bb8 100644
--- a/packages/backend/src/models/json-schema/role.ts
+++ b/packages/backend/src/models/json-schema/role.ts
@@ -145,6 +145,7 @@ export const packedRoleSchema = {
userEachUserListsLimit: rolePolicyValue,
canManageAvatarDecorations: rolePolicyValue,
canUseTranslator: rolePolicyValue,
+ avatarDecorationLimit: rolePolicyValue,
},
},
usersCount: {
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index c6b2707b80..c6b96b85f0 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -672,6 +672,10 @@ export const packedMeDetailedOnlySchema = {
type: 'number',
nullable: false, optional: false,
},
+ avatarDecorationLimit: {
+ type: 'number',
+ nullable: false, optional: false,
+ },
},
},
//#region secrets
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index b045c01189..399e6b88cb 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -125,7 +125,7 @@ export const meta = {
const muteWords = { type: 'array', items: { oneOf: [
{ type: 'array', items: { type: 'string' } },
- { type: 'string' }
+ { type: 'string' },
] } } as const;
export const paramDef = {
@@ -137,7 +137,7 @@ export const paramDef = {
birthday: { ...birthdaySchema, nullable: true },
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
- avatarDecorations: { type: 'array', maxItems: 1, items: {
+ avatarDecorations: { type: 'array', maxItems: 16, items: {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
@@ -251,7 +251,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
function validateMuteWordRegex(mutedWords: (string[] | string)[]) {
for (const mutedWord of mutedWords) {
- if (typeof mutedWord !== "string") continue;
+ if (typeof mutedWord !== 'string') continue;
const regexp = mutedWord.match(/^\/(.+)\/(.*)$/);
if (!regexp) throw new ApiError(meta.errors.invalidRegexp);
@@ -329,12 +329,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.avatarDecorations) {
const decorations = await this.avatarDecorationService.getAll(true);
- const myRoles = await this.roleService.getUserRoles(user.id);
+ const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]);
const allRoles = await this.roleService.getRoles();
const decorationIds = decorations
.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);
+ if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole);
+
updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
id: d.id,
angle: d.angle ?? 0,
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index 0e4e4b50ff..a6af298024 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -284,7 +284,7 @@ export async function openAccountMenu(opts: {
text: i18n.ts.profile,
to: `/@${ $i.username }`,
avatar: $i,
- }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
+ }, { type: 'divider' }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
icon: 'ti ti-plus',
text: i18n.ts.addAccount,
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 5322664664..b0c14d1f0b 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -39,6 +39,7 @@ import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
+import { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
@@ -250,7 +251,7 @@ function setAsUploadFolder() {
}
function onContextmenu(ev: MouseEvent) {
- let menu;
+ let menu: MenuItem[];
menu = [{
text: i18n.ts.openInWindow,
icon: 'ti ti-app-window',
@@ -260,18 +261,18 @@ function onContextmenu(ev: MouseEvent) {
}, {
}, 'closed');
},
- }, null, {
+ }, { type: 'divider' }, {
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: rename,
- }, null, {
+ }, { type: 'divider' }, {
text: i18n.ts.delete,
icon: 'ti ti-trash',
danger: true,
action: deleteFolder,
}];
if (defaultStore.state.devMode) {
- menu = menu.concat([null, {
+ menu = menu.concat([{ type: 'divider' }, {
icon: 'ti ti-id',
text: i18n.ts.copyFolderId,
action: () => {
diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue
index 1150a29e03..7c8ffcccf9 100644
--- a/packages/frontend/src/components/MkWindow.vue
+++ b/packages/frontend/src/components/MkWindow.vue
@@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue';
import contains from '@/scripts/contains.js';
import * as os from '@/os.js';
-import { MenuItem } from '@/types/menu';
+import { MenuItem } from '@/types/menu.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue
index 809dae421a..5552e96ee0 100644
--- a/packages/frontend/src/components/global/MkA.vue
+++ b/packages/frontend/src/components/global/MkA.vue
@@ -57,7 +57,7 @@ function onContextmenu(ev) {
action: () => {
router.push(props.to, 'forcePage');
},
- }, null, {
+ }, { type: 'divider' }, {
icon: 'ti ti-external-link',
text: i18n.ts.openInNewTab,
action: () => {
diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue
index c7e50e275a..6aa9a42037 100644
--- a/packages/frontend/src/components/global/MkAvatar.vue
+++ b/packages/frontend/src/components/global/MkAvatar.vue
@@ -23,16 +23,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
- <img
- v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)"
- :class="[$style.decoration]"
- :src="decoration?.url ?? user.avatarDecorations[0].url"
- :style="{
- rotate: getDecorationAngle(),
- scale: getDecorationScale(),
- }"
- alt=""
- >
+ <template v-if="showDecoration">
+ <img
+ v-for="decoration in decorations ?? user.avatarDecorations"
+ :class="[$style.decoration]"
+ :src="decoration.url"
+ :style="{
+ rotate: getDecorationAngle(decoration),
+ scale: getDecorationScale(decoration),
+ }"
+ alt=""
+ >
+ </template>
</component>
</template>
@@ -57,19 +59,14 @@ const props = withDefaults(defineProps<{
link?: boolean;
preview?: boolean;
indicator?: boolean;
- decoration?: {
- url: string;
- angle?: number;
- flipH?: boolean;
- flipV?: boolean;
- };
+ decorations?: Misskey.entities.UserDetailed['avatarDecorations'][number][];
forceShowDecoration?: boolean;
}>(), {
target: null,
link: false,
preview: false,
indicator: false,
- decoration: undefined,
+ decorations: undefined,
forceShowDecoration: false,
});
@@ -92,27 +89,13 @@ function onClick(ev: MouseEvent): void {
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;
- }
+function getDecorationAngle(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
+ const angle = decoration.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;
- }
+function getDecorationScale(decoration: Misskey.entities.UserDetailed['avatarDecorations'][number]) {
+ const scaleX = decoration.flipH ? -1 : 1;
return scaleX === 1 ? undefined : `${scaleX} 1`;
}
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index 397f804822..f016b7aa02 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -81,6 +81,7 @@ export const ROLE_POLICIES = [
'userListLimit',
'userEachUserListsLimit',
'rateLimitFactor',
+ 'avatarDecorationLimit',
] as const;
// なんか動かない
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index a8e0e8bbd1..5ded8d6931 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -531,6 +531,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
+ <template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
+ <template #suffix>
+ <span v-if="role.policies.avatarDecorationLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.avatarDecorationLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.avatarDecorationLimit)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0">
+ <template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
+ </MkInput>
+ <MkRange v-model="role.policies.avatarDecorationLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <template #label>{{ i18n.ts._role.priority }}</template>
+ </MkRange>
+ </div>
+ </MkFolder>
</div>
</FormSlot>
</div>
@@ -549,7 +569,7 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
import FormSlot from '@/components/form/slot.vue';
import { i18n } from '@/i18n.js';
-import { ROLE_POLICIES } from '@/const';
+import { ROLE_POLICIES } from '@/const.js';
import { instance } from '@/instance.js';
import { deepClone } from '@/scripts/clone.js';
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index db4595b150..1bb91a0a5b 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -192,6 +192,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
+ <template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
+ <template #suffix>{{ policies.avatarDecorationLimit }}</template>
+ <MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0">
+ </MkInput>
+ </MkFolder>
+
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
diff --git a/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue
index 4d571bc9ba..c27a21217b 100644
--- a/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue
+++ b/packages/frontend/src/pages/settings/profile.avatar-decoration-dialog.vue
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<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 }" forceShowDecoration/>
+ <MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decorations="[...$i.avatarDecorations, { url: decoration.url, angle, flipH }]" forceShowDecoration/>
</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)}°`">
@@ -54,6 +54,7 @@ const props = defineProps<{
decoration: {
id: string;
url: string;
+ name: string;
}
}>();
@@ -77,18 +78,18 @@ async function attach() {
flipH: flipH.value,
};
await os.apiWithDialog('i/update', {
- avatarDecorations: [decoration],
+ avatarDecorations: [...$i.avatarDecorations, decoration],
});
- $i.avatarDecorations = [decoration];
+ $i.avatarDecorations = [...$i.avatarDecorations, decoration];
dialog.value.close();
}
async function detach() {
await os.apiWithDialog('i/update', {
- avatarDecorations: [],
+ avatarDecorations: $i.avatarDecorations.filter(x => x.id !== props.decoration.id),
});
- $i.avatarDecorations = [];
+ $i.avatarDecorations = $i.avatarDecorations.filter(x => x.id !== props.decoration.id);
dialog.value.close();
}
diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue
index ba75b539e1..a5d3835b93 100644
--- a/packages/frontend/src/pages/settings/profile.vue
+++ b/packages/frontend/src/pages/settings/profile.vue
@@ -87,16 +87,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-sparkles"></i></template>
<template #label>{{ i18n.ts.avatarDecorations }}</template>
- <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
- <div
- v-for="avatarDecoration in avatarDecorations"
- :key="avatarDecoration.id"
- :class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
- @click="openDecoration(avatarDecoration)"
- >
- <div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
- <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/>
- <i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ti ti-lock"></i>
+ <div class="_gaps">
+ <MkInfo>{{ i18n.t('_profile.avatarDecorationMax', { max: $i?.policies.avatarDecorationLimit }) }} ({{ i18n.t('remainingN', { n: $i?.policies.avatarDecorationLimit - $i.avatarDecorations.length }) }})</MkInfo>
+
+ <MkButton v-if="$i.avatarDecorations.length > 0" danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton>
+
+ <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-gap: 12px;">
+ <div
+ v-for="avatarDecoration in avatarDecorations"
+ :key="avatarDecoration.id"
+ :class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
+ @click="openDecoration(avatarDecoration)"
+ >
+ <div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
+ <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/>
+ <i v-if="avatarDecoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => avatarDecoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id))" :class="$style.avatarDecorationLock" class="ti ti-lock"></i>
+ </div>
</div>
</div>
</MkFolder>
@@ -273,6 +279,19 @@ function openDecoration(avatarDecoration) {
}, {}, 'closed');
}
+function detachAllDecorations() {
+ os.confirm({
+ type: 'warning',
+ text: i18n.ts.areYouSure,
+ }).then(async ({ canceled }) => {
+ if (canceled) return;
+ await os.apiWithDialog('i/update', {
+ avatarDecorations: [],
+ });
+ $i.avatarDecorations = [];
+ });
+}
+
const headerActions = computed(() => []);
const headerTabs = computed(() => []);