Compare commits

...

182 Commits

Author SHA1 Message Date
おさむのひと bc0ae72951
Merge 3113af0743 into eed45c7915 2024-11-18 01:03:34 +09:00
おさむのひと 3113af0743 fix CHANGELOG.md 2024-11-15 22:51:55 +09:00
おさむのひと b34a522c42 fix color theme 2024-11-15 21:56:36 +09:00
おさむのひと a4aee93c3d Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/backend/src/core/CustomEmojiService.ts
#	packages/backend/src/server/api/endpoints/admin/emoji/update.ts
#	packages/frontend/src/components/global/MkStickyContainer.vue
2024-11-15 21:16:01 +09:00
おさむのひと 8d820dd7d4
Merge branch 'develop' into feature/emoji-grid 2024-10-05 21:41:03 +09:00
おさむのひと 01f83085bd fix CHANGELOG.md 2024-10-05 18:54:47 +09:00
おさむのひと a3d1fa199e Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/frontend/src/components/MkFolder.vue
2024-10-05 18:53:20 +09:00
syuilo 8fdf73d192 Update index.d.ts 2024-09-27 11:34:28 +09:00
syuilo c9cbbc2d39 Merge branch 'develop' into pr/13473 2024-09-27 11:33:25 +09:00
おさむのひと 6baf88e41a add comment to MkModal.vue 2024-08-20 12:10:53 +09:00
おさむのひと 33f685b527 fix error 2024-08-20 06:51:20 +09:00
おさむのひと c04b2d7810 fix error code 2024-08-20 06:27:48 +09:00
おさむのひと a0807edb40 fix MkDataCell.vue 2024-08-19 21:30:28 +09:00
おさむのひと de00b0575a 表示条件などを微調整 2024-08-19 21:00:06 +09:00
おさむのひと 9c16e7f1ef Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	CHANGELOG.md
2024-08-19 20:37:14 +09:00
おさむのひと 51c6186523 v2エンドポイントのルールに対応 2024-08-19 20:33:10 +09:00
おさむのひと 6c3b97bb2f Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	CHANGELOG.md
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/frontend/.storybook/generate.tsx
2024-08-12 15:34:12 +09:00
samunohito 29dd5e59d7 fix layout 2024-07-28 07:57:27 +09:00
samunohito bac10659e0 fix focus 2024-07-28 07:37:18 +09:00
samunohito ca2da9d296 ソートキーの指定方法を他と合わせた 2024-07-27 10:19:02 +09:00
samunohito fdf20a6605 最新への追従とレイアウト微調整 2024-07-27 08:46:38 +09:00
おさむのひと 7e0343d724
Merge branch 'develop' into feature/emoji-grid 2024-07-27 08:18:04 +09:00
samunohito 75f4eef769 tweak css 2024-07-15 17:31:34 +09:00
おさむのひと 151b6c7d93
Merge branch 'develop' into feature/emoji-grid 2024-07-14 19:32:35 +09:00
samunohito 2bd813dbda fix endpoint.ts 2024-07-07 17:30:06 +09:00
おさむのひと e3260a6f18
Merge branch 'develop' into feature/emoji-grid 2024-07-07 17:22:05 +09:00
samunohito 6e9f1e53bf fix overflow css 2024-07-07 17:17:26 +09:00
samunohito 4baf1596e8 fix last-child css 2024-07-07 17:14:40 +09:00
samunohito 20bcf4af4f tableタグやめる 2024-07-07 17:10:10 +09:00
おさむのひと 1cefedb6ac
Merge branch 'develop' into feature/emoji-grid 2024-07-07 11:58:32 +09:00
syuilo 82bbbb9f18 Merge branch 'develop' into pr/13473 2024-06-13 10:18:32 +09:00
syuilo 1bb237126c Merge branch 'feature/emoji-grid' of https://github.com/samunohito/misskey into pr/13473 2024-06-13 10:14:37 +09:00
syuilo e59bdf4085 Merge branch 'develop' into pr/13473 2024-06-13 10:14:18 +09:00
samunohito da7e9d36d5 テスト 2024-06-09 11:21:41 +09:00
samunohito 499f7b7324 ランダム値によるUI変更の抑制 2024-06-09 11:15:14 +09:00
samunohito d82aee2890 テスト 2024-06-09 10:56:13 +09:00
samunohito 629fb916f9 fix 2024-06-09 10:39:12 +09:00
おさむのひと 5fc7f04c4d
Merge branch 'develop' into feature/emoji-grid 2024-06-08 22:19:17 +09:00
samunohito 0e9bf77174 fix 2024-06-08 12:10:31 +09:00
samunohito 2c7e15f841 Merge branch 'refs/heads/develop' into feature/emoji-grid
# Conflicts:
#	CHANGELOG.md
#	packages/frontend/src/components/MkModal.vue
2024-06-08 09:56:40 +09:00
samunohito 57a1853d4a regenerate locales index.d.ts 2024-04-13 15:24:42 +09:00
samunohito ca9df52a5d Merge branch 'refs/heads/develop' into feature/emoji-grid
# Conflicts:
#	locales/index.d.ts
#	locales/ja-JP.yml
2024-04-13 15:22:09 +09:00
samunohito 4025845e54 MkStickyContainer化 2024-03-29 09:35:16 +09:00
samunohito 0bc2401894 縦スクロールを無効化 2024-03-29 07:50:47 +09:00
samunohito 695b75c944 fix log 2024-03-28 22:15:22 +09:00
samunohito cf95082450 CSS Module化 2024-03-28 21:23:16 +09:00
syuilo cd06807d53 Merge branch 'feature/emoji-grid' of https://github.com/samunohito/misskey into pr/13473 2024-03-28 18:47:46 +09:00
syuilo d4e2844711 Update types.ts 2024-03-28 18:47:10 +09:00
syuilo f1d17a4ab7 Merge branch 'develop' into pr/13473 2024-03-28 18:47:05 +09:00
samunohito 6a9dd7017d autogen 2024-03-28 08:11:41 +09:00
samunohito 82e9bcf9d4 Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	CHANGELOG.md
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/misskey-js/etc/misskey-js.api.md
#	packages/misskey-js/src/autogen/entities.ts
2024-03-28 08:09:46 +09:00
syuilo a4a58bb952 Merge branch 'develop' into pr/13473 2024-03-20 16:48:16 +09:00
syuilo 5c1bf0674c Merge branch 'feature/emoji-grid' of https://github.com/samunohito/misskey into pr/13473 2024-03-20 16:48:09 +09:00
samunohito 4915f6cc34 fix ci 2024-03-20 08:24:34 +09:00
おさむのひと ec0461f3b5
Merge branch 'develop' into feature/emoji-grid 2024-03-20 08:21:42 +09:00
syuilo 250266ab73 refactor 2024-03-18 16:33:58 +09:00
syuilo fafef696bf tweak style 2024-03-18 16:27:41 +09:00
syuilo f34a099251 lint 2024-03-18 16:27:33 +09:00
syuilo 7a22282346 Merge branch 'develop' into pr/13473 2024-03-17 17:00:01 +09:00
おさむのひと 1d1d1b0d64
Merge branch 'develop' into feature/emoji-grid 2024-03-12 09:55:34 +09:00
おさむのひと 3a7589e773
Merge branch 'develop' into feature/emoji-grid 2024-03-04 21:09:03 +09:00
samunohito f5fa33d2b9 fix lit 2024-03-03 06:51:26 +09:00
おさむのひと 791e2c5835
Merge branch 'develop' into feature/emoji-grid 2024-03-02 20:20:19 +09:00
おさむのひと 4a6f36ddca
Merge branch 'develop' into feature/emoji-grid 2024-03-01 21:54:18 +09:00
samunohito d6ac4ef5d7 fix CHANGELOG.md 2024-02-29 08:15:29 +09:00
samunohito 8454044642 fix types 2024-02-29 08:13:13 +09:00
samunohito bed39b644d Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/frontend/src/os.ts
2024-02-29 08:02:02 +09:00
samunohito 92c88a23ad add type column 2024-02-29 07:50:26 +09:00
samunohito 9289d013b6 fix bugs 2024-02-29 07:50:19 +09:00
samunohito da13426b89 fix pre test 2024-02-28 22:25:52 +09:00
samunohito 53f858d736 revert excess fixes 2024-02-27 21:11:45 +09:00
samunohito 1b720b36b9 add SPDX 2024-02-27 21:10:09 +09:00
samunohito cb136e635d separate sort order component 2024-02-27 21:00:03 +09:00
samunohito 47ca0e6689 revert excess fixes 2024-02-27 08:20:54 +09:00
samunohito 0aee64ca40 i18n 2024-02-27 08:20:04 +09:00
samunohito 9a6ee0370c patch from dev 2024-02-26 19:52:18 +09:00
samunohito 122fba32f5 Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/backend/src/core/CustomEmojiService.ts
2024-02-26 19:45:46 +09:00
samunohito 20b1da3184 optimize import 2024-02-26 19:36:53 +09:00
samunohito b60951e7ea fix upload 2024-02-26 08:29:35 +09:00
samunohito 390af67949 fix validation and register roles 2024-02-26 08:12:37 +09:00
samunohito cb668b22ad 課題はまだ残っているが、ひとまず完了 2024-02-26 07:27:41 +09:00
osamu e5b95755e8 wip 2024-02-25 14:22:01 +09:00
samunohito c2c920c4a2 [wip] add custom-emojis-manager2.stories.impl.ts 2024-02-23 09:34:26 +09:00
samunohito 879596ca7d [wip] add custom-emojis-manager2.stories.impl.ts 2024-02-23 08:59:15 +09:00
samunohito 702e4ea515 fix 2024-02-23 08:59:00 +09:00
samunohito 098cf397b9 add MkGrid.stories.impl.ts 2024-02-22 19:24:47 +09:00
samunohito 5a2b11ec17 fix MkRoleSelectDialog.vue and storybook scenario 2024-02-21 19:20:00 +09:00
samunohito 216325840d fix MkRoleSelectDialog.vue and storybook scenario 2024-02-21 08:40:08 +09:00
samunohito 06c44a9a02 fix comment 2024-02-20 21:29:56 +09:00
samunohito effe586092 fix copy/paste and delete 2024-02-20 20:25:42 +09:00
samunohito 0f896f6bdb fix keyEvent 2024-02-19 00:00:16 +09:00
samunohito 106e7910b9 fix copy/paste 2024-02-18 15:24:22 +09:00
samunohito bfdfc2c778 fix paste bugs 2024-02-18 15:16:01 +09:00
samunohito 38b4197395 enter key search 2024-02-18 13:53:12 +09:00
samunohito ac943195cf refine remote page 2024-02-18 13:34:01 +09:00
samunohito 1cdf1bf4c9 add route 2024-02-18 10:04:39 +09:00
samunohito 5e64974539 fix MkRoleSelectDialog.vue 2024-02-18 09:33:10 +09:00
samunohito 87ff8e8d94 Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/frontend/src/pages/about-misskey.vue
2024-02-17 21:27:04 +09:00
samunohito 1650ad350d fix url 2024-02-17 21:22:30 +09:00
samunohito 6ba613b4bb remove unused buttons 2024-02-17 21:22:18 +09:00
samunohito 07b9757b36 support role select 2024-02-17 19:51:44 +09:00
samunohito c0f941689b refine context menu setting 2024-02-16 08:08:04 +09:00
samunohito 369d5971d4 add index to emoji 2024-02-15 13:21:50 +09:00
samunohito 4bbf0457fa add role name 2024-02-15 13:20:22 +09:00
samunohito 089682c08d support sort 2024-02-14 22:10:42 +09:00
samunohito 9189117ef1 Merge branch 'develop' into feature/emoji-grid 2024-02-14 20:06:42 +09:00
samunohito 763cac0aad wip 2024-02-14 13:04:23 +09:00
samunohito 5dd1fd7c5f add columns 2024-02-12 12:04:27 +09:00
samunohito 83228a3422 support image change and highlight row 2024-02-10 22:31:04 +09:00
samunohito 171b596ac7 wip 2024-02-10 11:52:34 +09:00
samunohito e3240c556a fix autogen 2024-02-10 11:52:22 +09:00
samunohito d1210520a5 Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/frontend/package.json
#	packages/frontend/src/components/MkChartLegend.vue
#	packages/misskey-js/src/autogen/apiClientJSDoc.ts
#	packages/misskey-js/src/autogen/endpoint.ts
#	packages/misskey-js/src/autogen/entities.ts
#	packages/misskey-js/src/autogen/models.ts
#	packages/misskey-js/src/autogen/types.ts
#	pnpm-lock.yaml
2024-02-09 19:45:43 +09:00
samunohito 7943e524ad fix name 2024-02-08 20:13:48 +09:00
samunohito 9c4e40f83f add confirm dialog 2024-02-08 08:22:19 +09:00
samunohito b7192e5ac6 refactor 2024-02-08 08:14:34 +09:00
samunohito e84790e619 fix dialog 2024-02-07 22:13:41 +09:00
samunohito cdfd906366 support import log 2024-02-07 20:10:12 +09:00
samunohito dbb2efe45c fix 2024-02-07 19:57:21 +09:00
samunohito 453596e6bf add comment 2024-02-07 19:54:29 +09:00
samunohito fa737fccdd block delete 2024-02-07 19:52:00 +09:00
samunohito 1d04e3abab fix range select 2024-02-07 19:51:50 +09:00
samunohito 173b90e124 tweak cell selection 2024-02-07 08:39:12 +09:00
samunohito b0b474d2a3 tweak logs 2024-02-07 08:11:55 +09:00
samunohito e892fbf000 support search 2024-02-07 07:57:25 +09:00
samunohito 273e3bd2e4 fix 2024-02-06 23:43:12 +09:00
samunohito 2a0dca44c3 fix 2024-02-06 20:44:20 +09:00
samunohito 3a4a5dc6f0 fix paging 2024-02-06 20:42:43 +09:00
samunohito a655cece33 fix limit 2024-02-06 17:43:37 +09:00
samunohito 76977b38ab fix no clickable input text 2024-02-06 17:43:27 +09:00
samunohito c34d3234d5 fix api call 2024-02-06 17:24:18 +09:00
samunohito 041449e962 support pagination 2024-02-05 21:31:23 +09:00
samunohito f8529a01b9 add list v2 endpoint 2024-02-05 16:20:35 +09:00
samunohito d5db737469 fix performance 2024-02-05 08:35:26 +09:00
samunohito b0b28e0cb7 heightやめる 2024-02-05 08:32:59 +09:00
samunohito dcb6260e8f fix layout 2024-02-05 08:31:42 +09:00
samunohito dfe85d7722 support remote import 2024-02-04 18:42:03 +09:00
samunohito 84758b6eec support update and delete 2024-02-04 13:44:58 +09:00
samunohito 048e0b8323 fix 2024-02-04 10:14:55 +09:00
samunohito 4cb7e984aa fix performance 2024-02-04 09:25:36 +09:00
samunohito b37a27e154 fix patchData 2024-02-03 21:47:07 +09:00
samunohito 3cb3c3a148 remove log 2024-02-03 20:12:06 +09:00
samunohito 27020cb211 fix over 100 file drop 2024-02-03 20:11:11 +09:00
samunohito 57cd712064 fix number cell focus 2024-02-03 20:10:56 +09:00
samunohito 950c80bc7a fix 2024-02-03 18:51:35 +09:00
samunohito c88c8af8d9 add mimetype check 2024-02-03 18:51:09 +09:00
samunohito 9bb1e79c83 list maximum 2024-02-03 16:02:35 +09:00
samunohito f9e866e733 fix utils 2024-02-03 15:01:59 +09:00
samunohito e6ec32126f fix validation 2024-02-03 13:17:26 +09:00
samunohito 0ff55c0571 fix comment 2024-02-03 12:59:59 +09:00
samunohito a06ce1137a fix row remove 2024-02-03 12:19:37 +09:00
samunohito 295440a347 fix cell re-render bugs 2024-02-03 11:17:16 +09:00
samunohito ff48c77827 refactor grid 2024-02-02 10:28:08 +09:00
samunohito f96c7224a7 イベントの整理 2024-02-01 20:59:30 +09:00
samunohito 777920d739 wip イベント整理 2024-01-31 19:45:48 +09:00
samunohito f9516e6ae1 fix autogen 2024-01-31 14:28:24 +09:00
samunohito c370729336 Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/misskey-js/src/autogen/apiClientJSDoc.ts
#	packages/misskey-js/src/autogen/endpoint.ts
#	packages/misskey-js/src/autogen/entities.ts
#	packages/misskey-js/src/autogen/models.ts
#	packages/misskey-js/src/autogen/types.ts
2024-01-31 14:27:15 +09:00
samunohito 2b4bc4dccd support context menu on data area 2024-01-31 08:39:56 +09:00
samunohito 61066779c5 fix comment 2024-01-31 07:25:26 +09:00
samunohito 4fa943955a fix 2024-01-31 07:16:11 +09:00
samunohito dfb57afa11 support directory drag-drop 2024-01-30 22:59:32 +09:00
samunohito d453196c9f support choose pc file and drive file 2024-01-30 19:25:41 +09:00
samunohito fc67fa994b tweak comments 2024-01-30 10:07:54 +09:00
samunohito ad03ef03da fix display:none 2024-01-30 09:53:47 +09:00
samunohito b2c8548c67 fix border rendering 2024-01-30 07:59:21 +09:00
samunohito 3363de1070 support delete 2024-01-29 22:52:18 +09:00
samunohito 032687957f fix row selection 2024-01-29 13:41:13 +09:00
samunohito 18abb97f16 fix img autosize 2024-01-29 11:36:34 +09:00
samunohito de07347087 fix register logs 2024-01-29 11:28:48 +09:00
samunohito e0ad0f2aae fix size 2024-01-29 11:28:08 +09:00
samunohito ff14249507 fix 2024-01-29 09:35:26 +09:00
samunohito 53bad559a0 fix 2024-01-29 08:12:11 +09:00
samunohito e21c43e2aa fix 2024-01-28 22:57:10 +09:00
samunohito aacee3c970 wip 2024-01-28 17:43:36 +09:00
samunohito 8d1a5734cd wip 2024-01-27 12:00:58 +09:00
samunohito a2fcc81290 wip 2024-01-26 12:48:29 +09:00
samunohito e39ba6286f wip 2024-01-26 08:37:33 +09:00
samunohito 07efd85ffd Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/frontend/package.json
#	packages/misskey-js/src/autogen/apiClientJSDoc.ts
#	packages/misskey-js/src/autogen/endpoint.ts
#	packages/misskey-js/src/autogen/entities.ts
#	packages/misskey-js/src/autogen/models.ts
#	packages/misskey-js/src/autogen/types.ts
#	pnpm-lock.yaml
2024-01-23 12:32:17 +09:00
samunohito 9494c30c9f wip 2024-01-22 10:37:44 +09:00
samunohito 457a0a19ec wip 2024-01-21 11:39:52 +09:00
samunohito e47a2a52aa wip 2024-01-20 17:43:24 +09:00
samunohito feeafad523 Merge branch 'develop' into feature/emoji-grid
# Conflicts:
#	packages/misskey-js/src/api.types.ts
#	packages/misskey-js/src/autogen/apiClientJSDoc.ts
#	packages/misskey-js/src/autogen/endpoint.ts
#	packages/misskey-js/src/autogen/entities.ts
#	packages/misskey-js/src/autogen/models.ts
#	packages/misskey-js/src/autogen/types.ts
2024-01-20 17:41:45 +09:00
samunohito 5012cff445 wip 2024-01-20 17:01:45 +09:00
65 changed files with 7322 additions and 49 deletions

