feat: 新カスタム絵文字管理画面(β)の追加 (#13473)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* fix

* fix

* fix

* fix size

* fix register logs

* fix img autosize

* fix row selection

* support delete

* fix border rendering

* fix display:none

* tweak comments

* support choose pc file and drive file

* support directory drag-drop

* fix

* fix comment

* support context menu on data area

* fix autogen

* wip イベント整理

* イベントの整理

* refactor grid

* fix cell re-render bugs

* fix row remove

* fix comment

* fix validation

* fix utils

* list maximum

* add mimetype check

* fix

* fix number cell focus

* fix over 100 file drop

* remove log

* fix patchData

* fix performance

* fix

* support update and delete

* support remote import

* fix layout

* heightやめる

* fix performance

* add list v2 endpoint

* support pagination

* fix api call

* fix no clickable input text

* fix limit

* fix paging

* fix

* fix

* support search

* tweak logs

* tweak cell selection

* fix range select

* block delete

* add comment

* fix

* support import log

* fix dialog

* refactor

* add confirm dialog

* fix name

* fix autogen

* wip

* support image change and highlight row

* add columns

* wip

* support sort

* add role name

* add index to emoji

* refine context menu setting

* support role select

* remove unused buttons

* fix url

* fix MkRoleSelectDialog.vue

* add route

* refine remote page

* enter key search

* fix paste bugs

* fix copy/paste

* fix keyEvent

* fix copy/paste and delete

* fix comment

* fix MkRoleSelectDialog.vue and storybook scenario

* fix MkRoleSelectDialog.vue and storybook scenario

* add MkGrid.stories.impl.ts

* fix

* [wip] add custom-emojis-manager2.stories.impl.ts

* [wip] add custom-emojis-manager2.stories.impl.ts

* wip

* 課題はまだ残っているが、ひとまず完了

* fix validation and register roles

* fix upload

* optimize import

* patch from dev

* i18n

* revert excess fixes

* separate sort order component

* add SPDX

* revert excess fixes

* fix pre test

* fix bugs

* add type column

* fix types

* fix CHANGELOG.md

* fix lit

* lint

* tweak style

* refactor

* fix ci

* autogen

* Update types.ts

* CSS Module化

* fix log

* 縦スクロールを無効化

* MkStickyContainer化

* regenerate locales index.d.ts

* fix

* fix

* テスト

* ランダム値によるUI変更の抑制

* テスト

* tableタグやめる

* fix last-child css

* fix overflow css

* fix endpoint.ts

* tweak css

* 最新への追従とレイアウト微調整

* ソートキーの指定方法を他と合わせた

* fix focus

* fix layout

* v2エンドポイントのルールに対応

* 表示条件などを微調整

* fix MkDataCell.vue

* fix error code

* fix error

* add comment to MkModal.vue

* Update index.d.ts

* fix CHANGELOG.md

* fix color theme

* fix CHANGELOG.md

* fix CHANGELOG.md

* fix center

* fix: テーブルにフォーカスがあり、通常状態であるときはキーイベントの伝搬を止める

* fix: ロール選択用のダイアログにてコンディショナルロールを×ボタンで除外できなかったのを修正

* fix remote list folder

* sticky footers

* chore: fix ci error(just single line-break diff)

* fix loading

* fix like

* comma to space

* fix ci

* fix ci

* removed align-center

---------

Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Co-authored-by: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com>
This commit is contained in:
おさむのひと
2025-01-20 20:35:37 +09:00
committed by GitHub
parent b41e78090d
commit f9ad127aaf
66 changed files with 8275 additions and 58 deletions

View File

@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type RequestLogItem = {
failed: boolean;
url: string;
name: string;
error?: string;
};
export const gridSortOrderKeys = [
'name',
'category',
'aliases',
'type',
'license',
'host',
'uri',
'publicUrl',
'isSensitive',
'localOnly',
'updatedAt',
];
export type GridSortOrderKey = typeof gridSortOrderKeys[number];
export function emptyStrToUndefined(value: string | null) {
return value ? value : undefined;
}
export function emptyStrToNull(value: string) {
return value === '' ? null : value;
}
export function emptyStrToEmptyArray(value: string) {
return value === '' ? [] : value.split(' ').map(it => it.trim());
}
export function roleIdsParser(text: string): { id: string, name: string }[] {
// idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない
try {
const obj = JSON.parse(text);
if (!Array.isArray(obj)) {
return [];
}
if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) {
return [];
}
return obj.map(it => ({ id: it.id, name: it.name }));
} catch (ex) {
console.warn(ex);
return [];
}
}

View File

@@ -0,0 +1,757 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #default>
<div class="_gaps">
<MkFolder>
<template #icon><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
<template #caption>
{{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
</template>
<div class="_gaps">
<div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
<MkInput
v-model="queryName"
type="search"
autocapitalize="off"
:class="[$style.col1, $style.row1]"
@enter="onSearchRequest"
>
<template #label>name</template>
</MkInput>
<MkInput
v-model="queryCategory"
type="search"
autocapitalize="off"
:class="[$style.col2, $style.row1]"
@enter="onSearchRequest"
>
<template #label>category</template>
</MkInput>
<MkInput
v-model="queryAliases"
type="search"
autocapitalize="off"
:class="[$style.col3, $style.row1]"
@enter="onSearchRequest"
>
<template #label>aliases</template>
</MkInput>
<MkInput
v-model="queryType"
type="search"
autocapitalize="off"
:class="[$style.col1, $style.row2]"
@enter="onSearchRequest"
>
<template #label>type</template>
</MkInput>
<MkInput
v-model="queryLicense"
type="search"
autocapitalize="off"
:class="[$style.col2, $style.row2]"
@enter="onSearchRequest"
>
<template #label>license</template>
</MkInput>
<MkSelect
v-model="querySensitive"
:class="[$style.col3, $style.row2]"
>
<template #label>sensitive</template>
<option :value="null">-</option>
<option :value="true">true</option>
<option :value="false">false</option>
</MkSelect>
<MkSelect
v-model="queryLocalOnly"
:class="[$style.col1, $style.row3]"
>
<template #label>localOnly</template>
<option :value="null">-</option>
<option :value="true">true</option>
<option :value="false">false</option>
</MkSelect>
<MkInput
v-model="queryUpdatedAtFrom"
type="date"
autocapitalize="off"
:class="[$style.col2, $style.row3]"
@enter="onSearchRequest"
>
<template #label>updatedAt(from)</template>
</MkInput>
<MkInput
v-model="queryUpdatedAtTo"
type="date"
autocapitalize="off"
:class="[$style.col3, $style.row3]"
@enter="onSearchRequest"
>
<template #label>updatedAt(to)</template>
</MkInput>
<MkInput
v-model="queryRolesText"
type="text"
readonly
autocapitalize="off"
:class="[$style.col1, $style.row4]"
@click="onQueryRolesEditClicked"
>
<template #label>role</template>
<template #suffix><span class="ti ti-pencil"/></template>
</MkInput>
</div>
<MkFolder :spacerMax="8" :spacerMin="8">
<template #icon><i class="ti ti-arrows-sort"></i></template>
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
<MkSortOrderEditor
:baseOrderKeyNames="gridSortOrderKeys"
:currentOrders="sortOrders"
@update="onSortOrderUpdate"
/>
</MkFolder>
<div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
<MkButton primary @click="onSearchRequest">
{{ i18n.ts.search }}
</MkButton>
<MkButton @click="onQueryResetButtonClicked">
{{ i18n.ts.reset }}
</MkButton>
</div>
</div>
</MkFolder>
<XRegisterLogsFolder :logs="requestLogs"/>
<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
<template v-else>
<div v-if="gridItems.length === 0" style="text-align: center">
{{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
</div>
<template v-else>
<div :class="$style.gridArea">
<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
</div>
<div :class="$style.footer">
<div :class="$style.left">
<MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">
{{ i18n.ts.delete }} ({{ deleteItemsCount }})
</MkButton>
</div>
<div :class="$style.center">
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
</div>
<div :class="$style.right">
<MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
{{ i18n.ts.update }} ({{ updatedItemsCount }})
</MkButton>
<MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
</div>
</div>
</template>
</template>
</div>
</template>
</MkStickyContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, useCssModule } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import {
emptyStrToEmptyArray,
emptyStrToNull,
emptyStrToUndefined,
GridSortOrderKey,
gridSortOrderKeys,
RequestLogItem,
roleIdsParser,
} from '@/pages/admin/custom-emojis-manager.impl.js';
import MkGrid from '@/components/grid/MkGrid.vue';
import { i18n } from '@/i18n.js';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import { validators } from '@/components/grid/cell-validators.js';
import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkPagingButtons from '@/components/MkPagingButtons.vue';
import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
import { deviceKind } from '@/scripts/device-kind.js';
import { GridSetting } from '@/components/grid/grid.js';
import { selectFile } from '@/scripts/select-file.js';
import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
import { useLoading } from "@/components/hook/useLoading.js";
type GridItem = {
checked: boolean;
id: string;
url: string;
name: string;
host: string;
category: string;
aliases: string;
license: string;
isSensitive: boolean;
localOnly: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
fileId?: string;
updatedAt: string | null;
publicUrl?: string | null;
originalUrl?: string | null;
type: string | null;
}
function setupGrid(): GridSetting {
const $style = useCssModule();
const required = validators.required();
const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
const unique = validators.unique();
return {
row: {
showNumber: true,
selectable: true,
// グリッドの行数をあらかじめ100行確保する
minimumDefinitionCount: 100,
styleRules: [
{
// 初期値から変わっていたら背景色を変更
condition: ({ row }) => JSON.stringify(gridItems.value[row.index]) !== JSON.stringify(originGridItems.value[row.index]),
applyStyle: { className: $style.changedRow },
},
{
// バリデーションに引っかかっていたら背景色を変更
condition: ({ cells }) => cells.some(it => !it.violation.valid),
applyStyle: { className: $style.violationRow },
},
],
// 行のコンテキストメニュー設定
contextMenuFactory: (row, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
icon: 'ti ti-copy',
action: () => copyGridDataToClipboard(gridItems, context),
},
{
type: 'button',
text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRows,
icon: 'ti ti-trash',
action: () => {
for (const rangedRow of context.rangedRows) {
gridItems.value[rangedRow.index].checked = true;
}
},
},
];
},
events: {
delete(rows) {
// 行削除時は元データの行を消さず、削除対象としてマークするのみにする
for (const row of rows) {
gridItems.value[row.index].checked = true;
}
},
},
},
cols: [
{ bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 },
{
bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required],
async customValueEditor(row, col, value, cellElement) {
const file = await selectFile(cellElement);
gridItems.value[row.index].url = file.url;
gridItems.value[row.index].fileId = file.id;
return file.url;
},
},
{
bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
validators: [required, regex, unique],
},
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
{
bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
valueTransformer(row) {
// バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
.map((it) => it.name)
.join(',');
},
async customValueEditor(row) {
// ID直記入は体験的に最悪なのでモーダルを使って入力する
const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
const result = await os.selectRole({
initialRoleIds: current.map(it => it.id),
title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
publicOnly: true,
});
if (result.canceled) {
return current;
}
const transform = result.result.map(it => ({ id: it.id, name: it.name }));
gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
return transform;
},
events: {
paste: roleIdsParser,
delete(cell) {
// デフォルトはundefinedになるが、このプロパティは空配列にしたい
gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
},
},
},
{ bindTo: 'type', type: 'text', editable: false, width: 90 },
{ bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' },
{ bindTo: 'publicUrl', type: 'text', editable: false, width: 180 },
{ bindTo: 'originalUrl', type: 'text', editable: false, width: 180 },
],
cells: {
// セルのコンテキストメニュー設定
contextMenuFactory(col, row, value, context) {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
icon: 'ti ti-copy',
action: () => {
return copyGridDataToClipboard(gridItems, context);
},
},
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
icon: 'ti ti-trash',
action: () => {
removeDataFromGrid(context, (cell) => {
gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined;
});
},
},
{
type: 'button',
text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRanges,
icon: 'ti ti-trash',
action: () => {
for (const rowIdx of [...new Set(context.rangedCells.map(it => it.row.index)).values()]) {
gridItems.value[rowIdx].checked = true;
}
},
},
];
},
},
};
}
const loadingHandler = useLoading();
const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
const allPages = ref<number>(0);
const currentPage = ref<number>(0);
const queryName = ref<string | null>(null);
const queryCategory = ref<string | null>(null);
const queryAliases = ref<string | null>(null);
const queryType = ref<string | null>(null);
const queryLicense = ref<string | null>(null);
const queryUpdatedAtFrom = ref<string | null>(null);
const queryUpdatedAtTo = ref<string | null>(null);
const querySensitive = ref<string | null>(null);
const queryLocalOnly = ref<string | null>(null);
const queryRoles = ref<{ id: string, name: string }[]>([]);
const previousQuery = ref<string | undefined>(undefined);
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
const requestLogs = ref<RequestLogItem[]>([]);
const gridItems = ref<GridItem[]>([]);
const originGridItems = ref<GridItem[]>([]);
const updateButtonDisabled = ref<boolean>(false);
const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
const queryRolesText = computed(() => queryRoles.value.map(it => it.name).join(','));
const updatedItemsCount = computed(() => {
return gridItems.value.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(originGridItems.value[idx])).length;
});
const deleteItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
async function onUpdateButtonClicked() {
const _items = gridItems.value;
const _originItems = originGridItems.value;
if (_items.length !== _originItems.length) {
throw new Error('The number of items has been changed. Please refresh the page and try again.');
}
const updatedItems = _items.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(_originItems[idx]));
if (updatedItems.length === 0) {
await os.alert({
type: 'info',
text: i18n.ts._customEmojisManager._local._list.alertUpdateEmojisNothingDescription,
});
return;
}
const confirm = await os.confirm({
type: 'info',
title: i18n.ts._customEmojisManager._local._list.confirmUpdateEmojisTitle,
text: i18n.tsx._customEmojisManager._local._list.confirmUpdateEmojisDescription({ count: updatedItems.length }),
});
if (confirm.canceled) {
return;
}
const action = () => {
return updatedItems.map(item =>
misskeyApi(
'admin/emoji/update',
{
// eslint-disable-next-line
id: item.id!,
name: item.name,
category: emptyStrToNull(item.category),
aliases: emptyStrToEmptyArray(item.aliases),
license: emptyStrToNull(item.license),
isSensitive: item.isSensitive,
localOnly: item.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
fileId: item.fileId,
})
.then(() => ({ item, success: true, err: undefined }))
.catch(err => ({ item, success: false, err })),
);
};
const result = await os.promiseDialog(Promise.all(action()));
const failedItems = result.filter(it => !it.success);
if (failedItems.length > 0) {
await os.alert({
type: 'error',
title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
});
}
requestLogs.value = result.map(it => ({
failed: !it.success,
url: it.item.url,
name: it.item.name,
error: it.err ? JSON.stringify(it.err) : undefined,
}));
await refreshCustomEmojis();
}
async function onDeleteButtonClicked() {
const _items = gridItems.value;
const _originItems = originGridItems.value;
if (_items.length !== _originItems.length) {
throw new Error('The number of items has been changed. Please refresh the page and try again.');
}
const deleteItems = _items.filter((it) => it.checked);
if (deleteItems.length === 0) {
await os.alert({
type: 'info',
text: i18n.ts._customEmojisManager._local._list.alertDeleteEmojisNothingDescription,
});
return;
}
const confirm = await os.confirm({
type: 'info',
title: i18n.ts._customEmojisManager._local._list.confirmDeleteEmojisTitle,
text: i18n.tsx._customEmojisManager._local._list.confirmDeleteEmojisDescription({ count: deleteItems.length }),
});
if (confirm.canceled) {
return;
}
async function action() {
const deleteIds = deleteItems.map(it => it.id!);
await misskeyApi('admin/emoji/delete-bulk', { ids: deleteIds });
}
await os.promiseDialog(
action(),
);
}
function onGridResetButtonClicked() {
refreshGridItems();
}
async function onQueryRolesEditClicked() {
const result = await os.selectRole({
initialRoleIds: queryRoles.value.map(it => it.id),
title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle,
publicOnly: true,
});
if (result.canceled) {
return;
}
queryRoles.value = result.result;
}
function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
sortOrders.value = _sortOrders;
}
async function onSearchRequest() {
await refreshCustomEmojis();
}
function onQueryResetButtonClicked() {
queryName.value = null;
queryCategory.value = null;
queryAliases.value = null;
queryType.value = null;
queryLicense.value = null;
queryUpdatedAtFrom.value = null;
queryUpdatedAtTo.value = null;
querySensitive.value = null;
queryLocalOnly.value = null;
queryRoles.value = [];
}
async function onPageChanged(pageNumber: number) {
currentPage.value = pageNumber;
await refreshCustomEmojis();
}
function onGridEvent(event: GridEvent) {
switch (event.type) {
case 'cell-validation':
onGridCellValidation(event);
break;
case 'cell-value-change':
onGridCellValueChange(event);
break;
}
}
function onGridCellValidation(event: GridCellValidationEvent) {
updateButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
}
function onGridCellValueChange(event: GridCellValueChangeEvent) {
const { row, column, newValue } = event;
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
gridItems.value[row.index][column.setting.bindTo] = newValue;
}
}
async function refreshCustomEmojis() {
const limit = 100;
const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
name: emptyStrToUndefined(queryName.value),
type: emptyStrToUndefined(queryType.value),
aliases: emptyStrToUndefined(queryAliases.value),
category: emptyStrToUndefined(queryCategory.value),
license: emptyStrToUndefined(queryLicense.value),
isSensitive: querySensitive.value ? Boolean(querySensitive.value).valueOf() : undefined,
localOnly: queryLocalOnly.value ? Boolean(queryLocalOnly.value).valueOf() : undefined,
updatedAtFrom: emptyStrToUndefined(queryUpdatedAtFrom.value),
updatedAtTo: emptyStrToUndefined(queryUpdatedAtTo.value),
roleIds: queryRoles.value.map(it => it.id),
hostType: 'local',
};
if (JSON.stringify(query) !== previousQuery.value) {
currentPage.value = 1;
}
const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
query: query,
limit: limit,
page: currentPage.value,
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}` as any),
}));
customEmojis.value = result.emojis;
allPages.value = result.allPages;
previousQuery.value = JSON.stringify(query);
refreshGridItems();
}
function refreshGridItems() {
gridItems.value = customEmojis.value.map(it => ({
checked: false,
id: it.id,
fileId: undefined,
url: it.publicUrl,
name: it.name,
host: it.host ?? '',
category: it.category ?? '',
aliases: it.aliases.join(','),
license: it.license ?? '',
isSensitive: it.isSensitive,
localOnly: it.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction,
updatedAt: it.updatedAt,
publicUrl: it.publicUrl,
originalUrl: it.originalUrl,
type: it.type,
}));
originGridItems.value = JSON.parse(JSON.stringify(gridItems.value));
}
onMounted(async () => {
await refreshCustomEmojis();
});
</script>
<style module lang="scss">
.violationRow {
background-color: var(--MI_THEME-infoWarnBg);
}
.changedRow {
background-color: var(--MI_THEME-infoBg);
}
.editedRow {
background-color: var(--MI_THEME-infoBg);
}
.row1 {
grid-row: 1 / 2;
}
.row2 {
grid-row: 2 / 3;
}
.row3 {
grid-row: 3 / 4;
}
.row4 {
grid-row: 4 / 5;
}
.col1 {
grid-column: 1 / 2;
}
.col2 {
grid-column: 2 / 3;
}
.col3 {
grid-column: 3 / 4;
}
.searchArea {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 16px;
}
.searchAreaSp {
display: flex;
flex-direction: column;
gap: 8px;
}
.searchButtons {
display: flex;
justify-content: flex-end;
align-items: flex-end;
gap: 8px;
}
.searchButtonsSp {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.gridArea {
padding-top: 8px;
padding-bottom: 8px;
}
.footer {
background-color: var(--MI_THEME-bg);
position: sticky;
left:0;
bottom:0;
z-index: 1;
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
margin-top: calc(var(--MI-margin) * -1);
margin-bottom: calc(var(--MI-margin) * -1);
padding-top: var(--MI-margin);
padding-bottom: var(--MI-margin);
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
& .left {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
& .center {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
& .right {
display: flex;
align-items: center;
justify-content: flex-end;
flex-direction: row;
gap: 8px;
}
}
.divider {
margin: 8px 0;
border-top: solid 0.5px var(--MI_THEME-divider);
}
</style>

View File

@@ -0,0 +1,477 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkFolder>
<template #icon><i class="ti ti-settings"></i></template>
<template #label>{{ i18n.ts._customEmojisManager._local._register.uploadSettingTitle }}</template>
<template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template>
<div class="_gaps">
<MkSelect v-model="selectedFolderId">
<template #label>{{ i18n.ts.uploadFolder }}</template>
<option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
{{ folder.name }}
</option>
</MkSelect>
<MkSwitch v-model="keepOriginalUploading">
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
</MkSwitch>
<MkSwitch v-model="directoryToCategory">
<template #label>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryLabel }}</template>
<template #caption>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryCaption }}</template>
</MkSwitch>
</div>
</MkFolder>
<XRegisterLogsFolder :logs="requestLogs"/>
<div
:class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent.stop="onDrop"
>
<div style="margin-top: 1em">
{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }}
</div>
<ul>
<li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li>
<li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li>
<li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li>
</ul>
</div>
<div v-if="gridItems.length > 0" :class="$style.gridArea">
<MkGrid
:data="gridItems"
:settings="setupGrid()"
@event="onGridEvent"
/>
</div>
<div v-if="gridItems.length > 0" :class="$style.footer">
<MkButton primary :disabled="registerButtonDisabled" @click="onRegistryClicked">
{{ i18n.ts.registration }}
</MkButton>
<MkButton @click="onClearClicked">
{{ i18n.ts.clear }}
</MkButton>
</div>
</div>
</template>
<script setup lang="ts">
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as Misskey from 'misskey-js';
import { onMounted, ref, useCssModule } from 'vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import {
emptyStrToEmptyArray,
emptyStrToNull,
RequestLogItem,
roleIdsParser,
} from '@/pages/admin/custom-emojis-manager.impl.js';
import MkGrid from '@/components/grid/MkGrid.vue';
import { i18n } from '@/i18n.js';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { defaultStore } from '@/store.js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { validators } from '@/components/grid/cell-validators.js';
import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js';
import { uploadFile } from '@/scripts/upload.js';
import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js';
import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
import { GridSetting } from '@/components/grid/grid.js';
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
import { GridRow } from '@/components/grid/row.js';
const MAXIMUM_EMOJI_REGISTER_COUNT = 100;
type FolderItem = {
id?: string;
name: string;
};
type GridItem = {
fileId: string;
url: string;
name: string;
host: string;
category: string;
aliases: string;
license: string;
isSensitive: boolean;
localOnly: boolean;
roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
type: string | null;
}
function setupGrid(): GridSetting {
const $style = useCssModule();
const required = validators.required();
const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
const unique = validators.unique();
function removeRows(rows: GridRow[]) {
const idxes = [...new Set(rows.map(it => it.index))];
gridItems.value = gridItems.value.filter((_, i) => !idxes.includes(i));
}
return {
row: {
showNumber: true,
selectable: true,
minimumDefinitionCount: 100,
styleRules: [
{
// 1つでもバリデーションエラーがあれば行全体をエラー表示する
condition: ({ cells }) => cells.some(it => !it.violation.valid),
applyStyle: { className: $style.violationRow },
},
],
// 行のコンテキストメニュー設定
contextMenuFactory: (row, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
icon: 'ti ti-copy',
action: () => copyGridDataToClipboard(gridItems, context),
},
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRows,
icon: 'ti ti-trash',
action: () => removeRows(context.rangedRows),
},
];
},
events: {
delete(rows) {
removeRows(rows);
},
},
},
cols: [
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] },
{
bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
validators: [required, regex, unique],
},
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
{
bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
valueTransformer: (row) => {
// バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
.map((it) => it.name)
.join(',');
},
customValueEditor: async (row) => {
// ID直記入は体験的に最悪なのでモーダルを使って入力する
const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
const result = await os.selectRole({
initialRoleIds: current.map(it => it.id),
title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
publicOnly: true,
});
if (result.canceled) {
return current;
}
const transform = result.result.map(it => ({ id: it.id, name: it.name }));
gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
return transform;
},
events: {
paste: roleIdsParser,
delete(cell) {
// デフォルトはundefinedになるが、このプロパティは空配列にしたい
gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
},
},
},
{ bindTo: 'type', type: 'text', editable: false, width: 90 },
],
cells: {
// セルのコンテキストメニュー設定
contextMenuFactory: (col, row, value, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
icon: 'ti ti-copy',
action: () => copyGridDataToClipboard(gridItems, context),
},
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
icon: 'ti ti-trash',
action: () => removeRows(context.rangedCells.map(it => it.row)),
},
];
},
},
};
}
const uploadFolders = ref<FolderItem[]>([]);
const gridItems = ref<GridItem[]>([]);
const selectedFolderId = ref(defaultStore.state.uploadFolder);
const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
const directoryToCategory = ref<boolean>(false);
const registerButtonDisabled = ref<boolean>(false);
const requestLogs = ref<RequestLogItem[]>([]);
const isDragOver = ref<boolean>(false);
async function onRegistryClicked() {
const dialogSelection = await os.confirm({
type: 'info',
title: i18n.ts._customEmojisManager._local._register.confirmRegisterEmojisTitle,
text: i18n.tsx._customEmojisManager._local._register.confirmRegisterEmojisDescription({ count: MAXIMUM_EMOJI_REGISTER_COUNT }),
});
if (dialogSelection.canceled) {
return;
}
const items = gridItems.value;
const upload = () => {
return items.slice(0, MAXIMUM_EMOJI_REGISTER_COUNT)
.map(item =>
misskeyApi(
'admin/emoji/add', {
name: item.name,
category: emptyStrToNull(item.category),
aliases: emptyStrToEmptyArray(item.aliases),
license: emptyStrToNull(item.license),
isSensitive: item.isSensitive,
localOnly: item.localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
fileId: item.fileId!,
})
.then(() => ({ item, success: true, err: undefined }))
.catch(err => ({ item, success: false, err })),
);
};
const result = await os.promiseDialog(Promise.all(upload()));
const failedItems = result.filter(it => !it.success);
if (failedItems.length > 0) {
await os.alert({
type: 'error',
title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
});
}
requestLogs.value = result.map(it => ({
failed: !it.success,
url: it.item.url,
name: it.item.name,
error: it.err ? JSON.stringify(it.err) : undefined,
}));
// 登録に成功したものは一覧から除く
const successItems = result.filter(it => it.success).map(it => it.item);
gridItems.value = gridItems.value.filter(it => !successItems.includes(it));
}
async function onClearClicked() {
const result = await os.confirm({
type: 'warning',
title: i18n.ts._customEmojisManager._local._register.confirmClearEmojisTitle,
text: i18n.ts._customEmojisManager._local._register.confirmClearEmojisDescription,
});
if (!result.canceled) {
gridItems.value = [];
}
}
async function onDrop(ev: DragEvent) {
isDragOver.value = false;
const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
const confirm = await os.confirm({
type: 'info',
title: i18n.ts._customEmojisManager._local._register.confirmUploadEmojisTitle,
text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }),
});
if (confirm.canceled) {
return;
}
const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>();
try {
uploadedItems.push(
...await os.promiseDialog(
Promise.all(
droppedFiles.map(async (it) => ({
droppedFile: it,
driveFile: await uploadFile(
it.file,
selectedFolderId.value,
it.file.name.replace(/\.[^.]+$/, ''),
keepOriginalUploading.value,
),
}),
),
),
() => {
},
() => {
},
),
);
} catch (err) {
// ダイアログは共通部品側で出ているはずなので何もしない
return;
}
const items = uploadedItems.map(({ droppedFile, driveFile }) => {
const item = fromDriveFile(driveFile);
if (directoryToCategory.value) {
item.category = droppedFile.path
.replace(/^\//, '')
.replace(/\/[^/]+$/, '')
.replace(droppedFile.file.name, '');
}
return item;
});
gridItems.value.push(...items);
}
async function onFileSelectClicked() {
const driveFiles = await chooseFileFromPc(
true,
{
uploadFolder: selectedFolderId.value,
keepOriginal: keepOriginalUploading.value,
// 拡張子は消す
nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
},
);
gridItems.value.push(...driveFiles.map(fromDriveFile));
}
async function onDriveSelectClicked() {
const driveFiles = await chooseFileFromDrive(true);
gridItems.value.push(...driveFiles.map(fromDriveFile));
}
function onGridEvent(event: GridEvent) {
switch (event.type) {
case 'cell-validation':
onGridCellValidation(event);
break;
case 'cell-value-change':
onGridCellValueChange(event);
break;
}
}
function onGridCellValidation(event: GridCellValidationEvent) {
registerButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
}
function onGridCellValueChange(event: GridCellValueChangeEvent) {
const { row, column, newValue } = event;
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
gridItems.value[row.index][column.setting.bindTo] = newValue;
}
}
function fromDriveFile(it: Misskey.entities.DriveFile): GridItem {
return {
fileId: it.id,
url: it.url,
name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, ''),
host: '',
category: '',
aliases: '',
license: '',
isSensitive: it.isSensitive,
localOnly: false,
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
type: it.type,
};
}
async function refreshUploadFolders() {
const result = await misskeyApi('drive/folders', {});
uploadFolders.value = Array.of<FolderItem>({ name: '-' }, ...result);
}
onMounted(async () => {
await refreshUploadFolders();
});
</script>
<style module lang="scss">
.violationRow {
background-color: var(--MI_THEME-infoWarnBg);
}
.uploadBox {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: auto;
border: 0.5px dotted var(--MI_THEME-accentedBg);
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-accentedBg);
box-sizing: border-box;
&.dragOver {
cursor: copy;
}
}
.gridArea {
padding-top: 8px;
padding-bottom: 8px;
}
.footer {
background-color: var(--MI_THEME-bg);
position: sticky;
left:0;
bottom:0;
z-index: 1;
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
margin-top: calc(var(--MI-margin) * -1);
margin-bottom: calc(var(--MI-margin) * -1);
padding-top: var(--MI-margin);
padding-bottom: var(--MI-margin);
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,36 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps" :class="$style.root">
<MkTab v-model="modeTab" style="margin-bottom: var(--margin);">
<option value="list">{{ i18n.ts._customEmojisManager._local.tabTitleList }}</option>
<option value="register">{{ i18n.ts._customEmojisManager._local.tabTitleRegister }}</option>
</MkTab>
<div>
<XListComponent v-if="modeTab === 'list'"/>
<XRegisterComponent v-else/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { i18n } from '@/i18n.js';
import MkTab from '@/components/MkTab.vue';
import XListComponent from '@/pages/admin/custom-emojis-manager.local.list.vue';
import XRegisterComponent from '@/pages/admin/custom-emojis-manager.local.register.vue';
type PageMode = 'list' | 'register';
const modeTab = ref<PageMode>('list');
</script>
<style module lang="scss">
.root {
padding: var(--MI-margin);
}
</style>

View File

@@ -0,0 +1,102 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder>
<template #icon><i class="ti ti-notes"></i></template>
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
<template #caption>
{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
</template>
<div>
<div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;">
<MkSwitch v-model="showingSuccessLogs">
<template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template>
</MkSwitch>
<div>
<div v-if="filteredLogs.length > 0">
<MkGrid
:data="filteredLogs"
:settings="setupGrid()"
/>
</div>
<div v-else>
{{ i18n.ts._customEmojisManager._logs.failureLogNothing }}
</div>
</div>
</div>
<div v-else>
{{ i18n.ts._customEmojisManager._logs.logNothing }}
</div>
</div>
</MkFolder>
</template>
<script setup lang="ts">
import { computed, ref, toRefs } from 'vue';
import { i18n } from '@/i18n.js';
import { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
import MkGrid from '@/components/grid/MkGrid.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { GridSetting } from '@/components/grid/grid.js';
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
import MkFolder from '@/components/MkFolder.vue';
function setupGrid(): GridSetting {
return {
row: {
showNumber: false,
selectable: false,
contextMenuFactory: (row, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
icon: 'ti ti-copy',
action: () => copyGridDataToClipboard(logs, context),
},
];
},
},
cols: [
{ bindTo: 'failed', title: 'failed', type: 'boolean', editable: false, width: 50 },
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
{ bindTo: 'name', title: 'name', type: 'text', editable: false, width: 140 },
{ bindTo: 'error', title: 'log', type: 'text', editable: false, width: 'auto' },
],
cells: {
contextMenuFactory: (col, row, value, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
icon: 'ti ti-copy',
action: () => copyGridDataToClipboard(logs, context),
},
];
},
},
};
}
const props = defineProps<{
logs: RequestLogItem[];
}>();
const { logs } = toRefs(props);
const showingSuccessLogs = ref<boolean>(false);
const filteredLogs = computed(() => {
const forceShowing = showingSuccessLogs.value;
return logs.value.filter((log) => forceShowing || log.failed);
});
</script>
<style module lang="scss">
</style>

View File

@@ -0,0 +1,441 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #default>
<div :class="$style.root" class="_gaps">
<MkFolder>
<template #icon><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
<template #caption>
{{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
</template>
<div class="_gaps">
<div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
<MkInput
v-model="queryName"
type="search"
autocapitalize="off"
:class="[$style.col1, $style.row1]"
@enter="onSearchRequest"
>
<template #label>name</template>
</MkInput>
<MkInput
v-model="queryHost"
type="search"
autocapitalize="off"
:class="[$style.col2, $style.row1]"
@enter="onSearchRequest"
>
<template #label>host</template>
</MkInput>
<MkInput
v-model="queryUri"
type="search"
autocapitalize="off"
:class="[$style.col1, $style.row2]"
@enter="onSearchRequest"
>
<template #label>uri</template>
</MkInput>
<MkInput
v-model="queryPublicUrl"
type="search"
autocapitalize="off"
:class="[$style.col2, $style.row2]"
@enter="onSearchRequest"
>
<template #label>publicUrl</template>
</MkInput>
</div>
<MkFolder :spacerMax="8" :spacerMin="8">
<template #icon><i class="ti ti-arrows-sort"></i></template>
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
<MkSortOrderEditor
:baseOrderKeyNames="gridSortOrderKeys"
:currentOrders="sortOrders"
@update="onSortOrderUpdate"
/>
</MkFolder>
<div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
<MkButton primary @click="onSearchRequest">
{{ i18n.ts.search }}
</MkButton>
<MkButton @click="onQueryResetButtonClicked">
{{ i18n.ts.reset }}
</MkButton>
</div>
</div>
</MkFolder>
<XRegisterLogsFolder :logs="requestLogs"/>
<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
<template v-else>
<div v-if="gridItems.length === 0" style="text-align: center">
{{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
</div>
<template v-else>
<div v-if="gridItems.length > 0" :class="$style.gridArea">
<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
</div>
<div :class="$style.footer">
<div>
<!-- レイアウト調整用のスペース -->
</div>
<div :class="$style.center">
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
</div>
<div :class="$style.right">
<MkButton primary @click="onImportClicked">
{{
i18n.ts._customEmojisManager._remote.importEmojisButton
}} ({{ checkedItemsCount }})
</MkButton>
</div>
</div>
</template>
</template>
</div>
</template>
</MkStickyContainer>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, useCssModule } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkGrid from '@/components/grid/MkGrid.vue';
import {
emptyStrToUndefined,
GridSortOrderKey,
gridSortOrderKeys,
RequestLogItem,
} from '@/pages/admin/custom-emojis-manager.impl.js';
import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import MkFolder from '@/components/MkFolder.vue';
import XRegisterLogsFolder from '@/pages/admin/custom-emojis-manager.logs-folder.vue';
import * as os from '@/os.js';
import { GridSetting } from '@/components/grid/grid.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkPagingButtons from '@/components/MkPagingButtons.vue';
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
import { useLoading } from "@/components/hook/useLoading.js";
type GridItem = {
checked: boolean;
id: string;
url: string;
name: string;
host: string;
}
function setupGrid(): GridSetting {
const $style = useCssModule();
return {
row: {
// グリッドの行数をあらかじめ100行確保する
minimumDefinitionCount: 100,
styleRules: [
{
// チェックされたら背景色を変える
condition: ({ row }) => gridItems.value[row.index].checked,
applyStyle: { className: $style.changedRow },
},
],
contextMenuFactory: (row, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._remote.importSelectionRows,
icon: 'ti ti-download',
action: async () => {
const targets = context.rangedRows.map(it => gridItems.value[it.index]);
await importEmojis(targets);
},
},
];
},
},
cols: [
{ bindTo: 'checked', icon: 'ti-download', type: 'boolean', editable: true, width: 34 },
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
{ bindTo: 'name', title: 'name', type: 'text', editable: false, width: 'auto' },
{ bindTo: 'host', title: 'host', type: 'text', editable: false, width: 'auto' },
{ bindTo: 'uri', title: 'uri', type: 'text', editable: false, width: 'auto' },
{ bindTo: 'publicUrl', title: 'publicUrl', type: 'text', editable: false, width: 'auto' },
],
cells: {
contextMenuFactory: (col, row, value, context) => {
return [
{
type: 'button',
text: i18n.ts._customEmojisManager._remote.importSelectionRangesRows,
icon: 'ti ti-download',
action: async () => {
const targets = context.rangedCells.map(it => gridItems.value[it.row.index]);
await importEmojis(targets);
},
},
];
},
},
};
}
const loadingHandler = useLoading();
const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
const allPages = ref<number>(0);
const currentPage = ref<number>(0);
const queryName = ref<string | null>(null);
const queryHost = ref<string | null>(null);
const queryUri = ref<string | null>(null);
const queryPublicUrl = ref<string | null>(null);
const previousQuery = ref<string | undefined>(undefined);
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
const requestLogs = ref<RequestLogItem[]>([]);
const gridItems = ref<GridItem[]>([]);
const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
const checkedItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
sortOrders.value = _sortOrders;
}
async function onSearchRequest() {
await refreshCustomEmojis();
}
function onQueryResetButtonClicked() {
queryName.value = null;
queryHost.value = null;
queryUri.value = null;
queryPublicUrl.value = null;
}
async function onPageChanged(pageNumber: number) {
currentPage.value = pageNumber;
await refreshCustomEmojis();
}
async function onImportClicked() {
const targets = gridItems.value.filter(it => it.checked);
await importEmojis(targets);
}
function onGridEvent(event: GridEvent) {
switch (event.type) {
case 'cell-value-change':
onGridCellValueChange(event);
break;
}
}
function onGridCellValueChange(event: GridCellValueChangeEvent) {
const { row, column, newValue } = event;
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
gridItems.value[row.index][column.setting.bindTo] = newValue;
}
}
async function importEmojis(targets: GridItem[]) {
const confirm = await os.confirm({
type: 'info',
title: i18n.ts._customEmojisManager._remote.confirmImportEmojisTitle,
text: i18n.tsx._customEmojisManager._remote.confirmImportEmojisDescription({ count: targets.length }),
});
if (confirm.canceled) {
return;
}
const result = await os.promiseDialog(
Promise.all(
targets.map(item =>
misskeyApi(
'admin/emoji/copy',
{
emojiId: item.id!,
})
.then(() => ({ item, success: true, err: undefined }))
.catch(err => ({ item, success: false, err })),
),
),
);
const failedItems = result.filter(it => !it.success);
if (failedItems.length > 0) {
await os.alert({
type: 'error',
title: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedTitle,
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
});
}
requestLogs.value = result.map(it => ({
failed: !it.success,
url: it.item.url,
name: it.item.name,
error: it.err ? JSON.stringify(it.err) : undefined,
}));
await refreshCustomEmojis();
}
async function refreshCustomEmojis() {
const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
name: emptyStrToUndefined(queryName.value),
host: emptyStrToUndefined(queryHost.value),
uri: emptyStrToUndefined(queryUri.value),
publicUrl: emptyStrToUndefined(queryPublicUrl.value),
hostType: 'remote',
};
if (JSON.stringify(query) !== previousQuery.value) {
currentPage.value = 1;
}
const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
limit: 100,
query: query,
page: currentPage.value,
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`) as never[],
}));
customEmojis.value = result.emojis;
allPages.value = result.allPages;
previousQuery.value = JSON.stringify(query);
gridItems.value = customEmojis.value.map(it => ({
checked: false,
id: it.id,
url: it.publicUrl,
name: it.name,
host: it.host!,
}));
}
onMounted(async () => {
await refreshCustomEmojis();
});
</script>
<style module lang="scss">
.row1 {
grid-row: 1 / 2;
}
.row2 {
grid-row: 2 / 3;
}
.col1 {
grid-column: 1 / 2;
}
.col2 {
grid-column: 2 / 3;
}
.root {
padding: 16px;
}
.changedRow {
background-color: var(--MI_THEME-infoBg);
}
.searchArea {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.searchButtons {
display: flex;
justify-content: flex-end;
align-items: flex-end;
gap: 8px;
}
.searchButtonsSp {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
}
.searchAreaSp {
display: flex;
flex-direction: column;
gap: 8px;
}
.gridArea {
padding-top: 8px;
padding-bottom: 8px;
}
.pages {
display: flex;
justify-content: center;
align-items: center;
button {
background-color: var(--MI_THEME-buttonBg);
border-radius: 9999px;
border: none;
margin: 0 4px;
padding: 8px;
}
}
.footer {
background-color: var(--MI_THEME-bg);
position: sticky;
left:0;
bottom:0;
z-index: 1;
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
margin-top: calc(var(--MI-margin) * -1);
margin-bottom: calc(var(--MI-margin) * -1);
padding-top: var(--MI-margin);
padding-bottom: var(--MI-margin);
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
& .center {
display: flex;
justify-content: center;
align-items: center;
}
& .right {
display: flex;
justify-content: flex-end;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,160 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { delay, http, HttpResponse } from 'msw';
import { StoryObj } from '@storybook/vue3';
import { entities } from 'misskey-js';
import { commonHandlers } from '../../../.storybook/mocks.js';
import { emoji } from '../../../.storybook/fakes.js';
import { fakeId } from '../../../.storybook/fake-utils.js';
import custom_emojis_manager2 from './custom-emojis-manager2.vue';
function createRender(params: {
emojis: entities.EmojiDetailedAdmin[];
}) {
const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis];
const storedDriveFiles: entities.DriveFile[] = [];
return {
render(args) {
return {
components: {
custom_emojis_manager2,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<custom_emojis_manager2 v-bind="props" />',
};
},
args: {
},
parameters: {
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/v2/admin/emoji/list', async ({ request }) => {
await delay(100);
const bodyStream = request.body as ReadableStream;
const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest;
const emojis = storedEmojis;
const limit = body.limit ?? 10;
const page = body.page ?? 1;
const result = emojis.slice((page - 1) * limit, page * limit);
return HttpResponse.json({
emojis: result,
count: Math.min(emojis.length, limit),
allCount: emojis.length,
allPages: Math.ceil(emojis.length / limit),
});
}),
http.post('/api/drive/folders', () => {
return HttpResponse.json([]);
}),
http.post('/api/drive/files', () => {
return HttpResponse.json(storedDriveFiles);
}),
http.post('/api/drive/files/create', async ({ request }) => {
const data = await request.formData();
const file = data.get('file');
if (!file || !(file instanceof File)) {
return HttpResponse.json({ error: 'file is required' }, {
status: 400,
});
}
// FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.readAsDataURL(new Blob([file], { type: 'image/webp' }));
});
const driveFile: entities.DriveFile = {
id: fakeId(file.name),
createdAt: new Date().toISOString(),
name: file.name,
type: file.type,
md5: '',
size: file.size,
isSensitive: false,
blurhash: null,
properties: {},
url: base64,
thumbnailUrl: null,
comment: null,
folderId: null,
folder: null,
userId: null,
user: null,
};
storedDriveFiles.push(driveFile);
return HttpResponse.json(driveFile);
}),
http.post('api/admin/emoji/add', async ({ request }) => {
await delay(100);
const bodyStream = request.body as ReadableStream;
const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest;
const fileId = body.fileId;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const file = storedDriveFiles.find(f => f.id === fileId)!;
const em = emoji({
id: fakeId(file.name),
name: body.name,
publicUrl: file.url,
originalUrl: file.url,
type: file.type,
aliases: body.aliases,
category: body.category ?? undefined,
license: body.license ?? undefined,
localOnly: body.localOnly,
isSensitive: body.isSensitive,
});
storedEmojis.push(em);
return HttpResponse.json(null);
}),
],
},
},
} satisfies StoryObj<typeof custom_emojis_manager2>;
}
export const Default = createRender({
emojis: [],
});
export const List10 = createRender({
emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
});
export const List100 = createRender({
emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
});
export const List1000 = createRender({
emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
});

