diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d1434cde42ccf3ecdbe8da985765d93fc7b91f1..e5ff09edeca2a21d56e1009eb4f0aee9d9d5f2fd 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 846a6d503dbabe59a8f3b75d21a4a692ce06da56..d32023f5ac75a5919a891ce99a1f387c7abc83e9 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 0d84440bc868278c623b9f462a83e4f0ff1b5127..2ac57fd3111a9177d807061d5e00320298d8dffb 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 29e48aa8ca0d79fb408a3aae3ac8e156ae6a4b41..4de719d6a06b9a71dbb9b0fdac3dfb9d00dee09a 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 dd2f32b14d7768d1c26bdbaf87ad2f06601c1b13..b0c6804bb8f5021be0a3cad4c73b9581b2477728 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 c6b2707b80ed08c23a1147e1618f636d51a8107c..c6b96b85f0728f5b2bee5dbcb727455fbac58097 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 b045c011891e50a31bcfbcd06d84db7a2078bfce..399e6b88cb35d3e5077c31d79ee6d11641dea8e1 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 0e4e4b50ff90a602017f8ad0dd0a2be7b2a29819..a6af298024d2bdac518d8b1a65b68fdea89a74ec 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 53226646649585ad3dec1b6fdef91dca8b7fe7c8..b0c14d1f0bc131ff55f43c0c6b9db32c100233cd 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 1150a29e03d702bae73ff522f272cc3c63382266..7c8ffcccf9af9461652431cee90f5d2719eb9850 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 809dae421ab6a4c2c3944efb2114d9af6a829fe4..5552e96ee09de48ebd62b25dac862f0fefdb67a0 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 c7e50e275a3cf10a36fe59a2e0e8bcf92f7455a7..6aa9a42037c2e6efcbf78010bad8c68d206d8fdd 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 397f80482236012b883ef9e373cad09fc32a7587..f016b7aa02854394411a51be46df62b54a3079c6 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 a8e0e8bbd19f5db5ada47cec997824c5d65eef37..5ded8d6931ce0a3fe7c272307a3ea0f01af9bef0 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 db4595b150fe23161088e9705d06a74b4b5cfeb4..1bb91a0a5bd1f139f5cbde61f7215e93f8502451 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 4d571bc9ba8f14bc2793fc8565cf16f1818e01cc..c27a21217b562b8b7b2f9488d883581e6129acbd 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 ba75b539e1c8f3d6e5b4f4581e2b45b1bd4fcd87..a5d3835b934dcc8eb15e2d1f89774bc9f17b63e8 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(() => []);