* 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>
477 lines
14 KiB
Vue
477 lines
14 KiB
Vue
<!--
|
|
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>
|