View File

@@ -0,0 +1,44 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<!-- コンテナが入れ子になるのでz-indexが被らないよう大きめの数値を設定する-->
<MkStickyContainer :headerZIndex="2000">
<template #header>
<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/>
</template>
<XGridLocalComponent v-if="headerTab === 'local'"/>
<XGridRemoteComponent v-else/>
</MkStickyContainer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue';
import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue';
import MkPageHeader from '@/components/global/MkPageHeader.vue';
import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
type PageMode = 'local' | 'remote';
const headerTab = ref<PageMode>('local');
const headerTabs = computed(() => [{
key: 'local',
title: i18n.ts.local,
}, {
key: 'remote',
title: i18n.ts.remote,
}]);
definePageMetadata(computed(() => ({
title: i18n.ts.customEmojis,
icon: 'ti ti-icons',
})));
</script>

View File

@@ -121,6 +121,11 @@ const menuDef = computed(() => [{
text: i18n.ts.customEmojis,
to: '/admin/emojis',
active: currentPage.value?.route.name === 'emojis',
}, {
icon: 'ti ti-icons',
text: i18n.ts.customEmojis + '(beta)',
to: '/admin/emojis2',
active: currentPage.value?.route.name === 'emojis2',
}, {
icon: 'ti ti-sparkles',
text: i18n.ts.avatarDecorations,