wip
This commit is contained in:
parent
8d1a5734cd
commit
aacee3c970
|
@ -16,7 +16,6 @@ import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Serialized } from '@/types.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
|
@ -30,10 +29,8 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
|
@ -102,6 +99,64 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
return emoji;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addBulk(
|
||||
params: {
|
||||
driveFile: MiDriveFile;
|
||||
name: string;
|
||||
category: string | null;
|
||||
aliases: string[];
|
||||
host: string | null;
|
||||
license: string | null;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][];
|
||||
}[],
|
||||
moderator?: MiUser,
|
||||
): Promise<MiEmoji[]> {
|
||||
const emojis = await this.emojisRepository
|
||||
.insert(
|
||||
params.map(it => ({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
name: it.name,
|
||||
category: it.category,
|
||||
host: it.host,
|
||||
aliases: it.aliases,
|
||||
originalUrl: it.driveFile.url,
|
||||
publicUrl: it.driveFile.webpublicUrl ?? it.driveFile.url,
|
||||
type: it.driveFile.webpublicType ?? it.driveFile.type,
|
||||
license: it.license,
|
||||
isSensitive: it.isSensitive,
|
||||
localOnly: it.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
})),
|
||||
)
|
||||
.then(x => this.emojisRepository.createQueryBuilder('emoji').whereInIds(x.identifiers).getMany());
|
||||
|
||||
const localEmojis = emojis.filter(it => it.host == null);
|
||||
if (localEmojis.length > 0) {
|
||||
this.localEmojisCache.refresh();
|
||||
|
||||
this.emojiEntityService.packDetailedMany(localEmojis).then(it => {
|
||||
for (const emoji of it) {
|
||||
this.globalEventService.publishBroadcastStream('emojiAdded', { emoji });
|
||||
}
|
||||
});
|
||||
|
||||
if (moderator) {
|
||||
for (const emoji of localEmojis) {
|
||||
this.moderationLogService.log(moderator, 'addCustomEmoji', {
|
||||
emojiId: emoji.id,
|
||||
emoji: emoji,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emojis;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiEmoji['id'], data: {
|
||||
driveFile?: MiDriveFile;
|
||||
|
@ -159,6 +214,103 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateBulk(
|
||||
params: {
|
||||
id: MiEmoji['id'];
|
||||
driveFile?: MiDriveFile;
|
||||
name?: string;
|
||||
category?: string | null;
|
||||
aliases?: string[];
|
||||
license?: string | null;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
|
||||
}[],
|
||||
moderator?: MiUser,
|
||||
): Promise<void> {
|
||||
const ids = params.map(it => it.id);
|
||||
|
||||
// IDに対応するものと、新しく設定しようとしている名前と同じ名前を持つレコードをそれぞれ取得する
|
||||
const [storedEmojis, sameNameEmojis] = await Promise.all([
|
||||
this.emojisRepository.createQueryBuilder('emoji')
|
||||
.whereInIds(ids)
|
||||
.getMany()
|
||||
.then(emojis => new Map(emojis.map(it => [it.id, it]))),
|
||||
this.emojisRepository.createQueryBuilder('emoji')
|
||||
.where('emoji.name IN (:...names) AND emoji.host IS NULL', { names: params.map(it => it.name) })
|
||||
.getMany(),
|
||||
]);
|
||||
|
||||
// 新しく設定しようとしている名前と同じ名前を持つ別レコードがある場合、重複とみなしてエラーとする
|
||||
const alreadyExists = Array.of<string>();
|
||||
for (const sameNameEmoji of sameNameEmojis) {
|
||||
const emoji = storedEmojis.get(sameNameEmoji.id);
|
||||
if (emoji != null && emoji.id !== sameNameEmoji.id) {
|
||||
alreadyExists.push(sameNameEmoji.name);
|
||||
}
|
||||
}
|
||||
if (alreadyExists.length > 0) {
|
||||
throw new Error(`name already exists: ${alreadyExists.join(', ')}`);
|
||||
}
|
||||
|
||||
for (const emoji of params) {
|
||||
await this.emojisRepository.update(emoji.id, {
|
||||
updatedAt: new Date(),
|
||||
name: emoji.name,
|
||||
category: emoji.category,
|
||||
aliases: emoji.aliases,
|
||||
license: emoji.license,
|
||||
isSensitive: emoji.isSensitive,
|
||||
localOnly: emoji.localOnly,
|
||||
originalUrl: emoji.driveFile != null ? emoji.driveFile.url : undefined,
|
||||
publicUrl: emoji.driveFile != null ? (emoji.driveFile.webpublicUrl ?? emoji.driveFile.url) : undefined,
|
||||
type: emoji.driveFile != null ? (emoji.driveFile.webpublicType ?? emoji.driveFile.type) : undefined,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
this.localEmojisCache.refresh();
|
||||
|
||||
// 名前が変わっていないものはそのまま更新としてイベント発信
|
||||
const updateEmojis = params.filter(it => storedEmojis.get(it.id)?.name === it.name);
|
||||
if (updateEmojis.length > 0) {
|
||||
const packedList = await this.emojiEntityService.packDetailedMany(updateEmojis);
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: packedList,
|
||||
});
|
||||
}
|
||||
|
||||
// 名前が変わったものは削除・追加としてイベント発信
|
||||
const nameChangeEmojis = params.filter(it => storedEmojis.get(it.id)?.name !== it.name);
|
||||
if (nameChangeEmojis.length > 0) {
|
||||
const packedList = await this.emojiEntityService.packDetailedMany(nameChangeEmojis);
|
||||
this.globalEventService.publishBroadcastStream('emojiDeleted', {
|
||||
emojis: packedList,
|
||||
});
|
||||
|
||||
for (const packed of packedList) {
|
||||
this.globalEventService.publishBroadcastStream('emojiAdded', {
|
||||
emoji: packed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (moderator) {
|
||||
const updatedEmojis = await this.emojisRepository.createQueryBuilder('emoji')
|
||||
.whereInIds(storedEmojis.keys())
|
||||
.getMany()
|
||||
.then(it => new Map(it.map(it => [it.id, it])));
|
||||
for (const emoji of storedEmojis.values()) {
|
||||
this.moderationLogService.log(moderator, 'updateCustomEmoji', {
|
||||
emojiId: emoji.id,
|
||||
before: emoji,
|
||||
after: updatedEmojis.get(emoji.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async addAliasesBulk(ids: MiEmoji['id'][], aliases: string[]) {
|
||||
const emojis = await this.emojisRepository.findBy({
|
||||
|
|
|
@ -67,7 +67,7 @@ export class EmojiEntityService {
|
|||
@bindThis
|
||||
public packDetailedMany(
|
||||
emojis: any[],
|
||||
) {
|
||||
): Promise<Packed<'EmojiDetailed'>[]> {
|
||||
return Promise.all(emojis.map(x => this.packDetailed(x)));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,15 +44,21 @@ export const paramDef = {
|
|||
nullable: true,
|
||||
description: 'Use `null` to reset the category.',
|
||||
},
|
||||
aliases: { type: 'array', items: {
|
||||
aliases: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
},
|
||||
license: { type: 'string', nullable: true },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['name', 'fileId'],
|
||||
} as const;
|
||||
|
@ -64,9 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private customEmojiService: CustomEmojiService,
|
||||
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
|
|
|
@ -21,42 +21,9 @@ export const meta = {
|
|||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
aliases: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
host: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
description: 'The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
ref: 'EmojiDetailed',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -88,15 +55,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
let emojis: MiEmoji[];
|
||||
|
||||
if (ps.query) {
|
||||
//q.andWhere('emoji.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` });
|
||||
//const emojis = await q.limit(ps.limit).getMany();
|
||||
|
||||
emojis = await q.getMany();
|
||||
const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g);
|
||||
|
||||
if (queryarry) {
|
||||
const queries = ps.query.match(/:([a-z0-9_]*):/g);
|
||||
if (queries) {
|
||||
emojis = emojis.filter(emoji =>
|
||||
queryarry.includes(`:${emoji.name}:`),
|
||||
queries.includes(`:${emoji.name}:`),
|
||||
);
|
||||
} else {
|
||||
emojis = emojis.filter(emoji =>
|
||||
|
|
|
@ -25,19 +25,10 @@ export const meta = {
|
|||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'EmojiSimple',
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'EmojiDetailed',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -46,10 +37,6 @@ export const meta = {
|
|||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
detail: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -74,9 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
|
||||
return {
|
||||
emojis: ps.detail
|
||||
? await this.emojiEntityService.packDetailedMany(emojis)
|
||||
: await this.emojiEntityService.packSimpleMany(emojis),
|
||||
emojis: await this.emojiEntityService.packSimpleMany(emojis),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-else-if="cellType === 'boolean'">
|
||||
<span v-if="cell.value" class="ti ti-check"/>
|
||||
<span v-if="cell.value === true" class="ti ti-check"/>
|
||||
<span v-else class="ti ti-x"/>
|
||||
</div>
|
||||
<div v-else-if="cellType === 'image'">
|
||||
|
@ -49,18 +49,17 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, toRefs, watch } from 'vue';
|
||||
import {
|
||||
CellAddress,
|
||||
CellValue,
|
||||
equalCellAddress,
|
||||
getCellAddress,
|
||||
GridCell,
|
||||
GridEventEmitter, Size,
|
||||
GridEventEmitter,
|
||||
Size,
|
||||
} from '@/components/grid/types.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||
(ev: 'operation:selectionMove', sender: GridCell, next: CellAddress): void;
|
||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||
}>();
|
||||
|
|
|
@ -2,9 +2,7 @@
|
|||
<tr :class="$style.row">
|
||||
<MkNumberCell
|
||||
:content="(row.index + 1).toString()"
|
||||
:selectable="true"
|
||||
:row="row"
|
||||
@operation:selectionRow="(sender) => emit('operation:selectionRow', sender)"
|
||||
/>
|
||||
<MkDataCell
|
||||
v-for="cell in cells"
|
||||
|
@ -13,7 +11,6 @@
|
|||
:bus="bus"
|
||||
@operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
|
||||
@operation:endEdit="(sender) => emit('operation:endEdit', sender)"
|
||||
@operation:selectionMove="(sender, next) => emit('operation:selectionMove', sender, next)"
|
||||
@change:value="(sender, newValue) => emit('change:value', sender, newValue)"
|
||||
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
||||
/>
|
||||
|
@ -21,16 +18,14 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRefs } from 'vue';
|
||||
import { CellAddress, CellValue, GridCell, GridEventEmitter, GridRow, Size } from '@/components/grid/types.js';
|
||||
import { toRefs } from 'vue';
|
||||
import { CellValue, GridCell, GridEventEmitter, GridRow, Size } from '@/components/grid/types.js';
|
||||
import MkDataCell from '@/components/grid/MkDataCell.vue';
|
||||
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||
(ev: 'operation:selectionRow', sender: GridRow): void;
|
||||
(ev: 'operation:selectionMove', sender: GridCell, next: CellAddress): void;
|
||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||
}>();
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<table
|
||||
ref="rootEl"
|
||||
tabindex="-1"
|
||||
:class="$style.grid"
|
||||
@mousedown="onMouseDown"
|
||||
@keydown="onKeyDown"
|
||||
|
@ -11,7 +13,6 @@
|
|||
@operation:beginWidthChange="onHeaderCellWidthBeginChange"
|
||||
@operation:endWidthChange="onHeaderCellWidthEndChange"
|
||||
@operation:widthLargest="onHeaderCellWidthLargest"
|
||||
@operation:selectionColumn="onSelectionColumn"
|
||||
@change:width="onHeaderCellChangeWidth"
|
||||
@change:contentSize="onHeaderCellChangeContentSize"
|
||||
/>
|
||||
|
@ -25,8 +26,6 @@
|
|||
:bus="bus"
|
||||
@operation:beginEdit="onCellEditBegin"
|
||||
@operation:endEdit="onCellEditEnd"
|
||||
@operation:selectionMove="onSelectionMove"
|
||||
@operation:selectionRow="onSelectionRow"
|
||||
@change:value="onChangeCellValue"
|
||||
@change:contentSize="onChangeCellContentSize"
|
||||
/>
|
||||
|
@ -40,7 +39,7 @@ import {
|
|||
calcCellWidth,
|
||||
CELL_ADDRESS_NONE,
|
||||
CellAddress,
|
||||
CellValue,
|
||||
CellValue, CellValueChangedEvent,
|
||||
ColumnSetting,
|
||||
DataSource,
|
||||
equalCellAddress,
|
||||
|
@ -54,16 +53,22 @@ import {
|
|||
} from '@/components/grid/types.js';
|
||||
import MkDataRow from '@/components/grid/MkDataRow.vue';
|
||||
import MkHeaderRow from '@/components/grid/MkHeaderRow.vue';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const props = defineProps<{
|
||||
columnSettings: ColumnSetting[],
|
||||
data: DataSource[]
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'change:cellValue', event: CellValueChangedEvent): void;
|
||||
}>();
|
||||
|
||||
const bus = new GridEventEmitter();
|
||||
|
||||
const { columnSettings, data } = toRefs(props);
|
||||
|
||||
const rootEl = ref<InstanceType<typeof HTMLTableElement>>();
|
||||
const columns = ref<GridColumn[]>([]);
|
||||
const rows = ref<GridRow[]>([]);
|
||||
const cells = ref<GridCell[][]>([]);
|
||||
|
@ -78,9 +83,36 @@ const selectedCell = computed(() => {
|
|||
return selected.length > 0 ? selected[0] : undefined;
|
||||
});
|
||||
const rangedCells = computed(() => cells.value.flat().filter(it => it.ranged));
|
||||
const rangedBounds = computed(() => {
|
||||
const _cells = rangedCells.value;
|
||||
const leftTop = {
|
||||
col: Math.min(..._cells.map(it => it.address.col)),
|
||||
row: Math.min(..._cells.map(it => it.address.row)),
|
||||
};
|
||||
const rightBottom = {
|
||||
col: Math.max(..._cells.map(it => it.address.col)),
|
||||
row: Math.max(..._cells.map(it => it.address.row)),
|
||||
};
|
||||
return {
|
||||
leftTop,
|
||||
rightBottom,
|
||||
};
|
||||
});
|
||||
const availableBounds = computed(() => {
|
||||
const leftTop = {
|
||||
col: 0,
|
||||
row: 0,
|
||||
};
|
||||
const rightBottom = {
|
||||
col: Math.max(...columns.value.map(it => it.index)),
|
||||
row: Math.max(...rows.value.map(it => it.index)),
|
||||
};
|
||||
return { leftTop, rightBottom };
|
||||
});
|
||||
|
||||
watch(columnSettings, refreshColumnsSetting);
|
||||
watch(data, refreshData);
|
||||
|
||||
if (_DEV_) {
|
||||
watch(state, (value) => {
|
||||
console.log(`state: ${value}`);
|
||||
|
@ -88,29 +120,50 @@ if (_DEV_) {
|
|||
}
|
||||
|
||||
function onKeyDown(ev: KeyboardEvent) {
|
||||
switch (state.value) {
|
||||
case 'normal': {
|
||||
const selectedCellAddress = selectedCell.value?.address;
|
||||
if (!selectedCellAddress) {
|
||||
return;
|
||||
if (_DEV_) {
|
||||
console.log('[Grid]', `ctrl: ${ev.ctrlKey}, shift: ${ev.shiftKey}, code: ${ev.code}`);
|
||||
}
|
||||
|
||||
let next: CellAddress;
|
||||
switch (state.value) {
|
||||
case 'normal': {
|
||||
// normalの時は自前で制御したい
|
||||
ev.preventDefault();
|
||||
|
||||
if (ev.ctrlKey) {
|
||||
if (ev.shiftKey) {
|
||||
// ctrl + shiftキーが押されている場合は選択セルの範囲拡大(最大範囲)
|
||||
const selectedCellAddress = requireSelectionCell();
|
||||
const max = availableBounds.value;
|
||||
const bounds = rangedBounds.value;
|
||||
|
||||
let newBounds: { leftTop: CellAddress, rightBottom: CellAddress };
|
||||
switch (ev.code) {
|
||||
case 'ArrowRight': {
|
||||
next = { col: selectedCellAddress.col + 1, row: selectedCellAddress.row };
|
||||
newBounds = {
|
||||
leftTop: { col: selectedCellAddress.col, row: bounds.leftTop.row },
|
||||
rightBottom: { col: max.rightBottom.col, row: bounds.rightBottom.row },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
next = { col: selectedCellAddress.col - 1, row: selectedCellAddress.row };
|
||||
newBounds = {
|
||||
leftTop: { col: max.leftTop.col, row: bounds.leftTop.row },
|
||||
rightBottom: { col: selectedCellAddress.col, row: bounds.rightBottom.row },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
next = { col: selectedCellAddress.col, row: selectedCellAddress.row - 1 };
|
||||
newBounds = {
|
||||
leftTop: { col: bounds.leftTop.col, row: max.leftTop.row },
|
||||
rightBottom: { col: bounds.rightBottom.col, row: selectedCellAddress.row },
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
next = { col: selectedCellAddress.col, row: selectedCellAddress.row + 1 };
|
||||
newBounds = {
|
||||
leftTop: { col: bounds.leftTop.col, row: selectedCellAddress.row },
|
||||
rightBottom: { col: bounds.rightBottom.col, row: max.rightBottom.row },
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
@ -118,7 +171,138 @@ function onKeyDown(ev: KeyboardEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
selectionCell(next);
|
||||
unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom);
|
||||
expandRange(newBounds.leftTop, newBounds.rightBottom);
|
||||
} else {
|
||||
switch (ev.code) {
|
||||
case 'KeyC': {
|
||||
rangeCopyToClipboard();
|
||||
break;
|
||||
}
|
||||
case 'KeyV': {
|
||||
pasteFromClipboard();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (ev.shiftKey) {
|
||||
// shiftキーが押されている場合は選択セルの範囲拡大(隣のセルまで)
|
||||
const selectedCellAddress = requireSelectionCell();
|
||||
const bounds = rangedBounds.value;
|
||||
let newBounds: { leftTop: CellAddress, rightBottom: CellAddress };
|
||||
switch (ev.code) {
|
||||
case 'ArrowRight': {
|
||||
newBounds = {
|
||||
leftTop: {
|
||||
col: bounds.leftTop.col < selectedCellAddress.col
|
||||
? bounds.leftTop.col + 1
|
||||
: selectedCellAddress.col,
|
||||
row: bounds.leftTop.row,
|
||||
},
|
||||
rightBottom: {
|
||||
col: (bounds.rightBottom.col > selectedCellAddress.col || bounds.leftTop.col === selectedCellAddress.col)
|
||||
? bounds.rightBottom.col + 1
|
||||
: selectedCellAddress.col,
|
||||
row: bounds.rightBottom.row,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
newBounds = {
|
||||
leftTop: {
|
||||
col: (bounds.leftTop.col < selectedCellAddress.col || bounds.rightBottom.col === selectedCellAddress.col)
|
||||
? bounds.leftTop.col - 1
|
||||
: selectedCellAddress.col,
|
||||
row: bounds.leftTop.row,
|
||||
},
|
||||
rightBottom: {
|
||||
col: bounds.rightBottom.col > selectedCellAddress.col
|
||||
? bounds.rightBottom.col - 1
|
||||
: selectedCellAddress.col,
|
||||
row: bounds.rightBottom.row,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
newBounds = {
|
||||
leftTop: {
|
||||
col: bounds.leftTop.col,
|
||||
row: (bounds.leftTop.row < selectedCellAddress.row || bounds.rightBottom.row === selectedCellAddress.row)
|
||||
? bounds.leftTop.row - 1
|
||||
: selectedCellAddress.row,
|
||||
},
|
||||
rightBottom: {
|
||||
col: bounds.rightBottom.col,
|
||||
row: bounds.rightBottom.row > selectedCellAddress.row
|
||||
? bounds.rightBottom.row - 1
|
||||
: selectedCellAddress.row,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
newBounds = {
|
||||
leftTop: {
|
||||
col: bounds.leftTop.col,
|
||||
row: bounds.leftTop.row < selectedCellAddress.row
|
||||
? bounds.leftTop.row + 1
|
||||
: selectedCellAddress.row,
|
||||
},
|
||||
rightBottom: {
|
||||
col: bounds.rightBottom.col,
|
||||
row: (bounds.rightBottom.row > selectedCellAddress.row || bounds.leftTop.row === selectedCellAddress.row)
|
||||
? bounds.rightBottom.row + 1
|
||||
: selectedCellAddress.row,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unSelectionOutOfRange(newBounds.leftTop, newBounds.rightBottom);
|
||||
expandRange(newBounds.leftTop, newBounds.rightBottom);
|
||||
} else {
|
||||
// shiftキーもctrlキーが押されていない場合
|
||||
switch (ev.code) {
|
||||
case 'ArrowRight': {
|
||||
const selectedCellAddress = requireSelectionCell();
|
||||
selectionCell({ col: selectedCellAddress.col + 1, row: selectedCellAddress.row });
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
const selectedCellAddress = requireSelectionCell();
|
||||
selectionCell({ col: selectedCellAddress.col - 1, row: selectedCellAddress.row });
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
const selectedCellAddress = requireSelectionCell();
|
||||
selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row - 1 });
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
const selectedCellAddress = requireSelectionCell();
|
||||
selectionCell({ col: selectedCellAddress.col, row: selectedCellAddress.row + 1 });
|
||||
break;
|
||||
}
|
||||
case 'Delete': {
|
||||
const ranges = rangedCells.value;
|
||||
for (const range of ranges) {
|
||||
range.value = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -134,7 +318,6 @@ function onMouseDown(ev: MouseEvent) {
|
|||
break;
|
||||
}
|
||||
case 'normal': {
|
||||
const cellAddress = getCellAddress(ev.target as HTMLElement);
|
||||
if (availableCellAddress(cellAddress)) {
|
||||
selectionCell(cellAddress);
|
||||
|
||||
|
@ -151,6 +334,8 @@ function onMouseDown(ev: MouseEvent) {
|
|||
registerMouseMove();
|
||||
firstSelectionColumnIdx.value = cellAddress.col;
|
||||
state.value = 'colSelecting';
|
||||
|
||||
rootEl.value?.focus();
|
||||
} else if (isRowNumberCellAddress(cellAddress)) {
|
||||
unSelectionRange();
|
||||
|
||||
|
@ -161,6 +346,8 @@ function onMouseDown(ev: MouseEvent) {
|
|||
registerMouseMove();
|
||||
firstSelectionRowIdx.value = cellAddress.row;
|
||||
state.value = 'rowSelecting';
|
||||
|
||||
rootEl.value?.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -168,6 +355,7 @@ function onMouseDown(ev: MouseEvent) {
|
|||
}
|
||||
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
switch (state.value) {
|
||||
case 'cellSelecting': {
|
||||
const selectedCellAddress = selectedCell.value?.address;
|
||||
|
@ -240,6 +428,7 @@ function onMouseMove(ev: MouseEvent) {
|
|||
}
|
||||
|
||||
function onMouseUp(ev: MouseEvent) {
|
||||
ev.preventDefault();
|
||||
switch (state.value) {
|
||||
case 'rowSelecting':
|
||||
case 'colSelecting':
|
||||
|
@ -270,20 +459,14 @@ function onCellEditEnd() {
|
|||
}
|
||||
|
||||
function onChangeCellValue(sender: GridCell, newValue: CellValue) {
|
||||
cells.value[sender.address.row][sender.address.col].value = newValue;
|
||||
setCellValue(sender, newValue);
|
||||
}
|
||||
|
||||
function onChangeCellContentSize(sender: GridCell, contentSize: Size) {
|
||||
cells.value[sender.address.row][sender.address.col].contentSize = contentSize;
|
||||
}
|
||||
|
||||
function onSelectionMove(_: GridCell, next: CellAddress) {
|
||||
if (availableCellAddress(next)) {
|
||||
selectionCell(next);
|
||||
}
|
||||
}
|
||||
|
||||
function onHeaderCellWidthBeginChange(_: GridColumn) {
|
||||
function onHeaderCellWidthBeginChange() {
|
||||
switch (state.value) {
|
||||
case 'normal': {
|
||||
state.value = 'colResizing';
|
||||
|
@ -292,7 +475,7 @@ function onHeaderCellWidthBeginChange(_: GridColumn) {
|
|||
}
|
||||
}
|
||||
|
||||
function onHeaderCellWidthEndChange(_: GridColumn) {
|
||||
function onHeaderCellWidthEndChange() {
|
||||
switch (state.value) {
|
||||
case 'colResizing': {
|
||||
state.value = 'normal';
|
||||
|
@ -341,18 +524,14 @@ function onHeaderCellWidthLargest(sender: GridColumn) {
|
|||
}
|
||||
}
|
||||
|
||||
function onSelectionColumn(sender: GridColumn) {
|
||||
unSelectionRange();
|
||||
|
||||
const targets = cells.value.map(row => row[sender.index].address);
|
||||
selectionRange(...targets);
|
||||
}
|
||||
|
||||
function onSelectionRow(sender: GridRow) {
|
||||
unSelectionRange();
|
||||
|
||||
const targets = cells.value[sender.index].map(cell => cell.address);
|
||||
selectionRange(...targets);
|
||||
function setCellValue(sender: GridCell | CellAddress, newValue: CellValue) {
|
||||
const cellAddress = 'address' in sender ? sender.address : sender;
|
||||
cells.value[cellAddress.row][cellAddress.col].value = newValue;
|
||||
emit('change:cellValue', {
|
||||
column: columns.value[cellAddress.col],
|
||||
row: rows.value[cellAddress.row],
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
function selectionCell(target: CellAddress) {
|
||||
|
@ -367,6 +546,15 @@ function selectionCell(target: CellAddress) {
|
|||
_cells[target.row][target.col].ranged = true;
|
||||
}
|
||||
|
||||
function requireSelectionCell(): CellAddress {
|
||||
const selected = selectedCell.value;
|
||||
if (!selected) {
|
||||
throw new Error('No selected cell');
|
||||
}
|
||||
|
||||
return selected.address;
|
||||
}
|
||||
|
||||
function selectionRange(...targets: CellAddress[]) {
|
||||
const _cells = cells.value;
|
||||
for (const target of targets) {
|
||||
|
@ -414,6 +602,75 @@ function isRowNumberCellAddress(cellAddress: CellAddress): boolean {
|
|||
return cellAddress.row >= 0 && cellAddress.col === -1;
|
||||
}
|
||||
|
||||
function rangeCopyToClipboard() {
|
||||
const lines = Array.of<string>();
|
||||
const bounds = rangedBounds.value;
|
||||
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
|
||||
const items = Array.of<string>();
|
||||
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
|
||||
const cell = cells.value[row][col];
|
||||
items.push(cell.value?.toString() ?? '');
|
||||
}
|
||||
lines.push(items.join('\t'));
|
||||
}
|
||||
|
||||
const text = lines.join('\n');
|
||||
copyToClipboard(text);
|
||||
}
|
||||
|
||||
async function pasteFromClipboard() {
|
||||
function parseValue(value: string, type: ColumnSetting['type']): CellValue {
|
||||
switch (type) {
|
||||
case 'number': {
|
||||
return Number(value);
|
||||
}
|
||||
case 'boolean': {
|
||||
return value === 'true';
|
||||
}
|
||||
default: {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clipBoardText = await navigator.clipboard.readText();
|
||||
|
||||
const bounds = rangedBounds.value;
|
||||
const lines = clipBoardText.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(it => it.split('\t'));
|
||||
|
||||
if (lines.length === 1 && lines[0].length === 1) {
|
||||
// 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
|
||||
const ranges = rangedCells.value;
|
||||
for (const cell of ranges) {
|
||||
setCellValue(cell, parseValue(lines[0][0], cell.column.setting.type));
|
||||
}
|
||||
} else {
|
||||
// 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
|
||||
const offsetRow = bounds.leftTop.row;
|
||||
const offsetCol = bounds.leftTop.col;
|
||||
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
|
||||
const rowIdx = row - offsetRow;
|
||||
if (lines.length <= rowIdx) {
|
||||
// クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
|
||||
break;
|
||||
}
|
||||
|
||||
const items = lines[rowIdx];
|
||||
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
|
||||
const colIdx = col - offsetCol;
|
||||
if (items.length <= colIdx) {
|
||||
// クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
|
||||
break;
|
||||
}
|
||||
|
||||
setCellValue(cells.value[row][col], parseValue(items[colIdx], cells.value[row][col].column.setting.type));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshColumnsSetting() {
|
||||
const bindToList = columnSettings.value.map(it => it.bindTo);
|
||||
if (new Set(bindToList).size !== columnSettings.value.length) {
|
||||
|
@ -427,6 +684,7 @@ function refreshData() {
|
|||
const _data: DataSource[] = data.value;
|
||||
const _rows: GridRow[] = _data.map((_, index) => ({
|
||||
index,
|
||||
|
||||
}));
|
||||
const _columns: GridColumn[] = columnSettings.value.map((setting, index) => ({
|
||||
index,
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
<tr :class="$style.header">
|
||||
<MkNumberCell
|
||||
content="#"
|
||||
:selectable="false"
|
||||
:top="true"
|
||||
/>
|
||||
<MkHeaderCell
|
||||
|
|
|
@ -7,12 +7,9 @@
|
|||
<script setup lang="ts">
|
||||
import { GridRow } from '@/components/grid/types.js';
|
||||
|
||||
const emit = defineEmits<{}>();
|
||||
|
||||
defineProps<{
|
||||
content: string,
|
||||
row?: GridRow,
|
||||
selectable: boolean,
|
||||
top?: boolean,
|
||||
}>();
|
||||
|
||||
|
|
|
@ -56,6 +56,12 @@ export type GridRow = {
|
|||
index: number;
|
||||
}
|
||||
|
||||
export type CellValueChangedEvent = {
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
value: CellValue;
|
||||
}
|
||||
|
||||
export class GridEventEmitter extends EventEmitter<{}> {
|
||||
}
|
||||
|
||||
|
@ -71,7 +77,7 @@ export function isRowElement(elem: any): elem is HTMLTableRowElement {
|
|||
return elem instanceof HTMLTableRowElement;
|
||||
}
|
||||
|
||||
export function getCellAddress(elem: HTMLElement, parentNodeCount = 5): CellAddress {
|
||||
export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
|
||||
let node = elem;
|
||||
for (let i = 0; i < parentNodeCount; i++) {
|
||||
if (isCellElement(node) && isRowElement(node.parentElement)) {
|
||||
|
|
|
@ -1,9 +1,23 @@
|
|||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export class GridItem {
|
||||
export interface IGridItem {
|
||||
readonly id?: string;
|
||||
readonly url?: string;
|
||||
readonly blob?: Blob;
|
||||
readonly fileId?: string;
|
||||
readonly url: string;
|
||||
|
||||
name: string;
|
||||
category: string;
|
||||
aliases: string;
|
||||
license: string;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: string;
|
||||
}
|
||||
|
||||
export class GridItem implements IGridItem {
|
||||
readonly id?: string;
|
||||
readonly fileId?: string;
|
||||
readonly url: string;
|
||||
|
||||
public name: string;
|
||||
public category: string;
|
||||
|
@ -17,8 +31,8 @@ export class GridItem {
|
|||
|
||||
constructor(
|
||||
id: string | undefined,
|
||||
url: string | undefined = undefined,
|
||||
blob: Blob | undefined = undefined,
|
||||
fileId: string | undefined,
|
||||
url: string,
|
||||
name: string,
|
||||
category: string,
|
||||
aliases: string,
|
||||
|
@ -28,8 +42,8 @@ export class GridItem {
|
|||
roleIdsThatCanBeUsedThisEmojiAsReaction: string,
|
||||
) {
|
||||
this.id = id;
|
||||
this.fileId = fileId;
|
||||
this.url = url;
|
||||
this.blob = blob;
|
||||
|
||||
this.aliases = aliases;
|
||||
this.name = name;
|
||||
|
@ -42,11 +56,11 @@ export class GridItem {
|
|||
this.origin = JSON.stringify(this);
|
||||
}
|
||||
|
||||
static ofEmojiDetailed(it: Misskey.entities.EmojiDetailed): GridItem {
|
||||
static fromEmojiDetailed(it: Misskey.entities.EmojiDetailed): GridItem {
|
||||
return new GridItem(
|
||||
it.id,
|
||||
it.url,
|
||||
undefined,
|
||||
it.url,
|
||||
it.name,
|
||||
it.category ?? '',
|
||||
it.aliases.join(', '),
|
||||
|
@ -57,6 +71,21 @@ export class GridItem {
|
|||
);
|
||||
}
|
||||
|
||||
static fromDriveFile(it: Misskey.entities.DriveFile): GridItem {
|
||||
return new GridItem(
|
||||
undefined,
|
||||
it.id,
|
||||
it.url,
|
||||
it.name.replace(/\.[a-zA-Z0-9]+$/, ''),
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
it.isSensitive,
|
||||
false,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
public get edited(): boolean {
|
||||
const { origin, ..._this } = this;
|
||||
return JSON.stringify(_this) !== origin;
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div class="_gaps">
|
||||
<MkInput :modelValue="query" :debounce="true" type="search" autocapitalize="off" @change="(v) => query = v">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
<template #label>{{ i18n.ts.search }}</template>
|
||||
</MkInput>
|
||||
|
||||
<div
|
||||
style="overflow-y: scroll; padding-top: 8px; padding-bottom: 8px;"
|
||||
>
|
||||
<MkGrid :data="convertedGridItems" :columnSettings="columnSettings"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.pages">
|
||||
<button><<</button>
|
||||
<button><</button>
|
||||
|
||||
<button>1</button>
|
||||
<button>2</button>
|
||||
<button>3</button>
|
||||
<button>4</button>
|
||||
<button>5</button>
|
||||
<span>...</span>
|
||||
<button>10</button>
|
||||
|
||||
<button>></button>
|
||||
<button>>></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref, toRefs, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { ColumnSetting } from '@/components/grid/types.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
||||
const columnSettings: ColumnSetting[] = [
|
||||
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
const props = defineProps<{
|
||||
customEmojis: Misskey.entities.EmojiDetailed[];
|
||||
}>();
|
||||
|
||||
const { customEmojis } = toRefs(props);
|
||||
|
||||
const query = ref('');
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
|
||||
const convertedGridItems = computed(() => gridItems.value.map(it => it.asRecord()));
|
||||
|
||||
watch(customEmojis, refreshGridItems);
|
||||
|
||||
function refreshGridItems() {
|
||||
gridItems.value = customEmojis.value.map(it => GridItem.fromEmojiDetailed(it));
|
||||
}
|
||||
|
||||
refreshGridItems();
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
|
||||
button {
|
||||
background-color: var(--buttonBg);
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
margin: 0 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,218 @@
|
|||
<template>
|
||||
<div class="_gaps">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-settings"></i></template>
|
||||
<template #label>アップロード設定</template>
|
||||
<template #caption>この画面で絵文字アップロードを行う際の動作を設定できます。</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>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<div
|
||||
:class="$style.uploadBox"
|
||||
@dragover.prevent
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
ここに絵文字の画像ファイルをドラッグ&ドロップするとドライブにアップロードされます。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gridItems.length > 0"
|
||||
style="overflow-y: scroll;"
|
||||
>
|
||||
<MkGrid
|
||||
:data="convertedGridItems"
|
||||
:columnSettings="columnSettings"
|
||||
@change:cellValue="onChangeCellValue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="gridItems.length > 0"
|
||||
:class="$style.buttons"
|
||||
>
|
||||
<MkButton primary @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 { computed, onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { GridItem, IGridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { CellValueChangedEvent, ColumnSetting } from '@/components/grid/types.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import { uploadFile } from '@/scripts/upload.js';
|
||||
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';
|
||||
|
||||
type FolderItem = {
|
||||
id?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type UploadResult = { key: string, item: IGridItem, success: boolean, err: any };
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:registered'): void;
|
||||
}>();
|
||||
|
||||
const columnSettings: ColumnSetting[] = [
|
||||
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
|
||||
{ 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: 100 },
|
||||
];
|
||||
|
||||
const uploadFolders = ref<FolderItem[]>([]);
|
||||
const gridItems = ref<IGridItem[]>([]);
|
||||
const selectedFolderId = ref(defaultStore.state.uploadFolder);
|
||||
const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
|
||||
const convertedGridItems = computed(() => gridItems.value.map(it => it as Record<string, any>));
|
||||
|
||||
async function onRegistryClicked() {
|
||||
const dialogSelection = await os.confirm({
|
||||
type: 'info',
|
||||
title: '確認',
|
||||
text: 'リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?',
|
||||
});
|
||||
|
||||
if (dialogSelection.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = new Map<string, IGridItem>(gridItems.value.map(it => [`${it.fileId}|${it.name}`, it]));
|
||||
const upload = async (): Promise<UploadResult[]> => {
|
||||
const result = Array.of<UploadResult>();
|
||||
for (const [key, item] of items.entries()) {
|
||||
try {
|
||||
await misskeyApi('admin/emoji/add', {
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
aliases: item.aliases.split(',').map(it => it.trim()),
|
||||
license: item.license,
|
||||
isSensitive: item.isSensitive,
|
||||
localOnly: item.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.split(',').map(it => it.trim()),
|
||||
fileId: item.fileId!,
|
||||
});
|
||||
result.push({ key, item, success: true, err: undefined });
|
||||
} catch (err: any) {
|
||||
result.push({ key, item, success: false, err });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const result = await os.promiseDialog(upload());
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
gridItems.value = failedItems.map(it => it.item);
|
||||
|
||||
emit('operation:registered');
|
||||
}
|
||||
|
||||
async function onClearClicked() {
|
||||
const result = await os.confirm({
|
||||
type: 'warning',
|
||||
title: '確認',
|
||||
text: '編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?',
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
gridItems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(ev: DragEvent) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const dropFiles = ev.dataTransfer?.files;
|
||||
if (!dropFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadingPromises = Array.of<Promise<Misskey.entities.DriveFile>>();
|
||||
for (let i = 0; i < dropFiles.length; i++) {
|
||||
const file = dropFiles.item(i);
|
||||
if (file) {
|
||||
const name = file.name.replace(/\.[a-zA-Z0-9]+$/, '');
|
||||
uploadingPromises.push(
|
||||
uploadFile(
|
||||
file,
|
||||
selectedFolderId.value,
|
||||
name,
|
||||
keepOriginalUploading.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedFiles = await Promise.all(uploadingPromises);
|
||||
for (const uploadedFile of uploadedFiles) {
|
||||
const item = GridItem.fromDriveFile(uploadedFile);
|
||||
gridItems.value.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
function onChangeCellValue(event: CellValueChangedEvent) {
|
||||
console.log(event);
|
||||
const item = gridItems.value[event.row.index];
|
||||
item[event.column.setting.bindTo] = event.value;
|
||||
}
|
||||
|
||||
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">
|
||||
.uploadBox {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
border: 0.5px dotted var(--accentedBg);
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--accentedBg);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
|
@ -2,38 +2,17 @@
|
|||
<div>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
|
||||
<MkPageHeader v-model:tab="headerTab" :actions="headerActions" :tabs="headerTabs"/>
|
||||
</template>
|
||||
<div class="_gaps" :class="$style.root">
|
||||
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
<template #label>{{ i18n.ts.search }}</template>
|
||||
</MkInput>
|
||||
<MkTab v-model="modeTab" style="margin-bottom: var(--margin);">
|
||||
<option value="list">登録済み絵文字一覧</option>
|
||||
<option value="register">新規登録</option>
|
||||
</MkTab>
|
||||
|
||||
<div :class="$style.controller">
|
||||
<MkSelect v-model="limit">
|
||||
<option value="100">100件</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
|
||||
<div style="overflow-y: scroll; padding-top: 8px; padding-bottom: 8px;">
|
||||
<MkGrid :data="convertedGridItems" :columnSettings="columnSettings"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.pages">
|
||||
<button><<</button>
|
||||
<button><</button>
|
||||
|
||||
<button>1</button>
|
||||
<button>2</button>
|
||||
<button>3</button>
|
||||
<button>4</button>
|
||||
<button>5</button>
|
||||
<span>...</span>
|
||||
<button>10</button>
|
||||
|
||||
<button>></button>
|
||||
<button>>></button>
|
||||
<div>
|
||||
<XListComponent v-if="modeTab === 'list'" :customEmojis="customEmojis"/>
|
||||
<XRegisterComponent v-else @operation:registered="onOperationRegistered"/>
|
||||
</div>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
|
@ -41,49 +20,31 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { GridItem } from '@/pages/admin/custom-emojis-grid.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { ColumnSetting } from '@/components/grid/types.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
import XListComponent from '@/pages/admin/custom-emojis-grid.list.vue';
|
||||
import XRegisterComponent from '@/pages/admin/custom-emojis-grid.register.vue';
|
||||
|
||||
const columnSettings: ColumnSetting[] = [
|
||||
{ bindTo: 'url', title: '🎨', type: 'image', editable: false, width: 50 },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140 },
|
||||
{ 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 },
|
||||
];
|
||||
type PageMode = 'list' | 'register';
|
||||
|
||||
const customEmojis = ref<Misskey.entities.EmojiDetailed[]>([]);
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
const query = ref('');
|
||||
const limit = ref(100);
|
||||
const tab = ref('local');
|
||||
const headerTab = ref('local');
|
||||
const modeTab = ref<PageMode>('list');
|
||||
|
||||
const convertedGridItems = computed(() => gridItems.value.map(it => it.asRecord()));
|
||||
async function refreshCustomEmojis() {
|
||||
customEmojis.value = await misskeyApi('admin/emoji/list', { limit: 100 });
|
||||
}
|
||||
|
||||
const refreshCustomEmojis = async () => {
|
||||
customEmojis.value = await misskeyApi('emojis', { detail: true }).then(it => it.emojis);
|
||||
};
|
||||
|
||||
const refreshGridItems = () => {
|
||||
gridItems.value = customEmojis.value.map(it => GridItem.ofEmojiDetailed(it));
|
||||
};
|
||||
|
||||
watch(customEmojis, refreshGridItems);
|
||||
async function onOperationRegistered() {
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCustomEmojis();
|
||||
refreshGridItems();
|
||||
});
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
|
@ -98,10 +59,13 @@ const headerActions = computed(() => [{
|
|||
asFullButton: true,
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addEmoji,
|
||||
handler: () => {},
|
||||
handler: () => {
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-dots',
|
||||
handler: () => {},
|
||||
text: '',
|
||||
handler: () => {
|
||||
},
|
||||
}]);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
|
|
|
@ -1005,9 +1005,6 @@ type EmojiResponse = operations['emoji']['responses']['200']['content']['applica
|
|||
// @public (undocumented)
|
||||
type EmojiSimple = components['schemas']['EmojiSimple'];
|
||||
|
||||
// @public (undocumented)
|
||||
type EmojisRequest = operations['emojis']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
|
||||
|
||||
|
@ -1049,26 +1046,6 @@ export type Endpoints = Overwrite<Endpoints_2, {
|
|||
};
|
||||
};
|
||||
};
|
||||
'emojis': {
|
||||
req: EmojisRequest;
|
||||
res: {
|
||||
$switch: {
|
||||
$cases: [
|
||||
[
|
||||
{
|
||||
detail: true;
|
||||
},
|
||||
{
|
||||
emojis: EmojiDetailed[];
|
||||
}
|
||||
]
|
||||
];
|
||||
$default: {
|
||||
emojis: EmojiSimple[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
'signup': {
|
||||
req: SignupRequest;
|
||||
res: SignupResponse;
|
||||
|
@ -1468,7 +1445,6 @@ declare namespace entities {
|
|||
InviteLimitResponse,
|
||||
MetaRequest,
|
||||
MetaResponse,
|
||||
EmojisRequest,
|
||||
EmojisResponse,
|
||||
EmojiRequest,
|
||||
EmojiResponse,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Endpoints as Gen } from './autogen/endpoint.js';
|
||||
import { EmojiDetailed, EmojiSimple, UserDetailed } from './autogen/models.js';
|
||||
import { EmojisRequest, UsersShowRequest } from './autogen/entities.js';
|
||||
import { UserDetailed } from './autogen/models.js';
|
||||
import { UsersShowRequest } from './autogen/entities.js';
|
||||
import {
|
||||
SigninRequest,
|
||||
SigninResponse,
|
||||
|
@ -64,24 +64,6 @@ export type Endpoints = Overwrite<
|
|||
};
|
||||
};
|
||||
},
|
||||
'emojis': {
|
||||
req: EmojisRequest;
|
||||
res: {
|
||||
$switch: {
|
||||
$cases: [[
|
||||
{
|
||||
detail: true;
|
||||
},
|
||||
{
|
||||
emojis: EmojiDetailed[]
|
||||
}
|
||||
]];
|
||||
$default: {
|
||||
emojis: EmojiSimple[]
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
// api.jsonには載せないものなのでここで定義
|
||||
'signup': {
|
||||
req: SignupRequest;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.4
|
||||
* generatedAt: 2024-01-23T07:38:51.367Z
|
||||
* generatedAt: 2024-01-27T12:44:54.092Z
|
||||
*/
|
||||
|
||||
import type { SwitchCaseResponseType } from '../api.js';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.4
|
||||
* generatedAt: 2024-01-23T07:38:51.365Z
|
||||
* generatedAt: 2024-01-27T12:44:54.091Z
|
||||
*/
|
||||
|
||||
import type {
|
||||
|
@ -366,7 +366,6 @@ import type {
|
|||
InviteLimitResponse,
|
||||
MetaRequest,
|
||||
MetaResponse,
|
||||
EmojisRequest,
|
||||
EmojisResponse,
|
||||
EmojiRequest,
|
||||
EmojiResponse,
|
||||
|
@ -806,7 +805,7 @@ export type Endpoints = {
|
|||
'invite/list': { req: InviteListRequest; res: InviteListResponse };
|
||||
'invite/limit': { req: EmptyRequest; res: InviteLimitResponse };
|
||||
'meta': { req: MetaRequest; res: MetaResponse };
|
||||
'emojis': { req: EmojisRequest; res: EmojisResponse };
|
||||
'emojis': { req: EmptyRequest; res: EmojisResponse };
|
||||
'emoji': { req: EmojiRequest; res: EmojiResponse };
|
||||
'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse };
|
||||
'mute/create': { req: MuteCreateRequest; res: EmptyResponse };
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.4
|
||||
* generatedAt: 2024-01-23T07:38:51.364Z
|
||||
* generatedAt: 2024-01-27T12:44:54.089Z
|
||||
*/
|
||||
|
||||
import { operations } from './types.js';
|
||||
|
@ -368,7 +368,6 @@ export type InviteListResponse = operations['invite/list']['responses']['200']['
|
|||
export type InviteLimitResponse = operations['invite/limit']['responses']['200']['content']['application/json'];
|
||||
export type MetaRequest = operations['meta']['requestBody']['content']['application/json'];
|
||||
export type MetaResponse = operations['meta']['responses']['200']['content']['application/json'];
|
||||
export type EmojisRequest = operations['emojis']['requestBody']['content']['application/json'];
|
||||
export type EmojisResponse = operations['emojis']['responses']['200']['content']['application/json'];
|
||||
export type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
|
||||
export type EmojiResponse = operations['emoji']['responses']['200']['content']['application/json'];
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* version: 2024.2.0-beta.4
|
||||
* generatedAt: 2024-01-23T07:38:51.363Z
|
||||
* generatedAt: 2024-01-27T12:44:54.088Z
|
||||
*/
|
||||
|
||||
import { components } from './types.js';
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
/*
|
||||
* version: 2024.2.0-beta.4
|
||||
* generatedAt: 2024-01-23T07:38:51.282Z
|
||||
* generatedAt: 2024-01-27T12:44:54.008Z
|
||||
*/
|
||||
|
||||
/**
|
||||
|
@ -6539,16 +6539,7 @@ export type operations = {
|
|||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': ({
|
||||
/** Format: id */
|
||||
id: string;
|
||||
aliases: string[];
|
||||
name: string;
|
||||
category: string | null;
|
||||
/** @description The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files. */
|
||||
host: string | null;
|
||||
url: string;
|
||||
})[];
|
||||
'application/json': components['schemas']['EmojiDetailed'][];
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
|
@ -19027,19 +19018,12 @@ export type operations = {
|
|||
* **Credential required**: *No*
|
||||
*/
|
||||
emojis: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
detail?: boolean | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
emojis: (components['schemas']['EmojiSimple'] | components['schemas']['EmojiDetailed'])[];
|
||||
emojis: components['schemas']['EmojiSimple'][];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue