1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-06-05 01:14:04 +02:00

enhance: アバターデコレーションへのカテゴリの導入 (#17034)

* feat(backend): AvatarDecorationにcategoryを追加し、関連APIのプロパティ・戻り値にも反映

* feat(frontend): アバターデコレーションのカテゴリ設定機能

* chore(frontend): 管理画面とユーザー側の画面で、アバターデコレーションのグループ化のコードをある程度統一

* CHANGELOGを更新

* fix: group-avatar-decorations.tsを使用するよう修正

* chore: コーディング規約への準拠

* 型エラーを解消
This commit is contained in:
るちーか
2026-04-15 20:29:17 +09:00
committed by GitHub
parent c95aef7535
commit 360e805638
12 changed files with 116 additions and 20 deletions

View File

@@ -53,6 +53,7 @@
- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように - Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように
(Cherry-picked from https://github.com/MisskeyIO/misskey) (Cherry-picked from https://github.com/MisskeyIO/misskey)
- 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました - 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました
- Enhance: アバターデコレーションにカテゴリを設定できるように
- Fix: ユーザーハッシュタグページでユーザーの読み込みが重複する問題を修正 - Fix: ユーザーハッシュタグページでユーザーの読み込みが重複する問題を修正
- 依存関係の更新 - 依存関係の更新

View File

@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddCategoryToAvatarDecorations1766652173085 {
name = 'AddCategoryToAvatarDecorations1766652173085';
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query('ALTER TABLE "avatar_decoration" ADD "category" character varying(128)');
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query('ALTER TABLE "avatar_decoration" DROP COLUMN "category"');
}
};

View File

@@ -36,4 +36,9 @@ export class MiAvatarDecoration {
array: true, length: 128, default: '{}', array: true, length: 128, default: '{}',
}) })
public roleIdsThatCanBeUsedThisDecoration: string[]; public roleIdsThatCanBeUsedThisDecoration: string[];
@Column('varchar', {
length: 128, nullable: true,
})
public category: string | null;
} }

View File

@@ -55,6 +55,10 @@ export const meta = {
format: 'id', format: 'id',
}, },
}, },
category: {
type: 'string',
optional: false, nullable: true,
},
}, },
}, },
} as const; } as const;
@@ -68,6 +72,7 @@ export const paramDef = {
roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: {
type: 'string', type: 'string',
} }, } },
category: { type: 'string', nullable: true },
}, },
required: ['name', 'description', 'url'], required: ['name', 'description', 'url'],
} as const; } as const;
@@ -84,6 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
description: ps.description, description: ps.description,
url: ps.url, url: ps.url,
roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration,
category: ps.category,
}, me); }, me);
return { return {
@@ -94,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
description: created.description, description: created.description,
url: created.url, url: created.url,
roleIdsThatCanBeUsedThisDecoration: created.roleIdsThatCanBeUsedThisDecoration, roleIdsThatCanBeUsedThisDecoration: created.roleIdsThatCanBeUsedThisDecoration,
category: created.category,
}; };
}); });
} }

View File

@@ -60,6 +60,10 @@ export const meta = {
format: 'id', format: 'id',
}, },
}, },
category: {
type: 'string',
optional: true, nullable: true,
},
}, },
}, },
}, },
@@ -95,6 +99,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
description: avatarDecoration.description, description: avatarDecoration.description,
url: avatarDecoration.url, url: avatarDecoration.url,
roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration, roleIdsThatCanBeUsedThisDecoration: avatarDecoration.roleIdsThatCanBeUsedThisDecoration,
category: avatarDecoration.category,
})); }));
}); });
} }

View File

@@ -30,6 +30,7 @@ export const paramDef = {
roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: { roleIdsThatCanBeUsedThisDecoration: { type: 'array', items: {
type: 'string', type: 'string',
} }, } },
category: { type: 'string', nullable: true },
}, },
required: ['id'], required: ['id'],
} as const; } as const;
@@ -45,6 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
description: ps.description, description: ps.description,
url: ps.url, url: ps.url,
roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration,
category: ps.category,
}, me); }, me);
}); });
} }

View File