View File

@ -8,6 +8,8 @@
### General
- Feat: コンテンツの表示にログインを必須にできるように
- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように
- Feat: カスタム絵文字管理画面をリニューアル #10996
* β版として公開のため、旧画面も引き続き利用可能です
- Enhance: 依存関係の更新
- Enhance: l10nの更新

224
locales/index.d.ts vendored
View File

@ -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": {
/**
*

View File

@ -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: "ヘッダーを表示"

View File

@ -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"`)
}
}

View File

@ -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を生むので許可しない

View File

@ -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();

View File

@ -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 })));
}
}

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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,

View File

@ -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],

View File

@ -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 ?? [],

View File

@ -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,

View File

@ -85,7 +85,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
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,

View File

@ -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,
};
});
}
}

View File

@ -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);
}

View File

@ -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,
}
}

View File

@ -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'),

View File

@ -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>();

View File

@ -288,20 +288,23 @@ const align = () => {
const onOpened = () => {
emit('opened');
// NOTE: Chromatic undefined
if (content.value == null) return;
// contentnextTick
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 = () => {

View File

@ -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">&lt;</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">&gt;</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>

View File

@ -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>;

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -56,6 +56,7 @@ const toggle = () => {
display: flex;
transition: all 0.2s ease;
user-select: none;
align-items: center;
&:hover {
> .button {

View File

@ -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>;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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();

View File

@ -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: [],
};
}

View File

@ -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 },
};
}

View File

@ -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[];
};

View File

@ -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);
}
}
}
}

View File

@ -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;
}> {
}

View File

@ -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 = [];
}

View File

@ -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;

View File

@ -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, {

View File

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

View File

@ -0,0 +1,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) {
// IDID
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>

View File

@ -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) => {
// IDID
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>

View File

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

View File

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

View File

@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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;
}

View File

@ -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);
}
}
}

View File

@ -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',

View File

@ -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

View File

@ -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.
*

View File

@ -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.
*/

View File

@ -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'];

View File

@ -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'];

View File

@ -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.