Compare commits
182 Commits
5b789cfc3d
...
bc0ae72951
Author | SHA1 | Date |
---|---|---|
おさむのひと | bc0ae72951 | |
おさむのひと | 3113af0743 | |
おさむのひと | b34a522c42 | |
おさむのひと | a4aee93c3d | |
おさむのひと | 8d820dd7d4 | |
おさむのひと | 01f83085bd | |
おさむのひと | a3d1fa199e | |
syuilo | 8fdf73d192 | |
syuilo | c9cbbc2d39 | |
おさむのひと | 6baf88e41a | |
おさむのひと | 33f685b527 | |
おさむのひと | c04b2d7810 | |
おさむのひと | a0807edb40 | |
おさむのひと | de00b0575a | |
おさむのひと | 9c16e7f1ef | |
おさむのひと | 51c6186523 | |
おさむのひと | 6c3b97bb2f | |
samunohito | 29dd5e59d7 | |
samunohito | bac10659e0 | |
samunohito | ca2da9d296 | |
samunohito | fdf20a6605 | |
おさむのひと | 7e0343d724 | |
samunohito | 75f4eef769 | |
おさむのひと | 151b6c7d93 | |
samunohito | 2bd813dbda | |
おさむのひと | e3260a6f18 | |
samunohito | 6e9f1e53bf | |
samunohito | 4baf1596e8 | |
samunohito | 20bcf4af4f | |
おさむのひと | 1cefedb6ac | |
syuilo | 82bbbb9f18 | |
syuilo | 1bb237126c | |
syuilo | e59bdf4085 | |
samunohito | da7e9d36d5 | |
samunohito | 499f7b7324 | |
samunohito | d82aee2890 | |
samunohito | 629fb916f9 | |
おさむのひと | 5fc7f04c4d | |
samunohito | 0e9bf77174 | |
samunohito | 2c7e15f841 | |
samunohito | 57a1853d4a | |
samunohito | ca9df52a5d | |
samunohito | 4025845e54 | |
samunohito | 0bc2401894 | |
samunohito | 695b75c944 | |
samunohito | cf95082450 | |
syuilo | cd06807d53 | |
syuilo | d4e2844711 | |
syuilo | f1d17a4ab7 | |
samunohito | 6a9dd7017d | |
samunohito | 82e9bcf9d4 | |
syuilo | a4a58bb952 | |
syuilo | 5c1bf0674c | |
samunohito | 4915f6cc34 | |
おさむのひと | ec0461f3b5 | |
syuilo | 250266ab73 | |
syuilo | fafef696bf | |
syuilo | f34a099251 | |
syuilo | 7a22282346 | |
おさむのひと | 1d1d1b0d64 | |
おさむのひと | 3a7589e773 | |
samunohito | f5fa33d2b9 | |
おさむのひと | 791e2c5835 | |
おさむのひと | 4a6f36ddca | |
samunohito | d6ac4ef5d7 | |
samunohito | 8454044642 | |
samunohito | bed39b644d | |
samunohito | 92c88a23ad | |
samunohito | 9289d013b6 | |
samunohito | da13426b89 | |
samunohito | 53f858d736 | |
samunohito | 1b720b36b9 | |
samunohito | cb136e635d | |
samunohito | 47ca0e6689 | |
samunohito | 0aee64ca40 | |
samunohito | 9a6ee0370c | |
samunohito | 122fba32f5 | |
samunohito | 20b1da3184 | |
samunohito | b60951e7ea | |
samunohito | 390af67949 | |
samunohito | cb668b22ad | |
osamu | e5b95755e8 | |
samunohito | c2c920c4a2 | |
samunohito | 879596ca7d | |
samunohito | 702e4ea515 | |
samunohito | 098cf397b9 | |
samunohito | 5a2b11ec17 | |
samunohito | 216325840d | |
samunohito | 06c44a9a02 | |
samunohito | effe586092 | |
samunohito | 0f896f6bdb | |
samunohito | 106e7910b9 | |
samunohito | bfdfc2c778 | |
samunohito | 38b4197395 | |
samunohito | ac943195cf | |
samunohito | 1cdf1bf4c9 | |
samunohito | 5e64974539 | |
samunohito | 87ff8e8d94 | |
samunohito | 1650ad350d | |
samunohito | 6ba613b4bb | |
samunohito | 07b9757b36 | |
samunohito | c0f941689b | |
samunohito | 369d5971d4 | |
samunohito | 4bbf0457fa | |
samunohito | 089682c08d | |
samunohito | 9189117ef1 | |
samunohito | 763cac0aad | |
samunohito | 5dd1fd7c5f | |
samunohito | 83228a3422 | |
samunohito | 171b596ac7 | |
samunohito | e3240c556a | |
samunohito | d1210520a5 | |
samunohito | 7943e524ad | |
samunohito | 9c4e40f83f | |
samunohito | b7192e5ac6 | |
samunohito | e84790e619 | |
samunohito | cdfd906366 | |
samunohito | dbb2efe45c | |
samunohito | 453596e6bf | |
samunohito | fa737fccdd | |
samunohito | 1d04e3abab | |
samunohito | 173b90e124 | |
samunohito | b0b474d2a3 | |
samunohito | e892fbf000 | |
samunohito | 273e3bd2e4 | |
samunohito | 2a0dca44c3 | |
samunohito | 3a4a5dc6f0 | |
samunohito | a655cece33 | |
samunohito | 76977b38ab | |
samunohito | c34d3234d5 | |
samunohito | 041449e962 | |
samunohito | f8529a01b9 | |
samunohito | d5db737469 | |
samunohito | b0b28e0cb7 | |
samunohito | dcb6260e8f | |
samunohito | dfe85d7722 | |
samunohito | 84758b6eec | |
samunohito | 048e0b8323 | |
samunohito | 4cb7e984aa | |
samunohito | b37a27e154 | |
samunohito | 3cb3c3a148 | |
samunohito | 27020cb211 | |
samunohito | 57cd712064 | |
samunohito | 950c80bc7a | |
samunohito | c88c8af8d9 | |
samunohito | 9bb1e79c83 | |
samunohito | f9e866e733 | |
samunohito | e6ec32126f | |
samunohito | 0ff55c0571 | |
samunohito | a06ce1137a | |
samunohito | 295440a347 | |
samunohito | ff48c77827 | |
samunohito | f96c7224a7 | |
samunohito | 777920d739 | |
samunohito | f9516e6ae1 | |
samunohito | c370729336 | |
samunohito | 2b4bc4dccd | |
samunohito | 61066779c5 | |
samunohito | 4fa943955a | |
samunohito | dfb57afa11 | |
samunohito | d453196c9f | |
samunohito | fc67fa994b | |
samunohito | ad03ef03da | |
samunohito | b2c8548c67 | |
samunohito | 3363de1070 | |
samunohito | 032687957f | |
samunohito | 18abb97f16 | |
samunohito | de07347087 | |
samunohito | e0ad0f2aae | |
samunohito | ff14249507 | |
samunohito | 53bad559a0 | |
samunohito | e21c43e2aa | |
samunohito | aacee3c970 | |
samunohito | 8d1a5734cd | |
samunohito | a2fcc81290 | |
samunohito | e39ba6286f | |
samunohito | 07efd85ffd | |
samunohito | 9494c30c9f | |
samunohito | 457a0a19ec | |
samunohito | e47a2a52aa | |
samunohito | feeafad523 | |
samunohito | 5012cff445 |
|
@ -8,6 +8,8 @@
|
|||
### General
|
||||
- Feat: コンテンツの表示にログインを必須にできるように
|
||||
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
|
||||
- Feat: カスタム絵文字管理画面をリニューアル #10996
|
||||
* β版として公開のため、旧画面も引き続き利用可能です
|
||||
- Enhance: 依存関係の更新
|
||||
- Enhance: l10nの更新
|
||||
|
||||
|
|
|
@ -36,6 +36,10 @@ export interface Locale extends ILocale {
|
|||
* 検索
|
||||
*/
|
||||
"search": string;
|
||||
/**
|
||||
* リセット
|
||||
*/
|
||||
"reset": string;
|
||||
/**
|
||||
* 通知
|
||||
*/
|
||||
|
@ -10511,6 +10515,226 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"native": string;
|
||||
};
|
||||
"_gridComponent": {
|
||||
"_error": {
|
||||
/**
|
||||
* この値は必須項目です
|
||||
*/
|
||||
"requiredValue": string;
|
||||
/**
|
||||
* 正規表現によるバリデーションはtype:textのカラムのみサポートします。
|
||||
*/
|
||||
"columnTypeNotSupport": string;
|
||||
/**
|
||||
* この値は{pattern}のパターンに一致しません
|
||||
*/
|
||||
"patternNotMatch": ParameterizedString<"pattern">;
|
||||
/**
|
||||
* この値は一意である必要があります
|
||||
*/
|
||||
"notUnique": string;
|
||||
};
|
||||
};
|
||||
"_roleSelectDialog": {
|
||||
/**
|
||||
* 選択されていません
|
||||
*/
|
||||
"notSelected": string;
|
||||
};
|
||||
"_customEmojisManager": {
|
||||
"_gridCommon": {
|
||||
/**
|
||||
* 選択行をコピー
|
||||
*/
|
||||
"copySelectionRows": string;
|
||||
/**
|
||||
* 選択範囲をコピー
|
||||
*/
|
||||
"copySelectionRanges": string;
|
||||
/**
|
||||
* 選択行を削除
|
||||
*/
|
||||
"deleteSelectionRows": string;
|
||||
/**
|
||||
* 選択範囲の行を削除
|
||||
*/
|
||||
"deleteSelectionRanges": string;
|
||||
/**
|
||||
* 検索設定
|
||||
*/
|
||||
"searchSettings": string;
|
||||
/**
|
||||
* 検索条件を詳細に設定します。
|
||||
*/
|
||||
"searchSettingCaption": string;
|
||||
/**
|
||||
* 並び順
|
||||
*/
|
||||
"sortOrder": string;
|
||||
/**
|
||||
* 登録ログ
|
||||
*/
|
||||
"registrationLogs": string;
|
||||
/**
|
||||
* 絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。
|
||||
*/
|
||||
"registrationLogsCaption": string;
|
||||
/**
|
||||
* エラー
|
||||
*/
|
||||
"alertEmojisRegisterFailedTitle": string;
|
||||
/**
|
||||
* 絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。
|
||||
*/
|
||||
"alertEmojisRegisterFailedDescription": string;
|
||||
};
|
||||
"_logs": {
|
||||
/**
|
||||
* 成功ログを表示
|
||||
*/
|
||||
"showSuccessLogSwitch": string;
|
||||
/**
|
||||
* 失敗ログはありません。
|
||||
*/
|
||||
"failureLogNothing": string;
|
||||
/**
|
||||
* ログはありません。
|
||||
*/
|
||||
"logNothing": string;
|
||||
};
|
||||
"_remote": {
|
||||
/**
|
||||
* 選択行をインポート
|
||||
*/
|
||||
"importSelectionRows": string;
|
||||
/**
|
||||
* 選択範囲の行をインポート
|
||||
*/
|
||||
"importSelectionRangesRows": string;
|
||||
/**
|
||||
* チェックがついた絵文字をインポート
|
||||
*/
|
||||
"importEmojisButton": string;
|
||||
/**
|
||||
* 絵文字のインポート
|
||||
*/
|
||||
"confirmImportEmojisTitle": string;
|
||||
/**
|
||||
* リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか?
|
||||
*/
|
||||
"confirmImportEmojisDescription": ParameterizedString<"count">;
|
||||
};
|
||||
"_local": {
|
||||
/**
|
||||
* 登録済み絵文字一覧
|
||||
*/
|
||||
"tabTitleList": string;
|
||||
/**
|
||||
* 絵文字の登録
|
||||
*/
|
||||
"tabTitleRegister": string;
|
||||
"_list": {
|
||||
/**
|
||||
* 登録された絵文字はありません。
|
||||
*/
|
||||
"emojisNothing": string;
|
||||
/**
|
||||
* 選択行を削除対象にする
|
||||
*/
|
||||
"markAsDeleteTargetRows": string;
|
||||
/**
|
||||
* 選択範囲の行を削除対象にする
|
||||
*/
|
||||
"markAsDeleteTargetRanges": string;
|
||||
/**
|
||||
* 変更された絵文字はありません。
|
||||
*/
|
||||
"alertUpdateEmojisNothingDescription": string;
|
||||
/**
|
||||
* 削除対象の絵文字はありません。
|
||||
*/
|
||||
"alertDeleteEmojisNothingDescription": string;
|
||||
/**
|
||||
* 確認
|
||||
*/
|
||||
"confirmUpdateEmojisTitle": string;
|
||||
/**
|
||||
* {count}個の絵文字を更新します。実行しますか?
|
||||
*/
|
||||
"confirmUpdateEmojisDescription": ParameterizedString<"count">;
|
||||
/**
|
||||
* 確認
|
||||
*/
|
||||
"confirmDeleteEmojisTitle": string;
|
||||
/**
|
||||
* チェックがつけられた{count}個の絵文字を削除します。実行しますか?
|
||||
*/
|
||||
"confirmDeleteEmojisDescription": ParameterizedString<"count">;
|
||||
/**
|
||||
* 絵文字に設定されたロールで検索
|
||||
*/
|
||||
"dialogSelectRoleTitle": string;
|
||||
};
|
||||
"_register": {
|
||||
/**
|
||||
* アップロード設定
|
||||
*/
|
||||
"uploadSettingTitle": string;
|
||||
/**
|
||||
* この画面で絵文字アップロードを行う際の動作を設定できます。
|
||||
*/
|
||||
"uploadSettingDescription": string;
|
||||
/**
|
||||
* ディレクトリ名を"category"に入力する
|
||||
*/
|
||||
"directoryToCategoryLabel": string;
|
||||
/**
|
||||
* ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を"category"に入力します。
|
||||
*/
|
||||
"directoryToCategoryCaption": string;
|
||||
/**
|
||||
* いずれかの方法で登録する絵文字を選択してください。
|
||||
*/
|
||||
"emojiInputAreaCaption": string;
|
||||
/**
|
||||
* この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ
|
||||
*/
|
||||
"emojiInputAreaList1": string;
|
||||
/**
|
||||
* このリンクをクリックしてPCから選択する
|
||||
*/
|
||||
"emojiInputAreaList2": string;
|
||||
/**
|
||||
* このリンクをクリックしてドライブから選択する
|
||||
*/
|
||||
"emojiInputAreaList3": string;
|
||||
/**
|
||||
* 確認
|
||||
*/
|
||||
"confirmRegisterEmojisTitle": string;
|
||||
/**
|
||||
* リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)
|
||||
*/
|
||||
"confirmRegisterEmojisDescription": ParameterizedString<"count">;
|
||||
/**
|
||||
* 確認
|
||||
*/
|
||||
"confirmClearEmojisTitle": string;
|
||||
/**
|
||||
* 編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?
|
||||
*/
|
||||
"confirmClearEmojisDescription": string;
|
||||
/**
|
||||
* 確認
|
||||
*/
|
||||
"confirmUploadEmojisTitle": string;
|
||||
/**
|
||||
* ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?
|
||||
*/
|
||||
"confirmUploadEmojisDescription": ParameterizedString<"count">;
|
||||
};
|
||||
};
|
||||
};
|
||||
"_embedCodeGen": {
|
||||
/**
|
||||
* 埋め込みコードをカスタマイズ
|
||||
|
|
|
@ -5,6 +5,7 @@ introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マ
|
|||
poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォーム<b>Misskey</b>のサーバーのひとつです。"
|
||||
monthAndDay: "{month}月 {day}日"
|
||||
search: "検索"
|
||||
reset: "リセット"
|
||||
notifications: "通知"
|
||||
username: "ユーザー名"
|
||||
password: "パスワード"
|
||||
|
@ -2800,6 +2801,69 @@ _contextMenu:
|
|||
appWithShift: "Shiftキーでアプリケーション"
|
||||
native: "ブラウザのUI"
|
||||
|
||||
_gridComponent:
|
||||
_error:
|
||||
requiredValue: "この値は必須項目です"
|
||||
columnTypeNotSupport: "正規表現によるバリデーションはtype:textのカラムのみサポートします。"
|
||||
patternNotMatch: "この値は{pattern}のパターンに一致しません"
|
||||
notUnique: "この値は一意である必要があります"
|
||||
|
||||
_roleSelectDialog:
|
||||
notSelected: "選択されていません"
|
||||
|
||||
_customEmojisManager:
|
||||
_gridCommon:
|
||||
copySelectionRows: "選択行をコピー"
|
||||
copySelectionRanges: "選択範囲をコピー"
|
||||
deleteSelectionRows: "選択行を削除"
|
||||
deleteSelectionRanges: "選択範囲の行を削除"
|
||||
searchSettings: "検索設定"
|
||||
searchSettingCaption: "検索条件を詳細に設定します。"
|
||||
sortOrder: "並び順"
|
||||
registrationLogs: "登録ログ"
|
||||
registrationLogsCaption: "絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。"
|
||||
alertEmojisRegisterFailedTitle: "エラー"
|
||||
alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。"
|
||||
_logs:
|
||||
showSuccessLogSwitch: "成功ログを表示"
|
||||
failureLogNothing: "失敗ログはありません。"
|
||||
logNothing: "ログはありません。"
|
||||
_remote:
|
||||
importSelectionRows: "選択行をインポート"
|
||||
importSelectionRangesRows: "選択範囲の行をインポート"
|
||||
importEmojisButton: "チェックがついた絵文字をインポート"
|
||||
confirmImportEmojisTitle: "絵文字のインポート"
|
||||
confirmImportEmojisDescription: "リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか?"
|
||||
_local:
|
||||
tabTitleList: "登録済み絵文字一覧"
|
||||
tabTitleRegister: "絵文字の登録"
|
||||
_list:
|
||||
emojisNothing: "登録された絵文字はありません。"
|
||||
markAsDeleteTargetRows: "選択行を削除対象にする"
|
||||
markAsDeleteTargetRanges: "選択範囲の行を削除対象にする"
|
||||
alertUpdateEmojisNothingDescription: "変更された絵文字はありません。"
|
||||
alertDeleteEmojisNothingDescription: "削除対象の絵文字はありません。"
|
||||
confirmUpdateEmojisTitle: "確認"
|
||||
confirmUpdateEmojisDescription: "{count}個の絵文字を更新します。実行しますか?"
|
||||
confirmDeleteEmojisTitle: "確認"
|
||||
confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除します。実行しますか?"
|
||||
dialogSelectRoleTitle: "絵文字に設定されたロールで検索"
|
||||
_register:
|
||||
uploadSettingTitle: "アップロード設定"
|
||||
uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。"
|
||||
directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する"
|
||||
directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。"
|
||||
emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。"
|
||||
emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ"
|
||||
emojiInputAreaList2: "このリンクをクリックしてPCから選択する"
|
||||
emojiInputAreaList3: "このリンクをクリックしてドライブから選択する"
|
||||
confirmRegisterEmojisTitle: "確認"
|
||||
confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)"
|
||||
confirmClearEmojisTitle: "確認"
|
||||
confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?"
|
||||
confirmUploadEmojisTitle: "確認"
|
||||
confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?"
|
||||
|
||||
_embedCodeGen:
|
||||
title: "埋め込みコードをカスタマイズ"
|
||||
header: "ヘッダーを表示"
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class OptimizeEmojiIndex1709126576000 {
|
||||
name = 'OptimizeEmojiIndex1709126576000'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_EMOJI_ROLE_IDS" ON "emoji" using gin ("roleIdsThatCanBeUsedThisEmojiAsReaction")`)
|
||||
await queryRunner.query(`CREATE INDEX "IDX_EMOJI_CATEGORY" ON "emoji" ("category")`)
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "IDX_EMOJI_CATEGORY"`)
|
||||
await queryRunner.query(`DROP INDEX "IDX_EMOJI_ROLE_IDS"`)
|
||||
}
|
||||
}
|
|
@ -26,6 +26,18 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192;
|
|||
export const DB_MAX_IMAGE_COMMENT_LENGTH = 512;
|
||||
//#endregion
|
||||
|
||||
export const FILE_TYPE_IMAGE = [
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
'image/avif',
|
||||
'image/apng',
|
||||
'image/bmp',
|
||||
'image/tiff',
|
||||
'image/x-icon',
|
||||
];
|
||||
|
||||
// ブラウザで直接表示することを許可するファイルの種類のリスト
|
||||
// ここに含まれないものは application/octet-stream としてレスポンスされる
|
||||
// SVGはXSSを生むので許可しない
|
||||
|
|
|
@ -4,24 +4,58 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import { Brackets, In, IsNull, ObjectLiteral, SelectQueryBuilder, WhereExpressionBuilder } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiEmoji } from '@/models/Emoji.js';
|
||||
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';
|
||||
|
||||
const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/;
|
||||
|
||||
export const fetchEmojisHostTypes = [
|
||||
'local',
|
||||
'remote',
|
||||
'all',
|
||||
] as const;
|
||||
export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number];
|
||||
export const fetchEmojisSortKeys = [
|
||||
'+id',
|
||||
'-id',
|
||||
'+updatedAt',
|
||||
'-updatedAt',
|
||||
'+name',
|
||||
'-name',
|
||||
'+host',
|
||||
'-host',
|
||||
'+uri',
|
||||
'-uri',
|
||||
'+publicUrl',
|
||||
'-publicUrl',
|
||||
'+type',
|
||||
'-type',
|
||||
'+aliases',
|
||||
'-aliases',
|
||||
'+category',
|
||||
'-category',
|
||||
'+license',
|
||||
'-license',
|
||||
'+isSensitive',
|
||||
'-isSensitive',
|
||||
'+localOnly',
|
||||
'-localOnly',
|
||||
'+roleIdsThatCanBeUsedThisEmojiAsReaction',
|
||||
'-roleIdsThatCanBeUsedThisEmojiAsReaction',
|
||||
] as const;
|
||||
export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number];
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService implements OnApplicationShutdown {
|
||||
private emojisCache: MemoryKVCache<MiEmoji | null>;
|
||||
|
@ -30,10 +64,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,
|
||||
|
@ -58,7 +90,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
public async add(data: {
|
||||
driveFile: MiDriveFile;
|
||||
originalUrl: string;
|
||||
publicUrl: string;
|
||||
fileType: string;
|
||||
name: string;
|
||||
category: string | null;
|
||||
aliases: string[];
|
||||
|
@ -75,9 +109,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
category: data.category,
|
||||
host: data.host,
|
||||
aliases: data.aliases,
|
||||
originalUrl: data.driveFile.url,
|
||||
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
|
||||
type: data.driveFile.webpublicType ?? data.driveFile.type,
|
||||
originalUrl: data.originalUrl,
|
||||
publicUrl: data.publicUrl,
|
||||
type: data.fileType,
|
||||
license: data.license,
|
||||
isSensitive: data.isSensitive,
|
||||
localOnly: data.localOnly,
|
||||
|
@ -106,7 +140,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
public async update(data: (
|
||||
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
|
||||
) & {
|
||||
driveFile?: MiDriveFile;
|
||||
originalUrl?: string;
|
||||
publicUrl?: string;
|
||||
fileType?: string;
|
||||
category?: string | null;
|
||||
aliases?: string[];
|
||||
license?: string | null;
|
||||
|
@ -139,9 +175,9 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
license: data.license,
|
||||
isSensitive: data.isSensitive,
|
||||
localOnly: data.localOnly,
|
||||
originalUrl: data.driveFile != null ? data.driveFile.url : undefined,
|
||||
publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined,
|
||||
type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined,
|
||||
originalUrl: data.originalUrl,
|
||||
publicUrl: data.publicUrl,
|
||||
type: data.fileType,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined,
|
||||
});
|
||||
|
||||
|
@ -308,7 +344,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
||||
// クエリに使うホスト
|
||||
// クエリに使うホスト
|
||||
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
||||
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
||||
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
||||
|
@ -414,6 +450,176 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
return this.emojisRepository.findOneBy({ name, host: IsNull() });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchEmojis(
|
||||
params?: {
|
||||
query?: {
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
uri?: string;
|
||||
publicUrl?: string;
|
||||
type?: string;
|
||||
aliases?: string;
|
||||
category?: string;
|
||||
license?: string;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
hostType?: FetchEmojisHostTypes;
|
||||
roleIds?: string[];
|
||||
},
|
||||
sinceId?: string;
|
||||
untilId?: string;
|
||||
},
|
||||
opts?: {
|
||||
limit?: number;
|
||||
page?: number;
|
||||
sortKeys?: FetchEmojisSortKeys[]
|
||||
},
|
||||
) {
|
||||
function multipleWordsToQuery<T extends ObjectLiteral>(
|
||||
query: string,
|
||||
builder: SelectQueryBuilder<T>,
|
||||
action: (qb: WhereExpressionBuilder, idx: number, word: string) => void,
|
||||
) {
|
||||
const words = query.split(/\s/);
|
||||
builder.andWhere(new Brackets((qb => {
|
||||
for (const [idx, word] of words.entries()) {
|
||||
action(qb, idx, word);
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
const builder = this.emojisRepository.createQueryBuilder('emoji');
|
||||
if (params?.query) {
|
||||
const q = params.query;
|
||||
if (q.updatedAtFrom) {
|
||||
// noIndexScan
|
||||
builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom });
|
||||
}
|
||||
if (q.updatedAtTo) {
|
||||
// noIndexScan
|
||||
builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updateAtTo', { updateAtTo: q.updatedAtTo });
|
||||
}
|
||||
if (q.name) {
|
||||
multipleWordsToQuery(q.name, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.name LIKE :name${idx}`, Object.fromEntries([[`name${idx}`, `%${word}%`]]));
|
||||
});
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case q.hostType === 'local': {
|
||||
builder.andWhere('emoji.host IS NULL');
|
||||
break;
|
||||
}
|
||||
case q.hostType === 'remote': {
|
||||
if (q.host) {
|
||||
// noIndexScan
|
||||
multipleWordsToQuery(q.host, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.host LIKE :host${idx}`, Object.fromEntries([[`host${idx}`, `%${word}%`]]));
|
||||
});
|
||||
} else {
|
||||
builder.andWhere('emoji.host IS NOT NULL');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (q.uri) {
|
||||
// noIndexScan
|
||||
multipleWordsToQuery(q.uri, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.uri LIKE :uri${idx}`, Object.fromEntries([[`uri${idx}`, `%${word}%`]]));
|
||||
});
|
||||
}
|
||||
if (q.publicUrl) {
|
||||
// noIndexScan
|
||||
multipleWordsToQuery(q.publicUrl, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.publicUrl LIKE :publicUrl${idx}`, Object.fromEntries([[`publicUrl${idx}`, `%${word}%`]]));
|
||||
});
|
||||
}
|
||||
if (q.type) {
|
||||
// noIndexScan
|
||||
multipleWordsToQuery(q.type, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.type LIKE :type${idx}`, Object.fromEntries([[`type${idx}`, `%${word}%`]]));
|
||||
});
|
||||
}
|
||||
if (q.aliases) {
|
||||
// noIndexScan
|
||||
const subQueryBuilder = builder.subQuery()
|
||||
.select('COUNT(0)', 'count')
|
||||
.from(
|
||||
sq2 => sq2
|
||||
.select('unnest(subEmoji.aliases)', 'alias')
|
||||
.addSelect('subEmoji.id', 'id')
|
||||
.from('emoji', 'subEmoji'),
|
||||
'aliasTable',
|
||||
)
|
||||
.where('"emoji"."id" = "aliasTable"."id"');
|
||||
multipleWordsToQuery(q.aliases, subQueryBuilder, (qb, idx, word) => {
|
||||
qb.orWhere(`"aliasTable"."alias" LIKE :aliases${idx}`, Object.fromEntries([[`aliases${idx}`, `%${word}%`]]));
|
||||
});
|
||||
|
||||
builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`);
|
||||
}
|
||||
if (q.category) {
|
||||
multipleWordsToQuery(q.category, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.category LIKE :category${idx}`, Object.fromEntries([[`category${idx}`, `%${word}%`]]));
|
||||
});
|
||||
}
|
||||
if (q.license) {
|
||||
// noIndexScan
|
||||
multipleWordsToQuery(q.license, builder, (qb, idx, word) => {
|
||||
qb.orWhere(`emoji.license LIKE :license${idx}`, Object.fromEntries([[`license${idx}`, `%${word}%`]]));
|
||||
});
|
||||
}
|
||||
if (q.isSensitive != null) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive });
|
||||
}
|
||||
if (q.localOnly != null) {
|
||||
// noIndexScan
|
||||
builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly });
|
||||
}
|
||||
if (q.roleIds && q.roleIds.length > 0) {
|
||||
builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction @> :roleIds', { roleIds: q.roleIds });
|
||||
}
|
||||
}
|
||||
|
||||
if (params?.sinceId) {
|
||||
builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId });
|
||||
}
|
||||
if (params?.untilId) {
|
||||
builder.andWhere('emoji.id < :untilId', { untilId: params.untilId });
|
||||
}
|
||||
|
||||
if (opts?.sortKeys && opts.sortKeys.length > 0) {
|
||||
for (const sortKey of opts.sortKeys) {
|
||||
const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC';
|
||||
const key = sortKey.replace(/^[+-]/, '');
|
||||
builder.addOrderBy(`emoji.${key}`, direction);
|
||||
}
|
||||
} else {
|
||||
builder.addOrderBy('emoji.id', 'DESC');
|
||||
}
|
||||
|
||||
const limit = opts?.limit ?? 10;
|
||||
if (opts?.page) {
|
||||
builder.skip((opts.page - 1) * limit);
|
||||
}
|
||||
|
||||
builder.take(limit);
|
||||
|
||||
const [emojis, count] = await builder.getManyAndCount();
|
||||
|
||||
return {
|
||||
emojis,
|
||||
count: (count > limit ? emojis.length : count),
|
||||
allCount: count,
|
||||
allPages: Math.ceil(count / limit),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.emojisCache.dispose();
|
||||
|
|
|
@ -4,10 +4,10 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { EmojisRepository } from '@/models/_.js';
|
||||
import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
import type { } from '@/models/Blocking.js';
|
||||
import type { MiEmoji } from '@/models/Emoji.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
|
@ -16,6 +16,8 @@ export class EmojiEntityService {
|
|||
constructor(
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -68,8 +70,90 @@ export class EmojiEntityService {
|
|||
@bindThis
|
||||
public packDetailedMany(
|
||||
emojis: any[],
|
||||
) {
|
||||
): Promise<Packed<'EmojiDetailed'>[]> {
|
||||
return Promise.all(emojis.map(x => this.packDetailed(x)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailedAdmin(
|
||||
src: MiEmoji['id'] | MiEmoji,
|
||||
hint?: {
|
||||
roles?: Map<MiRole['id'], MiRole>
|
||||
},
|
||||
): Promise<Packed<'EmojiDetailedAdmin'>> {
|
||||
const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src });
|
||||
|
||||
const roles = Array.of<MiRole>();
|
||||
if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) {
|
||||
if (hint?.roles) {
|
||||
const hintRoles = hint.roles;
|
||||
roles.push(
|
||||
...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction
|
||||
.filter(x => hintRoles.has(x))
|
||||
.map(x => hintRoles.get(x)!),
|
||||
);
|
||||
} else {
|
||||
roles.push(
|
||||
...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }),
|
||||
);
|
||||
}
|
||||
|
||||
roles.sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) {
|
||||
return b.displayOrder - a.displayOrder;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: emoji.id,
|
||||
updatedAt: emoji.updatedAt?.toISOString() ?? null,
|
||||
name: emoji.name,
|
||||
host: emoji.host,
|
||||
uri: emoji.uri,
|
||||
type: emoji.type,
|
||||
aliases: emoji.aliases,
|
||||
category: emoji.category,
|
||||
publicUrl: emoji.publicUrl,
|
||||
originalUrl: emoji.originalUrl,
|
||||
license: emoji.license,
|
||||
localOnly: emoji.localOnly,
|
||||
isSensitive: emoji.isSensitive,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async packDetailedAdminMany(
|
||||
emojis: MiEmoji['id'][] | MiEmoji[],
|
||||
hint?: {
|
||||
roles?: Map<MiRole['id'], MiRole>
|
||||
},
|
||||
): Promise<Packed<'EmojiDetailedAdmin'>[]> {
|
||||
// IDのみの要素をピックアップし、DBからレコードを取り出して他の値を補完する
|
||||
const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[];
|
||||
const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[];
|
||||
if (emojiIdOnlyList.length > 0) {
|
||||
emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) }));
|
||||
}
|
||||
|
||||
// 特定ロール専用の絵文字である場合、そのロール情報をあらかじめまとめて取得しておく(pack側で都度取得も出来るが負荷が高いので)
|
||||
let hintRoles: Map<MiRole['id'], MiRole>;
|
||||
if (hint?.roles) {
|
||||
hintRoles = hint.roles;
|
||||
} else {
|
||||
const roles = Array.of<MiRole>();
|
||||
const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))];
|
||||
if (roleIds.length > 0) {
|
||||
roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) }));
|
||||
}
|
||||
|
||||
hintRoles = new Map(roles.map(x => [x.id, x]));
|
||||
}
|
||||
|
||||
return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles })));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js';
|
|||
import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js';
|
||||
import { packedQueueCountSchema } from '@/models/json-schema/queue.js';
|
||||
import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js';
|
||||
import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js';
|
||||
import {
|
||||
packedEmojiDetailedAdminSchema,
|
||||
packedEmojiDetailedSchema,
|
||||
packedEmojiSimpleSchema,
|
||||
} from '@/models/json-schema/emoji.js';
|
||||
import { packedFlashSchema } from '@/models/json-schema/flash.js';
|
||||
import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js';
|
||||
import { packedSigninSchema } from '@/models/json-schema/signin.js';
|
||||
|
@ -95,6 +99,7 @@ export const refs = {
|
|||
GalleryPost: packedGalleryPostSchema,
|
||||
EmojiSimple: packedEmojiSimpleSchema,
|
||||
EmojiDetailed: packedEmojiDetailedSchema,
|
||||
EmojiDetailedAdmin: packedEmojiDetailedAdminSchema,
|
||||
Flash: packedFlashSchema,
|
||||
Signin: packedSigninSchema,
|
||||
RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema,
|
||||
|
|
|
@ -104,3 +104,86 @@ export const packedEmojiDetailedSchema = {
|
|||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const packedEmojiDetailedAdminSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'id',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
host: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
description: 'The local host is represented with `null`.',
|
||||
},
|
||||
publicUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
originalUrl: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
uri: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
aliases: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
format: 'id',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
license: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
localOnly: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isSensitive: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'misskey:id',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
@ -87,6 +87,7 @@ export class ImportCustomEmojisProcessorService {
|
|||
await this.emojisRepository.delete({
|
||||
name: emojiInfo.name,
|
||||
});
|
||||
|
||||
try {
|
||||
const driveFile = await this.driveService.addFile({
|
||||
user: null,
|
||||
|
@ -95,11 +96,13 @@ export class ImportCustomEmojisProcessorService {
|
|||
force: true,
|
||||
});
|
||||
await this.customEmojiService.add({
|
||||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
fileType: driveFile.webpublicType ?? driveFile.type,
|
||||
name: emojiInfo.name,
|
||||
category: emojiInfo.category,
|
||||
host: null,
|
||||
aliases: emojiInfo.aliases,
|
||||
driveFile,
|
||||
license: emojiInfo.license,
|
||||
isSensitive: emojiInfo.isSensitive,
|
||||
localOnly: emojiInfo.localOnly,
|
||||
|
|
|
@ -48,6 +48,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al
|
|||
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
|
||||
import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
|
||||
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
|
||||
import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js';
|
||||
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
|
||||
import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
|
||||
import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js';
|
||||
|
@ -436,6 +437,7 @@ const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-ali
|
|||
const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default };
|
||||
const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default };
|
||||
const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default };
|
||||
const $admin_emoji_v2_list: Provider = { provide: 'ep:v2/admin/emoji/list', useClass: ep___v2_admin_emoji_list.default };
|
||||
const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default };
|
||||
const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default };
|
||||
const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default };
|
||||
|
@ -828,6 +830,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_emoji_setCategoryBulk,
|
||||
$admin_emoji_setLicenseBulk,
|
||||
$admin_emoji_update,
|
||||
$admin_emoji_v2_list,
|
||||
$admin_federation_deleteAllFiles,
|
||||
$admin_federation_refreshRemoteInstanceMetadata,
|
||||
$admin_federation_removeAllFollowing,
|
||||
|
@ -1214,6 +1217,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_emoji_setCategoryBulk,
|
||||
$admin_emoji_setLicenseBulk,
|
||||
$admin_emoji_update,
|
||||
$admin_emoji_v2_list,
|
||||
$admin_federation_deleteAllFiles,
|
||||
$admin_federation_refreshRemoteInstanceMetadata,
|
||||
$admin_federation_removeAllFollowing,
|
||||
|
|
|
@ -53,6 +53,7 @@ import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-al
|
|||
import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js';
|
||||
import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js';
|
||||
import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js';
|
||||
import * as ep___v2_admin_emoji_list from './endpoints/v2/admin/emoji/list.js';
|
||||
import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js';
|
||||
import * as ep___admin_federation_refreshRemoteInstanceMetadata
|
||||
from './endpoints/admin/federation/refresh-remote-instance-metadata.js';
|
||||
|
@ -440,6 +441,7 @@ const eps = [
|
|||
['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk],
|
||||
['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk],
|
||||
['admin/emoji/update', ep___admin_emoji_update],
|
||||
['v2/admin/emoji/list', ep___v2_admin_emoji_list],
|
||||
['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles],
|
||||
['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata],
|
||||
['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing],
|
||||
|
|
|
@ -9,6 +9,7 @@ import type { DriveFilesRepository } from '@/models/_.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { FILE_TYPE_IMAGE } from '@/const.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -24,6 +25,11 @@ export const meta = {
|
|||
code: 'NO_SUCH_FILE',
|
||||
id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf',
|
||||
},
|
||||
unsupportedFileType: {
|
||||
message: 'Unsupported file type.',
|
||||
code: 'UNSUPPORTED_FILE_TYPE',
|
||||
id: 'f7599d96-8750-af68-1633-9575d625c1a7',
|
||||
},
|
||||
duplicateName: {
|
||||
message: 'Duplicate name.',
|
||||
code: 'DUPLICATE_NAME',
|
||||
|
@ -47,15 +53,21 @@ export const paramDef = {
|
|||
nullable: true,
|
||||
description: 'Use `null` to reset the category.',
|
||||
},
|
||||
aliases: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
aliases: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
license: { type: 'string', nullable: true },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['name', 'fileId'],
|
||||
} as const;
|
||||
|
@ -67,9 +79,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) => {
|
||||
|
@ -77,9 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
|
||||
if (!FILE_TYPE_IMAGE.includes(driveFile.type)) throw new ApiError(meta.errors.unsupportedFileType);
|
||||
|
||||
const emoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
fileType: driveFile.webpublicType ?? driveFile.type,
|
||||
name: ps.name,
|
||||
category: ps.category ?? null,
|
||||
aliases: ps.aliases ?? [],
|
||||
|
|
|
@ -86,7 +86,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
|
||||
|
||||
const addedEmoji = await this.customEmojiService.add({
|
||||
driveFile,
|
||||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
fileType: driveFile.webpublicType ?? driveFile.type,
|
||||
name: emoji.name,
|
||||
category: emoji.category,
|
||||
aliases: emoji.aliases,
|
||||
|
|
|
@ -79,13 +79,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
// JSON schemeのanyOfの型変換がうまくいっていないらしい
|
||||
const required = { id: ps.id, name: ps.name } as
|
||||
const required = { id: ps.id, name: ps.name } as
|
||||
| { id: MiEmoji['id']; name?: string }
|
||||
| { id?: MiEmoji['id']; name: string };
|
||||
|
||||
const error = await this.customEmojiService.update({
|
||||
...required,
|
||||
driveFile,
|
||||
originalUrl: driveFile != null ? driveFile.url : undefined,
|
||||
publicUrl: driveFile != null ? (driveFile.webpublicUrl ?? driveFile.url) : undefined,
|
||||
fileType: driveFile != null ? (driveFile.webpublicType ?? driveFile.type) : undefined,
|
||||
category: ps.category,
|
||||
aliases: ps.aliases,
|
||||
license: ps.license,
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
|
||||
import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireRolePolicy: 'canManageCustomEmojis',
|
||||
kind: 'read:admin:emoji',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
emojis: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
ref: 'EmojiDetailedAdmin',
|
||||
},
|
||||
},
|
||||
count: { type: 'integer' },
|
||||
allCount: { type: 'integer' },
|
||||
allPages: { type: 'integer' },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
properties: {
|
||||
updatedAtFrom: { type: 'string' },
|
||||
updatedAtTo: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
host: { type: 'string' },
|
||||
uri: { type: 'string' },
|
||||
publicUrl: { type: 'string' },
|
||||
originalUrl: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
aliases: { type: 'string' },
|
||||
category: { type: 'string' },
|
||||
license: { type: 'string' },
|
||||
isSensitive: { type: 'boolean' },
|
||||
localOnly: { type: 'boolean' },
|
||||
hostType: {
|
||||
type: 'string',
|
||||
enum: fetchEmojisHostTypes,
|
||||
default: 'all',
|
||||
},
|
||||
roleIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
},
|
||||
},
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
page: { type: 'integer' },
|
||||
sortKeys: {
|
||||
type: 'array',
|
||||
default: ['-id'],
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: fetchEmojisSortKeys,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private customEmojiService: CustomEmojiService,
|
||||
private emojiEntityService: EmojiEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const q = ps.query;
|
||||
const result = await this.customEmojiService.fetchEmojis(
|
||||
{
|
||||
query: {
|
||||
updatedAtFrom: q?.updatedAtFrom,
|
||||
updatedAtTo: q?.updatedAtTo,
|
||||
name: q?.name,
|
||||
host: q?.host,
|
||||
uri: q?.uri,
|
||||
publicUrl: q?.publicUrl,
|
||||
type: q?.type,
|
||||
aliases: q?.aliases,
|
||||
category: q?.category,
|
||||
license: q?.license,
|
||||
isSensitive: q?.isSensitive,
|
||||
localOnly: q?.localOnly,
|
||||
hostType: q?.hostType,
|
||||
roleIds: q?.roleIds,
|
||||
},
|
||||
sinceId: ps.sinceId,
|
||||
untilId: ps.untilId,
|
||||
},
|
||||
{
|
||||
limit: ps.limit,
|
||||
page: ps.page,
|
||||
sortKeys: ps.sortKeys,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis),
|
||||
count: result.count,
|
||||
allCount: result.allCount,
|
||||
allPages: result.allPages,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import seedrandom from 'seedrandom';
|
||||
|
||||
/**
|
||||
* AIで生成した無作為なファーストネーム
|
||||
*/
|
||||
export const firstNameDict = [
|
||||
'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella',
|
||||
'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan',
|
||||
'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily',
|
||||
]
|
||||
|
||||
/**
|
||||
* AIで生成した無作為なラストネーム
|
||||
*/
|
||||
export const lastNameDict = [
|
||||
'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown',
|
||||
'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson',
|
||||
'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper',
|
||||
]
|
||||
|
||||
/**
|
||||
* AIで生成した無作為な国名
|
||||
*/
|
||||
export const countryDict = [
|
||||
'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India',
|
||||
'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand',
|
||||
'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru',
|
||||
]
|
||||
|
||||
export function text(length: number = 10, seed?: string): string {
|
||||
let result = "";
|
||||
|
||||
// シード値を使う場合、同じ数値が羅列されるが、ランダム文字列という意味では満たせていると思うのでこのまま使っておく
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
while (result.length < length) {
|
||||
result += rand.toString(36).substring(2);
|
||||
}
|
||||
|
||||
return result.substring(0, length);
|
||||
}
|
||||
|
||||
export function integer(min: number = 0, max: number = 9999, seed?: string): number {
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
return Math.floor(rand * (max - min)) + min;
|
||||
}
|
||||
|
||||
export function date(params?: {
|
||||
yearMin?: number,
|
||||
yearMax?: number,
|
||||
monthMin?: number,
|
||||
monthMax?: number,
|
||||
dayMin?: number,
|
||||
dayMax?: number,
|
||||
hourMin?: number,
|
||||
hourMax?: number,
|
||||
minuteMin?: number,
|
||||
minuteMax?: number,
|
||||
secondMin?: number,
|
||||
secondMax?: number,
|
||||
millisecondMin?: number,
|
||||
millisecondMax?: number,
|
||||
}, seed?: string): Date {
|
||||
const year = integer(params?.yearMin ?? 1970, params?.yearMax ?? (new Date()).getFullYear(), seed);
|
||||
const month = integer(params?.monthMin ?? 1, params?.monthMax ?? 12, seed);
|
||||
let day = integer(params?.dayMin ?? 1, params?.dayMax ?? 31, seed);
|
||||
if (month === 2) {
|
||||
day = Math.min(day, 28);
|
||||
} else if ([4, 6, 9, 11].includes(month)) {
|
||||
day = Math.min(day, 30);
|
||||
} else {
|
||||
day = Math.min(day, 31);
|
||||
}
|
||||
|
||||
const hour = integer(params?.hourMin ?? 0, params?.hourMax ?? 23, seed);
|
||||
const minute = integer(params?.minuteMin ?? 0, params?.minuteMax ?? 59, seed);
|
||||
const second = integer(params?.secondMin ?? 0, params?.secondMax ?? 59, seed);
|
||||
const millisecond = integer(params?.millisecondMin ?? 0, params?.millisecondMax ?? 999, seed);
|
||||
|
||||
return new Date(year, month - 1, day, hour, minute, second, millisecond);
|
||||
}
|
||||
|
||||
export function boolean(seed?: string): boolean {
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
return rand < 0.5;
|
||||
}
|
||||
|
||||
export function choose<T>(array: T[], seed?: string): T {
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
return array[Math.floor(rand * array.length)];
|
||||
}
|
||||
|
||||
export function firstName(seed?: string): string {
|
||||
return choose(firstNameDict, seed);
|
||||
}
|
||||
|
||||
export function lastName(seed?: string): string {
|
||||
return choose(lastNameDict, seed);
|
||||
}
|
||||
|
||||
export function country(seed?: string): string {
|
||||
return choose(countryDict, seed);
|
||||
}
|
||||
|
||||
const TIME2000 = 946684800000;
|
||||
export function fakeId(seed?: string): string {
|
||||
let time = new Date().getTime();
|
||||
|
||||
time = time - TIME2000;
|
||||
if (time < 0) time = 0;
|
||||
|
||||
const timeStr = time.toString(36).padStart(8, '0');
|
||||
const noiseStr = text(2, seed);
|
||||
|
||||
return timeStr + noiseStr;
|
||||
}
|
||||
|
||||
export function imageDataUrl(options?: {
|
||||
size?: {
|
||||
width?: number,
|
||||
height?: number,
|
||||
},
|
||||
color?: {
|
||||
red?: number,
|
||||
green?: number,
|
||||
blue?: number,
|
||||
alpha?: number,
|
||||
}
|
||||
}, seed?: string): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = options?.size?.width ?? 100;
|
||||
canvas.height = options?.size?.height ?? 100;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get 2d context');
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
|
||||
const red = options?.color?.red ?? integer(0, 255, seed);
|
||||
const green = options?.color?.green ?? integer(0, 255, seed);
|
||||
const blue = options?.color?.blue ?? integer(0, 255, seed);
|
||||
const alpha = options?.color?.alpha ?? 1;
|
||||
ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true);
|
||||
ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
ctx.fill();
|
||||
|
||||
return canvas.toDataURL('image/png', 1.0);
|
||||
}
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
|
||||
import type { entities } from 'misskey-js'
|
||||
import { date, imageDataUrl, text } from "./fake-utils.js";
|
||||
|
||||
export function abuseUserReport() {
|
||||
return {
|
||||
|
@ -301,3 +302,93 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa
|
|||
used: isUsed,
|
||||
}
|
||||
}
|
||||
|
||||
export function role(params: {
|
||||
id?: string,
|
||||
name?: string,
|
||||
color?: string | null,
|
||||
iconUrl?: string | null,
|
||||
description?: string,
|
||||
isModerator?: boolean,
|
||||
isAdministrator?: boolean,
|
||||
displayOrder?: number,
|
||||
createdAt?: string,
|
||||
updatedAt?: string,
|
||||
target?: 'manual' | 'conditional',
|
||||
isPublic?: boolean,
|
||||
isExplorable?: boolean,
|
||||
asBadge?: boolean,
|
||||
canEditMembersByModerator?: boolean,
|
||||
usersCount?: number,
|
||||
}, seed?: string): entities.Role {
|
||||
const prefix = params.displayOrder ? params.displayOrder.toString().padStart(3, '0') + '-' : '';
|
||||
const genId = text(36, seed);
|
||||
const createdAt = params.createdAt ?? date({}, seed).toISOString();
|
||||
const updatedAt = params.updatedAt ?? date({}, seed).toISOString();
|
||||
|
||||
return {
|
||||
id: params.id ?? genId,
|
||||
name: params.name ?? `${prefix}TestRole-${genId}`,
|
||||
color: params.color ?? '#445566',
|
||||
iconUrl: params.iconUrl ?? null,
|
||||
description: params.description ?? '',
|
||||
isModerator: params.isModerator ?? false,
|
||||
isAdministrator: params.isAdministrator ?? false,
|
||||
displayOrder: params.displayOrder ?? 0,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
target: params.target ?? 'manual',
|
||||
isPublic: params.isPublic ?? true,
|
||||
isExplorable: params.isExplorable ?? true,
|
||||
asBadge: params.asBadge ?? true,
|
||||
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
|
||||
usersCount: params.usersCount ?? 10,
|
||||
condFormula: {
|
||||
id: '',
|
||||
type: 'or',
|
||||
values: []
|
||||
},
|
||||
policies: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function emoji(params?: {
|
||||
id?: string,
|
||||
name?: string,
|
||||
host?: string,
|
||||
uri?: string,
|
||||
publicUrl?: string,
|
||||
originalUrl?: string,
|
||||
type?: string,
|
||||
aliases?: string[],
|
||||
category?: string,
|
||||
license?: string,
|
||||
isSensitive?: boolean,
|
||||
localOnly?: boolean,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[],
|
||||
updatedAt?: string,
|
||||
}, seed?: string): entities.EmojiDetailedAdmin {
|
||||
const _seed = seed ?? (params?.id ?? "DEFAULT_SEED");
|
||||
const id = params?.id ?? text(32, _seed);
|
||||
const name = params?.name ?? text(8, _seed);
|
||||
const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString();
|
||||
|
||||
const image = imageDataUrl({}, _seed)
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
host: params?.host ?? null,
|
||||
uri: params?.uri ?? null,
|
||||
publicUrl: params?.publicUrl ?? image,
|
||||
originalUrl: params?.originalUrl ?? image,
|
||||
type: params?.type ?? 'image/png',
|
||||
aliases: params?.aliases ?? [`alias1-${name}`, `alias2-${name}`],
|
||||
category: params?.category ?? null,
|
||||
license: params?.license ?? null,
|
||||
isSensitive: params?.isSensitive ?? false,
|
||||
localOnly: params?.localOnly ?? false,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
|
||||
updatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -416,6 +416,10 @@ function toStories(component: string): Promise<string> {
|
|||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/components/MkInstanceCardMini.vue'),
|
||||
glob('src/components/MkInviteCode.vue'),
|
||||
glob('src/components/MkTagItem.vue'),
|
||||
glob('src/components/MkRoleSelectDialog.vue'),
|
||||
glob('src/components/grid/MkGrid.vue'),
|
||||
glob('src/pages/admin/custom-emojis-manager2.vue'),
|
||||
glob('src/pages/admin/overview.ap-requests.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
glob('src/pages/search.vue'),
|
||||
|
|
|
@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div v-else>
|
||||
|
@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{
|
|||
defaultOpen?: boolean;
|
||||
maxHeight?: number | null;
|
||||
withSpacer?: boolean;
|
||||
spacerMin?: number;
|
||||
spacerMax?: number;
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
maxHeight: null,
|
||||
withSpacer: true,
|
||||
spacerMin: 14,
|
||||
spacerMax: 22,
|
||||
});
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
|
|
|
@ -288,20 +288,23 @@ const align = () => {
|
|||
const onOpened = () => {
|
||||
emit('opened');
|
||||
|
||||
// NOTE: Chromatic テストの際に undefined になる場合がある
|
||||
if (content.value == null) return;
|
||||
// contentの子要素にアクセスするためレンダリングの完了を待つ必要がある(nextTickが必要)
|
||||
nextTick(() => {
|
||||
// NOTE: Chromatic テストの際に undefined になる場合がある
|
||||
if (content.value == null) return;
|
||||
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const el = content.value.children[0];
|
||||
el.addEventListener('mousedown', ev => {
|
||||
contentClicking = true;
|
||||
window.addEventListener('mouseup', ev => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
window.setTimeout(() => {
|
||||
contentClicking = false;
|
||||
}, 100);
|
||||
}, { passive: true, once: true });
|
||||
}, { passive: true });
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const el = content.value.children[0];
|
||||
el.addEventListener('mousedown', ev => {
|
||||
contentClicking = true;
|
||||
window.addEventListener('mouseup', ev => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
window.setTimeout(() => {
|
||||
contentClicking = false;
|
||||
}, 100);
|
||||
}, { passive: true, once: true });
|
||||
}, { passive: true });
|
||||
});
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
|
|
|
@ -0,0 +1,124 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkButton primary :disabled="min === current" @click="onToPrevButtonClicked"><</MkButton>
|
||||
|
||||
<div :class="$style.buttons">
|
||||
<div v-if="prevDotVisible" :class="$style.headTailButtons">
|
||||
<MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton>
|
||||
<span class="ti ti-dots"/>
|
||||
</div>
|
||||
|
||||
<MkButton
|
||||
v-for="i in buttonRanges" :key="i"
|
||||
:disabled="current === i"
|
||||
@click="onNumberButtonClicked(i)"
|
||||
>
|
||||
{{ i }}
|
||||
</MkButton>
|
||||
|
||||
<div v-if="nextDotVisible" :class="$style.headTailButtons">
|
||||
<span class="ti ti-dots"/>
|
||||
<MkButton @click="onToTailButtonClicked">{{ max }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkButton primary :disabled="max === current" @click="onToNextButtonClicked">></MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, toRefs } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const min = 1;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'pageChanged', pageNumber: number): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
current: number;
|
||||
max: number;
|
||||
buttonCount: number;
|
||||
}>();
|
||||
|
||||
const { current, max } = toRefs(props);
|
||||
|
||||
const buttonCount = computed(() => Math.min(max.value, props.buttonCount));
|
||||
const buttonCountHalf = computed(() => Math.floor(buttonCount.value / 2));
|
||||
const buttonCountStart = computed(() => Math.min(Math.max(min, current.value - buttonCountHalf.value), max.value - buttonCount.value + 1));
|
||||
const buttonRanges = computed(() => Array.from({ length: buttonCount.value }, (_, i) => buttonCountStart.value + i));
|
||||
|
||||
const prevDotVisible = computed(() => (current.value - 1 > buttonCountHalf.value) && (max.value > buttonCount.value));
|
||||
const nextDotVisible = computed(() => (current.value < max.value - buttonCountHalf.value) && (max.value > buttonCount.value));
|
||||
|
||||
if (_DEV_) {
|
||||
console.log('[MkPagingButtons]', current.value, max.value, buttonCount.value, buttonCountHalf.value);
|
||||
console.log('[MkPagingButtons]', current.value < max.value - buttonCountHalf.value);
|
||||
console.log('[MkPagingButtons]', max.value > buttonCount.value);
|
||||
}
|
||||
|
||||
function onNumberButtonClicked(pageNumber: number) {
|
||||
emit('pageChanged', pageNumber);
|
||||
}
|
||||
|
||||
function onToHeadButtonClicked() {
|
||||
emit('pageChanged', min);
|
||||
}
|
||||
|
||||
function onToPrevButtonClicked() {
|
||||
const newPageNumber = current.value <= min ? min : current.value - 1;
|
||||
emit('pageChanged', newPageNumber);
|
||||
}
|
||||
|
||||
function onToNextButtonClicked() {
|
||||
const newPageNumber = current.value >= max.value ? max.value : current.value + 1;
|
||||
emit('pageChanged', newPageNumber);
|
||||
}
|
||||
|
||||
function onToTailButtonClicked() {
|
||||
emit('pageChanged', max.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
button {
|
||||
border-radius: 9999px;
|
||||
min-width: 2.5em;
|
||||
min-height: 2.5em;
|
||||
max-width: 2.5em;
|
||||
max-height: 2.5em;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headTailButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { role } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue';
|
||||
|
||||
const roles = [
|
||||
role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'),
|
||||
role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'),
|
||||
role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'),
|
||||
role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'),
|
||||
];
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkRoleSelectDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkRoleSelectDialog v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
initialRoleIds: undefined,
|
||||
infoMessage: undefined,
|
||||
title: undefined,
|
||||
publicOnly: true,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/admin/roles/list', ({ params }) => {
|
||||
return HttpResponse.json(roles);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [() => ({
|
||||
template: '<div style="width:100cqmin"><story/></div>',
|
||||
})],
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const InitialIds = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const InfoMessage = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
infoMessage: 'This is a message.',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const Title = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
title: 'Select roles',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const Full = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialRoleIds: roles.map(it => it.id),
|
||||
infoMessage: InfoMessage.args.infoMessage,
|
||||
title: Title.args.title,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const FullWithPrivate = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialRoleIds: roles.map(it => it.id),
|
||||
infoMessage: InfoMessage.args.infoMessage,
|
||||
title: Title.args.title,
|
||||
publicOnly: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
|
@ -0,0 +1,201 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="windowEl"
|
||||
:withOkButton="false"
|
||||
:okButtonDisabled="false"
|
||||
:width="400"
|
||||
:height="500"
|
||||
@close="onCloseModalWindow"
|
||||
@closed="console.log('MkRoleSelectDialog: closed') ; $emit('dispose')"
|
||||
>
|
||||
<template #header>{{ title }}</template>
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else class="_gaps" :class="$style.root">
|
||||
<div :class="$style.header">
|
||||
<MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRoles.length > 0" class="_gaps" :class="$style.roleItemArea">
|
||||
<div v-for="role in selectedRoles" :key="role.id" :class="$style.roleItem">
|
||||
<MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
|
||||
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnAssign" @click="removeRole(role.id)"><i class="ti ti-x"></i></button>
|
||||
<button v-else class="_button" :class="$style.roleUnAssign" disabled><i class="ti ti-ban"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.roleItemArea" style="text-align: center">
|
||||
{{ i18n.ts._roleSelectDialog.notSelected }}
|
||||
</div>
|
||||
|
||||
<MkInfo v-if="infoMessage">{{ infoMessage }}</MkInfo>
|
||||
|
||||
<div :class="$style.buttons">
|
||||
<MkButton primary @click="onOkClicked">{{ i18n.ts.ok }}</MkButton>
|
||||
<MkButton @click="onCancelClicked">{{ i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineProps, ref, toRefs } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkSpacer from '@/components/global/MkSpacer.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', value: Misskey.entities.Role[]),
|
||||
(ev: 'close'),
|
||||
(ev: 'dispose'),
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
initialRoleIds?: string[],
|
||||
infoMessage?: string,
|
||||
title?: string,
|
||||
publicOnly: boolean,
|
||||
}>(), {
|
||||
initialRoleIds: undefined,
|
||||
infoMessage: undefined,
|
||||
title: undefined,
|
||||
publicOnly: true,
|
||||
});
|
||||
|
||||
const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props);
|
||||
|
||||
const windowEl = ref<InstanceType<typeof MkModalWindow>>();
|
||||
const roles = ref<Misskey.entities.Role[]>([]);
|
||||
const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []);
|
||||
const fetching = ref(false);
|
||||
|
||||
const selectedRoles = computed(() => {
|
||||
const r = roles.value.filter(role => selectedRoleIds.value.includes(role.id));
|
||||
r.sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) {
|
||||
return b.displayOrder - a.displayOrder;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return r;
|
||||
});
|
||||
|
||||
async function fetchRoles() {
|
||||
fetching.value = true;
|
||||
const result = await misskeyApi('admin/roles/list', {});
|
||||
roles.value = result.filter(it => publicOnly.value ? it.isPublic : true);
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
async function addRole() {
|
||||
const items = roles.value
|
||||
.filter(r => r.isPublic)
|
||||
.filter(r => !selectedRoleIds.value.includes(r.id))
|
||||
.map(r => ({ text: r.name, value: r }));
|
||||
|
||||
const { canceled, result: role } = await os.select({ items });
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedRoleIds.value.push(role.id);
|
||||
}
|
||||
|
||||
async function removeRole(roleId: string) {
|
||||
selectedRoleIds.value = selectedRoleIds.value.filter(x => x !== roleId);
|
||||
}
|
||||
|
||||
function onOkClicked() {
|
||||
emit('done', selectedRoles.value);
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
function onCancelClicked() {
|
||||
emit('close');
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
function onCloseModalWindow() {
|
||||
emit('close');
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
max-height: 410px;
|
||||
height: 410px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.roleItemArea {
|
||||
background-color: var(--MI_THEME-acrylicBg);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.roleItem {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.role {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roleUnAssign {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 8px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.addRoleButton {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
max-width: 32px;
|
||||
max-height: 32px;
|
||||
margin-left: 8px;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type SortOrderDirection = '+' | '-'
|
||||
|
||||
export type SortOrder<T extends string> = {
|
||||
key: T;
|
||||
direction: SortOrderDirection;
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.sortOrderArea">
|
||||
<div :class="$style.sortOrderAreaTags">
|
||||
<MkTagItem
|
||||
v-for="order in currentOrders"
|
||||
:key="order.key"
|
||||
:iconClass="order.direction === '+' ? 'ti ti-arrow-up' : 'ti ti-arrow-down'"
|
||||
:exButtonIconClass="'ti ti-x'"
|
||||
:content="order.key"
|
||||
@click="onToggleSortOrderButtonClicked(order)"
|
||||
@exButtonClick="onRemoveSortOrderButtonClicked(order)"
|
||||
/>
|
||||
</div>
|
||||
<MkButton :class="$style.sortOrderAddButton" @click="onAddSortOrderButtonClicked">
|
||||
<span class="ti ti-plus"/>
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
import { toRefs } from 'vue';
|
||||
import MkTagItem from '@/components/MkTagItem.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update', sortOrders: SortOrder<T>[]): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
baseOrderKeyNames: T[];
|
||||
currentOrders: SortOrder<T>[];
|
||||
}>();
|
||||
|
||||
const { currentOrders } = toRefs(props);
|
||||
|
||||
function onToggleSortOrderButtonClicked(order: SortOrder<T>) {
|
||||
switch (order.direction) {
|
||||
case '+':
|
||||
order.direction = '-';
|
||||
break;
|
||||
case '-':
|
||||
order.direction = '+';
|
||||
break;
|
||||
}
|
||||
|
||||
emitOrder(currentOrders.value);
|
||||
}
|
||||
|
||||
function onAddSortOrderButtonClicked(ev: MouseEvent) {
|
||||
const menuItems: MenuItem[] = props.baseOrderKeyNames
|
||||
.filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey))
|
||||
.map(it => {
|
||||
return {
|
||||
text: it,
|
||||
action: () => {
|
||||
emitOrder([...currentOrders.value, { key: it, direction: '+' }]);
|
||||
},
|
||||
};
|
||||
});
|
||||
os.contextMenu(menuItems, ev);
|
||||
}
|
||||
|
||||
function onRemoveSortOrderButtonClicked(order: SortOrder<T>) {
|
||||
emitOrder(currentOrders.value.filter(it => it.key !== order.key));
|
||||
}
|
||||
|
||||
function emitOrder(sortOrders: SortOrder<T>[]) {
|
||||
emit('update', sortOrders);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.sortOrderArea {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sortOrderAreaTags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sortOrderAddButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-width: 2.0em;
|
||||
min-height: 2.0em;
|
||||
max-width: 2.0em;
|
||||
max-height: 2.0em;
|
||||
padding: 8px;
|
||||
margin-left: auto;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
</style>
|
|
@ -56,6 +56,7 @@ const toggle = () => {
|
|||
display: flex;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
> .button {
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkTagItem from './MkTagItem.vue';
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkTagItem: MkTagItem,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
click: action('click'),
|
||||
exButtonClick: action('exButtonClick'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkTagItem v-bind="props" v-on="events"></MkTagItem>',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
content: 'name',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
|
||||
export const Icon = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
iconClass: 'ti ti-arrow-up',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
|
||||
export const ExButton = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
exButtonIconClass: 'ti ti-x',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
|
||||
export const IconExButton = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
iconClass: 'ti ti-arrow-up',
|
||||
exButtonIconClass: 'ti ti-x',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
|
@ -0,0 +1,76 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" @click="(ev) => emit('click', ev)">
|
||||
<span v-if="iconClass" :class="[$style.icon, iconClass]"/>
|
||||
<span :class="$style.content">{{ content }}</span>
|
||||
<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
|
||||
<span :class="[$style.exButtonIcon, exButtonIconClass]"/>
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'click', payload: MouseEvent): void;
|
||||
(ev: 'exButtonClick', payload: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
iconClass?: string;
|
||||
content: string;
|
||||
exButtonIconClass?: string
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$buttonSize : 1.8em;
|
||||
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding: 4px 6px;
|
||||
gap: 3px;
|
||||
|
||||
background-color: var(--MI_THEME-buttonBg);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-buttonHoverBg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.70em;
|
||||
}
|
||||
|
||||
.exButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
max-height: $buttonSize;
|
||||
max-width: $buttonSize;
|
||||
min-height: $buttonSize;
|
||||
min-width: $buttonSize;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.exButtonIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.80em;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,35 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')">
|
||||
<div :class="$style.root">
|
||||
{{ content }}
|
||||
</div>
|
||||
</MkTooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkTooltip from '@/components/MkTooltip.vue';
|
||||
|
||||
defineProps<{
|
||||
showing: boolean;
|
||||
content: string;
|
||||
targetElement: HTMLElement;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
font-size: 0.9em;
|
||||
text-align: left;
|
||||
text-wrap: normal;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,391 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="cell.row.using"
|
||||
ref="rootEl"
|
||||
class="mk_grid_td"
|
||||
:class="$style.cell"
|
||||
:style="{ maxWidth: cellWidth, minWidth: cellWidth }"
|
||||
:tabindex="-1"
|
||||
data-grid-cell
|
||||
:data-grid-cell-row="cell.row.index"
|
||||
:data-grid-cell-col="cell.column.index"
|
||||
@keydown="onCellKeyDown"
|
||||
@dblclick.prevent="onCellDoubleClick"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
$style.root,
|
||||
[(cell.violation.valid || cell.selected) ? {} : $style.error],
|
||||
[cell.selected ? $style.selected : {}],
|
||||
// 行が選択されているときは範囲選択色の適用を行側に任せる
|
||||
[(cell.ranged && !cell.row.ranged) ? $style.ranged : {}],
|
||||
[needsContentCentering ? $style.center : {}],
|
||||
]"
|
||||
>
|
||||
<div v-if="!editing" :class="$style.contentArea">
|
||||
<div ref="contentAreaEl" :class="$style.content">
|
||||
<div v-if="cellType === 'text'">
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-if="cellType === 'number'">
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-if="cellType === 'date'">
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-else-if="cellType === 'boolean'">
|
||||
<span v-if="cell.value === true" class="ti ti-check"/>
|
||||
<span v-else class="ti"/>
|
||||
</div>
|
||||
<div v-else-if="cellType === 'image'">
|
||||
<img
|
||||
:src="cell.value as string"
|
||||
:alt="cell.value as string"
|
||||
:class="$style.viewImage"
|
||||
@load="emitContentSizeChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="inputAreaEl" :class="$style.inputArea">
|
||||
<input
|
||||
v-if="cellType === 'text'"
|
||||
type="text"
|
||||
:class="$style.editingInput"
|
||||
:value="editingValue"
|
||||
@input="onInputText"
|
||||
@mousedown.stop
|
||||
@contextmenu.stop
|
||||
/>
|
||||
<input
|
||||
v-if="cellType === 'number'"
|
||||
type="number"
|
||||
:class="$style.editingInput"
|
||||
:value="editingValue"
|
||||
@input="onInputText"
|
||||
@mousedown.stop
|
||||
@contextmenu.stop
|
||||
/>
|
||||
<input
|
||||
v-if="cellType === 'date'"
|
||||
type="date"
|
||||
:class="$style.editingInput"
|
||||
:value="editingValue"
|
||||
@input="onInputText"
|
||||
@mousedown.stop
|
||||
@contextmenu.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
|
||||
import { GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
cell: GridCell,
|
||||
rowSetting: GridRowSetting,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
|
||||
const { cell, bus } = toRefs(props);
|
||||
|
||||
const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
|
||||
const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
|
||||
const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
|
||||
|
||||
/** 値が編集中かどうか */
|
||||
const editing = ref<boolean>(false);
|
||||
/** 編集中の値. {@link beginEditing}と{@link endEditing}内、および各inputタグやそのコールバックからの操作のみを想定する */
|
||||
const editingValue = ref<CellValue>(undefined);
|
||||
|
||||
const cellWidth = computed(() => cell.value.column.width);
|
||||
const cellType = computed(() => cell.value.column.setting.type);
|
||||
const needsContentCentering = computed(() => {
|
||||
switch (cellType.value) {
|
||||
case 'boolean':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => [cell.value.value], () => {
|
||||
// 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
|
||||
nextTick(emitContentSizeChanged);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => cell.value.selected, () => {
|
||||
if (cell.value.selected) {
|
||||
requestFocus();
|
||||
}
|
||||
});
|
||||
|
||||
function onCellDoubleClick(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'dblclick': {
|
||||
beginEditing(ev.target as HTMLElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideMouseDown(ev: MouseEvent) {
|
||||
const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target);
|
||||
if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) {
|
||||
endEditing(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
function onCellKeyDown(ev: KeyboardEvent) {
|
||||
if (!editing.value) {
|
||||
ev.preventDefault();
|
||||
switch (ev.code) {
|
||||
case 'NumpadEnter':
|
||||
case 'Enter':
|
||||
case 'F2': {
|
||||
beginEditing(ev.target as HTMLElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (ev.code) {
|
||||
case 'Escape': {
|
||||
endEditing(false, true);
|
||||
break;
|
||||
}
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
if (!ev.isComposing) {
|
||||
endEditing(true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInputText(ev: Event) {
|
||||
editingValue.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
function onForceRefreshContentSize() {
|
||||
emitContentSizeChanged();
|
||||
}
|
||||
|
||||
function registerOutsideMouseDown() {
|
||||
unregisterOutsideMouseDown();
|
||||
addEventListener('mousedown', onOutsideMouseDown);
|
||||
}
|
||||
|
||||
function unregisterOutsideMouseDown() {
|
||||
removeEventListener('mousedown', onOutsideMouseDown);
|
||||
}
|
||||
|
||||
async function beginEditing(target: HTMLElement) {
|
||||
if (editing.value || !cell.value.selected || !cell.value.column.setting.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cell.value.column.setting.customValueEditor) {
|
||||
emit('operation:beginEdit', cell.value);
|
||||
const newValue = await cell.value.column.setting.customValueEditor(
|
||||
cell.value.row,
|
||||
cell.value.column,
|
||||
cell.value.value,
|
||||
target,
|
||||
);
|
||||
emit('operation:endEdit', cell.value);
|
||||
|
||||
if (newValue !== cell.value.value) {
|
||||
emitValueChange(newValue);
|
||||
}
|
||||
|
||||
requestFocus();
|
||||
} else {
|
||||
switch (cellType.value) {
|
||||
case 'number':
|
||||
case 'date':
|
||||
case 'text': {
|
||||
editingValue.value = cell.value.value;
|
||||
editing.value = true;
|
||||
registerOutsideMouseDown();
|
||||
emit('operation:beginEdit', cell.value);
|
||||
|
||||
await nextTick(() => {
|
||||
// inputの展開後にフォーカスを当てたい
|
||||
if (inputAreaEl.value) {
|
||||
(inputAreaEl.value.querySelector('*') as HTMLElement).focus();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
// とくに特殊なUIは設けず、トグルするだけ
|
||||
emitValueChange(!cell.value.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function endEditing(applyValue: boolean, requireFocus: boolean) {
|
||||
if (!editing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = editingValue.value;
|
||||
editingValue.value = undefined;
|
||||
|
||||
emit('operation:endEdit', cell.value);
|
||||
unregisterOutsideMouseDown();
|
||||
|
||||
if (applyValue && newValue !== cell.value.value) {
|
||||
emitValueChange(newValue);
|
||||
}
|
||||
|
||||
editing.value = false;
|
||||
|
||||
if (requireFocus) {
|
||||
requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
function requestFocus() {
|
||||
nextTick(() => {
|
||||
rootEl.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function emitValueChange(newValue: CellValue) {
|
||||
const _cell = cell.value;
|
||||
emit('change:value', _cell, newValue);
|
||||
}
|
||||
|
||||
function emitContentSizeChanged() {
|
||||
emit('change:contentSize', cell.value, {
|
||||
width: contentAreaEl.value?.clientWidth ?? 0,
|
||||
height: contentAreaEl.value?.clientHeight ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
useTooltip(rootEl, (showing) => {
|
||||
if (cell.value.violation.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n');
|
||||
const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), {
|
||||
showing,
|
||||
content,
|
||||
targetElement: rootEl.value!,
|
||||
}, {
|
||||
closed: () => {
|
||||
result.dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$cellHeight: 28px;
|
||||
|
||||
.cell {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
height: $cellHeight;
|
||||
max-height: $cellHeight;
|
||||
min-height: $cellHeight;
|
||||
cursor: cell;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
|
||||
// selected適用時に中身がズレてしまうので、透明の線をあらかじめ引いておきたい
|
||||
border: solid 0.5px transparent;
|
||||
|
||||
&.selected {
|
||||
border: solid 0.5px var(--MI_THEME-accentLighten);
|
||||
}
|
||||
|
||||
&.ranged {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: solid 0.5px var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
|
||||
.contentArea, .inputArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.viewImage {
|
||||
width: auto;
|
||||
max-height: $cellHeight;
|
||||
height: $cellHeight;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.editingInput {
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: $cellHeight - 2;
|
||||
max-height: $cellHeight - 2;
|
||||
height: $cellHeight - 2;
|
||||
outline: none;
|
||||
border: none;
|
||||
font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mk_grid_tr"
|
||||
:class="[
|
||||
$style.row,
|
||||
row.ranged ? $style.ranged : {},
|
||||
...(row.additionalStyles ?? []).map(it => it.className ?? {}),
|
||||
]"
|
||||
:style="[
|
||||
...(row.additionalStyles ?? []).map(it => it.style ?? {}),
|
||||
]"
|
||||
:data-grid-row="row.index"
|
||||
>
|
||||
<MkNumberCell
|
||||
v-if="setting.showNumber"
|
||||
:content="(row.index + 1).toString()"
|
||||
:row="row"
|
||||
/>
|
||||
<MkDataCell
|
||||
v-for="cell in cells"
|
||||
:key="cell.address.col"
|
||||
:vIf="cell.column.setting.type !== 'hidden'"
|
||||
:cell="cell"
|
||||
:rowSetting="setting"
|
||||
:bus="bus"
|
||||
@operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
|
||||
@operation:endEdit="(sender) => emit('operation:endEdit', sender)"
|
||||
@change:value="(sender, newValue) => emit('change:value', sender, newValue)"
|
||||
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import MkDataCell from '@/components/grid/MkDataCell.vue';
|
||||
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridRow, GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||
}>();
|
||||
defineProps<{
|
||||
row: GridRow,
|
||||
cells: GridCell[],
|
||||
setting: GridRowSetting,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
|
||||
&.ranged {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,223 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js';
|
||||
import MkGrid from './MkGrid.vue';
|
||||
import { GridContext, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import { DataSource, GridSetting } from '@/components/grid/grid.js';
|
||||
import { GridColumnSetting } from '@/components/grid/column.js';
|
||||
|
||||
function d(p: {
|
||||
check?: boolean,
|
||||
name?: string,
|
||||
email?: string,
|
||||
age?: number,
|
||||
birthday?: string,
|
||||
gender?: string,
|
||||
country?: string,
|
||||
reportCount?: number,
|
||||
createdAt?: string,
|
||||
}, seed: string) {
|
||||
const prefix = text(10, seed);
|
||||
|
||||
return {
|
||||
check: p.check ?? boolean(seed),
|
||||
name: p.name ?? `${firstName(seed)} ${lastName(seed)}`,
|
||||
email: p.email ?? `${prefix}@example.com`,
|
||||
age: p.age ?? integer(20, 80, seed),
|
||||
birthday: date({}, seed).toISOString(),
|
||||
gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed),
|
||||
country: p.country ?? country(seed),
|
||||
reportCount: p.reportCount ?? integer(0, 9999, seed),
|
||||
createdAt: p.createdAt ?? date({}, seed).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultCols: GridColumnSetting[] = [
|
||||
{ bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 },
|
||||
{ bindTo: 'name', title: 'Name', type: 'text', width: 'auto' },
|
||||
{ bindTo: 'email', title: 'Email', type: 'text', width: 'auto' },
|
||||
{ bindTo: 'age', title: 'Age', type: 'number', width: 50 },
|
||||
{ bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' },
|
||||
{ bindTo: 'gender', title: 'Gender', type: 'text', width: 80 },
|
||||
{ bindTo: 'country', title: 'Country', type: 'text', width: 120 },
|
||||
{ bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' },
|
||||
{ bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' },
|
||||
];
|
||||
|
||||
function createArgs(overrides?: { settings?: Partial<GridSetting>, data?: DataSource[] }) {
|
||||
const refData = ref<ReturnType<typeof d>[]>([]);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
refData.value.push(d({}, i.toString()));
|
||||
}
|
||||
|
||||
return {
|
||||
settings: {
|
||||
row: overrides?.settings?.row,
|
||||
cols: [
|
||||
...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true),
|
||||
...overrides?.settings?.cols ?? [],
|
||||
],
|
||||
cells: overrides?.settings?.cells,
|
||||
},
|
||||
data: refData.value,
|
||||
};
|
||||
}
|
||||
|
||||
function createRender(params: { settings: GridSetting, data: DataSource[] }) {
|
||||
return {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkGrid,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
data: args.data,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
event: (event: GridEvent, context: GridContext) => {
|
||||
switch (event.type) {
|
||||
case 'cell-value-change': {
|
||||
args.data[event.row.index][event.column.setting.bindTo] = event.newValue;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<div style="padding:20px"><MkGrid v-bind="props" v-on="events" /></div>',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
...params,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGrid>;
|
||||
}
|
||||
|
||||
export const Default = createRender(createArgs());
|
||||
|
||||
export const NoNumber = createRender(createArgs({
|
||||
settings: {
|
||||
row: {
|
||||
showNumber: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const NoSelectable = createRender(createArgs({
|
||||
settings: {
|
||||
row: {
|
||||
selectable: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const Editable = createRender(createArgs({
|
||||
settings: {
|
||||
cols: defaultCols.map(col => ({ ...col, editable: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
export const AdditionalRowStyle = createRender(createArgs({
|
||||
settings: {
|
||||
cols: defaultCols.map(col => ({ ...col, editable: true })),
|
||||
row: {
|
||||
styleRules: [
|
||||
{
|
||||
condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean,
|
||||
applyStyle: {
|
||||
style: {
|
||||
backgroundColor: 'lightgray',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const ContextMenu = createRender(createArgs({
|
||||
settings: {
|
||||
cols: [
|
||||
{
|
||||
bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Check All',
|
||||
action: () => {
|
||||
for (const d of ContextMenu.args.data) {
|
||||
d.check = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Uncheck All',
|
||||
action: () => {
|
||||
for (const d of ContextMenu.args.data) {
|
||||
d.check = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
row: {
|
||||
contextMenuFactory: (row, context) => [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
action: () => {
|
||||
const idxes = context.rangedRows.map(r => r.index);
|
||||
const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i));
|
||||
|
||||
ContextMenu.args.data.splice(0);
|
||||
ContextMenu.args.data.push(...newData);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cells: {
|
||||
contextMenuFactory: (col, row, value, context) => [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
action: () => {
|
||||
for (const cell of context.rangedCells) {
|
||||
ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,216 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="mk_grid_th"
|
||||
:class="$style.cell"
|
||||
:style="[{ maxWidth: column.width, minWidth: column.width, width: column.width }]"
|
||||
data-grid-cell
|
||||
:data-grid-cell-row="-1"
|
||||
:data-grid-cell-col="column.index"
|
||||
>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.left"/>
|
||||
<div :class="$style.wrapper">
|
||||
<div ref="contentEl" :class="$style.contentArea">
|
||||
<span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"/>
|
||||
<span v-else>{{ text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="$style.right"
|
||||
@mousedown="onHandleMouseDown"
|
||||
@dblclick="onHandleDoubleClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue';
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:endWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:widthLargest', sender: GridColumn): void;
|
||||
(ev: 'change:width', sender: GridColumn, width: string): void;
|
||||
(ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
column: GridColumn,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
|
||||
const { column, bus } = toRefs(props);
|
||||
|
||||
const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
|
||||
const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
|
||||
|
||||
const resizing = ref<boolean>(false);
|
||||
|
||||
const text = computed(() => {
|
||||
const result = column.value.setting.title ?? column.value.setting.bindTo;
|
||||
return result.length > 0 ? result : ' ';
|
||||
});
|
||||
|
||||
watch(column, () => {
|
||||
// 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
|
||||
nextTick(emitContentSizeChanged);
|
||||
}, { immediate: true });
|
||||
|
||||
function onHandleDoubleClick(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'dblclick': {
|
||||
emit('operation:widthLargest', column.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleMouseDown(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'mousedown': {
|
||||
if (!resizing.value) {
|
||||
registerHandleMouseUp();
|
||||
registerHandleMouseMove();
|
||||
resizing.value = true;
|
||||
emit('operation:beginWidthChange', column.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleMouseMove(ev: MouseEvent) {
|
||||
if (!rootEl.value) {
|
||||
// 型ガード
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'mousemove': {
|
||||
if (resizing.value) {
|
||||
const bounds = rootEl.value.getBoundingClientRect();
|
||||
const clientWidth = rootEl.value.clientWidth;
|
||||
const clientRight = bounds.left + clientWidth;
|
||||
const nextWidth = clientWidth + (ev.clientX - clientRight);
|
||||
emit('change:width', column.value, `${nextWidth}px`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleMouseUp(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'mouseup': {
|
||||
if (resizing.value) {
|
||||
unregisterHandleMouseUp();
|
||||
unregisterHandleMouseMove();
|
||||
resizing.value = false;
|
||||
emit('operation:endWidthChange', column.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onForceRefreshContentSize() {
|
||||
emitContentSizeChanged();
|
||||
}
|
||||
|
||||
function registerHandleMouseMove() {
|
||||
unregisterHandleMouseMove();
|
||||
addEventListener('mousemove', onHandleMouseMove);
|
||||
}
|
||||
|
||||
function unregisterHandleMouseMove() {
|
||||
removeEventListener('mousemove', onHandleMouseMove);
|
||||
}
|
||||
|
||||
function registerHandleMouseUp() {
|
||||
unregisterHandleMouseUp();
|
||||
addEventListener('mouseup', onHandleMouseUp);
|
||||
}
|
||||
|
||||
function unregisterHandleMouseUp() {
|
||||
removeEventListener('mouseup', onHandleMouseUp);
|
||||
}
|
||||
|
||||
function emitContentSizeChanged() {
|
||||
const clientWidth = contentEl.value?.clientWidth ?? 0;
|
||||
const clientHeight = contentEl.value?.clientHeight ?? 0;
|
||||
emit('change:contentSize', column.value, {
|
||||
// バーの横幅も考慮したいので、+3px
|
||||
width: clientWidth + 3 + 3,
|
||||
height: clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$handleWidth: 5px;
|
||||
$cellHeight: 28px;
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: $cellHeight;
|
||||
max-height: $cellHeight;
|
||||
min-height: $cellHeight;
|
||||
|
||||
.wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
display: flex;
|
||||
padding: 6px 4px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
// rightのぶんだけズレるのでそれを相殺するためのネガティブマージン
|
||||
margin-left: -$handleWidth;
|
||||
margin-right: auto;
|
||||
width: $handleWidth;
|
||||
min-width: $handleWidth;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
// 判定を罫線の上に重ねたいのでネガティブマージンを使う
|
||||
margin-right: -$handleWidth;
|
||||
width: $handleWidth;
|
||||
min-width: $handleWidth;
|
||||
cursor: w-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,60 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mk_grid_tr"
|
||||
:class="$style.root"
|
||||
:data-grid-row="-1"
|
||||
>
|
||||
<MkNumberCell
|
||||
v-if="gridSetting.showNumber"
|
||||
content="#"
|
||||
:top="true"
|
||||
/>
|
||||
<MkHeaderCell
|
||||
v-for="column in columns"
|
||||
:key="column.index"
|
||||
:column="column"
|
||||
:bus="bus"
|
||||
@operation:beginWidthChange="(sender) => emit('operation:beginWidthChange', sender)"
|
||||
@operation:endWidthChange="(sender) => emit('operation:endWidthChange', sender)"
|
||||
@operation:widthLargest="(sender) => emit('operation:widthLargest', sender)"
|
||||
@change:width="(sender, width) => emit('change:width', sender, width)"
|
||||
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import MkHeaderCell from '@/components/grid/MkHeaderCell.vue';
|
||||
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:endWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:widthLargest', sender: GridColumn): void;
|
||||
(ev: 'operation:selectionColumn', sender: GridColumn): void;
|
||||
(ev: 'change:width', sender: GridColumn, width: string): void;
|
||||
(ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
columns: GridColumn[],
|
||||
gridSetting: GridRowSetting,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,61 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mk_grid_th"
|
||||
:class="[$style.cell]"
|
||||
:tabindex="-1"
|
||||
data-grid-cell
|
||||
:data-grid-cell-row="row?.index ?? -1"
|
||||
:data-grid-cell-col="-1"
|
||||
>
|
||||
<div :class="[$style.root]">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
|
||||
defineProps<{
|
||||
content: string,
|
||||
row?: GridRow,
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$cellHeight: 28px;
|
||||
$cellWidth: 34px;
|
||||
|
||||
.cell {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
height: $cellHeight;
|
||||
max-height: $cellHeight;
|
||||
min-height: $cellHeight;
|
||||
min-width: $cellWidth;
|
||||
width: $cellWidth;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
height: 100%;
|
||||
border: solid 0.5px transparent;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export type ValidatorParams = {
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
value: CellValue;
|
||||
allCells: GridCell[];
|
||||
};
|
||||
|
||||
export type ValidatorResult = {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type GridCellValidator = {
|
||||
name?: string;
|
||||
ignoreViolation?: boolean;
|
||||
validate: (params: ValidatorParams) => ValidatorResult;
|
||||
}
|
||||
|
||||
export type ValidateViolation = {
|
||||
valid: boolean;
|
||||
params: ValidatorParams;
|
||||
violations: ValidateViolationItem[];
|
||||
}
|
||||
|
||||
export type ValidateViolationItem = {
|
||||
valid: boolean;
|
||||
validator: GridCellValidator;
|
||||
result: ValidatorResult;
|
||||
}
|
||||
|
||||
export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation {
|
||||
const { column, row } = cell;
|
||||
const validators = column.setting.validators ?? [];
|
||||
|
||||
const params: ValidatorParams = {
|
||||
column,
|
||||
row,
|
||||
value: newValue,
|
||||
allCells,
|
||||
};
|
||||
|
||||
const violations: ValidateViolationItem[] = validators.map(validator => {
|
||||
const result = validator.validate(params);
|
||||
return {
|
||||
valid: result.valid,
|
||||
validator,
|
||||
result,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
valid: violations.every(v => v.result.valid),
|
||||
params,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
class ValidatorPreset {
|
||||
required(): GridCellValidator {
|
||||
return {
|
||||
name: 'required',
|
||||
validate: ({ value }): ValidatorResult => {
|
||||
return {
|
||||
valid: value !== null && value !== undefined && value !== '',
|
||||
message: i18n.ts._gridComponent._error.requiredValue,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
regex(pattern: RegExp): GridCellValidator {
|
||||
return {
|
||||
name: 'regex',
|
||||
validate: ({ value }): ValidatorResult => {
|
||||
return {
|
||||
valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''),
|
||||
message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
unique(): GridCellValidator {
|
||||
return {
|
||||
name: 'unique',
|
||||
validate: ({ column, row, value, allCells }): ValidatorResult => {
|
||||
const bindTo = column.setting.bindTo;
|
||||
const isUnique = allCells
|
||||
.filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index)
|
||||
.every(cell => cell.value !== value);
|
||||
return {
|
||||
valid: isUnique,
|
||||
message: i18n.ts._gridComponent._error.notUnique,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const validators = new ValidatorPreset();
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ValidateViolation } from '@/components/grid/cell-validators.js';
|
||||
import { Size } from '@/components/grid/grid.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
|
||||
export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>;
|
||||
|
||||
export type CellAddress = {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export const CELL_ADDRESS_NONE: CellAddress = {
|
||||
row: -1,
|
||||
col: -1,
|
||||
};
|
||||
|
||||
export type GridCell = {
|
||||
address: CellAddress;
|
||||
value: CellValue;
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
selected: boolean;
|
||||
ranged: boolean;
|
||||
contentSize: Size;
|
||||
setting: GridCellSetting;
|
||||
violation: ValidateViolation;
|
||||
}
|
||||
|
||||
export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[];
|
||||
|
||||
export type GridCellSetting = {
|
||||
contextMenuFactory?: GridCellContextMenuFactory;
|
||||
}
|
||||
|
||||
export function createCell(
|
||||
column: GridColumn,
|
||||
row: GridRow,
|
||||
value: CellValue,
|
||||
setting: GridCellSetting,
|
||||
): GridCell {
|
||||
const newValue = (row.using && column.setting.valueTransformer)
|
||||
? column.setting.valueTransformer(row, column, value)
|
||||
: value;
|
||||
|
||||
return {
|
||||
address: { row: row.index, col: column.index },
|
||||
value: newValue,
|
||||
column,
|
||||
row,
|
||||
selected: false,
|
||||
ranged: false,
|
||||
contentSize: { width: 0, height: 0 },
|
||||
violation: {
|
||||
valid: true,
|
||||
params: {
|
||||
column,
|
||||
row,
|
||||
value,
|
||||
allCells: [],
|
||||
},
|
||||
violations: [],
|
||||
},
|
||||
setting,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetCell(cell: GridCell): void {
|
||||
cell.selected = false;
|
||||
cell.ranged = false;
|
||||
cell.violation = {
|
||||
valid: true,
|
||||
params: {
|
||||
column: cell.column,
|
||||
row: cell.row,
|
||||
value: cell.value,
|
||||
allCells: [],
|
||||
},
|
||||
violations: [],
|
||||
};
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { GridCellValidator } from '@/components/grid/cell-validators.js';
|
||||
import { Size, SizeStyle } from '@/components/grid/grid.js';
|
||||
import { calcCellWidth } from '@/components/grid/grid-utils.js';
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
|
||||
export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden';
|
||||
|
||||
export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise<CellValue>;
|
||||
export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue;
|
||||
export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[];
|
||||
|
||||
export type GridColumnSetting = {
|
||||
bindTo: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
type: ColumnType;
|
||||
width: SizeStyle;
|
||||
editable?: boolean;
|
||||
validators?: GridCellValidator[];
|
||||
customValueEditor?: CustomValueEditor;
|
||||
valueTransformer?: CellValueTransformer;
|
||||
contextMenuFactory?: GridColumnContextMenuFactory;
|
||||
events?: {
|
||||
copy?: (value: CellValue) => string;
|
||||
paste?: (text: string) => CellValue;
|
||||
delete?: (cell: GridCell, context: GridContext) => void;
|
||||
}
|
||||
};
|
||||
|
||||
export type GridColumn = {
|
||||
index: number;
|
||||
setting: GridColumnSetting;
|
||||
width: string;
|
||||
contentSize: Size;
|
||||
}
|
||||
|
||||
export function createColumn(setting: GridColumnSetting, index: number): GridColumn {
|
||||
return {
|
||||
index,
|
||||
setting,
|
||||
width: calcCellWidth(setting.width),
|
||||
contentSize: { width: 0, height: 0 },
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridState } from '@/components/grid/grid.js';
|
||||
import { ValidateViolation } from '@/components/grid/cell-validators.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
|
||||
export type GridContext = {
|
||||
selectedCell?: GridCell;
|
||||
rangedCells: GridCell[];
|
||||
rangedRows: GridRow[];
|
||||
randedBounds: {
|
||||
leftTop: CellAddress;
|
||||
rightBottom: CellAddress;
|
||||
};
|
||||
availableBounds: {
|
||||
leftTop: CellAddress;
|
||||
rightBottom: CellAddress;
|
||||
};
|
||||
state: GridState;
|
||||
rows: GridRow[];
|
||||
columns: GridColumn[];
|
||||
};
|
||||
|
||||
export type GridEvent =
|
||||
GridCellValueChangeEvent |
|
||||
GridCellValidationEvent
|
||||
;
|
||||
|
||||
export type GridCellValueChangeEvent = {
|
||||
type: 'cell-value-change';
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
oldValue: CellValue;
|
||||
newValue: CellValue;
|
||||
};
|
||||
|
||||
export type GridCellValidationEvent = {
|
||||
type: 'cell-validation';
|
||||
violation?: ValidateViolation;
|
||||
all: ValidateViolation[];
|
||||
};
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { isRef, Ref } from 'vue';
|
||||
import { DataSource, SizeStyle } from '@/components/grid/grid.js';
|
||||
import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { GridColumn, GridColumnSetting } from '@/components/grid/column.js';
|
||||
|
||||
export function isCellElement(elem: HTMLElement): boolean {
|
||||
return elem.hasAttribute('data-grid-cell');
|
||||
}
|
||||
|
||||
export function isRowElement(elem: HTMLElement): boolean {
|
||||
return elem.hasAttribute('data-grid-row');
|
||||
}
|
||||
|
||||
export function calcCellWidth(widthSetting: SizeStyle): string {
|
||||
switch (widthSetting) {
|
||||
case undefined:
|
||||
case 'auto': {
|
||||
return 'auto';
|
||||
}
|
||||
default: {
|
||||
return `${widthSetting}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCellRowByAttribute(elem: HTMLElement): number {
|
||||
const row = elem.getAttribute('data-grid-cell-row');
|
||||
if (row === null) {
|
||||
throw new Error('data-grid-cell-row attribute not found');
|
||||
}
|
||||
return Number(row);
|
||||
}
|
||||
|
||||
function getCellColByAttribute(elem: HTMLElement): number {
|
||||
const col = elem.getAttribute('data-grid-cell-col');
|
||||
if (col === null) {
|
||||
throw new Error('data-grid-cell-col attribute not found');
|
||||
}
|
||||
return Number(col);
|
||||
}
|
||||
|
||||
export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
|
||||
let node = elem;
|
||||
for (let i = 0; i < parentNodeCount; i++) {
|
||||
if (!node.parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCellElement(node) && isRowElement(node.parentElement)) {
|
||||
const row = getCellRowByAttribute(node);
|
||||
const col = getCellColByAttribute(node);
|
||||
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
return CELL_ADDRESS_NONE;
|
||||
}
|
||||
|
||||
export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null {
|
||||
let node = elem;
|
||||
for (let i = 0; i < parentNodeCount; i++) {
|
||||
if (isCellElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (!node.parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
|
||||
return a.row === b.row && a.col === b.col;
|
||||
}
|
||||
|
||||
/**
|
||||
* グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。
|
||||
*/
|
||||
export function copyGridDataToClipboard(
|
||||
gridItems: Ref<DataSource[]> | DataSource[],
|
||||
context: GridContext,
|
||||
) {
|
||||
const items = isRef(gridItems) ? gridItems.value : gridItems;
|
||||
const lines = Array.of<string>();
|
||||
const bounds = context.randedBounds;
|
||||
|
||||
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
|
||||
const rowItems = Array.of<string>();
|
||||
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
|
||||
const { bindTo, events } = context.columns[col].setting;
|
||||
const value = items[row][bindTo];
|
||||
const transformValue = events?.copy
|
||||
? events.copy(value)
|
||||
: typeof value === 'object' || Array.isArray(value)
|
||||
? JSON.stringify(value)
|
||||
: value?.toString() ?? '';
|
||||
rowItems.push(transformValue);
|
||||
}
|
||||
lines.push(rowItems.join('\t'));
|
||||
}
|
||||
|
||||
const text = lines.join('\n');
|
||||
copyToClipboard(text);
|
||||
|
||||
if (_DEV_) {
|
||||
console.log(`Copied to clipboard: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。
|
||||
* …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
|
||||
*/
|
||||
export async function pasteToGridFromClipboard(
|
||||
context: GridContext,
|
||||
callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void,
|
||||
) {
|
||||
function parseValue(value: string, setting: GridColumnSetting): CellValue {
|
||||
if (setting.events?.paste) {
|
||||
return setting.events.paste(value);
|
||||
} else {
|
||||
switch (setting.type) {
|
||||
case 'number': {
|
||||
return Number(value);
|
||||
}
|
||||
case 'boolean': {
|
||||
return value === 'true';
|
||||
}
|
||||
default: {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clipBoardText = await navigator.clipboard.readText();
|
||||
if (_DEV_) {
|
||||
console.log(`Paste from clipboard: ${clipBoardText}`);
|
||||
}
|
||||
|
||||
const bounds = context.randedBounds;
|
||||
const lines = clipBoardText.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(it => it.split('\t'));
|
||||
|
||||
if (lines.length === 1 && lines[0].length === 1) {
|
||||
// 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
|
||||
const ranges = context.rangedCells;
|
||||
for (const cell of ranges) {
|
||||
if (cell.column.setting.editable) {
|
||||
callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
|
||||
const offsetRow = bounds.leftTop.row;
|
||||
const offsetCol = bounds.leftTop.col;
|
||||
const { columns, rows } = context;
|
||||
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;
|
||||
}
|
||||
|
||||
if (columns[col].setting.editable) {
|
||||
callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。
|
||||
* …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
|
||||
*/
|
||||
export function removeDataFromGrid(
|
||||
context: GridContext,
|
||||
callback: (cell: GridCell) => void,
|
||||
) {
|
||||
for (const cell of context.rangedCells) {
|
||||
const { editable, events } = cell.column.setting;
|
||||
if (editable) {
|
||||
if (events?.delete) {
|
||||
events.delete(cell, context);
|
||||
} else {
|
||||
callback(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { CellValue, GridCellSetting } from '@/components/grid/cell.js';
|
||||
import { GridColumnSetting } from '@/components/grid/column.js';
|
||||
import { GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
export type GridSetting = {
|
||||
row?: GridRowSetting;
|
||||
cols: GridColumnSetting[];
|
||||
cells?: GridCellSetting;
|
||||
};
|
||||
|
||||
export type DataSource = Record<string, CellValue>;
|
||||
|
||||
export type GridState =
|
||||
'normal' |
|
||||
'cellSelecting' |
|
||||
'cellEditing' |
|
||||
'colResizing' |
|
||||
'colSelecting' |
|
||||
'rowSelecting' |
|
||||
'hidden'
|
||||
;
|
||||
|
||||
export type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type SizeStyle = number | 'auto' | undefined;
|
||||
|
||||
export type AdditionalStyle = {
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export class GridEventEmitter extends EventEmitter<{
|
||||
'forceRefreshContentSize': void;
|
||||
}> {
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AdditionalStyle } from '@/components/grid/grid.js';
|
||||
import { GridCell } from '@/components/grid/cell.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
|
||||
export const defaultGridRowSetting: Required<GridRowSetting> = {
|
||||
showNumber: true,
|
||||
selectable: true,
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [],
|
||||
contextMenuFactory: () => [],
|
||||
events: {},
|
||||
};
|
||||
|
||||
export type GridRowStyleRuleConditionParams = {
|
||||
row: GridRow,
|
||||
targetCols: GridColumn[],
|
||||
cells: GridCell[]
|
||||
};
|
||||
|
||||
export type GridRowStyleRule = {
|
||||
condition: (params: GridRowStyleRuleConditionParams) => boolean;
|
||||
applyStyle: AdditionalStyle;
|
||||
}
|
||||
|
||||
export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[];
|
||||
|
||||
export type GridRowSetting = {
|
||||
showNumber?: boolean;
|
||||
selectable?: boolean;
|
||||
minimumDefinitionCount?: number;
|
||||
styleRules?: GridRowStyleRule[];
|
||||
contextMenuFactory?: GridRowContextMenuFactory;
|
||||
events?: {
|
||||
delete?: (rows: GridRow[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export type GridRow = {
|
||||
index: number;
|
||||
ranged: boolean;
|
||||
using: boolean;
|
||||
setting: GridRowSetting;
|
||||
additionalStyles: AdditionalStyle[];
|
||||
}
|
||||
|
||||
export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow {
|
||||
return {
|
||||
index,
|
||||
ranged: false,
|
||||
using: using,
|
||||
setting,
|
||||
additionalStyles: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function resetRow(row: GridRow): void {
|
||||
row.ranged = false;
|
||||
row.using = false;
|
||||
row.additionalStyles = [];
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
worker-src 'self';
|
||||
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
font-src 'self' data:;
|
||||
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
|
||||
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
|
||||
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com;
|
||||
|
|
|
@ -601,6 +601,27 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti
|
|||
});
|
||||
}
|
||||
|
||||
export async function selectRole(params: {
|
||||
initialRoleIds?: string[],
|
||||
title?: string,
|
||||
infoMessage?: string,
|
||||
publicOnly?: boolean,
|
||||
}): Promise<
|
||||
{ canceled: true; result: undefined; } |
|
||||
{ canceled: false; result: Misskey.entities.Role[] }
|
||||
> {
|
||||
return new Promise((resolve) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, {
|
||||
done: roles => {
|
||||
resolve({ canceled: false, result: roles });
|
||||
},
|
||||
close: () => {
|
||||
resolve({ canceled: true, result: undefined });
|
||||
},
|
||||
}, 'dispose');
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
const { dispose } = popup(MkEmojiPickerDialog, {
|
||||
|
|
|
@ -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 [];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,714 @@
|
|||
<!--
|
||||
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"/>
|
||||
|
||||
<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>
|
||||
|
||||
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
|
||||
</template>
|
||||
|
||||
<div :class="$style.buttons">
|
||||
<MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">{{ i18n.ts.delete }}</MkButton>
|
||||
<MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
|
||||
{{
|
||||
i18n.ts.update
|
||||
}}
|
||||
</MkButton>
|
||||
<MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
|
||||
</div>
|
||||
</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';
|
||||
|
||||
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 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(','));
|
||||
|
||||
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 os.promiseDialog(
|
||||
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;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 8px 0;
|
||||
border-top: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
</style>
|
|
@ -0,0 +1,466 @@
|
|||
<!--
|
||||
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.buttons">
|
||||
<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;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
|
@ -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: 16px;
|
||||
}
|
||||
</style>
|
|
@ -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>
|
|
@ -0,0 +1,395 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<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="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"/>
|
||||
|
||||
<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>
|
||||
|
||||
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
|
||||
</template>
|
||||
|
||||
<div v-if="gridItems.length > 0" class="_gaps" :class="$style.buttons">
|
||||
<MkButton primary @click="onImportClicked">
|
||||
{{
|
||||
i18n.ts._customEmojisManager._remote.importEmojisButton
|
||||
}}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } 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';
|
||||
|
||||
type GridItem = {
|
||||
checked: boolean;
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
return {
|
||||
row: {
|
||||
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 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));
|
||||
|
||||
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 action = () => {
|
||||
return targets.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/copy',
|
||||
{
|
||||
emojiId: item.id!,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
);
|
||||
};
|
||||
|
||||
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(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 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 os.promiseDialog(
|
||||
misskeyApi('v2/admin/emoji/list', {
|
||||
limit: 100,
|
||||
query: query,
|
||||
page: currentPage.value,
|
||||
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`),
|
||||
}),
|
||||
() => {
|
||||
},
|
||||
() => {
|
||||
},
|
||||
);
|
||||
|
||||
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 {
|
||||
--stickyTop: 0px;
|
||||
|
||||
padding: 16px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
|
@ -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())),
|
||||
});
|
|
@ -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>
|
|
@ -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,
|
||||
|
|
|
@ -382,6 +382,10 @@ const routes: RouteDef[] = [{
|
|||
path: '/emojis',
|
||||
name: 'emojis',
|
||||
component: page(() => import('@/pages/custom-emojis-manager.vue')),
|
||||
}, {
|
||||
path: '/emojis2',
|
||||
name: 'emojis2',
|
||||
component: page(() => import('@/pages/admin/custom-emojis-manager2.vue')),
|
||||
}, {
|
||||
path: '/avatar-decorations',
|
||||
name: 'avatarDecorations',
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type DroppedItem = DroppedFile | DroppedDirectory;
|
||||
|
||||
export type DroppedFile = {
|
||||
isFile: true;
|
||||
path: string;
|
||||
file: File;
|
||||
};
|
||||
|
||||
export type DroppedDirectory = {
|
||||
isFile: false;
|
||||
path: string;
|
||||
children: DroppedItem[];
|
||||
}
|
||||
|
||||
export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> {
|
||||
const dropItems = ev.dataTransfer?.items;
|
||||
if (!dropItems || dropItems.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const apiTestItem = dropItems[0];
|
||||
if ('webkitGetAsEntry' in apiTestItem) {
|
||||
return readDataTransferItems(dropItems);
|
||||
} else {
|
||||
// webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない)
|
||||
const dropFiles = ev.dataTransfer.files;
|
||||
if (dropFiles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const droppedFiles = Array.of<DroppedFile>();
|
||||
for (let i = 0; i < dropFiles.length; i++) {
|
||||
const file = dropFiles.item(i);
|
||||
if (file) {
|
||||
droppedFiles.push({
|
||||
isFile: true,
|
||||
path: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return droppedFiles;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。
|
||||
*/
|
||||
export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> {
|
||||
async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> {
|
||||
if (entry.isFile) {
|
||||
return {
|
||||
isFile: true,
|
||||
path: entry.fullPath,
|
||||
file: await readFile(entry as FileSystemFileEntry),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
isFile: false,
|
||||
path: entry.fullPath,
|
||||
children: await readDirectory(entry as FileSystemDirectoryEntry),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fileSystemFileEntry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
|
||||
function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> {
|
||||
return new Promise(async (resolve) => {
|
||||
const allEntries = Array.of<FileSystemEntry>();
|
||||
const reader = fileSystemDirectoryEntry.createReader();
|
||||
while (true) {
|
||||
const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej));
|
||||
if (entries.length === 0) {
|
||||
break;
|
||||
}
|
||||
allEntries.push(...entries);
|
||||
}
|
||||
|
||||
resolve(await Promise.all(allEntries.map(readEntry)));
|
||||
});
|
||||
}
|
||||
|
||||
// 扱いにくいので配列に変換
|
||||
const items = Array.of<DataTransferItem>();
|
||||
for (let i = 0; i < itemList.length; i++) {
|
||||
items.push(itemList[i]);
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
items
|
||||
.map(it => it.webkitGetAsEntry())
|
||||
.filter(it => it)
|
||||
.map(it => readEntry(it!)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。
|
||||
*/
|
||||
export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] {
|
||||
const result = Array.of<DroppedFile>();
|
||||
for (const item of items) {
|
||||
if (item.isFile) {
|
||||
result.push(item);
|
||||
} else {
|
||||
result.push(...flattenDroppedFiles(item.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
|
||||
*/
|
||||
export type KeyCode =
|
||||
| 'Backspace'
|
||||
| 'Tab'
|
||||
| 'Enter'
|
||||
| 'Shift'
|
||||
| 'Control'
|
||||
| 'Alt'
|
||||
| 'Pause'
|
||||
| 'CapsLock'
|
||||
| 'Escape'
|
||||
| 'Space'
|
||||
| 'PageUp'
|
||||
| 'PageDown'
|
||||
| 'End'
|
||||
| 'Home'
|
||||
| 'ArrowLeft'
|
||||
| 'ArrowUp'
|
||||
| 'ArrowRight'
|
||||
| 'ArrowDown'
|
||||
| 'Insert'
|
||||
| 'Delete'
|
||||
| 'Digit0'
|
||||
| 'Digit1'
|
||||
| 'Digit2'
|
||||
| 'Digit3'
|
||||
| 'Digit4'
|
||||
| 'Digit5'
|
||||
| 'Digit6'
|
||||
| 'Digit7'
|
||||
| 'Digit8'
|
||||
| 'Digit9'
|
||||
| 'KeyA'
|
||||
| 'KeyB'
|
||||
| 'KeyC'
|
||||
| 'KeyD'
|
||||
| 'KeyE'
|
||||
| 'KeyF'
|
||||
| 'KeyG'
|
||||
| 'KeyH'
|
||||
| 'KeyI'
|
||||
| 'KeyJ'
|
||||
| 'KeyK'
|
||||
| 'KeyL'
|
||||
| 'KeyM'
|
||||
| 'KeyN'
|
||||
| 'KeyO'
|
||||
| 'KeyP'
|
||||
| 'KeyQ'
|
||||
| 'KeyR'
|
||||
| 'KeyS'
|
||||
| 'KeyT'
|
||||
| 'KeyU'
|
||||
| 'KeyV'
|
||||
| 'KeyW'
|
||||
| 'KeyX'
|
||||
| 'KeyY'
|
||||
| 'KeyZ'
|
||||
| 'MetaLeft'
|
||||
| 'MetaRight'
|
||||
| 'ContextMenu'
|
||||
| 'F1'
|
||||
| 'F2'
|
||||
| 'F3'
|
||||
| 'F4'
|
||||
| 'F5'
|
||||
| 'F6'
|
||||
| 'F7'
|
||||
| 'F8'
|
||||
| 'F9'
|
||||
| 'F10'
|
||||
| 'F11'
|
||||
| 'F12'
|
||||
| 'NumLock'
|
||||
| 'ScrollLock'
|
||||
| 'Semicolon'
|
||||
| 'Equal'
|
||||
| 'Comma'
|
||||
| 'Minus'
|
||||
| 'Period'
|
||||
| 'Slash'
|
||||
| 'Backquote'
|
||||
| 'BracketLeft'
|
||||
| 'Backslash'
|
||||
| 'BracketRight'
|
||||
| 'Quote'
|
||||
| 'Meta'
|
||||
| 'AltGraph'
|
||||
;
|
||||
|
||||
/**
|
||||
* 修飾キーを表す文字列。不足分は適宜追加する。
|
||||
*/
|
||||
export type KeyModifier =
|
||||
| 'Shift'
|
||||
| 'Control'
|
||||
| 'Alt'
|
||||
| 'Meta'
|
||||
;
|
||||
|
||||
/**
|
||||
* 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。
|
||||
*/
|
||||
export type KeyState =
|
||||
| 'composing'
|
||||
| 'repeat'
|
||||
;
|
||||
|
||||
export type KeyEventHandler = {
|
||||
modifiers?: KeyModifier[];
|
||||
states?: KeyState[];
|
||||
code: KeyCode | 'any';
|
||||
handler: (event: KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) {
|
||||
function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) {
|
||||
if (modifiers) {
|
||||
return modifiers.every(modifier => ev.getModifierState(modifier));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkState(ev: KeyboardEvent, states?: KeyState[]) {
|
||||
if (states) {
|
||||
return states.every(state => ev.getModifierState(state));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let hit = false;
|
||||
for (const handler of handlers.filter(it => it.code === event.code)) {
|
||||
if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) {
|
||||
handler.handler(event);
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hit) {
|
||||
for (const handler of handlers.filter(it => it.code === 'any')) {
|
||||
handler.handler(event);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,14 +12,28 @@ import { i18n } from '@/i18n.js';
|
|||
import { defaultStore } from '@/store.js';
|
||||
import { uploadFile } from '@/scripts/upload.js';
|
||||
|
||||
export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<Misskey.entities.DriveFile[]> {
|
||||
export function chooseFileFromPc(
|
||||
multiple: boolean,
|
||||
options?: {
|
||||
uploadFolder?: string | null;
|
||||
keepOriginal?: boolean;
|
||||
nameConverter?: (file: File) => string | undefined;
|
||||
},
|
||||
): Promise<Misskey.entities.DriveFile[]> {
|
||||
const uploadFolder = options?.uploadFolder ?? defaultStore.state.uploadFolder;
|
||||
const keepOriginal = options?.keepOriginal ?? defaultStore.state.keepOriginalUploading;
|
||||
const nameConverter = options?.nameConverter ?? (() => undefined);
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = multiple;
|
||||
input.onchange = () => {
|
||||
if (!input.files) return res([]);
|
||||
const promises = Array.from(input.files, file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal));
|
||||
const promises = Array.from(
|
||||
input.files,
|
||||
file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
|
||||
);
|
||||
|
||||
Promise.all(promises).then(driveFiles => {
|
||||
res(driveFiles);
|
||||
|
@ -94,7 +108,7 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul
|
|||
}, {
|
||||
text: i18n.ts.upload,
|
||||
icon: 'ti ti-upload',
|
||||
action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)),
|
||||
action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)),
|
||||
}, {
|
||||
text: i18n.ts.fromDrive,
|
||||
icon: 'ti ti-cloud',
|
||||
|
|
|
@ -1110,6 +1110,9 @@ type EmojiDeleted = {
|
|||
// @public (undocumented)
|
||||
type EmojiDetailed = components['schemas']['EmojiDetailed'];
|
||||
|
||||
// @public (undocumented)
|
||||
type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin'];
|
||||
|
||||
// @public (undocumented)
|
||||
type EmojiRequest = operations['emoji']['requestBody']['content']['application/json'];
|
||||
|
||||
|
@ -1285,6 +1288,8 @@ declare namespace entities {
|
|||
AdminEmojiSetCategoryBulkRequest,
|
||||
AdminEmojiSetLicenseBulkRequest,
|
||||
AdminEmojiUpdateRequest,
|
||||
V2AdminEmojiListRequest,
|
||||
V2AdminEmojiListResponse,
|
||||
AdminFederationDeleteAllFilesRequest,
|
||||
AdminFederationRefreshRemoteInstanceMetadataRequest,
|
||||
AdminFederationRemoveAllFollowingRequest,
|
||||
|
@ -1838,6 +1843,7 @@ declare namespace entities {
|
|||
GalleryPost,
|
||||
EmojiSimple,
|
||||
EmojiDetailed,
|
||||
EmojiDetailedAdmin,
|
||||
Flash,
|
||||
Signin,
|
||||
RoleCondFormulaLogics,
|
||||
|
@ -3410,6 +3416,12 @@ type UsersShowResponse = operations['users___show']['responses']['200']['content
|
|||
// @public (undocumented)
|
||||
type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json'];
|
||||
|
||||
// @public (undocumented)
|
||||
type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json'];
|
||||
|
||||
// Warnings were encountered during analysis:
|
||||
//
|
||||
// src/entities.ts:50:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||
|
|
|
@ -471,6 +471,17 @@ declare module '../api.js' {
|
|||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
|
||||
*/
|
||||
request<E extends 'v2/admin/emoji/list', P extends Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
params: P,
|
||||
credential?: string | null,
|
||||
): Promise<SwitchCaseResponseType<E, P>>;
|
||||
|
||||
/**
|
||||
* No description provided.
|
||||
*
|
||||
|
|
|
@ -60,6 +60,8 @@ import type {
|
|||
AdminEmojiSetCategoryBulkRequest,
|
||||
AdminEmojiSetLicenseBulkRequest,
|
||||
AdminEmojiUpdateRequest,
|
||||
V2AdminEmojiListRequest,
|
||||
V2AdminEmojiListResponse,
|
||||
AdminFederationDeleteAllFilesRequest,
|
||||
AdminFederationRefreshRemoteInstanceMetadataRequest,
|
||||
AdminFederationRemoveAllFollowingRequest,
|
||||
|
@ -624,6 +626,7 @@ export type Endpoints = {
|
|||
'admin/emoji/set-category-bulk': { req: AdminEmojiSetCategoryBulkRequest; res: EmptyResponse };
|
||||
'admin/emoji/set-license-bulk': { req: AdminEmojiSetLicenseBulkRequest; res: EmptyResponse };
|
||||
'admin/emoji/update': { req: AdminEmojiUpdateRequest; res: EmptyResponse };
|
||||
'v2/admin/emoji/list': { req: V2AdminEmojiListRequest; res: V2AdminEmojiListResponse };
|
||||
'admin/federation/delete-all-files': { req: AdminFederationDeleteAllFilesRequest; res: EmptyResponse };
|
||||
'admin/federation/refresh-remote-instance-metadata': { req: AdminFederationRefreshRemoteInstanceMetadataRequest; res: EmptyResponse };
|
||||
'admin/federation/remove-all-following': { req: AdminFederationRemoveAllFollowingRequest; res: EmptyResponse };
|
||||
|
@ -967,7 +970,6 @@ export type Endpoints = {
|
|||
'reversi/surrender': { req: ReversiSurrenderRequest; res: EmptyResponse };
|
||||
'reversi/verify': { req: ReversiVerifyRequest; res: ReversiVerifyResponse };
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: The content-type for all endpoints not listed here is application/json.
|
||||
*/
|
||||
|
|
|
@ -63,6 +63,8 @@ export type AdminEmojiSetAliasesBulkRequest = operations['admin___emoji___set-al
|
|||
export type AdminEmojiSetCategoryBulkRequest = operations['admin___emoji___set-category-bulk']['requestBody']['content']['application/json'];
|
||||
export type AdminEmojiSetLicenseBulkRequest = operations['admin___emoji___set-license-bulk']['requestBody']['content']['application/json'];
|
||||
export type AdminEmojiUpdateRequest = operations['admin___emoji___update']['requestBody']['content']['application/json'];
|
||||
export type V2AdminEmojiListRequest = operations['v2___admin___emoji___list']['requestBody']['content']['application/json'];
|
||||
export type V2AdminEmojiListResponse = operations['v2___admin___emoji___list']['responses']['200']['content']['application/json'];
|
||||
export type AdminFederationDeleteAllFilesRequest = operations['admin___federation___delete-all-files']['requestBody']['content']['application/json'];
|
||||
export type AdminFederationRefreshRemoteInstanceMetadataRequest = operations['admin___federation___refresh-remote-instance-metadata']['requestBody']['content']['application/json'];
|
||||
export type AdminFederationRemoveAllFollowingRequest = operations['admin___federation___remove-all-following']['requestBody']['content']['application/json'];
|
||||
|
|
|
@ -33,6 +33,7 @@ export type FederationInstance = components['schemas']['FederationInstance'];
|
|||
export type GalleryPost = components['schemas']['GalleryPost'];
|
||||
export type EmojiSimple = components['schemas']['EmojiSimple'];
|
||||
export type EmojiDetailed = components['schemas']['EmojiDetailed'];
|
||||
export type EmojiDetailedAdmin = components['schemas']['EmojiDetailedAdmin'];
|
||||
export type Flash = components['schemas']['Flash'];
|
||||
export type Signin = components['schemas']['Signin'];
|
||||
export type RoleCondFormulaLogics = components['schemas']['RoleCondFormulaLogics'];
|
||||
|
|
|
@ -396,6 +396,15 @@ export type paths = {
|
|||
*/
|
||||
post: operations['admin___emoji___update'];
|
||||
};
|
||||
'/v2/admin/emoji/list': {
|
||||
/**
|
||||
* v2/admin/emoji/list
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
|
||||
*/
|
||||
post: operations['v2___admin___emoji___list'];
|
||||
};
|
||||
'/admin/federation/delete-all-files': {
|
||||
/**
|
||||
* admin/federation/delete-all-files
|
||||
|
@ -4731,6 +4740,29 @@ export type components = {
|
|||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: string[];
|
||||
};
|
||||
EmojiDetailedAdmin: {
|
||||
/** Format: id */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
updatedAt: string | null;
|
||||
name: string;
|
||||
/** @description The local host is represented with `null`. */
|
||||
host: string | null;
|
||||
publicUrl: string;
|
||||
originalUrl: string;
|
||||
uri: string | null;
|
||||
type: string | null;
|
||||
aliases: string[];
|
||||
category: string | null;
|
||||
license: string | null;
|
||||
localOnly: boolean;
|
||||
isSensitive: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: {
|
||||
/** Format: misskey:id */
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
};
|
||||
Flash: {
|
||||
/**
|
||||
* Format: id
|
||||
|
@ -7730,6 +7762,97 @@ export type operations = {
|
|||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* v2/admin/emoji/list
|
||||
* @description No description provided.
|
||||
*
|
||||
* **Credential required**: *Yes* / **Permission**: *read:admin:emoji*
|
||||
*/
|
||||
v2___admin___emoji___list: {
|
||||
requestBody: {
|
||||
content: {
|
||||
'application/json': {
|
||||
query?: ({
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
uri?: string;
|
||||
publicUrl?: string;
|
||||
originalUrl?: string;
|
||||
type?: string;
|
||||
aliases?: string;
|
||||
category?: string;
|
||||
license?: string;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
/**
|
||||
* @default all
|
||||
* @enum {string}
|
||||
*/
|
||||
hostType?: 'local' | 'remote' | 'all';
|
||||
roleIds?: string[];
|
||||
}) | null;
|
||||
/** Format: misskey:id */
|
||||
sinceId?: string;
|
||||
/** Format: misskey:id */
|
||||
untilId?: string;
|
||||
/** @default 10 */
|
||||
limit?: number;
|
||||
page?: number;
|
||||
/**
|
||||
* @default [
|
||||
* "-id"
|
||||
* ]
|
||||
*/
|
||||
sortKeys?: ('+id' | '-id' | '+updatedAt' | '-updatedAt' | '+name' | '-name' | '+host' | '-host' | '+uri' | '-uri' | '+publicUrl' | '-publicUrl' | '+type' | '-type' | '+aliases' | '-aliases' | '+category' | '-category' | '+license' | '-license' | '+isSensitive' | '-isSensitive' | '+localOnly' | '-localOnly' | '+roleIdsThatCanBeUsedThisEmojiAsReaction' | '-roleIdsThatCanBeUsedThisEmojiAsReaction')[];
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK (with results) */
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
emojis: components['schemas']['EmojiDetailedAdmin'][];
|
||||
count: number;
|
||||
allCount: number;
|
||||
allPages: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Client error */
|
||||
400: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Authentication error */
|
||||
401: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden error */
|
||||
403: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description I'm Ai */
|
||||
418: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
/** @description Internal server error */
|
||||
500: {
|
||||
content: {
|
||||
'application/json': components['schemas']['Error'];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
/**
|
||||
* admin/federation/delete-all-files
|
||||
* @description No description provided.
|
||||
|
|
Loading…
Reference in New Issue