@@ -49,6 +49,10 @@ export const meta = {
format: 'id', format: 'id',
}, },
}, },
category: {
type: 'string',
optional: true, nullable: true,
},
}, },
}, },
}, },
@@ -76,6 +80,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
description: decoration.description, description: decoration.description,
url: decoration.url, url: decoration.url,
roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(role => role.id === roleId)), roleIdsThatCanBeUsedThisDecoration: decoration.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(role => role.id === roleId)),
category: decoration.category,
})); }));
}); });
} }

View File

@@ -32,6 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="url"> <MkInput v-model="url">
<template #label>{{ i18n.ts.imageUrl }}</template> <template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput> </MkInput>
<MkInput v-model="category" :datalist="props.categories || []">
<template #label>{{ i18n.ts.category }}</template>
</MkInput>
<MkTextarea v-model="description"> <MkTextarea v-model="description">
<template #label>{{ i18n.ts.description }}</template> <template #label>{{ i18n.ts.description }}</template>
</MkTextarea> </MkTextarea>
@@ -79,6 +82,7 @@ const $i = ensureSignin();
const props = defineProps<{ const props = defineProps<{
avatarDecoration?: Misskey.entities.AdminAvatarDecorationsListResponse[number], avatarDecoration?: Misskey.entities.AdminAvatarDecorationsListResponse[number],
categories?: string[],
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -89,6 +93,7 @@ const emit = defineEmits<{
const windowEl = useTemplateRef('windowEl'); const windowEl = useTemplateRef('windowEl');
const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : ''); const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : '');
const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : ''); const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : '');
const category = ref<string>(props.avatarDecoration?.category ? props.avatarDecoration.category : '');
const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : ''); const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : '');
const roleIdsThatCanBeUsedThisDecoration = ref(props.avatarDecoration ? props.avatarDecoration.roleIdsThatCanBeUsedThisDecoration : []); const roleIdsThatCanBeUsedThisDecoration = ref(props.avatarDecoration ? props.avatarDecoration.roleIdsThatCanBeUsedThisDecoration : []);
const rolesThatCanBeUsedThisDecoration = ref<Misskey.entities.Role[]>([]); const rolesThatCanBeUsedThisDecoration = ref<Misskey.entities.Role[]>([]);
@@ -118,6 +123,7 @@ async function done() {
url: url.value, url: url.value,
name: name.value, name: name.value,
description: description.value, description: description.value,
category: category.value,
roleIdsThatCanBeUsedThisDecoration: rolesThatCanBeUsedThisDecoration.value.map(x => x.id), roleIdsThatCanBeUsedThisDecoration: rolesThatCanBeUsedThisDecoration.value.map(x => x.id),
}; };

View File

@@ -7,9 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_spacer" style="--MI_SPACER-w: 900px;">
<div class="_gaps"> <div class="_gaps">
<MkFoldableSection v-for="category in Object.keys(groupedDecorations)" :key="category" :expanded="true">
<template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.decorations"> <div :class="$style.decorations">
<div <div
v-for="avatarDecoration in avatarDecorations" v-for="avatarDecoration in groupedDecorations[category]"
:key="avatarDecoration.id" :key="avatarDecoration.id"
v-panel v-panel
:class="$style.decoration" :class="$style.decoration"
@@ -19,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/> <MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/>
</div> </div>
</div> </div>
</MkFoldableSection>
</div> </div>
</div> </div>
</PageWithHeader> </PageWithHeader>
@@ -32,10 +35,13 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { groupAvatarDecorations } from '@/utility/group-avatar-decorations.js';
const $i = ensureSignin(); const $i = ensureSignin();
const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]); const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]);
const groupedDecorations = computed(() => groupAvatarDecorations(avatarDecorations.value));
function load() { function load() {
misskeyApi('admin/avatar-decorations/list').then(_avatarDecorations => { misskeyApi('admin/avatar-decorations/list').then(_avatarDecorations => {
@@ -47,6 +53,7 @@ load();
async function add(ev: PointerEvent) { async function add(ev: PointerEvent) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), { const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), {
categories: Object.keys(groupedDecorations.value),
}, { }, {
done: result => { done: result => {
if (result.created) { if (result.created) {
@@ -60,6 +67,7 @@ async function add(ev: PointerEvent) {
async function edit(avatarDecoration: Misskey.entities.AdminAvatarDecorationsListResponse[number]) { async function edit(avatarDecoration: Misskey.entities.AdminAvatarDecorationsListResponse[number]) {
const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), { const { dispose } = await os.popupAsyncWithDialog(import('./avatar-decoration-edit-dialog.vue').then(x => x.default), {
avatarDecoration: avatarDecoration, avatarDecoration: avatarDecoration,
categories: Object.keys(groupedDecorations.value),
}, { }, {
done: result => { done: result => {
if (result.updated) { if (result.updated) {

View File

@@ -29,15 +29,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton> <MkButton danger @click="detachAllDecorations">{{ i18n.ts.detachAll }}</MkButton>
</div> </div>
<MkFoldableSection v-for="category in Object.keys(groupedDecorations)" :key="category" :expanded="true">
<template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.decorations"> <div :class="$style.decorations">
<XDecoration <XDecoration
v-for="avatarDecoration in avatarDecorations" v-for="avatarDecoration in groupedDecorations[category]"
:key="avatarDecoration.id" :key="avatarDecoration.id"
:decoration="avatarDecoration" :decoration="avatarDecoration"
@click="openDecoration(avatarDecoration)" @click="openDecoration(avatarDecoration)"
/> />
</div> </div>
</MkFoldableSection>
</div> </div>
<div v-else> <div v-else>
<MkLoading/> <MkLoading/>
@@ -52,17 +54,20 @@ import * as Misskey from 'misskey-js';
import XDecoration from './avatar-decoration.decoration.vue'; import XDecoration from './avatar-decoration.decoration.vue';
import XDialog from './avatar-decoration.dialog.vue'; import XDialog from './avatar-decoration.dialog.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { groupAvatarDecorations } from '@/utility/group-avatar-decorations.js';
const $i = ensureSignin(); const $i = ensureSignin();
const loading = ref(true); const loading = ref(true);
const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]); const avatarDecorations = ref<Misskey.entities.GetAvatarDecorationsResponse>([]);
const groupedDecorations = computed(() => groupAvatarDecorations(avatarDecorations.value));
misskeyApi('get-avatar-decorations').then(_avatarDecorations => { misskeyApi('get-avatar-decorations').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations; avatarDecorations.value = _avatarDecorations;

View File

@@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type AvatarDecorationBase = { category?: string | null | undefined };
/**
* アバターデコレーションをカテゴリごとにグループ化します。
* @param decorations アバターデコレーションの配列
* @returns カテゴリごとにグループ化されたアバターデコレーションオブジェクト
*/
export function groupAvatarDecorations<T extends AvatarDecorationBase>(decorations: T[]) {
const grouped: Record<string, T[]> = {};
for (const decoration of decorations) {
const category = decoration.category ?? '';
if (!(category in grouped)) {
grouped[category] = [];
}
grouped[category].push(decoration);
}
return grouped;
}

View File

@@ -6964,6 +6964,7 @@ export interface operations {
description: string; description: string;
url: string; url: string;
roleIdsThatCanBeUsedThisDecoration?: string[]; roleIdsThatCanBeUsedThisDecoration?: string[];
category?: string | null;
}; };
}; };
}; };
@@ -6985,6 +6986,7 @@ export interface operations {
description: string; description: string;
url: string; url: string;
roleIdsThatCanBeUsedThisDecoration: string[]; roleIdsThatCanBeUsedThisDecoration: string[];
category: string | null;
}; };
}; };
}; };
@@ -7136,6 +7138,7 @@ export interface operations {
description: string; description: string;
url: string; url: string;
roleIdsThatCanBeUsedThisDecoration: string[]; roleIdsThatCanBeUsedThisDecoration: string[];
category?: string | null;
}[]; }[];
}; };
}; };
@@ -7196,6 +7199,7 @@ export interface operations {
description?: string; description?: string;
url?: string; url?: string;
roleIdsThatCanBeUsedThisDecoration?: string[]; roleIdsThatCanBeUsedThisDecoration?: string[];
category?: string | null;
}; };
}; };
}; };
@@ -23633,6 +23637,7 @@ export interface operations {
description: string; description: string;
url: string; url: string;
roleIdsThatCanBeUsedThisDecoration: string[]; roleIdsThatCanBeUsedThisDecoration: string[];
category?: string | null;
}[]; }[];
}; };
}; };