From 9384f5399da39e53855beb8e7f8ded1aa56bf72e Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 27 Dec 2022 14:36:33 +0900 Subject: rename: client -> frontend --- packages/frontend/.eslintrc.js | 89 + packages/frontend/.vscode/settings.json | 11 + packages/frontend/@types/global.d.ts | 10 + packages/frontend/@types/theme.d.ts | 7 + packages/frontend/@types/vue.d.ts | 16 + packages/frontend/assets/about-icon.png | Bin 0 -> 20528 bytes packages/frontend/assets/dummy.png | Bin 0 -> 6285 bytes packages/frontend/assets/fedi.jpg | Bin 0 -> 77752 bytes packages/frontend/assets/label-red.svg | 6 + packages/frontend/assets/label.svg | 6 + packages/frontend/assets/misskey.svg | 7 + packages/frontend/assets/remove.png | Bin 0 -> 424 bytes packages/frontend/assets/sounds/aisha/1.mp3 | Bin 0 -> 34480 bytes packages/frontend/assets/sounds/aisha/2.mp3 | Bin 0 -> 24031 bytes packages/frontend/assets/sounds/aisha/3.mp3 | Bin 0 -> 29256 bytes .../assets/sounds/noizenecio/kick_gaba1.mp3 | Bin 0 -> 18866 bytes .../assets/sounds/noizenecio/kick_gaba2.mp3 | Bin 0 -> 27144 bytes .../assets/sounds/noizenecio/kick_gaba3.mp3 | Bin 0 -> 19853 bytes .../assets/sounds/noizenecio/kick_gaba4.mp3 | Bin 0 -> 19853 bytes .../assets/sounds/noizenecio/kick_gaba5.mp3 | Bin 0 -> 19853 bytes .../assets/sounds/noizenecio/kick_gaba6.mp3 | Bin 0 -> 19853 bytes .../assets/sounds/noizenecio/kick_gaba7.mp3 | Bin 0 -> 19853 bytes packages/frontend/assets/sounds/syuilo/down.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/kick.mp3 | Bin 0 -> 15672 bytes .../assets/sounds/syuilo/pirori-square-wet.mp3 | Bin 0 -> 139200 bytes .../frontend/assets/sounds/syuilo/pirori-wet.mp3 | Bin 0 -> 139200 bytes packages/frontend/assets/sounds/syuilo/pirori.mp3 | Bin 0 -> 19200 bytes packages/frontend/assets/sounds/syuilo/poi1.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/poi2.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/pope1.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/pope2.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/popo.mp3 | Bin 0 -> 18240 bytes .../frontend/assets/sounds/syuilo/queue-jammed.mp3 | Bin 0 -> 351466 bytes .../frontend/assets/sounds/syuilo/reverved.mp3 | Bin 0 -> 276480 bytes packages/frontend/assets/sounds/syuilo/ryukyu.mp3 | Bin 0 -> 139200 bytes packages/frontend/assets/sounds/syuilo/snare.mp3 | Bin 0 -> 26121 bytes .../frontend/assets/sounds/syuilo/square-pico.mp3 | Bin 0 -> 19200 bytes packages/frontend/assets/sounds/syuilo/triple.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/up.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/sounds/syuilo/waon.mp3 | Bin 0 -> 18240 bytes packages/frontend/assets/tagcanvas.min.js | 21 + packages/frontend/assets/unread.svg | 7 + packages/frontend/package.json | 94 ++ packages/frontend/src/account.ts | 238 +++ packages/frontend/src/components/MkAbuseReport.vue | 109 ++ .../src/components/MkAbuseReportWindow.vue | 65 + .../src/components/MkActiveUsersHeatmap.vue | 236 +++ packages/frontend/src/components/MkAnalogClock.vue | 225 +++ .../frontend/src/components/MkAutocomplete.vue | 476 ++++++ packages/frontend/src/components/MkAvatars.vue | 24 + packages/frontend/src/components/MkButton.vue | 227 +++ packages/frontend/src/components/MkCaptcha.vue | 118 ++ .../src/components/MkChannelFollowButton.vue | 129 ++ .../frontend/src/components/MkChannelPreview.vue | 154 ++ packages/frontend/src/components/MkChart.vue | 859 ++++++++++ .../frontend/src/components/MkChartTooltip.vue | 53 + packages/frontend/src/components/MkCode.core.vue | 20 + packages/frontend/src/components/MkCode.vue | 15 + packages/frontend/src/components/MkContainer.vue | 275 +++ packages/frontend/src/components/MkContextMenu.vue | 85 + .../frontend/src/components/MkCropperDialog.vue | 174 ++ packages/frontend/src/components/MkCwButton.vue | 62 + .../src/components/MkDateSeparatedList.vue | 189 +++ packages/frontend/src/components/MkDialog.vue | 208 +++ .../frontend/src/components/MkDigitalClock.vue | 77 + packages/frontend/src/components/MkDrive.file.vue | 334 ++++ .../frontend/src/components/MkDrive.folder.vue | 330 ++++ .../frontend/src/components/MkDrive.navFolder.vue | 147 ++ packages/frontend/src/components/MkDrive.vue | 801 +++++++++ .../src/components/MkDriveFileThumbnail.vue | 80 + .../src/components/MkDriveSelectDialog.vue | 58 + packages/frontend/src/components/MkDriveWindow.vue | 30 + .../src/components/MkEmojiPicker.section.vue | 36 + packages/frontend/src/components/MkEmojiPicker.vue | 569 +++++++ .../src/components/MkEmojiPickerDialog.vue | 73 + .../src/components/MkEmojiPickerWindow.vue | 180 ++ .../frontend/src/components/MkFeaturedPhotos.vue | 22 + .../src/components/MkFileCaptionEditWindow.vue | 175 ++ .../frontend/src/components/MkFileListForAdmin.vue | 117 ++ packages/frontend/src/components/MkFolder.vue | 159 ++ .../frontend/src/components/MkFollowButton.vue | 187 ++ .../frontend/src/components/MkForgotPassword.vue | 80 + packages/frontend/src/components/MkFormDialog.vue | 127 ++ packages/frontend/src/components/MkFormula.vue | 24 + packages/frontend/src/components/MkFormulaCore.vue | 34 + .../src/components/MkGalleryPostPreview.vue | 115 ++ packages/frontend/src/components/MkGoogle.vue | 51 + packages/frontend/src/components/MkImageViewer.vue | 77 + .../frontend/src/components/MkImgWithBlurhash.vue | 76 + packages/frontend/src/components/MkInfo.vue | 34 + .../frontend/src/components/MkInstanceCardMini.vue | 105 ++ .../frontend/src/components/MkInstanceStats.vue | 255 +++ .../frontend/src/components/MkInstanceTicker.vue | 80 + packages/frontend/src/components/MkKeyValue.vue | 58 + packages/frontend/src/components/MkLaunchPad.vue | 138 ++ packages/frontend/src/components/MkLink.vue | 47 + packages/frontend/src/components/MkMarquee.vue | 106 ++ packages/frontend/src/components/MkMediaBanner.vue | 102 ++ packages/frontend/src/components/MkMediaImage.vue | 130 ++ packages/frontend/src/components/MkMediaList.vue | 189 +++ packages/frontend/src/components/MkMediaVideo.vue | 88 + packages/frontend/src/components/MkMention.vue | 66 + packages/frontend/src/components/MkMenu.child.vue | 65 + packages/frontend/src/components/MkMenu.vue | 367 ++++ packages/frontend/src/components/MkMiniChart.vue | 73 + packages/frontend/src/components/MkModal.vue | 406 +++++ .../frontend/src/components/MkModalPageWindow.vue | 181 ++ packages/frontend/src/components/MkModalWindow.vue | 146 ++ packages/frontend/src/components/MkNote.vue | 658 ++++++++ .../frontend/src/components/MkNoteDetailed.vue | 677 ++++++++ packages/frontend/src/components/MkNoteHeader.vue | 75 + packages/frontend/src/components/MkNotePreview.vue | 112 ++ packages/frontend/src/components/MkNoteSimple.vue | 119 ++ packages/frontend/src/components/MkNoteSub.vue | 140 ++ packages/frontend/src/components/MkNotes.vue | 58 + .../frontend/src/components/MkNotification.vue | 323 ++++ .../src/components/MkNotificationSettingWindow.vue | 87 + .../src/components/MkNotificationToast.vue | 68 + .../frontend/src/components/MkNotifications.vue | 104 ++ packages/frontend/src/components/MkNumberDiff.vue | 47 + .../frontend/src/components/MkObjectView.value.vue | 160 ++ packages/frontend/src/components/MkObjectView.vue | 20 + packages/frontend/src/components/MkPagePreview.vue | 162 ++ packages/frontend/src/components/MkPageWindow.vue | 140 ++ packages/frontend/src/components/MkPagination.vue | 317 ++++ packages/frontend/src/components/MkPoll.vue | 152 ++ packages/frontend/src/components/MkPollEditor.vue | 219 +++ packages/frontend/src/components/MkPopupMenu.vue | 36 + packages/frontend/src/components/MkPostForm.vue | 1050 ++++++++++++ .../frontend/src/components/MkPostFormAttaches.vue | 168 ++ .../frontend/src/components/MkPostFormDialog.vue | 19 + .../components/MkPushNotificationAllowButton.vue | 167 ++ .../frontend/src/components/MkReactionIcon.vue | 13 + .../frontend/src/components/MkReactionTooltip.vue | 43 + .../src/components/MkReactionsViewer.details.vue | 96 ++ .../src/components/MkReactionsViewer.reaction.vue | 135 ++ .../frontend/src/components/MkReactionsViewer.vue | 36 + .../frontend/src/components/MkRemoteCaution.vue | 25 + .../frontend/src/components/MkRenoteButton.vue | 99 ++ packages/frontend/src/components/MkRipple.vue | 116 ++ packages/frontend/src/components/MkSample.vue | 116 ++ packages/frontend/src/components/MkSignin.vue | 259 +++ .../frontend/src/components/MkSigninDialog.vue | 46 + packages/frontend/src/components/MkSignup.vue | 246 +++ .../frontend/src/components/MkSignupDialog.vue | 46 + packages/frontend/src/components/MkSparkle.vue | 130 ++ .../frontend/src/components/MkSubNoteContent.vue | 90 + packages/frontend/src/components/MkSuperMenu.vue | 161 ++ packages/frontend/src/components/MkTab.vue | 73 + packages/frontend/src/components/MkTagCloud.vue | 90 + packages/frontend/src/components/MkTimeline.vue | 143 ++ packages/frontend/src/components/MkToast.vue | 66 + .../src/components/MkTokenGenerateWindow.vue | 90 + packages/frontend/src/components/MkTooltip.vue | 101 ++ packages/frontend/src/components/MkUpdated.vue | 51 + packages/frontend/src/components/MkUrlPreview.vue | 383 +++++ .../frontend/src/components/MkUrlPreviewPopup.vue | 45 + .../frontend/src/components/MkUserCardMini.vue | 99 ++ packages/frontend/src/components/MkUserInfo.vue | 137 ++ packages/frontend/src/components/MkUserList.vue | 39 + .../src/components/MkUserOnlineIndicator.vue | 45 + packages/frontend/src/components/MkUserPreview.vue | 184 ++ .../frontend/src/components/MkUserSelectDialog.vue | 190 +++ .../frontend/src/components/MkUsersTooltip.vue | 51 + packages/frontend/src/components/MkVisibility.vue | 48 + .../frontend/src/components/MkVisibilityPicker.vue | 159 ++ .../frontend/src/components/MkWaitingDialog.vue | 73 + packages/frontend/src/components/MkWidgets.vue | 165 ++ packages/frontend/src/components/MkWindow.vue | 571 +++++++ .../frontend/src/components/MkYoutubePlayer.vue | 72 + packages/frontend/src/components/form/checkbox.vue | 144 ++ packages/frontend/src/components/form/folder.vue | 107 ++ packages/frontend/src/components/form/input.vue | 263 +++ packages/frontend/src/components/form/link.vue | 95 ++ packages/frontend/src/components/form/radio.vue | 132 ++ packages/frontend/src/components/form/radios.vue | 83 + packages/frontend/src/components/form/range.vue | 259 +++ packages/frontend/src/components/form/section.vue | 43 + packages/frontend/src/components/form/select.vue | 279 +++ packages/frontend/src/components/form/slot.vue | 41 + packages/frontend/src/components/form/split.vue | 27 + packages/frontend/src/components/form/suspense.vue | 98 ++ packages/frontend/src/components/form/switch.vue | 144 ++ packages/frontend/src/components/form/textarea.vue | 260 +++ packages/frontend/src/components/global/MkA.vue | 102 ++ packages/frontend/src/components/global/MkAcct.vue | 27 + packages/frontend/src/components/global/MkAd.vue | 186 ++ .../frontend/src/components/global/MkAvatar.vue | 143 ++ .../frontend/src/components/global/MkEllipsis.vue | 34 + .../frontend/src/components/global/MkEmoji.vue | 81 + .../frontend/src/components/global/MkError.vue | 36 + .../frontend/src/components/global/MkLoading.vue | 101 ++ .../global/MkMisskeyFlavoredMarkdown.vue | 191 +++ .../src/components/global/MkPageHeader.vue | 368 ++++ .../frontend/src/components/global/MkSpacer.vue | 96 ++ .../src/components/global/MkStickyContainer.vue | 66 + packages/frontend/src/components/global/MkTime.vue | 56 + packages/frontend/src/components/global/MkUrl.vue | 89 + .../frontend/src/components/global/MkUserName.vue | 15 + .../frontend/src/components/global/RouterView.vue | 61 + packages/frontend/src/components/global/i18n.ts | 42 + packages/frontend/src/components/index.ts | 61 + packages/frontend/src/components/mfm.ts | 331 ++++ .../frontend/src/components/page/page.block.vue | 44 + .../frontend/src/components/page/page.button.vue | 66 + .../frontend/src/components/page/page.canvas.vue | 49 + .../frontend/src/components/page/page.counter.vue | 52 + packages/frontend/src/components/page/page.if.vue | 31 + .../frontend/src/components/page/page.image.vue | 28 + .../frontend/src/components/page/page.note.vue | 47 + .../src/components/page/page.number-input.vue | 55 + .../frontend/src/components/page/page.post.vue | 109 ++ .../src/components/page/page.radio-button.vue | 45 + .../frontend/src/components/page/page.section.vue | 60 + .../frontend/src/components/page/page.switch.vue | 55 + .../src/components/page/page.text-input.vue | 55 + .../frontend/src/components/page/page.text.vue | 68 + .../src/components/page/page.textarea-input.vue | 47 + .../frontend/src/components/page/page.textarea.vue | 39 + packages/frontend/src/components/page/page.vue | 85 + packages/frontend/src/config.ts | 15 + packages/frontend/src/const.ts | 45 + .../frontend/src/directives/adaptive-border.ts | 24 + packages/frontend/src/directives/anim.ts | 18 + packages/frontend/src/directives/appear.ts | 22 + packages/frontend/src/directives/click-anime.ts | 31 + packages/frontend/src/directives/follow-append.ts | 35 + packages/frontend/src/directives/get-size.ts | 54 + packages/frontend/src/directives/hotkey.ts | 24 + packages/frontend/src/directives/index.ts | 28 + packages/frontend/src/directives/panel.ts | 24 + packages/frontend/src/directives/ripple.ts | 18 + packages/frontend/src/directives/size.ts | 123 ++ packages/frontend/src/directives/tooltip.ts | 93 + packages/frontend/src/directives/user-preview.ts | 118 ++ packages/frontend/src/emojilist.json | 1785 ++++++++++++++++++++ packages/frontend/src/events.ts | 4 + packages/frontend/src/filters/bytes.ts | 9 + packages/frontend/src/filters/note.ts | 3 + packages/frontend/src/filters/number.ts | 1 + packages/frontend/src/filters/user.ts | 15 + packages/frontend/src/i18n.ts | 5 + packages/frontend/src/init.ts | 433 +++++ packages/frontend/src/instance.ts | 45 + packages/frontend/src/navbar.ts | 135 ++ packages/frontend/src/nirax.ts | 275 +++ packages/frontend/src/os.ts | 588 +++++++ packages/frontend/src/pages/_empty_.vue | 7 + packages/frontend/src/pages/_error_.vue | 89 + packages/frontend/src/pages/_loading_.vue | 6 + packages/frontend/src/pages/about-misskey.vue | 264 +++ packages/frontend/src/pages/about.emojis.vue | 134 ++ packages/frontend/src/pages/about.federation.vue | 106 ++ packages/frontend/src/pages/about.vue | 166 ++ packages/frontend/src/pages/admin-file.vue | 160 ++ packages/frontend/src/pages/admin/_header_.vue | 292 ++++ packages/frontend/src/pages/admin/abuses.vue | 97 ++ packages/frontend/src/pages/admin/ads.vue | 132 ++ .../frontend/src/pages/admin/announcements.vue | 112 ++ .../frontend/src/pages/admin/bot-protection.vue | 109 ++ packages/frontend/src/pages/admin/database.vue | 35 + .../frontend/src/pages/admin/email-settings.vue | 126 ++ .../frontend/src/pages/admin/emoji-edit-dialog.vue | 106 ++ packages/frontend/src/pages/admin/emojis.vue | 398 +++++ packages/frontend/src/pages/admin/files.vue | 120 ++ packages/frontend/src/pages/admin/index.vue | 316 ++++ .../frontend/src/pages/admin/instance-block.vue | 51 + .../src/pages/admin/integrations.discord.vue | 60 + .../src/pages/admin/integrations.github.vue | 60 + .../src/pages/admin/integrations.twitter.vue | 60 + packages/frontend/src/pages/admin/integrations.vue | 57 + packages/frontend/src/pages/admin/metrics.vue | 472 ++++++ .../frontend/src/pages/admin/object-storage.vue | 148 ++ .../frontend/src/pages/admin/other-settings.vue | 44 + .../src/pages/admin/overview.active-users.vue | 217 +++ .../src/pages/admin/overview.ap-requests.vue | 346 ++++ .../src/pages/admin/overview.federation.vue | 185 ++ .../frontend/src/pages/admin/overview.heatmap.vue | 15 + .../src/pages/admin/overview.instances.vue | 50 + .../src/pages/admin/overview.moderators.vue | 55 + packages/frontend/src/pages/admin/overview.pie.vue | 110 ++ .../src/pages/admin/overview.queue.chart.vue | 186 ++ .../frontend/src/pages/admin/overview.queue.vue | 127 ++ .../src/pages/admin/overview.retention.vue | 49 + .../frontend/src/pages/admin/overview.stats.vue | 155 ++ .../frontend/src/pages/admin/overview.users.vue | 57 + packages/frontend/src/pages/admin/overview.vue | 190 +++ .../frontend/src/pages/admin/proxy-account.vue | 62 + .../frontend/src/pages/admin/queue.chart.chart.vue | 186 ++ packages/frontend/src/pages/admin/queue.chart.vue | 149 ++ packages/frontend/src/pages/admin/queue.vue | 56 + packages/frontend/src/pages/admin/relays.vue | 103 ++ packages/frontend/src/pages/admin/security.vue | 179 ++ packages/frontend/src/pages/admin/settings.vue | 262 +++ packages/frontend/src/pages/admin/users.vue | 170 ++ packages/frontend/src/pages/announcements.vue | 69 + packages/frontend/src/pages/antenna-timeline.vue | 128 ++ packages/frontend/src/pages/api-console.vue | 89 + packages/frontend/src/pages/auth.form.vue | 60 + packages/frontend/src/pages/auth.vue | 91 + packages/frontend/src/pages/channel-editor.vue | 122 ++ packages/frontend/src/pages/channel.vue | 184 ++ packages/frontend/src/pages/channels.vue | 79 + packages/frontend/src/pages/clip.vue | 129 ++ packages/frontend/src/pages/drive.vue | 25 + packages/frontend/src/pages/emojis.emoji.vue | 72 + packages/frontend/src/pages/explore.featured.vue | 30 + packages/frontend/src/pages/explore.users.vue | 148 ++ packages/frontend/src/pages/explore.vue | 87 + packages/frontend/src/pages/favorites.vue | 49 + packages/frontend/src/pages/follow-requests.vue | 153 ++ packages/frontend/src/pages/follow.vue | 62 + packages/frontend/src/pages/gallery/edit.vue | 149 ++ packages/frontend/src/pages/gallery/index.vue | 139 ++ packages/frontend/src/pages/gallery/post.vue | 265 +++ packages/frontend/src/pages/instance-info.vue | 258 +++ packages/frontend/src/pages/messaging/index.vue | 327 ++++ .../src/pages/messaging/messaging-room.form.vue | 364 ++++ .../src/pages/messaging/messaging-room.message.vue | 367 ++++ .../src/pages/messaging/messaging-room.vue | 411 +++++ packages/frontend/src/pages/mfm-cheat-sheet.vue | 387 +++++ packages/frontend/src/pages/miauth.vue | 90 + packages/frontend/src/pages/my-antennas/create.vue | 46 + packages/frontend/src/pages/my-antennas/edit.vue | 43 + packages/frontend/src/pages/my-antennas/editor.vue | 155 ++ packages/frontend/src/pages/my-antennas/index.vue | 64 + packages/frontend/src/pages/my-clips/index.vue | 100 ++ packages/frontend/src/pages/my-lists/index.vue | 82 + packages/frontend/src/pages/my-lists/list.vue | 162 ++ packages/frontend/src/pages/not-found.vue | 22 + packages/frontend/src/pages/note.vue | 206 +++ packages/frontend/src/pages/notifications.vue | 95 ++ .../pages/page-editor/els/page-editor.el.image.vue | 63 + .../pages/page-editor/els/page-editor.el.note.vue | 57 + .../page-editor/els/page-editor.el.section.vue | 97 ++ .../pages/page-editor/els/page-editor.el.text.vue | 54 + .../src/pages/page-editor/page-editor.blocks.vue | 65 + .../pages/page-editor/page-editor.container.vue | 155 ++ .../frontend/src/pages/page-editor/page-editor.vue | 394 +++++ packages/frontend/src/pages/page.vue | 277 +++ packages/frontend/src/pages/pages.vue | 99 ++ packages/frontend/src/pages/preview.vue | 27 + packages/frontend/src/pages/registry.keys.vue | 96 ++ packages/frontend/src/pages/registry.value.vue | 123 ++ packages/frontend/src/pages/registry.vue | 74 + packages/frontend/src/pages/reset-password.vue | 59 + packages/frontend/src/pages/scratchpad.vue | 137 ++ packages/frontend/src/pages/search.vue | 38 + packages/frontend/src/pages/settings/2fa.vue | 216 +++ .../frontend/src/pages/settings/account-info.vue | 158 ++ packages/frontend/src/pages/settings/accounts.vue | 143 ++ packages/frontend/src/pages/settings/api.vue | 46 + packages/frontend/src/pages/settings/apps.vue | 96 ++ .../frontend/src/pages/settings/custom-css.vue | 46 + packages/frontend/src/pages/settings/deck.vue | 39 + .../frontend/src/pages/settings/delete-account.vue | 52 + packages/frontend/src/pages/settings/drive.vue | 145 ++ packages/frontend/src/pages/settings/email.vue | 111 ++ packages/frontend/src/pages/settings/general.vue | 196 +++ .../frontend/src/pages/settings/import-export.vue | 165 ++ packages/frontend/src/pages/settings/index.vue | 291 ++++ .../frontend/src/pages/settings/instance-mute.vue | 53 + .../frontend/src/pages/settings/integration.vue | 99 ++ .../frontend/src/pages/settings/mute-block.vue | 61 + packages/frontend/src/pages/settings/navbar.vue | 87 + .../frontend/src/pages/settings/notifications.vue | 90 + packages/frontend/src/pages/settings/other.vue | 47 + .../frontend/src/pages/settings/plugin.install.vue | 124 ++ packages/frontend/src/pages/settings/plugin.vue | 98 ++ .../src/pages/settings/preferences-backups.vue | 444 +++++ packages/frontend/src/pages/settings/privacy.vue | 100 ++ packages/frontend/src/pages/settings/profile.vue | 220 +++ packages/frontend/src/pages/settings/reaction.vue | 154 ++ packages/frontend/src/pages/settings/security.vue | 160 ++ .../frontend/src/pages/settings/sounds.sound.vue | 45 + packages/frontend/src/pages/settings/sounds.vue | 82 + .../src/pages/settings/statusbar.statusbar.vue | 140 ++ packages/frontend/src/pages/settings/statusbar.vue | 54 + .../frontend/src/pages/settings/theme.install.vue | 80 + .../frontend/src/pages/settings/theme.manage.vue | 78 + packages/frontend/src/pages/settings/theme.vue | 409 +++++ .../frontend/src/pages/settings/webhook.edit.vue | 95 ++ .../frontend/src/pages/settings/webhook.new.vue | 82 + packages/frontend/src/pages/settings/webhook.vue | 53 + packages/frontend/src/pages/settings/word-mute.vue | 128 ++ packages/frontend/src/pages/share.vue | 169 ++ packages/frontend/src/pages/signup-complete.vue | 41 + packages/frontend/src/pages/tag.vue | 35 + packages/frontend/src/pages/theme-editor.vue | 283 ++++ packages/frontend/src/pages/timeline.tutorial.vue | 142 ++ packages/frontend/src/pages/timeline.vue | 183 ++ packages/frontend/src/pages/user-info.vue | 485 ++++++ packages/frontend/src/pages/user-list-timeline.vue | 121 ++ packages/frontend/src/pages/user/clips.vue | 47 + packages/frontend/src/pages/user/follow-list.vue | 47 + packages/frontend/src/pages/user/followers.vue | 61 + packages/frontend/src/pages/user/following.vue | 61 + packages/frontend/src/pages/user/gallery.vue | 38 + packages/frontend/src/pages/user/home.vue | 530 ++++++ .../frontend/src/pages/user/index.activity.vue | 52 + packages/frontend/src/pages/user/index.photos.vue | 102 ++ .../frontend/src/pages/user/index.timeline.vue | 45 + packages/frontend/src/pages/user/index.vue | 113 ++ packages/frontend/src/pages/user/pages.vue | 30 + packages/frontend/src/pages/user/reactions.vue | 61 + packages/frontend/src/pages/welcome.entrance.a.vue | 309 ++++ packages/frontend/src/pages/welcome.entrance.b.vue | 237 +++ packages/frontend/src/pages/welcome.entrance.c.vue | 306 ++++ packages/frontend/src/pages/welcome.setup.vue | 89 + packages/frontend/src/pages/welcome.timeline.vue | 99 ++ packages/frontend/src/pages/welcome.vue | 30 + packages/frontend/src/pizzax.ts | 169 ++ packages/frontend/src/plugin.ts | 123 ++ packages/frontend/src/router.ts | 501 ++++++ packages/frontend/src/scripts/2fa.ts | 33 + packages/frontend/src/scripts/aiscript/api.ts | 43 + packages/frontend/src/scripts/array.ts | 149 ++ packages/frontend/src/scripts/autocomplete.ts | 276 +++ packages/frontend/src/scripts/chart-vline.ts | 21 + packages/frontend/src/scripts/check-word-mute.ts | 37 + packages/frontend/src/scripts/clone.ts | 18 + packages/frontend/src/scripts/collect-page-vars.ts | 68 + packages/frontend/src/scripts/contains.ts | 9 + packages/frontend/src/scripts/copy-to-clipboard.ts | 33 + packages/frontend/src/scripts/device-kind.ts | 10 + packages/frontend/src/scripts/emoji-base.ts | 20 + packages/frontend/src/scripts/emojilist.ts | 17 + .../src/scripts/extract-avg-color-from-blurhash.ts | 9 + packages/frontend/src/scripts/extract-mentions.ts | 11 + .../frontend/src/scripts/extract-url-from-mfm.ts | 19 + packages/frontend/src/scripts/focus.ts | 27 + packages/frontend/src/scripts/form.ts | 59 + .../frontend/src/scripts/format-time-string.ts | 50 + packages/frontend/src/scripts/gen-search-query.ts | 30 + .../frontend/src/scripts/get-account-from-id.ts | 7 + packages/frontend/src/scripts/get-note-menu.ts | 341 ++++ packages/frontend/src/scripts/get-note-summary.ts | 55 + .../frontend/src/scripts/get-static-image-url.ts | 19 + packages/frontend/src/scripts/get-user-menu.ts | 253 +++ packages/frontend/src/scripts/get-user-name.ts | 3 + packages/frontend/src/scripts/hotkey.ts | 90 + packages/frontend/src/scripts/hpml/block.ts | 109 ++ packages/frontend/src/scripts/hpml/evaluator.ts | 232 +++ packages/frontend/src/scripts/hpml/expr.ts | 79 + packages/frontend/src/scripts/hpml/index.ts | 103 ++ packages/frontend/src/scripts/hpml/lib.ts | 247 +++ packages/frontend/src/scripts/hpml/type-checker.ts | 191 +++ packages/frontend/src/scripts/i18n.ts | 29 + packages/frontend/src/scripts/idb-proxy.ts | 36 + packages/frontend/src/scripts/initialize-sw.ts | 13 + .../frontend/src/scripts/is-device-darkmode.ts | 3 + packages/frontend/src/scripts/keycode.ts | 33 + packages/frontend/src/scripts/langmap.ts | 666 ++++++++ packages/frontend/src/scripts/login-id.ts | 11 + packages/frontend/src/scripts/lookup-user.ts | 36 + packages/frontend/src/scripts/media-proxy.ts | 15 + packages/frontend/src/scripts/mfm-tags.ts | 1 + packages/frontend/src/scripts/page-metadata.ts | 41 + packages/frontend/src/scripts/physics.ts | 152 ++ packages/frontend/src/scripts/please-login.ts | 21 + packages/frontend/src/scripts/popout.ts | 23 + packages/frontend/src/scripts/popup-position.ts | 158 ++ packages/frontend/src/scripts/reaction-picker.ts | 41 + packages/frontend/src/scripts/safe-uri-decode.ts | 7 + packages/frontend/src/scripts/scroll.ts | 85 + packages/frontend/src/scripts/search.ts | 63 + packages/frontend/src/scripts/select-file.ts | 103 ++ .../frontend/src/scripts/show-suspended-dialog.ts | 10 + packages/frontend/src/scripts/shuffle.ts | 19 + packages/frontend/src/scripts/sound.ts | 66 + packages/frontend/src/scripts/sticky-sidebar.ts | 50 + packages/frontend/src/scripts/theme-editor.ts | 81 + packages/frontend/src/scripts/theme.ts | 148 ++ packages/frontend/src/scripts/time.ts | 39 + packages/frontend/src/scripts/timezones.ts | 49 + packages/frontend/src/scripts/touch.ts | 23 + packages/frontend/src/scripts/unison-reload.ts | 15 + packages/frontend/src/scripts/upload.ts | 137 ++ .../frontend/src/scripts/upload/compress-config.ts | 23 + packages/frontend/src/scripts/url.ts | 13 + packages/frontend/src/scripts/use-chart-tooltip.ts | 54 + packages/frontend/src/scripts/use-interval.ts | 24 + packages/frontend/src/scripts/use-leave-guard.ts | 47 + packages/frontend/src/scripts/use-note-capture.ts | 110 ++ packages/frontend/src/scripts/use-tooltip.ts | 86 + packages/frontend/src/store.ts | 383 +++++ packages/frontend/src/stream.ts | 8 + packages/frontend/src/style.scss | 584 +++++++ packages/frontend/src/theme-store.ts | 34 + packages/frontend/src/themes/_dark.json5 | 99 ++ packages/frontend/src/themes/_light.json5 | 99 ++ packages/frontend/src/themes/d-astro.json5 | 78 + packages/frontend/src/themes/d-botanical.json5 | 26 + packages/frontend/src/themes/d-cherry.json5 | 20 + packages/frontend/src/themes/d-dark.json5 | 26 + packages/frontend/src/themes/d-future.json5 | 27 + packages/frontend/src/themes/d-green-lime.json5 | 24 + packages/frontend/src/themes/d-green-orange.json5 | 24 + packages/frontend/src/themes/d-ice.json5 | 13 + packages/frontend/src/themes/d-persimmon.json5 | 25 + packages/frontend/src/themes/d-u0.json5 | 88 + packages/frontend/src/themes/l-apricot.json5 | 22 + packages/frontend/src/themes/l-cherry.json5 | 21 + packages/frontend/src/themes/l-coffee.json5 | 21 + packages/frontend/src/themes/l-light.json5 | 20 + packages/frontend/src/themes/l-rainy.json5 | 21 + packages/frontend/src/themes/l-sushi.json5 | 18 + packages/frontend/src/themes/l-u0.json5 | 87 + packages/frontend/src/themes/l-vivid.json5 | 82 + packages/frontend/src/types/menu.ts | 21 + packages/frontend/src/ui/_common_/common.vue | 139 ++ .../frontend/src/ui/_common_/navbar-for-mobile.vue | 314 ++++ packages/frontend/src/ui/_common_/navbar.vue | 521 ++++++ .../src/ui/_common_/statusbar-federation.vue | 108 ++ .../frontend/src/ui/_common_/statusbar-rss.vue | 93 + .../src/ui/_common_/statusbar-user-list.vue | 113 ++ packages/frontend/src/ui/_common_/statusbars.vue | 92 + .../frontend/src/ui/_common_/stream-indicator.vue | 61 + packages/frontend/src/ui/_common_/sw-inject.ts | 35 + packages/frontend/src/ui/_common_/upload.vue | 129 ++ packages/frontend/src/ui/classic.header.vue | 217 +++ packages/frontend/src/ui/classic.sidebar.vue | 268 +++ packages/frontend/src/ui/classic.vue | 320 ++++ packages/frontend/src/ui/classic.widgets.vue | 84 + packages/frontend/src/ui/deck.vue | 435 +++++ packages/frontend/src/ui/deck/antenna-column.vue | 70 + packages/frontend/src/ui/deck/column-core.vue | 34 + packages/frontend/src/ui/deck/column.vue | 398 +++++ packages/frontend/src/ui/deck/deck-store.ts | 296 ++++ packages/frontend/src/ui/deck/direct-column.vue | 31 + packages/frontend/src/ui/deck/list-column.vue | 58 + packages/frontend/src/ui/deck/main-column.vue | 68 + packages/frontend/src/ui/deck/mentions-column.vue | 28 + .../frontend/src/ui/deck/notifications-column.vue | 44 + packages/frontend/src/ui/deck/tl-column.vue | 119 ++ packages/frontend/src/ui/deck/widgets-column.vue | 69 + packages/frontend/src/ui/universal.vue | 390 +++++ packages/frontend/src/ui/universal.widgets.vue | 71 + packages/frontend/src/ui/visitor.vue | 19 + packages/frontend/src/ui/visitor/a.vue | 259 +++ packages/frontend/src/ui/visitor/b.vue | 248 +++ packages/frontend/src/ui/visitor/header.vue | 228 +++ packages/frontend/src/ui/visitor/kanban.vue | 257 +++ packages/frontend/src/ui/zen.vue | 34 + .../frontend/src/widgets/activity.calendar.vue | 81 + packages/frontend/src/widgets/activity.chart.vue | 92 + packages/frontend/src/widgets/activity.vue | 90 + packages/frontend/src/widgets/aichan.vue | 74 + packages/frontend/src/widgets/aiscript.vue | 175 ++ packages/frontend/src/widgets/button.vue | 103 ++ packages/frontend/src/widgets/calendar.vue | 213 +++ packages/frontend/src/widgets/clock.vue | 203 +++ packages/frontend/src/widgets/digital-clock.vue | 92 + packages/frontend/src/widgets/federation.vue | 147 ++ packages/frontend/src/widgets/index.ts | 53 + packages/frontend/src/widgets/instance-cloud.vue | 81 + packages/frontend/src/widgets/job-queue.vue | 197 +++ packages/frontend/src/widgets/memo.vue | 111 ++ packages/frontend/src/widgets/notifications.vue | 70 + packages/frontend/src/widgets/online-users.vue | 78 + packages/frontend/src/widgets/photos.vue | 123 ++ packages/frontend/src/widgets/post-form.vue | 35 + packages/frontend/src/widgets/rss-ticker.vue | 152 ++ packages/frontend/src/widgets/rss.vue | 96 ++ .../frontend/src/widgets/server-metric/cpu-mem.vue | 167 ++ .../frontend/src/widgets/server-metric/cpu.vue | 65 + .../frontend/src/widgets/server-metric/disk.vue | 57 + .../frontend/src/widgets/server-metric/index.vue | 87 + .../frontend/src/widgets/server-metric/mem.vue | 73 + .../frontend/src/widgets/server-metric/net.vue | 140 ++ .../frontend/src/widgets/server-metric/pie.vue | 52 + packages/frontend/src/widgets/slideshow.vue | 159 ++ packages/frontend/src/widgets/timeline.vue | 129 ++ packages/frontend/src/widgets/trends.vue | 120 ++ packages/frontend/src/widgets/unix-clock.vue | 116 ++ packages/frontend/src/widgets/user-list.vue | 136 ++ packages/frontend/src/widgets/widget.ts | 73 + packages/frontend/tsconfig.json | 47 + packages/frontend/vite.config.ts | 70 + packages/frontend/vite.json5.ts | 38 + 580 files changed, 68857 insertions(+) create mode 100644 packages/frontend/.eslintrc.js create mode 100644 packages/frontend/.vscode/settings.json create mode 100644 packages/frontend/@types/global.d.ts create mode 100644 packages/frontend/@types/theme.d.ts create mode 100644 packages/frontend/@types/vue.d.ts create mode 100644 packages/frontend/assets/about-icon.png create mode 100644 packages/frontend/assets/dummy.png create mode 100644 packages/frontend/assets/fedi.jpg create mode 100644 packages/frontend/assets/label-red.svg create mode 100644 packages/frontend/assets/label.svg create mode 100644 packages/frontend/assets/misskey.svg create mode 100644 packages/frontend/assets/remove.png create mode 100644 packages/frontend/assets/sounds/aisha/1.mp3 create mode 100644 packages/frontend/assets/sounds/aisha/2.mp3 create mode 100644 packages/frontend/assets/sounds/aisha/3.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3 create mode 100644 packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/down.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/kick.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/pirori.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/poi1.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/poi2.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/pope1.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/pope2.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/popo.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/reverved.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/ryukyu.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/snare.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/square-pico.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/triple.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/up.mp3 create mode 100644 packages/frontend/assets/sounds/syuilo/waon.mp3 create mode 100644 packages/frontend/assets/tagcanvas.min.js create mode 100644 packages/frontend/assets/unread.svg create mode 100644 packages/frontend/package.json create mode 100644 packages/frontend/src/account.ts create mode 100644 packages/frontend/src/components/MkAbuseReport.vue create mode 100644 packages/frontend/src/components/MkAbuseReportWindow.vue create mode 100644 packages/frontend/src/components/MkActiveUsersHeatmap.vue create mode 100644 packages/frontend/src/components/MkAnalogClock.vue create mode 100644 packages/frontend/src/components/MkAutocomplete.vue create mode 100644 packages/frontend/src/components/MkAvatars.vue create mode 100644 packages/frontend/src/components/MkButton.vue create mode 100644 packages/frontend/src/components/MkCaptcha.vue create mode 100644 packages/frontend/src/components/MkChannelFollowButton.vue create mode 100644 packages/frontend/src/components/MkChannelPreview.vue create mode 100644 packages/frontend/src/components/MkChart.vue create mode 100644 packages/frontend/src/components/MkChartTooltip.vue create mode 100644 packages/frontend/src/components/MkCode.core.vue create mode 100644 packages/frontend/src/components/MkCode.vue create mode 100644 packages/frontend/src/components/MkContainer.vue create mode 100644 packages/frontend/src/components/MkContextMenu.vue create mode 100644 packages/frontend/src/components/MkCropperDialog.vue create mode 100644 packages/frontend/src/components/MkCwButton.vue create mode 100644 packages/frontend/src/components/MkDateSeparatedList.vue create mode 100644 packages/frontend/src/components/MkDialog.vue create mode 100644 packages/frontend/src/components/MkDigitalClock.vue create mode 100644 packages/frontend/src/components/MkDrive.file.vue create mode 100644 packages/frontend/src/components/MkDrive.folder.vue create mode 100644 packages/frontend/src/components/MkDrive.navFolder.vue create mode 100644 packages/frontend/src/components/MkDrive.vue create mode 100644 packages/frontend/src/components/MkDriveFileThumbnail.vue create mode 100644 packages/frontend/src/components/MkDriveSelectDialog.vue create mode 100644 packages/frontend/src/components/MkDriveWindow.vue create mode 100644 packages/frontend/src/components/MkEmojiPicker.section.vue create mode 100644 packages/frontend/src/components/MkEmojiPicker.vue create mode 100644 packages/frontend/src/components/MkEmojiPickerDialog.vue create mode 100644 packages/frontend/src/components/MkEmojiPickerWindow.vue create mode 100644 packages/frontend/src/components/MkFeaturedPhotos.vue create mode 100644 packages/frontend/src/components/MkFileCaptionEditWindow.vue create mode 100644 packages/frontend/src/components/MkFileListForAdmin.vue create mode 100644 packages/frontend/src/components/MkFolder.vue create mode 100644 packages/frontend/src/components/MkFollowButton.vue create mode 100644 packages/frontend/src/components/MkForgotPassword.vue create mode 100644 packages/frontend/src/components/MkFormDialog.vue create mode 100644 packages/frontend/src/components/MkFormula.vue create mode 100644 packages/frontend/src/components/MkFormulaCore.vue create mode 100644 packages/frontend/src/components/MkGalleryPostPreview.vue create mode 100644 packages/frontend/src/components/MkGoogle.vue create mode 100644 packages/frontend/src/components/MkImageViewer.vue create mode 100644 packages/frontend/src/components/MkImgWithBlurhash.vue create mode 100644 packages/frontend/src/components/MkInfo.vue create mode 100644 packages/frontend/src/components/MkInstanceCardMini.vue create mode 100644 packages/frontend/src/components/MkInstanceStats.vue create mode 100644 packages/frontend/src/components/MkInstanceTicker.vue create mode 100644 packages/frontend/src/components/MkKeyValue.vue create mode 100644 packages/frontend/src/components/MkLaunchPad.vue create mode 100644 packages/frontend/src/components/MkLink.vue create mode 100644 packages/frontend/src/components/MkMarquee.vue create mode 100644 packages/frontend/src/components/MkMediaBanner.vue create mode 100644 packages/frontend/src/components/MkMediaImage.vue create mode 100644 packages/frontend/src/components/MkMediaList.vue create mode 100644 packages/frontend/src/components/MkMediaVideo.vue create mode 100644 packages/frontend/src/components/MkMention.vue create mode 100644 packages/frontend/src/components/MkMenu.child.vue create mode 100644 packages/frontend/src/components/MkMenu.vue create mode 100644 packages/frontend/src/components/MkMiniChart.vue create mode 100644 packages/frontend/src/components/MkModal.vue create mode 100644 packages/frontend/src/components/MkModalPageWindow.vue create mode 100644 packages/frontend/src/components/MkModalWindow.vue create mode 100644 packages/frontend/src/components/MkNote.vue create mode 100644 packages/frontend/src/components/MkNoteDetailed.vue create mode 100644 packages/frontend/src/components/MkNoteHeader.vue create mode 100644 packages/frontend/src/components/MkNotePreview.vue create mode 100644 packages/frontend/src/components/MkNoteSimple.vue create mode 100644 packages/frontend/src/components/MkNoteSub.vue create mode 100644 packages/frontend/src/components/MkNotes.vue create mode 100644 packages/frontend/src/components/MkNotification.vue create mode 100644 packages/frontend/src/components/MkNotificationSettingWindow.vue create mode 100644 packages/frontend/src/components/MkNotificationToast.vue create mode 100644 packages/frontend/src/components/MkNotifications.vue create mode 100644 packages/frontend/src/components/MkNumberDiff.vue create mode 100644 packages/frontend/src/components/MkObjectView.value.vue create mode 100644 packages/frontend/src/components/MkObjectView.vue create mode 100644 packages/frontend/src/components/MkPagePreview.vue create mode 100644 packages/frontend/src/components/MkPageWindow.vue create mode 100644 packages/frontend/src/components/MkPagination.vue create mode 100644 packages/frontend/src/components/MkPoll.vue create mode 100644 packages/frontend/src/components/MkPollEditor.vue create mode 100644 packages/frontend/src/components/MkPopupMenu.vue create mode 100644 packages/frontend/src/components/MkPostForm.vue create mode 100644 packages/frontend/src/components/MkPostFormAttaches.vue create mode 100644 packages/frontend/src/components/MkPostFormDialog.vue create mode 100644 packages/frontend/src/components/MkPushNotificationAllowButton.vue create mode 100644 packages/frontend/src/components/MkReactionIcon.vue create mode 100644 packages/frontend/src/components/MkReactionTooltip.vue create mode 100644 packages/frontend/src/components/MkReactionsViewer.details.vue create mode 100644 packages/frontend/src/components/MkReactionsViewer.reaction.vue create mode 100644 packages/frontend/src/components/MkReactionsViewer.vue create mode 100644 packages/frontend/src/components/MkRemoteCaution.vue create mode 100644 packages/frontend/src/components/MkRenoteButton.vue create mode 100644 packages/frontend/src/components/MkRipple.vue create mode 100644 packages/frontend/src/components/MkSample.vue create mode 100644 packages/frontend/src/components/MkSignin.vue create mode 100644 packages/frontend/src/components/MkSigninDialog.vue create mode 100644 packages/frontend/src/components/MkSignup.vue create mode 100644 packages/frontend/src/components/MkSignupDialog.vue create mode 100644 packages/frontend/src/components/MkSparkle.vue create mode 100644 packages/frontend/src/components/MkSubNoteContent.vue create mode 100644 packages/frontend/src/components/MkSuperMenu.vue create mode 100644 packages/frontend/src/components/MkTab.vue create mode 100644 packages/frontend/src/components/MkTagCloud.vue create mode 100644 packages/frontend/src/components/MkTimeline.vue create mode 100644 packages/frontend/src/components/MkToast.vue create mode 100644 packages/frontend/src/components/MkTokenGenerateWindow.vue create mode 100644 packages/frontend/src/components/MkTooltip.vue create mode 100644 packages/frontend/src/components/MkUpdated.vue create mode 100644 packages/frontend/src/components/MkUrlPreview.vue create mode 100644 packages/frontend/src/components/MkUrlPreviewPopup.vue create mode 100644 packages/frontend/src/components/MkUserCardMini.vue create mode 100644 packages/frontend/src/components/MkUserInfo.vue create mode 100644 packages/frontend/src/components/MkUserList.vue create mode 100644 packages/frontend/src/components/MkUserOnlineIndicator.vue create mode 100644 packages/frontend/src/components/MkUserPreview.vue create mode 100644 packages/frontend/src/components/MkUserSelectDialog.vue create mode 100644 packages/frontend/src/components/MkUsersTooltip.vue create mode 100644 packages/frontend/src/components/MkVisibility.vue create mode 100644 packages/frontend/src/components/MkVisibilityPicker.vue create mode 100644 packages/frontend/src/components/MkWaitingDialog.vue create mode 100644 packages/frontend/src/components/MkWidgets.vue create mode 100644 packages/frontend/src/components/MkWindow.vue create mode 100644 packages/frontend/src/components/MkYoutubePlayer.vue create mode 100644 packages/frontend/src/components/form/checkbox.vue create mode 100644 packages/frontend/src/components/form/folder.vue create mode 100644 packages/frontend/src/components/form/input.vue create mode 100644 packages/frontend/src/components/form/link.vue create mode 100644 packages/frontend/src/components/form/radio.vue create mode 100644 packages/frontend/src/components/form/radios.vue create mode 100644 packages/frontend/src/components/form/range.vue create mode 100644 packages/frontend/src/components/form/section.vue create mode 100644 packages/frontend/src/components/form/select.vue create mode 100644 packages/frontend/src/components/form/slot.vue create mode 100644 packages/frontend/src/components/form/split.vue create mode 100644 packages/frontend/src/components/form/suspense.vue create mode 100644 packages/frontend/src/components/form/switch.vue create mode 100644 packages/frontend/src/components/form/textarea.vue create mode 100644 packages/frontend/src/components/global/MkA.vue create mode 100644 packages/frontend/src/components/global/MkAcct.vue create mode 100644 packages/frontend/src/components/global/MkAd.vue create mode 100644 packages/frontend/src/components/global/MkAvatar.vue create mode 100644 packages/frontend/src/components/global/MkEllipsis.vue create mode 100644 packages/frontend/src/components/global/MkEmoji.vue create mode 100644 packages/frontend/src/components/global/MkError.vue create mode 100644 packages/frontend/src/components/global/MkLoading.vue create mode 100644 packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue create mode 100644 packages/frontend/src/components/global/MkPageHeader.vue create mode 100644 packages/frontend/src/components/global/MkSpacer.vue create mode 100644 packages/frontend/src/components/global/MkStickyContainer.vue create mode 100644 packages/frontend/src/components/global/MkTime.vue create mode 100644 packages/frontend/src/components/global/MkUrl.vue create mode 100644 packages/frontend/src/components/global/MkUserName.vue create mode 100644 packages/frontend/src/components/global/RouterView.vue create mode 100644 packages/frontend/src/components/global/i18n.ts create mode 100644 packages/frontend/src/components/index.ts create mode 100644 packages/frontend/src/components/mfm.ts create mode 100644 packages/frontend/src/components/page/page.block.vue create mode 100644 packages/frontend/src/components/page/page.button.vue create mode 100644 packages/frontend/src/components/page/page.canvas.vue create mode 100644 packages/frontend/src/components/page/page.counter.vue create mode 100644 packages/frontend/src/components/page/page.if.vue create mode 100644 packages/frontend/src/components/page/page.image.vue create mode 100644 packages/frontend/src/components/page/page.note.vue create mode 100644 packages/frontend/src/components/page/page.number-input.vue create mode 100644 packages/frontend/src/components/page/page.post.vue create mode 100644 packages/frontend/src/components/page/page.radio-button.vue create mode 100644 packages/frontend/src/components/page/page.section.vue create mode 100644 packages/frontend/src/components/page/page.switch.vue create mode 100644 packages/frontend/src/components/page/page.text-input.vue create mode 100644 packages/frontend/src/components/page/page.text.vue create mode 100644 packages/frontend/src/components/page/page.textarea-input.vue create mode 100644 packages/frontend/src/components/page/page.textarea.vue create mode 100644 packages/frontend/src/components/page/page.vue create mode 100644 packages/frontend/src/config.ts create mode 100644 packages/frontend/src/const.ts create mode 100644 packages/frontend/src/directives/adaptive-border.ts create mode 100644 packages/frontend/src/directives/anim.ts create mode 100644 packages/frontend/src/directives/appear.ts create mode 100644 packages/frontend/src/directives/click-anime.ts create mode 100644 packages/frontend/src/directives/follow-append.ts create mode 100644 packages/frontend/src/directives/get-size.ts create mode 100644 packages/frontend/src/directives/hotkey.ts create mode 100644 packages/frontend/src/directives/index.ts create mode 100644 packages/frontend/src/directives/panel.ts create mode 100644 packages/frontend/src/directives/ripple.ts create mode 100644 packages/frontend/src/directives/size.ts create mode 100644 packages/frontend/src/directives/tooltip.ts create mode 100644 packages/frontend/src/directives/user-preview.ts create mode 100644 packages/frontend/src/emojilist.json create mode 100644 packages/frontend/src/events.ts create mode 100644 packages/frontend/src/filters/bytes.ts create mode 100644 packages/frontend/src/filters/note.ts create mode 100644 packages/frontend/src/filters/number.ts create mode 100644 packages/frontend/src/filters/user.ts create mode 100644 packages/frontend/src/i18n.ts create mode 100644 packages/frontend/src/init.ts create mode 100644 packages/frontend/src/instance.ts create mode 100644 packages/frontend/src/navbar.ts create mode 100644 packages/frontend/src/nirax.ts create mode 100644 packages/frontend/src/os.ts create mode 100644 packages/frontend/src/pages/_empty_.vue create mode 100644 packages/frontend/src/pages/_error_.vue create mode 100644 packages/frontend/src/pages/_loading_.vue create mode 100644 packages/frontend/src/pages/about-misskey.vue create mode 100644 packages/frontend/src/pages/about.emojis.vue create mode 100644 packages/frontend/src/pages/about.federation.vue create mode 100644 packages/frontend/src/pages/about.vue create mode 100644 packages/frontend/src/pages/admin-file.vue create mode 100644 packages/frontend/src/pages/admin/_header_.vue create mode 100644 packages/frontend/src/pages/admin/abuses.vue create mode 100644 packages/frontend/src/pages/admin/ads.vue create mode 100644 packages/frontend/src/pages/admin/announcements.vue create mode 100644 packages/frontend/src/pages/admin/bot-protection.vue create mode 100644 packages/frontend/src/pages/admin/database.vue create mode 100644 packages/frontend/src/pages/admin/email-settings.vue create mode 100644 packages/frontend/src/pages/admin/emoji-edit-dialog.vue create mode 100644 packages/frontend/src/pages/admin/emojis.vue create mode 100644 packages/frontend/src/pages/admin/files.vue create mode 100644 packages/frontend/src/pages/admin/index.vue create mode 100644 packages/frontend/src/pages/admin/instance-block.vue create mode 100644 packages/frontend/src/pages/admin/integrations.discord.vue create mode 100644 packages/frontend/src/pages/admin/integrations.github.vue create mode 100644 packages/frontend/src/pages/admin/integrations.twitter.vue create mode 100644 packages/frontend/src/pages/admin/integrations.vue create mode 100644 packages/frontend/src/pages/admin/metrics.vue create mode 100644 packages/frontend/src/pages/admin/object-storage.vue create mode 100644 packages/frontend/src/pages/admin/other-settings.vue create mode 100644 packages/frontend/src/pages/admin/overview.active-users.vue create mode 100644 packages/frontend/src/pages/admin/overview.ap-requests.vue create mode 100644 packages/frontend/src/pages/admin/overview.federation.vue create mode 100644 packages/frontend/src/pages/admin/overview.heatmap.vue create mode 100644 packages/frontend/src/pages/admin/overview.instances.vue create mode 100644 packages/frontend/src/pages/admin/overview.moderators.vue create mode 100644 packages/frontend/src/pages/admin/overview.pie.vue create mode 100644 packages/frontend/src/pages/admin/overview.queue.chart.vue create mode 100644 packages/frontend/src/pages/admin/overview.queue.vue create mode 100644 packages/frontend/src/pages/admin/overview.retention.vue create mode 100644 packages/frontend/src/pages/admin/overview.stats.vue create mode 100644 packages/frontend/src/pages/admin/overview.users.vue create mode 100644 packages/frontend/src/pages/admin/overview.vue create mode 100644 packages/frontend/src/pages/admin/proxy-account.vue create mode 100644 packages/frontend/src/pages/admin/queue.chart.chart.vue create mode 100644 packages/frontend/src/pages/admin/queue.chart.vue create mode 100644 packages/frontend/src/pages/admin/queue.vue create mode 100644 packages/frontend/src/pages/admin/relays.vue create mode 100644 packages/frontend/src/pages/admin/security.vue create mode 100644 packages/frontend/src/pages/admin/settings.vue create mode 100644 packages/frontend/src/pages/admin/users.vue create mode 100644 packages/frontend/src/pages/announcements.vue create mode 100644 packages/frontend/src/pages/antenna-timeline.vue create mode 100644 packages/frontend/src/pages/api-console.vue create mode 100644 packages/frontend/src/pages/auth.form.vue create mode 100644 packages/frontend/src/pages/auth.vue create mode 100644 packages/frontend/src/pages/channel-editor.vue create mode 100644 packages/frontend/src/pages/channel.vue create mode 100644 packages/frontend/src/pages/channels.vue create mode 100644 packages/frontend/src/pages/clip.vue create mode 100644 packages/frontend/src/pages/drive.vue create mode 100644 packages/frontend/src/pages/emojis.emoji.vue create mode 100644 packages/frontend/src/pages/explore.featured.vue create mode 100644 packages/frontend/src/pages/explore.users.vue create mode 100644 packages/frontend/src/pages/explore.vue create mode 100644 packages/frontend/src/pages/favorites.vue create mode 100644 packages/frontend/src/pages/follow-requests.vue create mode 100644 packages/frontend/src/pages/follow.vue create mode 100644 packages/frontend/src/pages/gallery/edit.vue create mode 100644 packages/frontend/src/pages/gallery/index.vue create mode 100644 packages/frontend/src/pages/gallery/post.vue create mode 100644 packages/frontend/src/pages/instance-info.vue create mode 100644 packages/frontend/src/pages/messaging/index.vue create mode 100644 packages/frontend/src/pages/messaging/messaging-room.form.vue create mode 100644 packages/frontend/src/pages/messaging/messaging-room.message.vue create mode 100644 packages/frontend/src/pages/messaging/messaging-room.vue create mode 100644 packages/frontend/src/pages/mfm-cheat-sheet.vue create mode 100644 packages/frontend/src/pages/miauth.vue create mode 100644 packages/frontend/src/pages/my-antennas/create.vue create mode 100644 packages/frontend/src/pages/my-antennas/edit.vue create mode 100644 packages/frontend/src/pages/my-antennas/editor.vue create mode 100644 packages/frontend/src/pages/my-antennas/index.vue create mode 100644 packages/frontend/src/pages/my-clips/index.vue create mode 100644 packages/frontend/src/pages/my-lists/index.vue create mode 100644 packages/frontend/src/pages/my-lists/list.vue create mode 100644 packages/frontend/src/pages/not-found.vue create mode 100644 packages/frontend/src/pages/note.vue create mode 100644 packages/frontend/src/pages/notifications.vue create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue create mode 100644 packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue create mode 100644 packages/frontend/src/pages/page-editor/page-editor.blocks.vue create mode 100644 packages/frontend/src/pages/page-editor/page-editor.container.vue create mode 100644 packages/frontend/src/pages/page-editor/page-editor.vue create mode 100644 packages/frontend/src/pages/page.vue create mode 100644 packages/frontend/src/pages/pages.vue create mode 100644 packages/frontend/src/pages/preview.vue create mode 100644 packages/frontend/src/pages/registry.keys.vue create mode 100644 packages/frontend/src/pages/registry.value.vue create mode 100644 packages/frontend/src/pages/registry.vue create mode 100644 packages/frontend/src/pages/reset-password.vue create mode 100644 packages/frontend/src/pages/scratchpad.vue create mode 100644 packages/frontend/src/pages/search.vue create mode 100644 packages/frontend/src/pages/settings/2fa.vue create mode 100644 packages/frontend/src/pages/settings/account-info.vue create mode 100644 packages/frontend/src/pages/settings/accounts.vue create mode 100644 packages/frontend/src/pages/settings/api.vue create mode 100644 packages/frontend/src/pages/settings/apps.vue create mode 100644 packages/frontend/src/pages/settings/custom-css.vue create mode 100644 packages/frontend/src/pages/settings/deck.vue create mode 100644 packages/frontend/src/pages/settings/delete-account.vue create mode 100644 packages/frontend/src/pages/settings/drive.vue create mode 100644 packages/frontend/src/pages/settings/email.vue create mode 100644 packages/frontend/src/pages/settings/general.vue create mode 100644 packages/frontend/src/pages/settings/import-export.vue create mode 100644 packages/frontend/src/pages/settings/index.vue create mode 100644 packages/frontend/src/pages/settings/instance-mute.vue create mode 100644 packages/frontend/src/pages/settings/integration.vue create mode 100644 packages/frontend/src/pages/settings/mute-block.vue create mode 100644 packages/frontend/src/pages/settings/navbar.vue create mode 100644 packages/frontend/src/pages/settings/notifications.vue create mode 100644 packages/frontend/src/pages/settings/other.vue create mode 100644 packages/frontend/src/pages/settings/plugin.install.vue create mode 100644 packages/frontend/src/pages/settings/plugin.vue create mode 100644 packages/frontend/src/pages/settings/preferences-backups.vue create mode 100644 packages/frontend/src/pages/settings/privacy.vue create mode 100644 packages/frontend/src/pages/settings/profile.vue create mode 100644 packages/frontend/src/pages/settings/reaction.vue create mode 100644 packages/frontend/src/pages/settings/security.vue create mode 100644 packages/frontend/src/pages/settings/sounds.sound.vue create mode 100644 packages/frontend/src/pages/settings/sounds.vue create mode 100644 packages/frontend/src/pages/settings/statusbar.statusbar.vue create mode 100644 packages/frontend/src/pages/settings/statusbar.vue create mode 100644 packages/frontend/src/pages/settings/theme.install.vue create mode 100644 packages/frontend/src/pages/settings/theme.manage.vue create mode 100644 packages/frontend/src/pages/settings/theme.vue create mode 100644 packages/frontend/src/pages/settings/webhook.edit.vue create mode 100644 packages/frontend/src/pages/settings/webhook.new.vue create mode 100644 packages/frontend/src/pages/settings/webhook.vue create mode 100644 packages/frontend/src/pages/settings/word-mute.vue create mode 100644 packages/frontend/src/pages/share.vue create mode 100644 packages/frontend/src/pages/signup-complete.vue create mode 100644 packages/frontend/src/pages/tag.vue create mode 100644 packages/frontend/src/pages/theme-editor.vue create mode 100644 packages/frontend/src/pages/timeline.tutorial.vue create mode 100644 packages/frontend/src/pages/timeline.vue create mode 100644 packages/frontend/src/pages/user-info.vue create mode 100644 packages/frontend/src/pages/user-list-timeline.vue create mode 100644 packages/frontend/src/pages/user/clips.vue create mode 100644 packages/frontend/src/pages/user/follow-list.vue create mode 100644 packages/frontend/src/pages/user/followers.vue create mode 100644 packages/frontend/src/pages/user/following.vue create mode 100644 packages/frontend/src/pages/user/gallery.vue create mode 100644 packages/frontend/src/pages/user/home.vue create mode 100644 packages/frontend/src/pages/user/index.activity.vue create mode 100644 packages/frontend/src/pages/user/index.photos.vue create mode 100644 packages/frontend/src/pages/user/index.timeline.vue create mode 100644 packages/frontend/src/pages/user/index.vue create mode 100644 packages/frontend/src/pages/user/pages.vue create mode 100644 packages/frontend/src/pages/user/reactions.vue create mode 100644 packages/frontend/src/pages/welcome.entrance.a.vue create mode 100644 packages/frontend/src/pages/welcome.entrance.b.vue create mode 100644 packages/frontend/src/pages/welcome.entrance.c.vue create mode 100644 packages/frontend/src/pages/welcome.setup.vue create mode 100644 packages/frontend/src/pages/welcome.timeline.vue create mode 100644 packages/frontend/src/pages/welcome.vue create mode 100644 packages/frontend/src/pizzax.ts create mode 100644 packages/frontend/src/plugin.ts create mode 100644 packages/frontend/src/router.ts create mode 100644 packages/frontend/src/scripts/2fa.ts create mode 100644 packages/frontend/src/scripts/aiscript/api.ts create mode 100644 packages/frontend/src/scripts/array.ts create mode 100644 packages/frontend/src/scripts/autocomplete.ts create mode 100644 packages/frontend/src/scripts/chart-vline.ts create mode 100644 packages/frontend/src/scripts/check-word-mute.ts create mode 100644 packages/frontend/src/scripts/clone.ts create mode 100644 packages/frontend/src/scripts/collect-page-vars.ts create mode 100644 packages/frontend/src/scripts/contains.ts create mode 100644 packages/frontend/src/scripts/copy-to-clipboard.ts create mode 100644 packages/frontend/src/scripts/device-kind.ts create mode 100644 packages/frontend/src/scripts/emoji-base.ts create mode 100644 packages/frontend/src/scripts/emojilist.ts create mode 100644 packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts create mode 100644 packages/frontend/src/scripts/extract-mentions.ts create mode 100644 packages/frontend/src/scripts/extract-url-from-mfm.ts create mode 100644 packages/frontend/src/scripts/focus.ts create mode 100644 packages/frontend/src/scripts/form.ts create mode 100644 packages/frontend/src/scripts/format-time-string.ts create mode 100644 packages/frontend/src/scripts/gen-search-query.ts create mode 100644 packages/frontend/src/scripts/get-account-from-id.ts create mode 100644 packages/frontend/src/scripts/get-note-menu.ts create mode 100644 packages/frontend/src/scripts/get-note-summary.ts create mode 100644 packages/frontend/src/scripts/get-static-image-url.ts create mode 100644 packages/frontend/src/scripts/get-user-menu.ts create mode 100644 packages/frontend/src/scripts/get-user-name.ts create mode 100644 packages/frontend/src/scripts/hotkey.ts create mode 100644 packages/frontend/src/scripts/hpml/block.ts create mode 100644 packages/frontend/src/scripts/hpml/evaluator.ts create mode 100644 packages/frontend/src/scripts/hpml/expr.ts create mode 100644 packages/frontend/src/scripts/hpml/index.ts create mode 100644 packages/frontend/src/scripts/hpml/lib.ts create mode 100644 packages/frontend/src/scripts/hpml/type-checker.ts create mode 100644 packages/frontend/src/scripts/i18n.ts create mode 100644 packages/frontend/src/scripts/idb-proxy.ts create mode 100644 packages/frontend/src/scripts/initialize-sw.ts create mode 100644 packages/frontend/src/scripts/is-device-darkmode.ts create mode 100644 packages/frontend/src/scripts/keycode.ts create mode 100644 packages/frontend/src/scripts/langmap.ts create mode 100644 packages/frontend/src/scripts/login-id.ts create mode 100644 packages/frontend/src/scripts/lookup-user.ts create mode 100644 packages/frontend/src/scripts/media-proxy.ts create mode 100644 packages/frontend/src/scripts/mfm-tags.ts create mode 100644 packages/frontend/src/scripts/page-metadata.ts create mode 100644 packages/frontend/src/scripts/physics.ts create mode 100644 packages/frontend/src/scripts/please-login.ts create mode 100644 packages/frontend/src/scripts/popout.ts create mode 100644 packages/frontend/src/scripts/popup-position.ts create mode 100644 packages/frontend/src/scripts/reaction-picker.ts create mode 100644 packages/frontend/src/scripts/safe-uri-decode.ts create mode 100644 packages/frontend/src/scripts/scroll.ts create mode 100644 packages/frontend/src/scripts/search.ts create mode 100644 packages/frontend/src/scripts/select-file.ts create mode 100644 packages/frontend/src/scripts/show-suspended-dialog.ts create mode 100644 packages/frontend/src/scripts/shuffle.ts create mode 100644 packages/frontend/src/scripts/sound.ts create mode 100644 packages/frontend/src/scripts/sticky-sidebar.ts create mode 100644 packages/frontend/src/scripts/theme-editor.ts create mode 100644 packages/frontend/src/scripts/theme.ts create mode 100644 packages/frontend/src/scripts/time.ts create mode 100644 packages/frontend/src/scripts/timezones.ts create mode 100644 packages/frontend/src/scripts/touch.ts create mode 100644 packages/frontend/src/scripts/unison-reload.ts create mode 100644 packages/frontend/src/scripts/upload.ts create mode 100644 packages/frontend/src/scripts/upload/compress-config.ts create mode 100644 packages/frontend/src/scripts/url.ts create mode 100644 packages/frontend/src/scripts/use-chart-tooltip.ts create mode 100644 packages/frontend/src/scripts/use-interval.ts create mode 100644 packages/frontend/src/scripts/use-leave-guard.ts create mode 100644 packages/frontend/src/scripts/use-note-capture.ts create mode 100644 packages/frontend/src/scripts/use-tooltip.ts create mode 100644 packages/frontend/src/store.ts create mode 100644 packages/frontend/src/stream.ts create mode 100644 packages/frontend/src/style.scss create mode 100644 packages/frontend/src/theme-store.ts create mode 100644 packages/frontend/src/themes/_dark.json5 create mode 100644 packages/frontend/src/themes/_light.json5 create mode 100644 packages/frontend/src/themes/d-astro.json5 create mode 100644 packages/frontend/src/themes/d-botanical.json5 create mode 100644 packages/frontend/src/themes/d-cherry.json5 create mode 100644 packages/frontend/src/themes/d-dark.json5 create mode 100644 packages/frontend/src/themes/d-future.json5 create mode 100644 packages/frontend/src/themes/d-green-lime.json5 create mode 100644 packages/frontend/src/themes/d-green-orange.json5 create mode 100644 packages/frontend/src/themes/d-ice.json5 create mode 100644 packages/frontend/src/themes/d-persimmon.json5 create mode 100644 packages/frontend/src/themes/d-u0.json5 create mode 100644 packages/frontend/src/themes/l-apricot.json5 create mode 100644 packages/frontend/src/themes/l-cherry.json5 create mode 100644 packages/frontend/src/themes/l-coffee.json5 create mode 100644 packages/frontend/src/themes/l-light.json5 create mode 100644 packages/frontend/src/themes/l-rainy.json5 create mode 100644 packages/frontend/src/themes/l-sushi.json5 create mode 100644 packages/frontend/src/themes/l-u0.json5 create mode 100644 packages/frontend/src/themes/l-vivid.json5 create mode 100644 packages/frontend/src/types/menu.ts create mode 100644 packages/frontend/src/ui/_common_/common.vue create mode 100644 packages/frontend/src/ui/_common_/navbar-for-mobile.vue create mode 100644 packages/frontend/src/ui/_common_/navbar.vue create mode 100644 packages/frontend/src/ui/_common_/statusbar-federation.vue create mode 100644 packages/frontend/src/ui/_common_/statusbar-rss.vue create mode 100644 packages/frontend/src/ui/_common_/statusbar-user-list.vue create mode 100644 packages/frontend/src/ui/_common_/statusbars.vue create mode 100644 packages/frontend/src/ui/_common_/stream-indicator.vue create mode 100644 packages/frontend/src/ui/_common_/sw-inject.ts create mode 100644 packages/frontend/src/ui/_common_/upload.vue create mode 100644 packages/frontend/src/ui/classic.header.vue create mode 100644 packages/frontend/src/ui/classic.sidebar.vue create mode 100644 packages/frontend/src/ui/classic.vue create mode 100644 packages/frontend/src/ui/classic.widgets.vue create mode 100644 packages/frontend/src/ui/deck.vue create mode 100644 packages/frontend/src/ui/deck/antenna-column.vue create mode 100644 packages/frontend/src/ui/deck/column-core.vue create mode 100644 packages/frontend/src/ui/deck/column.vue create mode 100644 packages/frontend/src/ui/deck/deck-store.ts create mode 100644 packages/frontend/src/ui/deck/direct-column.vue create mode 100644 packages/frontend/src/ui/deck/list-column.vue create mode 100644 packages/frontend/src/ui/deck/main-column.vue create mode 100644 packages/frontend/src/ui/deck/mentions-column.vue create mode 100644 packages/frontend/src/ui/deck/notifications-column.vue create mode 100644 packages/frontend/src/ui/deck/tl-column.vue create mode 100644 packages/frontend/src/ui/deck/widgets-column.vue create mode 100644 packages/frontend/src/ui/universal.vue create mode 100644 packages/frontend/src/ui/universal.widgets.vue create mode 100644 packages/frontend/src/ui/visitor.vue create mode 100644 packages/frontend/src/ui/visitor/a.vue create mode 100644 packages/frontend/src/ui/visitor/b.vue create mode 100644 packages/frontend/src/ui/visitor/header.vue create mode 100644 packages/frontend/src/ui/visitor/kanban.vue create mode 100644 packages/frontend/src/ui/zen.vue create mode 100644 packages/frontend/src/widgets/activity.calendar.vue create mode 100644 packages/frontend/src/widgets/activity.chart.vue create mode 100644 packages/frontend/src/widgets/activity.vue create mode 100644 packages/frontend/src/widgets/aichan.vue create mode 100644 packages/frontend/src/widgets/aiscript.vue create mode 100644 packages/frontend/src/widgets/button.vue create mode 100644 packages/frontend/src/widgets/calendar.vue create mode 100644 packages/frontend/src/widgets/clock.vue create mode 100644 packages/frontend/src/widgets/digital-clock.vue create mode 100644 packages/frontend/src/widgets/federation.vue create mode 100644 packages/frontend/src/widgets/index.ts create mode 100644 packages/frontend/src/widgets/instance-cloud.vue create mode 100644 packages/frontend/src/widgets/job-queue.vue create mode 100644 packages/frontend/src/widgets/memo.vue create mode 100644 packages/frontend/src/widgets/notifications.vue create mode 100644 packages/frontend/src/widgets/online-users.vue create mode 100644 packages/frontend/src/widgets/photos.vue create mode 100644 packages/frontend/src/widgets/post-form.vue create mode 100644 packages/frontend/src/widgets/rss-ticker.vue create mode 100644 packages/frontend/src/widgets/rss.vue create mode 100644 packages/frontend/src/widgets/server-metric/cpu-mem.vue create mode 100644 packages/frontend/src/widgets/server-metric/cpu.vue create mode 100644 packages/frontend/src/widgets/server-metric/disk.vue create mode 100644 packages/frontend/src/widgets/server-metric/index.vue create mode 100644 packages/frontend/src/widgets/server-metric/mem.vue create mode 100644 packages/frontend/src/widgets/server-metric/net.vue create mode 100644 packages/frontend/src/widgets/server-metric/pie.vue create mode 100644 packages/frontend/src/widgets/slideshow.vue create mode 100644 packages/frontend/src/widgets/timeline.vue create mode 100644 packages/frontend/src/widgets/trends.vue create mode 100644 packages/frontend/src/widgets/unix-clock.vue create mode 100644 packages/frontend/src/widgets/user-list.vue create mode 100644 packages/frontend/src/widgets/widget.ts create mode 100644 packages/frontend/tsconfig.json create mode 100644 packages/frontend/vite.config.ts create mode 100644 packages/frontend/vite.json5.ts (limited to 'packages/frontend') diff --git a/packages/frontend/.eslintrc.js b/packages/frontend/.eslintrc.js new file mode 100644 index 0000000000..6c3bfb5a6e --- /dev/null +++ b/packages/frontend/.eslintrc.js @@ -0,0 +1,89 @@ +module.exports = { + root: true, + env: { + 'node': false, + }, + parser: 'vue-eslint-parser', + parserOptions: { + 'parser': '@typescript-eslint/parser', + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + extraFileExtensions: ['.vue'], + }, + extends: [ + '../shared/.eslintrc.js', + 'plugin:vue/vue3-recommended', + ], + rules: { + '@typescript-eslint/no-empty-interface': [ + 'error', + { + 'allowSingleExtends': true, + }, + ], + '@typescript-eslint/prefer-nullish-coalescing': [ + 'error', + ], + // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため + // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + 'alphabetical': false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + 'allowUsingIterationVar': false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + 'ignoreProperties': false, + }], + 'vue/no-v-html': 'warn', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + 'attribute': 1, + 'baseIndent': 0, + 'closeBracket': 0, + 'alignAttributesVertically': true, + 'ignores': [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + 'startTag': 'never', + 'endTag': 'never', + 'selfClosingTag': 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-destructure': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + // (vue/vue3-recommended disabled the autofix for Vue 2 compatibility) + 'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }], + }, + globals: { + // Node.js + 'module': false, + 'require': false, + '__dirname': false, + + // Vue + '$$': false, + '$ref': false, + '$shallowRef': false, + '$computed': false, + + // Misskey + '_DEV_': false, + '_LANGS_': false, + '_VERSION_': false, + '_ENV_': false, + '_PERF_PREFIX_': false, + '_DATA_TRANSFER_DRIVE_FILE_': false, + '_DATA_TRANSFER_DRIVE_FOLDER_': false, + '_DATA_TRANSFER_DECK_COLUMN_': false, + }, +}; diff --git a/packages/frontend/.vscode/settings.json b/packages/frontend/.vscode/settings.json new file mode 100644 index 0000000000..1a79b6a7dc --- /dev/null +++ b/packages/frontend/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib", + "path-intellisense.mappings": { + "@": "${workspaceRoot}/packages/frontend/src/" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "vue" + ] +} diff --git a/packages/frontend/@types/global.d.ts b/packages/frontend/@types/global.d.ts new file mode 100644 index 0000000000..c757482900 --- /dev/null +++ b/packages/frontend/@types/global.d.ts @@ -0,0 +1,10 @@ +type FIXME = any; + +declare const _LANGS_: string[][]; +declare const _VERSION_: string; +declare const _ENV_: string; +declare const _DEV_: boolean; +declare const _PERF_PREFIX_: string; +declare const _DATA_TRANSFER_DRIVE_FILE_: string; +declare const _DATA_TRANSFER_DRIVE_FOLDER_: string; +declare const _DATA_TRANSFER_DECK_COLUMN_: string; diff --git a/packages/frontend/@types/theme.d.ts b/packages/frontend/@types/theme.d.ts new file mode 100644 index 0000000000..67f724a9aa --- /dev/null +++ b/packages/frontend/@types/theme.d.ts @@ -0,0 +1,7 @@ +declare module '@/themes/*.json5' { + import { Theme } from "@/scripts/theme"; + + const theme: Theme; + + export default theme; +} diff --git a/packages/frontend/@types/vue.d.ts b/packages/frontend/@types/vue.d.ts new file mode 100644 index 0000000000..9c9c34ccc5 --- /dev/null +++ b/packages/frontend/@types/vue.d.ts @@ -0,0 +1,16 @@ +/// + +import type { $i } from '@/account'; +import type { defaultStore } from '@/store'; +import type { instance } from '@/instance'; +import type { i18n } from '@/i18n'; + +declare module 'vue' { + interface ComponentCustomProperties { + $i: typeof $i; + $store: typeof defaultStore; + $instance: typeof instance; + $t: typeof i18n['t']; + $ts: typeof i18n['ts']; + } +} diff --git a/packages/frontend/assets/about-icon.png b/packages/frontend/assets/about-icon.png new file mode 100644 index 0000000000..afc1f0c728 Binary files /dev/null and b/packages/frontend/assets/about-icon.png differ diff --git a/packages/frontend/assets/dummy.png b/packages/frontend/assets/dummy.png new file mode 100644 index 0000000000..39332b0c1b Binary files /dev/null and b/packages/frontend/assets/dummy.png differ diff --git a/packages/frontend/assets/fedi.jpg b/packages/frontend/assets/fedi.jpg new file mode 100644 index 0000000000..cbf3748eb8 Binary files /dev/null and b/packages/frontend/assets/fedi.jpg differ diff --git a/packages/frontend/assets/label-red.svg b/packages/frontend/assets/label-red.svg new file mode 100644 index 0000000000..45996aa9ce --- /dev/null +++ b/packages/frontend/assets/label-red.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/packages/frontend/assets/label.svg b/packages/frontend/assets/label.svg new file mode 100644 index 0000000000..b1f85f3c07 --- /dev/null +++ b/packages/frontend/assets/label.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/packages/frontend/assets/misskey.svg b/packages/frontend/assets/misskey.svg new file mode 100644 index 0000000000..3fcb2d3ecb --- /dev/null +++ b/packages/frontend/assets/misskey.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/frontend/assets/remove.png b/packages/frontend/assets/remove.png new file mode 100644 index 0000000000..c2e222a0fc Binary files /dev/null and b/packages/frontend/assets/remove.png differ diff --git a/packages/frontend/assets/sounds/aisha/1.mp3 b/packages/frontend/assets/sounds/aisha/1.mp3 new file mode 100644 index 0000000000..d8e9a2f265 Binary files /dev/null and b/packages/frontend/assets/sounds/aisha/1.mp3 differ diff --git a/packages/frontend/assets/sounds/aisha/2.mp3 b/packages/frontend/assets/sounds/aisha/2.mp3 new file mode 100644 index 0000000000..477c2eba43 Binary files /dev/null and b/packages/frontend/assets/sounds/aisha/2.mp3 differ diff --git a/packages/frontend/assets/sounds/aisha/3.mp3 b/packages/frontend/assets/sounds/aisha/3.mp3 new file mode 100644 index 0000000000..fe0d8063df Binary files /dev/null and b/packages/frontend/assets/sounds/aisha/3.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 new file mode 100644 index 0000000000..616b506c4f Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 new file mode 100644 index 0000000000..33c2837620 Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3 new file mode 100644 index 0000000000..1791f26573 Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3 new file mode 100644 index 0000000000..5f8bf468e5 Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3 new file mode 100644 index 0000000000..dabe754b5b Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3 new file mode 100644 index 0000000000..57ecb01bda Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3 differ diff --git a/packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3 b/packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3 new file mode 100644 index 0000000000..6ba317deb1 Binary files /dev/null and b/packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/down.mp3 b/packages/frontend/assets/sounds/syuilo/down.mp3 new file mode 100644 index 0000000000..4cd421139d Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/down.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/kick.mp3 b/packages/frontend/assets/sounds/syuilo/kick.mp3 new file mode 100644 index 0000000000..4e0e72091c Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/kick.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 b/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 new file mode 100644 index 0000000000..babf1fce60 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 b/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 new file mode 100644 index 0000000000..25e2c46a64 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pirori-wet.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/pirori.mp3 b/packages/frontend/assets/sounds/syuilo/pirori.mp3 new file mode 100644 index 0000000000..a745415ac0 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pirori.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/poi1.mp3 b/packages/frontend/assets/sounds/syuilo/poi1.mp3 new file mode 100644 index 0000000000..59dae90965 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/poi1.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/poi2.mp3 b/packages/frontend/assets/sounds/syuilo/poi2.mp3 new file mode 100644 index 0000000000..a65c653891 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/poi2.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/pope1.mp3 b/packages/frontend/assets/sounds/syuilo/pope1.mp3 new file mode 100644 index 0000000000..d6f53cfacc Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pope1.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/pope2.mp3 b/packages/frontend/assets/sounds/syuilo/pope2.mp3 new file mode 100644 index 0000000000..fe5d95e292 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/pope2.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/popo.mp3 b/packages/frontend/assets/sounds/syuilo/popo.mp3 new file mode 100644 index 0000000000..a2a1605bbb Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/popo.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 b/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 new file mode 100644 index 0000000000..99e0c437fe Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/queue-jammed.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/reverved.mp3 b/packages/frontend/assets/sounds/syuilo/reverved.mp3 new file mode 100644 index 0000000000..47588ef270 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/reverved.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/ryukyu.mp3 b/packages/frontend/assets/sounds/syuilo/ryukyu.mp3 new file mode 100644 index 0000000000..9e935e3f37 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/ryukyu.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/snare.mp3 b/packages/frontend/assets/sounds/syuilo/snare.mp3 new file mode 100644 index 0000000000..9244189c2d Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/snare.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/square-pico.mp3 b/packages/frontend/assets/sounds/syuilo/square-pico.mp3 new file mode 100644 index 0000000000..c4d8305ae7 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/square-pico.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/triple.mp3 b/packages/frontend/assets/sounds/syuilo/triple.mp3 new file mode 100644 index 0000000000..54ab974d46 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/triple.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/up.mp3 b/packages/frontend/assets/sounds/syuilo/up.mp3 new file mode 100644 index 0000000000..3f30867764 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/up.mp3 differ diff --git a/packages/frontend/assets/sounds/syuilo/waon.mp3 b/packages/frontend/assets/sounds/syuilo/waon.mp3 new file mode 100644 index 0000000000..a4af473861 Binary files /dev/null and b/packages/frontend/assets/sounds/syuilo/waon.mp3 differ diff --git a/packages/frontend/assets/tagcanvas.min.js b/packages/frontend/assets/tagcanvas.min.js new file mode 100644 index 0000000000..bcee46e682 --- /dev/null +++ b/packages/frontend/assets/tagcanvas.min.js @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2010-2021 Graham Breach + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +/** + * TagCanvas 2.11 + * For more information, please contact + */ + (function(){"use strict";var r,C,p=Math.abs,o=Math.sin,l=Math.cos,g=Math.max,h=Math.min,af=Math.ceil,E=Math.sqrt,w=Math.pow,I={},D={},R={0:"0,",1:"17,",2:"34,",3:"51,",4:"68,",5:"85,",6:"102,",7:"119,",8:"136,",9:"153,",a:"170,",A:"170,",b:"187,",B:"187,",c:"204,",C:"204,",d:"221,",D:"221,",e:"238,",E:"238,",f:"255,",F:"255,"},f,d,b,T,z,F,M,c=document,v,e,P,j={};for(r=0;r<256;++r)C=r.toString(16),r<16&&(C='0'+C),D[C]=D[C.toUpperCase()]=r.toString()+',';function n(a){return typeof a!='undefined'}function B(a){return typeof a=='object'&&a!=null}function G(a,c,b){return isNaN(a)?b:h(b,g(c,a))}function x(){return!1}function q(){return(new Date).valueOf()}function ak(c,d){var b=[],e=c.length,a;for(a=0;a=1)?0:a<=-1?Math.PI:Math.acos(a)},z.unit=function(){var a=this.length();return new s(this.x/a,this.y/a,this.z/a)};function ay(b,a){a=a*Math.PI/180,b=b*Math.PI/180;var c=o(b)*l(a),d=-o(a),e=-l(b)*l(a);return new s(c,d,e)}function m(a){this[1]={1:a[0],2:a[1],3:a[2]},this[2]={1:a[3],2:a[4],3:a[5]},this[3]={1:a[6],2:a[7],3:a[8]}}T=m.prototype,m.Identity=function(){return new m([1,0,0,0,1,0,0,0,1])},m.Rotation=function(e,a){var c=o(e),d=l(e),b=1-d;return new m([d+w(a.x,2)*b,a.x*a.y*b-a.z*c,a.x*a.z*b+a.y*c,a.y*a.x*b+a.z*c,d+w(a.y,2)*b,a.y*a.z*b-a.x*c,a.z*a.x*b-a.y*c,a.z*a.y*b+a.x*c,d+w(a.z,2)*b])},T.mul=function(c){var d=[],a,b,e=c.xform?1:0;for(a=1;a<=3;++a)for(b=1;b<=3;++b)e?d.push(this[a][1]*c[1][b]+this[a][2]*c[2][b]+this[a][3]*c[3][b]):d.push(this[a][b]*c);return new m(d)},T.xform=function(b){var a={},c=b.x,d=b.y,e=b.z;return a.x=c*this[1][1]+d*this[2][1]+e*this[3][1],a.y=c*this[1][2]+d*this[2][2]+e*this[3][2],a.z=c*this[1][3]+d*this[2][3]+e*this[3][3],a};function aB(g,j,k,m,f){var a,b,c,d,e=[],h=2/g,i;i=Math.PI*(3-E(5)+(parseFloat(f)?parseFloat(f):0));for(a=0;a0)}function aC(a,c,f,d){var e=a.createLinearGradient(0,0,c,0),b;for(b in d)e.addColorStop(1-b,d[b]);a.fillStyle=e,a.fillRect(0,f,c,1)}function L(a,m,j){var l=1024,d=1,e=a.weightGradient,i,f,b,c;if(a.gCanvas)f=a.gCanvas.getContext('2d'),d=a.gCanvas.height;else{if(B(e[0])?d=e.length:e=[e],a.gCanvas=i=k(l,d),!i)return null;f=i.getContext('2d');for(b=0;b0?b=i*b/100:b=b*j,a=e.getContext('2d'),a.globalCompositeOperation='source-over',a.fillStyle='#fff',b>=i/2?(b=h(c,d)/2,a.beginPath(),a.moveTo(c/2,d/2),a.arc(c/2,d/2,b,0,2*Math.PI,!1),a.fill(),a.closePath()):(b=h(c/2,d/2,b),y(a,0,0,c,d,b,!0),a.fill()),a.globalCompositeOperation='source-in',a.drawImage(l,0,0,c,d),e)}function ao(q,m,i,b,h,a,c){var g=p(c[0]),f=p(c[1]),j=m+(g>a?g+a:a*2)*b,l=i+(f>a?f+a:a*2)*b,n=b*((a||0)+(c[0]<0?g:0)),o=b*((a||0)+(c[1]<0?f:0)),e,d;return e=k(j,l),!e?null:(d=e.getContext('2d'),h&&(d.shadowColor=h),a&&(d.shadowBlur=a*b),c&&(d.shadowOffsetX=c[0]*b,d.shadowOffsetY=c[1]*b),d.drawImage(q,n,o,m,i),{image:e,width:j/b,height:l/b})}function ae(m,o,l){var c=parseInt(m.toString().length*l),h=parseInt(l*2*m.length),j=k(c,h),g,i,e,f,b,d,n,a;if(!j)return null;g=j.getContext('2d'),g.fillStyle='#000',g.fillRect(0,0,c,h),Y(g,l+'px '+o,'#fff',m,0,0,0,0,[],'centre'),i=g.getImageData(0,0,c,h),e=i.width,f=i.height,a={min:{x:e,y:f},max:{x:-1,y:-1}};for(d=0;d0&&(ba.max.x&&(a.max.x=b),da.max.y&&(a.max.y=d));return e!=c&&(a.min.x*=c/e,a.max.x*=c/e),f!=h&&(a.min.y*=c/f,a.max.y*=c/f),j=null,a}function Q(a){return"'"+a.replace(/(\'|\")/g,'').replace(/\s*,\s*/g,"', '")+"'"}function t(b,d,a){a=a||c,a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent('on'+b,d)}function am(b,d,a){a=a||c,a.removeEventListener?a.removeEventListener(b,d):a.detachEvent('on'+b,d)}function A(g,e,j,a,b){var l=b.imageScale,h,c,k,m,f,d;if(!e.complete)return t('load',function(){A(g,e,j,a,b)},e);if(!g.complete)return t('load',function(){A(g,e,j,a,b)},g);if(j&&!j.complete)return t('load',function(){A(g,e,j,a,b)},j);e.width=e.width,e.height=e.height,l&&(g.width=e.width*l,g.height=e.height*l),a.iw=g.width,a.ih=g.height,b.txtOpt&&(c=g,h=b.zoomMax*b.txtScale,f=a.iw*h,d=a.ih*h,f0?(a.iw+=2*b.outlineIncrease,a.ih+=2*b.outlineIncrease,f=h*a.iw,d=h*a.ih,c=S(a.fimage,f,d),a.oimage=c,a.fimage=H(a.fimage,a.oimage.width,a.oimage.height)):(f=h*(a.iw+2*b.outlineIncrease),d=h*(a.ih+2*b.outlineIncrease),c=S(a.fimage,f,d),a.oimage=H(c,a.fimage.width,a.fimage.height))))),a.alt=j,a.Init()}function i(a,d){var b=c.defaultView,e=d.replace(/\-([a-z])/g,function(a){return a.charAt(1).toUpperCase()});return b&&b.getComputedStyle&&b.getComputedStyle(a,null).getPropertyValue(d)||a.currentStyle&&a.currentStyle[e]}function aj(c,d,e){var b=1,a;return d?b=1*(c.getAttribute(d)||e):(a=i(c,'font-size'))&&(b=a.indexOf('px')>-1&&a.replace('px','')*1||a.indexOf('pt')>-1&&a.replace('pt','')*1.25||a*3.3),b}function u(a){return a.target&&n(a.target.id)?a.target.id:a.srcElement.parentNode.id}function K(a,c){var b,d,e=parseInt(i(c,'width'))/c.width,f=parseInt(i(c,'height'))/c.height;return n(a.offsetX)?b={x:a.offsetX,y:a.offsetY}:(d=X(c.id),n(a.changedTouches)&&(a=a.changedTouches[0]),a.pageX&&(b={x:a.pageX-d.x,y:a.pageY-d.y})),b&&e&&f&&(b.x/=e,b.y/=f),b}function an(c){var d=c.target||c.fromElement.parentNode,b=a.tc[d.id];b&&(b.mx=b.my=-1,b.UnFreeze(),b.EndDrag())}function ad(e){var g,c=a,b,d,f=u(e);for(g in c.tc)b=c.tc[g],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);f&&c.tc[f]&&(b=c.tc[f],(d=K(e,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(e,d)),b.drawn=0)}function ap(b){var e=a,f=c.addEventListener?0:1,d=u(b);d&&b.button==f&&e.tc[d]&&e.tc[d].BeginDrag(b)}function aq(b){var f=a,g=c.addEventListener?0:1,e=u(b),d;e&&b.button==g&&f.tc[e]&&(d=f.tc[e],ad(b),!d.EndDrag()&&!d.touchState&&d.Clicked(b))}function ar(c){var e=u(c),b=e&&a.tc[e],d;b&&c.changedTouches&&(c.touches.length==1&&b.touchState==0?(b.touchState=1,b.BeginDrag(c),(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.drawn=0)):c.targetTouches.length==2&&b.pinchZoom?(b.touchState=3,b.EndDrag(),b.BeginPinch(c)):(b.EndDrag(),b.EndPinch(),b.touchState=0))}function ac(c){var d=u(c),b=d&&a.tc[d];if(b&&c.changedTouches){switch(b.touchState){case 1:b.Draw(),b.Clicked();break;break;case 2:b.EndDrag();break;case 3:b.EndPinch()}b.touchState=0}}function au(c){var f,e=a,b,d,g=u(c);for(f in e.tc)b=e.tc[f],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);if(b=g&&e.tc[g],b&&c.changedTouches&&b.touchState){switch(b.touchState){case 1:case 2:(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(c,d)&&(b.touchState=2));break;case 3:b.Pinch(c)}b.drawn=0}}function ab(b){var d=a,c=u(b);c&&d.tc[c]&&(b.cancelBubble=!0,b.returnValue=!1,b.preventDefault&&b.preventDefault(),d.tc[c].Wheel((b.wheelDelta||b.detail)>0))}function aw(d){var c,b=a;clearTimeout(b.scrollTimer);for(c in b.tc)b.tc[c].Pause();b.scrollTimer=setTimeout(function(){var b,c=a;for(b in c.tc)c.tc[b].Resume()},b.scrollPause)}function al(){Z(q())}function Z(b){var c=a.tc,d;a.NextFrame(a.interval),b=b||q();for(d in c)c[d].Draw(b)}function az(){requestAnimationFrame(Z)}function aA(a){setTimeout(al,a)}function X(f){var g=c.getElementById(f),b=g.getBoundingClientRect(),a=c.documentElement,d=c.body,e=window,h=e.pageXOffset||a.scrollLeft,i=e.pageYOffset||a.scrollTop,j=a.clientLeft||d.clientLeft,k=a.clientTop||d.clientTop;return{x:b.left+h-j,y:b.top+i-k}}function aI(a,b,d,e){var c=a.radius*a.z1/(a.z1+a.z2+b.z);return{x:b.x*c*d,y:b.y*c*e,z:b.z,w:(a.z1-b.z)/a.z2}}function V(a){this.e=a,this.br=0,this.line=[],this.text=[],this.original=a.innerText||a.textContent}F=V.prototype,F.Empty=function(){for(var a=0;ah?(d.push(this.line.join(' ')),this.line=[a[b]]):this.line.push(a[b]);d.push(this.line.join(' '))}return this.text=d};function _(a,b){this.ts=null,this.tc=a,this.tag=b,this.x=this.y=this.w=this.h=this.sc=1,this.z=0,this.pulse=1,this.pulsate=a.pulsateTo<1,this.colour=a.outlineColour,this.adash=~~a.outlineDash,this.agap=~~a.outlineDashSpace||this.adash,this.aspeed=a.outlineDashSpeed*1,this.colour=='tag'?this.colour=i(b.a,'color'):this.colour=='tagbg'&&(this.colour=i(b.a,'background-color')),this.Draw=this.pulsate?this.DrawPulsate:this.DrawSimple,this.radius=a.outlineRadius|0,this.SetMethod(a.outlineMethod,a.altImage)}f=_.prototype,f.SetMethod=function(a,d){var b={block:['PreDraw','DrawBlock'],colour:['PreDraw','DrawColour'],outline:['PostDraw','DrawOutline'],classic:['LastDraw','DrawOutline'],size:['PreDraw','DrawSize'],none:['LastDraw']},c=b[a]||b.outline;a=='none'?this.Draw=function(){return 1}:this.drawFunc=this[c[1]],this[c[0]]=this.Draw,d&&(this.RealPreDraw=this.PreDraw,this.PreDraw=this.DrawAlt)},f.Update=function(d,e,i,j,a,f,g,h){var b=this.tc.outlineOffset,c=2*b;this.x=a*d+g-b,this.y=a*e+h-b,this.w=a*i+c,this.h=a*j+c,this.sc=a,this.z=f},f.Ants=function(k){if(!this.adash)return;var b=this.adash,c=this.agap,a=this.aspeed,j=b+c,h=0,g=b,f=c,i=0,d=0,e;a&&(d=p(a)*(q()-this.ts)/50,a<0&&(d=864e4-d),a=~~d%j),a?(b>=a?(h=b-a,g=a):(f=j-a,i=c-f),e=[h,f,g,i]):e=[b,c],k.setLineDash(e)},f.DrawOutline=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.strokeStyle=f,this.Ants(a),y(a,d,e,b,c,g,!0)},f.DrawSize=function(i,n,m,l,k,j,a,h,g){var f=a.w,e=a.h,c,b,d;return this.pulsate?(a.image?d=(a.image.height+this.tc.outlineIncrease)/a.image.height:d=a.oscale,b=a.fimage||a.image,c=1+(d-1)*(1-this.pulse),a.h*=c,a.w*=c):b=a.oimage,a.alpha=1,a.Draw(i,h,g,b),a.h=e,a.w=f,1},f.DrawColour=function(d,h,i,e,f,g,a,b,c){return a.oimage?(this.pulse<1?(a.alpha=1-w(this.pulse,2),a.Draw(d,b,c,a.fimage),a.alpha=this.pulse):a.alpha=1,a.Draw(d,b,c,a.oimage),1):this[a.image?'DrawColourImage':'DrawColourText'](d,h,i,e,f,g,a,b,c)},f.DrawColourText=function(f,h,i,j,g,e,a,b,c){var d=a.colour;return a.colour=e,a.alpha=1,a.Draw(f,b,c),a.colour=d,1},f.DrawColourImage=function(a,q,p,o,n,m,i,r,l){var f=a.canvas,e=~~g(q,0),d=~~g(p,0),c=h(f.width-e,o)+.5|0,b=h(f.height-d,n)+.5|0,j;return v?(v.width=c,v.height=b):v=k(c,b),!v?this.SetMethod('outline'):(j=v.getContext('2d'),j.drawImage(f,e,d,c,b,0,0,c,b),a.clearRect(e,d,c,b),this.pulsate?i.alpha=1-w(this.pulse,2):i.alpha=1,i.Draw(a,r,l),a.setTransform(1,0,0,1,0,0),a.save(),a.beginPath(),a.rect(e,d,c,b),a.clip(),a.globalCompositeOperation='source-in',a.fillStyle=m,a.fillRect(e,d,c,b),a.restore(),a.globalAlpha=1,a.globalCompositeOperation='destination-over',a.drawImage(v,0,0,c,b,e,d,c,b),a.globalCompositeOperation='source-over',1)},f.DrawAlt=function(b,a,c,d,f,g){var e=this.RealPreDraw(b,a,c,d,f,g);return a.alt&&(a.DrawImage(b,c,d,a.alt),e=1),e},f.DrawBlock=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.fillStyle=f,y(a,d,e,b,c,g)},f.DrawSimple=function(a,b,c,d,e,f){var g=this.tc;return a.setTransform(1,0,0,1,0,0),a.strokeStyle=this.colour,a.lineWidth=g.outlineThickness,a.shadowBlur=a.shadowOffsetX=a.shadowOffsetY=0,a.globalAlpha=f?e:1,this.drawFunc(a,this.x,this.y,this.w,this.h,this.colour,b,c,d)},f.DrawPulsate=function(h,d,e,f){var g=q()-this.ts,c=this.tc,b=c.pulsateTo+(1-c.pulsateTo)*(.5+l(2*Math.PI*g/(1e3*c.pulsateTime))/2);return this.pulse=b=a.Smooth(1,b),this.DrawSimple(h,d,e,f,b,1)},f.Active=function(d,a,b){var c=a>=this.x&&b>=this.y&&a<=this.x+this.w&&b<=this.y+this.h;return c?this.ts=this.ts||q():this.ts=null,c},f.PreDraw=f.PostDraw=f.LastDraw=x;function J(a,h,c,b,e,f,g,d,i,j,k,l,m,n){this.tc=a,this.image=null,this.text=h,this.text_original=n,this.line_widths=[],this.title=c.title||null,this.a=c,this.position=new s(b[0],b[1],b[2]),this.x=this.y=this.z=0,this.w=e,this.h=f,this.colour=g||a.textColour,this.bgColour=d||a.bgColour,this.bgRadius=i|0,this.bgOutline=j||this.colour,this.bgOutlineThickness=k|0,this.textFont=l||a.textFont,this.padding=m|0,this.sc=this.alpha=1,this.weighted=!a.weight,this.outline=new _(a,this),this.audio=null}d=J.prototype,d.Init=function(b){var a=this.tc;this.textHeight=a.textHeight,this.HasText()?this.Measure(a.ctxt,a):(this.w=this.iw,this.h=this.ih),this.SetShadowColour=a.shadowAlpha?this.SetShadowColourAlpha:this.SetShadowColourFixed,this.SetDraw(a)},d.Draw=x,d.HasText=function(){return this.text&&this.text[0].length>0},d.EqualTo=function(a){var b=a.getElementsByTagName('img');return this.a.href!=a.href?0:b.length?this.image.src==b[0].src:(a.innerText||a.textContent)==this.text_original},d.SetImage=function(a){this.image=this.fimage=a},d.SetAudio=function(a){this.audio=a,this.audio.load()},d.SetDraw=function(a){this.Draw=this.fimage?a.ie>7?this.DrawImageIE:this.DrawImage:this.DrawText,a.noSelect&&(this.CheckActive=x)},d.MeasureText=function(d){var a,e=this.text.length,b=0,c;for(a=0;a0?c=H(c,this.oimage.width,this.oimage.height):this.oimage=H(this.oimage,c.width,c.height)),c&&(this.fimage=c,l=this.fimage.width/b,j=this.fimage.height/b),this.SetDraw(a),a.txtOpt=!!this.fimage),this.h=j,this.w=l},d.SetFont=function(a,b,c,d){this.textFont=a,this.colour=b,this.bgColour=c,this.bgOutline=d,this.Measure(this.tc.ctxt,this.tc)},d.SetWeight=function(c){var b=this.tc,e=b.weightMode.split(/[, ]/),d,a,f=c.length;if(!this.HasText())return;this.weighted=!0;for(a=0;a0&&a.weightSizeMax>a.weightSizeMin?this.textHeight=a.weightSize*(a.weightSizeMin+(a.weightSizeMax-a.weightSizeMin)*c):this.textHeight=g(1,b*a.weightSize))},d.SetShadowColourFixed=function(a,b,c){a.shadowColor=b},d.SetShadowColourAlpha=function(a,b,c){a.shadowColor=aE(b,c)},d.DrawText=function(a,h,i){var e=this.tc,g=this.x,f=this.y,c=this.sc,b,d;a.globalAlpha=this.alpha,a.fillStyle=this.colour,e.shadow&&this.SetShadowColour(a,e.shadow,this.alpha),a.font=this.font,g+=h/c,f+=i/c-this.h/2;for(b=0;b{this.stopped?this.audio.pause():this.playing=1}),1}};function a(f,o,k){var d,i,b=c.getElementById(f),l=['id','class','innerHTML'];if(!b)throw 0;if(n(window.G_vmlCanvasManager)&&(b=window.G_vmlCanvasManager.initElement(b),this.ie=parseFloat(navigator.appVersion.split('MSIE')[1])),b&&(!b.getContext||!b.getContext('2d').fillText)){i=c.createElement('DIV');for(d=0;d0?a.scrollPause=~~this.scrollPause:this.scrollPause=0,this.minTags>0&&this.repeatTags<1&&(d=this.GetTags().length)&&(this.repeatTags=af(this.minTags/d)-1),this.transform=m.Identity(),this.startTime=this.time=q(),this.mx=this.my=-1,this.centreImage&&av(this),this.Animate=this.dragControl?this.AnimateDrag:this.AnimatePosition,this.animTiming=typeof a[this.animTiming]=='function'?a[this.animTiming]:a.Smooth,this.shadowBlur||this.shadowOffset[0]||this.shadowOffset[1]?(this.ctxt.shadowColor=this.shadow,this.shadow=this.ctxt.shadowColor,this.shadowAlpha=aD()):delete this.shadow,this.activeAudio===!1?e='off':this.activeAudio&&this.LoadAudio(),this.Load(),o&&this.hideTags&&function(b){a.loaded?b.HideTags():t('load',function(){b.HideTags()},window)}(this),this.yaw=this.initial?this.initial[0]*this.maxSpeed:0,this.pitch=this.initial?this.initial[1]*this.maxSpeed:0,this.tooltip?(this.ctitle=b.title,b.title='',this.tooltip=='native'?this.Tooltip=this.TooltipNative:(this.Tooltip=this.TooltipDiv,this.ttdiv||(this.ttdiv=c.createElement('div'),this.ttdiv.className=this.tooltipClass,this.ttdiv.style.position='absolute',this.ttdiv.style.zIndex=b.style.zIndex+1,t('mouseover',function(a){a.target.style.display='none'},this.ttdiv),c.body.appendChild(this.ttdiv)))):this.Tooltip=this.TooltipNone,!this.noMouse&&!j[f]){j[f]=[['mousemove',ad],['mouseout',an],['mouseup',aq],['touchstart',ar],['touchend',ac],['touchcancel',ac],['touchmove',au]],this.dragControl&&(j[f].push(['mousedown',ap]),j[f].push(['selectstart',x])),this.wheelZoom&&(j[f].push(['mousewheel',ab]),j[f].push(['DOMMouseScroll',ab])),this.scrollPause&&j[f].push(['scroll',aw,window]);for(d=0;dthis.max_weight[a])&&(this.max_weight[a]=c),(!this.min_weight[a]||cthis.min_weight[a]&&(g=1);if(g)for(b=0;b=d&&this.my>=e)return!0},b.ToggleAudio=function(){var a=this.audioOff||e&&e.state==='suspended';a||this.currentAudio&&this.currentAudio.StopAudio(),this.audioOff=!a},b.Draw=function(s){if(this.paused)return;var l=this.canvas,i=l.width,j=l.height,q=0,p=(s-this.time)*a.interval/1e3,h=i/2+this.offsetX,g=j/2+this.offsetY,d=this.ctxt,b,f,c,o=-1,e=this.taglist,k=e.length,t=this.active&&this.active.tag,m='',u=this.frontSelect,r=this.centreFunc==x,n;if(this.time=s,this.frozen&&this.drawn)return this.Animate(i,j,p);n=this.AnimateFixed(),d.setTransform(1,0,0,1,0,0);for(c=0;c=0&&this.my>=0&&this.taglist[c].CheckActive(d,h,g),f&&f.sc>q&&(!u||f.z<=0)&&(b=f,o=c,b.tag=this.taglist[c],q=f.sc);this.active=b}this.txtOpt||this.shadow&&this.SetShadow(d),d.clearRect(0,0,i,j);for(c=0;c=this.fadeIn?(this.fadeIn=0,this.fixedAlpha=1):this.fixedAlpha=b/this.fadeIn),this.fixedAnim)&&(this.fixedAnim.transform||(this.fixedAnim.transform=this.transform),a=this.fixedAnim,b=q()-a.t0,c=a.angle,d,e=this.animTiming(a.t,b),this.transform=a.transform,b>=a.t?(this.fixedCallbackTag=a.tag,this.fixedCallback=a.cb,this.fixedAnim=this.yaw=this.pitch=0):c*=e,d=m.Rotation(c,a.axis),this.transform=this.transform.mul(d),this.fixedAnim!=0)},b.AnimatePosition=function(g,h,f){var a=this,d=a.mx,e=a.my,b,c;!a.frozen&&d>=0&&e>=0&&db&&(a.yaw=c>a.z0?a.yaw*a.decel:0),!a.ly&&d>b&&(a.pitch=d>a.z0?a.pitch*a.decel:0)},b.Zoom=function(a){this.z2=this.z1*(1/a),this.drawn=0},b.Clicked=function(b){if(this.CheckAudioIcon()){this.ToggleAudio();return}var a=this.active;try{a&&a.tag&&(this.clickToFront===!1||this.clickToFront===null?a.tag.Clicked(b):this.TagToFront(a.tag,this.clickToFront,function(){a.tag.Clicked(b)},!0))}catch(a){}},b.Wheel=function(a){var b=this.zoom+this.zoomStep*(a?1:-1);this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.BeginDrag=function(a){this.down=K(a,this.canvas),a.cancelBubble=!0,a.returnValue=!1,a.preventDefault&&a.preventDefault()},b.Drag=function(e,a){if(this.dragControl&&this.down){var d=this.dragThreshold*this.dragThreshold,b=a.x-this.down.x,c=a.y-this.down.y;(this.dragging||b*b+c*c>d)&&(this.dx=b,this.dy=c,this.dragging=1,this.down=a)}return this.dragging},b.EndDrag=function(){var a=this.dragging;return this.dragging=this.down=null,a};function ah(a){var b=a.targetTouches[0],c=a.targetTouches[1];return E(w(c.pageX-b.pageX,2)+w(c.pageY-b.pageY,2))}b.BeginPinch=function(a){this.pinched=[ah(a),this.zoom],a.preventDefault&&a.preventDefault()},b.Pinch=function(d){var b,c,a=this.pinched;if(!a)return;c=ah(d),b=a[1]*c/a[0],this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.EndPinch=function(a){this.pinched=null},b.Pause=function(){this.paused=!0},b.Resume=function(){this.paused=!1},b.SetSpeed=function(a){this.initial=a,this.yaw=a[0]*this.maxSpeed,this.pitch=a[1]*this.maxSpeed},b.FindTag=function(a){if(!n(a))return null;if(n(a.index)&&(a=a.index),!B(a))return this.taglist[a];var c,d,b;n(a.id)?(c='id',d=a.id):n(a.text)&&(c='innerText',d=a.text);for(b=0;b + + + + + diff --git a/packages/frontend/package.json b/packages/frontend/package.json new file mode 100644 index 0000000000..c23adf7c70 --- /dev/null +++ b/packages/frontend/package.json @@ -0,0 +1,94 @@ +{ + "name": "frontend", + "private": true, + "scripts": { + "watch": "vite", + "build": "vite build", + "lint": "vue-tsc --noEmit && eslint --quiet \"src/**/*.{ts,vue}\"" + }, + "dependencies": { + "@discordapp/twemoji": "14.0.2", + "@rollup/plugin-alias": "4.0.2", + "@rollup/plugin-json": "6.0.0", + "@rollup/pluginutils": "5.0.2", + "@syuilo/aiscript": "0.11.1", + "@tabler/icons": "^1.118.0", + "@vitejs/plugin-vue": "4.0.0", + "@vue/compiler-sfc": "3.2.45", + "autobind-decorator": "2.4.0", + "autosize": "5.0.2", + "blurhash": "2.0.4", + "broadcast-channel": "4.18.1", + "browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3", + "chart.js": "4.1.1", + "chartjs-adapter-date-fns": "3.0.0", + "chartjs-chart-matrix": "^1.3.0", + "chartjs-plugin-gradient": "0.6.1", + "chartjs-plugin-zoom": "2.0.0", + "compare-versions": "5.0.1", + "cropperjs": "2.0.0-beta", + "date-fns": "2.29.3", + "escape-regexp": "0.0.1", + "eventemitter3": "5.0.0", + "idb-keyval": "6.2.0", + "insert-text-at-cursor": "0.3.0", + "is-file-animated": "1.0.2", + "json5": "2.2.2", + "katex": "0.15.6", + "matter-js": "0.18.0", + "mfm-js": "0.23.0", + "misskey-js": "0.0.14", + "photoswipe": "5.3.4", + "prismjs": "1.29.0", + "punycode": "2.1.1", + "querystring": "0.2.1", + "rndstr": "1.0.0", + "rollup": "3.8.0", + "s-age": "1.1.2", + "sass": "1.57.1", + "seedrandom": "3.0.5", + "strict-event-emitter-types": "2.0.0", + "stringz": "2.1.0", + "syuilo-password-strength": "0.0.1", + "textarea-caret": "3.1.0", + "three": "0.148.0", + "throttle-debounce": "5.0.0", + "tinycolor2": "1.4.2", + "tsc-alias": "1.8.2", + "tsconfig-paths": "4.1.1", + "twemoji-parser": "14.0.0", + "typescript": "4.9.4", + "uuid": "9.0.0", + "vanilla-tilt": "1.8.0", + "vite": "4.0.3", + "vue": "3.2.45", + "vue-prism-editor": "2.0.0-alpha.2", + "vuedraggable": "next" + }, + "devDependencies": { + "@types/escape-regexp": "0.0.1", + "@types/glob": "8.0.0", + "@types/gulp": "4.0.10", + "@types/gulp-rename": "2.0.1", + "@types/katex": "0.14.0", + "@types/matter-js": "0.18.2", + "@types/punycode": "2.1.0", + "@types/seedrandom": "3.0.3", + "@types/throttle-debounce": "5.0.0", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "9.0.0", + "@types/websocket": "1.0.5", + "@types/ws": "8.5.3", + "@typescript-eslint/eslint-plugin": "5.47.0", + "@typescript-eslint/parser": "5.47.0", + "@vue/runtime-core": "3.2.45", + "cross-env": "7.0.3", + "cypress": "12.2.0", + "eslint": "8.30.0", + "eslint-plugin-import": "2.26.0", + "eslint-plugin-vue": "9.8.0", + "start-server-and-test": "1.15.2", + "vue-eslint-parser": "^9.1.0", + "vue-tsc": "^1.0.16" + } +} diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts new file mode 100644 index 0000000000..0e991cdfb5 --- /dev/null +++ b/packages/frontend/src/account.ts @@ -0,0 +1,238 @@ +import { defineAsyncComponent, reactive } from 'vue'; +import * as misskey from 'misskey-js'; +import { showSuspendedDialog } from './scripts/show-suspended-dialog'; +import { i18n } from './i18n'; +import { del, get, set } from '@/scripts/idb-proxy'; +import { apiUrl } from '@/config'; +import { waiting, api, popup, popupMenu, success, alert } from '@/os'; +import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; + +// TODO: 他のタブと永続化されたstateを同期 + +type Account = misskey.entities.MeDetailed; + +const accountData = localStorage.getItem('account'); + +// TODO: 外部からはreadonlyに +export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; + +export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmAdmin = $i != null && $i.isAdmin; + +export async function signout() { + waiting(); + localStorage.removeItem('account'); + + await removeAccount($i.id); + + const accounts = await getAccounts(); + + //#region Remove service worker registration + try { + if (navigator.serviceWorker.controller) { + const registration = await navigator.serviceWorker.ready; + const push = await registration.pushManager.getSubscription(); + if (push) { + await window.fetch(`${apiUrl}/sw/unregister`, { + method: 'POST', + body: JSON.stringify({ + i: $i.token, + endpoint: push.endpoint, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + } + } + + if (accounts.length === 0) { + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }); + } + } catch (err) {} + //#endregion + + document.cookie = 'igi=; path=/'; + + if (accounts.length > 0) login(accounts[0].token); + else unisonReload('/'); +} + +export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> { + return (await get('accounts')) || []; +} + +export async function addAccount(id: Account['id'], token: Account['token']) { + const accounts = await getAccounts(); + if (!accounts.some(x => x.id === id)) { + await set('accounts', accounts.concat([{ id, token }])); + } +} + +export async function removeAccount(id: Account['id']) { + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === id), 1); + + if (accounts.length > 0) await set('accounts', accounts); + else await del('accounts'); +} + +function fetchAccount(token: string): Promise { + return new Promise((done, fail) => { + // Fetch user + window.fetch(`${apiUrl}/i`, { + method: 'POST', + body: JSON.stringify({ + i: token, + }), + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(res => res.json()) + .then(res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + showSuspendedDialog().then(() => { + signout(); + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); + } + } else { + res.token = token; + done(res); + } + }) + .catch(fail); + }); +} + +export function updateAccount(accountData) { + for (const [key, value] of Object.entries(accountData)) { + $i[key] = value; + } + localStorage.setItem('account', JSON.stringify($i)); +} + +export function refreshAccount() { + return fetchAccount($i.token).then(updateAccount); +} + +export async function login(token: Account['token'], redirect?: string) { + waiting(); + if (_DEV_) console.log('logging as token ', token); + const me = await fetchAccount(token); + localStorage.setItem('account', JSON.stringify(me)); + document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う + await addAccount(me.id, token); + + if (redirect) { + // 他のタブは再読み込みするだけ + reloadChannel.postMessage(null); + // このページはredirectで指定された先に移動 + location.href = redirect; + return; + } + + unisonReload(); +} + +export async function openAccountMenu(opts: { + includeCurrentAccount?: boolean; + withExtraOperation: boolean; + active?: misskey.entities.UserDetailed['id']; + onChoose?: (account: misskey.entities.UserDetailed) => void; +}, ev: MouseEvent) { + function showSigninDialog() { + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: res => { + addAccount(res.id, res.i); + success(); + }, + }, 'closed'); + } + + function createAccount() { + popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: res => { + addAccount(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); + } + + async function switchAccount(account: misskey.entities.UserDetailed) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); + } + + function switchAccountWithToken(token: string) { + login(token); + } + + const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id)); + const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) }); + + function createItem(account: misskey.entities.UserDetailed) { + return { + type: 'user', + user: account, + active: opts.active != null ? opts.active === account.id : false, + action: () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(account); + } + }, + }; + } + + const accountItemPromises = storedAccounts.map(a => new Promise(res => { + accountsPromise.then(accounts => { + const account = accounts.find(x => x.id === a.id); + if (account == null) return res(null); + res(createItem(account)); + }); + })); + + if (opts.withExtraOperation) { + popupMenu([...[{ + type: 'link', + text: i18n.ts.profile, + to: `/@${ $i.username }`, + avatar: $i, + }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { + type: 'parent', + icon: 'ti ti-plus', + text: i18n.ts.addAccount, + children: [{ + text: i18n.ts.existingAccount, + action: () => { showSigninDialog(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], + }, { + type: 'link', + icon: 'ti ti-users', + text: i18n.ts.manageAccounts, + to: '/settings/accounts', + }]], ev.currentTarget ?? ev.target, { + align: 'left', + }); + } else { + popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { + align: 'left', + }); + } +} diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue new file mode 100644 index 0000000000..9a3464b640 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue new file mode 100644 index 0000000000..039f77c859 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/frontend/src/components/MkActiveUsersHeatmap.vue b/packages/frontend/src/components/MkActiveUsersHeatmap.vue new file mode 100644 index 0000000000..02b2eeeb36 --- /dev/null +++ b/packages/frontend/src/components/MkActiveUsersHeatmap.vue @@ -0,0 +1,236 @@ + + + diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue new file mode 100644 index 0000000000..40ef626aed --- /dev/null +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue new file mode 100644 index 0000000000..72783921d5 --- /dev/null +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -0,0 +1,476 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue new file mode 100644 index 0000000000..162338b639 --- /dev/null +++ b/packages/frontend/src/components/MkAvatars.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue new file mode 100644 index 0000000000..891645bb2a --- /dev/null +++ b/packages/frontend/src/components/MkButton.vue @@ -0,0 +1,227 @@ + + + + + diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue new file mode 100644 index 0000000000..6d218389fc --- /dev/null +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -0,0 +1,118 @@ + + + diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue new file mode 100644 index 0000000000..9e275d6172 --- /dev/null +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue new file mode 100644 index 0000000000..6ef50bddcf --- /dev/null +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue new file mode 100644 index 0000000000..fbbc231b88 --- /dev/null +++ b/packages/frontend/src/components/MkChart.vue @@ -0,0 +1,859 @@ + + + + + diff --git a/packages/frontend/src/components/MkChartTooltip.vue b/packages/frontend/src/components/MkChartTooltip.vue new file mode 100644 index 0000000000..d36f45463c --- /dev/null +++ b/packages/frontend/src/components/MkChartTooltip.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue new file mode 100644 index 0000000000..b074028821 --- /dev/null +++ b/packages/frontend/src/components/MkCode.core.vue @@ -0,0 +1,20 @@ + + + + diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue new file mode 100644 index 0000000000..1640258d5b --- /dev/null +++ b/packages/frontend/src/components/MkCode.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue new file mode 100644 index 0000000000..6d4d5be2bc --- /dev/null +++ b/packages/frontend/src/components/MkContainer.vue @@ -0,0 +1,275 @@ + + + + + diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue new file mode 100644 index 0000000000..cfc9502b41 --- /dev/null +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue new file mode 100644 index 0000000000..ae18160dea --- /dev/null +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue new file mode 100644 index 0000000000..ee611921ef --- /dev/null +++ b/packages/frontend/src/components/MkCwButton.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue new file mode 100644 index 0000000000..1f88bdf137 --- /dev/null +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -0,0 +1,189 @@ + + + diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue new file mode 100644 index 0000000000..374ecd8abf --- /dev/null +++ b/packages/frontend/src/components/MkDialog.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/packages/frontend/src/components/MkDigitalClock.vue b/packages/frontend/src/components/MkDigitalClock.vue new file mode 100644 index 0000000000..9ed8d63d19 --- /dev/null +++ b/packages/frontend/src/components/MkDigitalClock.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue new file mode 100644 index 0000000000..8c17c0530a --- /dev/null +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue new file mode 100644 index 0000000000..82653ca0b4 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -0,0 +1,330 @@ + + + + + diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue new file mode 100644 index 0000000000..dbbfef5f05 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.navFolder.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue new file mode 100644 index 0000000000..4053870950 --- /dev/null +++ b/packages/frontend/src/components/MkDrive.vue @@ -0,0 +1,801 @@ + + + + + diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue new file mode 100644 index 0000000000..33379ed5ca --- /dev/null +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveSelectDialog.vue new file mode 100644 index 0000000000..3ee821b539 --- /dev/null +++ b/packages/frontend/src/components/MkDriveSelectDialog.vue @@ -0,0 +1,58 @@ + + + diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue new file mode 100644 index 0000000000..617200321b --- /dev/null +++ b/packages/frontend/src/components/MkDriveWindow.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue new file mode 100644 index 0000000000..f6ba7abfc4 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue new file mode 100644 index 0000000000..814f71168a --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -0,0 +1,569 @@ + + + + + diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue new file mode 100644 index 0000000000..3b41f9d75b --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/frontend/src/components/MkEmojiPickerWindow.vue b/packages/frontend/src/components/MkEmojiPickerWindow.vue new file mode 100644 index 0000000000..523e4ba695 --- /dev/null +++ b/packages/frontend/src/components/MkEmojiPickerWindow.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue new file mode 100644 index 0000000000..e58b5d2849 --- /dev/null +++ b/packages/frontend/src/components/MkFeaturedPhotos.vue @@ -0,0 +1,22 @@ + + + + + diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue new file mode 100644 index 0000000000..73875251f0 --- /dev/null +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/packages/frontend/src/components/MkFileListForAdmin.vue b/packages/frontend/src/components/MkFileListForAdmin.vue new file mode 100644 index 0000000000..4910506a95 --- /dev/null +++ b/packages/frontend/src/components/MkFileListForAdmin.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue new file mode 100644 index 0000000000..9e83b07cd7 --- /dev/null +++ b/packages/frontend/src/components/MkFolder.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue new file mode 100644 index 0000000000..ee256d9263 --- /dev/null +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue new file mode 100644 index 0000000000..1b55451c94 --- /dev/null +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue new file mode 100644 index 0000000000..b2bf76a8c7 --- /dev/null +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/packages/frontend/src/components/MkFormula.vue b/packages/frontend/src/components/MkFormula.vue new file mode 100644 index 0000000000..65a2fee930 --- /dev/null +++ b/packages/frontend/src/components/MkFormula.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/frontend/src/components/MkFormulaCore.vue b/packages/frontend/src/components/MkFormulaCore.vue new file mode 100644 index 0000000000..6028db9e64 --- /dev/null +++ b/packages/frontend/src/components/MkFormulaCore.vue @@ -0,0 +1,34 @@ + + + + + + diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue new file mode 100644 index 0000000000..a133f6431b --- /dev/null +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue new file mode 100644 index 0000000000..d104cd4cd4 --- /dev/null +++ b/packages/frontend/src/components/MkGoogle.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/packages/frontend/src/components/MkImageViewer.vue b/packages/frontend/src/components/MkImageViewer.vue new file mode 100644 index 0000000000..f074b1a2f2 --- /dev/null +++ b/packages/frontend/src/components/MkImageViewer.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue new file mode 100644 index 0000000000..80d7c201a4 --- /dev/null +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue new file mode 100644 index 0000000000..7aaf2c5bcb --- /dev/null +++ b/packages/frontend/src/components/MkInfo.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue new file mode 100644 index 0000000000..4625de40af --- /dev/null +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue new file mode 100644 index 0000000000..41f6f9ffd5 --- /dev/null +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue new file mode 100644 index 0000000000..646172fe8d --- /dev/null +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue new file mode 100644 index 0000000000..ff69c79641 --- /dev/null +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue new file mode 100644 index 0000000000..1ccc648c72 --- /dev/null +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue new file mode 100644 index 0000000000..6148ec6195 --- /dev/null +++ b/packages/frontend/src/components/MkLink.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/frontend/src/components/MkMarquee.vue b/packages/frontend/src/components/MkMarquee.vue new file mode 100644 index 0000000000..5ca04b0b48 --- /dev/null +++ b/packages/frontend/src/components/MkMarquee.vue @@ -0,0 +1,106 @@ + + + diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue new file mode 100644 index 0000000000..aa06c00fc6 --- /dev/null +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue new file mode 100644 index 0000000000..56570eaa05 --- /dev/null +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue new file mode 100644 index 0000000000..c6f8612182 --- /dev/null +++ b/packages/frontend/src/components/MkMediaList.vue @@ -0,0 +1,189 @@ + + + + + + + diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue new file mode 100644 index 0000000000..df0bf84116 --- /dev/null +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/packages/frontend/src/components/MkMention.vue b/packages/frontend/src/components/MkMention.vue new file mode 100644 index 0000000000..3091b435e4 --- /dev/null +++ b/packages/frontend/src/components/MkMention.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/frontend/src/components/MkMenu.child.vue b/packages/frontend/src/components/MkMenu.child.vue new file mode 100644 index 0000000000..3ada4afbdc --- /dev/null +++ b/packages/frontend/src/components/MkMenu.child.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue new file mode 100644 index 0000000000..64d18b6b7c --- /dev/null +++ b/packages/frontend/src/components/MkMenu.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue new file mode 100644 index 0000000000..c64ce163f9 --- /dev/null +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -0,0 +1,73 @@ + + + diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue new file mode 100644 index 0000000000..2305a02794 --- /dev/null +++ b/packages/frontend/src/components/MkModal.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/packages/frontend/src/components/MkModalPageWindow.vue b/packages/frontend/src/components/MkModalPageWindow.vue new file mode 100644 index 0000000000..ced8a7a714 --- /dev/null +++ b/packages/frontend/src/components/MkModalPageWindow.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue new file mode 100644 index 0000000000..d977ca6e9c --- /dev/null +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -0,0 +1,146 @@ + + + + + diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue new file mode 100644 index 0000000000..a4100e1f2c --- /dev/null +++ b/packages/frontend/src/components/MkNote.vue @@ -0,0 +1,658 @@ + + + + + diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue new file mode 100644 index 0000000000..7ce8e039d9 --- /dev/null +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -0,0 +1,677 @@ + + + + + diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue new file mode 100644 index 0000000000..333c3ddbd9 --- /dev/null +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue new file mode 100644 index 0000000000..0c81059091 --- /dev/null +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue new file mode 100644 index 0000000000..96d29831d2 --- /dev/null +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue new file mode 100644 index 0000000000..d03ce7c434 --- /dev/null +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue new file mode 100644 index 0000000000..5abcdc2298 --- /dev/null +++ b/packages/frontend/src/components/MkNotes.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue new file mode 100644 index 0000000000..8b8d3f452d --- /dev/null +++ b/packages/frontend/src/components/MkNotification.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/packages/frontend/src/components/MkNotificationSettingWindow.vue b/packages/frontend/src/components/MkNotificationSettingWindow.vue new file mode 100644 index 0000000000..75bea2976c --- /dev/null +++ b/packages/frontend/src/components/MkNotificationSettingWindow.vue @@ -0,0 +1,87 @@ + + + diff --git a/packages/frontend/src/components/MkNotificationToast.vue b/packages/frontend/src/components/MkNotificationToast.vue new file mode 100644 index 0000000000..07640792c0 --- /dev/null +++ b/packages/frontend/src/components/MkNotificationToast.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue new file mode 100644 index 0000000000..0e1cc06743 --- /dev/null +++ b/packages/frontend/src/components/MkNotifications.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue new file mode 100644 index 0000000000..e7d4a5472a --- /dev/null +++ b/packages/frontend/src/components/MkNumberDiff.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/frontend/src/components/MkObjectView.value.vue b/packages/frontend/src/components/MkObjectView.value.vue new file mode 100644 index 0000000000..0c7230d783 --- /dev/null +++ b/packages/frontend/src/components/MkObjectView.value.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/packages/frontend/src/components/MkObjectView.vue b/packages/frontend/src/components/MkObjectView.vue new file mode 100644 index 0000000000..55578a37f6 --- /dev/null +++ b/packages/frontend/src/components/MkObjectView.vue @@ -0,0 +1,20 @@ + + + + + diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue new file mode 100644 index 0000000000..009582e540 --- /dev/null +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue new file mode 100644 index 0000000000..29d45558a7 --- /dev/null +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue new file mode 100644 index 0000000000..291409171a --- /dev/null +++ b/packages/frontend/src/components/MkPagination.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue new file mode 100644 index 0000000000..a1b927e42a --- /dev/null +++ b/packages/frontend/src/components/MkPoll.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue new file mode 100644 index 0000000000..556abc5fd0 --- /dev/null +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue new file mode 100644 index 0000000000..f04c7f5618 --- /dev/null +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue new file mode 100644 index 0000000000..f79e5a32cd --- /dev/null +++ b/packages/frontend/src/components/MkPostForm.vue @@ -0,0 +1,1050 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue new file mode 100644 index 0000000000..5a0ba0d8d3 --- /dev/null +++ b/packages/frontend/src/components/global/MkA.vue @@ -0,0 +1,102 @@ + + + diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue new file mode 100644 index 0000000000..c3e806b5fb --- /dev/null +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue new file mode 100644 index 0000000000..a80efb142c --- /dev/null +++ b/packages/frontend/src/components/global/MkAd.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue new file mode 100644 index 0000000000..5f3e3c176d --- /dev/null +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkEllipsis.vue b/packages/frontend/src/components/global/MkEllipsis.vue new file mode 100644 index 0000000000..0a46f486d6 --- /dev/null +++ b/packages/frontend/src/components/global/MkEllipsis.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue new file mode 100644 index 0000000000..ce1299a39f --- /dev/null +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkError.vue b/packages/frontend/src/components/global/MkError.vue new file mode 100644 index 0000000000..e135d4184b --- /dev/null +++ b/packages/frontend/src/components/global/MkError.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkLoading.vue b/packages/frontend/src/components/global/MkLoading.vue new file mode 100644 index 0000000000..64e12e3b44 --- /dev/null +++ b/packages/frontend/src/components/global/MkLoading.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue new file mode 100644 index 0000000000..70d0108e9f --- /dev/null +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.vue @@ -0,0 +1,191 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue new file mode 100644 index 0000000000..a228dfe883 --- /dev/null +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkSpacer.vue b/packages/frontend/src/components/global/MkSpacer.vue new file mode 100644 index 0000000000..b3a42d77e7 --- /dev/null +++ b/packages/frontend/src/components/global/MkSpacer.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue new file mode 100644 index 0000000000..44f4f065a6 --- /dev/null +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -0,0 +1,66 @@ + + + + + + + diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue new file mode 100644 index 0000000000..f72b153f56 --- /dev/null +++ b/packages/frontend/src/components/global/MkTime.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue new file mode 100644 index 0000000000..9f5be96224 --- /dev/null +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/frontend/src/components/global/MkUserName.vue b/packages/frontend/src/components/global/MkUserName.vue new file mode 100644 index 0000000000..090de3df30 --- /dev/null +++ b/packages/frontend/src/components/global/MkUserName.vue @@ -0,0 +1,15 @@ + + + diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue new file mode 100644 index 0000000000..e21a57471c --- /dev/null +++ b/packages/frontend/src/components/global/RouterView.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/frontend/src/components/global/i18n.ts b/packages/frontend/src/components/global/i18n.ts new file mode 100644 index 0000000000..1fd293ba10 --- /dev/null +++ b/packages/frontend/src/components/global/i18n.ts @@ -0,0 +1,42 @@ +import { h, defineComponent } from 'vue'; + +export default defineComponent({ + props: { + src: { + type: String, + required: true, + }, + tag: { + type: String, + required: false, + default: 'span', + }, + textTag: { + type: String, + required: false, + default: null, + }, + }, + render() { + let str = this.src; + const parsed = [] as (string | { arg: string; })[]; + while (true) { + const nextBracketOpen = str.indexOf('{'); + const nextBracketClose = str.indexOf('}'); + + if (nextBracketOpen === -1) { + parsed.push(str); + break; + } else { + if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen)); + parsed.push({ + arg: str.substring(nextBracketOpen + 1, nextBracketClose), + }); + } + + str = str.substr(nextBracketClose + 1); + } + + return h(this.tag, parsed.map(x => typeof x === 'string' ? (this.textTag ? h(this.textTag, x) : x) : this.$slots[x.arg]())); + }, +}); diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts new file mode 100644 index 0000000000..8639257003 --- /dev/null +++ b/packages/frontend/src/components/index.ts @@ -0,0 +1,61 @@ +import { App } from 'vue'; + +import Mfm from './global/MkMisskeyFlavoredMarkdown.vue'; +import MkA from './global/MkA.vue'; +import MkAcct from './global/MkAcct.vue'; +import MkAvatar from './global/MkAvatar.vue'; +import MkEmoji from './global/MkEmoji.vue'; +import MkUserName from './global/MkUserName.vue'; +import MkEllipsis from './global/MkEllipsis.vue'; +import MkTime from './global/MkTime.vue'; +import MkUrl from './global/MkUrl.vue'; +import I18n from './global/i18n'; +import RouterView from './global/RouterView.vue'; +import MkLoading from './global/MkLoading.vue'; +import MkError from './global/MkError.vue'; +import MkAd from './global/MkAd.vue'; +import MkPageHeader from './global/MkPageHeader.vue'; +import MkSpacer from './global/MkSpacer.vue'; +import MkStickyContainer from './global/MkStickyContainer.vue'; + +export default function(app: App) { + app.component('I18n', I18n); + app.component('RouterView', RouterView); + app.component('Mfm', Mfm); + app.component('MkA', MkA); + app.component('MkAcct', MkAcct); + app.component('MkAvatar', MkAvatar); + app.component('MkEmoji', MkEmoji); + app.component('MkUserName', MkUserName); + app.component('MkEllipsis', MkEllipsis); + app.component('MkTime', MkTime); + app.component('MkUrl', MkUrl); + app.component('MkLoading', MkLoading); + app.component('MkError', MkError); + app.component('MkAd', MkAd); + app.component('MkPageHeader', MkPageHeader); + app.component('MkSpacer', MkSpacer); + app.component('MkStickyContainer', MkStickyContainer); +} + +declare module '@vue/runtime-core' { + export interface GlobalComponents { + I18n: typeof I18n; + RouterView: typeof RouterView; + Mfm: typeof Mfm; + MkA: typeof MkA; + MkAcct: typeof MkAcct; + MkAvatar: typeof MkAvatar; + MkEmoji: typeof MkEmoji; + MkUserName: typeof MkUserName; + MkEllipsis: typeof MkEllipsis; + MkTime: typeof MkTime; + MkUrl: typeof MkUrl; + MkLoading: typeof MkLoading; + MkError: typeof MkError; + MkAd: typeof MkAd; + MkPageHeader: typeof MkPageHeader; + MkSpacer: typeof MkSpacer; + MkStickyContainer: typeof MkStickyContainer; + } +} diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts new file mode 100644 index 0000000000..5b5b1caae3 --- /dev/null +++ b/packages/frontend/src/components/mfm.ts @@ -0,0 +1,331 @@ +import { VNode, defineComponent, h } from 'vue'; +import * as mfm from 'mfm-js'; +import MkUrl from '@/components/global/MkUrl.vue'; +import MkLink from '@/components/MkLink.vue'; +import MkMention from '@/components/MkMention.vue'; +import MkEmoji from '@/components/global/MkEmoji.vue'; +import { concat } from '@/scripts/array'; +import MkFormula from '@/components/MkFormula.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkGoogle from '@/components/MkGoogle.vue'; +import MkSparkle from '@/components/MkSparkle.vue'; +import MkA from '@/components/global/MkA.vue'; +import { host } from '@/config'; +import { MFM_TAGS } from '@/scripts/mfm-tags'; + +export default defineComponent({ + props: { + text: { + type: String, + required: true, + }, + plain: { + type: Boolean, + default: false, + }, + nowrap: { + type: Boolean, + default: false, + }, + author: { + type: Object, + default: null, + }, + i: { + type: Object, + default: null, + }, + customEmojis: { + required: false, + }, + isNote: { + type: Boolean, + default: true, + }, + }, + + render() { + if (this.text == null || this.text === '') return; + + const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, { fnNameList: MFM_TAGS }); + + const validTime = (t: string | null | undefined) => { + if (t == null) return null; + return t.match(/^[0-9.]+s$/) ? t : null; + }; + + const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => { + switch (token.type) { + case 'text': { + const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n'); + + if (!this.plain) { + const res: (VNode | string)[] = []; + for (const t of text.split('\n')) { + res.push(h('br')); + res.push(t); + } + res.shift(); + return res; + } else { + return [text.replace(/\n/g, ' ')]; + } + } + + case 'bold': { + return [h('b', genEl(token.children))]; + } + + case 'strike': { + return [h('del', genEl(token.children))]; + } + + case 'italic': { + return h('i', { + style: 'font-style: oblique;', + }, genEl(token.children)); + } + + case 'fn': { + // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる + let style; + switch (token.props.name) { + case 'tada': { + const speed = validTime(token.props.args.speed) || '1s'; + style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: tada ${speed} linear infinite both;` : ''); + break; + } + case 'jelly': { + const speed = validTime(token.props.args.speed) || '1s'; + style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + break; + } + case 'twitch': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + break; + } + case 'shake': { + const speed = validTime(token.props.args.speed) || '0.5s'; + style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : ''; + break; + } + case 'spin': { + const direction = + token.props.args.left ? 'reverse' : + token.props.args.alternate ? 'alternate' : + 'normal'; + const anime = + token.props.args.x ? 'mfm-spinX' : + token.props.args.y ? 'mfm-spinY' : + 'mfm-spin'; + const speed = validTime(token.props.args.speed) || '1.5s'; + style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + break; + } + case 'jump': { + const speed = validTime(token.props.args.speed) || '0.75s'; + style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : ''; + break; + } + case 'bounce': { + const speed = validTime(token.props.args.speed) || '0.75s'; + style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; + break; + } + case 'flip': { + const transform = + (token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' : + token.props.args.v ? 'scaleY(-1)' : + 'scaleX(-1)'; + style = `transform: ${transform};`; + break; + } + case 'x2': { + return h('span', { + class: 'mfm-x2', + }, genEl(token.children)); + } + case 'x3': { + return h('span', { + class: 'mfm-x3', + }, genEl(token.children)); + } + case 'x4': { + return h('span', { + class: 'mfm-x4', + }, genEl(token.children)); + } + case 'font': { + const family = + token.props.args.serif ? 'serif' : + token.props.args.monospace ? 'monospace' : + token.props.args.cursive ? 'cursive' : + token.props.args.fantasy ? 'fantasy' : + token.props.args.emoji ? 'emoji' : + token.props.args.math ? 'math' : + null; + if (family) style = `font-family: ${family};`; + break; + } + case 'blur': { + return h('span', { + class: '_mfm_blur_', + }, genEl(token.children)); + } + case 'rainbow': { + const speed = validTime(token.props.args.speed) || '1s'; + style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; + break; + } + case 'sparkle': { + if (!this.$store.state.animatedMfm) { + return genEl(token.children); + } + return h(MkSparkle, {}, genEl(token.children)); + } + case 'rotate': { + const degrees = parseInt(token.props.args.deg) || '90'; + style = `transform: rotate(${degrees}deg); transform-origin: center center;`; + break; + } + } + if (style == null) { + return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children), ']']); + } else { + return h('span', { + style: 'display: inline-block;' + style, + }, genEl(token.children)); + } + } + + case 'small': { + return [h('small', { + style: 'opacity: 0.7;', + }, genEl(token.children))]; + } + + case 'center': { + return [h('div', { + style: 'text-align:center;', + }, genEl(token.children))]; + } + + case 'url': { + return [h(MkUrl, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + })]; + } + + case 'link': { + return [h(MkLink, { + key: Math.random(), + url: token.props.url, + rel: 'nofollow noopener', + }, genEl(token.children))]; + } + + case 'mention': { + return [h(MkMention, { + key: Math.random(), + host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, + username: token.props.username, + })]; + } + + case 'hashtag': { + return [h(MkA, { + key: Math.random(), + to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, + style: 'color:var(--hashtag);', + }, `#${token.props.hashtag}`)]; + } + + case 'blockCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + lang: token.props.lang, + })]; + } + + case 'inlineCode': { + return [h(MkCode, { + key: Math.random(), + code: token.props.code, + inline: true, + })]; + } + + case 'quote': { + if (!this.nowrap) { + return [h('div', { + class: 'quote', + }, genEl(token.children))]; + } else { + return [h('span', { + class: 'quote', + }, genEl(token.children))]; + } + } + + case 'emojiCode': { + return [h(MkEmoji, { + key: Math.random(), + emoji: `:${token.props.name}:`, + customEmojis: this.customEmojis, + normal: this.plain, + })]; + } + + case 'unicodeEmoji': { + return [h(MkEmoji, { + key: Math.random(), + emoji: token.props.emoji, + customEmojis: this.customEmojis, + normal: this.plain, + })]; + } + + case 'mathInline': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: false, + })]; + } + + case 'mathBlock': { + return [h(MkFormula, { + key: Math.random(), + formula: token.props.formula, + block: true, + })]; + } + + case 'search': { + return [h(MkGoogle, { + key: Math.random(), + q: token.props.query, + })]; + } + + case 'plain': { + return [h('span', genEl(token.children))]; + } + + default: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + console.error('unrecognized ast type:', (token as any).type); + + return []; + } + } + }).flat(Infinity) as (VNode | string)[]; + + // Parse ast to DOM + return h('span', genEl(ast)); + }, +}); diff --git a/packages/frontend/src/components/page/page.block.vue b/packages/frontend/src/components/page/page.block.vue new file mode 100644 index 0000000000..f3e7764604 --- /dev/null +++ b/packages/frontend/src/components/page/page.block.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/frontend/src/components/page/page.button.vue b/packages/frontend/src/components/page/page.button.vue new file mode 100644 index 0000000000..83931021d8 --- /dev/null +++ b/packages/frontend/src/components/page/page.button.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.canvas.vue b/packages/frontend/src/components/page/page.canvas.vue new file mode 100644 index 0000000000..80f6c8339c --- /dev/null +++ b/packages/frontend/src/components/page/page.canvas.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.counter.vue b/packages/frontend/src/components/page/page.counter.vue new file mode 100644 index 0000000000..a9e1f41a54 --- /dev/null +++ b/packages/frontend/src/components/page/page.counter.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.if.vue b/packages/frontend/src/components/page/page.if.vue new file mode 100644 index 0000000000..372a15f0c6 --- /dev/null +++ b/packages/frontend/src/components/page/page.if.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/frontend/src/components/page/page.image.vue b/packages/frontend/src/components/page/page.image.vue new file mode 100644 index 0000000000..8ba70c5855 --- /dev/null +++ b/packages/frontend/src/components/page/page.image.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.note.vue b/packages/frontend/src/components/page/page.note.vue new file mode 100644 index 0000000000..7d5c484a1b --- /dev/null +++ b/packages/frontend/src/components/page/page.note.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.number-input.vue b/packages/frontend/src/components/page/page.number-input.vue new file mode 100644 index 0000000000..50cf6d0770 --- /dev/null +++ b/packages/frontend/src/components/page/page.number-input.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.post.vue b/packages/frontend/src/components/page/page.post.vue new file mode 100644 index 0000000000..0ef50d65cd --- /dev/null +++ b/packages/frontend/src/components/page/page.post.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.radio-button.vue b/packages/frontend/src/components/page/page.radio-button.vue new file mode 100644 index 0000000000..b4d9e01a54 --- /dev/null +++ b/packages/frontend/src/components/page/page.radio-button.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/frontend/src/components/page/page.section.vue b/packages/frontend/src/components/page/page.section.vue new file mode 100644 index 0000000000..630c1f5179 --- /dev/null +++ b/packages/frontend/src/components/page/page.section.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.switch.vue b/packages/frontend/src/components/page/page.switch.vue new file mode 100644 index 0000000000..64dc4ff8aa --- /dev/null +++ b/packages/frontend/src/components/page/page.switch.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.text-input.vue b/packages/frontend/src/components/page/page.text-input.vue new file mode 100644 index 0000000000..840649ece6 --- /dev/null +++ b/packages/frontend/src/components/page/page.text-input.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue new file mode 100644 index 0000000000..689c484521 --- /dev/null +++ b/packages/frontend/src/components/page/page.text.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/frontend/src/components/page/page.textarea-input.vue b/packages/frontend/src/components/page/page.textarea-input.vue new file mode 100644 index 0000000000..507e1bd97b --- /dev/null +++ b/packages/frontend/src/components/page/page.textarea-input.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/frontend/src/components/page/page.textarea.vue b/packages/frontend/src/components/page/page.textarea.vue new file mode 100644 index 0000000000..f809925081 --- /dev/null +++ b/packages/frontend/src/components/page/page.textarea.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue new file mode 100644 index 0000000000..b5cb73c009 --- /dev/null +++ b/packages/frontend/src/components/page/page.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts new file mode 100644 index 0000000000..f2022b0f02 --- /dev/null +++ b/packages/frontend/src/config.ts @@ -0,0 +1,15 @@ +const address = new URL(location.href); +const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content; + +export const host = address.host; +export const hostname = address.hostname; +export const url = address.origin; +export const apiUrl = url + '/api'; +export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; +export const lang = localStorage.getItem('lang'); +export const langs = _LANGS_; +export const locale = JSON.parse(localStorage.getItem('locale')); +export const version = _VERSION_; +export const instanceName = siteName === 'Misskey' ? host : siteName; +export const ui = localStorage.getItem('ui'); +export const debug = localStorage.getItem('debug') === 'true'; diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts new file mode 100644 index 0000000000..77366cf07b --- /dev/null +++ b/packages/frontend/src/const.ts @@ -0,0 +1,45 @@ +// ブラウザで直接表示することを許可するファイルの種類のリスト +// ここに含まれないものは application/octet-stream としてレスポンスされる +// SVGはXSSを生むので許可しない +export const FILE_TYPE_BROWSERSAFE = [ + // Images + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/apng', + 'image/bmp', + 'image/tiff', + 'image/x-icon', + + // OggS + 'audio/opus', + 'video/ogg', + 'audio/ogg', + 'application/ogg', + + // ISO/IEC base media file format + 'video/quicktime', + 'video/mp4', + 'audio/mp4', + 'video/x-m4v', + 'audio/x-m4a', + 'video/3gpp', + 'video/3gpp2', + + 'video/mpeg', + 'audio/mpeg', + + 'video/webm', + 'audio/webm', + + 'audio/aac', + 'audio/x-flac', + 'audio/vnd.wave', +]; +/* +https://github.com/sindresorhus/file-type/blob/main/supported.js +https://github.com/sindresorhus/file-type/blob/main/core.js +https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers +*/ diff --git a/packages/frontend/src/directives/adaptive-border.ts b/packages/frontend/src/directives/adaptive-border.ts new file mode 100644 index 0000000000..619c9f0b6d --- /dev/null +++ b/packages/frontend/src/directives/adaptive-border.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = window.getComputedStyle(src).backgroundColor; + + if (parentBg === myBg) { + src.style.borderColor = 'var(--divider)'; + } else { + src.style.borderColor = myBg; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/anim.ts b/packages/frontend/src/directives/anim.ts new file mode 100644 index 0000000000..04e1c6a404 --- /dev/null +++ b/packages/frontend/src/directives/anim.ts @@ -0,0 +1,18 @@ +import { Directive } from 'vue'; + +export default { + beforeMount(src, binding, vn) { + src.style.opacity = '0'; + src.style.transform = 'scale(0.9)'; + // ページネーションと相性が悪いので + //if (typeof binding.value === 'number') src.style.transitionDelay = `${binding.value * 30}ms`; + src.classList.add('_zoom'); + }, + + mounted(src, binding, vn) { + window.setTimeout(() => { + src.style.opacity = '1'; + src.style.transform = 'none'; + }, 1); + }, +} as Directive; diff --git a/packages/frontend/src/directives/appear.ts b/packages/frontend/src/directives/appear.ts new file mode 100644 index 0000000000..7fa43fc34a --- /dev/null +++ b/packages/frontend/src/directives/appear.ts @@ -0,0 +1,22 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const fn = binding.value; + if (fn == null) return; + + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + fn(); + } + }); + + observer.observe(src); + + src._observer_ = observer; + }, + + unmounted(src, binding, vn) { + if (src._observer_) src._observer_.disconnect(); + }, +} as Directive; diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts new file mode 100644 index 0000000000..e2f514b7ca --- /dev/null +++ b/packages/frontend/src/directives/click-anime.ts @@ -0,0 +1,31 @@ +import { Directive } from 'vue'; +import { defaultStore } from '@/store'; + +export default { + mounted(el, binding, vn) { + /* + if (!defaultStore.state.animation) return; + + el.classList.add('_anime_bounce_standBy'); + + el.addEventListener('mousedown', () => { + el.classList.add('_anime_bounce_standBy'); + el.classList.add('_anime_bounce_ready'); + + el.addEventListener('mouseleave', () => { + el.classList.remove('_anime_bounce_ready'); + }); + }); + + el.addEventListener('click', () => { + el.classList.add('_anime_bounce'); + }); + + el.addEventListener('animationend', () => { + el.classList.remove('_anime_bounce_ready'); + el.classList.remove('_anime_bounce'); + el.classList.add('_anime_bounce_standBy'); + }); + */ + }, +} as Directive; diff --git a/packages/frontend/src/directives/follow-append.ts b/packages/frontend/src/directives/follow-append.ts new file mode 100644 index 0000000000..62e0ac3b94 --- /dev/null +++ b/packages/frontend/src/directives/follow-append.ts @@ -0,0 +1,35 @@ +import { Directive } from 'vue'; +import { getScrollContainer, getScrollPosition } from '@/scripts/scroll'; + +export default { + mounted(src, binding, vn) { + if (binding.value === false) return; + + let isBottom = true; + + const container = getScrollContainer(src)!; + container.addEventListener('scroll', () => { + const pos = getScrollPosition(container); + const viewHeight = container.clientHeight; + const height = container.scrollHeight; + isBottom = (pos + viewHeight > height - 32); + }, { passive: true }); + container.scrollTop = container.scrollHeight; + + const ro = new ResizeObserver((entries, observer) => { + if (isBottom) { + const height = container.scrollHeight; + container.scrollTop = height; + } + }); + + ro.observe(src); + + // TODO: 新たにプロパティを作るのをやめMapを使う + src._ro_ = ro; + }, + + unmounted(src, binding, vn) { + if (src._ro_) src._ro_.unobserve(src); + }, +} as Directive; diff --git a/packages/frontend/src/directives/get-size.ts b/packages/frontend/src/directives/get-size.ts new file mode 100644 index 0000000000..ff3bdd78ac --- /dev/null +++ b/packages/frontend/src/directives/get-size.ts @@ -0,0 +1,54 @@ +import { Directive } from 'vue'; + +const mountings = new Map void; +}>(); + +function calc(src: Element) { + const info = mountings.get(src); + const height = src.clientHeight; + const width = src.clientWidth; + + if (!info) return; + + // アクティベート前などでsrcが描画されていない場合 + if (!height) { + // IntersectionObserverで表示検出する + if (!info.intersection) { + info.intersection = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) calc(src); + }); + } + info.intersection.observe(src); + return; + } + if (info.intersection) { + info.intersection.disconnect(); + delete info.intersection; + } + + info.fn(width, height); +} + +export default { + mounted(src, binding, vn) { + const resize = new ResizeObserver((entries, observer) => { + calc(src); + }); + resize.observe(src); + + mountings.set(src, { resize, fn: binding.value }); + calc(src); + }, + + unmounted(src, binding, vn) { + binding.value(0, 0); + const info = mountings.get(src); + if (!info) return; + info.resize.disconnect(); + if (info.intersection) info.intersection.disconnect(); + mountings.delete(src); + }, +} as Directive void>; diff --git a/packages/frontend/src/directives/hotkey.ts b/packages/frontend/src/directives/hotkey.ts new file mode 100644 index 0000000000..dfc5f646a4 --- /dev/null +++ b/packages/frontend/src/directives/hotkey.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; +import { makeHotkey } from '../scripts/hotkey'; + +export default { + mounted(el, binding) { + el._hotkey_global = binding.modifiers.global === true; + + el._keyHandler = makeHotkey(binding.value); + + if (el._hotkey_global) { + document.addEventListener('keydown', el._keyHandler); + } else { + el.addEventListener('keydown', el._keyHandler); + } + }, + + unmounted(el) { + if (el._hotkey_global) { + document.removeEventListener('keydown', el._keyHandler); + } else { + el.removeEventListener('keydown', el._keyHandler); + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts new file mode 100644 index 0000000000..401a917cba --- /dev/null +++ b/packages/frontend/src/directives/index.ts @@ -0,0 +1,28 @@ +import { App } from 'vue'; + +import userPreview from './user-preview'; +import size from './size'; +import getSize from './get-size'; +import ripple from './ripple'; +import tooltip from './tooltip'; +import hotkey from './hotkey'; +import appear from './appear'; +import anim from './anim'; +import clickAnime from './click-anime'; +import panel from './panel'; +import adaptiveBorder from './adaptive-border'; + +export default function(app: App) { + app.directive('userPreview', userPreview); + app.directive('user-preview', userPreview); + app.directive('size', size); + app.directive('get-size', getSize); + app.directive('ripple', ripple); + app.directive('tooltip', tooltip); + app.directive('hotkey', hotkey); + app.directive('appear', appear); + app.directive('anim', anim); + app.directive('click-anime', clickAnime); + app.directive('panel', panel); + app.directive('adaptive-border', adaptiveBorder); +} diff --git a/packages/frontend/src/directives/panel.ts b/packages/frontend/src/directives/panel.ts new file mode 100644 index 0000000000..d31dc41ed4 --- /dev/null +++ b/packages/frontend/src/directives/panel.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel'); + + if (parentBg === myBg) { + src.style.backgroundColor = 'var(--bg)'; + } else { + src.style.backgroundColor = 'var(--panel)'; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/ripple.ts b/packages/frontend/src/directives/ripple.ts new file mode 100644 index 0000000000..d32f7ab441 --- /dev/null +++ b/packages/frontend/src/directives/ripple.ts @@ -0,0 +1,18 @@ +import Ripple from '@/components/MkRipple.vue'; +import { popup } from '@/os'; + +export default { + mounted(el, binding, vn) { + // 明示的に false であればバインドしない + if (binding.value === false) return; + + el.addEventListener('click', () => { + const rect = el.getBoundingClientRect(); + + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + + popup(Ripple, { x, y }, {}, 'end'); + }); + }, +}; diff --git a/packages/frontend/src/directives/size.ts b/packages/frontend/src/directives/size.ts new file mode 100644 index 0000000000..da8bd78ea1 --- /dev/null +++ b/packages/frontend/src/directives/size.ts @@ -0,0 +1,123 @@ +import { Directive } from 'vue'; + +type Value = { max?: number[]; min?: number[]; }; + +//const observers = new Map(); +const mountings = new Map(); + +type ClassOrder = { + add: string[]; + remove: string[]; +}; + +const isContainerQueriesSupported = ('container' in document.documentElement.style); + +const cache = new Map(); + +function getClassOrder(width: number, queue: Value): ClassOrder { + const getMaxClass = (v: number) => `max-width_${v}px`; + const getMinClass = (v: number) => `min-width_${v}px`; + + return { + add: [ + ...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []), + ], + remove: [ + ...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []), + ...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []), + ], + }; +} + +function applyClassOrder(el: Element, order: ClassOrder) { + el.classList.add(...order.add); + el.classList.remove(...order.remove); +} + +function getOrderName(width: number, queue: Value): string { + return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`; +} + +function calc(el: Element) { + const info = mountings.get(el); + const width = el.clientWidth; + + if (!info || info.previousWidth === width) return; + + // アクティベート前などでsrcが描画されていない場合 + if (!width) { + // IntersectionObserverで表示検出する + if (!info.intersection) { + info.intersection = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) calc(el); + }); + } + info.intersection.observe(el); + return; + } + if (info.intersection) { + info.intersection.disconnect(); + delete info.intersection; + } + + mountings.set(el, { ...info, ...{ previousWidth: width, twoPreviousWidth: info.previousWidth }}); + + // Prevent infinite resizing + // https://github.com/misskey-dev/misskey/issues/9076 + if (info.twoPreviousWidth === width) { + return; + } + + const cached = cache.get(getOrderName(width, info.value)); + if (cached) { + applyClassOrder(el, cached); + } else { + const order = getClassOrder(width, info.value); + cache.set(getOrderName(width, info.value), order); + applyClassOrder(el, order); + } +} + +export default { + mounted(src, binding, vn) { + if (isContainerQueriesSupported) return; + + const resize = new ResizeObserver((entries, observer) => { + calc(src); + }); + + mountings.set(src, { + value: binding.value, + resize, + previousWidth: 0, + twoPreviousWidth: 0, + }); + + calc(src); + resize.observe(src); + }, + + updated(src, binding, vn) { + if (isContainerQueriesSupported) return; + + mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value })); + calc(src); + }, + + unmounted(src, binding, vn) { + if (isContainerQueriesSupported) return; + + const info = mountings.get(src); + if (!info) return; + info.resize.disconnect(); + if (info.intersection) info.intersection.disconnect(); + mountings.delete(src); + }, +} as Directive; diff --git a/packages/frontend/src/directives/tooltip.ts b/packages/frontend/src/directives/tooltip.ts new file mode 100644 index 0000000000..5d13497b5f --- /dev/null +++ b/packages/frontend/src/directives/tooltip.ts @@ -0,0 +1,93 @@ +// TODO: useTooltip関数使うようにしたい +// ただディレクティブ内でonUnmountedなどのcomposition api使えるのか不明 + +import { defineAsyncComponent, Directive, ref } from 'vue'; +import { isTouchUsing } from '@/scripts/touch'; +import { popup, alert } from '@/os'; + +const start = isTouchUsing ? 'touchstart' : 'mouseover'; +const end = isTouchUsing ? 'touchend' : 'mouseleave'; + +export default { + mounted(el: HTMLElement, binding, vn) { + const delay = binding.modifiers.noDelay ? 0 : 100; + + const self = (el as any)._tooltipDirective_ = {} as any; + + self.text = binding.value as string; + self._close = null; + self.showTimer = null; + self.hideTimer = null; + self.checkTimer = null; + + self.close = () => { + if (self._close) { + window.clearInterval(self.checkTimer); + self._close(); + self._close = null; + } + }; + + if (binding.arg === 'dialog') { + el.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + alert({ + type: 'info', + text: binding.value, + }); + return false; + }); + } + + self.show = () => { + if (!document.body.contains(el)) return; + if (self._close) return; + if (self.text == null) return; + + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), { + showing, + text: self.text, + asMfm: binding.modifiers.mfm, + direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top', + targetElement: el, + }, {}, 'closed'); + + self._close = () => { + showing.value = false; + }; + }; + + el.addEventListener('selectstart', ev => { + ev.preventDefault(); + }); + + el.addEventListener(start, () => { + window.clearTimeout(self.showTimer); + window.clearTimeout(self.hideTimer); + self.showTimer = window.setTimeout(self.show, delay); + }, { passive: true }); + + el.addEventListener(end, () => { + window.clearTimeout(self.showTimer); + window.clearTimeout(self.hideTimer); + self.hideTimer = window.setTimeout(self.close, delay); + }, { passive: true }); + + el.addEventListener('click', () => { + window.clearTimeout(self.showTimer); + self.close(); + }); + }, + + updated(el, binding) { + const self = el._tooltipDirective_; + self.text = binding.value as string; + }, + + unmounted(el, binding, vn) { + const self = el._tooltipDirective_; + window.clearInterval(self.checkTimer); + }, +} as Directive; diff --git a/packages/frontend/src/directives/user-preview.ts b/packages/frontend/src/directives/user-preview.ts new file mode 100644 index 0000000000..ed5f00ca65 --- /dev/null +++ b/packages/frontend/src/directives/user-preview.ts @@ -0,0 +1,118 @@ +import { defineAsyncComponent, Directive, ref } from 'vue'; +import autobind from 'autobind-decorator'; +import { popup } from '@/os'; + +export class UserPreview { + private el; + private user; + private showTimer; + private hideTimer; + private checkTimer; + private promise; + + constructor(el, user) { + this.el = el; + this.user = user; + + this.attach(); + } + + @autobind + private show() { + if (!document.body.contains(this.el)) return; + if (this.promise) return; + + const showing = ref(true); + + popup(defineAsyncComponent(() => import('@/components/MkUserPreview.vue')), { + showing, + q: this.user, + source: this.el, + }, { + mouseover: () => { + window.clearTimeout(this.hideTimer); + }, + mouseleave: () => { + window.clearTimeout(this.showTimer); + this.hideTimer = window.setTimeout(this.close, 500); + }, + }, 'closed'); + + this.promise = { + cancel: () => { + showing.value = false; + }, + }; + + this.checkTimer = window.setInterval(() => { + if (!document.body.contains(this.el)) { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.close(); + } + }, 1000); + } + + @autobind + private close() { + if (this.promise) { + window.clearInterval(this.checkTimer); + this.promise.cancel(); + this.promise = null; + } + } + + @autobind + private onMouseover() { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.showTimer = window.setTimeout(this.show, 500); + } + + @autobind + private onMouseleave() { + window.clearTimeout(this.showTimer); + window.clearTimeout(this.hideTimer); + this.hideTimer = window.setTimeout(this.close, 500); + } + + @autobind + private onClick() { + window.clearTimeout(this.showTimer); + this.close(); + } + + @autobind + public attach() { + this.el.addEventListener('mouseover', this.onMouseover); + this.el.addEventListener('mouseleave', this.onMouseleave); + this.el.addEventListener('click', this.onClick); + } + + @autobind + public detach() { + this.el.removeEventListener('mouseover', this.onMouseover); + this.el.removeEventListener('mouseleave', this.onMouseleave); + this.el.removeEventListener('click', this.onClick); + window.clearInterval(this.checkTimer); + } +} + +export default { + mounted(el: HTMLElement, binding, vn) { + if (binding.value == null) return; + + // TODO: 新たにプロパティを作るのをやめMapを使う + // ただメモリ的には↓の方が省メモリかもしれないので検討中 + const self = (el as any)._userPreviewDirective_ = {} as any; + + self.preview = new UserPreview(el, binding.value); + }, + + unmounted(el, binding, vn) { + if (binding.value == null) return; + + const self = el._userPreviewDirective_; + self.preview.detach(); + }, +} as Directive; diff --git a/packages/frontend/src/emojilist.json b/packages/frontend/src/emojilist.json new file mode 100644 index 0000000000..402e82e33b --- /dev/null +++ b/packages/frontend/src/emojilist.json @@ -0,0 +1,1785 @@ +[ + { "category": "face", "char": "😀", "name": "grinning", "keywords": ["face", "smile", "happy", "joy", ": D", "grin"] }, + { "category": "face", "char": "😬", "name": "grimacing", "keywords": ["face", "grimace", "teeth"] }, + { "category": "face", "char": "😁", "name": "grin", "keywords": ["face", "happy", "smile", "joy", "kawaii"] }, + { "category": "face", "char": "😂", "name": "joy", "keywords": ["face", "cry", "tears", "weep", "happy", "happytears", "haha"] }, + { "category": "face", "char": "🤣", "name": "rofl", "keywords": ["face", "rolling", "floor", "laughing", "lol", "haha"] }, + { "category": "face", "char": "🥳", "name": "partying", "keywords": ["face", "celebration", "woohoo"] }, + { "category": "face", "char": "😃", "name": "smiley", "keywords": ["face", "happy", "joy", "haha", ": D", ": )", "smile", "funny"] }, + { "category": "face", "char": "😄", "name": "smile", "keywords": ["face", "happy", "joy", "funny", "haha", "laugh", "like", ": D", ": )"] }, + { "category": "face", "char": "😅", "name": "sweat_smile", "keywords": ["face", "hot", "happy", "laugh", "sweat", "smile", "relief"] }, + { "category": "face", "char": "🥲", "name": "smiling_face_with_tear", "keywords": ["face"] }, + { "category": "face", "char": "😆", "name": "laughing", "keywords": ["happy", "joy", "lol", "satisfied", "haha", "face", "glad", "XD", "laugh"] }, + { "category": "face", "char": "😇", "name": "innocent", "keywords": ["face", "angel", "heaven", "halo"] }, + { "category": "face", "char": "😉", "name": "wink", "keywords": ["face", "happy", "mischievous", "secret", ";)", "smile", "eye"] }, + { "category": "face", "char": "😊", "name": "blush", "keywords": ["face", "smile", "happy", "flushed", "crush", "embarrassed", "shy", "joy"] }, + { "category": "face", "char": "🙂", "name": "slightly_smiling_face", "keywords": ["face", "smile"] }, + { "category": "face", "char": "🙃", "name": "upside_down_face", "keywords": ["face", "flipped", "silly", "smile"] }, + { "category": "face", "char": "☺️", "name": "relaxed", "keywords": ["face", "blush", "massage", "happiness"] }, + { "category": "face", "char": "😋", "name": "yum", "keywords": ["happy", "joy", "tongue", "smile", "face", "silly", "yummy", "nom", "delicious", "savouring"] }, + { "category": "face", "char": "😌", "name": "relieved", "keywords": ["face", "relaxed", "phew", "massage", "happiness"] }, + { "category": "face", "char": "😍", "name": "heart_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "heart"] }, + { "category": "face", "char": "🥰", "name": "smiling_face_with_three_hearts", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "crush", "hearts", "adore"] }, + { "category": "face", "char": "😘", "name": "kissing_heart", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😗", "name": "kissing", "keywords": ["love", "like", "face", "3", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😙", "name": "kissing_smiling_eyes", "keywords": ["face", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😚", "name": "kissing_closed_eyes", "keywords": ["face", "love", "like", "affection", "valentines", "infatuation", "kiss"] }, + { "category": "face", "char": "😜", "name": "stuck_out_tongue_winking_eye", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "wink", "tongue"] }, + { "category": "face", "char": "🤪", "name": "zany", "keywords": ["face", "goofy", "crazy"] }, + { "category": "face", "char": "🤨", "name": "raised_eyebrow", "keywords": ["face", "distrust", "scepticism", "disapproval", "disbelief", "surprise"] }, + { "category": "face", "char": "🧐", "name": "monocle", "keywords": ["face", "stuffy", "wealthy"] }, + { "category": "face", "char": "😝", "name": "stuck_out_tongue_closed_eyes", "keywords": ["face", "prank", "playful", "mischievous", "smile", "tongue"] }, + { "category": "face", "char": "😛", "name": "stuck_out_tongue", "keywords": ["face", "prank", "childish", "playful", "mischievous", "smile", "tongue"] }, + { "category": "face", "char": "🤑", "name": "money_mouth_face", "keywords": ["face", "rich", "dollar", "money"] }, + { "category": "face", "char": "🤓", "name": "nerd_face", "keywords": ["face", "nerdy", "geek", "dork"] }, + { "category": "face", "char": "🥸", "name": "disguised_face", "keywords": ["face", "nose", "glasses", "incognito"] }, + { "category": "face", "char": "😎", "name": "sunglasses", "keywords": ["face", "cool", "smile", "summer", "beach", "sunglass"] }, + { "category": "face", "char": "🤩", "name": "star_struck", "keywords": ["face", "smile", "starry", "eyes", "grinning"] }, + { "category": "face", "char": "🤡", "name": "clown_face", "keywords": ["face"] }, + { "category": "face", "char": "🤠", "name": "cowboy_hat_face", "keywords": ["face", "cowgirl", "hat"] }, + { "category": "face", "char": "🤗", "name": "hugs", "keywords": ["face", "smile", "hug"] }, + { "category": "face", "char": "😏", "name": "smirk", "keywords": ["face", "smile", "mean", "prank", "smug", "sarcasm"] }, + { "category": "face", "char": "😶", "name": "no_mouth", "keywords": ["face", "hellokitty"] }, + { "category": "face", "char": "😐", "name": "neutral_face", "keywords": ["indifference", "meh", ": |", "neutral"] }, + { "category": "face", "char": "😑", "name": "expressionless", "keywords": ["face", "indifferent", "-_-", "meh", "deadpan"] }, + { "category": "face", "char": "😒", "name": "unamused", "keywords": ["indifference", "bored", "straight face", "serious", "sarcasm", "unimpressed", "skeptical", "dubious", "side_eye"] }, + { "category": "face", "char": "🙄", "name": "roll_eyes", "keywords": ["face", "eyeroll", "frustrated"] }, + { "category": "face", "char": "🤔", "name": "thinking", "keywords": ["face", "hmmm", "think", "consider"] }, + { "category": "face", "char": "🤥", "name": "lying_face", "keywords": ["face", "lie", "pinocchio"] }, + { "category": "face", "char": "🤭", "name": "hand_over_mouth", "keywords": ["face", "whoops", "shock", "surprise"] }, + { "category": "face", "char": "🤫", "name": "shushing", "keywords": ["face", "quiet", "shhh"] }, + { "category": "face", "char": "🤬", "name": "symbols_over_mouth", "keywords": ["face", "swearing", "cursing", "cussing", "profanity", "expletive"] }, + { "category": "face", "char": "🤯", "name": "exploding_head", "keywords": ["face", "shocked", "mind", "blown"] }, + { "category": "face", "char": "😳", "name": "flushed", "keywords": ["face", "blush", "shy", "flattered"] }, + { "category": "face", "char": "😞", "name": "disappointed", "keywords": ["face", "sad", "upset", "depressed", ": ("] }, + { "category": "face", "char": "😟", "name": "worried", "keywords": ["face", "concern", "nervous", ": ("] }, + { "category": "face", "char": "😠", "name": "angry", "keywords": ["mad", "face", "annoyed", "frustrated"] }, + { "category": "face", "char": "😡", "name": "rage", "keywords": ["angry", "mad", "hate", "despise"] }, + { "category": "face", "char": "😔", "name": "pensive", "keywords": ["face", "sad", "depressed", "upset"] }, + { "category": "face", "char": "😕", "name": "confused", "keywords": ["face", "indifference", "huh", "weird", "hmmm", ": /"] }, + { "category": "face", "char": "🙁", "name": "slightly_frowning_face", "keywords": ["face", "frowning", "disappointed", "sad", "upset"] }, + { "category": "face", "char": "☹", "name": "frowning_face", "keywords": ["face", "sad", "upset", "frown"] }, + { "category": "face", "char": "😣", "name": "persevere", "keywords": ["face", "sick", "no", "upset", "oops"] }, + { "category": "face", "char": "😖", "name": "confounded", "keywords": ["face", "confused", "sick", "unwell", "oops", ": S"] }, + { "category": "face", "char": "😫", "name": "tired_face", "keywords": ["sick", "whine", "upset", "frustrated"] }, + { "category": "face", "char": "😩", "name": "weary", "keywords": ["face", "tired", "sleepy", "sad", "frustrated", "upset"] }, + { "category": "face", "char": "🥺", "name": "pleading", "keywords": ["face", "begging", "mercy"] }, + { "category": "face", "char": "😤", "name": "triumph", "keywords": ["face", "gas", "phew", "proud", "pride"] }, + { "category": "face", "char": "😮", "name": "open_mouth", "keywords": ["face", "surprise", "impressed", "wow", "whoa", ": O"] }, + { "category": "face", "char": "😱", "name": "scream", "keywords": ["face", "munch", "scared", "omg"] }, + { "category": "face", "char": "😨", "name": "fearful", "keywords": ["face", "scared", "terrified", "nervous", "oops", "huh"] }, + { "category": "face", "char": "😰", "name": "cold_sweat", "keywords": ["face", "nervous", "sweat"] }, + { "category": "face", "char": "😯", "name": "hushed", "keywords": ["face", "woo", "shh"] }, + { "category": "face", "char": "😦", "name": "frowning", "keywords": ["face", "aw", "what"] }, + { "category": "face", "char": "😧", "name": "anguished", "keywords": ["face", "stunned", "nervous"] }, + { "category": "face", "char": "😢", "name": "cry", "keywords": ["face", "tears", "sad", "depressed", "upset", ": '("] }, + { "category": "face", "char": "😥", "name": "disappointed_relieved", "keywords": ["face", "phew", "sweat", "nervous"] }, + { "category": "face", "char": "🤤", "name": "drooling_face", "keywords": ["face"] }, + { "category": "face", "char": "😪", "name": "sleepy", "keywords": ["face", "tired", "rest", "nap"] }, + { "category": "face", "char": "😓", "name": "sweat", "keywords": ["face", "hot", "sad", "tired", "exercise"] }, + { "category": "face", "char": "🥵", "name": "hot", "keywords": ["face", "feverish", "heat", "red", "sweating"] }, + { "category": "face", "char": "🥶", "name": "cold", "keywords": ["face", "blue", "freezing", "frozen", "frostbite", "icicles"] }, + { "category": "face", "char": "😭", "name": "sob", "keywords": ["face", "cry", "tears", "sad", "upset", "depressed"] }, + { "category": "face", "char": "😵", "name": "dizzy_face", "keywords": ["spent", "unconscious", "xox", "dizzy"] }, + { "category": "face", "char": "😲", "name": "astonished", "keywords": ["face", "xox", "surprised", "poisoned"] }, + { "category": "face", "char": "🤐", "name": "zipper_mouth_face", "keywords": ["face", "sealed", "zipper", "secret"] }, + { "category": "face", "char": "🤢", "name": "nauseated_face", "keywords": ["face", "vomit", "gross", "green", "sick", "throw up", "ill"] }, + { "category": "face", "char": "🤧", "name": "sneezing_face", "keywords": ["face", "gesundheit", "sneeze", "sick", "allergy"] }, + { "category": "face", "char": "🤮", "name": "vomiting", "keywords": ["face", "sick"] }, + { "category": "face", "char": "😷", "name": "mask", "keywords": ["face", "sick", "ill", "disease"] }, + { "category": "face", "char": "🤒", "name": "face_with_thermometer", "keywords": ["sick", "temperature", "thermometer", "cold", "fever"] }, + { "category": "face", "char": "🤕", "name": "face_with_head_bandage", "keywords": ["injured", "clumsy", "bandage", "hurt"] }, + { "category": "face", "char": "🥴", "name": "woozy", "keywords": ["face", "dizzy", "intoxicated", "tipsy", "wavy"] }, + { "category": "face", "char": "🥱", "name": "yawning", "keywords": ["face", "tired", "yawning"] }, + { "category": "face", "char": "😴", "name": "sleeping", "keywords": ["face", "tired", "sleepy", "night", "zzz"] }, + { "category": "face", "char": "💤", "name": "zzz", "keywords": ["sleepy", "tired", "dream"] }, + { "category": "face", "char": "\uD83D\uDE36\u200D\uD83C\uDF2B\uFE0F", "name": "face_in_clouds", "keywords": [] }, + { "category": "face", "char": "\uD83D\uDE2E\u200D\uD83D\uDCA8", "name": "face_exhaling", "keywords": [] }, + { "category": "face", "char": "\uD83D\uDE35\u200D\uD83D\uDCAB", "name": "face_with_spiral_eyes", "keywords": [] }, + { "category": "face", "char": "\uD83E\uDEE0", "name": "melting_face", "keywords": ["disappear", "dissolve", "liquid", "melt", "toketa"] }, + { "category": "face", "char": "\uD83E\uDEE2", "name": "face_with_open_eyes_and_hand_over_mouth", "keywords": ["amazement", "awe", "disbelief", "embarrass", "scared", "surprise", "ohoho"] }, + { "category": "face", "char": "\uD83E\uDEE3", "name": "face_with_peeking_eye", "keywords": ["captivated", "peep", "stare", "chunibyo"] }, + { "category": "face", "char": "\uD83E\uDEE1", "name": "saluting_face", "keywords": ["ok", "salute", "sunny", "troops", "yes", "raja"] }, + { "category": "face", "char": "\uD83E\uDEE5", "name": "dotted_line_face", "keywords": ["depressed", "disappear", "hide", "introvert", "invisible", "tensen"] }, + { "category": "face", "char": "\uD83E\uDEE4", "name": "face_with_diagonal_mouth", "keywords": ["disappointed", "meh", "skeptical", "unsure"] }, + { "category": "face", "char": "\uD83E\uDD79", "name": "face_holding_back_tears", "keywords": ["angry", "cry", "proud", "resist", "sad"] }, + { "category": "face", "char": "💩", "name": "poop", "keywords": ["hankey", "shitface", "fail", "turd", "shit"] }, + { "category": "face", "char": "😈", "name": "smiling_imp", "keywords": ["devil", "horns"] }, + { "category": "face", "char": "👿", "name": "imp", "keywords": ["devil", "angry", "horns"] }, + { "category": "face", "char": "👹", "name": "japanese_ogre", "keywords": ["monster", "red", "mask", "halloween", "scary", "creepy", "devil", "demon", "japanese", "ogre"] }, + { "category": "face", "char": "👺", "name": "japanese_goblin", "keywords": ["red", "evil", "mask", "monster", "scary", "creepy", "japanese", "goblin"] }, + { "category": "face", "char": "💀", "name": "skull", "keywords": ["dead", "skeleton", "creepy", "death"] }, + { "category": "face", "char": "👻", "name": "ghost", "keywords": ["halloween", "spooky", "scary"] }, + { "category": "face", "char": "👽", "name": "alien", "keywords": ["UFO", "paul", "weird", "outer_space"] }, + { "category": "face", "char": "🤖", "name": "robot", "keywords": ["computer", "machine", "bot"] }, + { "category": "face", "char": "😺", "name": "smiley_cat", "keywords": ["animal", "cats", "happy", "smile"] }, + { "category": "face", "char": "😸", "name": "smile_cat", "keywords": ["animal", "cats", "smile"] }, + { "category": "face", "char": "😹", "name": "joy_cat", "keywords": ["animal", "cats", "haha", "happy", "tears"] }, + { "category": "face", "char": "😻", "name": "heart_eyes_cat", "keywords": ["animal", "love", "like", "affection", "cats", "valentines", "heart"] }, + { "category": "face", "char": "😼", "name": "smirk_cat", "keywords": ["animal", "cats", "smirk"] }, + { "category": "face", "char": "😽", "name": "kissing_cat", "keywords": ["animal", "cats", "kiss"] }, + { "category": "face", "char": "🙀", "name": "scream_cat", "keywords": ["animal", "cats", "munch", "scared", "scream"] }, + { "category": "face", "char": "😿", "name": "crying_cat_face", "keywords": ["animal", "tears", "weep", "sad", "cats", "upset", "cry"] }, + { "category": "face", "char": "😾", "name": "pouting_cat", "keywords": ["animal", "cats"] }, + { "category": "people", "char": "🤲", "name": "palms_up", "keywords": ["hands", "gesture", "cupped", "prayer"] }, + { "category": "people", "char": "🙌", "name": "raised_hands", "keywords": ["gesture", "hooray", "yea", "celebration", "hands"] }, + { "category": "people", "char": "👏", "name": "clap", "keywords": ["hands", "praise", "applause", "congrats", "yay"] }, + { "category": "people", "char": "👋", "name": "wave", "keywords": ["hands", "gesture", "goodbye", "solong", "farewell", "hello", "hi", "palm"] }, + { "category": "people", "char": "🤙", "name": "call_me_hand", "keywords": ["hands", "gesture"] }, + { "category": "people", "char": "👍", "name": "+1", "keywords": ["thumbsup", "yes", "awesome", "good", "agree", "accept", "cool", "hand", "like"] }, + { "category": "people", "char": "👎", "name": "-1", "keywords": ["thumbsdown", "no", "dislike", "hand"] }, + { "category": "people", "char": "👊", "name": "facepunch", "keywords": ["angry", "violence", "fist", "hit", "attack", "hand"] }, + { "category": "people", "char": "✊", "name": "fist", "keywords": ["fingers", "hand", "grasp"] }, + { "category": "people", "char": "🤛", "name": "fist_left", "keywords": ["hand", "fistbump"] }, + { "category": "people", "char": "🤜", "name": "fist_right", "keywords": ["hand", "fistbump"] }, + { "category": "people", "char": "✌", "name": "v", "keywords": ["fingers", "ohyeah", "hand", "peace", "victory", "two"] }, + { "category": "people", "char": "👌", "name": "ok_hand", "keywords": ["fingers", "limbs", "perfect", "ok", "okay"] }, + { "category": "people", "char": "✋", "name": "raised_hand", "keywords": ["fingers", "stop", "highfive", "palm", "ban"] }, + { "category": "people", "char": "🤚", "name": "raised_back_of_hand", "keywords": ["fingers", "raised", "backhand"] }, + { "category": "people", "char": "👐", "name": "open_hands", "keywords": ["fingers", "butterfly", "hands", "open"] }, + { "category": "people", "char": "💪", "name": "muscle", "keywords": ["arm", "flex", "hand", "summer", "strong", "biceps"] }, + { "category": "people", "char": "🦾", "name": "mechanical_arm", "keywords": ["flex", "hand", "strong", "biceps"] }, + { "category": "people", "char": "🙏", "name": "pray", "keywords": ["please", "hope", "wish", "namaste", "highfive"] }, + { "category": "people", "char": "🦶", "name": "foot", "keywords": ["kick", "stomp"] }, + { "category": "people", "char": "🦵", "name": "leg", "keywords": ["kick", "limb"] }, + { "category": "people", "char": "🦿", "name": "mechanical_leg", "keywords": ["kick", "limb"] }, + { "category": "people", "char": "🤝", "name": "handshake", "keywords": ["agreement", "shake"] }, + { "category": "people", "char": "☝", "name": "point_up", "keywords": ["hand", "fingers", "direction", "up"] }, + { "category": "people", "char": "👆", "name": "point_up_2", "keywords": ["fingers", "hand", "direction", "up"] }, + { "category": "people", "char": "👇", "name": "point_down", "keywords": ["fingers", "hand", "direction", "down"] }, + { "category": "people", "char": "👈", "name": "point_left", "keywords": ["direction", "fingers", "hand", "left"] }, + { "category": "people", "char": "👉", "name": "point_right", "keywords": ["fingers", "hand", "direction", "right"] }, + { "category": "people", "char": "🖕", "name": "fu", "keywords": ["hand", "fingers", "rude", "middle", "flipping"] }, + { "category": "people", "char": "🖐", "name": "raised_hand_with_fingers_splayed", "keywords": ["hand", "fingers", "palm"] }, + { "category": "people", "char": "🤟", "name": "love_you", "keywords": ["hand", "fingers", "gesture"] }, + { "category": "people", "char": "🤘", "name": "metal", "keywords": ["hand", "fingers", "evil_eye", "sign_of_horns", "rock_on"] }, + { "category": "people", "char": "🤞", "name": "crossed_fingers", "keywords": ["good", "lucky"] }, + { "category": "people", "char": "🖖", "name": "vulcan_salute", "keywords": ["hand", "fingers", "spock", "star trek"] }, + { "category": "people", "char": "✍", "name": "writing_hand", "keywords": ["lower_left_ballpoint_pen", "stationery", "write", "compose"] }, + { "category": "people", "char": "\uD83E\uDEF0", "name": "hand_with_index_finger_and_thumb_crossed", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF1", "name": "rightwards_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF2", "name": "leftwards_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF3", "name": "palm_down_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF4", "name": "palm_up_hand", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF5", "name": "index_pointing_at_the_viewer", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEF6", "name": "heart_hands", "keywords": ["moemoekyun"] }, + { "category": "people", "char": "🤏", "name": "pinching_hand", "keywords": ["hand", "fingers"] }, + { "category": "people", "char": "🤌", "name": "pinched_fingers", "keywords": ["hand", "fingers"] }, + { "category": "people", "char": "🤳", "name": "selfie", "keywords": ["camera", "phone"] }, + { "category": "people", "char": "💅", "name": "nail_care", "keywords": ["beauty", "manicure", "finger", "fashion", "nail"] }, + { "category": "people", "char": "👄", "name": "lips", "keywords": ["mouth", "kiss"] }, + { "category": "people", "char": "\uD83E\uDEE6", "name": "biting_lip", "keywords": [] }, + { "category": "people", "char": "🦷", "name": "tooth", "keywords": ["teeth", "dentist"] }, + { "category": "people", "char": "👅", "name": "tongue", "keywords": ["mouth", "playful"] }, + { "category": "people", "char": "👂", "name": "ear", "keywords": ["face", "hear", "sound", "listen"] }, + { "category": "people", "char": "🦻", "name": "ear_with_hearing_aid", "keywords": ["face", "hear", "sound", "listen"] }, + { "category": "people", "char": "👃", "name": "nose", "keywords": ["smell", "sniff"] }, + { "category": "people", "char": "👁", "name": "eye", "keywords": ["face", "look", "see", "watch", "stare"] }, + { "category": "people", "char": "👀", "name": "eyes", "keywords": ["look", "watch", "stalk", "peek", "see"] }, + { "category": "people", "char": "🧠", "name": "brain", "keywords": ["smart", "intelligent"] }, + { "category": "people", "char": "🫀", "name": "anatomical_heart", "keywords": [] }, + { "category": "people", "char": "🫁", "name": "lungs", "keywords": [] }, + { "category": "people", "char": "👤", "name": "bust_in_silhouette", "keywords": ["user", "person", "human"] }, + { "category": "people", "char": "👥", "name": "busts_in_silhouette", "keywords": ["user", "person", "human", "group", "team"] }, + { "category": "people", "char": "🗣", "name": "speaking_head", "keywords": ["user", "person", "human", "sing", "say", "talk"] }, + { "category": "people", "char": "👶", "name": "baby", "keywords": ["child", "boy", "girl", "toddler"] }, + { "category": "people", "char": "🧒", "name": "child", "keywords": ["gender-neutral", "young"] }, + { "category": "people", "char": "👦", "name": "boy", "keywords": ["man", "male", "guy", "teenager"] }, + { "category": "people", "char": "👧", "name": "girl", "keywords": ["female", "woman", "teenager"] }, + { "category": "people", "char": "🧑", "name": "adult", "keywords": ["gender-neutral", "person"] }, + { "category": "people", "char": "👨", "name": "man", "keywords": ["mustache", "father", "dad", "guy", "classy", "sir", "moustache"] }, + { "category": "people", "char": "👩", "name": "woman", "keywords": ["female", "girls", "lady"] }, + { "category": "people", "char": "🧑‍🦱", "name": "curly_hair", "keywords": ["curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "👩‍🦱", "name": "curly_hair_woman", "keywords": ["woman", "female", "girl", "curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "👨‍🦱", "name": "curly_hair_man", "keywords": ["man", "male", "boy", "guy", "curly", "afro", "braids", "ringlets"] }, + { "category": "people", "char": "🧑‍🦰", "name": "red_hair", "keywords": ["redhead"] }, + { "category": "people", "char": "👩‍🦰", "name": "red_hair_woman", "keywords": ["woman", "female", "girl", "ginger", "redhead"] }, + { "category": "people", "char": "👨‍🦰", "name": "red_hair_man", "keywords": ["man", "male", "boy", "guy", "ginger", "redhead"] }, + { "category": "people", "char": "👱‍♀️", "name": "blonde_woman", "keywords": ["woman", "female", "girl", "blonde", "person"] }, + { "category": "people", "char": "👱", "name": "blonde_man", "keywords": ["man", "male", "boy", "blonde", "guy", "person"] }, + { "category": "people", "char": "🧑‍🦳", "name": "white_hair", "keywords": ["gray", "old", "white"] }, + { "category": "people", "char": "👩‍🦳", "name": "white_hair_woman", "keywords": ["woman", "female", "girl", "gray", "old", "white"] }, + { "category": "people", "char": "👨‍🦳", "name": "white_hair_man", "keywords": ["man", "male", "boy", "guy", "gray", "old", "white"] }, + { "category": "people", "char": "🧑‍🦲", "name": "bald", "keywords": ["bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "👩‍🦲", "name": "bald_woman", "keywords": ["woman", "female", "girl", "bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "👨‍🦲", "name": "bald_man", "keywords": ["man", "male", "boy", "guy", "bald", "chemotherapy", "hairless", "shaven"] }, + { "category": "people", "char": "🧔", "name": "bearded_person", "keywords": ["person", "bewhiskered"] }, + { "category": "people", "char": "🧓", "name": "older_adult", "keywords": ["human", "elder", "senior", "gender-neutral"] }, + { "category": "people", "char": "👴", "name": "older_man", "keywords": ["human", "male", "men", "old", "elder", "senior"] }, + { "category": "people", "char": "👵", "name": "older_woman", "keywords": ["human", "female", "women", "lady", "old", "elder", "senior"] }, + { "category": "people", "char": "👲", "name": "man_with_gua_pi_mao", "keywords": ["male", "boy", "chinese"] }, + { "category": "people", "char": "🧕", "name": "woman_with_headscarf", "keywords": ["female", "hijab", "mantilla", "tichel"] }, + { "category": "people", "char": "👳‍♀️", "name": "woman_with_turban", "keywords": ["female", "indian", "hinduism", "arabs", "woman"] }, + { "category": "people", "char": "👳", "name": "man_with_turban", "keywords": ["male", "indian", "hinduism", "arabs"] }, + { "category": "people", "char": "👮‍♀️", "name": "policewoman", "keywords": ["woman", "police", "law", "legal", "enforcement", "arrest", "911", "female"] }, + { "category": "people", "char": "👮", "name": "policeman", "keywords": ["man", "police", "law", "legal", "enforcement", "arrest", "911"] }, + { "category": "people", "char": "👷‍♀️", "name": "construction_worker_woman", "keywords": ["female", "human", "wip", "build", "construction", "worker", "labor", "woman"] }, + { "category": "people", "char": "👷", "name": "construction_worker_man", "keywords": ["male", "human", "wip", "guy", "build", "construction", "worker", "labor"] }, + { "category": "people", "char": "💂‍♀️", "name": "guardswoman", "keywords": ["uk", "gb", "british", "female", "royal", "woman"] }, + { "category": "people", "char": "💂", "name": "guardsman", "keywords": ["uk", "gb", "british", "male", "guy", "royal"] }, + { "category": "people", "char": "🕵️‍♀️", "name": "female_detective", "keywords": ["human", "spy", "detective", "female", "woman"] }, + { "category": "people", "char": "🕵", "name": "male_detective", "keywords": ["human", "spy", "detective"] }, + { "category": "people", "char": "🧑‍⚕️", "name": "health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "human"] }, + { "category": "people", "char": "👩‍⚕️", "name": "woman_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "woman", "human"] }, + { "category": "people", "char": "👨‍⚕️", "name": "man_health_worker", "keywords": ["doctor", "nurse", "therapist", "healthcare", "man", "human"] }, + { "category": "people", "char": "🧑‍🌾", "name": "farmer", "keywords": ["rancher", "gardener", "human"] }, + { "category": "people", "char": "👩‍🌾", "name": "woman_farmer", "keywords": ["rancher", "gardener", "woman", "human"] }, + { "category": "people", "char": "👨‍🌾", "name": "man_farmer", "keywords": ["rancher", "gardener", "man", "human"] }, + { "category": "people", "char": "🧑‍🍳", "name": "cook", "keywords": ["chef", "human"] }, + { "category": "people", "char": "👩‍🍳", "name": "woman_cook", "keywords": ["chef", "woman", "human"] }, + { "category": "people", "char": "👨‍🍳", "name": "man_cook", "keywords": ["chef", "man", "human"] }, + { "category": "people", "char": "🧑‍🎓", "name": "student", "keywords": ["graduate", "human"] }, + { "category": "people", "char": "👩‍🎓", "name": "woman_student", "keywords": ["graduate", "woman", "human"] }, + { "category": "people", "char": "👨‍🎓", "name": "man_student", "keywords": ["graduate", "man", "human"] }, + { "category": "people", "char": "🧑‍🎤", "name": "singer", "keywords": ["rockstar", "entertainer", "human"] }, + { "category": "people", "char": "👩‍🎤", "name": "woman_singer", "keywords": ["rockstar", "entertainer", "woman", "human"] }, + { "category": "people", "char": "👨‍🎤", "name": "man_singer", "keywords": ["rockstar", "entertainer", "man", "human"] }, + { "category": "people", "char": "🧑‍🏫", "name": "teacher", "keywords": ["instructor", "professor", "human"] }, + { "category": "people", "char": "👩‍🏫", "name": "woman_teacher", "keywords": ["instructor", "professor", "woman", "human"] }, + { "category": "people", "char": "👨‍🏫", "name": "man_teacher", "keywords": ["instructor", "professor", "man", "human"] }, + { "category": "people", "char": "🧑‍🏭", "name": "factory_worker", "keywords": ["assembly", "industrial", "human"] }, + { "category": "people", "char": "👩‍🏭", "name": "woman_factory_worker", "keywords": ["assembly", "industrial", "woman", "human"] }, + { "category": "people", "char": "👨‍🏭", "name": "man_factory_worker", "keywords": ["assembly", "industrial", "man", "human"] }, + { "category": "people", "char": "🧑‍💻", "name": "technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "human", "laptop", "computer"] }, + { "category": "people", "char": "👩‍💻", "name": "woman_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "woman", "human", "laptop", "computer"] }, + { "category": "people", "char": "👨‍💻", "name": "man_technologist", "keywords": ["coder", "developer", "engineer", "programmer", "software", "man", "human", "laptop", "computer"] }, + { "category": "people", "char": "🧑‍💼", "name": "office_worker", "keywords": ["business", "manager", "human"] }, + { "category": "people", "char": "👩‍💼", "name": "woman_office_worker", "keywords": ["business", "manager", "woman", "human"] }, + { "category": "people", "char": "👨‍💼", "name": "man_office_worker", "keywords": ["business", "manager", "man", "human"] }, + { "category": "people", "char": "🧑‍🔧", "name": "mechanic", "keywords": ["plumber", "human", "wrench"] }, + { "category": "people", "char": "👩‍🔧", "name": "woman_mechanic", "keywords": ["plumber", "woman", "human", "wrench"] }, + { "category": "people", "char": "👨‍🔧", "name": "man_mechanic", "keywords": ["plumber", "man", "human", "wrench"] }, + { "category": "people", "char": "🧑‍🔬", "name": "scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "human"] }, + { "category": "people", "char": "👩‍🔬", "name": "woman_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "woman", "human"] }, + { "category": "people", "char": "👨‍🔬", "name": "man_scientist", "keywords": ["biologist", "chemist", "engineer", "physicist", "man", "human"] }, + { "category": "people", "char": "🧑‍🎨", "name": "artist", "keywords": ["painter", "human"] }, + { "category": "people", "char": "👩‍🎨", "name": "woman_artist", "keywords": ["painter", "woman", "human"] }, + { "category": "people", "char": "👨‍🎨", "name": "man_artist", "keywords": ["painter", "man", "human"] }, + { "category": "people", "char": "🧑‍🚒", "name": "firefighter", "keywords": ["fireman", "human"] }, + { "category": "people", "char": "👩‍🚒", "name": "woman_firefighter", "keywords": ["fireman", "woman", "human"] }, + { "category": "people", "char": "👨‍🚒", "name": "man_firefighter", "keywords": ["fireman", "man", "human"] }, + { "category": "people", "char": "🧑‍✈️", "name": "pilot", "keywords": ["aviator", "plane", "human"] }, + { "category": "people", "char": "👩‍✈️", "name": "woman_pilot", "keywords": ["aviator", "plane", "woman", "human"] }, + { "category": "people", "char": "👨‍✈️", "name": "man_pilot", "keywords": ["aviator", "plane", "man", "human"] }, + { "category": "people", "char": "🧑‍🚀", "name": "astronaut", "keywords": ["space", "rocket", "human"] }, + { "category": "people", "char": "👩‍🚀", "name": "woman_astronaut", "keywords": ["space", "rocket", "woman", "human"] }, + { "category": "people", "char": "👨‍🚀", "name": "man_astronaut", "keywords": ["space", "rocket", "man", "human"] }, + { "category": "people", "char": "🧑‍⚖️", "name": "judge", "keywords": ["justice", "court", "human"] }, + { "category": "people", "char": "👩‍⚖️", "name": "woman_judge", "keywords": ["justice", "court", "woman", "human"] }, + { "category": "people", "char": "👨‍⚖️", "name": "man_judge", "keywords": ["justice", "court", "man", "human"] }, + { "category": "people", "char": "🦸‍♀️", "name": "woman_superhero", "keywords": ["woman", "female", "good", "heroine", "superpowers"] }, + { "category": "people", "char": "🦸‍♂️", "name": "man_superhero", "keywords": ["man", "male", "good", "hero", "superpowers"] }, + { "category": "people", "char": "🦹‍♀️", "name": "woman_supervillain", "keywords": ["woman", "female", "evil", "bad", "criminal", "heroine", "superpowers"] }, + { "category": "people", "char": "🦹‍♂️", "name": "man_supervillain", "keywords": ["man", "male", "evil", "bad", "criminal", "hero", "superpowers"] }, + { "category": "people", "char": "🤶", "name": "mrs_claus", "keywords": ["woman", "female", "xmas", "mother christmas"] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF84", "name": "mx_claus", "keywords": ["xmas", "christmas"] }, + { "category": "people", "char": "🎅", "name": "santa", "keywords": ["festival", "man", "male", "xmas", "father christmas"] }, + { "category": "people", "char": "🥷", "name": "ninja", "keywords": [] }, + { "category": "people", "char": "🧙‍♀️", "name": "sorceress", "keywords": ["woman", "female", "mage", "witch"] }, + { "category": "people", "char": "🧙‍♂️", "name": "wizard", "keywords": ["man", "male", "mage", "sorcerer"] }, + { "category": "people", "char": "🧝‍♀️", "name": "woman_elf", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧝‍♂️", "name": "man_elf", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧛‍♀️", "name": "woman_vampire", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧛‍♂️", "name": "man_vampire", "keywords": ["man", "male", "dracula"] }, + { "category": "people", "char": "🧟‍♀️", "name": "woman_zombie", "keywords": ["woman", "female", "undead", "walking dead"] }, + { "category": "people", "char": "🧟‍♂️", "name": "man_zombie", "keywords": ["man", "male", "dracula", "undead", "walking dead"] }, + { "category": "people", "char": "🧞‍♀️", "name": "woman_genie", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧞‍♂️", "name": "man_genie", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧜‍♀️", "name": "mermaid", "keywords": ["woman", "female", "merwoman", "ariel"] }, + { "category": "people", "char": "🧜‍♂️", "name": "merman", "keywords": ["man", "male", "triton"] }, + { "category": "people", "char": "🧚‍♀️", "name": "woman_fairy", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧚‍♂️", "name": "man_fairy", "keywords": ["man", "male"] }, + { "category": "people", "char": "👼", "name": "angel", "keywords": ["heaven", "wings", "halo"] }, + { "category": "people", "char": "\uD83E\uDDCC", "name": "troll", "keywords": [] }, + { "category": "people", "char": "🤰", "name": "pregnant_woman", "keywords": ["baby"] }, + { "category": "people", "char": "\uD83E\uDEC3", "name": "pregnant_man", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEC4", "name": "pregnant_person", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDEC5", "name": "person_with_crown", "keywords": [] }, + { "category": "people", "char": "🤱", "name": "breastfeeding", "keywords": ["nursing", "baby"] }, + { "category": "people", "char": "\uD83D\uDC69\u200D\uD83C\uDF7C", "name": "woman_feeding_baby", "keywords": [] }, + { "category": "people", "char": "\uD83D\uDC68\u200D\uD83C\uDF7C", "name": "man_feeding_baby", "keywords": [] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83C\uDF7C", "name": "person_feeding_baby", "keywords": [] }, + { "category": "people", "char": "👸", "name": "princess", "keywords": ["girl", "woman", "female", "blond", "crown", "royal", "queen"] }, + { "category": "people", "char": "🤴", "name": "prince", "keywords": ["boy", "man", "male", "crown", "royal", "king"] }, + { "category": "people", "char": "👰", "name": "person_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, + { "category": "people", "char": "👰", "name": "bride_with_veil", "keywords": ["couple", "marriage", "wedding", "woman", "bride"] }, + { "category": "people", "char": "🤵", "name": "person_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, + { "category": "people", "char": "🤵", "name": "man_in_tuxedo", "keywords": ["couple", "marriage", "wedding", "groom"] }, + { "category": "people", "char": "🏃‍♀️", "name": "running_woman", "keywords": ["woman", "walking", "exercise", "race", "running", "female"] }, + { "category": "people", "char": "🏃", "name": "running_man", "keywords": ["man", "walking", "exercise", "race", "running"] }, + { "category": "people", "char": "🚶‍♀️", "name": "walking_woman", "keywords": ["human", "feet", "steps", "woman", "female"] }, + { "category": "people", "char": "🚶", "name": "walking_man", "keywords": ["human", "feet", "steps"] }, + { "category": "people", "char": "💃", "name": "dancer", "keywords": ["female", "girl", "woman", "fun"] }, + { "category": "people", "char": "🕺", "name": "man_dancing", "keywords": ["male", "boy", "fun", "dancer"] }, + { "category": "people", "char": "👯", "name": "dancing_women", "keywords": ["female", "bunny", "women", "girls"] }, + { "category": "people", "char": "👯‍♂️", "name": "dancing_men", "keywords": ["male", "bunny", "men", "boys"] }, + { "category": "people", "char": "👫", "name": "couple", "keywords": ["pair", "people", "human", "love", "date", "dating", "like", "affection", "valentines", "marriage"] }, + { "category": "people", "char": "\uD83E\uDDD1\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1", "name": "people_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "human"] }, + { "category": "people", "char": "👬", "name": "two_men_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "man", "human"] }, + { "category": "people", "char": "👭", "name": "two_women_holding_hands", "keywords": ["pair", "couple", "love", "like", "bromance", "friendship", "people", "female", "human"] }, + { "category": "people", "char": "🫂", "name": "people_hugging", "keywords": [] }, + { "category": "people", "char": "🙇‍♀️", "name": "bowing_woman", "keywords": ["woman", "female", "girl"] }, + { "category": "people", "char": "🙇", "name": "bowing_man", "keywords": ["man", "male", "boy"] }, + { "category": "people", "char": "🤦‍♂️", "name": "man_facepalming", "keywords": ["man", "male", "boy", "disbelief"] }, + { "category": "people", "char": "🤦‍♀️", "name": "woman_facepalming", "keywords": ["woman", "female", "girl", "disbelief"] }, + { "category": "people", "char": "🤷", "name": "woman_shrugging", "keywords": ["woman", "female", "girl", "confused", "indifferent", "doubt"] }, + { "category": "people", "char": "🤷‍♂️", "name": "man_shrugging", "keywords": ["man", "male", "boy", "confused", "indifferent", "doubt"] }, + { "category": "people", "char": "💁", "name": "tipping_hand_woman", "keywords": ["female", "girl", "woman", "human", "information"] }, + { "category": "people", "char": "💁‍♂️", "name": "tipping_hand_man", "keywords": ["male", "boy", "man", "human", "information"] }, + { "category": "people", "char": "🙅", "name": "no_good_woman", "keywords": ["female", "girl", "woman", "nope"] }, + { "category": "people", "char": "🙅‍♂️", "name": "no_good_man", "keywords": ["male", "boy", "man", "nope"] }, + { "category": "people", "char": "🙆", "name": "ok_woman", "keywords": ["women", "girl", "female", "pink", "human", "woman"] }, + { "category": "people", "char": "🙆‍♂️", "name": "ok_man", "keywords": ["men", "boy", "male", "blue", "human", "man"] }, + { "category": "people", "char": "🙋", "name": "raising_hand_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "🙋‍♂️", "name": "raising_hand_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "🙎", "name": "pouting_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "🙎‍♂️", "name": "pouting_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "🙍", "name": "frowning_woman", "keywords": ["female", "girl", "woman", "sad", "depressed", "discouraged", "unhappy"] }, + { "category": "people", "char": "🙍‍♂️", "name": "frowning_man", "keywords": ["male", "boy", "man", "sad", "depressed", "discouraged", "unhappy"] }, + { "category": "people", "char": "💇", "name": "haircut_woman", "keywords": ["female", "girl", "woman"] }, + { "category": "people", "char": "💇‍♂️", "name": "haircut_man", "keywords": ["male", "boy", "man"] }, + { "category": "people", "char": "💆", "name": "massage_woman", "keywords": ["female", "girl", "woman", "head"] }, + { "category": "people", "char": "💆‍♂️", "name": "massage_man", "keywords": ["male", "boy", "man", "head"] }, + { "category": "people", "char": "🧖‍♀️", "name": "woman_in_steamy_room", "keywords": ["female", "woman", "spa", "steamroom", "sauna"] }, + { "category": "people", "char": "🧖‍♂️", "name": "man_in_steamy_room", "keywords": ["male", "man", "spa", "steamroom", "sauna"] }, + { "category": "people", "char": "🧏‍♀️", "name": "woman_deaf", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧏‍♂️", "name": "man_deaf", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧍‍♀️", "name": "woman_standing", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧍‍♂️", "name": "man_standing", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧎‍♀️", "name": "woman_kneeling", "keywords": ["woman", "female"] }, + { "category": "people", "char": "🧎‍♂️", "name": "man_kneeling", "keywords": ["man", "male"] }, + { "category": "people", "char": "🧑‍🦯", "name": "person_with_probing_cane", "keywords": ["accessibility", "blind"] }, + { "category": "people", "char": "👩‍🦯", "name": "woman_with_probing_cane", "keywords": ["woman", "female", "accessibility", "blind"] }, + { "category": "people", "char": "👨‍🦯", "name": "man_with_probing_cane", "keywords": ["man", "male", "accessibility", "blind"] }, + { "category": "people", "char": "🧑‍🦼", "name": "person_in_motorized_wheelchair", "keywords": ["accessibility"] }, + { "category": "people", "char": "👩‍🦼", "name": "woman_in_motorized_wheelchair", "keywords": ["woman", "female", "accessibility"] }, + { "category": "people", "char": "👨‍🦼", "name": "man_in_motorized_wheelchair", "keywords": ["man", "male", "accessibility"] }, + { "category": "people", "char": "🧑‍🦽", "name": "person_in_manual_wheelchair", "keywords": ["accessibility"] }, + { "category": "people", "char": "👩‍🦽", "name": "woman_in_manual_wheelchair", "keywords": ["woman", "female", "accessibility"] }, + { "category": "people", "char": "👨‍🦽", "name": "man_in_manual_wheelchair", "keywords": ["man", "male", "accessibility"] }, + { "category": "people", "char": "💑", "name": "couple_with_heart_woman_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "👩‍❤️‍👩", "name": "couple_with_heart_woman_woman", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "👨‍❤️‍👨", "name": "couple_with_heart_man_man", "keywords": ["pair", "love", "like", "affection", "human", "dating", "valentines", "marriage"] }, + { "category": "people", "char": "💏", "name": "couplekiss_man_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👩‍❤️‍💋‍👩", "name": "couplekiss_woman_woman", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👨‍❤️‍💋‍👨", "name": "couplekiss_man_man", "keywords": ["pair", "valentines", "love", "like", "dating", "marriage"] }, + { "category": "people", "char": "👪", "name": "family_man_woman_boy", "keywords": ["home", "parents", "child", "mom", "dad", "father", "mother", "people", "human"] }, + { "category": "people", "char": "👨‍👩‍👧", "name": "family_man_woman_girl", "keywords": ["home", "parents", "people", "human", "child"] }, + { "category": "people", "char": "👨‍👩‍👧‍👦", "name": "family_man_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👩‍👦‍👦", "name": "family_man_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👩‍👧‍👧", "name": "family_man_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👩‍👦", "name": "family_woman_woman_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👩‍👧", "name": "family_woman_woman_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👩‍👧‍👦", "name": "family_woman_woman_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👩‍👦‍👦", "name": "family_woman_woman_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👩‍👧‍👧", "name": "family_woman_woman_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👨‍👦", "name": "family_man_man_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👨‍👧", "name": "family_man_man_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👨‍👧‍👦", "name": "family_man_man_girl_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👨‍👦‍👦", "name": "family_man_man_boy_boy", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👨‍👧‍👧", "name": "family_man_man_girl_girl", "keywords": ["home", "parents", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👦", "name": "family_woman_boy", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👩‍👧", "name": "family_woman_girl", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👩‍👧‍👦", "name": "family_woman_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👦‍👦", "name": "family_woman_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👩‍👧‍👧", "name": "family_woman_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👦", "name": "family_man_boy", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👨‍👧", "name": "family_man_girl", "keywords": ["home", "parent", "people", "human", "child"] }, + { "category": "people", "char": "👨‍👧‍👦", "name": "family_man_girl_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👦‍👦", "name": "family_man_boy_boy", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "👨‍👧‍👧", "name": "family_man_girl_girl", "keywords": ["home", "parent", "people", "human", "children"] }, + { "category": "people", "char": "🧶", "name": "yarn", "keywords": ["ball", "crochet", "knit"] }, + { "category": "people", "char": "🧵", "name": "thread", "keywords": ["needle", "sewing", "spool", "string"] }, + { "category": "people", "char": "🧥", "name": "coat", "keywords": ["jacket"] }, + { "category": "people", "char": "🥼", "name": "labcoat", "keywords": ["doctor", "experiment", "scientist", "chemist"] }, + { "category": "people", "char": "👚", "name": "womans_clothes", "keywords": ["fashion", "shopping_bags", "female"] }, + { "category": "people", "char": "👕", "name": "tshirt", "keywords": ["fashion", "cloth", "casual", "shirt", "tee"] }, + { "category": "people", "char": "👖", "name": "jeans", "keywords": ["fashion", "shopping"] }, + { "category": "people", "char": "👔", "name": "necktie", "keywords": ["shirt", "suitup", "formal", "fashion", "cloth", "business"] }, + { "category": "people", "char": "👗", "name": "dress", "keywords": ["clothes", "fashion", "shopping"] }, + { "category": "people", "char": "👙", "name": "bikini", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, + { "category": "people", "char": "🩱", "name": "one_piece_swimsuit", "keywords": ["swimming", "female", "woman", "girl", "fashion", "beach", "summer"] }, + { "category": "people", "char": "👘", "name": "kimono", "keywords": ["dress", "fashion", "women", "female", "japanese"] }, + { "category": "people", "char": "🥻", "name": "sari", "keywords": ["dress", "fashion", "women", "female"] }, + { "category": "people", "char": "🩲", "name": "briefs", "keywords": ["dress", "fashion"] }, + { "category": "people", "char": "🩳", "name": "shorts", "keywords": ["dress", "fashion"] }, + { "category": "people", "char": "💄", "name": "lipstick", "keywords": ["female", "girl", "fashion", "woman"] }, + { "category": "people", "char": "💋", "name": "kiss", "keywords": ["face", "lips", "love", "like", "affection", "valentines"] }, + { "category": "people", "char": "👣", "name": "footprints", "keywords": ["feet", "tracking", "walking", "beach"] }, + { "category": "people", "char": "🥿", "name": "flat_shoe", "keywords": ["ballet", "slip-on", "slipper"] }, + { "category": "people", "char": "👠", "name": "high_heel", "keywords": ["fashion", "shoes", "female", "pumps", "stiletto"] }, + { "category": "people", "char": "👡", "name": "sandal", "keywords": ["shoes", "fashion", "flip flops"] }, + { "category": "people", "char": "👢", "name": "boot", "keywords": ["shoes", "fashion"] }, + { "category": "people", "char": "👞", "name": "mans_shoe", "keywords": ["fashion", "male"] }, + { "category": "people", "char": "👟", "name": "athletic_shoe", "keywords": ["shoes", "sports", "sneakers"] }, + { "category": "people", "char": "🩴", "name": "thong_sandal", "keywords": [] }, + { "category": "people", "char": "🩰", "name": "ballet_shoes", "keywords": ["shoes", "sports"] }, + { "category": "people", "char": "🧦", "name": "socks", "keywords": ["stockings", "clothes"] }, + { "category": "people", "char": "🧤", "name": "gloves", "keywords": ["hands", "winter", "clothes"] }, + { "category": "people", "char": "🧣", "name": "scarf", "keywords": ["neck", "winter", "clothes"] }, + { "category": "people", "char": "👒", "name": "womans_hat", "keywords": ["fashion", "accessories", "female", "lady", "spring"] }, + { "category": "people", "char": "🎩", "name": "tophat", "keywords": ["magic", "gentleman", "classy", "circus"] }, + { "category": "people", "char": "🧢", "name": "billed_hat", "keywords": ["cap", "baseball"] }, + { "category": "people", "char": "⛑", "name": "rescue_worker_helmet", "keywords": ["construction", "build"] }, + { "category": "people", "char": "🪖", "name": "military_helmet", "keywords": [] }, + { "category": "people", "char": "🎓", "name": "mortar_board", "keywords": ["school", "college", "degree", "university", "graduation", "cap", "hat", "legal", "learn", "education"] }, + { "category": "people", "char": "👑", "name": "crown", "keywords": ["king", "kod", "leader", "royalty", "lord"] }, + { "category": "people", "char": "🎒", "name": "school_satchel", "keywords": ["student", "education", "bag", "backpack"] }, + { "category": "people", "char": "🧳", "name": "luggage", "keywords": ["packing", "travel"] }, + { "category": "people", "char": "👝", "name": "pouch", "keywords": ["bag", "accessories", "shopping"] }, + { "category": "people", "char": "👛", "name": "purse", "keywords": ["fashion", "accessories", "money", "sales", "shopping"] }, + { "category": "people", "char": "👜", "name": "handbag", "keywords": ["fashion", "accessory", "accessories", "shopping"] }, + { "category": "people", "char": "💼", "name": "briefcase", "keywords": ["business", "documents", "work", "law", "legal", "job", "career"] }, + { "category": "people", "char": "👓", "name": "eyeglasses", "keywords": ["fashion", "accessories", "eyesight", "nerdy", "dork", "geek"] }, + { "category": "people", "char": "🕶", "name": "dark_sunglasses", "keywords": ["face", "cool", "accessories"] }, + { "category": "people", "char": "🥽", "name": "goggles", "keywords": ["eyes", "protection", "safety"] }, + { "category": "people", "char": "💍", "name": "ring", "keywords": ["wedding", "propose", "marriage", "valentines", "diamond", "fashion", "jewelry", "gem", "engagement"] }, + { "category": "people", "char": "🌂", "name": "closed_umbrella", "keywords": ["weather", "rain", "drizzle"] }, + { "category": "animals_and_nature", "char": "🐶", "name": "dog", "keywords": ["animal", "friend", "nature", "woof", "puppy", "pet", "faithful"] }, + { "category": "animals_and_nature", "char": "🐱", "name": "cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, + { "category": "animals_and_nature", "char": "🐈‍⬛", "name": "black_cat", "keywords": ["animal", "meow", "nature", "pet", "kitten"] }, + { "category": "animals_and_nature", "char": "🐭", "name": "mouse", "keywords": ["animal", "nature", "cheese_wedge", "rodent"] }, + { "category": "animals_and_nature", "char": "🐹", "name": "hamster", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐰", "name": "rabbit", "keywords": ["animal", "nature", "pet", "spring", "magic", "bunny"] }, + { "category": "animals_and_nature", "char": "🦊", "name": "fox_face", "keywords": ["animal", "nature", "face"] }, + { "category": "animals_and_nature", "char": "🐻", "name": "bear", "keywords": ["animal", "nature", "wild"] }, + { "category": "animals_and_nature", "char": "🐼", "name": "panda_face", "keywords": ["animal", "nature", "panda"] }, + { "category": "animals_and_nature", "char": "🐨", "name": "koala", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐯", "name": "tiger", "keywords": ["animal", "cat", "danger", "wild", "nature", "roar"] }, + { "category": "animals_and_nature", "char": "🦁", "name": "lion", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐮", "name": "cow", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, + { "category": "animals_and_nature", "char": "🐷", "name": "pig", "keywords": ["animal", "oink", "nature"] }, + { "category": "animals_and_nature", "char": "🐽", "name": "pig_nose", "keywords": ["animal", "oink"] }, + { "category": "animals_and_nature", "char": "🐸", "name": "frog", "keywords": ["animal", "nature", "croak", "toad"] }, + { "category": "animals_and_nature", "char": "🦑", "name": "squid", "keywords": ["animal", "nature", "ocean", "sea"] }, + { "category": "animals_and_nature", "char": "🐙", "name": "octopus", "keywords": ["animal", "creature", "ocean", "sea", "nature", "beach"] }, + { "category": "animals_and_nature", "char": "🦐", "name": "shrimp", "keywords": ["animal", "ocean", "nature", "seafood"] }, + { "category": "animals_and_nature", "char": "🐵", "name": "monkey_face", "keywords": ["animal", "nature", "circus"] }, + { "category": "animals_and_nature", "char": "🦍", "name": "gorilla", "keywords": ["animal", "nature", "circus"] }, + { "category": "animals_and_nature", "char": "🙈", "name": "see_no_evil", "keywords": ["monkey", "animal", "nature", "haha"] }, + { "category": "animals_and_nature", "char": "🙉", "name": "hear_no_evil", "keywords": ["animal", "monkey", "nature"] }, + { "category": "animals_and_nature", "char": "🙊", "name": "speak_no_evil", "keywords": ["monkey", "animal", "nature", "omg"] }, + { "category": "animals_and_nature", "char": "🐒", "name": "monkey", "keywords": ["animal", "nature", "banana", "circus"] }, + { "category": "animals_and_nature", "char": "🐔", "name": "chicken", "keywords": ["animal", "cluck", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🐧", "name": "penguin", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐦", "name": "bird", "keywords": ["animal", "nature", "fly", "tweet", "spring"] }, + { "category": "animals_and_nature", "char": "🐤", "name": "baby_chick", "keywords": ["animal", "chicken", "bird"] }, + { "category": "animals_and_nature", "char": "🐣", "name": "hatching_chick", "keywords": ["animal", "chicken", "egg", "born", "baby", "bird"] }, + { "category": "animals_and_nature", "char": "🐥", "name": "hatched_chick", "keywords": ["animal", "chicken", "baby", "bird"] }, + { "category": "animals_and_nature", "char": "🦆", "name": "duck", "keywords": ["animal", "nature", "bird", "mallard"] }, + { "category": "animals_and_nature", "char": "🦅", "name": "eagle", "keywords": ["animal", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🦉", "name": "owl", "keywords": ["animal", "nature", "bird", "hoot"] }, + { "category": "animals_and_nature", "char": "🦇", "name": "bat", "keywords": ["animal", "nature", "blind", "vampire"] }, + { "category": "animals_and_nature", "char": "🐺", "name": "wolf", "keywords": ["animal", "nature", "wild"] }, + { "category": "animals_and_nature", "char": "🐗", "name": "boar", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐴", "name": "horse", "keywords": ["animal", "brown", "nature"] }, + { "category": "animals_and_nature", "char": "🦄", "name": "unicorn", "keywords": ["animal", "nature", "mystical"] }, + { "category": "animals_and_nature", "char": "🐝", "name": "honeybee", "keywords": ["animal", "insect", "nature", "bug", "spring", "honey"] }, + { "category": "animals_and_nature", "char": "🐛", "name": "bug", "keywords": ["animal", "insect", "nature", "worm"] }, + { "category": "animals_and_nature", "char": "🦋", "name": "butterfly", "keywords": ["animal", "insect", "nature", "caterpillar"] }, + { "category": "animals_and_nature", "char": "🐌", "name": "snail", "keywords": ["slow", "animal", "shell"] }, + { "category": "animals_and_nature", "char": "🐞", "name": "lady_beetle", "keywords": ["animal", "insect", "nature", "ladybug"] }, + { "category": "animals_and_nature", "char": "🐜", "name": "ant", "keywords": ["animal", "insect", "nature", "bug"] }, + { "category": "animals_and_nature", "char": "🦗", "name": "grasshopper", "keywords": ["animal", "cricket", "chirp"] }, + { "category": "animals_and_nature", "char": "🕷", "name": "spider", "keywords": ["animal", "arachnid"] }, + { "category": "animals_and_nature", "char": "🪲", "name": "beetle", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪳", "name": "cockroach", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪰", "name": "fly", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🪱", "name": "worm", "keywords": ["animal"] }, + { "category": "animals_and_nature", "char": "🦂", "name": "scorpion", "keywords": ["animal", "arachnid"] }, + { "category": "animals_and_nature", "char": "🦀", "name": "crab", "keywords": ["animal", "crustacean"] }, + { "category": "animals_and_nature", "char": "🐍", "name": "snake", "keywords": ["animal", "evil", "nature", "hiss", "python"] }, + { "category": "animals_and_nature", "char": "🦎", "name": "lizard", "keywords": ["animal", "nature", "reptile"] }, + { "category": "animals_and_nature", "char": "🦖", "name": "t-rex", "keywords": ["animal", "nature", "dinosaur", "tyrannosaurus", "extinct"] }, + { "category": "animals_and_nature", "char": "🦕", "name": "sauropod", "keywords": ["animal", "nature", "dinosaur", "brachiosaurus", "brontosaurus", "diplodocus", "extinct"] }, + { "category": "animals_and_nature", "char": "🐢", "name": "turtle", "keywords": ["animal", "slow", "nature", "tortoise"] }, + { "category": "animals_and_nature", "char": "🐠", "name": "tropical_fish", "keywords": ["animal", "swim", "ocean", "beach", "nemo"] }, + { "category": "animals_and_nature", "char": "🐟", "name": "fish", "keywords": ["animal", "food", "nature"] }, + { "category": "animals_and_nature", "char": "🐡", "name": "blowfish", "keywords": ["animal", "nature", "food", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐬", "name": "dolphin", "keywords": ["animal", "nature", "fish", "sea", "ocean", "flipper", "fins", "beach"] }, + { "category": "animals_and_nature", "char": "🦈", "name": "shark", "keywords": ["animal", "nature", "fish", "sea", "ocean", "jaws", "fins", "beach"] }, + { "category": "animals_and_nature", "char": "🐳", "name": "whale", "keywords": ["animal", "nature", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐋", "name": "whale2", "keywords": ["animal", "nature", "sea", "ocean"] }, + { "category": "animals_and_nature", "char": "🐊", "name": "crocodile", "keywords": ["animal", "nature", "reptile", "lizard", "alligator"] }, + { "category": "animals_and_nature", "char": "🐆", "name": "leopard", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦓", "name": "zebra", "keywords": ["animal", "nature", "stripes", "safari"] }, + { "category": "animals_and_nature", "char": "🐅", "name": "tiger2", "keywords": ["animal", "nature", "roar"] }, + { "category": "animals_and_nature", "char": "🐃", "name": "water_buffalo", "keywords": ["animal", "nature", "ox", "cow"] }, + { "category": "animals_and_nature", "char": "🐂", "name": "ox", "keywords": ["animal", "cow", "beef"] }, + { "category": "animals_and_nature", "char": "🐄", "name": "cow2", "keywords": ["beef", "ox", "animal", "nature", "moo", "milk"] }, + { "category": "animals_and_nature", "char": "🦌", "name": "deer", "keywords": ["animal", "nature", "horns", "venison"] }, + { "category": "animals_and_nature", "char": "🐪", "name": "dromedary_camel", "keywords": ["animal", "hot", "desert", "hump"] }, + { "category": "animals_and_nature", "char": "🐫", "name": "camel", "keywords": ["animal", "nature", "hot", "desert", "hump"] }, + { "category": "animals_and_nature", "char": "🦒", "name": "giraffe", "keywords": ["animal", "nature", "spots", "safari"] }, + { "category": "animals_and_nature", "char": "🐘", "name": "elephant", "keywords": ["animal", "nature", "nose", "th", "circus"] }, + { "category": "animals_and_nature", "char": "🦏", "name": "rhinoceros", "keywords": ["animal", "nature", "horn"] }, + { "category": "animals_and_nature", "char": "🐐", "name": "goat", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐏", "name": "ram", "keywords": ["animal", "sheep", "nature"] }, + { "category": "animals_and_nature", "char": "🐑", "name": "sheep", "keywords": ["animal", "nature", "wool", "shipit"] }, + { "category": "animals_and_nature", "char": "🐎", "name": "racehorse", "keywords": ["animal", "gamble", "luck"] }, + { "category": "animals_and_nature", "char": "🐖", "name": "pig2", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐀", "name": "rat", "keywords": ["animal", "mouse", "rodent"] }, + { "category": "animals_and_nature", "char": "🐁", "name": "mouse2", "keywords": ["animal", "nature", "rodent"] }, + { "category": "animals_and_nature", "char": "🐓", "name": "rooster", "keywords": ["animal", "nature", "chicken"] }, + { "category": "animals_and_nature", "char": "🦃", "name": "turkey", "keywords": ["animal", "bird"] }, + { "category": "animals_and_nature", "char": "🕊", "name": "dove", "keywords": ["animal", "bird"] }, + { "category": "animals_and_nature", "char": "🐕", "name": "dog2", "keywords": ["animal", "nature", "friend", "doge", "pet", "faithful"] }, + { "category": "animals_and_nature", "char": "🐩", "name": "poodle", "keywords": ["dog", "animal", "101", "nature", "pet"] }, + { "category": "animals_and_nature", "char": "🐈", "name": "cat2", "keywords": ["animal", "meow", "pet", "cats"] }, + { "category": "animals_and_nature", "char": "🐇", "name": "rabbit2", "keywords": ["animal", "nature", "pet", "magic", "spring"] }, + { "category": "animals_and_nature", "char": "🐿", "name": "chipmunk", "keywords": ["animal", "nature", "rodent", "squirrel"] }, + { "category": "animals_and_nature", "char": "🦔", "name": "hedgehog", "keywords": ["animal", "nature", "spiny"] }, + { "category": "animals_and_nature", "char": "🦝", "name": "raccoon", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦙", "name": "llama", "keywords": ["animal", "nature", "alpaca"] }, + { "category": "animals_and_nature", "char": "🦛", "name": "hippopotamus", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦘", "name": "kangaroo", "keywords": ["animal", "nature", "australia", "joey", "hop", "marsupial"] }, + { "category": "animals_and_nature", "char": "🦡", "name": "badger", "keywords": ["animal", "nature", "honey"] }, + { "category": "animals_and_nature", "char": "🦢", "name": "swan", "keywords": ["animal", "nature", "bird"] }, + { "category": "animals_and_nature", "char": "🦚", "name": "peacock", "keywords": ["animal", "nature", "peahen", "bird"] }, + { "category": "animals_and_nature", "char": "🦜", "name": "parrot", "keywords": ["animal", "nature", "bird", "pirate", "talk"] }, + { "category": "animals_and_nature", "char": "🦞", "name": "lobster", "keywords": ["animal", "nature", "bisque", "claws", "seafood"] }, + { "category": "animals_and_nature", "char": "🦠", "name": "microbe", "keywords": ["amoeba", "bacteria", "germs"] }, + { "category": "animals_and_nature", "char": "🦟", "name": "mosquito", "keywords": ["animal", "nature", "insect", "malaria"] }, + { "category": "animals_and_nature", "char": "🦬", "name": "bison", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦣", "name": "mammoth", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦫", "name": "beaver", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐻‍❄️", "name": "polar_bear", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] }, + { "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, + { "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] }, + { "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦮", "name": "guide_dog", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🐕‍🦺", "name": "service_dog", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦥", "name": "sloth", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦦", "name": "otter", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦨", "name": "skunk", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🦩", "name": "flamingo", "keywords": ["animal", "nature"] }, + { "category": "animals_and_nature", "char": "🌵", "name": "cactus", "keywords": ["vegetable", "plant", "nature"] }, + { "category": "animals_and_nature", "char": "🎄", "name": "christmas_tree", "keywords": ["festival", "vacation", "december", "xmas", "celebration"] }, + { "category": "animals_and_nature", "char": "🌲", "name": "evergreen_tree", "keywords": ["plant", "nature"] }, + { "category": "animals_and_nature", "char": "🌳", "name": "deciduous_tree", "keywords": ["plant", "nature"] }, + { "category": "animals_and_nature", "char": "🌴", "name": "palm_tree", "keywords": ["plant", "vegetable", "nature", "summer", "beach", "mojito", "tropical"] }, + { "category": "animals_and_nature", "char": "🌱", "name": "seedling", "keywords": ["plant", "nature", "grass", "lawn", "spring"] }, + { "category": "animals_and_nature", "char": "🌿", "name": "herb", "keywords": ["vegetable", "plant", "medicine", "weed", "grass", "lawn"] }, + { "category": "animals_and_nature", "char": "☘", "name": "shamrock", "keywords": ["vegetable", "plant", "nature", "irish", "clover"] }, + { "category": "animals_and_nature", "char": "🍀", "name": "four_leaf_clover", "keywords": ["vegetable", "plant", "nature", "lucky", "irish"] }, + { "category": "animals_and_nature", "char": "🎍", "name": "bamboo", "keywords": ["plant", "nature", "vegetable", "panda", "pine_decoration"] }, + { "category": "animals_and_nature", "char": "🎋", "name": "tanabata_tree", "keywords": ["plant", "nature", "branch", "summer"] }, + { "category": "animals_and_nature", "char": "🍃", "name": "leaves", "keywords": ["nature", "plant", "tree", "vegetable", "grass", "lawn", "spring"] }, + { "category": "animals_and_nature", "char": "🍂", "name": "fallen_leaf", "keywords": ["nature", "plant", "vegetable", "leaves"] }, + { "category": "animals_and_nature", "char": "🍁", "name": "maple_leaf", "keywords": ["nature", "plant", "vegetable", "ca", "fall"] }, + { "category": "animals_and_nature", "char": "🌾", "name": "ear_of_rice", "keywords": ["nature", "plant"] }, + { "category": "animals_and_nature", "char": "🌺", "name": "hibiscus", "keywords": ["plant", "vegetable", "flowers", "beach"] }, + { "category": "animals_and_nature", "char": "🌻", "name": "sunflower", "keywords": ["nature", "plant", "fall"] }, + { "category": "animals_and_nature", "char": "🌹", "name": "rose", "keywords": ["flowers", "valentines", "love", "spring"] }, + { "category": "animals_and_nature", "char": "🥀", "name": "wilted_flower", "keywords": ["plant", "nature", "flower"] }, + { "category": "animals_and_nature", "char": "🌷", "name": "tulip", "keywords": ["flowers", "plant", "nature", "summer", "spring"] }, + { "category": "animals_and_nature", "char": "🌼", "name": "blossom", "keywords": ["nature", "flowers", "yellow"] }, + { "category": "animals_and_nature", "char": "🌸", "name": "cherry_blossom", "keywords": ["nature", "plant", "spring", "flower"] }, + { "category": "animals_and_nature", "char": "💐", "name": "bouquet", "keywords": ["flowers", "nature", "spring"] }, + { "category": "animals_and_nature", "char": "🍄", "name": "mushroom", "keywords": ["plant", "vegetable"] }, + { "category": "animals_and_nature", "char": "🪴", "name": "potted_plant", "keywords": ["plant"] }, + { "category": "animals_and_nature", "char": "🌰", "name": "chestnut", "keywords": ["food", "squirrel"] }, + { "category": "animals_and_nature", "char": "🎃", "name": "jack_o_lantern", "keywords": ["halloween", "light", "pumpkin", "creepy", "fall"] }, + { "category": "animals_and_nature", "char": "🐚", "name": "shell", "keywords": ["nature", "sea", "beach"] }, + { "category": "animals_and_nature", "char": "🕸", "name": "spider_web", "keywords": ["animal", "insect", "arachnid", "silk"] }, + { "category": "animals_and_nature", "char": "🌎", "name": "earth_americas", "keywords": ["globe", "world", "USA", "international"] }, + { "category": "animals_and_nature", "char": "🌍", "name": "earth_africa", "keywords": ["globe", "world", "international"] }, + { "category": "animals_and_nature", "char": "🌏", "name": "earth_asia", "keywords": ["globe", "world", "east", "international"] }, + { "category": "animals_and_nature", "char": "🪐", "name": "ringed_planet", "keywords": ["saturn"] }, + { "category": "animals_and_nature", "char": "🌕", "name": "full_moon", "keywords": ["nature", "yellow", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌖", "name": "waning_gibbous_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep", "waxing_gibbous_moon"] }, + { "category": "animals_and_nature", "char": "🌗", "name": "last_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌘", "name": "waning_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌑", "name": "new_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌒", "name": "waxing_crescent_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌓", "name": "first_quarter_moon", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌔", "name": "waxing_gibbous_moon", "keywords": ["nature", "night", "sky", "gray", "twilight", "planet", "space", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌚", "name": "new_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌝", "name": "full_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌛", "name": "first_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌜", "name": "last_quarter_moon_with_face", "keywords": ["nature", "twilight", "planet", "space", "night", "evening", "sleep"] }, + { "category": "animals_and_nature", "char": "🌞", "name": "sun_with_face", "keywords": ["nature", "morning", "sky"] }, + { "category": "animals_and_nature", "char": "🌙", "name": "crescent_moon", "keywords": ["night", "sleep", "sky", "evening", "magic"] }, + { "category": "animals_and_nature", "char": "⭐", "name": "star", "keywords": ["night", "yellow"] }, + { "category": "animals_and_nature", "char": "🌟", "name": "star2", "keywords": ["night", "sparkle", "awesome", "good", "magic"] }, + { "category": "animals_and_nature", "char": "💫", "name": "dizzy", "keywords": ["star", "sparkle", "shoot", "magic"] }, + { "category": "animals_and_nature", "char": "✨", "name": "sparkles", "keywords": ["stars", "shine", "shiny", "cool", "awesome", "good", "magic"] }, + { "category": "animals_and_nature", "char": "☄", "name": "comet", "keywords": ["space"] }, + { "category": "animals_and_nature", "char": "☀️", "name": "sunny", "keywords": ["weather", "nature", "brightness", "summer", "beach", "spring"] }, + { "category": "animals_and_nature", "char": "🌤", "name": "sun_behind_small_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛅", "name": "partly_sunny", "keywords": ["weather", "nature", "cloudy", "morning", "fall", "spring"] }, + { "category": "animals_and_nature", "char": "🌥", "name": "sun_behind_large_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "🌦", "name": "sun_behind_rain_cloud", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "☁️", "name": "cloud", "keywords": ["weather", "sky"] }, + { "category": "animals_and_nature", "char": "🌧", "name": "cloud_with_rain", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛈", "name": "cloud_with_lightning_and_rain", "keywords": ["weather", "lightning"] }, + { "category": "animals_and_nature", "char": "🌩", "name": "cloud_with_lightning", "keywords": ["weather", "thunder"] }, + { "category": "animals_and_nature", "char": "⚡", "name": "zap", "keywords": ["thunder", "weather", "lightning bolt", "fast"] }, + { "category": "animals_and_nature", "char": "🔥", "name": "fire", "keywords": ["hot", "cook", "flame"] }, + { "category": "animals_and_nature", "char": "💥", "name": "boom", "keywords": ["bomb", "explode", "explosion", "collision", "blown"] }, + { "category": "animals_and_nature", "char": "❄️", "name": "snowflake", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas"] }, + { "category": "animals_and_nature", "char": "🌨", "name": "cloud_with_snow", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "⛄", "name": "snowman", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen", "without_snow"] }, + { "category": "animals_and_nature", "char": "☃", "name": "snowman_with_snow", "keywords": ["winter", "season", "cold", "weather", "christmas", "xmas", "frozen"] }, + { "category": "animals_and_nature", "char": "🌬", "name": "wind_face", "keywords": ["gust", "air"] }, + { "category": "animals_and_nature", "char": "💨", "name": "dash", "keywords": ["wind", "air", "fast", "shoo", "fart", "smoke", "puff"] }, + { "category": "animals_and_nature", "char": "🌪", "name": "tornado", "keywords": ["weather", "cyclone", "twister"] }, + { "category": "animals_and_nature", "char": "🌫", "name": "fog", "keywords": ["weather"] }, + { "category": "animals_and_nature", "char": "☂", "name": "open_umbrella", "keywords": ["weather", "spring"] }, + { "category": "animals_and_nature", "char": "☔", "name": "umbrella", "keywords": ["rainy", "weather", "spring"] }, + { "category": "animals_and_nature", "char": "💧", "name": "droplet", "keywords": ["water", "drip", "faucet", "spring"] }, + { "category": "animals_and_nature", "char": "💦", "name": "sweat_drops", "keywords": ["water", "drip", "oops"] }, + { "category": "animals_and_nature", "char": "🌊", "name": "ocean", "keywords": ["sea", "water", "wave", "nature", "tsunami", "disaster"] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEB7", "name": "lotus", "keywords": [] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEB8", "name": "coral", "keywords": [] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEB9", "name": "empty_nest", "keywords": [] }, + { "category": "animals_and_nature", "char": "\uD83E\uDEBA", "name": "nest_with_eggs", "keywords": [] }, + { "category": "food_and_drink", "char": "🍏", "name": "green_apple", "keywords": ["fruit", "nature"] }, + { "category": "food_and_drink", "char": "🍎", "name": "apple", "keywords": ["fruit", "mac", "school"] }, + { "category": "food_and_drink", "char": "🍐", "name": "pear", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍊", "name": "tangerine", "keywords": ["food", "fruit", "nature", "orange"] }, + { "category": "food_and_drink", "char": "🍋", "name": "lemon", "keywords": ["fruit", "nature"] }, + { "category": "food_and_drink", "char": "🍌", "name": "banana", "keywords": ["fruit", "food", "monkey"] }, + { "category": "food_and_drink", "char": "🍉", "name": "watermelon", "keywords": ["fruit", "food", "picnic", "summer"] }, + { "category": "food_and_drink", "char": "🍇", "name": "grapes", "keywords": ["fruit", "food", "wine"] }, + { "category": "food_and_drink", "char": "🍓", "name": "strawberry", "keywords": ["fruit", "food", "nature"] }, + { "category": "food_and_drink", "char": "🍈", "name": "melon", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍒", "name": "cherries", "keywords": ["food", "fruit"] }, + { "category": "food_and_drink", "char": "🍑", "name": "peach", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍍", "name": "pineapple", "keywords": ["fruit", "nature", "food"] }, + { "category": "food_and_drink", "char": "🥥", "name": "coconut", "keywords": ["fruit", "nature", "food", "palm"] }, + { "category": "food_and_drink", "char": "🥝", "name": "kiwi_fruit", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥭", "name": "mango", "keywords": ["fruit", "food", "tropical"] }, + { "category": "food_and_drink", "char": "🥑", "name": "avocado", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥦", "name": "broccoli", "keywords": ["fruit", "food", "vegetable"] }, + { "category": "food_and_drink", "char": "🍅", "name": "tomato", "keywords": ["fruit", "vegetable", "nature", "food"] }, + { "category": "food_and_drink", "char": "🍆", "name": "eggplant", "keywords": ["vegetable", "nature", "food", "aubergine"] }, + { "category": "food_and_drink", "char": "🥒", "name": "cucumber", "keywords": ["fruit", "food", "pickle"] }, + { "category": "food_and_drink", "char": "🫐", "name": "blueberries", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🫒", "name": "olive", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🫑", "name": "bell_pepper", "keywords": ["fruit", "food"] }, + { "category": "food_and_drink", "char": "🥕", "name": "carrot", "keywords": ["vegetable", "food", "orange"] }, + { "category": "food_and_drink", "char": "🌶", "name": "hot_pepper", "keywords": ["food", "spicy", "chilli", "chili"] }, + { "category": "food_and_drink", "char": "🥔", "name": "potato", "keywords": ["food", "tuber", "vegatable", "starch"] }, + { "category": "food_and_drink", "char": "🌽", "name": "corn", "keywords": ["food", "vegetable", "plant"] }, + { "category": "food_and_drink", "char": "🥬", "name": "leafy_greens", "keywords": ["food", "vegetable", "plant", "bok choy", "cabbage", "kale", "lettuce"] }, + { "category": "food_and_drink", "char": "🍠", "name": "sweet_potato", "keywords": ["food", "nature"] }, + { "category": "food_and_drink", "char": "🥜", "name": "peanuts", "keywords": ["food", "nut"] }, + { "category": "food_and_drink", "char": "🧄", "name": "garlic", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧅", "name": "onion", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🍯", "name": "honey_pot", "keywords": ["bees", "sweet", "kitchen"] }, + { "category": "food_and_drink", "char": "🥐", "name": "croissant", "keywords": ["food", "bread", "french"] }, + { "category": "food_and_drink", "char": "🍞", "name": "bread", "keywords": ["food", "wheat", "breakfast", "toast"] }, + { "category": "food_and_drink", "char": "🥖", "name": "baguette_bread", "keywords": ["food", "bread", "french"] }, + { "category": "food_and_drink", "char": "🥯", "name": "bagel", "keywords": ["food", "bread", "bakery", "schmear"] }, + { "category": "food_and_drink", "char": "🥨", "name": "pretzel", "keywords": ["food", "bread", "twisted"] }, + { "category": "food_and_drink", "char": "🧀", "name": "cheese", "keywords": ["food", "chadder"] }, + { "category": "food_and_drink", "char": "🥚", "name": "egg", "keywords": ["food", "chicken", "breakfast"] }, + { "category": "food_and_drink", "char": "🥓", "name": "bacon", "keywords": ["food", "breakfast", "pork", "pig", "meat"] }, + { "category": "food_and_drink", "char": "🥩", "name": "steak", "keywords": ["food", "cow", "meat", "cut", "chop", "lambchop", "porkchop"] }, + { "category": "food_and_drink", "char": "🥞", "name": "pancakes", "keywords": ["food", "breakfast", "flapjacks", "hotcakes"] }, + { "category": "food_and_drink", "char": "🍗", "name": "poultry_leg", "keywords": ["food", "meat", "drumstick", "bird", "chicken", "turkey"] }, + { "category": "food_and_drink", "char": "🍖", "name": "meat_on_bone", "keywords": ["good", "food", "drumstick"] }, + { "category": "food_and_drink", "char": "🦴", "name": "bone", "keywords": ["skeleton"] }, + { "category": "food_and_drink", "char": "🍤", "name": "fried_shrimp", "keywords": ["food", "animal", "appetizer", "summer"] }, + { "category": "food_and_drink", "char": "🍳", "name": "fried_egg", "keywords": ["food", "breakfast", "kitchen", "egg"] }, + { "category": "food_and_drink", "char": "🍔", "name": "hamburger", "keywords": ["meat", "fast food", "beef", "cheeseburger", "mcdonalds", "burger king"] }, + { "category": "food_and_drink", "char": "🍟", "name": "fries", "keywords": ["chips", "snack", "fast food"] }, + { "category": "food_and_drink", "char": "🥙", "name": "stuffed_flatbread", "keywords": ["food", "flatbread", "stuffed", "gyro"] }, + { "category": "food_and_drink", "char": "🌭", "name": "hotdog", "keywords": ["food", "frankfurter"] }, + { "category": "food_and_drink", "char": "🍕", "name": "pizza", "keywords": ["food", "party"] }, + { "category": "food_and_drink", "char": "🥪", "name": "sandwich", "keywords": ["food", "lunch", "bread"] }, + { "category": "food_and_drink", "char": "🥫", "name": "canned_food", "keywords": ["food", "soup"] }, + { "category": "food_and_drink", "char": "🍝", "name": "spaghetti", "keywords": ["food", "italian", "noodle"] }, + { "category": "food_and_drink", "char": "🌮", "name": "taco", "keywords": ["food", "mexican"] }, + { "category": "food_and_drink", "char": "🌯", "name": "burrito", "keywords": ["food", "mexican"] }, + { "category": "food_and_drink", "char": "🥗", "name": "green_salad", "keywords": ["food", "healthy", "lettuce"] }, + { "category": "food_and_drink", "char": "🥘", "name": "shallow_pan_of_food", "keywords": ["food", "cooking", "casserole", "paella"] }, + { "category": "food_and_drink", "char": "🍜", "name": "ramen", "keywords": ["food", "japanese", "noodle", "chopsticks"] }, + { "category": "food_and_drink", "char": "🍲", "name": "stew", "keywords": ["food", "meat", "soup"] }, + { "category": "food_and_drink", "char": "🍥", "name": "fish_cake", "keywords": ["food", "japan", "sea", "beach", "narutomaki", "pink", "swirl", "kamaboko", "surimi", "ramen"] }, + { "category": "food_and_drink", "char": "🥠", "name": "fortune_cookie", "keywords": ["food", "prophecy"] }, + { "category": "food_and_drink", "char": "🍣", "name": "sushi", "keywords": ["food", "fish", "japanese", "rice"] }, + { "category": "food_and_drink", "char": "🍱", "name": "bento", "keywords": ["food", "japanese", "box"] }, + { "category": "food_and_drink", "char": "🍛", "name": "curry", "keywords": ["food", "spicy", "hot", "indian"] }, + { "category": "food_and_drink", "char": "🍙", "name": "rice_ball", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍚", "name": "rice", "keywords": ["food", "china", "asian"] }, + { "category": "food_and_drink", "char": "🍘", "name": "rice_cracker", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍢", "name": "oden", "keywords": ["food", "japanese"] }, + { "category": "food_and_drink", "char": "🍡", "name": "dango", "keywords": ["food", "dessert", "sweet", "japanese", "barbecue", "meat"] }, + { "category": "food_and_drink", "char": "🍧", "name": "shaved_ice", "keywords": ["hot", "dessert", "summer"] }, + { "category": "food_and_drink", "char": "🍨", "name": "ice_cream", "keywords": ["food", "hot", "dessert"] }, + { "category": "food_and_drink", "char": "🍦", "name": "icecream", "keywords": ["food", "hot", "dessert", "summer"] }, + { "category": "food_and_drink", "char": "🥧", "name": "pie", "keywords": ["food", "dessert", "pastry"] }, + { "category": "food_and_drink", "char": "🍰", "name": "cake", "keywords": ["food", "dessert"] }, + { "category": "food_and_drink", "char": "🧁", "name": "cupcake", "keywords": ["food", "dessert", "bakery", "sweet"] }, + { "category": "food_and_drink", "char": "🥮", "name": "moon_cake", "keywords": ["food", "autumn"] }, + { "category": "food_and_drink", "char": "🎂", "name": "birthday", "keywords": ["food", "dessert", "cake"] }, + { "category": "food_and_drink", "char": "🍮", "name": "custard", "keywords": ["dessert", "food"] }, + { "category": "food_and_drink", "char": "🍬", "name": "candy", "keywords": ["snack", "dessert", "sweet", "lolly"] }, + { "category": "food_and_drink", "char": "🍭", "name": "lollipop", "keywords": ["food", "snack", "candy", "sweet"] }, + { "category": "food_and_drink", "char": "🍫", "name": "chocolate_bar", "keywords": ["food", "snack", "dessert", "sweet"] }, + { "category": "food_and_drink", "char": "🍿", "name": "popcorn", "keywords": ["food", "movie theater", "films", "snack"] }, + { "category": "food_and_drink", "char": "🥟", "name": "dumpling", "keywords": ["food", "empanada", "pierogi", "potsticker"] }, + { "category": "food_and_drink", "char": "🍩", "name": "doughnut", "keywords": ["food", "dessert", "snack", "sweet", "donut"] }, + { "category": "food_and_drink", "char": "🍪", "name": "cookie", "keywords": ["food", "snack", "oreo", "chocolate", "sweet", "dessert"] }, + { "category": "food_and_drink", "char": "🧇", "name": "waffle", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧆", "name": "falafel", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧈", "name": "butter", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🦪", "name": "oyster", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫓", "name": "flatbread", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫔", "name": "tamale", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🫕", "name": "fondue", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🥛", "name": "milk_glass", "keywords": ["beverage", "drink", "cow"] }, + { "category": "food_and_drink", "char": "🍺", "name": "beer", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🍻", "name": "beers", "keywords": ["relax", "beverage", "drink", "drunk", "party", "pub", "summer", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🥂", "name": "clinking_glasses", "keywords": ["beverage", "drink", "party", "alcohol", "celebrate", "cheers", "wine", "champagne", "toast"] }, + { "category": "food_and_drink", "char": "🍷", "name": "wine_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🥃", "name": "tumbler_glass", "keywords": ["drink", "beverage", "drunk", "alcohol", "liquor", "booze", "bourbon", "scotch", "whisky", "glass", "shot"] }, + { "category": "food_and_drink", "char": "🍸", "name": "cocktail", "keywords": ["drink", "drunk", "alcohol", "beverage", "booze", "mojito"] }, + { "category": "food_and_drink", "char": "🍹", "name": "tropical_drink", "keywords": ["beverage", "cocktail", "summer", "beach", "alcohol", "booze", "mojito"] }, + { "category": "food_and_drink", "char": "🍾", "name": "champagne", "keywords": ["drink", "wine", "bottle", "celebration"] }, + { "category": "food_and_drink", "char": "🍶", "name": "sake", "keywords": ["wine", "drink", "drunk", "beverage", "japanese", "alcohol", "booze"] }, + { "category": "food_and_drink", "char": "🍵", "name": "tea", "keywords": ["drink", "bowl", "breakfast", "green", "british"] }, + { "category": "food_and_drink", "char": "🥤", "name": "cup_with_straw", "keywords": ["drink", "soda"] }, + { "category": "food_and_drink", "char": "☕", "name": "coffee", "keywords": ["beverage", "caffeine", "latte", "espresso"] }, + { "category": "food_and_drink", "char": "🫖", "name": "teapot", "keywords": [] }, + { "category": "food_and_drink", "char": "🧋", "name": "bubble_tea", "keywords": ["tapioca"] }, + { "category": "food_and_drink", "char": "🍼", "name": "baby_bottle", "keywords": ["food", "container", "milk"] }, + { "category": "food_and_drink", "char": "🧃", "name": "beverage_box", "keywords": ["food", "drink"] }, + { "category": "food_and_drink", "char": "🧉", "name": "mate", "keywords": ["food", "drink"] }, + { "category": "food_and_drink", "char": "🧊", "name": "ice_cube", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "🧂", "name": "salt", "keywords": ["condiment", "shaker"] }, + { "category": "food_and_drink", "char": "🥄", "name": "spoon", "keywords": ["cutlery", "kitchen", "tableware"] }, + { "category": "food_and_drink", "char": "🍴", "name": "fork_and_knife", "keywords": ["cutlery", "kitchen"] }, + { "category": "food_and_drink", "char": "🍽", "name": "plate_with_cutlery", "keywords": ["food", "eat", "meal", "lunch", "dinner", "restaurant"] }, + { "category": "food_and_drink", "char": "🥣", "name": "bowl_with_spoon", "keywords": ["food", "breakfast", "cereal", "oatmeal", "porridge"] }, + { "category": "food_and_drink", "char": "🥡", "name": "takeout_box", "keywords": ["food", "leftovers"] }, + { "category": "food_and_drink", "char": "🥢", "name": "chopsticks", "keywords": ["food"] }, + { "category": "food_and_drink", "char": "\uD83E\uDED7", "name": "pouring_liquid", "keywords": [] }, + { "category": "food_and_drink", "char": "\uD83E\uDED8", "name": "beans", "keywords": [] }, + { "category": "food_and_drink", "char": "\uD83E\uDED9", "name": "jar", "keywords": [] }, + { "category": "activity", "char": "⚽", "name": "soccer", "keywords": ["sports", "football"] }, + { "category": "activity", "char": "🏀", "name": "basketball", "keywords": ["sports", "balls", "NBA"] }, + { "category": "activity", "char": "🏈", "name": "football", "keywords": ["sports", "balls", "NFL"] }, + { "category": "activity", "char": "⚾", "name": "baseball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🥎", "name": "softball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🎾", "name": "tennis", "keywords": ["sports", "balls", "green"] }, + { "category": "activity", "char": "🏐", "name": "volleyball", "keywords": ["sports", "balls"] }, + { "category": "activity", "char": "🏉", "name": "rugby_football", "keywords": ["sports", "team"] }, + { "category": "activity", "char": "🥏", "name": "flying_disc", "keywords": ["sports", "frisbee", "ultimate"] }, + { "category": "activity", "char": "🎱", "name": "8ball", "keywords": ["pool", "hobby", "game", "luck", "magic"] }, + { "category": "activity", "char": "⛳", "name": "golf", "keywords": ["sports", "business", "flag", "hole", "summer"] }, + { "category": "activity", "char": "🏌️‍♀️", "name": "golfing_woman", "keywords": ["sports", "business", "woman", "female"] }, + { "category": "activity", "char": "🏌", "name": "golfing_man", "keywords": ["sports", "business"] }, + { "category": "activity", "char": "🏓", "name": "ping_pong", "keywords": ["sports", "pingpong"] }, + { "category": "activity", "char": "🏸", "name": "badminton", "keywords": ["sports"] }, + { "category": "activity", "char": "🥅", "name": "goal_net", "keywords": ["sports"] }, + { "category": "activity", "char": "🏒", "name": "ice_hockey", "keywords": ["sports"] }, + { "category": "activity", "char": "🏑", "name": "field_hockey", "keywords": ["sports"] }, + { "category": "activity", "char": "🥍", "name": "lacrosse", "keywords": ["sports", "ball", "stick"] }, + { "category": "activity", "char": "🏏", "name": "cricket", "keywords": ["sports"] }, + { "category": "activity", "char": "🎿", "name": "ski", "keywords": ["sports", "winter", "cold", "snow"] }, + { "category": "activity", "char": "⛷", "name": "skier", "keywords": ["sports", "winter", "snow"] }, + { "category": "activity", "char": "🏂", "name": "snowboarder", "keywords": ["sports", "winter"] }, + { "category": "activity", "char": "🤺", "name": "person_fencing", "keywords": ["sports", "fencing", "sword"] }, + { "category": "activity", "char": "🤼‍♀️", "name": "women_wrestling", "keywords": ["sports", "wrestlers"] }, + { "category": "activity", "char": "🤼‍♂️", "name": "men_wrestling", "keywords": ["sports", "wrestlers"] }, + { "category": "activity", "char": "🤸‍♀️", "name": "woman_cartwheeling", "keywords": ["gymnastics"] }, + { "category": "activity", "char": "🤸‍♂️", "name": "man_cartwheeling", "keywords": ["gymnastics"] }, + { "category": "activity", "char": "🤾‍♀️", "name": "woman_playing_handball", "keywords": ["sports"] }, + { "category": "activity", "char": "🤾‍♂️", "name": "man_playing_handball", "keywords": ["sports"] }, + { "category": "activity", "char": "⛸", "name": "ice_skate", "keywords": ["sports"] }, + { "category": "activity", "char": "🥌", "name": "curling_stone", "keywords": ["sports"] }, + { "category": "activity", "char": "🛹", "name": "skateboard", "keywords": ["board"] }, + { "category": "activity", "char": "🛷", "name": "sled", "keywords": ["sleigh", "luge", "toboggan"] }, + { "category": "activity", "char": "🏹", "name": "bow_and_arrow", "keywords": ["sports"] }, + { "category": "activity", "char": "🎣", "name": "fishing_pole_and_fish", "keywords": ["food", "hobby", "summer"] }, + { "category": "activity", "char": "🥊", "name": "boxing_glove", "keywords": ["sports", "fighting"] }, + { "category": "activity", "char": "🥋", "name": "martial_arts_uniform", "keywords": ["judo", "karate", "taekwondo"] }, + { "category": "activity", "char": "🚣‍♀️", "name": "rowing_woman", "keywords": ["sports", "hobby", "water", "ship", "woman", "female"] }, + { "category": "activity", "char": "🚣", "name": "rowing_man", "keywords": ["sports", "hobby", "water", "ship"] }, + { "category": "activity", "char": "🧗‍♀️", "name": "climbing_woman", "keywords": ["sports", "hobby", "woman", "female", "rock"] }, + { "category": "activity", "char": "🧗‍♂️", "name": "climbing_man", "keywords": ["sports", "hobby", "man", "male", "rock"] }, + { "category": "activity", "char": "🏊‍♀️", "name": "swimming_woman", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer", "woman", "female"] }, + { "category": "activity", "char": "🏊", "name": "swimming_man", "keywords": ["sports", "exercise", "human", "athlete", "water", "summer"] }, + { "category": "activity", "char": "🤽‍♀️", "name": "woman_playing_water_polo", "keywords": ["sports", "pool"] }, + { "category": "activity", "char": "🤽‍♂️", "name": "man_playing_water_polo", "keywords": ["sports", "pool"] }, + { "category": "activity", "char": "🧘‍♀️", "name": "woman_in_lotus_position", "keywords": ["woman", "female", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, + { "category": "activity", "char": "🧘‍♂️", "name": "man_in_lotus_position", "keywords": ["man", "male", "meditation", "yoga", "serenity", "zen", "mindfulness"] }, + { "category": "activity", "char": "🏄‍♀️", "name": "surfing_woman", "keywords": ["sports", "ocean", "sea", "summer", "beach", "woman", "female"] }, + { "category": "activity", "char": "🏄", "name": "surfing_man", "keywords": ["sports", "ocean", "sea", "summer", "beach"] }, + { "category": "activity", "char": "🛀", "name": "bath", "keywords": ["clean", "shower", "bathroom"] }, + { "category": "activity", "char": "⛹️‍♀️", "name": "basketball_woman", "keywords": ["sports", "human", "woman", "female"] }, + { "category": "activity", "char": "⛹", "name": "basketball_man", "keywords": ["sports", "human"] }, + { "category": "activity", "char": "🏋️‍♀️", "name": "weight_lifting_woman", "keywords": ["sports", "training", "exercise", "woman", "female"] }, + { "category": "activity", "char": "🏋", "name": "weight_lifting_man", "keywords": ["sports", "training", "exercise"] }, + { "category": "activity", "char": "🚴‍♀️", "name": "biking_woman", "keywords": ["sports", "bike", "exercise", "hipster", "woman", "female"] }, + { "category": "activity", "char": "🚴", "name": "biking_man", "keywords": ["sports", "bike", "exercise", "hipster"] }, + { "category": "activity", "char": "🚵‍♀️", "name": "mountain_biking_woman", "keywords": ["transportation", "sports", "human", "race", "bike", "woman", "female"] }, + { "category": "activity", "char": "🚵", "name": "mountain_biking_man", "keywords": ["transportation", "sports", "human", "race", "bike"] }, + { "category": "activity", "char": "🏇", "name": "horse_racing", "keywords": ["animal", "betting", "competition", "gambling", "luck"] }, + { "category": "activity", "char": "🤿", "name": "diving_mask", "keywords": ["sports"] }, + { "category": "activity", "char": "🪀", "name": "yo_yo", "keywords": ["sports"] }, + { "category": "activity", "char": "🪁", "name": "kite", "keywords": ["sports"] }, + { "category": "activity", "char": "🦺", "name": "safety_vest", "keywords": ["sports"] }, + { "category": "activity", "char": "🪡", "name": "sewing_needle", "keywords": [] }, + { "category": "activity", "char": "🪢", "name": "knot", "keywords": [] }, + { "category": "activity", "char": "🕴", "name": "business_suit_levitating", "keywords": ["suit", "business", "levitate", "hover", "jump"] }, + { "category": "activity", "char": "🏆", "name": "trophy", "keywords": ["win", "award", "contest", "place", "ftw", "ceremony"] }, + { "category": "activity", "char": "🎽", "name": "running_shirt_with_sash", "keywords": ["play", "pageant"] }, + { "category": "activity", "char": "🏅", "name": "medal_sports", "keywords": ["award", "winning"] }, + { "category": "activity", "char": "🎖", "name": "medal_military", "keywords": ["award", "winning", "army"] }, + { "category": "activity", "char": "🥇", "name": "1st_place_medal", "keywords": ["award", "winning", "first"] }, + { "category": "activity", "char": "🥈", "name": "2nd_place_medal", "keywords": ["award", "second"] }, + { "category": "activity", "char": "🥉", "name": "3rd_place_medal", "keywords": ["award", "third"] }, + { "category": "activity", "char": "🎗", "name": "reminder_ribbon", "keywords": ["sports", "cause", "support", "awareness"] }, + { "category": "activity", "char": "🏵", "name": "rosette", "keywords": ["flower", "decoration", "military"] }, + { "category": "activity", "char": "🎫", "name": "ticket", "keywords": ["event", "concert", "pass"] }, + { "category": "activity", "char": "🎟", "name": "tickets", "keywords": ["sports", "concert", "entrance"] }, + { "category": "activity", "char": "🎭", "name": "performing_arts", "keywords": ["acting", "theater", "drama"] }, + { "category": "activity", "char": "🎨", "name": "art", "keywords": ["design", "paint", "draw", "colors"] }, + { "category": "activity", "char": "🎪", "name": "circus_tent", "keywords": ["festival", "carnival", "party"] }, + { "category": "activity", "char": "🤹‍♀️", "name": "woman_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, + { "category": "activity", "char": "🤹‍♂️", "name": "man_juggling", "keywords": ["juggle", "balance", "skill", "multitask"] }, + { "category": "activity", "char": "🎤", "name": "microphone", "keywords": ["sound", "music", "PA", "sing", "talkshow"] }, + { "category": "activity", "char": "🎧", "name": "headphones", "keywords": ["music", "score", "gadgets"] }, + { "category": "activity", "char": "🎼", "name": "musical_score", "keywords": ["treble", "clef", "compose"] }, + { "category": "activity", "char": "🎹", "name": "musical_keyboard", "keywords": ["piano", "instrument", "compose"] }, + { "category": "activity", "char": "🥁", "name": "drum", "keywords": ["music", "instrument", "drumsticks", "snare"] }, + { "category": "activity", "char": "🎷", "name": "saxophone", "keywords": ["music", "instrument", "jazz", "blues"] }, + { "category": "activity", "char": "🎺", "name": "trumpet", "keywords": ["music", "brass"] }, + { "category": "activity", "char": "🎸", "name": "guitar", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🎻", "name": "violin", "keywords": ["music", "instrument", "orchestra", "symphony"] }, + { "category": "activity", "char": "🪕", "name": "banjo", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🪗", "name": "accordion", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🪘", "name": "long_drum", "keywords": ["music", "instrument"] }, + { "category": "activity", "char": "🎬", "name": "clapper", "keywords": ["movie", "film", "record"] }, + { "category": "activity", "char": "🎮", "name": "video_game", "keywords": ["play", "console", "PS4", "controller"] }, + { "category": "activity", "char": "👾", "name": "space_invader", "keywords": ["game", "arcade", "play"] }, + { "category": "activity", "char": "🎯", "name": "dart", "keywords": ["game", "play", "bar", "target", "bullseye"] }, + { "category": "activity", "char": "🎲", "name": "game_die", "keywords": ["dice", "random", "tabletop", "play", "luck"] }, + { "category": "activity", "char": "♟️", "name": "chess_pawn", "keywords": ["expendable"] }, + { "category": "activity", "char": "🎰", "name": "slot_machine", "keywords": ["bet", "gamble", "vegas", "fruit machine", "luck", "casino"] }, + { "category": "activity", "char": "🧩", "name": "jigsaw", "keywords": ["interlocking", "puzzle", "piece"] }, + { "category": "activity", "char": "🎳", "name": "bowling", "keywords": ["sports", "fun", "play"] }, + { "category": "activity", "char": "🪄", "name": "magic_wand", "keywords": [] }, + { "category": "activity", "char": "🪅", "name": "pinata", "keywords": [] }, + { "category": "activity", "char": "🪆", "name": "nesting_dolls", "keywords": [] }, + { "category": "activity", "char": "\uD83E\uDEAC", "name": "hamsa", "keywords": [] }, + { "category": "activity", "char": "\uD83E\uDEA9", "name": "mirror_ball", "keywords": [] }, + { "category": "travel_and_places", "char": "🚗", "name": "red_car", "keywords": ["red", "transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚕", "name": "taxi", "keywords": ["uber", "vehicle", "cars", "transportation"] }, + { "category": "travel_and_places", "char": "🚙", "name": "blue_car", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚌", "name": "bus", "keywords": ["car", "vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚎", "name": "trolleybus", "keywords": ["bart", "transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🏎", "name": "racing_car", "keywords": ["sports", "race", "fast", "formula", "f1"] }, + { "category": "travel_and_places", "char": "🚓", "name": "police_car", "keywords": ["vehicle", "cars", "transportation", "law", "legal", "enforcement"] }, + { "category": "travel_and_places", "char": "🚑", "name": "ambulance", "keywords": ["health", "911", "hospital"] }, + { "category": "travel_and_places", "char": "🚒", "name": "fire_engine", "keywords": ["transportation", "cars", "vehicle"] }, + { "category": "travel_and_places", "char": "🚐", "name": "minibus", "keywords": ["vehicle", "car", "transportation"] }, + { "category": "travel_and_places", "char": "🚚", "name": "truck", "keywords": ["cars", "transportation"] }, + { "category": "travel_and_places", "char": "🚛", "name": "articulated_lorry", "keywords": ["vehicle", "cars", "transportation", "express"] }, + { "category": "travel_and_places", "char": "🚜", "name": "tractor", "keywords": ["vehicle", "car", "farming", "agriculture"] }, + { "category": "travel_and_places", "char": "🛴", "name": "kick_scooter", "keywords": ["vehicle", "kick", "razor"] }, + { "category": "travel_and_places", "char": "🏍", "name": "motorcycle", "keywords": ["race", "sports", "fast"] }, + { "category": "travel_and_places", "char": "🚲", "name": "bike", "keywords": ["sports", "bicycle", "exercise", "hipster"] }, + { "category": "travel_and_places", "char": "🛵", "name": "motor_scooter", "keywords": ["vehicle", "vespa", "sasha"] }, + { "category": "travel_and_places", "char": "🦽", "name": "manual_wheelchair", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🦼", "name": "motorized_wheelchair", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🛺", "name": "auto_rickshaw", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🪂", "name": "parachute", "keywords": ["vehicle"] }, + { "category": "travel_and_places", "char": "🚨", "name": "rotating_light", "keywords": ["police", "ambulance", "911", "emergency", "alert", "error", "pinged", "law", "legal"] }, + { "category": "travel_and_places", "char": "🚔", "name": "oncoming_police_car", "keywords": ["vehicle", "law", "legal", "enforcement", "911"] }, + { "category": "travel_and_places", "char": "🚍", "name": "oncoming_bus", "keywords": ["vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚘", "name": "oncoming_automobile", "keywords": ["car", "vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚖", "name": "oncoming_taxi", "keywords": ["vehicle", "cars", "uber"] }, + { "category": "travel_and_places", "char": "🚡", "name": "aerial_tramway", "keywords": ["transportation", "vehicle", "ski"] }, + { "category": "travel_and_places", "char": "🚠", "name": "mountain_cableway", "keywords": ["transportation", "vehicle", "ski"] }, + { "category": "travel_and_places", "char": "🚟", "name": "suspension_railway", "keywords": ["vehicle", "transportation"] }, + { "category": "travel_and_places", "char": "🚃", "name": "railway_car", "keywords": ["transportation", "vehicle", "train"] }, + { "category": "travel_and_places", "char": "🚋", "name": "train", "keywords": ["transportation", "vehicle", "carriage", "public", "travel"] }, + { "category": "travel_and_places", "char": "🚝", "name": "monorail", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚄", "name": "bullettrain_side", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚅", "name": "bullettrain_front", "keywords": ["transportation", "vehicle", "speed", "fast", "public", "travel"] }, + { "category": "travel_and_places", "char": "🚈", "name": "light_rail", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚞", "name": "mountain_railway", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚂", "name": "steam_locomotive", "keywords": ["transportation", "vehicle", "train"] }, + { "category": "travel_and_places", "char": "🚆", "name": "train2", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚇", "name": "metro", "keywords": ["transportation", "blue-square", "mrt", "underground", "tube"] }, + { "category": "travel_and_places", "char": "🚊", "name": "tram", "keywords": ["transportation", "vehicle"] }, + { "category": "travel_and_places", "char": "🚉", "name": "station", "keywords": ["transportation", "vehicle", "public"] }, + { "category": "travel_and_places", "char": "🛸", "name": "flying_saucer", "keywords": ["transportation", "vehicle", "ufo"] }, + { "category": "travel_and_places", "char": "🚁", "name": "helicopter", "keywords": ["transportation", "vehicle", "fly"] }, + { "category": "travel_and_places", "char": "🛩", "name": "small_airplane", "keywords": ["flight", "transportation", "fly", "vehicle"] }, + { "category": "travel_and_places", "char": "✈️", "name": "airplane", "keywords": ["vehicle", "transportation", "flight", "fly"] }, + { "category": "travel_and_places", "char": "🛫", "name": "flight_departure", "keywords": ["airport", "flight", "landing"] }, + { "category": "travel_and_places", "char": "🛬", "name": "flight_arrival", "keywords": ["airport", "flight", "boarding"] }, + { "category": "travel_and_places", "char": "⛵", "name": "sailboat", "keywords": ["ship", "summer", "transportation", "water", "sailing"] }, + { "category": "travel_and_places", "char": "🛥", "name": "motor_boat", "keywords": ["ship"] }, + { "category": "travel_and_places", "char": "🚤", "name": "speedboat", "keywords": ["ship", "transportation", "vehicle", "summer"] }, + { "category": "travel_and_places", "char": "⛴", "name": "ferry", "keywords": ["boat", "ship", "yacht"] }, + { "category": "travel_and_places", "char": "🛳", "name": "passenger_ship", "keywords": ["yacht", "cruise", "ferry"] }, + { "category": "travel_and_places", "char": "🚀", "name": "rocket", "keywords": ["launch", "ship", "staffmode", "NASA", "outer space", "outer_space", "fly"] }, + { "category": "travel_and_places", "char": "🛰", "name": "artificial_satellite", "keywords": ["communication", "gps", "orbit", "spaceflight", "NASA", "ISS"] }, + { "category": "travel_and_places", "char": "🛻", "name": "pickup_truck", "keywords": ["car"] }, + { "category": "travel_and_places", "char": "🛼", "name": "roller_skate", "keywords": [] }, + { "category": "travel_and_places", "char": "💺", "name": "seat", "keywords": ["sit", "airplane", "transport", "bus", "flight", "fly"] }, + { "category": "travel_and_places", "char": "🛶", "name": "canoe", "keywords": ["boat", "paddle", "water", "ship"] }, + { "category": "travel_and_places", "char": "⚓", "name": "anchor", "keywords": ["ship", "ferry", "sea", "boat"] }, + { "category": "travel_and_places", "char": "🚧", "name": "construction", "keywords": ["wip", "progress", "caution", "warning"] }, + { "category": "travel_and_places", "char": "⛽", "name": "fuelpump", "keywords": ["gas station", "petroleum"] }, + { "category": "travel_and_places", "char": "🚏", "name": "busstop", "keywords": ["transportation", "wait"] }, + { "category": "travel_and_places", "char": "🚦", "name": "vertical_traffic_light", "keywords": ["transportation", "driving"] }, + { "category": "travel_and_places", "char": "🚥", "name": "traffic_light", "keywords": ["transportation", "signal"] }, + { "category": "travel_and_places", "char": "🏁", "name": "checkered_flag", "keywords": ["contest", "finishline", "race", "gokart"] }, + { "category": "travel_and_places", "char": "🚢", "name": "ship", "keywords": ["transportation", "titanic", "deploy"] }, + { "category": "travel_and_places", "char": "🎡", "name": "ferris_wheel", "keywords": ["photo", "carnival", "londoneye"] }, + { "category": "travel_and_places", "char": "🎢", "name": "roller_coaster", "keywords": ["carnival", "playground", "photo", "fun"] }, + { "category": "travel_and_places", "char": "🎠", "name": "carousel_horse", "keywords": ["photo", "carnival"] }, + { "category": "travel_and_places", "char": "🏗", "name": "building_construction", "keywords": ["wip", "working", "progress"] }, + { "category": "travel_and_places", "char": "🌁", "name": "foggy", "keywords": ["photo", "mountain"] }, + { "category": "travel_and_places", "char": "🏭", "name": "factory", "keywords": ["building", "industry", "pollution", "smoke"] }, + { "category": "travel_and_places", "char": "⛲", "name": "fountain", "keywords": ["photo", "summer", "water", "fresh"] }, + { "category": "travel_and_places", "char": "🎑", "name": "rice_scene", "keywords": ["photo", "japan", "asia", "tsukimi"] }, + { "category": "travel_and_places", "char": "⛰", "name": "mountain", "keywords": ["photo", "nature", "environment"] }, + { "category": "travel_and_places", "char": "🏔", "name": "mountain_snow", "keywords": ["photo", "nature", "environment", "winter", "cold"] }, + { "category": "travel_and_places", "char": "🗻", "name": "mount_fuji", "keywords": ["photo", "mountain", "nature", "japanese"] }, + { "category": "travel_and_places", "char": "🌋", "name": "volcano", "keywords": ["photo", "nature", "disaster"] }, + { "category": "travel_and_places", "char": "🗾", "name": "japan", "keywords": ["nation", "country", "japanese", "asia"] }, + { "category": "travel_and_places", "char": "🏕", "name": "camping", "keywords": ["photo", "outdoors", "tent"] }, + { "category": "travel_and_places", "char": "⛺", "name": "tent", "keywords": ["photo", "camping", "outdoors"] }, + { "category": "travel_and_places", "char": "🏞", "name": "national_park", "keywords": ["photo", "environment", "nature"] }, + { "category": "travel_and_places", "char": "🛣", "name": "motorway", "keywords": ["road", "cupertino", "interstate", "highway"] }, + { "category": "travel_and_places", "char": "🛤", "name": "railway_track", "keywords": ["train", "transportation"] }, + { "category": "travel_and_places", "char": "🌅", "name": "sunrise", "keywords": ["morning", "view", "vacation", "photo"] }, + { "category": "travel_and_places", "char": "🌄", "name": "sunrise_over_mountains", "keywords": ["view", "vacation", "photo"] }, + { "category": "travel_and_places", "char": "🏜", "name": "desert", "keywords": ["photo", "warm", "saharah"] }, + { "category": "travel_and_places", "char": "🏖", "name": "beach_umbrella", "keywords": ["weather", "summer", "sunny", "sand", "mojito"] }, + { "category": "travel_and_places", "char": "🏝", "name": "desert_island", "keywords": ["photo", "tropical", "mojito"] }, + { "category": "travel_and_places", "char": "🌇", "name": "city_sunrise", "keywords": ["photo", "good morning", "dawn"] }, + { "category": "travel_and_places", "char": "🌆", "name": "city_sunset", "keywords": ["photo", "evening", "sky", "buildings"] }, + { "category": "travel_and_places", "char": "🏙", "name": "cityscape", "keywords": ["photo", "night life", "urban"] }, + { "category": "travel_and_places", "char": "🌃", "name": "night_with_stars", "keywords": ["evening", "city", "downtown"] }, + { "category": "travel_and_places", "char": "🌉", "name": "bridge_at_night", "keywords": ["photo", "sanfrancisco"] }, + { "category": "travel_and_places", "char": "🌌", "name": "milky_way", "keywords": ["photo", "space", "stars"] }, + { "category": "travel_and_places", "char": "🌠", "name": "stars", "keywords": ["night", "photo"] }, + { "category": "travel_and_places", "char": "🎇", "name": "sparkler", "keywords": ["stars", "night", "shine"] }, + { "category": "travel_and_places", "char": "🎆", "name": "fireworks", "keywords": ["photo", "festival", "carnival", "congratulations"] }, + { "category": "travel_and_places", "char": "🌈", "name": "rainbow", "keywords": ["nature", "happy", "unicorn_face", "photo", "sky", "spring"] }, + { "category": "travel_and_places", "char": "🏘", "name": "houses", "keywords": ["buildings", "photo"] }, + { "category": "travel_and_places", "char": "🏰", "name": "european_castle", "keywords": ["building", "royalty", "history"] }, + { "category": "travel_and_places", "char": "🏯", "name": "japanese_castle", "keywords": ["photo", "building"] }, + { "category": "travel_and_places", "char": "🗼", "name": "tokyo_tower", "keywords": ["photo", "japanese"] }, + { "category": "travel_and_places", "char": "", "name": "shibuya_109", "keywords": ["photo", "japanese"] }, + { "category": "travel_and_places", "char": "🏟", "name": "stadium", "keywords": ["photo", "place", "sports", "concert", "venue"] }, + { "category": "travel_and_places", "char": "🗽", "name": "statue_of_liberty", "keywords": ["american", "newyork"] }, + { "category": "travel_and_places", "char": "🏠", "name": "house", "keywords": ["building", "home"] }, + { "category": "travel_and_places", "char": "🏡", "name": "house_with_garden", "keywords": ["home", "plant", "nature"] }, + { "category": "travel_and_places", "char": "🏚", "name": "derelict_house", "keywords": ["abandon", "evict", "broken", "building"] }, + { "category": "travel_and_places", "char": "🏢", "name": "office", "keywords": ["building", "bureau", "work"] }, + { "category": "travel_and_places", "char": "🏬", "name": "department_store", "keywords": ["building", "shopping", "mall"] }, + { "category": "travel_and_places", "char": "🏣", "name": "post_office", "keywords": ["building", "envelope", "communication"] }, + { "category": "travel_and_places", "char": "🏤", "name": "european_post_office", "keywords": ["building", "email"] }, + { "category": "travel_and_places", "char": "🏥", "name": "hospital", "keywords": ["building", "health", "surgery", "doctor"] }, + { "category": "travel_and_places", "char": "🏦", "name": "bank", "keywords": ["building", "money", "sales", "cash", "business", "enterprise"] }, + { "category": "travel_and_places", "char": "🏨", "name": "hotel", "keywords": ["building", "accomodation", "checkin"] }, + { "category": "travel_and_places", "char": "🏪", "name": "convenience_store", "keywords": ["building", "shopping", "groceries"] }, + { "category": "travel_and_places", "char": "🏫", "name": "school", "keywords": ["building", "student", "education", "learn", "teach"] }, + { "category": "travel_and_places", "char": "🏩", "name": "love_hotel", "keywords": ["like", "affection", "dating"] }, + { "category": "travel_and_places", "char": "💒", "name": "wedding", "keywords": ["love", "like", "affection", "couple", "marriage", "bride", "groom"] }, + { "category": "travel_and_places", "char": "🏛", "name": "classical_building", "keywords": ["art", "culture", "history"] }, + { "category": "travel_and_places", "char": "⛪", "name": "church", "keywords": ["building", "religion", "christ"] }, + { "category": "travel_and_places", "char": "🕌", "name": "mosque", "keywords": ["islam", "worship", "minaret"] }, + { "category": "travel_and_places", "char": "🕍", "name": "synagogue", "keywords": ["judaism", "worship", "temple", "jewish"] }, + { "category": "travel_and_places", "char": "🕋", "name": "kaaba", "keywords": ["mecca", "mosque", "islam"] }, + { "category": "travel_and_places", "char": "⛩", "name": "shinto_shrine", "keywords": ["temple", "japan", "kyoto"] }, + { "category": "travel_and_places", "char": "🛕", "name": "hindu_temple", "keywords": ["temple"] }, + { "category": "travel_and_places", "char": "🪨", "name": "rock", "keywords": [] }, + { "category": "travel_and_places", "char": "🪵", "name": "wood", "keywords": [] }, + { "category": "travel_and_places", "char": "🛖", "name": "hut", "keywords": [] }, + { "category": "travel_and_places", "char": "\uD83D\uDEDD", "name": "playground_slide", "keywords": [] }, + { "category": "travel_and_places", "char": "\uD83D\uDEDE", "name": "wheel", "keywords": [] }, + { "category": "travel_and_places", "char": "\uD83D\uDEDF", "name": "ring_buoy", "keywords": [] }, + { "category": "objects", "char": "⌚", "name": "watch", "keywords": ["time", "accessories"] }, + { "category": "objects", "char": "📱", "name": "iphone", "keywords": ["technology", "apple", "gadgets", "dial"] }, + { "category": "objects", "char": "📲", "name": "calling", "keywords": ["iphone", "incoming"] }, + { "category": "objects", "char": "💻", "name": "computer", "keywords": ["technology", "laptop", "screen", "display", "monitor"] }, + { "category": "objects", "char": "⌨", "name": "keyboard", "keywords": ["technology", "computer", "type", "input", "text"] }, + { "category": "objects", "char": "🖥", "name": "desktop_computer", "keywords": ["technology", "computing", "screen"] }, + { "category": "objects", "char": "🖨", "name": "printer", "keywords": ["paper", "ink"] }, + { "category": "objects", "char": "🖱", "name": "computer_mouse", "keywords": ["click"] }, + { "category": "objects", "char": "🖲", "name": "trackball", "keywords": ["technology", "trackpad"] }, + { "category": "objects", "char": "🕹", "name": "joystick", "keywords": ["game", "play"] }, + { "category": "objects", "char": "🗜", "name": "clamp", "keywords": ["tool"] }, + { "category": "objects", "char": "💽", "name": "minidisc", "keywords": ["technology", "record", "data", "disk", "90s"] }, + { "category": "objects", "char": "💾", "name": "floppy_disk", "keywords": ["oldschool", "technology", "save", "90s", "80s"] }, + { "category": "objects", "char": "💿", "name": "cd", "keywords": ["technology", "dvd", "disk", "disc", "90s"] }, + { "category": "objects", "char": "📀", "name": "dvd", "keywords": ["cd", "disk", "disc"] }, + { "category": "objects", "char": "📼", "name": "vhs", "keywords": ["record", "video", "oldschool", "90s", "80s"] }, + { "category": "objects", "char": "📷", "name": "camera", "keywords": ["gadgets", "photography"] }, + { "category": "objects", "char": "📸", "name": "camera_flash", "keywords": ["photography", "gadgets"] }, + { "category": "objects", "char": "📹", "name": "video_camera", "keywords": ["film", "record"] }, + { "category": "objects", "char": "🎥", "name": "movie_camera", "keywords": ["film", "record"] }, + { "category": "objects", "char": "📽", "name": "film_projector", "keywords": ["video", "tape", "record", "movie"] }, + { "category": "objects", "char": "🎞", "name": "film_strip", "keywords": ["movie"] }, + { "category": "objects", "char": "📞", "name": "telephone_receiver", "keywords": ["technology", "communication", "dial"] }, + { "category": "objects", "char": "☎️", "name": "phone", "keywords": ["technology", "communication", "dial", "telephone"] }, + { "category": "objects", "char": "📟", "name": "pager", "keywords": ["bbcall", "oldschool", "90s"] }, + { "category": "objects", "char": "📠", "name": "fax", "keywords": ["communication", "technology"] }, + { "category": "objects", "char": "📺", "name": "tv", "keywords": ["technology", "program", "oldschool", "show", "television"] }, + { "category": "objects", "char": "📻", "name": "radio", "keywords": ["communication", "music", "podcast", "program"] }, + { "category": "objects", "char": "🎙", "name": "studio_microphone", "keywords": ["sing", "recording", "artist", "talkshow"] }, + { "category": "objects", "char": "🎚", "name": "level_slider", "keywords": ["scale"] }, + { "category": "objects", "char": "🎛", "name": "control_knobs", "keywords": ["dial"] }, + { "category": "objects", "char": "🧭", "name": "compass", "keywords": ["magnetic", "navigation", "orienteering"] }, + { "category": "objects", "char": "⏱", "name": "stopwatch", "keywords": ["time", "deadline"] }, + { "category": "objects", "char": "⏲", "name": "timer_clock", "keywords": ["alarm"] }, + { "category": "objects", "char": "⏰", "name": "alarm_clock", "keywords": ["time", "wake"] }, + { "category": "objects", "char": "🕰", "name": "mantelpiece_clock", "keywords": ["time"] }, + { "category": "objects", "char": "⏳", "name": "hourglass_flowing_sand", "keywords": ["oldschool", "time", "countdown"] }, + { "category": "objects", "char": "⌛", "name": "hourglass", "keywords": ["time", "clock", "oldschool", "limit", "exam", "quiz", "test"] }, + { "category": "objects", "char": "📡", "name": "satellite", "keywords": ["communication", "future", "radio", "space"] }, + { "category": "objects", "char": "🔋", "name": "battery", "keywords": ["power", "energy", "sustain"] }, + { "category": "objects", "char": "\uD83E\uDEAB", "name": "battery", "keywords": [] }, + { "category": "objects", "char": "🔌", "name": "electric_plug", "keywords": ["charger", "power"] }, + { "category": "objects", "char": "💡", "name": "bulb", "keywords": ["light", "electricity", "idea"] }, + { "category": "objects", "char": "🔦", "name": "flashlight", "keywords": ["dark", "camping", "sight", "night"] }, + { "category": "objects", "char": "🕯", "name": "candle", "keywords": ["fire", "wax"] }, + { "category": "objects", "char": "🧯", "name": "fire_extinguisher", "keywords": ["quench"] }, + { "category": "objects", "char": "🗑", "name": "wastebasket", "keywords": ["bin", "trash", "rubbish", "garbage", "toss"] }, + { "category": "objects", "char": "🛢", "name": "oil_drum", "keywords": ["barrell"] }, + { "category": "objects", "char": "💸", "name": "money_with_wings", "keywords": ["dollar", "bills", "payment", "sale"] }, + { "category": "objects", "char": "💵", "name": "dollar", "keywords": ["money", "sales", "bill", "currency"] }, + { "category": "objects", "char": "💴", "name": "yen", "keywords": ["money", "sales", "japanese", "dollar", "currency"] }, + { "category": "objects", "char": "💶", "name": "euro", "keywords": ["money", "sales", "dollar", "currency"] }, + { "category": "objects", "char": "💷", "name": "pound", "keywords": ["british", "sterling", "money", "sales", "bills", "uk", "england", "currency"] }, + { "category": "objects", "char": "💰", "name": "moneybag", "keywords": ["dollar", "payment", "coins", "sale"] }, + { "category": "objects", "char": "🪙", "name": "coin", "keywords": ["dollar", "payment", "coins", "sale"] }, + { "category": "objects", "char": "💳", "name": "credit_card", "keywords": ["money", "sales", "dollar", "bill", "payment", "shopping"] }, + { "category": "objects", "char": "\uD83E\uDEAB", "name": "identification_card", "keywords": [] }, + { "category": "objects", "char": "💎", "name": "gem", "keywords": ["blue", "ruby", "diamond", "jewelry"] }, + { "category": "objects", "char": "⚖", "name": "balance_scale", "keywords": ["law", "fairness", "weight"] }, + { "category": "objects", "char": "🧰", "name": "toolbox", "keywords": ["tools", "diy", "fix", "maintainer", "mechanic"] }, + { "category": "objects", "char": "🔧", "name": "wrench", "keywords": ["tools", "diy", "ikea", "fix", "maintainer"] }, + { "category": "objects", "char": "🔨", "name": "hammer", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "⚒", "name": "hammer_and_pick", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "🛠", "name": "hammer_and_wrench", "keywords": ["tools", "build", "create"] }, + { "category": "objects", "char": "⛏", "name": "pick", "keywords": ["tools", "dig"] }, + { "category": "objects", "char": "🪓", "name": "axe", "keywords": ["tools"] }, + { "category": "objects", "char": "🦯", "name": "probing_cane", "keywords": ["tools"] }, + { "category": "objects", "char": "🔩", "name": "nut_and_bolt", "keywords": ["handy", "tools", "fix"] }, + { "category": "objects", "char": "⚙", "name": "gear", "keywords": ["cog"] }, + { "category": "objects", "char": "🪃", "name": "boomerang", "keywords": ["tool"] }, + { "category": "objects", "char": "🪚", "name": "carpentry_saw", "keywords": ["tool"] }, + { "category": "objects", "char": "🪛", "name": "screwdriver", "keywords": ["tool"] }, + { "category": "objects", "char": "🪝", "name": "hook", "keywords": ["tool"] }, + { "category": "objects", "char": "🪜", "name": "ladder", "keywords": ["tool"] }, + { "category": "objects", "char": "🧱", "name": "brick", "keywords": ["bricks"] }, + { "category": "objects", "char": "⛓", "name": "chains", "keywords": ["lock", "arrest"] }, + { "category": "objects", "char": "🧲", "name": "magnet", "keywords": ["attraction", "magnetic"] }, + { "category": "objects", "char": "🔫", "name": "gun", "keywords": ["violence", "weapon", "pistol", "revolver"] }, + { "category": "objects", "char": "💣", "name": "bomb", "keywords": ["boom", "explode", "explosion", "terrorism"] }, + { "category": "objects", "char": "🧨", "name": "firecracker", "keywords": ["dynamite", "boom", "explode", "explosion", "explosive"] }, + { "category": "objects", "char": "🔪", "name": "hocho", "keywords": ["knife", "blade", "cutlery", "kitchen", "weapon"] }, + { "category": "objects", "char": "🗡", "name": "dagger", "keywords": ["weapon"] }, + { "category": "objects", "char": "⚔", "name": "crossed_swords", "keywords": ["weapon"] }, + { "category": "objects", "char": "🛡", "name": "shield", "keywords": ["protection", "security"] }, + { "category": "objects", "char": "🚬", "name": "smoking", "keywords": ["kills", "tobacco", "cigarette", "joint", "smoke"] }, + { "category": "objects", "char": "☠", "name": "skull_and_crossbones", "keywords": ["poison", "danger", "deadly", "scary", "death", "pirate", "evil"] }, + { "category": "objects", "char": "⚰", "name": "coffin", "keywords": ["vampire", "dead", "die", "death", "rip", "graveyard", "cemetery", "casket", "funeral", "box"] }, + { "category": "objects", "char": "⚱", "name": "funeral_urn", "keywords": ["dead", "die", "death", "rip", "ashes"] }, + { "category": "objects", "char": "🏺", "name": "amphora", "keywords": ["vase", "jar"] }, + { "category": "objects", "char": "🔮", "name": "crystal_ball", "keywords": ["disco", "party", "magic", "circus", "fortune_teller"] }, + { "category": "objects", "char": "📿", "name": "prayer_beads", "keywords": ["dhikr", "religious"] }, + { "category": "objects", "char": "🧿", "name": "nazar_amulet", "keywords": ["bead", "charm"] }, + { "category": "objects", "char": "💈", "name": "barber", "keywords": ["hair", "salon", "style"] }, + { "category": "objects", "char": "⚗", "name": "alembic", "keywords": ["distilling", "science", "experiment", "chemistry"] }, + { "category": "objects", "char": "🔭", "name": "telescope", "keywords": ["stars", "space", "zoom", "science", "astronomy"] }, + { "category": "objects", "char": "🔬", "name": "microscope", "keywords": ["laboratory", "experiment", "zoomin", "science", "study"] }, + { "category": "objects", "char": "🕳", "name": "hole", "keywords": ["embarrassing"] }, + { "category": "objects", "char": "💊", "name": "pill", "keywords": ["health", "medicine", "doctor", "pharmacy", "drug"] }, + { "category": "objects", "char": "💉", "name": "syringe", "keywords": ["health", "hospital", "drugs", "blood", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩸", "name": "drop_of_blood", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩹", "name": "adhesive_bandage", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🩺", "name": "stethoscope", "keywords": ["health", "hospital", "medicine", "needle", "doctor", "nurse"] }, + { "category": "objects", "char": "🪒", "name": "razor", "keywords": ["health"] }, + { "category": "objects", "char": "\uD83E\uDE7B", "name": "xray", "keywords": [] }, + { "category": "objects", "char": "\uD83E\uDE7C", "name": "crutch", "keywords": [] }, + { "category": "objects", "char": "🧬", "name": "dna", "keywords": ["biologist", "genetics", "life"] }, + { "category": "objects", "char": "🧫", "name": "petri_dish", "keywords": ["bacteria", "biology", "culture", "lab"] }, + { "category": "objects", "char": "🧪", "name": "test_tube", "keywords": ["chemistry", "experiment", "lab", "science"] }, + { "category": "objects", "char": "🌡", "name": "thermometer", "keywords": ["weather", "temperature", "hot", "cold"] }, + { "category": "objects", "char": "🧹", "name": "broom", "keywords": ["cleaning", "sweeping", "witch"] }, + { "category": "objects", "char": "🧺", "name": "basket", "keywords": ["laundry"] }, + { "category": "objects", "char": "🧻", "name": "toilet_paper", "keywords": ["roll"] }, + { "category": "objects", "char": "🏷", "name": "label", "keywords": ["sale", "tag"] }, + { "category": "objects", "char": "🔖", "name": "bookmark", "keywords": ["favorite", "label", "save"] }, + { "category": "objects", "char": "🚽", "name": "toilet", "keywords": ["restroom", "wc", "washroom", "bathroom", "potty"] }, + { "category": "objects", "char": "🚿", "name": "shower", "keywords": ["clean", "water", "bathroom"] }, + { "category": "objects", "char": "🛁", "name": "bathtub", "keywords": ["clean", "shower", "bathroom"] }, + { "category": "objects", "char": "🧼", "name": "soap", "keywords": ["bar", "bathing", "cleaning", "lather"] }, + { "category": "objects", "char": "🧽", "name": "sponge", "keywords": ["absorbing", "cleaning", "porous"] }, + { "category": "objects", "char": "🧴", "name": "lotion_bottle", "keywords": ["moisturizer", "sunscreen"] }, + { "category": "objects", "char": "🔑", "name": "key", "keywords": ["lock", "door", "password"] }, + { "category": "objects", "char": "🗝", "name": "old_key", "keywords": ["lock", "door", "password"] }, + { "category": "objects", "char": "🛋", "name": "couch_and_lamp", "keywords": ["read", "chill"] }, + { "category": "objects", "char": "🪔", "name": "diya_Lamp", "keywords": ["light", "oil"] }, + { "category": "objects", "char": "🛌", "name": "sleeping_bed", "keywords": ["bed", "rest"] }, + { "category": "objects", "char": "🛏", "name": "bed", "keywords": ["sleep", "rest"] }, + { "category": "objects", "char": "🚪", "name": "door", "keywords": ["house", "entry", "exit"] }, + { "category": "objects", "char": "🪑", "name": "chair", "keywords": ["house", "desk"] }, + { "category": "objects", "char": "🛎", "name": "bellhop_bell", "keywords": ["service"] }, + { "category": "objects", "char": "🧸", "name": "teddy_bear", "keywords": ["plush", "stuffed"] }, + { "category": "objects", "char": "🖼", "name": "framed_picture", "keywords": ["photography"] }, + { "category": "objects", "char": "🗺", "name": "world_map", "keywords": ["location", "direction"] }, + { "category": "objects", "char": "🛗", "name": "elevator", "keywords": ["household"] }, + { "category": "objects", "char": "🪞", "name": "mirror", "keywords": ["household"] }, + { "category": "objects", "char": "🪟", "name": "window", "keywords": ["household"] }, + { "category": "objects", "char": "🪠", "name": "plunger", "keywords": ["household"] }, + { "category": "objects", "char": "🪤", "name": "mouse_trap", "keywords": ["household"] }, + { "category": "objects", "char": "🪣", "name": "bucket", "keywords": ["household"] }, + { "category": "objects", "char": "🪥", "name": "toothbrush", "keywords": ["household"] }, + { "category": "objects", "char": "\uD83E\uDEE7", "name": "bubbles", "keywords": [] }, + { "category": "objects", "char": "⛱", "name": "parasol_on_ground", "keywords": ["weather", "summer"] }, + { "category": "objects", "char": "🗿", "name": "moyai", "keywords": ["rock", "easter island", "moai"] }, + { "category": "objects", "char": "🛍", "name": "shopping", "keywords": ["mall", "buy", "purchase"] }, + { "category": "objects", "char": "🛒", "name": "shopping_cart", "keywords": ["trolley"] }, + { "category": "objects", "char": "🎈", "name": "balloon", "keywords": ["party", "celebration", "birthday", "circus"] }, + { "category": "objects", "char": "🎏", "name": "flags", "keywords": ["fish", "japanese", "koinobori", "carp", "banner"] }, + { "category": "objects", "char": "🎀", "name": "ribbon", "keywords": ["decoration", "pink", "girl", "bowtie"] }, + { "category": "objects", "char": "🎁", "name": "gift", "keywords": ["present", "birthday", "christmas", "xmas"] }, + { "category": "objects", "char": "🎊", "name": "confetti_ball", "keywords": ["festival", "party", "birthday", "circus"] }, + { "category": "objects", "char": "🎉", "name": "tada", "keywords": ["party", "congratulations", "birthday", "magic", "circus", "celebration"] }, + { "category": "objects", "char": "🎎", "name": "dolls", "keywords": ["japanese", "toy", "kimono"] }, + { "category": "objects", "char": "🎐", "name": "wind_chime", "keywords": ["nature", "ding", "spring", "bell"] }, + { "category": "objects", "char": "🎌", "name": "crossed_flags", "keywords": ["japanese", "nation", "country", "border"] }, + { "category": "objects", "char": "🏮", "name": "izakaya_lantern", "keywords": ["light", "paper", "halloween", "spooky"] }, + { "category": "objects", "char": "🧧", "name": "red_envelope", "keywords": ["gift"] }, + { "category": "objects", "char": "✉️", "name": "email", "keywords": ["letter", "postal", "inbox", "communication"] }, + { "category": "objects", "char": "📩", "name": "envelope_with_arrow", "keywords": ["email", "communication"] }, + { "category": "objects", "char": "📨", "name": "incoming_envelope", "keywords": ["email", "inbox"] }, + { "category": "objects", "char": "📧", "name": "e-mail", "keywords": ["communication", "inbox"] }, + { "category": "objects", "char": "💌", "name": "love_letter", "keywords": ["email", "like", "affection", "envelope", "valentines"] }, + { "category": "objects", "char": "📮", "name": "postbox", "keywords": ["email", "letter", "envelope"] }, + { "category": "objects", "char": "📪", "name": "mailbox_closed", "keywords": ["email", "communication", "inbox"] }, + { "category": "objects", "char": "📫", "name": "mailbox", "keywords": ["email", "inbox", "communication"] }, + { "category": "objects", "char": "📬", "name": "mailbox_with_mail", "keywords": ["email", "inbox", "communication"] }, + { "category": "objects", "char": "📭", "name": "mailbox_with_no_mail", "keywords": ["email", "inbox"] }, + { "category": "objects", "char": "📦", "name": "package", "keywords": ["mail", "gift", "cardboard", "box", "moving"] }, + { "category": "objects", "char": "📯", "name": "postal_horn", "keywords": ["instrument", "music"] }, + { "category": "objects", "char": "📥", "name": "inbox_tray", "keywords": ["email", "documents"] }, + { "category": "objects", "char": "📤", "name": "outbox_tray", "keywords": ["inbox", "email"] }, + { "category": "objects", "char": "📜", "name": "scroll", "keywords": ["documents", "ancient", "history", "paper"] }, + { "category": "objects", "char": "📃", "name": "page_with_curl", "keywords": ["documents", "office", "paper"] }, + { "category": "objects", "char": "📑", "name": "bookmark_tabs", "keywords": ["favorite", "save", "order", "tidy"] }, + { "category": "objects", "char": "🧾", "name": "receipt", "keywords": ["accounting", "expenses"] }, + { "category": "objects", "char": "📊", "name": "bar_chart", "keywords": ["graph", "presentation", "stats"] }, + { "category": "objects", "char": "📈", "name": "chart_with_upwards_trend", "keywords": ["graph", "presentation", "stats", "recovery", "business", "economics", "money", "sales", "good", "success"] }, + { "category": "objects", "char": "📉", "name": "chart_with_downwards_trend", "keywords": ["graph", "presentation", "stats", "recession", "business", "economics", "money", "sales", "bad", "failure"] }, + { "category": "objects", "char": "📄", "name": "page_facing_up", "keywords": ["documents", "office", "paper", "information"] }, + { "category": "objects", "char": "📅", "name": "date", "keywords": ["calendar", "schedule"] }, + { "category": "objects", "char": "📆", "name": "calendar", "keywords": ["schedule", "date", "planning"] }, + { "category": "objects", "char": "🗓", "name": "spiral_calendar", "keywords": ["date", "schedule", "planning"] }, + { "category": "objects", "char": "📇", "name": "card_index", "keywords": ["business", "stationery"] }, + { "category": "objects", "char": "🗃", "name": "card_file_box", "keywords": ["business", "stationery"] }, + { "category": "objects", "char": "🗳", "name": "ballot_box", "keywords": ["election", "vote"] }, + { "category": "objects", "char": "🗄", "name": "file_cabinet", "keywords": ["filing", "organizing"] }, + { "category": "objects", "char": "📋", "name": "clipboard", "keywords": ["stationery", "documents"] }, + { "category": "objects", "char": "🗒", "name": "spiral_notepad", "keywords": ["memo", "stationery"] }, + { "category": "objects", "char": "📁", "name": "file_folder", "keywords": ["documents", "business", "office"] }, + { "category": "objects", "char": "📂", "name": "open_file_folder", "keywords": ["documents", "load"] }, + { "category": "objects", "char": "🗂", "name": "card_index_dividers", "keywords": ["organizing", "business", "stationery"] }, + { "category": "objects", "char": "🗞", "name": "newspaper_roll", "keywords": ["press", "headline"] }, + { "category": "objects", "char": "📰", "name": "newspaper", "keywords": ["press", "headline"] }, + { "category": "objects", "char": "📓", "name": "notebook", "keywords": ["stationery", "record", "notes", "paper", "study"] }, + { "category": "objects", "char": "📕", "name": "closed_book", "keywords": ["read", "library", "knowledge", "textbook", "learn"] }, + { "category": "objects", "char": "📗", "name": "green_book", "keywords": ["read", "library", "knowledge", "study"] }, + { "category": "objects", "char": "📘", "name": "blue_book", "keywords": ["read", "library", "knowledge", "learn", "study"] }, + { "category": "objects", "char": "📙", "name": "orange_book", "keywords": ["read", "library", "knowledge", "textbook", "study"] }, + { "category": "objects", "char": "📔", "name": "notebook_with_decorative_cover", "keywords": ["classroom", "notes", "record", "paper", "study"] }, + { "category": "objects", "char": "📒", "name": "ledger", "keywords": ["notes", "paper"] }, + { "category": "objects", "char": "📚", "name": "books", "keywords": ["literature", "library", "study"] }, + { "category": "objects", "char": "📖", "name": "open_book", "keywords": ["book", "read", "library", "knowledge", "literature", "learn", "study"] }, + { "category": "objects", "char": "🧷", "name": "safety_pin", "keywords": ["diaper"] }, + { "category": "objects", "char": "🔗", "name": "link", "keywords": ["rings", "url"] }, + { "category": "objects", "char": "📎", "name": "paperclip", "keywords": ["documents", "stationery"] }, + { "category": "objects", "char": "🖇", "name": "paperclips", "keywords": ["documents", "stationery"] }, + { "category": "objects", "char": "✂️", "name": "scissors", "keywords": ["stationery", "cut"] }, + { "category": "objects", "char": "📐", "name": "triangular_ruler", "keywords": ["stationery", "math", "architect", "sketch"] }, + { "category": "objects", "char": "📏", "name": "straight_ruler", "keywords": ["stationery", "calculate", "length", "math", "school", "drawing", "architect", "sketch"] }, + { "category": "objects", "char": "🧮", "name": "abacus", "keywords": ["calculation"] }, + { "category": "objects", "char": "📌", "name": "pushpin", "keywords": ["stationery", "mark", "here"] }, + { "category": "objects", "char": "📍", "name": "round_pushpin", "keywords": ["stationery", "location", "map", "here"] }, + { "category": "objects", "char": "🚩", "name": "triangular_flag_on_post", "keywords": ["mark", "milestone", "place"] }, + { "category": "objects", "char": "🏳", "name": "white_flag", "keywords": ["losing", "loser", "lost", "surrender", "give up", "fail"] }, + { "category": "objects", "char": "🏴", "name": "black_flag", "keywords": ["pirate"] }, + { "category": "objects", "char": "🏳️‍🌈", "name": "rainbow_flag", "keywords": ["flag", "rainbow", "pride", "gay", "lgbt", "glbt", "queer", "homosexual", "lesbian", "bisexual", "transgender"] }, + { "category": "objects", "char": "🏳️‍⚧️", "name": "transgender_flag", "keywords": ["flag", "transgender"] }, + { "category": "objects", "char": "🔐", "name": "closed_lock_with_key", "keywords": ["security", "privacy"] }, + { "category": "objects", "char": "🔒", "name": "lock", "keywords": ["security", "password", "padlock"] }, + { "category": "objects", "char": "🔓", "name": "unlock", "keywords": ["privacy", "security"] }, + { "category": "objects", "char": "🔏", "name": "lock_with_ink_pen", "keywords": ["security", "secret"] }, + { "category": "objects", "char": "🖊", "name": "pen", "keywords": ["stationery", "writing", "write"] }, + { "category": "objects", "char": "🖋", "name": "fountain_pen", "keywords": ["stationery", "writing", "write"] }, + { "category": "objects", "char": "✒️", "name": "black_nib", "keywords": ["pen", "stationery", "writing", "write"] }, + { "category": "objects", "char": "📝", "name": "memo", "keywords": ["write", "documents", "stationery", "pencil", "paper", "writing", "legal", "exam", "quiz", "test", "study", "compose"] }, + { "category": "objects", "char": "✏️", "name": "pencil2", "keywords": ["stationery", "write", "paper", "writing", "school", "study"] }, + { "category": "objects", "char": "🖍", "name": "crayon", "keywords": ["drawing", "creativity"] }, + { "category": "objects", "char": "🖌", "name": "paintbrush", "keywords": ["drawing", "creativity", "art"] }, + { "category": "objects", "char": "🔍", "name": "mag", "keywords": ["search", "zoom", "find", "detective"] }, + { "category": "objects", "char": "🔎", "name": "mag_right", "keywords": ["search", "zoom", "find", "detective"] }, + { "category": "objects", "char": "🪦", "name": "headstone", "keywords": [] }, + { "category": "objects", "char": "🪧", "name": "placard", "keywords": [] }, + { "category": "symbols", "char": "💯", "name": "100", "keywords": ["score", "perfect", "numbers", "century", "exam", "quiz", "test", "pass", "hundred"] }, + { "category": "symbols", "char": "🔢", "name": "1234", "keywords": ["numbers", "blue-square"] }, + { "category": "symbols", "char": "❤️", "name": "heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🧡", "name": "orange_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💛", "name": "yellow_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💚", "name": "green_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💙", "name": "blue_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💜", "name": "purple_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🤎", "name": "brown_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🖤", "name": "black_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "🤍", "name": "white_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💔", "name": "broken_heart", "keywords": ["sad", "sorry", "break", "heart", "heartbreak"] }, + { "category": "symbols", "char": "❣", "name": "heavy_heart_exclamation", "keywords": ["decoration", "love"] }, + { "category": "symbols", "char": "💕", "name": "two_hearts", "keywords": ["love", "like", "affection", "valentines", "heart"] }, + { "category": "symbols", "char": "💞", "name": "revolving_hearts", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💓", "name": "heartbeat", "keywords": ["love", "like", "affection", "valentines", "pink", "heart"] }, + { "category": "symbols", "char": "💗", "name": "heartpulse", "keywords": ["like", "love", "affection", "valentines", "pink"] }, + { "category": "symbols", "char": "💖", "name": "sparkling_heart", "keywords": ["love", "like", "affection", "valentines"] }, + { "category": "symbols", "char": "💘", "name": "cupid", "keywords": ["love", "like", "heart", "affection", "valentines"] }, + { "category": "symbols", "char": "💝", "name": "gift_heart", "keywords": ["love", "valentines"] }, + { "category": "symbols", "char": "💟", "name": "heart_decoration", "keywords": ["purple-square", "love", "like"] }, + { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83D\uDD25", "name": "heart_on_fire", "keywords": [] }, + { "category": "symbols", "char": "\u2764\uFE0F\u200D\uD83E\uDE79", "name": "mending_heart", "keywords": [] }, + { "category": "symbols", "char": "☮", "name": "peace_symbol", "keywords": ["hippie"] }, + { "category": "symbols", "char": "✝", "name": "latin_cross", "keywords": ["christianity"] }, + { "category": "symbols", "char": "☪", "name": "star_and_crescent", "keywords": ["islam"] }, + { "category": "symbols", "char": "🕉", "name": "om", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, + { "category": "symbols", "char": "☸", "name": "wheel_of_dharma", "keywords": ["hinduism", "buddhism", "sikhism", "jainism"] }, + { "category": "symbols", "char": "✡", "name": "star_of_david", "keywords": ["judaism"] }, + { "category": "symbols", "char": "🔯", "name": "six_pointed_star", "keywords": ["purple-square", "religion", "jewish", "hexagram"] }, + { "category": "symbols", "char": "🕎", "name": "menorah", "keywords": ["hanukkah", "candles", "jewish"] }, + { "category": "symbols", "char": "☯", "name": "yin_yang", "keywords": ["balance"] }, + { "category": "symbols", "char": "☦", "name": "orthodox_cross", "keywords": ["suppedaneum", "religion"] }, + { "category": "symbols", "char": "🛐", "name": "place_of_worship", "keywords": ["religion", "church", "temple", "prayer"] }, + { "category": "symbols", "char": "⛎", "name": "ophiuchus", "keywords": ["sign", "purple-square", "constellation", "astrology"] }, + { "category": "symbols", "char": "♈", "name": "aries", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♉", "name": "taurus", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♊", "name": "gemini", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♋", "name": "cancer", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♌", "name": "leo", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♍", "name": "virgo", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♎", "name": "libra", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♏", "name": "scorpius", "keywords": ["sign", "zodiac", "purple-square", "astrology", "scorpio"] }, + { "category": "symbols", "char": "♐", "name": "sagittarius", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♑", "name": "capricorn", "keywords": ["sign", "zodiac", "purple-square", "astrology"] }, + { "category": "symbols", "char": "♒", "name": "aquarius", "keywords": ["sign", "purple-square", "zodiac", "astrology"] }, + { "category": "symbols", "char": "♓", "name": "pisces", "keywords": ["purple-square", "sign", "zodiac", "astrology"] }, + { "category": "symbols", "char": "🆔", "name": "id", "keywords": ["purple-square", "words"] }, + { "category": "symbols", "char": "⚛", "name": "atom_symbol", "keywords": ["science", "physics", "chemistry"] }, + { "category": "symbols", "char": "⚧️", "name": "transgender_symbol", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, + { "category": "symbols", "char": "🈳", "name": "u7a7a", "keywords": ["kanji", "japanese", "chinese", "empty", "sky", "blue-square", "aki"] }, + { "category": "symbols", "char": "🈹", "name": "u5272", "keywords": ["cut", "divide", "chinese", "kanji", "pink-square", "waribiki"] }, + { "category": "symbols", "char": "☢", "name": "radioactive", "keywords": ["nuclear", "danger"] }, + { "category": "symbols", "char": "☣", "name": "biohazard", "keywords": ["danger"] }, + { "category": "symbols", "char": "📴", "name": "mobile_phone_off", "keywords": ["mute", "orange-square", "silence", "quiet"] }, + { "category": "symbols", "char": "📳", "name": "vibration_mode", "keywords": ["orange-square", "phone"] }, + { "category": "symbols", "char": "🈶", "name": "u6709", "keywords": ["orange-square", "chinese", "have", "kanji", "ari"] }, + { "category": "symbols", "char": "🈚", "name": "u7121", "keywords": ["nothing", "chinese", "kanji", "japanese", "orange-square", "nashi"] }, + { "category": "symbols", "char": "🈸", "name": "u7533", "keywords": ["chinese", "japanese", "kanji", "orange-square", "moushikomi"] }, + { "category": "symbols", "char": "🈺", "name": "u55b6", "keywords": ["japanese", "opening hours", "orange-square", "eigyo"] }, + { "category": "symbols", "char": "🈷️", "name": "u6708", "keywords": ["chinese", "month", "moon", "japanese", "orange-square", "kanji", "tsuki", "tsukigime", "getsugaku"] }, + { "category": "symbols", "char": "✴️", "name": "eight_pointed_black_star", "keywords": ["orange-square", "shape", "polygon"] }, + { "category": "symbols", "char": "🆚", "name": "vs", "keywords": ["words", "orange-square"] }, + { "category": "symbols", "char": "🉑", "name": "accept", "keywords": ["ok", "good", "chinese", "kanji", "agree", "yes", "orange-circle"] }, + { "category": "symbols", "char": "💮", "name": "white_flower", "keywords": ["japanese", "spring"] }, + { "category": "symbols", "char": "🉐", "name": "ideograph_advantage", "keywords": ["chinese", "kanji", "obtain", "get", "circle"] }, + { "category": "symbols", "char": "㊙️", "name": "secret", "keywords": ["privacy", "chinese", "sshh", "kanji", "red-circle"] }, + { "category": "symbols", "char": "㊗️", "name": "congratulations", "keywords": ["chinese", "kanji", "japanese", "red-circle"] }, + { "category": "symbols", "char": "🈴", "name": "u5408", "keywords": ["japanese", "chinese", "join", "kanji", "red-square", "goukaku", "pass"] }, + { "category": "symbols", "char": "🈵", "name": "u6e80", "keywords": ["full", "chinese", "japanese", "red-square", "kanji", "man"] }, + { "category": "symbols", "char": "🈲", "name": "u7981", "keywords": ["kanji", "japanese", "chinese", "forbidden", "limit", "restricted", "red-square", "kinshi"] }, + { "category": "symbols", "char": "🅰️", "name": "a", "keywords": ["red-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🅱️", "name": "b", "keywords": ["red-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🆎", "name": "ab", "keywords": ["red-square", "alphabet"] }, + { "category": "symbols", "char": "🆑", "name": "cl", "keywords": ["alphabet", "words", "red-square"] }, + { "category": "symbols", "char": "🅾️", "name": "o2", "keywords": ["alphabet", "red-square", "letter"] }, + { "category": "symbols", "char": "🆘", "name": "sos", "keywords": ["help", "red-square", "words", "emergency", "911"] }, + { "category": "symbols", "char": "⛔", "name": "no_entry", "keywords": ["limit", "security", "privacy", "bad", "denied", "stop", "circle"] }, + { "category": "symbols", "char": "📛", "name": "name_badge", "keywords": ["fire", "forbid"] }, + { "category": "symbols", "char": "🚫", "name": "no_entry_sign", "keywords": ["forbid", "stop", "limit", "denied", "disallow", "circle"] }, + { "category": "symbols", "char": "❌", "name": "x", "keywords": ["no", "delete", "remove", "cancel", "red"] }, + { "category": "symbols", "char": "⭕", "name": "o", "keywords": ["circle", "round"] }, + { "category": "symbols", "char": "🛑", "name": "stop_sign", "keywords": ["stop"] }, + { "category": "symbols", "char": "💢", "name": "anger", "keywords": ["angry", "mad"] }, + { "category": "symbols", "char": "♨️", "name": "hotsprings", "keywords": ["bath", "warm", "relax"] }, + { "category": "symbols", "char": "🚷", "name": "no_pedestrians", "keywords": ["rules", "crossing", "walking", "circle"] }, + { "category": "symbols", "char": "🚯", "name": "do_not_litter", "keywords": ["trash", "bin", "garbage", "circle"] }, + { "category": "symbols", "char": "🚳", "name": "no_bicycles", "keywords": ["cyclist", "prohibited", "circle"] }, + { "category": "symbols", "char": "🚱", "name": "non-potable_water", "keywords": ["drink", "faucet", "tap", "circle"] }, + { "category": "symbols", "char": "🔞", "name": "underage", "keywords": ["18", "drink", "pub", "night", "minor", "circle"] }, + { "category": "symbols", "char": "📵", "name": "no_mobile_phones", "keywords": ["iphone", "mute", "circle"] }, + { "category": "symbols", "char": "❗", "name": "exclamation", "keywords": ["heavy_exclamation_mark", "danger", "surprise", "punctuation", "wow", "warning"] }, + { "category": "symbols", "char": "❕", "name": "grey_exclamation", "keywords": ["surprise", "punctuation", "gray", "wow", "warning"] }, + { "category": "symbols", "char": "❓", "name": "question", "keywords": ["doubt", "confused"] }, + { "category": "symbols", "char": "❔", "name": "grey_question", "keywords": ["doubts", "gray", "huh", "confused"] }, + { "category": "symbols", "char": "‼️", "name": "bangbang", "keywords": ["exclamation", "surprise"] }, + { "category": "symbols", "char": "⁉️", "name": "interrobang", "keywords": ["wat", "punctuation", "surprise"] }, + { "category": "symbols", "char": "🔅", "name": "low_brightness", "keywords": ["sun", "afternoon", "warm", "summer"] }, + { "category": "symbols", "char": "🔆", "name": "high_brightness", "keywords": ["sun", "light"] }, + { "category": "symbols", "char": "🔱", "name": "trident", "keywords": ["weapon", "spear"] }, + { "category": "symbols", "char": "⚜", "name": "fleur_de_lis", "keywords": ["decorative", "scout"] }, + { "category": "symbols", "char": "〽️", "name": "part_alternation_mark", "keywords": ["graph", "presentation", "stats", "business", "economics", "bad"] }, + { "category": "symbols", "char": "⚠️", "name": "warning", "keywords": ["exclamation", "wip", "alert", "error", "problem", "issue"] }, + { "category": "symbols", "char": "🚸", "name": "children_crossing", "keywords": ["school", "warning", "danger", "sign", "driving", "yellow-diamond"] }, + { "category": "symbols", "char": "🔰", "name": "beginner", "keywords": ["badge", "shield"] }, + { "category": "symbols", "char": "♻️", "name": "recycle", "keywords": ["arrow", "environment", "garbage", "trash"] }, + { "category": "symbols", "char": "🈯", "name": "u6307", "keywords": ["chinese", "point", "green-square", "kanji", "reserved", "shiteiseki"] }, + { "category": "symbols", "char": "💹", "name": "chart", "keywords": ["green-square", "graph", "presentation", "stats"] }, + { "category": "symbols", "char": "❇️", "name": "sparkle", "keywords": ["stars", "green-square", "awesome", "good", "fireworks"] }, + { "category": "symbols", "char": "✳️", "name": "eight_spoked_asterisk", "keywords": ["star", "sparkle", "green-square"] }, + { "category": "symbols", "char": "❎", "name": "negative_squared_cross_mark", "keywords": ["x", "green-square", "no", "deny"] }, + { "category": "symbols", "char": "✅", "name": "white_check_mark", "keywords": ["green-square", "ok", "agree", "vote", "election", "answer", "tick"] }, + { "category": "symbols", "char": "💠", "name": "diamond_shape_with_a_dot_inside", "keywords": ["jewel", "blue", "gem", "crystal", "fancy"] }, + { "category": "symbols", "char": "🌀", "name": "cyclone", "keywords": ["weather", "swirl", "blue", "cloud", "vortex", "spiral", "whirlpool", "spin", "tornado", "hurricane", "typhoon"] }, + { "category": "symbols", "char": "➿", "name": "loop", "keywords": ["tape", "cassette"] }, + { "category": "symbols", "char": "🌐", "name": "globe_with_meridians", "keywords": ["earth", "international", "world", "internet", "interweb", "i18n"] }, + { "category": "symbols", "char": "Ⓜ️", "name": "m", "keywords": ["alphabet", "blue-circle", "letter"] }, + { "category": "symbols", "char": "🏧", "name": "atm", "keywords": ["money", "sales", "cash", "blue-square", "payment", "bank"] }, + { "category": "symbols", "char": "🈂️", "name": "sa", "keywords": ["japanese", "blue-square", "katakana"] }, + { "category": "symbols", "char": "🛂", "name": "passport_control", "keywords": ["custom", "blue-square"] }, + { "category": "symbols", "char": "🛃", "name": "customs", "keywords": ["passport", "border", "blue-square"] }, + { "category": "symbols", "char": "🛄", "name": "baggage_claim", "keywords": ["blue-square", "airport", "transport"] }, + { "category": "symbols", "char": "🛅", "name": "left_luggage", "keywords": ["blue-square", "travel"] }, + { "category": "symbols", "char": "♿", "name": "wheelchair", "keywords": ["blue-square", "disabled", "a11y", "accessibility"] }, + { "category": "symbols", "char": "🚭", "name": "no_smoking", "keywords": ["cigarette", "blue-square", "smell", "smoke"] }, + { "category": "symbols", "char": "🚾", "name": "wc", "keywords": ["toilet", "restroom", "blue-square"] }, + { "category": "symbols", "char": "🅿️", "name": "parking", "keywords": ["cars", "blue-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🚰", "name": "potable_water", "keywords": ["blue-square", "liquid", "restroom", "cleaning", "faucet"] }, + { "category": "symbols", "char": "🚹", "name": "mens", "keywords": ["toilet", "restroom", "wc", "blue-square", "gender", "male"] }, + { "category": "symbols", "char": "🚺", "name": "womens", "keywords": ["purple-square", "woman", "female", "toilet", "loo", "restroom", "gender"] }, + { "category": "symbols", "char": "🚼", "name": "baby_symbol", "keywords": ["orange-square", "child"] }, + { "category": "symbols", "char": "🚻", "name": "restroom", "keywords": ["blue-square", "toilet", "refresh", "wc", "gender"] }, + { "category": "symbols", "char": "🚮", "name": "put_litter_in_its_place", "keywords": ["blue-square", "sign", "human", "info"] }, + { "category": "symbols", "char": "🎦", "name": "cinema", "keywords": ["blue-square", "record", "film", "movie", "curtain", "stage", "theater"] }, + { "category": "symbols", "char": "📶", "name": "signal_strength", "keywords": ["blue-square", "reception", "phone", "internet", "connection", "wifi", "bluetooth", "bars"] }, + { "category": "symbols", "char": "🈁", "name": "koko", "keywords": ["blue-square", "here", "katakana", "japanese", "destination"] }, + { "category": "symbols", "char": "🆖", "name": "ng", "keywords": ["blue-square", "words", "shape", "icon"] }, + { "category": "symbols", "char": "🆗", "name": "ok", "keywords": ["good", "agree", "yes", "blue-square"] }, + { "category": "symbols", "char": "🆙", "name": "up", "keywords": ["blue-square", "above", "high"] }, + { "category": "symbols", "char": "🆒", "name": "cool", "keywords": ["words", "blue-square"] }, + { "category": "symbols", "char": "🆕", "name": "new", "keywords": ["blue-square", "words", "start"] }, + { "category": "symbols", "char": "🆓", "name": "free", "keywords": ["blue-square", "words"] }, + { "category": "symbols", "char": "0️⃣", "name": "zero", "keywords": ["0", "numbers", "blue-square", "null"] }, + { "category": "symbols", "char": "1️⃣", "name": "one", "keywords": ["blue-square", "numbers", "1"] }, + { "category": "symbols", "char": "2️⃣", "name": "two", "keywords": ["numbers", "2", "prime", "blue-square"] }, + { "category": "symbols", "char": "3️⃣", "name": "three", "keywords": ["3", "numbers", "prime", "blue-square"] }, + { "category": "symbols", "char": "4️⃣", "name": "four", "keywords": ["4", "numbers", "blue-square"] }, + { "category": "symbols", "char": "5️⃣", "name": "five", "keywords": ["5", "numbers", "blue-square", "prime"] }, + { "category": "symbols", "char": "6️⃣", "name": "six", "keywords": ["6", "numbers", "blue-square"] }, + { "category": "symbols", "char": "7️⃣", "name": "seven", "keywords": ["7", "numbers", "blue-square", "prime"] }, + { "category": "symbols", "char": "8️⃣", "name": "eight", "keywords": ["8", "blue-square", "numbers"] }, + { "category": "symbols", "char": "9️⃣", "name": "nine", "keywords": ["blue-square", "numbers", "9"] }, + { "category": "symbols", "char": "🔟", "name": "keycap_ten", "keywords": ["numbers", "10", "blue-square"] }, + { "category": "symbols", "char": "*⃣", "name": "asterisk", "keywords": ["star", "keycap"] }, + { "category": "symbols", "char": "⏏️", "name": "eject_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "▶️", "name": "arrow_forward", "keywords": ["blue-square", "right", "direction", "play"] }, + { "category": "symbols", "char": "⏸", "name": "pause_button", "keywords": ["pause", "blue-square"] }, + { "category": "symbols", "char": "⏭", "name": "next_track_button", "keywords": ["forward", "next", "blue-square"] }, + { "category": "symbols", "char": "⏹", "name": "stop_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "⏺", "name": "record_button", "keywords": ["blue-square"] }, + { "category": "symbols", "char": "⏯", "name": "play_or_pause_button", "keywords": ["blue-square", "play", "pause"] }, + { "category": "symbols", "char": "⏮", "name": "previous_track_button", "keywords": ["backward"] }, + { "category": "symbols", "char": "⏩", "name": "fast_forward", "keywords": ["blue-square", "play", "speed", "continue"] }, + { "category": "symbols", "char": "⏪", "name": "rewind", "keywords": ["play", "blue-square"] }, + { "category": "symbols", "char": "🔀", "name": "twisted_rightwards_arrows", "keywords": ["blue-square", "shuffle", "music", "random"] }, + { "category": "symbols", "char": "🔁", "name": "repeat", "keywords": ["loop", "record"] }, + { "category": "symbols", "char": "🔂", "name": "repeat_one", "keywords": ["blue-square", "loop"] }, + { "category": "symbols", "char": "◀️", "name": "arrow_backward", "keywords": ["blue-square", "left", "direction"] }, + { "category": "symbols", "char": "🔼", "name": "arrow_up_small", "keywords": ["blue-square", "triangle", "direction", "point", "forward", "top"] }, + { "category": "symbols", "char": "🔽", "name": "arrow_down_small", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "⏫", "name": "arrow_double_up", "keywords": ["blue-square", "direction", "top"] }, + { "category": "symbols", "char": "⏬", "name": "arrow_double_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "➡️", "name": "arrow_right", "keywords": ["blue-square", "next"] }, + { "category": "symbols", "char": "⬅️", "name": "arrow_left", "keywords": ["blue-square", "previous", "back"] }, + { "category": "symbols", "char": "⬆️", "name": "arrow_up", "keywords": ["blue-square", "continue", "top", "direction"] }, + { "category": "symbols", "char": "⬇️", "name": "arrow_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "↗️", "name": "arrow_upper_right", "keywords": ["blue-square", "point", "direction", "diagonal", "northeast"] }, + { "category": "symbols", "char": "↘️", "name": "arrow_lower_right", "keywords": ["blue-square", "direction", "diagonal", "southeast"] }, + { "category": "symbols", "char": "↙️", "name": "arrow_lower_left", "keywords": ["blue-square", "direction", "diagonal", "southwest"] }, + { "category": "symbols", "char": "↖️", "name": "arrow_upper_left", "keywords": ["blue-square", "point", "direction", "diagonal", "northwest"] }, + { "category": "symbols", "char": "↕️", "name": "arrow_up_down", "keywords": ["blue-square", "direction", "way", "vertical"] }, + { "category": "symbols", "char": "↔️", "name": "left_right_arrow", "keywords": ["shape", "direction", "horizontal", "sideways"] }, + { "category": "symbols", "char": "🔄", "name": "arrows_counterclockwise", "keywords": ["blue-square", "sync", "cycle"] }, + { "category": "symbols", "char": "↪️", "name": "arrow_right_hook", "keywords": ["blue-square", "return", "rotate", "direction"] }, + { "category": "symbols", "char": "↩️", "name": "leftwards_arrow_with_hook", "keywords": ["back", "return", "blue-square", "undo", "enter"] }, + { "category": "symbols", "char": "⤴️", "name": "arrow_heading_up", "keywords": ["blue-square", "direction", "top"] }, + { "category": "symbols", "char": "⤵️", "name": "arrow_heading_down", "keywords": ["blue-square", "direction", "bottom"] }, + { "category": "symbols", "char": "#️⃣", "name": "hash", "keywords": ["symbol", "blue-square", "twitter"] }, + { "category": "symbols", "char": "ℹ️", "name": "information_source", "keywords": ["blue-square", "alphabet", "letter"] }, + { "category": "symbols", "char": "🔤", "name": "abc", "keywords": ["blue-square", "alphabet"] }, + { "category": "symbols", "char": "🔡", "name": "abcd", "keywords": ["blue-square", "alphabet"] }, + { "category": "symbols", "char": "🔠", "name": "capital_abcd", "keywords": ["alphabet", "words", "blue-square"] }, + { "category": "symbols", "char": "🔣", "name": "symbols", "keywords": ["blue-square", "music", "note", "ampersand", "percent", "glyphs", "characters"] }, + { "category": "symbols", "char": "🎵", "name": "musical_note", "keywords": ["score", "tone", "sound"] }, + { "category": "symbols", "char": "🎶", "name": "notes", "keywords": ["music", "score"] }, + { "category": "symbols", "char": "〰️", "name": "wavy_dash", "keywords": ["draw", "line", "moustache", "mustache", "squiggle", "scribble"] }, + { "category": "symbols", "char": "➰", "name": "curly_loop", "keywords": ["scribble", "draw", "shape", "squiggle"] }, + { "category": "symbols", "char": "✔️", "name": "heavy_check_mark", "keywords": ["ok", "nike", "answer", "yes", "tick"] }, + { "category": "symbols", "char": "🔃", "name": "arrows_clockwise", "keywords": ["sync", "cycle", "round", "repeat"] }, + { "category": "symbols", "char": "➕", "name": "heavy_plus_sign", "keywords": ["math", "calculation", "addition", "more", "increase"] }, + { "category": "symbols", "char": "➖", "name": "heavy_minus_sign", "keywords": ["math", "calculation", "subtract", "less"] }, + { "category": "symbols", "char": "➗", "name": "heavy_division_sign", "keywords": ["divide", "math", "calculation"] }, + { "category": "symbols", "char": "✖️", "name": "heavy_multiplication_x", "keywords": ["math", "calculation"] }, + { "category": "symbols", "char": "\uD83D\uDFF0", "name": "heavy_equals_sign", "keywords": [] }, + { "category": "symbols", "char": "♾", "name": "infinity", "keywords": ["forever"] }, + { "category": "symbols", "char": "💲", "name": "heavy_dollar_sign", "keywords": ["money", "sales", "payment", "currency", "buck"] }, + { "category": "symbols", "char": "💱", "name": "currency_exchange", "keywords": ["money", "sales", "dollar", "travel"] }, + { "category": "symbols", "char": "©️", "name": "copyright", "keywords": ["ip", "license", "circle", "law", "legal"] }, + { "category": "symbols", "char": "®️", "name": "registered", "keywords": ["alphabet", "circle"] }, + { "category": "symbols", "char": "™️", "name": "tm", "keywords": ["trademark", "brand", "law", "legal"] }, + { "category": "symbols", "char": "🔚", "name": "end", "keywords": ["words", "arrow"] }, + { "category": "symbols", "char": "🔙", "name": "back", "keywords": ["arrow", "words", "return"] }, + { "category": "symbols", "char": "🔛", "name": "on", "keywords": ["arrow", "words"] }, + { "category": "symbols", "char": "🔝", "name": "top", "keywords": ["words", "blue-square"] }, + { "category": "symbols", "char": "🔜", "name": "soon", "keywords": ["arrow", "words"] }, + { "category": "symbols", "char": "☑️", "name": "ballot_box_with_check", "keywords": ["ok", "agree", "confirm", "black-square", "vote", "election", "yes", "tick"] }, + { "category": "symbols", "char": "🔘", "name": "radio_button", "keywords": ["input", "old", "music", "circle"] }, + { "category": "symbols", "char": "⚫", "name": "black_circle", "keywords": ["shape", "button", "round"] }, + { "category": "symbols", "char": "⚪", "name": "white_circle", "keywords": ["shape", "round"] }, + { "category": "symbols", "char": "🔴", "name": "red_circle", "keywords": ["shape", "error", "danger"] }, + { "category": "symbols", "char": "🟠", "name": "orange_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟡", "name": "yellow_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟢", "name": "green_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔵", "name": "large_blue_circle", "keywords": ["shape", "icon", "button"] }, + { "category": "symbols", "char": "🟣", "name": "purple_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟤", "name": "brown_circle", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔸", "name": "small_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔹", "name": "small_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔶", "name": "large_orange_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔷", "name": "large_blue_diamond", "keywords": ["shape", "jewel", "gem"] }, + { "category": "symbols", "char": "🔺", "name": "small_red_triangle", "keywords": ["shape", "direction", "up", "top"] }, + { "category": "symbols", "char": "▪️", "name": "black_small_square", "keywords": ["shape", "icon"] }, + { "category": "symbols", "char": "▫️", "name": "white_small_square", "keywords": ["shape", "icon"] }, + { "category": "symbols", "char": "⬛", "name": "black_large_square", "keywords": ["shape", "icon", "button"] }, + { "category": "symbols", "char": "⬜", "name": "white_large_square", "keywords": ["shape", "icon", "stone", "button"] }, + { "category": "symbols", "char": "🟥", "name": "red_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟧", "name": "orange_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟨", "name": "yellow_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟩", "name": "green_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟦", "name": "blue_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟪", "name": "purple_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🟫", "name": "brown_square", "keywords": ["shape"] }, + { "category": "symbols", "char": "🔻", "name": "small_red_triangle_down", "keywords": ["shape", "direction", "bottom"] }, + { "category": "symbols", "char": "◼️", "name": "black_medium_square", "keywords": ["shape", "button", "icon"] }, + { "category": "symbols", "char": "◻️", "name": "white_medium_square", "keywords": ["shape", "stone", "icon"] }, + { "category": "symbols", "char": "◾", "name": "black_medium_small_square", "keywords": ["icon", "shape", "button"] }, + { "category": "symbols", "char": "◽", "name": "white_medium_small_square", "keywords": ["shape", "stone", "icon", "button"] }, + { "category": "symbols", "char": "🔲", "name": "black_square_button", "keywords": ["shape", "input", "frame"] }, + { "category": "symbols", "char": "🔳", "name": "white_square_button", "keywords": ["shape", "input"] }, + { "category": "symbols", "char": "🔈", "name": "speaker", "keywords": ["sound", "volume", "silence", "broadcast"] }, + { "category": "symbols", "char": "🔉", "name": "sound", "keywords": ["volume", "speaker", "broadcast"] }, + { "category": "symbols", "char": "🔊", "name": "loud_sound", "keywords": ["volume", "noise", "noisy", "speaker", "broadcast"] }, + { "category": "symbols", "char": "🔇", "name": "mute", "keywords": ["sound", "volume", "silence", "quiet"] }, + { "category": "symbols", "char": "📣", "name": "mega", "keywords": ["sound", "speaker", "volume"] }, + { "category": "symbols", "char": "📢", "name": "loudspeaker", "keywords": ["volume", "sound"] }, + { "category": "symbols", "char": "🔔", "name": "bell", "keywords": ["sound", "notification", "christmas", "xmas", "chime"] }, + { "category": "symbols", "char": "🔕", "name": "no_bell", "keywords": ["sound", "volume", "mute", "quiet", "silent"] }, + { "category": "symbols", "char": "🃏", "name": "black_joker", "keywords": ["poker", "cards", "game", "play", "magic"] }, + { "category": "symbols", "char": "🀄", "name": "mahjong", "keywords": ["game", "play", "chinese", "kanji"] }, + { "category": "symbols", "char": "♠️", "name": "spades", "keywords": ["poker", "cards", "suits", "magic"] }, + { "category": "symbols", "char": "♣️", "name": "clubs", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "♥️", "name": "hearts", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "♦️", "name": "diamonds", "keywords": ["poker", "cards", "magic", "suits"] }, + { "category": "symbols", "char": "🎴", "name": "flower_playing_cards", "keywords": ["game", "sunset", "red"] }, + { "category": "symbols", "char": "💭", "name": "thought_balloon", "keywords": ["bubble", "cloud", "speech", "thinking", "dream"] }, + { "category": "symbols", "char": "🗯", "name": "right_anger_bubble", "keywords": ["caption", "speech", "thinking", "mad"] }, + { "category": "symbols", "char": "💬", "name": "speech_balloon", "keywords": ["bubble", "words", "message", "talk", "chatting"] }, + { "category": "symbols", "char": "🗨", "name": "left_speech_bubble", "keywords": ["words", "message", "talk", "chatting"] }, + { "category": "symbols", "char": "🕐", "name": "clock1", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕑", "name": "clock2", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕒", "name": "clock3", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕓", "name": "clock4", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕔", "name": "clock5", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕕", "name": "clock6", "keywords": ["time", "late", "early", "schedule", "dawn", "dusk"] }, + { "category": "symbols", "char": "🕖", "name": "clock7", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕗", "name": "clock8", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕘", "name": "clock9", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕙", "name": "clock10", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕚", "name": "clock11", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕛", "name": "clock12", "keywords": ["time", "noon", "midnight", "midday", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕜", "name": "clock130", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕝", "name": "clock230", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕞", "name": "clock330", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕟", "name": "clock430", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕠", "name": "clock530", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕡", "name": "clock630", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕢", "name": "clock730", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕣", "name": "clock830", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕤", "name": "clock930", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕥", "name": "clock1030", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕦", "name": "clock1130", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "symbols", "char": "🕧", "name": "clock1230", "keywords": ["time", "late", "early", "schedule"] }, + { "category": "flags", "char": "🇦🇫", "name": "afghanistan", "keywords": ["af", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇽", "name": "aland_islands", "keywords": ["Åland", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇱", "name": "albania", "keywords": ["al", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇿", "name": "algeria", "keywords": ["dz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇸", "name": "american_samoa", "keywords": ["american", "ws", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇩", "name": "andorra", "keywords": ["ad", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇴", "name": "angola", "keywords": ["ao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇮", "name": "anguilla", "keywords": ["ai", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇶", "name": "antarctica", "keywords": ["aq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇬", "name": "antigua_barbuda", "keywords": ["antigua", "barbuda", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇷", "name": "argentina", "keywords": ["ar", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇲", "name": "armenia", "keywords": ["am", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇼", "name": "aruba", "keywords": ["aw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇨", "name": "ascension_island", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇺", "name": "australia", "keywords": ["au", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇹", "name": "austria", "keywords": ["at", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇿", "name": "azerbaijan", "keywords": ["az", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇸", "name": "bahamas", "keywords": ["bs", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇭", "name": "bahrain", "keywords": ["bh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇩", "name": "bangladesh", "keywords": ["bd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇧", "name": "barbados", "keywords": ["bb", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇾", "name": "belarus", "keywords": ["by", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇪", "name": "belgium", "keywords": ["be", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇿", "name": "belize", "keywords": ["bz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇯", "name": "benin", "keywords": ["bj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇲", "name": "bermuda", "keywords": ["bm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇹", "name": "bhutan", "keywords": ["bt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇴", "name": "bolivia", "keywords": ["bo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇶", "name": "caribbean_netherlands", "keywords": ["bonaire", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇦", "name": "bosnia_herzegovina", "keywords": ["bosnia", "herzegovina", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇼", "name": "botswana", "keywords": ["bw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇷", "name": "brazil", "keywords": ["br", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇴", "name": "british_indian_ocean_territory", "keywords": ["british", "indian", "ocean", "territory", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇬", "name": "british_virgin_islands", "keywords": ["british", "virgin", "islands", "bvi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇳", "name": "brunei", "keywords": ["bn", "darussalam", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇬", "name": "bulgaria", "keywords": ["bg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇫", "name": "burkina_faso", "keywords": ["burkina", "faso", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇮", "name": "burundi", "keywords": ["bi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇻", "name": "cape_verde", "keywords": ["cabo", "verde", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇭", "name": "cambodia", "keywords": ["kh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇲", "name": "cameroon", "keywords": ["cm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇦", "name": "canada", "keywords": ["ca", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇨", "name": "canary_islands", "keywords": ["canary", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇾", "name": "cayman_islands", "keywords": ["cayman", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇫", "name": "central_african_republic", "keywords": ["central", "african", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇩", "name": "chad", "keywords": ["td", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇱", "name": "chile", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇳", "name": "cn", "keywords": ["china", "chinese", "prc", "flag", "country", "nation", "banner"] }, + { "category": "flags", "char": "🇨🇽", "name": "christmas_island", "keywords": ["christmas", "island", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇨", "name": "cocos_islands", "keywords": ["cocos", "keeling", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇴", "name": "colombia", "keywords": ["co", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇲", "name": "comoros", "keywords": ["km", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇬", "name": "congo_brazzaville", "keywords": ["congo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇩", "name": "congo_kinshasa", "keywords": ["congo", "democratic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇰", "name": "cook_islands", "keywords": ["cook", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇷", "name": "costa_rica", "keywords": ["costa", "rica", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇷", "name": "croatia", "keywords": ["hr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇺", "name": "cuba", "keywords": ["cu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇼", "name": "curacao", "keywords": ["curaçao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇾", "name": "cyprus", "keywords": ["cy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇿", "name": "czech_republic", "keywords": ["cz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇰", "name": "denmark", "keywords": ["dk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇯", "name": "djibouti", "keywords": ["dj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇲", "name": "dominica", "keywords": ["dm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇴", "name": "dominican_republic", "keywords": ["dominican", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇨", "name": "ecuador", "keywords": ["ec", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇬", "name": "egypt", "keywords": ["eg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇻", "name": "el_salvador", "keywords": ["el", "salvador", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇶", "name": "equatorial_guinea", "keywords": ["equatorial", "gn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇷", "name": "eritrea", "keywords": ["er", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇪", "name": "estonia", "keywords": ["ee", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇹", "name": "ethiopia", "keywords": ["et", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇺", "name": "eu", "keywords": ["european", "union", "flag", "banner"] }, + { "category": "flags", "char": "🇫🇰", "name": "falkland_islands", "keywords": ["falkland", "islands", "malvinas", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇴", "name": "faroe_islands", "keywords": ["faroe", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇯", "name": "fiji", "keywords": ["fj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇮", "name": "finland", "keywords": ["fi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇷", "name": "fr", "keywords": ["banner", "flag", "nation", "france", "french", "country"] }, + { "category": "flags", "char": "🇬🇫", "name": "french_guiana", "keywords": ["french", "guiana", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇫", "name": "french_polynesia", "keywords": ["french", "polynesia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇫", "name": "french_southern_territories", "keywords": ["french", "southern", "territories", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇦", "name": "gabon", "keywords": ["ga", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇲", "name": "gambia", "keywords": ["gm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇪", "name": "georgia", "keywords": ["ge", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇩🇪", "name": "de", "keywords": ["german", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇬🇭", "name": "ghana", "keywords": ["gh", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇮", "name": "gibraltar", "keywords": ["gi", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇷", "name": "greece", "keywords": ["gr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇱", "name": "greenland", "keywords": ["gl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇩", "name": "grenada", "keywords": ["gd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇵", "name": "guadeloupe", "keywords": ["gp", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇺", "name": "guam", "keywords": ["gu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇹", "name": "guatemala", "keywords": ["gt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇬", "name": "guernsey", "keywords": ["gg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇳", "name": "guinea", "keywords": ["gn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇼", "name": "guinea_bissau", "keywords": ["gw", "bissau", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇾", "name": "guyana", "keywords": ["gy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇹", "name": "haiti", "keywords": ["ht", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇳", "name": "honduras", "keywords": ["hn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇰", "name": "hong_kong", "keywords": ["hong", "kong", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇭🇺", "name": "hungary", "keywords": ["hu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇸", "name": "iceland", "keywords": ["is", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇳", "name": "india", "keywords": ["in", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇩", "name": "indonesia", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇷", "name": "iran", "keywords": ["iran, ", "islamic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇶", "name": "iraq", "keywords": ["iq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇪", "name": "ireland", "keywords": ["ie", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇲", "name": "isle_of_man", "keywords": ["isle", "man", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇱", "name": "israel", "keywords": ["il", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇮🇹", "name": "it", "keywords": ["italy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇮", "name": "cote_divoire", "keywords": ["ivory", "coast", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇲", "name": "jamaica", "keywords": ["jm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇵", "name": "jp", "keywords": ["japanese", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇯🇪", "name": "jersey", "keywords": ["je", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇯🇴", "name": "jordan", "keywords": ["jo", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇿", "name": "kazakhstan", "keywords": ["kz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇪", "name": "kenya", "keywords": ["ke", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇮", "name": "kiribati", "keywords": ["ki", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇽🇰", "name": "kosovo", "keywords": ["xk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇼", "name": "kuwait", "keywords": ["kw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇬", "name": "kyrgyzstan", "keywords": ["kg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇦", "name": "laos", "keywords": ["lao", "democratic", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇻", "name": "latvia", "keywords": ["lv", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇧", "name": "lebanon", "keywords": ["lb", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇸", "name": "lesotho", "keywords": ["ls", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇷", "name": "liberia", "keywords": ["lr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇾", "name": "libya", "keywords": ["ly", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇮", "name": "liechtenstein", "keywords": ["li", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇹", "name": "lithuania", "keywords": ["lt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇺", "name": "luxembourg", "keywords": ["lu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇴", "name": "macau", "keywords": ["macao", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇰", "name": "macedonia", "keywords": ["macedonia, ", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇬", "name": "madagascar", "keywords": ["mg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇼", "name": "malawi", "keywords": ["mw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇾", "name": "malaysia", "keywords": ["my", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇻", "name": "maldives", "keywords": ["mv", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇱", "name": "mali", "keywords": ["ml", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇹", "name": "malta", "keywords": ["mt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇭", "name": "marshall_islands", "keywords": ["marshall", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇶", "name": "martinique", "keywords": ["mq", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇷", "name": "mauritania", "keywords": ["mr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇺", "name": "mauritius", "keywords": ["mu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇾🇹", "name": "mayotte", "keywords": ["yt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇽", "name": "mexico", "keywords": ["mx", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇫🇲", "name": "micronesia", "keywords": ["micronesia, ", "federated", "states", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇩", "name": "moldova", "keywords": ["moldova, ", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇨", "name": "monaco", "keywords": ["mc", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇳", "name": "mongolia", "keywords": ["mn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇪", "name": "montenegro", "keywords": ["me", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇸", "name": "montserrat", "keywords": ["ms", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇦", "name": "morocco", "keywords": ["ma", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇿", "name": "mozambique", "keywords": ["mz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇲", "name": "myanmar", "keywords": ["mm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇦", "name": "namibia", "keywords": ["na", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇷", "name": "nauru", "keywords": ["nr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇵", "name": "nepal", "keywords": ["np", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇱", "name": "netherlands", "keywords": ["nl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇨", "name": "new_caledonia", "keywords": ["new", "caledonia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇿", "name": "new_zealand", "keywords": ["new", "zealand", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇮", "name": "nicaragua", "keywords": ["ni", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇪", "name": "niger", "keywords": ["ne", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇬", "name": "nigeria", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇺", "name": "niue", "keywords": ["nu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇳🇫", "name": "norfolk_island", "keywords": ["norfolk", "island", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇲🇵", "name": "northern_mariana_islands", "keywords": ["northern", "mariana", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇵", "name": "north_korea", "keywords": ["north", "korea", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇳🇴", "name": "norway", "keywords": ["no", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇴🇲", "name": "oman", "keywords": ["om_symbol", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇰", "name": "pakistan", "keywords": ["pk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇼", "name": "palau", "keywords": ["pw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇸", "name": "palestinian_territories", "keywords": ["palestine", "palestinian", "territories", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇦", "name": "panama", "keywords": ["pa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇬", "name": "papua_new_guinea", "keywords": ["papua", "new", "guinea", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇾", "name": "paraguay", "keywords": ["py", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇪", "name": "peru", "keywords": ["pe", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇭", "name": "philippines", "keywords": ["ph", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇳", "name": "pitcairn_islands", "keywords": ["pitcairn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇱", "name": "poland", "keywords": ["pl", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇹", "name": "portugal", "keywords": ["pt", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇷", "name": "puerto_rico", "keywords": ["puerto", "rico", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇶🇦", "name": "qatar", "keywords": ["qa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇪", "name": "reunion", "keywords": ["réunion", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇴", "name": "romania", "keywords": ["ro", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇺", "name": "ru", "keywords": ["russian", "federation", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇼", "name": "rwanda", "keywords": ["rw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇧🇱", "name": "st_barthelemy", "keywords": ["saint", "barthélemy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇭", "name": "st_helena", "keywords": ["saint", "helena", "ascension", "tristan", "cunha", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇳", "name": "st_kitts_nevis", "keywords": ["saint", "kitts", "nevis", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇨", "name": "st_lucia", "keywords": ["saint", "lucia", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇵🇲", "name": "st_pierre_miquelon", "keywords": ["saint", "pierre", "miquelon", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇨", "name": "st_vincent_grenadines", "keywords": ["saint", "vincent", "grenadines", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇼🇸", "name": "samoa", "keywords": ["ws", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇲", "name": "san_marino", "keywords": ["san", "marino", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇹", "name": "sao_tome_principe", "keywords": ["sao", "tome", "principe", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇦", "name": "saudi_arabia", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇳", "name": "senegal", "keywords": ["sn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇷🇸", "name": "serbia", "keywords": ["rs", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇨", "name": "seychelles", "keywords": ["sc", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇱", "name": "sierra_leone", "keywords": ["sierra", "leone", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇬", "name": "singapore", "keywords": ["sg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇽", "name": "sint_maarten", "keywords": ["sint", "maarten", "dutch", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇰", "name": "slovakia", "keywords": ["sk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇮", "name": "slovenia", "keywords": ["si", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇧", "name": "solomon_islands", "keywords": ["solomon", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇴", "name": "somalia", "keywords": ["so", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇦", "name": "south_africa", "keywords": ["south", "africa", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇸", "name": "south_georgia_south_sandwich_islands", "keywords": ["south", "georgia", "sandwich", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇰🇷", "name": "kr", "keywords": ["south", "korea", "nation", "flag", "country", "banner"] }, + { "category": "flags", "char": "🇸🇸", "name": "south_sudan", "keywords": ["south", "sd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇸", "name": "es", "keywords": ["spain", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇱🇰", "name": "sri_lanka", "keywords": ["sri", "lanka", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇩", "name": "sudan", "keywords": ["sd", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇷", "name": "suriname", "keywords": ["sr", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇿", "name": "swaziland", "keywords": ["sz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇪", "name": "sweden", "keywords": ["se", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇨🇭", "name": "switzerland", "keywords": ["ch", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇸🇾", "name": "syria", "keywords": ["syrian", "arab", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇼", "name": "taiwan", "keywords": ["tw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇯", "name": "tajikistan", "keywords": ["tj", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇿", "name": "tanzania", "keywords": ["tanzania, ", "united", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇭", "name": "thailand", "keywords": ["th", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇱", "name": "timor_leste", "keywords": ["timor", "leste", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇬", "name": "togo", "keywords": ["tg", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇰", "name": "tokelau", "keywords": ["tk", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇴", "name": "tonga", "keywords": ["to", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇹", "name": "trinidad_tobago", "keywords": ["trinidad", "tobago", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇦", "name": "tristan_da_cunha", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇳", "name": "tunisia", "keywords": ["tn", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇷", "name": "tr", "keywords": ["turkey", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇲", "name": "turkmenistan", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇨", "name": "turks_caicos_islands", "keywords": ["turks", "caicos", "islands", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇹🇻", "name": "tuvalu", "keywords": ["flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇬", "name": "uganda", "keywords": ["ug", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇦", "name": "ukraine", "keywords": ["ua", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇦🇪", "name": "united_arab_emirates", "keywords": ["united", "arab", "emirates", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇬🇧", "name": "uk", "keywords": ["united", "kingdom", "great", "britain", "northern", "ireland", "flag", "nation", "country", "banner", "british", "UK", "english", "england", "union jack"] }, + { "category": "flags", "char": "🏴󠁧󠁢󠁥󠁮󠁧󠁿", "name": "england", "keywords": ["flag", "english"] }, + { "category": "flags", "char": "🏴󠁧󠁢󠁳󠁣󠁴󠁿", "name": "scotland", "keywords": ["flag", "scottish"] }, + { "category": "flags", "char": "🏴󠁧󠁢󠁷󠁬󠁳󠁿", "name": "wales", "keywords": ["flag", "welsh"] }, + { "category": "flags", "char": "🇺🇸", "name": "us", "keywords": ["united", "states", "america", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇮", "name": "us_virgin_islands", "keywords": ["virgin", "islands", "us", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇾", "name": "uruguay", "keywords": ["uy", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇿", "name": "uzbekistan", "keywords": ["uz", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇺", "name": "vanuatu", "keywords": ["vu", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇦", "name": "vatican_city", "keywords": ["vatican", "city", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇪", "name": "venezuela", "keywords": ["ve", "bolivarian", "republic", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇻🇳", "name": "vietnam", "keywords": ["viet", "nam", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇼🇫", "name": "wallis_futuna", "keywords": ["wallis", "futuna", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇪🇭", "name": "western_sahara", "keywords": ["western", "sahara", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇾🇪", "name": "yemen", "keywords": ["ye", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇲", "name": "zambia", "keywords": ["zm", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇿🇼", "name": "zimbabwe", "keywords": ["zw", "flag", "nation", "country", "banner"] }, + { "category": "flags", "char": "🇺🇳", "name": "united_nations", "keywords": ["un", "flag", "banner"] }, + { "category": "flags", "char": "🏴‍☠️", "name": "pirate_flag", "keywords": ["skull", "crossbones", "flag", "banner"] } +] + diff --git a/packages/frontend/src/events.ts b/packages/frontend/src/events.ts new file mode 100644 index 0000000000..dbbd908b8f --- /dev/null +++ b/packages/frontend/src/events.ts @@ -0,0 +1,4 @@ +import { EventEmitter } from 'eventemitter3'; + +// TODO: 型付け +export const globalEvents = new EventEmitter(); diff --git a/packages/frontend/src/filters/bytes.ts b/packages/frontend/src/filters/bytes.ts new file mode 100644 index 0000000000..c80f2f0ed2 --- /dev/null +++ b/packages/frontend/src/filters/bytes.ts @@ -0,0 +1,9 @@ +export default (v, digits = 0) => { + if (v == null) return '?'; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + if (v === 0) return '0'; + const isMinus = v < 0; + if (isMinus) v = -v; + const i = Math.floor(Math.log(v) / Math.log(1024)); + return (isMinus ? '-' : '') + (v / Math.pow(1024, i)).toFixed(digits).replace(/\.0+$/, '') + sizes[i]; +}; diff --git a/packages/frontend/src/filters/note.ts b/packages/frontend/src/filters/note.ts new file mode 100644 index 0000000000..cd9b7d98d2 --- /dev/null +++ b/packages/frontend/src/filters/note.ts @@ -0,0 +1,3 @@ +export const notePage = note => { + return `/notes/${note.id}`; +}; diff --git a/packages/frontend/src/filters/number.ts b/packages/frontend/src/filters/number.ts new file mode 100644 index 0000000000..880a848ca4 --- /dev/null +++ b/packages/frontend/src/filters/number.ts @@ -0,0 +1 @@ +export default n => n == null ? 'N/A' : n.toLocaleString(); diff --git a/packages/frontend/src/filters/user.ts b/packages/frontend/src/filters/user.ts new file mode 100644 index 0000000000..ff2f7e2dae --- /dev/null +++ b/packages/frontend/src/filters/user.ts @@ -0,0 +1,15 @@ +import * as misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; +import { url } from '@/config'; + +export const acct = (user: misskey.Acct) => { + return Acct.toString(user); +}; + +export const userName = (user: misskey.entities.User) => { + return user.name || user.username; +}; + +export const userPage = (user: misskey.Acct, path?, absolute = false) => { + return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`; +}; diff --git a/packages/frontend/src/i18n.ts b/packages/frontend/src/i18n.ts new file mode 100644 index 0000000000..31e066960d --- /dev/null +++ b/packages/frontend/src/i18n.ts @@ -0,0 +1,5 @@ +import { markRaw } from 'vue'; +import { locale } from '@/config'; +import { I18n } from '@/scripts/i18n'; + +export const i18n = markRaw(new I18n(locale)); diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts new file mode 100644 index 0000000000..508d3262b3 --- /dev/null +++ b/packages/frontend/src/init.ts @@ -0,0 +1,433 @@ +/** + * Client entry point + */ +// https://vitejs.dev/config/build-options.html#build-modulepreload +import 'vite/modulepreload-polyfill'; + +import '@/style.scss'; + +//#region account indexedDB migration +import { set } from '@/scripts/idb-proxy'; + +if (localStorage.getItem('accounts') != null) { + set('accounts', JSON.parse(localStorage.getItem('accounts'))); + localStorage.removeItem('accounts'); +} +//#endregion + +import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; +import { compareVersions } from 'compare-versions'; +import JSON5 from 'json5'; + +import widgets from '@/widgets'; +import directives from '@/directives'; +import components from '@/components'; +import { version, ui, lang, host } from '@/config'; +import { applyTheme } from '@/scripts/theme'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { i18n } from '@/i18n'; +import { confirm, alert, post, popup, toast } from '@/os'; +import { stream } from '@/stream'; +import * as sound from '@/scripts/sound'; +import { $i, refreshAccount, login, updateAccount, signout } from '@/account'; +import { defaultStore, ColdDeviceStorage } from '@/store'; +import { fetchInstance, instance } from '@/instance'; +import { makeHotkey } from '@/scripts/hotkey'; +import { search } from '@/scripts/search'; +import { deviceKind } from '@/scripts/device-kind'; +import { initializeSw } from '@/scripts/initialize-sw'; +import { reloadChannel } from '@/scripts/unison-reload'; +import { reactionPicker } from '@/scripts/reaction-picker'; +import { getUrlWithoutLoginId } from '@/scripts/login-id'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; + +(async () => { + console.info(`Misskey v${version}`); + + if (_DEV_) { + console.warn('Development mode!!!'); + + console.info(`vue ${vueVersion}`); + + (window as any).$i = $i; + (window as any).$store = defaultStore; + + window.addEventListener('error', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled error', + text: event.message + }); + */ + }); + + window.addEventListener('unhandledrejection', event => { + console.error(event); + /* + alert({ + type: 'error', + title: 'DEV: Unhandled promise rejection', + text: event.reason + }); + */ + }); + } + + // タッチデバイスでCSSの:hoverを機能させる + document.addEventListener('touchend', () => {}, { passive: true }); + + // 一斉リロード + reloadChannel.addEventListener('message', path => { + if (path !== null) location.href = path; + else location.reload(); + }); + + // If mobile, insert the viewport meta tag + if (['smartphone', 'tablet'].includes(deviceKind)) { + const viewport = document.getElementsByName('viewport').item(0); + viewport.setAttribute('content', + `${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`); + } + + //#region Set lang attr + const html = document.documentElement; + html.setAttribute('lang', lang); + //#endregion + + //#region loginId + const params = new URLSearchParams(location.search); + const loginId = params.get('loginId'); + + if (loginId) { + const target = getUrlWithoutLoginId(location.href); + + if (!$i || $i.id !== loginId) { + const account = await getAccountFromId(loginId); + if (account) { + await login(account.token, target); + } + } + + history.replaceState({ misskey: 'loginId' }, '', target); + } + + //#endregion + + //#region Fetch user + if ($i && $i.token) { + if (_DEV_) { + console.log('account cache found. refreshing...'); + } + + refreshAccount(); + } else { + if (_DEV_) { + console.log('no account cache found.'); + } + + // 連携ログインの場合用にCookieを参照する + const i = (document.cookie.match(/igi=(\w+)/) ?? [null, null])[1]; + + if (i != null && i !== 'null') { + if (_DEV_) { + console.log('signing...'); + } + + try { + document.body.innerHTML = '
Please wait...
'; + await login(i); + } catch (err) { + // Render the error screen + // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな) + document.body.innerHTML = '
Oops!
'; + } + } else { + if (_DEV_) { + console.log('not signed in'); + } + } + } + //#endregion + + const fetchInstanceMetaPromise = fetchInstance(); + + fetchInstanceMetaPromise.then(() => { + localStorage.setItem('v', instance.version); + + // Init service worker + initializeSw(); + }); + + const app = createApp( + window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : + !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : + ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : + ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : + defineAsyncComponent(() => import('@/ui/universal.vue')), + ); + + if (_DEV_) { + app.config.performance = true; + } + + app.config.globalProperties = { + $i, + $store: defaultStore, + $instance: instance, + $t: i18n.t, + $ts: i18n.ts, + }; + + widgets(app); + directives(app); + components(app); + + const splash = document.getElementById('splash'); + // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) + if (splash) splash.addEventListener('transitionend', () => { + splash.remove(); + }); + + // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 + // なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する + const rootEl = (() => { + const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; + + const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID); + + if (currentEl) { + console.warn('multiple import detected'); + return currentEl; + } + + const rootEl = document.createElement('div'); + rootEl.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(rootEl); + return rootEl; + })(); + + app.mount(rootEl); + + // boot.jsのやつを解除 + window.onerror = null; + window.onunhandledrejection = null; + + reactionPicker.init(); + + if (splash) { + splash.style.opacity = '0'; + splash.style.pointerEvents = 'none'; + } + + // クライアントが更新されたか? + const lastVersion = localStorage.getItem('lastVersion'); + if (lastVersion !== version) { + localStorage.setItem('lastVersion', version); + + // テーマリビルドするため + localStorage.removeItem('theme'); + + try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため + if (lastVersion != null && compareVersions(version, lastVersion) === 1) { + // ログインしてる場合だけ + if ($i) { + popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); + } + } + } catch (err) { + } + } + + // NOTE: この処理は必ず↑のクライアント更新時処理より後に来ること(テーマ再構築のため) + watch(defaultStore.reactiveState.darkMode, (darkMode) => { + applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme')); + }, { immediate: localStorage.theme == null }); + + const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme')); + const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme')); + + watch(darkTheme, (theme) => { + if (defaultStore.state.darkMode) { + applyTheme(theme); + } + }); + + watch(lightTheme, (theme) => { + if (!defaultStore.state.darkMode) { + applyTheme(theme); + } + }); + + //#region Sync dark mode + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } + + window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) { + defaultStore.set('darkMode', mql.matches); + } + }); + //#endregion + + fetchInstanceMetaPromise.then(() => { + if (defaultStore.state.themeInitial) { + if (instance.defaultLightTheme != null) ColdDeviceStorage.set('lightTheme', JSON5.parse(instance.defaultLightTheme)); + if (instance.defaultDarkTheme != null) ColdDeviceStorage.set('darkTheme', JSON5.parse(instance.defaultDarkTheme)); + defaultStore.set('themeInitial', false); + } + }); + + watch(defaultStore.reactiveState.useBlurEffectForModal, v => { + document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); + }, { immediate: true }); + + watch(defaultStore.reactiveState.useBlurEffect, v => { + if (v) { + document.documentElement.style.removeProperty('--blur'); + } else { + document.documentElement.style.setProperty('--blur', 'none'); + } + }, { immediate: true }); + + let reloadDialogShowing = false; + stream.on('_disconnected_', async () => { + if (defaultStore.state.serverDisconnectedBehavior === 'reload') { + location.reload(); + } else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') { + if (reloadDialogShowing) return; + reloadDialogShowing = true; + const { canceled } = await confirm({ + type: 'warning', + title: i18n.ts.disconnectedFromServer, + text: i18n.ts.reloadConfirm, + }); + reloadDialogShowing = false; + if (!canceled) { + location.reload(); + } + } + }); + + stream.on('emojiAdded', emojiData => { + // TODO + //store.commit('instance/set', ); + }); + + for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { + import('./plugin').then(({ install }) => { + install(plugin); + }); + } + + const hotkeys = { + 'd': (): void => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': search, + }; + + if ($i) { + // only add post shortcuts if logged in + hotkeys['p|n'] = post; + + if ($i.isDeleted) { + alert({ + type: 'warning', + text: i18n.ts.accountDeletionInProgress, + }); + } + + const lastUsed = localStorage.getItem('lastUsed'); + if (lastUsed) { + const lastUsedDate = parseInt(lastUsed, 10); + // 二時間以上前なら + if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { + toast(i18n.t('welcomeBackWithName', { + name: $i.name || $i.username, + })); + } + } + localStorage.setItem('lastUsed', Date.now().toString()); + + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission === 'default') { + Notification.requestPermission(); + } + } + + const main = markRaw(stream.useChannel('main', null, 'System')); + + // 自分の情報が更新されたとき + main.on('meUpdated', i => { + updateAccount(i); + }); + + main.on('readAllNotifications', () => { + updateAccount({ hasUnreadNotification: false }); + }); + + main.on('unreadNotification', () => { + updateAccount({ hasUnreadNotification: true }); + }); + + main.on('unreadMention', () => { + updateAccount({ hasUnreadMentions: true }); + }); + + main.on('readAllUnreadMentions', () => { + updateAccount({ hasUnreadMentions: false }); + }); + + main.on('unreadSpecifiedNote', () => { + updateAccount({ hasUnreadSpecifiedNotes: true }); + }); + + main.on('readAllUnreadSpecifiedNotes', () => { + updateAccount({ hasUnreadSpecifiedNotes: false }); + }); + + main.on('readAllMessagingMessages', () => { + updateAccount({ hasUnreadMessagingMessage: false }); + }); + + main.on('unreadMessagingMessage', () => { + updateAccount({ hasUnreadMessagingMessage: true }); + sound.play('chatBg'); + }); + + main.on('readAllAntennas', () => { + updateAccount({ hasUnreadAntenna: false }); + }); + + main.on('unreadAntenna', () => { + updateAccount({ hasUnreadAntenna: true }); + sound.play('antenna'); + }); + + main.on('readAllAnnouncements', () => { + updateAccount({ hasUnreadAnnouncement: false }); + }); + + main.on('readAllChannels', () => { + updateAccount({ hasUnreadChannel: false }); + }); + + main.on('unreadChannel', () => { + updateAccount({ hasUnreadChannel: true }); + sound.play('channel'); + }); + + // トークンが再生成されたとき + // このままではMisskeyが利用できないので強制的にサインアウトさせる + main.on('myTokenRegenerated', () => { + signout(); + }); + } + + // shortcut + document.addEventListener('keydown', makeHotkey(hotkeys)); +})(); diff --git a/packages/frontend/src/instance.ts b/packages/frontend/src/instance.ts new file mode 100644 index 0000000000..51464f32fb --- /dev/null +++ b/packages/frontend/src/instance.ts @@ -0,0 +1,45 @@ +import { computed, reactive } from 'vue'; +import * as Misskey from 'misskey-js'; +import { api } from './os'; + +// TODO: 他のタブと永続化されたstateを同期 + +const instanceData = localStorage.getItem('instance'); + +// TODO: instanceをリアクティブにするかは再考の余地あり + +export const instance: Misskey.entities.InstanceMetadata = reactive(instanceData ? JSON.parse(instanceData) : { + // TODO: set default values +}); + +export async function fetchInstance() { + const meta = await api('meta', { + detail: false, + }); + + for (const [k, v] of Object.entries(meta)) { + instance[k] = v; + } + + localStorage.setItem('instance', JSON.stringify(instance)); +} + +export const emojiCategories = computed(() => { + if (instance.emojis == null) return []; + const categories = new Set(); + for (const emoji of instance.emojis) { + categories.add(emoji.category); + } + return Array.from(categories); +}); + +export const emojiTags = computed(() => { + if (instance.emojis == null) return []; + const tags = new Set(); + for (const emoji of instance.emojis) { + for (const tag of emoji.aliases) { + tags.add(tag); + } + } + return Array.from(tags); +}); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts new file mode 100644 index 0000000000..31e6cd64a4 --- /dev/null +++ b/packages/frontend/src/navbar.ts @@ -0,0 +1,135 @@ +import { computed, ref, reactive } from 'vue'; +import { $i } from './account'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { ui } from '@/config'; +import { unisonReload } from '@/scripts/unison-reload'; + +export const navbarItemDef = reactive({ + notifications: { + title: 'notifications', + icon: 'ti ti-bell', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadNotification), + to: '/my/notifications', + }, + messaging: { + title: 'messaging', + icon: 'ti ti-messages', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage), + to: '/my/messaging', + }, + drive: { + title: 'drive', + icon: 'ti ti-cloud', + show: computed(() => $i != null), + to: '/my/drive', + }, + followRequests: { + title: 'followRequests', + icon: 'ti ti-user-plus', + show: computed(() => $i != null && $i.isLocked), + indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), + to: '/my/follow-requests', + }, + explore: { + title: 'explore', + icon: 'ti ti-hash', + to: '/explore', + }, + announcements: { + title: 'announcements', + icon: 'ti ti-speakerphone', + indicated: computed(() => $i != null && $i.hasUnreadAnnouncement), + to: '/announcements', + }, + search: { + title: 'search', + icon: 'ti ti-search', + action: () => search(), + }, + lists: { + title: 'lists', + icon: 'ti ti-list', + show: computed(() => $i != null), + to: '/my/lists', + }, + /* + groups: { + title: 'groups', + icon: 'ti ti-users', + show: computed(() => $i != null), + to: '/my/groups', + }, + */ + antennas: { + title: 'antennas', + icon: 'ti ti-antenna', + show: computed(() => $i != null), + to: '/my/antennas', + }, + favorites: { + title: 'favorites', + icon: 'ti ti-star', + show: computed(() => $i != null), + to: '/my/favorites', + }, + pages: { + title: 'pages', + icon: 'ti ti-news', + to: '/pages', + }, + gallery: { + title: 'gallery', + icon: 'ti ti-icons', + to: '/gallery', + }, + clips: { + title: 'clip', + icon: 'ti ti-paperclip', + show: computed(() => $i != null), + to: '/my/clips', + }, + channels: { + title: 'channel', + icon: 'ti ti-device-tv', + to: '/channels', + }, + ui: { + title: 'switchUi', + icon: 'ti ti-devices', + action: (ev) => { + os.popupMenu([{ + text: i18n.ts.default, + active: ui === 'default' || ui === null, + action: () => { + localStorage.setItem('ui', 'default'); + unisonReload(); + }, + }, { + text: i18n.ts.deck, + active: ui === 'deck', + action: () => { + localStorage.setItem('ui', 'deck'); + unisonReload(); + }, + }, { + text: i18n.ts.classic, + active: ui === 'classic', + action: () => { + localStorage.setItem('ui', 'classic'); + unisonReload(); + }, + }], ev.currentTarget ?? ev.target); + }, + }, + reload: { + title: 'reload', + icon: 'ti ti-refresh', + action: (ev) => { + location.reload(); + }, + }, +}); diff --git a/packages/frontend/src/nirax.ts b/packages/frontend/src/nirax.ts new file mode 100644 index 0000000000..53e73a8d48 --- /dev/null +++ b/packages/frontend/src/nirax.ts @@ -0,0 +1,275 @@ +// NIRAX --- A lightweight router + +import { EventEmitter } from 'eventemitter3'; +import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue'; +import { pleaseLogin } from '@/scripts/please-login'; +import { safeURIDecode } from '@/scripts/safe-uri-decode'; + +type RouteDef = { + path: string; + component: Component; + query?: Record; + loginRequired?: boolean; + name?: string; + hash?: string; + globalCacheKey?: string; + children?: RouteDef[]; +}; + +type ParsedPath = (string | { + name: string; + startsWith?: string; + wildcard?: boolean; + optional?: boolean; +})[]; + +export type Resolved = { route: RouteDef; props: Map; child?: Resolved; }; + +function parsePath(path: string): ParsedPath { + const res = [] as ParsedPath; + + path = path.substring(1); + + for (const part of path.split('/')) { + if (part.includes(':')) { + const prefix = part.substring(0, part.indexOf(':')); + const placeholder = part.substring(part.indexOf(':') + 1); + const wildcard = placeholder.includes('(*)'); + const optional = placeholder.endsWith('?'); + res.push({ + name: placeholder.replace('(*)', '').replace('?', ''), + startsWith: prefix !== '' ? prefix : undefined, + wildcard, + optional, + }); + } else if (part.length !== 0) { + res.push(part); + } + } + + return res; +} + +export class Router extends EventEmitter<{ + change: (ctx: { + beforePath: string; + path: string; + resolved: Resolved; + key: string; + }) => void; + replace: (ctx: { + path: string; + key: string; + }) => void; + push: (ctx: { + beforePath: string; + path: string; + route: RouteDef | null; + props: Map | null; + key: string; + }) => void; + same: () => void; +}> { + private routes: RouteDef[]; + public current: Resolved; + public currentRef: ShallowRef = shallowRef(); + public currentRoute: ShallowRef = shallowRef(); + private currentPath: string; + private currentKey = Date.now().toString(); + + public navHook: ((path: string, flag?: any) => boolean) | null = null; + + constructor(routes: Router['routes'], currentPath: Router['currentPath']) { + super(); + + this.routes = routes; + this.currentPath = currentPath; + this.navigate(currentPath, null, false); + } + + public resolve(path: string): Resolved | null { + let queryString: string | null = null; + let hash: string | null = null; + if (path[0] === '/') path = path.substring(1); + if (path.includes('#')) { + hash = path.substring(path.indexOf('#') + 1); + path = path.substring(0, path.indexOf('#')); + } + if (path.includes('?')) { + queryString = path.substring(path.indexOf('?') + 1); + path = path.substring(0, path.indexOf('?')); + } + + if (_DEV_) console.log('Routing: ', path, queryString); + + function check(routes: RouteDef[], _parts: string[]): Resolved | null { + forEachRouteLoop: + for (const route of routes) { + let parts = [..._parts]; + const props = new Map(); + + pathMatchLoop: + for (const p of parsePath(route.path)) { + if (typeof p === 'string') { + if (p === parts[0]) { + parts.shift(); + } else { + continue forEachRouteLoop; + } + } else { + if (parts[0] == null && !p.optional) { + continue forEachRouteLoop; + } + if (p.wildcard) { + if (parts.length !== 0) { + props.set(p.name, safeURIDecode(parts.join('/'))); + parts = []; + } + break pathMatchLoop; + } else { + if (p.startsWith) { + if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop; + + props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length))); + parts.shift(); + } else { + if (parts[0]) { + props.set(p.name, safeURIDecode(parts[0])); + } + parts.shift(); + } + } + } + } + + if (parts.length === 0) { + if (route.children) { + const child = check(route.children, []); + if (child) { + return { + route, + props, + child, + }; + } else { + continue forEachRouteLoop; + } + } + + if (route.hash != null && hash != null) { + props.set(route.hash, safeURIDecode(hash)); + } + + if (route.query != null && queryString != null) { + const queryObject = [...new URLSearchParams(queryString).entries()] + .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); + + for (const q in route.query) { + const as = route.query[q]; + if (queryObject[q]) { + props.set(as, safeURIDecode(queryObject[q])); + } + } + } + + return { + route, + props, + }; + } else { + if (route.children) { + const child = check(route.children, parts); + if (child) { + return { + route, + props, + child, + }; + } else { + continue forEachRouteLoop; + } + } else { + continue forEachRouteLoop; + } + } + } + + return null; + } + + const _parts = path.split('/').filter(part => part.length !== 0); + + return check(this.routes, _parts); + } + + private navigate(path: string, key: string | null | undefined, emitChange = true) { + const beforePath = this.currentPath; + this.currentPath = path; + + const res = this.resolve(this.currentPath); + + if (res == null) { + throw new Error('no route found for: ' + path); + } + + if (res.route.loginRequired) { + pleaseLogin('/'); + } + + const isSamePath = beforePath === path; + if (isSamePath && key == null) key = this.currentKey; + this.current = res; + this.currentRef.value = res; + this.currentRoute.value = res.route; + this.currentKey = res.route.globalCacheKey ?? key ?? path; + + if (emitChange) { + this.emit('change', { + beforePath, + path, + resolved: res, + key: this.currentKey, + }); + } + + return res; + } + + public getCurrentPath() { + return this.currentPath; + } + + public getCurrentKey() { + return this.currentKey; + } + + public push(path: string, flag?: any) { + const beforePath = this.currentPath; + if (path === beforePath) { + this.emit('same'); + return; + } + if (this.navHook) { + const cancel = this.navHook(path, flag); + if (cancel) return; + } + const res = this.navigate(path, null); + this.emit('push', { + beforePath, + path, + route: res.route, + props: res.props, + key: this.currentKey, + }); + } + + public replace(path: string, key?: string | null, emitEvent = true) { + this.navigate(path, key); + if (emitEvent) { + this.emit('replace', { + path, + key: this.currentKey, + }); + } + } +} diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts new file mode 100644 index 0000000000..7e57dcb4af --- /dev/null +++ b/packages/frontend/src/os.ts @@ -0,0 +1,588 @@ +// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する + +import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; +import { EventEmitter } from 'eventemitter3'; +import insertTextAtCursor from 'insert-text-at-cursor'; +import * as Misskey from 'misskey-js'; +import { apiUrl, url } from '@/config'; +import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; +import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; +import { MenuItem } from '@/types/menu'; +import { $i } from '@/account'; + +export const pendingApiRequestsCount = ref(0); + +const apiClient = new Misskey.api.APIClient({ + origin: url, +}); + +export const api = ((endpoint: string, data: Record = {}, token?: string | null | undefined) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const promise = new Promise((resolve, reject) => { + // Append a credential + if ($i) (data as any).i = $i.token; + if (token !== undefined) (data as any).i = token; + + // Send request + window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + +export const apiGet = ((endpoint: string, data: Record = {}) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data); + + const promise = new Promise((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + +export const apiWithDialog = (( + endpoint: string, + data: Record = {}, + token?: string | null | undefined, +) => { + const promise = api(endpoint, data, token); + promiseDialog(promise, null, (err) => { + alert({ + type: 'error', + text: err.message + '\n' + (err as any).id, + }); + }); + + return promise; +}) as typeof api; + +export function promiseDialog>( + promise: T, + onSuccess?: ((res: any) => void) | null, + onFailure?: ((err: Error) => void) | null, + text?: string, +): T { + const showing = ref(true); + const success = ref(false); + + promise.then(res => { + if (onSuccess) { + showing.value = false; + onSuccess(res); + } else { + success.value = true; + window.setTimeout(() => { + showing.value = false; + }, 1000); + } + }).catch(err => { + showing.value = false; + if (onFailure) { + onFailure(err); + } else { + alert({ + type: 'error', + text: err, + }); + } + }); + + // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない) + popup(MkWaitingDialog, { + success: success, + showing: showing, + text: text, + }, {}, 'closed'); + + return promise; +} + +let popupIdCount = 0; +export const popups = ref([]) as Ref<{ + id: any; + component: any; + props: Record; +}[]>; + +const zIndexes = { + low: 1000000, + middle: 2000000, + high: 3000000, +}; +export function claimZIndex(priority: 'low' | 'middle' | 'high' = 'low'): number { + zIndexes[priority] += 100; + return zIndexes[priority]; +} + +export async function popup(component: Component, props: Record, events = {}, disposeEvent?: string) { + markRaw(component); + + const id = ++popupIdCount; + const dispose = () => { + // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ? + window.setTimeout(() => { + popups.value = popups.value.filter(popup => popup.id !== id); + }, 0); + }; + const state = { + component, + props, + events: disposeEvent ? { + ...events, + [disposeEvent]: dispose, + } : events, + id, + }; + + popups.value.push(state); + + return { + dispose, + }; +} + +export function pageWindow(path: string) { + popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { + initialPath: path, + }, {}, 'closed'); +} + +export function modalPageWindow(path: string) { + popup(defineAsyncComponent(() => import('@/components/MkModalPageWindow.vue')), { + initialPath: path, + }, {}, 'closed'); +} + +export function toast(message: string) { + popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { + message, + }, {}, 'closed'); +} + +export function alert(props: { + type?: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string | null; + text?: string | null; +}): Promise { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { + done: result => { + resolve(); + }, + }, 'closed'); + }); +} + +export function confirm(props: { + type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string | null; + text?: string | null; +}): Promise<{ canceled: boolean }> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + ...props, + showCancelButton: true, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function inputText(props: { + type?: 'text' | 'email' | 'password' | 'url'; + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: string | null; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: string; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + input: { + type: props.type, + placeholder: props.placeholder, + default: props.default, + }, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function inputNumber(props: { + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: number | null; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: number; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + input: { + type: 'number', + placeholder: props.placeholder, + default: props.default, + }, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function inputDate(props: { + title?: string | null; + text?: string | null; + placeholder?: string | null; + default?: Date | null; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: Date; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + input: { + type: 'date', + placeholder: props.placeholder, + default: props.default, + }, + }, { + done: result => { + resolve(result ? { result: new Date(result.result), canceled: false } : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function select(props: { + title?: string | null; + text?: string | null; + default?: string | null; +} & ({ + items: { + value: C; + text: string; + }[]; +} | { + groupedItems: { + label: string; + items: { + value: C; + text: string; + }[]; + }[]; +})): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: C; +}> { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + title: props.title, + text: props.text, + select: { + items: props.items, + groupedItems: props.groupedItems, + default: props.default, + }, + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + +export function success() { + return new Promise((resolve, reject) => { + const showing = ref(true); + window.setTimeout(() => { + showing.value = false; + }, 1000); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: true, + showing: showing, + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function waiting() { + return new Promise((resolve, reject) => { + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, { + done: () => resolve(), + }, 'closed'); + }); +} + +export function form(title, form) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkFormDialog.vue')), { title, form }, { + done: result => { + resolve(result); + }, + }, 'closed'); + }); +} + +export async function selectUser() { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {}, { + ok: user => { + resolve(user); + }, + }, 'closed'); + }); +} + +export async function selectDriveFile(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { + type: 'file', + multiple, + }, { + done: files => { + if (files) { + resolve(multiple ? files : files[0]); + } + }, + }, 'closed'); + }); +} + +export async function selectDriveFolder(multiple: boolean) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { + type: 'folder', + multiple, + }, { + done: folders => { + if (folders) { + resolve(multiple ? folders : folders[0]); + } + }, + }, 'closed'); + }); +} + +export async function pickEmoji(src: HTMLElement | null, opts) { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src, + ...opts, + }, { + done: emoji => { + resolve(emoji); + }, + }, 'closed'); + }); +} + +export async function cropImage(image: Misskey.entities.DriveFile, options: { + aspectRatio: number; +}): Promise { + return new Promise((resolve, reject) => { + popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), { + file: image, + aspectRatio: options.aspectRatio, + }, { + ok: x => { + resolve(x); + }, + }, 'closed'); + }); +} + +type AwaitType = + T extends Promise ? U : + T extends (...args: any[]) => Promise ? V : + T; +let openingEmojiPicker: AwaitType> | null = null; +let activeTextarea: HTMLTextAreaElement | HTMLInputElement | null = null; +export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: typeof activeTextarea) { + if (openingEmojiPicker) return; + + activeTextarea = initialTextarea; + + const textareas = document.querySelectorAll('textarea, input'); + for (const textarea of Array.from(textareas)) { + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + + const observer = new MutationObserver(records => { + for (const record of records) { + for (const node of Array.from(record.addedNodes).filter(node => node instanceof HTMLElement) as HTMLElement[]) { + const textareas = node.querySelectorAll('textarea, input') as NodeListOf>; + for (const textarea of Array.from(textareas).filter(textarea => textarea.dataset.preventEmojiInsert == null)) { + if (document.activeElement === textarea) activeTextarea = textarea; + textarea.addEventListener('focus', () => { + activeTextarea = textarea; + }); + } + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }); + + openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { + src, + ...opts, + }, { + chosen: emoji => { + insertTextAtCursor(activeTextarea, emoji); + }, + closed: () => { + openingEmojiPicker!.dispose(); + openingEmojiPicker = null; + observer.disconnect(); + }, + }); +} + +export function popupMenu(items: MenuItem[] | Ref, src?: HTMLElement, options?: { + align?: string; + width?: number; + viaKeyboard?: boolean; +}) { + return new Promise((resolve, reject) => { + let dispose; + popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { + items, + src, + width: options?.width, + align: options?.align, + viaKeyboard: options?.viaKeyboard, + }, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export function contextMenu(items: MenuItem[] | Ref, ev: MouseEvent) { + ev.preventDefault(); + return new Promise((resolve, reject) => { + let dispose; + popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { + items, + ev, + }, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export function post(props: Record = {}) { + return new Promise((resolve, reject) => { + // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない + // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 + // Vueが渡されたコンポーネントに内部的に__propsというプロパティを生やす影響で、 + // 複数のpost formを開いたときに場合によってはエラーになる + // もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが + let dispose; + popup(MkPostFormDialog, props, { + closed: () => { + resolve(); + dispose(); + }, + }).then(res => { + dispose = res.dispose; + }); + }); +} + +export const deckGlobalEvents = new EventEmitter(); + +/* +export function checkExistence(fileData: ArrayBuffer): Promise { + return new Promise((resolve, reject) => { + const data = new FormData(); + data.append('md5', getMD5(fileData)); + + os.api('drive/files/find-by-hash', { + md5: getMD5(fileData) + }).then(resp => { + resolve(resp.length > 0 ? resp[0] : null); + }); + }); +}*/ diff --git a/packages/frontend/src/pages/_empty_.vue b/packages/frontend/src/pages/_empty_.vue new file mode 100644 index 0000000000..000b6decc9 --- /dev/null +++ b/packages/frontend/src/pages/_empty_.vue @@ -0,0 +1,7 @@ + + + diff --git a/packages/frontend/src/pages/_error_.vue b/packages/frontend/src/pages/_error_.vue new file mode 100644 index 0000000000..232d525347 --- /dev/null +++ b/packages/frontend/src/pages/_error_.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/frontend/src/pages/_loading_.vue b/packages/frontend/src/pages/_loading_.vue new file mode 100644 index 0000000000..1dd2e46e10 --- /dev/null +++ b/packages/frontend/src/pages/_loading_.vue @@ -0,0 +1,6 @@ + + + diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue new file mode 100644 index 0000000000..3ec972bcda --- /dev/null +++ b/packages/frontend/src/pages/about-misskey.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue new file mode 100644 index 0000000000..53ce1e4b75 --- /dev/null +++ b/packages/frontend/src/pages/about.emojis.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue new file mode 100644 index 0000000000..6c92ab1264 --- /dev/null +++ b/packages/frontend/src/pages/about.federation.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue new file mode 100644 index 0000000000..0ed692c5c5 --- /dev/null +++ b/packages/frontend/src/pages/about.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/packages/frontend/src/pages/admin-file.vue b/packages/frontend/src/pages/admin-file.vue new file mode 100644 index 0000000000..a11249e75d --- /dev/null +++ b/packages/frontend/src/pages/admin-file.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue new file mode 100644 index 0000000000..bdb41b2d2c --- /dev/null +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -0,0 +1,292 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue new file mode 100644 index 0000000000..973ec871ab --- /dev/null +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue new file mode 100644 index 0000000000..2ec926c65c --- /dev/null +++ b/packages/frontend/src/pages/admin/ads.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue new file mode 100644 index 0000000000..607ad8aa02 --- /dev/null +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue new file mode 100644 index 0000000000..d03961cf95 --- /dev/null +++ b/packages/frontend/src/pages/admin/bot-protection.vue @@ -0,0 +1,109 @@ + + + diff --git a/packages/frontend/src/pages/admin/database.vue b/packages/frontend/src/pages/admin/database.vue new file mode 100644 index 0000000000..5a0d3d5e51 --- /dev/null +++ b/packages/frontend/src/pages/admin/database.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue new file mode 100644 index 0000000000..6c9dee1704 --- /dev/null +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -0,0 +1,126 @@ + + + diff --git a/packages/frontend/src/pages/admin/emoji-edit-dialog.vue b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue new file mode 100644 index 0000000000..bd601cb1de --- /dev/null +++ b/packages/frontend/src/pages/admin/emoji-edit-dialog.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/emojis.vue b/packages/frontend/src/pages/admin/emojis.vue new file mode 100644 index 0000000000..14c8466d73 --- /dev/null +++ b/packages/frontend/src/pages/admin/emojis.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue new file mode 100644 index 0000000000..8ad6bd4fc0 --- /dev/null +++ b/packages/frontend/src/pages/admin/files.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue new file mode 100644 index 0000000000..6c07a87eeb --- /dev/null +++ b/packages/frontend/src/pages/admin/index.vue @@ -0,0 +1,316 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue new file mode 100644 index 0000000000..1bdd174de4 --- /dev/null +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -0,0 +1,51 @@ + + + diff --git a/packages/frontend/src/pages/admin/integrations.discord.vue b/packages/frontend/src/pages/admin/integrations.discord.vue new file mode 100644 index 0000000000..0a69c44c93 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.discord.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/frontend/src/pages/admin/integrations.github.vue b/packages/frontend/src/pages/admin/integrations.github.vue new file mode 100644 index 0000000000..66419d5891 --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.github.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/frontend/src/pages/admin/integrations.twitter.vue b/packages/frontend/src/pages/admin/integrations.twitter.vue new file mode 100644 index 0000000000..1e8d882b9c --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.twitter.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/frontend/src/pages/admin/integrations.vue b/packages/frontend/src/pages/admin/integrations.vue new file mode 100644 index 0000000000..9cc35baefd --- /dev/null +++ b/packages/frontend/src/pages/admin/integrations.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue new file mode 100644 index 0000000000..db8e448639 --- /dev/null +++ b/packages/frontend/src/pages/admin/metrics.vue @@ -0,0 +1,472 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue new file mode 100644 index 0000000000..f2ab30eaa5 --- /dev/null +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -0,0 +1,148 @@ + + + diff --git a/packages/frontend/src/pages/admin/other-settings.vue b/packages/frontend/src/pages/admin/other-settings.vue new file mode 100644 index 0000000000..62dff6ce7f --- /dev/null +++ b/packages/frontend/src/pages/admin/other-settings.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue new file mode 100644 index 0000000000..c3ce5ac901 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue new file mode 100644 index 0000000000..024ffdc245 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -0,0 +1,346 @@ + + + + + + diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue new file mode 100644 index 0000000000..71f5a054b4 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -0,0 +1,185 @@ + + + + + + diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue new file mode 100644 index 0000000000..16d1c83b9f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.instances.vue b/packages/frontend/src/pages/admin/overview.instances.vue new file mode 100644 index 0000000000..29848bf03b --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.instances.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.moderators.vue b/packages/frontend/src/pages/admin/overview.moderators.vue new file mode 100644 index 0000000000..a1f63c8711 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.moderators.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue new file mode 100644 index 0000000000..94509cf006 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue new file mode 100644 index 0000000000..1e095bddaa --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue new file mode 100644 index 0000000000..72ebddc72f --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.retention.vue b/packages/frontend/src/pages/admin/overview.retention.vue new file mode 100644 index 0000000000..feac6f8118 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.retention.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue new file mode 100644 index 0000000000..4dcf7e751a --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.users.vue b/packages/frontend/src/pages/admin/overview.users.vue new file mode 100644 index 0000000000..5d4be11742 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.users.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue new file mode 100644 index 0000000000..d656e55200 --- /dev/null +++ b/packages/frontend/src/pages/admin/overview.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/proxy-account.vue b/packages/frontend/src/pages/admin/proxy-account.vue new file mode 100644 index 0000000000..5d0d67980e --- /dev/null +++ b/packages/frontend/src/pages/admin/proxy-account.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue new file mode 100644 index 0000000000..5777674ae3 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/queue.chart.vue new file mode 100644 index 0000000000..186a22c43e --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.chart.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/queue.vue new file mode 100644 index 0000000000..8d19b49fc5 --- /dev/null +++ b/packages/frontend/src/pages/admin/queue.vue @@ -0,0 +1,56 @@ + + + diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue new file mode 100644 index 0000000000..4768ae67b1 --- /dev/null +++ b/packages/frontend/src/pages/admin/relays.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue new file mode 100644 index 0000000000..2682bda337 --- /dev/null +++ b/packages/frontend/src/pages/admin/security.vue @@ -0,0 +1,179 @@ + + + diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue new file mode 100644 index 0000000000..460eb92694 --- /dev/null +++ b/packages/frontend/src/pages/admin/settings.vue @@ -0,0 +1,262 @@ + + + diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue new file mode 100644 index 0000000000..d466e21907 --- /dev/null +++ b/packages/frontend/src/pages/admin/users.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue new file mode 100644 index 0000000000..6a93b3b9fa --- /dev/null +++ b/packages/frontend/src/pages/announcements.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue new file mode 100644 index 0000000000..0b2c284c99 --- /dev/null +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/frontend/src/pages/api-console.vue b/packages/frontend/src/pages/api-console.vue new file mode 100644 index 0000000000..1d5339b44c --- /dev/null +++ b/packages/frontend/src/pages/api-console.vue @@ -0,0 +1,89 @@ + + + diff --git a/packages/frontend/src/pages/auth.form.vue b/packages/frontend/src/pages/auth.form.vue new file mode 100644 index 0000000000..1546735266 --- /dev/null +++ b/packages/frontend/src/pages/auth.form.vue @@ -0,0 +1,60 @@ + + + diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue new file mode 100644 index 0000000000..bb55881a22 --- /dev/null +++ b/packages/frontend/src/pages/auth.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue new file mode 100644 index 0000000000..5ae7e63f99 --- /dev/null +++ b/packages/frontend/src/pages/channel-editor.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue new file mode 100644 index 0000000000..f271bb270f --- /dev/null +++ b/packages/frontend/src/pages/channel.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue new file mode 100644 index 0000000000..34e9dac196 --- /dev/null +++ b/packages/frontend/src/pages/channels.vue @@ -0,0 +1,79 @@ + + + diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue new file mode 100644 index 0000000000..e0fbcb6bed --- /dev/null +++ b/packages/frontend/src/pages/clip.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/packages/frontend/src/pages/drive.vue b/packages/frontend/src/pages/drive.vue new file mode 100644 index 0000000000..04ade5c207 --- /dev/null +++ b/packages/frontend/src/pages/drive.vue @@ -0,0 +1,25 @@ + + + diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue new file mode 100644 index 0000000000..40fe496520 --- /dev/null +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue new file mode 100644 index 0000000000..18a371a086 --- /dev/null +++ b/packages/frontend/src/pages/explore.featured.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue new file mode 100644 index 0000000000..bfee0a6c07 --- /dev/null +++ b/packages/frontend/src/pages/explore.users.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue new file mode 100644 index 0000000000..6b0bcdaf62 --- /dev/null +++ b/packages/frontend/src/pages/explore.vue @@ -0,0 +1,87 @@ + + + diff --git a/packages/frontend/src/pages/favorites.vue b/packages/frontend/src/pages/favorites.vue new file mode 100644 index 0000000000..ab47efec71 --- /dev/null +++ b/packages/frontend/src/pages/favorites.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue new file mode 100644 index 0000000000..b20679ccc1 --- /dev/null +++ b/packages/frontend/src/pages/follow-requests.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/packages/frontend/src/pages/follow.vue b/packages/frontend/src/pages/follow.vue new file mode 100644 index 0000000000..828246d678 --- /dev/null +++ b/packages/frontend/src/pages/follow.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue new file mode 100644 index 0000000000..c8111d7890 --- /dev/null +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue new file mode 100644 index 0000000000..24a634bab5 --- /dev/null +++ b/packages/frontend/src/pages/gallery/index.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue new file mode 100644 index 0000000000..85ab1048be --- /dev/null +++ b/packages/frontend/src/pages/gallery/post.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue new file mode 100644 index 0000000000..a2a1254360 --- /dev/null +++ b/packages/frontend/src/pages/instance-info.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/index.vue b/packages/frontend/src/pages/messaging/index.vue new file mode 100644 index 0000000000..0d30998330 --- /dev/null +++ b/packages/frontend/src/pages/messaging/index.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/messaging-room.form.vue b/packages/frontend/src/pages/messaging/messaging-room.form.vue new file mode 100644 index 0000000000..84572815c0 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.form.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/messaging-room.message.vue b/packages/frontend/src/pages/messaging/messaging-room.message.vue new file mode 100644 index 0000000000..dbf0e37b73 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.message.vue @@ -0,0 +1,367 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue new file mode 100644 index 0000000000..b6eeb9260e --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -0,0 +1,411 @@ + + + + + diff --git a/packages/frontend/src/pages/mfm-cheat-sheet.vue b/packages/frontend/src/pages/mfm-cheat-sheet.vue new file mode 100644 index 0000000000..7c85dfb7ad --- /dev/null +++ b/packages/frontend/src/pages/mfm-cheat-sheet.vue @@ -0,0 +1,387 @@ + + + + + diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue new file mode 100644 index 0000000000..5de072cbfa --- /dev/null +++ b/packages/frontend/src/pages/miauth.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue new file mode 100644 index 0000000000..005b036696 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/packages/frontend/src/pages/my-antennas/edit.vue b/packages/frontend/src/pages/my-antennas/edit.vue new file mode 100644 index 0000000000..cb583faaeb --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/edit.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/packages/frontend/src/pages/my-antennas/editor.vue b/packages/frontend/src/pages/my-antennas/editor.vue new file mode 100644 index 0000000000..a409a734b5 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/editor.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/packages/frontend/src/pages/my-antennas/index.vue b/packages/frontend/src/pages/my-antennas/index.vue new file mode 100644 index 0000000000..9daf23f9b5 --- /dev/null +++ b/packages/frontend/src/pages/my-antennas/index.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue new file mode 100644 index 0000000000..dd6b5b3a37 --- /dev/null +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/packages/frontend/src/pages/my-lists/index.vue b/packages/frontend/src/pages/my-lists/index.vue new file mode 100644 index 0000000000..3476436b27 --- /dev/null +++ b/packages/frontend/src/pages/my-lists/index.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue new file mode 100644 index 0000000000..f6234ffe44 --- /dev/null +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue new file mode 100644 index 0000000000..e58e44ef79 --- /dev/null +++ b/packages/frontend/src/pages/not-found.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue new file mode 100644 index 0000000000..ba2bb91239 --- /dev/null +++ b/packages/frontend/src/pages/note.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue new file mode 100644 index 0000000000..7106951de2 --- /dev/null +++ b/packages/frontend/src/pages/notifications.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue new file mode 100644 index 0000000000..a84cb1e80e --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue new file mode 100644 index 0000000000..dc2a620c09 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -0,0 +1,57 @@ + + + diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue new file mode 100644 index 0000000000..27324bdaef --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue new file mode 100644 index 0000000000..6f11e2a08b --- /dev/null +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/frontend/src/pages/page-editor/page-editor.blocks.vue b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue new file mode 100644 index 0000000000..f99fcb202f --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.blocks.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/frontend/src/pages/page-editor/page-editor.container.vue b/packages/frontend/src/pages/page-editor/page-editor.container.vue new file mode 100644 index 0000000000..15cdda5efb --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.container.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue new file mode 100644 index 0000000000..968aa12de2 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -0,0 +1,394 @@ + + + + + + + diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue new file mode 100644 index 0000000000..a95bfe485c --- /dev/null +++ b/packages/frontend/src/pages/page.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue new file mode 100644 index 0000000000..b077180df8 --- /dev/null +++ b/packages/frontend/src/pages/pages.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/packages/frontend/src/pages/preview.vue b/packages/frontend/src/pages/preview.vue new file mode 100644 index 0000000000..354f686e46 --- /dev/null +++ b/packages/frontend/src/pages/preview.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue new file mode 100644 index 0000000000..f179fbe957 --- /dev/null +++ b/packages/frontend/src/pages/registry.keys.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/packages/frontend/src/pages/registry.value.vue b/packages/frontend/src/pages/registry.value.vue new file mode 100644 index 0000000000..378420b1ba --- /dev/null +++ b/packages/frontend/src/pages/registry.value.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/packages/frontend/src/pages/registry.vue b/packages/frontend/src/pages/registry.vue new file mode 100644 index 0000000000..a2c65294fc --- /dev/null +++ b/packages/frontend/src/pages/registry.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/packages/frontend/src/pages/reset-password.vue b/packages/frontend/src/pages/reset-password.vue new file mode 100644 index 0000000000..8ec15f6425 --- /dev/null +++ b/packages/frontend/src/pages/reset-password.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue new file mode 100644 index 0000000000..edb2d8e18c --- /dev/null +++ b/packages/frontend/src/pages/scratchpad.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue new file mode 100644 index 0000000000..c080b763bb --- /dev/null +++ b/packages/frontend/src/pages/search.vue @@ -0,0 +1,38 @@ + + + diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..1803129aaa --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -0,0 +1,216 @@ + + + diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..ccd99c162a --- /dev/null +++ b/packages/frontend/src/pages/settings/account-info.vue @@ -0,0 +1,158 @@ + + + diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..493d3b2618 --- /dev/null +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue new file mode 100644 index 0000000000..8d7291cd10 --- /dev/null +++ b/packages/frontend/src/pages/settings/api.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue new file mode 100644 index 0000000000..05abadff23 --- /dev/null +++ b/packages/frontend/src/pages/settings/apps.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..2caad22b7b --- /dev/null +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -0,0 +1,46 @@ + + + diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue new file mode 100644 index 0000000000..82cefe05d5 --- /dev/null +++ b/packages/frontend/src/pages/settings/deck.vue @@ -0,0 +1,39 @@ + + + diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..8a25ff39f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/delete-account.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue new file mode 100644 index 0000000000..2d45b1add8 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.vue @@ -0,0 +1,145 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue new file mode 100644 index 0000000000..3fff8c6b1d --- /dev/null +++ b/packages/frontend/src/pages/settings/email.vue @@ -0,0 +1,111 @@ + + + diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue new file mode 100644 index 0000000000..84d99d2fd7 --- /dev/null +++ b/packages/frontend/src/pages/settings/general.vue @@ -0,0 +1,196 @@ + + + diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..7db267c142 --- /dev/null +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue new file mode 100644 index 0000000000..01436cd554 --- /dev/null +++ b/packages/frontend/src/pages/settings/index.vue @@ -0,0 +1,291 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/instance-mute.vue b/packages/frontend/src/pages/settings/instance-mute.vue new file mode 100644 index 0000000000..54504de188 --- /dev/null +++ b/packages/frontend/src/pages/settings/instance-mute.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/frontend/src/pages/settings/integration.vue b/packages/frontend/src/pages/settings/integration.vue new file mode 100644 index 0000000000..557fe778e6 --- /dev/null +++ b/packages/frontend/src/pages/settings/integration.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..1cf33d34db --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -0,0 +1,61 @@ + + + diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue new file mode 100644 index 0000000000..0b2776ec90 --- /dev/null +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -0,0 +1,87 @@ + + + diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..e85fede157 --- /dev/null +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -0,0 +1,90 @@ + + + diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue new file mode 100644 index 0000000000..40bb202789 --- /dev/null +++ b/packages/frontend/src/pages/settings/other.vue @@ -0,0 +1,47 @@ + + + diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..550bba242e --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -0,0 +1,124 @@ + + + diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..905efd833d --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue new file mode 100644 index 0000000000..f427a170c4 --- /dev/null +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..915ca05767 --- /dev/null +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -0,0 +1,100 @@ + + + diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue new file mode 100644 index 0000000000..14eeeaaa11 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.vue @@ -0,0 +1,220 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..2748cd7d4e --- /dev/null +++ b/packages/frontend/src/pages/settings/reaction.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue new file mode 100644 index 0000000000..33f49eb3ef --- /dev/null +++ b/packages/frontend/src/pages/settings/security.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue new file mode 100644 index 0000000000..62627c6333 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ef60b2c3c9 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue new file mode 100644 index 0000000000..608222386e --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -0,0 +1,140 @@ + + + diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue new file mode 100644 index 0000000000..86c69fa2c3 --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -0,0 +1,54 @@ + + + diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..52a436e18d --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -0,0 +1,80 @@ + + + diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..409f0af650 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -0,0 +1,78 @@ + + + diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue new file mode 100644 index 0000000000..f37c213b06 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.vue @@ -0,0 +1,409 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue new file mode 100644 index 0000000000..c8ec1ea586 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -0,0 +1,95 @@ + + + diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue new file mode 100644 index 0000000000..00a547da69 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue new file mode 100644 index 0000000000..9be23ee4f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..6961d8151d --- /dev/null +++ b/packages/frontend/src/pages/settings/word-mute.vue @@ -0,0 +1,128 @@ + + + diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue new file mode 100644 index 0000000000..a7e797eeab --- /dev/null +++ b/packages/frontend/src/pages/share.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/packages/frontend/src/pages/signup-complete.vue b/packages/frontend/src/pages/signup-complete.vue new file mode 100644 index 0000000000..5459532310 --- /dev/null +++ b/packages/frontend/src/pages/signup-complete.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/packages/frontend/src/pages/tag.vue b/packages/frontend/src/pages/tag.vue new file mode 100644 index 0000000000..72775ed5c9 --- /dev/null +++ b/packages/frontend/src/pages/tag.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue new file mode 100644 index 0000000000..d8ff170ca2 --- /dev/null +++ b/packages/frontend/src/pages/theme-editor.vue @@ -0,0 +1,283 @@ + + + + + diff --git a/packages/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue new file mode 100644 index 0000000000..ae7b098b90 --- /dev/null +++ b/packages/frontend/src/pages/timeline.tutorial.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue new file mode 100644 index 0000000000..1c9e389367 --- /dev/null +++ b/packages/frontend/src/pages/timeline.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue new file mode 100644 index 0000000000..addc8db9e6 --- /dev/null +++ b/packages/frontend/src/pages/user-info.vue @@ -0,0 +1,485 @@ + + + + + + + diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue new file mode 100644 index 0000000000..fdb3167375 --- /dev/null +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/packages/frontend/src/pages/user/clips.vue b/packages/frontend/src/pages/user/clips.vue new file mode 100644 index 0000000000..8c71aacb0c --- /dev/null +++ b/packages/frontend/src/pages/user/clips.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/frontend/src/pages/user/follow-list.vue b/packages/frontend/src/pages/user/follow-list.vue new file mode 100644 index 0000000000..d42acd838f --- /dev/null +++ b/packages/frontend/src/pages/user/follow-list.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/packages/frontend/src/pages/user/followers.vue b/packages/frontend/src/pages/user/followers.vue new file mode 100644 index 0000000000..17c2843381 --- /dev/null +++ b/packages/frontend/src/pages/user/followers.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/frontend/src/pages/user/following.vue b/packages/frontend/src/pages/user/following.vue new file mode 100644 index 0000000000..03892ec03d --- /dev/null +++ b/packages/frontend/src/pages/user/following.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/frontend/src/pages/user/gallery.vue b/packages/frontend/src/pages/user/gallery.vue new file mode 100644 index 0000000000..b80e83fb11 --- /dev/null +++ b/packages/frontend/src/pages/user/gallery.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue new file mode 100644 index 0000000000..43c1b37e1d --- /dev/null +++ b/packages/frontend/src/pages/user/home.vue @@ -0,0 +1,530 @@ + + + + + diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue new file mode 100644 index 0000000000..523072d2e6 --- /dev/null +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -0,0 +1,52 @@ + + + diff --git a/packages/frontend/src/pages/user/index.photos.vue b/packages/frontend/src/pages/user/index.photos.vue new file mode 100644 index 0000000000..b33979a79d --- /dev/null +++ b/packages/frontend/src/pages/user/index.photos.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue new file mode 100644 index 0000000000..41983a5ae8 --- /dev/null +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue new file mode 100644 index 0000000000..6e895cd8d7 --- /dev/null +++ b/packages/frontend/src/pages/user/index.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/packages/frontend/src/pages/user/pages.vue b/packages/frontend/src/pages/user/pages.vue new file mode 100644 index 0000000000..7833d6c42c --- /dev/null +++ b/packages/frontend/src/pages/user/pages.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/packages/frontend/src/pages/user/reactions.vue b/packages/frontend/src/pages/user/reactions.vue new file mode 100644 index 0000000000..ab3df34301 --- /dev/null +++ b/packages/frontend/src/pages/user/reactions.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue new file mode 100644 index 0000000000..bfa54d39f2 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -0,0 +1,309 @@ + + + + + + + diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue new file mode 100644 index 0000000000..8230adaf1f --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.b.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/packages/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue new file mode 100644 index 0000000000..d2d07bb1f0 --- /dev/null +++ b/packages/frontend/src/pages/welcome.entrance.c.vue @@ -0,0 +1,306 @@ + + + + + diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue new file mode 100644 index 0000000000..2729d30d4b --- /dev/null +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue new file mode 100644 index 0000000000..d6a88540d1 --- /dev/null +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/packages/frontend/src/pages/welcome.vue b/packages/frontend/src/pages/welcome.vue new file mode 100644 index 0000000000..a1c3fc2abb --- /dev/null +++ b/packages/frontend/src/pages/welcome.vue @@ -0,0 +1,30 @@ + + + diff --git a/packages/frontend/src/pizzax.ts b/packages/frontend/src/pizzax.ts new file mode 100644 index 0000000000..642e1f4f7f --- /dev/null +++ b/packages/frontend/src/pizzax.ts @@ -0,0 +1,169 @@ +// PIZZAX --- A lightweight store + +import { onUnmounted, Ref, ref, watch } from 'vue'; +import { $i } from './account'; +import { api } from './os'; +import { stream } from './stream'; + +type StateDef = Record; + +type ArrayElement = A extends readonly (infer T)[] ? T : never; + +const connection = $i && stream.useChannel('main'); + +export class Storage { + public readonly key: string; + public readonly keyForLocalStorage: string; + + public readonly def: T; + + // TODO: これが実装されたらreadonlyにしたい: https://github.com/microsoft/TypeScript/issues/37487 + public readonly state: { [K in keyof T]: T[K]['default'] }; + public readonly reactiveState: { [K in keyof T]: Ref }; + + constructor(key: string, def: T) { + this.key = key; + this.keyForLocalStorage = 'pizzax::' + key; + this.def = def; + + // TODO: indexedDBにする + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + const deviceAccountState = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}') : {}; + const registryCache = $i ? JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}') : {}; + + const state = {}; + const reactiveState = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'device' && Object.prototype.hasOwnProperty.call(deviceState, k)) { + state[k] = deviceState[k]; + } else if (v.where === 'account' && $i && Object.prototype.hasOwnProperty.call(registryCache, k)) { + state[k] = registryCache[k]; + } else if (v.where === 'deviceAccount' && Object.prototype.hasOwnProperty.call(deviceAccountState, k)) { + state[k] = deviceAccountState[k]; + } else { + state[k] = v.default; + if (_DEV_) console.log('Use default value', k, v.default); + } + } + for (const [k, v] of Object.entries(state)) { + reactiveState[k] = ref(v); + } + this.state = state as any; + this.reactiveState = reactiveState as any; + + if ($i) { + // なぜかsetTimeoutしないとapi関数内でエラーになる(おそらく循環参照してることに原因がありそう) + window.setTimeout(() => { + api('i/registry/get-all', { scope: ['client', this.key] }).then(kvs => { + const cache = {}; + for (const [k, v] of Object.entries(def)) { + if (v.where === 'account') { + if (Object.prototype.hasOwnProperty.call(kvs, k)) { + state[k] = kvs[k]; + reactiveState[k].value = kvs[k]; + cache[k] = kvs[k]; + } else { + state[k] = v.default; + reactiveState[k].value = v.default; + } + } + } + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + }); + }, 1); + // streamingのuser storage updateイベントを監視して更新 + connection?.on('registryUpdated', ({ scope, key, value }: { scope: string[], key: keyof T, value: T[typeof key]['default'] }) => { + if (scope.length !== 2 || scope[0] !== 'client' || scope[1] !== this.key || this.state[key] === value) return; + + this.state[key] = value; + this.reactiveState[key].value = value; + + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + if (cache[key] !== value) { + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + } + }); + } + } + + public set(key: K, value: T[K]['default']): void { + if (_DEV_) console.log('set', key, value); + + this.state[key] = value; + this.reactiveState[key].value = value; + + switch (this.def[key].where) { + case 'device': { + const deviceState = JSON.parse(localStorage.getItem(this.keyForLocalStorage) || '{}'); + deviceState[key] = value; + localStorage.setItem(this.keyForLocalStorage, JSON.stringify(deviceState)); + break; + } + case 'deviceAccount': { + if ($i == null) break; + const deviceAccountState = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::' + $i.id) || '{}'); + deviceAccountState[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::' + $i.id, JSON.stringify(deviceAccountState)); + break; + } + case 'account': { + if ($i == null) break; + const cache = JSON.parse(localStorage.getItem(this.keyForLocalStorage + '::cache::' + $i.id) || '{}'); + cache[key] = value; + localStorage.setItem(this.keyForLocalStorage + '::cache::' + $i.id, JSON.stringify(cache)); + api('i/registry/set', { + scope: ['client', this.key], + key: key, + value: value, + }); + break; + } + } + } + + public push(key: K, value: ArrayElement): void { + const currentState = this.state[key]; + this.set(key, [...currentState, value]); + } + + public reset(key: keyof T) { + this.set(key, this.def[key].default); + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public makeGetterSetter(key: K, getter?: (v: T[K]) => unknown, setter?: (v: unknown) => T[K]) { + const valueRef = ref(this.state[key]); + + const stop = watch(this.reactiveState[key], val => { + valueRef.value = val; + }); + + // NOTE: vueコンポーネント内で呼ばれない限りは、onUnmounted は無意味なのでメモリリークする + onUnmounted(() => { + stop(); + }); + + // TODO: VueのcustomRef使うと良い感じになるかも + return { + get: () => { + if (getter) { + return getter(valueRef.value); + } else { + return valueRef.value; + } + }, + set: (value: unknown) => { + const val = setter ? setter(value) : value; + this.set(key, val); + valueRef.value = val; + }, + }; + } +} diff --git a/packages/frontend/src/plugin.ts b/packages/frontend/src/plugin.ts new file mode 100644 index 0000000000..3a00cd0455 --- /dev/null +++ b/packages/frontend/src/plugin.ts @@ -0,0 +1,123 @@ +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { deserialize } from '@syuilo/aiscript/built/serializer'; +import { jsToVal } from '@syuilo/aiscript/built/interpreter/util'; +import { createAiScriptEnv } from '@/scripts/aiscript/api'; +import { inputText } from '@/os'; +import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store'; + +const pluginContexts = new Map(); + +export function install(plugin) { + console.info('Plugin installed:', plugin.name, 'v' + plugin.version); + + const aiscript = new AiScript(createPluginEnv({ + plugin: plugin, + storageKey: 'plugins:' + plugin.id, + }), { + in: (q) => { + return new Promise(ok => { + inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + }); + + initPlugin({ plugin, aiscript }); + + aiscript.exec(deserialize(plugin.ast)); +} + +function createPluginEnv(opts) { + const config = new Map(); + for (const [k, v] of Object.entries(opts.plugin.config || {})) { + config.set(k, jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); + } + + return { + ...createAiScriptEnv({ ...opts, token: opts.plugin.token }), + //#region Deprecated + 'Mk:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Mk:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + //#endregion + 'Plugin:register_post_form_action': values.FN_NATIVE(([title, handler]) => { + registerPostFormAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_user_action': values.FN_NATIVE(([title, handler]) => { + registerUserAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_action': values.FN_NATIVE(([title, handler]) => { + registerNoteAction({ pluginId: opts.plugin.id, title: title.value, handler }); + }), + 'Plugin:register_note_view_interruptor': values.FN_NATIVE(([handler]) => { + registerNoteViewInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:register_note_post_interruptor': values.FN_NATIVE(([handler]) => { + registerNotePostInterruptor({ pluginId: opts.plugin.id, handler }); + }), + 'Plugin:open_url': values.FN_NATIVE(([url]) => { + window.open(url.value, '_blank'); + }), + 'Plugin:config': values.OBJ(config), + }; +} + +function initPlugin({ plugin, aiscript }) { + pluginContexts.set(plugin.id, aiscript); +} + +function registerPostFormAction({ pluginId, title, handler }) { + postFormActions.push({ + title, handler: (form, update) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(form), values.FN_NATIVE(([key, value]) => { + update(key.value, value.value); + })]); + }, + }); +} + +function registerUserAction({ pluginId, title, handler }) { + userActions.push({ + title, handler: (user) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(user)]); + }, + }); +} + +function registerNoteAction({ pluginId, title, handler }) { + noteActions.push({ + title, handler: (note) => { + pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)]); + }, + }); +} + +function registerNoteViewInterruptor({ pluginId, handler }) { + noteViewInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + }, + }); +} + +function registerNotePostInterruptor({ pluginId, handler }) { + notePostInterruptors.push({ + handler: async (note) => { + return utils.valToJs(await pluginContexts.get(pluginId).execFn(handler, [utils.jsToVal(note)])); + }, + }); +} diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts new file mode 100644 index 0000000000..111b15e0a6 --- /dev/null +++ b/packages/frontend/src/router.ts @@ -0,0 +1,501 @@ +import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue'; +import { Router } from '@/nirax'; +import { $i, iAmModerator } from '@/account'; +import MkLoading from '@/pages/_loading_.vue'; +import MkError from '@/pages/_error_.vue'; +import { ui } from '@/config'; + +const page = (loader: AsyncComponentLoader) => defineAsyncComponent({ + loader: loader, + loadingComponent: MkLoading, + errorComponent: MkError, +}); + +export const routes = [{ + path: '/@:initUser/pages/:initPageName/view-source', + component: page(() => import('./pages/page-editor/page-editor.vue')), +}, { + path: '/@:username/pages/:pageName', + component: page(() => import('./pages/page.vue')), +}, { + path: '/@:acct/following', + component: page(() => import('./pages/user/following.vue')), +}, { + path: '/@:acct/followers', + component: page(() => import('./pages/user/followers.vue')), +}, { + name: 'user', + path: '/@:acct/:page?', + component: page(() => import('./pages/user/index.vue')), +}, { + name: 'note', + path: '/notes/:noteId', + component: page(() => import('./pages/note.vue')), +}, { + path: '/clips/:clipId', + component: page(() => import('./pages/clip.vue')), +}, { + path: '/user-info/:userId', + component: page(() => import('./pages/user-info.vue')), +}, { + path: '/instance-info/:host', + component: page(() => import('./pages/instance-info.vue')), +}, { + name: 'settings', + path: '/settings', + component: page(() => import('./pages/settings/index.vue')), + loginRequired: true, + children: [{ + path: '/profile', + name: 'profile', + component: page(() => import('./pages/settings/profile.vue')), + }, { + path: '/privacy', + name: 'privacy', + component: page(() => import('./pages/settings/privacy.vue')), + }, { + path: '/reaction', + name: 'reaction', + component: page(() => import('./pages/settings/reaction.vue')), + }, { + path: '/drive', + name: 'drive', + component: page(() => import('./pages/settings/drive.vue')), + }, { + path: '/notifications', + name: 'notifications', + component: page(() => import('./pages/settings/notifications.vue')), + }, { + path: '/email', + name: 'email', + component: page(() => import('./pages/settings/email.vue')), + }, { + path: '/integration', + name: 'integration', + component: page(() => import('./pages/settings/integration.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('./pages/settings/security.vue')), + }, { + path: '/general', + name: 'general', + component: page(() => import('./pages/settings/general.vue')), + }, { + path: '/theme/install', + name: 'theme', + component: page(() => import('./pages/settings/theme.install.vue')), + }, { + path: '/theme/manage', + name: 'theme', + component: page(() => import('./pages/settings/theme.manage.vue')), + }, { + path: '/theme', + name: 'theme', + component: page(() => import('./pages/settings/theme.vue')), + }, { + path: '/navbar', + name: 'navbar', + component: page(() => import('./pages/settings/navbar.vue')), + }, { + path: '/statusbar', + name: 'statusbar', + component: page(() => import('./pages/settings/statusbar.vue')), + }, { + path: '/sounds', + name: 'sounds', + component: page(() => import('./pages/settings/sounds.vue')), + }, { + path: '/plugin/install', + name: 'plugin', + component: page(() => import('./pages/settings/plugin.install.vue')), + }, { + path: '/plugin', + name: 'plugin', + component: page(() => import('./pages/settings/plugin.vue')), + }, { + path: '/import-export', + name: 'import-export', + component: page(() => import('./pages/settings/import-export.vue')), + }, { + path: '/instance-mute', + name: 'instance-mute', + component: page(() => import('./pages/settings/instance-mute.vue')), + }, { + path: '/mute-block', + name: 'mute-block', + component: page(() => import('./pages/settings/mute-block.vue')), + }, { + path: '/word-mute', + name: 'word-mute', + component: page(() => import('./pages/settings/word-mute.vue')), + }, { + path: '/api', + name: 'api', + component: page(() => import('./pages/settings/api.vue')), + }, { + path: '/apps', + name: 'api', + component: page(() => import('./pages/settings/apps.vue')), + }, { + path: '/webhook/edit/:webhookId', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.edit.vue')), + }, { + path: '/webhook/new', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.new.vue')), + }, { + path: '/webhook', + name: 'webhook', + component: page(() => import('./pages/settings/webhook.vue')), + }, { + path: '/deck', + name: 'deck', + component: page(() => import('./pages/settings/deck.vue')), + }, { + path: '/preferences-backups', + name: 'preferences-backups', + component: page(() => import('./pages/settings/preferences-backups.vue')), + }, { + path: '/custom-css', + name: 'general', + component: page(() => import('./pages/settings/custom-css.vue')), + }, { + path: '/accounts', + name: 'profile', + component: page(() => import('./pages/settings/accounts.vue')), + }, { + path: '/account-info', + name: 'other', + component: page(() => import('./pages/settings/account-info.vue')), + }, { + path: '/delete-account', + name: 'other', + component: page(() => import('./pages/settings/delete-account.vue')), + }, { + path: '/other', + name: 'other', + component: page(() => import('./pages/settings/other.vue')), + }, { + path: '/', + component: page(() => import('./pages/_empty_.vue')), + }], +}, { + path: '/reset-password/:token?', + component: page(() => import('./pages/reset-password.vue')), +}, { + path: '/signup-complete/:code', + component: page(() => import('./pages/signup-complete.vue')), +}, { + path: '/announcements', + component: page(() => import('./pages/announcements.vue')), +}, { + path: '/about', + component: page(() => import('./pages/about.vue')), + hash: 'initialTab', +}, { + path: '/about-misskey', + component: page(() => import('./pages/about-misskey.vue')), +}, { + path: '/theme-editor', + component: page(() => import('./pages/theme-editor.vue')), + loginRequired: true, +}, { + path: '/explore/tags/:tag', + component: page(() => import('./pages/explore.vue')), +}, { + path: '/explore', + component: page(() => import('./pages/explore.vue')), +}, { + path: '/search', + component: page(() => import('./pages/search.vue')), + query: { + q: 'query', + channel: 'channel', + }, +}, { + path: '/authorize-follow', + component: page(() => import('./pages/follow.vue')), + loginRequired: true, +}, { + path: '/share', + component: page(() => import('./pages/share.vue')), + loginRequired: true, +}, { + path: '/api-console', + component: page(() => import('./pages/api-console.vue')), + loginRequired: true, +}, { + path: '/mfm-cheat-sheet', + component: page(() => import('./pages/mfm-cheat-sheet.vue')), +}, { + path: '/scratchpad', + component: page(() => import('./pages/scratchpad.vue')), +}, { + path: '/preview', + component: page(() => import('./pages/preview.vue')), +}, { + path: '/auth/:token', + component: page(() => import('./pages/auth.vue')), +}, { + path: '/miauth/:session', + component: page(() => import('./pages/miauth.vue')), + query: { + callback: 'callback', + name: 'name', + icon: 'icon', + permission: 'permission', + }, +}, { + path: '/tags/:tag', + component: page(() => import('./pages/tag.vue')), +}, { + path: '/pages/new', + component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages/edit/:initPageId', + component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages', + component: page(() => import('./pages/pages.vue')), +}, { + path: '/gallery/:postId/edit', + component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/new', + component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/:postId', + component: page(() => import('./pages/gallery/post.vue')), +}, { + path: '/gallery', + component: page(() => import('./pages/gallery/index.vue')), +}, { + path: '/channels/:channelId/edit', + component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/new', + component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/:channelId', + component: page(() => import('./pages/channel.vue')), +}, { + path: '/channels', + component: page(() => import('./pages/channels.vue')), +}, { + path: '/registry/keys/system/:path(*)?', + component: page(() => import('./pages/registry.keys.vue')), +}, { + path: '/registry/value/system/:path(*)?', + component: page(() => import('./pages/registry.value.vue')), +}, { + path: '/registry', + component: page(() => import('./pages/registry.vue')), +}, { + path: '/admin/file/:fileId', + component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')), +}, { + path: '/admin', + component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')), + children: [{ + path: '/overview', + name: 'overview', + component: page(() => import('./pages/admin/overview.vue')), + }, { + path: '/users', + name: 'users', + component: page(() => import('./pages/admin/users.vue')), + }, { + path: '/emojis', + name: 'emojis', + component: page(() => import('./pages/admin/emojis.vue')), + }, { + path: '/queue', + name: 'queue', + component: page(() => import('./pages/admin/queue.vue')), + }, { + path: '/files', + name: 'files', + component: page(() => import('./pages/admin/files.vue')), + }, { + path: '/announcements', + name: 'announcements', + component: page(() => import('./pages/admin/announcements.vue')), + }, { + path: '/ads', + name: 'ads', + component: page(() => import('./pages/admin/ads.vue')), + }, { + path: '/database', + name: 'database', + component: page(() => import('./pages/admin/database.vue')), + }, { + path: '/abuses', + name: 'abuses', + component: page(() => import('./pages/admin/abuses.vue')), + }, { + path: '/settings', + name: 'settings', + component: page(() => import('./pages/admin/settings.vue')), + }, { + path: '/email-settings', + name: 'email-settings', + component: page(() => import('./pages/admin/email-settings.vue')), + }, { + path: '/object-storage', + name: 'object-storage', + component: page(() => import('./pages/admin/object-storage.vue')), + }, { + path: '/security', + name: 'security', + component: page(() => import('./pages/admin/security.vue')), + }, { + path: '/relays', + name: 'relays', + component: page(() => import('./pages/admin/relays.vue')), + }, { + path: '/integrations', + name: 'integrations', + component: page(() => import('./pages/admin/integrations.vue')), + }, { + path: '/instance-block', + name: 'instance-block', + component: page(() => import('./pages/admin/instance-block.vue')), + }, { + path: '/proxy-account', + name: 'proxy-account', + component: page(() => import('./pages/admin/proxy-account.vue')), + }, { + path: '/other-settings', + name: 'other-settings', + component: page(() => import('./pages/admin/other-settings.vue')), + }, { + path: '/', + component: page(() => import('./pages/_empty_.vue')), + }], +}, { + path: '/my/notifications', + component: page(() => import('./pages/notifications.vue')), + loginRequired: true, +}, { + path: '/my/favorites', + component: page(() => import('./pages/favorites.vue')), + loginRequired: true, +}, { + name: 'messaging', + path: '/my/messaging', + component: page(() => import('./pages/messaging/index.vue')), + loginRequired: true, +}, { + path: '/my/messaging/:userAcct', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/messaging/group/:groupId', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/drive/folder/:folder', + component: page(() => import('./pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/drive', + component: page(() => import('./pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/follow-requests', + component: page(() => import('./pages/follow-requests.vue')), + loginRequired: true, +}, { + path: '/my/lists/:listId', + component: page(() => import('./pages/my-lists/list.vue')), + loginRequired: true, +}, { + path: '/my/lists', + component: page(() => import('./pages/my-lists/index.vue')), + loginRequired: true, +}, { + path: '/my/clips', + component: page(() => import('./pages/my-clips/index.vue')), + loginRequired: true, +}, { + path: '/my/antennas/create', + component: page(() => import('./pages/my-antennas/create.vue')), + loginRequired: true, +}, { + path: '/my/antennas/:antennaId', + component: page(() => import('./pages/my-antennas/edit.vue')), + loginRequired: true, +}, { + path: '/my/antennas', + component: page(() => import('./pages/my-antennas/index.vue')), + loginRequired: true, +}, { + path: '/timeline/list/:listId', + component: page(() => import('./pages/user-list-timeline.vue')), + loginRequired: true, +}, { + path: '/timeline/antenna/:antennaId', + component: page(() => import('./pages/antenna-timeline.vue')), + loginRequired: true, +}, { + name: 'index', + path: '/', + component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')), + globalCacheKey: 'index', +}, { + path: '/:(*)', + component: page(() => import('./pages/not-found.vue')), +}]; + +export const mainRouter = new Router(routes, location.pathname + location.search + location.hash); + +window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); + +// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする +// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも + +const scrollPosStore = new Map(); + +window.setInterval(() => { + scrollPosStore.set(window.history.state?.key, window.scrollY); +}, 1000); + +mainRouter.addListener('push', ctx => { + window.history.pushState({ key: ctx.key }, '', ctx.path); + const scrollPos = scrollPosStore.get(ctx.key) ?? 0; + window.scroll({ top: scrollPos, behavior: 'instant' }); + if (scrollPos !== 0) { + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + window.scroll({ top: scrollPos, behavior: 'instant' }); + }, 100); + } +}); + +mainRouter.addListener('replace', ctx => { + window.history.replaceState({ key: ctx.key }, '', ctx.path); +}); + +mainRouter.addListener('same', () => { + window.scroll({ top: 0, behavior: 'smooth' }); +}); + +window.addEventListener('popstate', (event) => { + mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false); + const scrollPos = scrollPosStore.get(event.state?.key) ?? 0; + window.scroll({ top: scrollPos, behavior: 'instant' }); + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + window.scroll({ top: scrollPos, behavior: 'instant' }); + }, 100); +}); + +export function useRouter(): Router { + return inject('router', null) ?? mainRouter; +} diff --git a/packages/frontend/src/scripts/2fa.ts b/packages/frontend/src/scripts/2fa.ts new file mode 100644 index 0000000000..62a38ff02a --- /dev/null +++ b/packages/frontend/src/scripts/2fa.ts @@ -0,0 +1,33 @@ +export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') { + switch (encoding) { + case 'ascii': + return Uint8Array.from(string, c => c.charCodeAt(0)); + case 'base64': + return Uint8Array.from( + atob( + string + .replace(/-/g, '+') + .replace(/_/g, '/'), + ), + c => c.charCodeAt(0), + ); + case 'hex': + return new Uint8Array( + string + .match(/.{1,2}/g) + .map(byte => parseInt(byte, 16)), + ); + } +} + +export function hexify(buffer: ArrayBuffer) { + return Array.from(new Uint8Array(buffer)) + .reduce( + (str, byte) => str + byte.toString(16).padStart(2, '0'), + '', + ); +} + +export function stringify(buffer: ArrayBuffer) { + return String.fromCharCode(... new Uint8Array(buffer)); +} diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts new file mode 100644 index 0000000000..6debcb8a13 --- /dev/null +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -0,0 +1,43 @@ +import { utils, values } from '@syuilo/aiscript'; +import * as os from '@/os'; +import { $i } from '@/account'; + +export function createAiScriptEnv(opts) { + let apiRequests = 0; + return { + USER_ID: $i ? values.STR($i.id) : values.NULL, + USER_NAME: $i ? values.STR($i.name) : values.NULL, + USER_USERNAME: $i ? values.STR($i.username) : values.NULL, + 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + await os.alert({ + type: type ? type.value : 'info', + title: title.value, + text: text.value, + }); + }), + 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + const confirm = await os.confirm({ + type: type ? type.value : 'question', + title: title.value, + text: text.value, + }); + return confirm.canceled ? values.FALSE : values.TRUE; + }), + 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { + if (token) utils.assertString(token); + apiRequests++; + if (apiRequests > 16) return values.NULL; + const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null)); + return utils.jsToVal(res); + }), + 'Mk:save': values.FN_NATIVE(([key, value]) => { + utils.assertString(key); + localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value))); + return values.NULL; + }), + 'Mk:load': values.FN_NATIVE(([key]) => { + utils.assertString(key); + return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value))); + }), + }; +} diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts new file mode 100644 index 0000000000..4620c8b735 --- /dev/null +++ b/packages/frontend/src/scripts/array.ts @@ -0,0 +1,149 @@ +import { EndoRelation, Predicate } from './relation'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf(f: Predicate, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function uniqueBy(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] { + const map = new Map(); + + for (const value of values) { + const key = keySelector(value); + if (!map.has(key)) map.set(key, value); + } + + return [...map.values()]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy(f: EndoRelation, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record, item: T) => { + const key = keySelector(item); + if (typeof obj[key] === 'undefined') { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile(f: Predicate, xs: T[]): T[] { + const ys: T[] = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts new file mode 100644 index 0000000000..1bae3790f5 --- /dev/null +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -0,0 +1,276 @@ +import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; +import getCaretCoordinates from 'textarea-caret'; +import { toASCII } from 'punycode/'; +import { popup } from '@/os'; + +export class Autocomplete { + private suggestion: { + x: Ref; + y: Ref; + q: Ref; + close: () => void; + } | null; + private textarea: HTMLInputElement | HTMLTextAreaElement; + private currentType: string; + private textRef: Ref; + private opening: boolean; + + private get text(): string { + // Use raw .value to get the latest value + // (Because v-model does not update while composition) + return this.textarea.value; + } + + private set text(text: string) { + // Use ref value to notify other watchers + // (Because .value setter never fires input/change events) + this.textRef.value = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.textRef = textRef; + this.opening = false; + + this.attach(); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caretPos = this.textarea.selectionStart; + const text = this.text.substr(0, caretPos).split('\n').pop()!; + + const mentionIndex = text.lastIndexOf('@'); + const hashtagIndex = text.lastIndexOf('#'); + const emojiIndex = text.lastIndexOf(':'); + const mfmTagIndex = text.lastIndexOf('$'); + + const max = Math.max( + mentionIndex, + hashtagIndex, + emojiIndex, + mfmTagIndex); + + if (max === -1) { + this.close(); + return; + } + + const isMention = mentionIndex !== -1; + const isHashtag = hashtagIndex !== -1; + const isMfmTag = mfmTagIndex !== -1; + const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); + + let opened = false; + + if (isMention) { + const username = text.substr(mentionIndex + 1); + if (username !== '' && username.match(/^[a-zA-Z0-9_]+$/)) { + this.open('user', username); + opened = true; + } else if (username === '') { + this.open('user', null); + opened = true; + } + } + + if (isHashtag && !opened) { + const hashtag = text.substr(hashtagIndex + 1); + if (!hashtag.includes(' ')) { + this.open('hashtag', hashtag); + opened = true; + } + } + + if (isEmoji && !opened) { + const emoji = text.substr(emojiIndex + 1); + if (!emoji.includes(' ')) { + this.open('emoji', emoji); + opened = true; + } + } + + if (isMfmTag && !opened) { + const mfmTag = text.substr(mfmTagIndex + 1); + if (!mfmTag.includes(' ')) { + this.open('mfmTag', mfmTag.replace('[', '')); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private async open(type: string, q: string | null) { + if (type !== this.currentType) { + this.close(); + } + if (this.opening) return; + this.opening = true; + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x.value = x; + this.suggestion.y.value = y; + this.suggestion.q.value = q; + + this.opening = false; + } else { + const _x = ref(x); + const _y = ref(y); + const _q = ref(q); + + const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), { + textarea: this.textarea, + close: this.close, + type: type, + q: _q, + x: _x, + y: _y, + }, { + done: (res) => { + this.complete(res); + }, + }); + + this.suggestion = { + q: _q, + x: _x, + y: _y, + close: () => dispose(), + }; + + this.opening = false; + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.close(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete({ type, value }) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type === 'user') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(caret); + + const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; + + // 挿入 + this.text = `${trimmedBefore}@${acct} ${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (acct.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'hashtag') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('#')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}#${value} ${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'emoji') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + value.length; + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'mfmTag') { + const source = this.text; + + const before = source.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('$')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}$[${value} ]${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 3); + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts new file mode 100644 index 0000000000..8e3c4436b2 --- /dev/null +++ b/packages/frontend/src/scripts/chart-vline.ts @@ -0,0 +1,21 @@ +export const chartVLine = (vLineColor: string) => ({ + id: 'vLine', + beforeDraw(chart, args, options) { + if (chart.tooltip?._active?.length) { + const activePoint = chart.tooltip._active[0]; + const ctx = chart.ctx; + const x = activePoint.element.x; + const topY = chart.scales.y.top; + const bottomY = chart.scales.y.bottom; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, bottomY); + ctx.lineTo(x, topY); + ctx.lineWidth = 1; + ctx.strokeStyle = vLineColor; + ctx.stroke(); + ctx.restore(); + } + }, +}); diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts new file mode 100644 index 0000000000..35d40a6e08 --- /dev/null +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -0,0 +1,37 @@ +export function checkWordMute(note: Record, me: Record | null | undefined, mutedWords: Array): boolean { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + if (mutedWords.length > 0) { + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; + + const matched = mutedWords.some(filter => { + if (Array.isArray(filter)) { + // Clean up + const filteredFilter = filter.filter(keyword => keyword !== ''); + if (filteredFilter.length === 0) return false; + + return filteredFilter.every(keyword => text.includes(keyword)); + } else { + // represents RegExp + const regexp = filter.match(/^\/(.+)\/(.*)$/); + + // This should never happen due to input sanitisation. + if (!regexp) return false; + + try { + return new RegExp(regexp[1], regexp[2]).test(text); + } catch (err) { + // This should never happen due to input sanitisation. + return false; + } + } + }); + + if (matched) return true; + } + + return false; +} diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts new file mode 100644 index 0000000000..16fad24129 --- /dev/null +++ b/packages/frontend/src/scripts/clone.ts @@ -0,0 +1,18 @@ +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html + +type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; + +export function deepClone(x: T): T { + if (typeof x === 'object') { + if (x === null) return x; + if (Array.isArray(x)) return x.map(deepClone) as T; + const obj = {} as Record; + for (const [k, v] of Object.entries(x)) { + obj[k] = deepClone(v); + } + return obj as T; + } else { + return x; + } +} diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/scripts/collect-page-vars.ts new file mode 100644 index 0000000000..76b68beaf6 --- /dev/null +++ b/packages/frontend/src/scripts/collect-page-vars.ts @@ -0,0 +1,68 @@ +interface StringPageVar { + name: string, + type: 'string', + value: string +} + +interface NumberPageVar { + name: string, + type: 'number', + value: number +} + +interface BooleanPageVar { + name: string, + type: 'boolean', + value: boolean +} + +type PageVar = StringPageVar | NumberPageVar | BooleanPageVar; + +export function collectPageVars(content): PageVar[] { + const pageVars: PageVar[] = []; + const collect = (xs: any[]): void => { + for (const x of xs) { + if (x.type === 'textInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'textareaInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'numberInput') { + pageVars.push({ + name: x.name, + type: 'number', + value: x.default || 0, + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default || false, + }); + } else if (x.type === 'counter') { + pageVars.push({ + name: x.name, + type: 'number', + value: 0, + }); + } else if (x.type === 'radioButton') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/scripts/contains.ts new file mode 100644 index 0000000000..256e09d293 --- /dev/null +++ b/packages/frontend/src/scripts/contains.ts @@ -0,0 +1,9 @@ +export default (parent, child, checkSame = true) => { + if (checkSame && parent === child) return true; + let node = child.parentNode; + while (node) { + if (node === parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..ab13cab970 --- /dev/null +++ b/packages/frontend/src/scripts/copy-to-clipboard.ts @@ -0,0 +1,33 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + // 空div 生成 + const tmp = document.createElement('div'); + // 選択用のタグ生成 + const pre = document.createElement('pre'); + + // 親要素のCSSで user-select: none だとコピーできないので書き換える + pre.style.webkitUserSelect = 'auto'; + pre.style.userSelect = 'auto'; + + tmp.appendChild(pre).textContent = val; + + // 要素を画面外へ + const s = tmp.style; + s.position = 'fixed'; + s.right = '200%'; + + // body に追加 + document.body.appendChild(tmp); + // 要素を選択 + document.getSelection().selectAllChildren(tmp); + + // クリップボードにコピー + const result = document.execCommand('copy'); + + // 要素削除 + document.body.removeChild(tmp); + + return result; +}; diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts new file mode 100644 index 0000000000..544cac0604 --- /dev/null +++ b/packages/frontend/src/scripts/device-kind.ts @@ -0,0 +1,10 @@ +import { defaultStore } from '@/store'; + +const ua = navigator.userAgent.toLowerCase(); +const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700); +const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua); + +export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind + : isSmartphone ? 'smartphone' + : isTablet ? 'tablet' + : 'desktop'; diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend/src/scripts/emoji-base.ts new file mode 100644 index 0000000000..3f05642d57 --- /dev/null +++ b/packages/frontend/src/scripts/emoji-base.ts @@ -0,0 +1,20 @@ +const twemojiSvgBase = '/twemoji'; +const fluentEmojiPngBase = '/fluent-emoji'; + +export function char2twemojiFilePath(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.join('-'); + return `${twemojiSvgBase}/${fileName}.svg`; +} + +export function char2fluentEmojiFilePath(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + // Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25 + if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.map(x => x!.padStart(4, '0')).join('-'); + return `${fluentEmojiPngBase}/${fileName}.png`; +} diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts new file mode 100644 index 0000000000..bc52fa7a43 --- /dev/null +++ b/packages/frontend/src/scripts/emojilist.ts @@ -0,0 +1,17 @@ +export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const; + +export type UnicodeEmojiDef = { + name: string; + keywords: string[]; + char: string; + category: typeof unicodeEmojiCategories[number]; +} + +// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb +import _emojilist from '../emojilist.json'; + +export const emojilist = _emojilist as UnicodeEmojiDef[]; + +export function getEmojiName(char: string): string | undefined { + return emojilist.find(emo => emo.char === char)?.name; +} diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts new file mode 100644 index 0000000000..af517f2672 --- /dev/null +++ b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts @@ -0,0 +1,9 @@ +export function extractAvgColorFromBlurhash(hash: string) { + return typeof hash === 'string' + ? '#' + [...hash.slice(2, 6)] + .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x)) + .reduce((a, c) => a * 83 + c, 0) + .toString(16) + .padStart(6, '0') + : undefined; +} diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/scripts/extract-mentions.ts new file mode 100644 index 0000000000..cc19b161a8 --- /dev/null +++ b/packages/frontend/src/scripts/extract-mentions.ts @@ -0,0 +1,11 @@ +// test is located in test/extract-mentions + +import * as mfm from 'mfm-js'; + +export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { + // TODO: 重複を削除 + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentions = mentionNodes.map(x => x.props); + + return mentions; +} diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/scripts/extract-url-from-mfm.ts new file mode 100644 index 0000000000..34e3eb6c19 --- /dev/null +++ b/packages/frontend/src/scripts/extract-url-from-mfm.ts @@ -0,0 +1,19 @@ +import * as mfm from 'mfm-js'; +import { unique } from '@/scripts/array'; + +// unique without hash +// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] +const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); + +export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { + const urlNodes = mfm.extract(nodes, (node) => { + return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent)); + }); + const urls: string[] = unique(urlNodes.map(x => x.props.url)); + + return urls.reduce((array, url) => { + const urlWithoutHash = removeHash(url); + if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url); + return array; + }, [] as string[]); +} diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts new file mode 100644 index 0000000000..d6802fa322 --- /dev/null +++ b/packages/frontend/src/scripts/focus.ts @@ -0,0 +1,27 @@ +export function focusPrev(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.previousElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll, + }); + } else { + focusPrev(el.previousElementSibling, true); + } + } +} + +export function focusNext(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.nextElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll, + }); + } else { + focusPrev(el.nextElementSibling, true); + } + } +} diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts new file mode 100644 index 0000000000..7f321cc0ae --- /dev/null +++ b/packages/frontend/src/scripts/form.ts @@ -0,0 +1,59 @@ +export type FormItem = { + label?: string; + type: 'string'; + default: string | null; + hidden?: boolean; + multiline?: boolean; +} | { + label?: string; + type: 'number'; + default: number | null; + hidden?: boolean; + step?: number; +} | { + label?: string; + type: 'boolean'; + default: boolean | null; + hidden?: boolean; +} | { + label?: string; + type: 'enum'; + default: string | null; + hidden?: boolean; + enum: string[]; +} | { + label?: string; + type: 'radio'; + default: unknown | null; + hidden?: boolean; + options: { + label: string; + value: unknown; + }[]; +} | { + label?: string; + type: 'object'; + default: Record | null; + hidden: true; +} | { + label?: string; + type: 'array'; + default: unknown[] | null; + hidden: true; +}; + +export type Form = Record; + +type GetItemType = + Item['type'] extends 'string' ? string : + Item['type'] extends 'number' ? number : + Item['type'] extends 'boolean' ? boolean : + Item['type'] extends 'radio' ? unknown : + Item['type'] extends 'enum' ? string : + Item['type'] extends 'array' ? unknown[] : + Item['type'] extends 'object' ? Record + : never; + +export type GetFormResultType = { + [P in keyof F]: GetItemType; +}; diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/scripts/format-time-string.ts new file mode 100644 index 0000000000..c20db5e827 --- /dev/null +++ b/packages/frontend/src/scripts/format-time-string.ts @@ -0,0 +1,50 @@ +const defaultLocaleStringFormats: {[index: string]: string} = { + 'weekday': 'narrow', + 'era': 'narrow', + 'year': 'numeric', + 'month': 'numeric', + 'day': 'numeric', + 'hour': 'numeric', + 'minute': 'numeric', + 'second': 'numeric', + 'timeZoneName': 'short', +}; + +function formatLocaleString(date: Date, format: string): string { + return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => { + if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) { + return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] }); + } else { + return match; + } + }); +} + +export function formatDateTimeString(date: Date, format: string): string { + return format + .replace(/yyyy/g, date.getFullYear().toString()) + .replace(/yy/g, date.getFullYear().toString().slice(-2)) + .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' })) + .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' })) + .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2)) + .replace(/M/g, (date.getMonth() + 1).toString()) + .replace(/dd/g, (`0${date.getDate()}`).slice(-2)) + .replace(/d/g, date.getDate().toString()) + .replace(/HH/g, (`0${date.getHours()}`).slice(-2)) + .replace(/H/g, date.getHours().toString()) + .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2)) + .replace(/h/g, ((date.getHours() % 12) || 12).toString()) + .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2)) + .replace(/m/g, date.getMinutes().toString()) + .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2)) + .replace(/s/g, date.getSeconds().toString()) + .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM'); +} + +export function formatTimeString(date: Date, format: string): string { + return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => { + if (localeformat) return formatLocaleString(date, localeformat); + if (datetimeformat) return formatDateTimeString(date, datetimeformat); + return match; + }); +} diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts new file mode 100644 index 0000000000..da7d622632 --- /dev/null +++ b/packages/frontend/src/scripts/gen-search-query.ts @@ -0,0 +1,30 @@ +import * as Acct from 'misskey-js/built/acct'; +import { host as localHost } from '@/config'; + +export async function genSearchQuery(v: any, q: string) { + let host: string; + let userId: string; + if (q.split(' ').some(x => x.startsWith('@'))) { + for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) { + if (at.includes('.')) { + if (at === localHost || at === '.') { + host = null; + } else { + host = at; + } + } else { + const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null); + if (user) { + userId = user.id; + } else { + // todo: show error + } + } + } + } + return { + query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), + host: host, + userId: userId, + }; +} diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/scripts/get-account-from-id.ts new file mode 100644 index 0000000000..1da897f176 --- /dev/null +++ b/packages/frontend/src/scripts/get-account-from-id.ts @@ -0,0 +1,7 @@ +import { get } from '@/scripts/idb-proxy'; + +export async function getAccountFromId(id: string) { + const accounts = await get('accounts') as { token: string; id: string; }[]; + if (!accounts) console.log('Accounts are not recorded'); + return accounts.find(account => account.id === id); +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts new file mode 100644 index 0000000000..7656770894 --- /dev/null +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -0,0 +1,341 @@ +import { defineAsyncComponent, Ref, inject } from 'vue'; +import * as misskey from 'misskey-js'; +import { pleaseLogin } from './please-login'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { url } from '@/config'; +import { noteActions } from '@/store'; +import { notePage } from '@/filters/note'; + +export function getNoteMenu(props: { + note: misskey.entities.Note; + menuButton: Ref; + translation: Ref; + translating: Ref; + isDeleted: Ref; + currentClipPage?: Ref; +}) { + const isRenote = ( + props.note.renote != null && + props.note.text == null && + props.note.fileIds.length === 0 && + props.note.poll == null + ); + + const appearNote = isRenote ? props.note.renote as misskey.entities.Note : props.note; + + function del(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.noteDeleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: appearNote.id, + }); + }); + } + + function delEdit(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteAndEditConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: appearNote.id, + }); + + os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); + }); + } + + function toggleFavorite(favorite: boolean): void { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: appearNote.id, + }); + } + + function toggleThreadMute(mute: boolean): void { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: appearNote.id, + }); + } + + function copyContent(): void { + copyToClipboard(appearNote.text); + os.success(); + } + + function copyLink(): void { + copyToClipboard(`${url}/notes/${appearNote.id}`); + os.success(); + } + + function togglePin(pin: boolean): void { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: appearNote.id, + }, undefined, null, res => { + if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.alert({ + type: 'error', + text: i18n.ts.pinLimitExceeded, + }); + } + }); + } + + async function clip(): Promise { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.promiseDialog( + os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, + }))], props.menuButton.value, { + }).then(focus); + } + + async function unclip(): Promise { + os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); + props.isDeleted.value = true; + } + + async function promote(): Promise { + const { canceled, result: days } = await os.inputNumber({ + title: i18n.ts.numberOfDays, + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: appearNote.id, + expiresAt: Date.now() + (86400000 * days), + }); + } + + function share(): void { + navigator.share({ + title: i18n.t('noteOf', { user: appearNote.user.name }), + text: appearNote.text, + url: `${url}/notes/${appearNote.id}`, + }); + } + function notedetails(): void { + os.pageWindow(`/notes/${appearNote.id}`); + } + async function translate(): Promise { + if (props.translation.value != null) return; + props.translating.value = true; + const res = await os.api('notes/translate', { + noteId: appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + props.translating.value = false; + props.translation.value = res; + } + + let menu; + if ($i) { + const statePromise = os.api('notes/state', { + noteId: appearNote.id, + }); + + menu = [ + ...( + props.currentClipPage?.value.userId === $i.id ? [{ + icon: 'ti ti-backspace', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, null] : [] + ), { + icon: 'ti ti-external-link', + text: i18n.ts.details, + action: notedetails, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined, + { + icon: 'ti ti-share', + text: i18n.ts.share, + action: share, + }, + instance.translatorAvailable ? { + icon: 'ti ti-language-hiragana', + text: i18n.ts.translate, + action: translate, + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'ti ti-star-off', + text: i18n.ts.unfavorite, + action: () => toggleFavorite(false), + } : { + icon: 'ti ti-star', + text: i18n.ts.favorite, + action: () => toggleFavorite(true), + }), + { + icon: 'ti ti-paperclip', + text: i18n.ts.clip, + action: () => clip(), + }, + statePromise.then(state => state.isMutedThread ? { + icon: 'ti ti-message-off', + text: i18n.ts.unmuteThread, + action: () => toggleThreadMute(false), + } : { + icon: 'ti ti-message-off', + text: i18n.ts.muteThread, + action: () => toggleThreadMute(true), + }), + appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => togglePin(false), + } : { + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => togglePin(true), + } : undefined, + /* + ...($i.isModerator || $i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: i18n.ts.promote, + action: promote + }] + : [] + ),*/ + ...(appearNote.userId !== $i.id ? [ + null, + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: () => { + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: appearNote.user, + initialComment: `Note: ${u}\n-----\n`, + }, {}, 'closed'); + }, + }] + : [] + ), + ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ + null, + appearNote.userId === $i.id ? { + icon: 'ti ti-edit', + text: i18n.ts.deleteAndEdit, + action: delEdit, + } : undefined, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: del, + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'ti ti-external-link', + text: i18n.ts.detailed, + action: openDetail, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(appearNote); + }, + }))]); + } + + return menu; +} diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts new file mode 100644 index 0000000000..d57e1c3029 --- /dev/null +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -0,0 +1,55 @@ +import * as misskey from 'misskey-js'; +import { i18n } from '@/i18n'; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note: misskey.entities.Note): string => { + if (note.deletedAt) { + return `(${i18n.ts.deletedNote})`; + } + + if (note.isHidden) { + return `(${i18n.ts.invisibleNote})`; + } + + let summary = ''; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } + + // ファイルが添付されているとき + if ((note.files || []).length !== 0) { + summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += ` (${i18n.ts.poll})`; + } + + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + + return summary.trim(); +}; diff --git a/packages/frontend/src/scripts/get-static-image-url.ts b/packages/frontend/src/scripts/get-static-image-url.ts new file mode 100644 index 0000000000..cbd1761983 --- /dev/null +++ b/packages/frontend/src/scripts/get-static-image-url.ts @@ -0,0 +1,19 @@ +import { url as instanceUrl } from '@/config'; +import * as url from '@/scripts/url'; + +export function getStaticImageUrl(baseUrl: string): string { + const u = new URL(baseUrl); + if (u.href.startsWith(`${instanceUrl}/proxy/`)) { + // もう既にproxyっぽそうだったらsearchParams付けるだけ + u.searchParams.set('static', '1'); + return u.href; + } + + // 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する + const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`; + + return `${instanceUrl}/proxy/${dummy}?${url.query({ + url: u.href, + static: '1', + })}`; +} diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts new file mode 100644 index 0000000000..2faacffdfc --- /dev/null +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -0,0 +1,253 @@ +import * as Acct from 'misskey-js/built/acct'; +import { defineAsyncComponent } from 'vue'; +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { host } from '@/config'; +import * as os from '@/os'; +import { userActions } from '@/store'; +import { $i, iAmModerator } from '@/account'; +import { mainRouter } from '@/router'; +import { Router } from '@/nirax'; + +export function getUserMenu(user, router: Router = mainRouter) { + const meId = $i ? $i.id : null; + + async function pushList() { + const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await os.api('users/lists/list'); + if (lists.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoLists, + }); + return; + } + const { canceled, result: listId } = await os.select({ + title: t, + items: lists.map(list => ({ + value: list.id, text: list.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/lists/push', { + listId: listId, + userId: user.id, + }); + } + + async function inviteGroup() { + const groups = await os.api('users/groups/owned'); + if (groups.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoGroups, + }); + return; + } + const { canceled, result: groupId } = await os.select({ + title: i18n.ts.group, + items: groups.map(group => ({ + value: group.id, text: group.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/groups/invite', { + groupId: groupId, + userId: user.id, + }); + } + + async function toggleMute() { + if (user.isMuted) { + os.apiWithDialog('mute/delete', { + userId: user.id, + }).then(() => { + user.isMuted = false; + }); + } else { + const { canceled, result: period } = await os.select({ + title: i18n.ts.mutePeriod, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'tenMinutes', text: i18n.ts.tenMinutes, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }], + default: 'indefinitely', + }); + if (canceled) return; + + const expiresAt = period === 'indefinitely' ? null + : period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10) + : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : null; + + os.apiWithDialog('mute/create', { + userId: user.id, + expiresAt, + }).then(() => { + user.isMuted = true; + }); + } + } + + async function toggleBlock() { + if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; + + os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: user.id, + }).then(() => { + user.isBlocking = !user.isBlocking; + }); + } + + async function toggleSilence() { + if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; + + os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { + userId: user.id, + }).then(() => { + user.isSilenced = !user.isSilenced; + }); + } + + async function toggleSuspend() { + if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: user.id, + }).then(() => { + user.isSuspended = !user.isSuspended; + }); + } + + function reportAbuse() { + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: user, + }, {}, 'closed'); + } + + async function getConfirmed(text: string): Promise { + const confirm = await os.confirm({ + type: 'warning', + title: 'confirm', + text, + }); + + return !confirm.canceled; + } + + async function invalidateFollow() { + os.apiWithDialog('following/invalidate', { + userId: user.id, + }).then(() => { + user.isFollowed = !user.isFollowed; + }); + } + + let menu = [{ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host || host}`); + }, + }, { + icon: 'ti ti-rss', + text: i18n.ts.copyRSS, + action: () => { + copyToClipboard(`${user.host || host}/@${user.username}.atom`); + } + }, { + icon: 'ti ti-info-circle', + text: i18n.ts.info, + action: () => { + router.push(`/user-info/${user.id}`); + }, + }, { + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + os.post({ specified: user }); + }, + }, meId !== user.id ? { + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts.startMessaging, + to: '/my/messaging/' + Acct.toString(user), + } : undefined, null, { + icon: 'ti ti-list', + text: i18n.ts.addToList, + action: pushList, + }, meId !== user.id ? { + icon: 'ti ti-users', + text: i18n.ts.inviteToGroup, + action: inviteGroup, + } : undefined] as any; + + if ($i && meId !== user.id) { + menu = menu.concat([null, { + icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', + text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, + action: toggleMute, + }, { + icon: 'ti ti-ban', + text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, + action: toggleBlock, + }]); + + if (user.isFollowed) { + menu = menu.concat([{ + icon: 'ti ti-link-off', + text: i18n.ts.breakFollow, + action: invalidateFollow, + }]); + } + + menu = menu.concat([null, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }]); + + if (iAmModerator) { + menu = menu.concat([null, { + icon: 'ti ti-microphone-2-off', + text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence, + action: toggleSilence, + }, { + icon: 'ti ti-snowflake', + text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend, + action: toggleSuspend, + }]); + } + } + + if ($i && meId === user.id) { + menu = menu.concat([null, { + icon: 'ti ti-pencil', + text: i18n.ts.editProfile, + action: () => { + router.push('/settings/profile'); + }, + }]); + } + + if (userActions.length > 0) { + menu = menu.concat([null, ...userActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(user); + }, + }))]); + } + + return menu; +} diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts new file mode 100644 index 0000000000..d499ea0203 --- /dev/null +++ b/packages/frontend/src/scripts/get-user-name.ts @@ -0,0 +1,3 @@ +export default function(user: { name?: string | null, username: string }): string { + return user.name || user.username; +} diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts new file mode 100644 index 0000000000..4a0ded637d --- /dev/null +++ b/packages/frontend/src/scripts/hotkey.ts @@ -0,0 +1,90 @@ +import keyCode from './keycode'; + +type Callback = (ev: KeyboardEvent) => void; + +type Keymap = Record; + +type Pattern = { + which: string[]; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: Callback; + allowRepeat: boolean; +}; + +const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { + const result = { + patterns: [], + callback, + allowRepeat: true, + } as Action; + + if (patterns.match(/^\(.*\)$/) !== null) { + result.allowRepeat = false; + patterns = patterns.slice(1, -1); + } + + result.patterns = patterns.split('|').map(part => { + const pattern = { + which: [], + ctrl: false, + alt: false, + shift: false, + } as Pattern; + + const keys = part.trim().split('+').map(x => x.trim().toLowerCase()); + for (const key of keys) { + switch (key) { + case 'ctrl': pattern.ctrl = true; break; + case 'alt': pattern.alt = true; break; + case 'shift': pattern.shift = true; break; + default: pattern.which = keyCode(key).map(k => k.toLowerCase()); + } + } + + return pattern; + }); + + return result; +}); + +const ignoreElemens = ['input', 'textarea']; + +function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean { + const key = ev.code.toLowerCase(); + return patterns.some(pattern => pattern.which.includes(key) && + pattern.ctrl === ev.ctrlKey && + pattern.shift === ev.shiftKey && + pattern.alt === ev.altKey && + !ev.metaKey, + ); +} + +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + + return (ev: KeyboardEvent) => { + if (document.activeElement) { + if (ignoreElemens.some(el => document.activeElement!.matches(el))) return; + if (document.activeElement.attributes['contenteditable']) return; + } + + for (const action of actions) { + const matched = match(ev, action.patterns); + + if (matched) { + if (!action.allowRepeat && ev.repeat) return; + + ev.preventDefault(); + ev.stopPropagation(); + action.callback(ev); + break; + } + } + }; +}; diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts new file mode 100644 index 0000000000..804c5c1124 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/block.ts @@ -0,0 +1,109 @@ +// blocks + +export type BlockBase = { + id: string; + type: string; +}; + +export type TextBlock = BlockBase & { + type: 'text'; + text: string; +}; + +export type SectionBlock = BlockBase & { + type: 'section'; + title: string; + children: (Block | VarBlock)[]; +}; + +export type ImageBlock = BlockBase & { + type: 'image'; + fileId: string | null; +}; + +export type ButtonBlock = BlockBase & { + type: 'button'; + text: any; + primary: boolean; + action: string; + content: string; + event: string; + message: string; + var: string; + fn: string; +}; + +export type IfBlock = BlockBase & { + type: 'if'; + var: string; + children: Block[]; +}; + +export type TextareaBlock = BlockBase & { + type: 'textarea'; + text: string; +}; + +export type PostBlock = BlockBase & { + type: 'post'; + text: string; + attachCanvasImage: boolean; + canvasId: string; +}; + +export type CanvasBlock = BlockBase & { + type: 'canvas'; + name: string; // canvas id + width: number; + height: number; +}; + +export type NoteBlock = BlockBase & { + type: 'note'; + detailed: boolean; + note: string | null; +}; + +export type Block = + TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock; + +// variable blocks + +export type VarBlockBase = BlockBase & { + name: string; +}; + +export type NumberInputVarBlock = VarBlockBase & { + type: 'numberInput'; + text: string; +}; + +export type TextInputVarBlock = VarBlockBase & { + type: 'textInput'; + text: string; +}; + +export type SwitchVarBlock = VarBlockBase & { + type: 'switch'; + text: string; +}; + +export type RadioButtonVarBlock = VarBlockBase & { + type: 'radioButton'; + title: string; + values: string[]; +}; + +export type CounterVarBlock = VarBlockBase & { + type: 'counter'; + text: string; + inc: number; +}; + +export type VarBlock = + NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock; + +const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter']; +export function isVarBlock(block: Block): block is VarBlock { + return varBlock.includes(block.type); +} diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts new file mode 100644 index 0000000000..196b3142a1 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/evaluator.ts @@ -0,0 +1,232 @@ +import autobind from 'autobind-decorator'; +import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; +import { version } from '@/config'; +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '../aiscript/api'; +import { collectPageVars } from '../collect-page-vars'; +import { initHpmlLib, initAiLib } from './lib'; +import * as os from '@/os'; +import { markRaw, ref, Ref, unref } from 'vue'; +import { Expr, isLiteralValue, Variable } from './expr'; + +/** + * Hpml evaluator + */ +export class Hpml { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record; + public aiscript?: AiScript; + public pageVarUpdatedCallback?: values.VFn; + public canvases: Record = {}; + public vars: Ref> = ref({}); + public page: Record; + + private opts: { + randomSeed: string; visitor?: any; url?: string; + enableAiScript: boolean; + }; + + constructor(page: Hpml['page'], opts: Hpml['opts']) { + this.page = page; + this.variables = this.page.variables; + this.pageVars = collectPageVars(this.page.content); + this.opts = opts; + + if (this.opts.enableAiScript) { + this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({ + storageKey: 'pages:' + this.page.id, + }), ...initAiLib(this) }, { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + })); + + this.aiscript.scope.opts.onUpdated = (name, value) => { + this.eval(); + }; + } + + const date = new Date(); + + this.envVars = { + AI: 'kawaii', + VERSION: version, + URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '', + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + SEED: opts.randomSeed ? opts.randomSeed : '', + YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, + AISCRIPT_DISABLED: !this.opts.enableAiScript, + NULL: null, + }; + + this.eval(); + } + + @autobind + public eval() { + try { + this.vars.value = this.evaluateVars(); + } catch (err) { + //this.onError(e); + } + } + + @autobind + public interpolate(str: string) { + if (str == null) return null; + return str.replace(/{(.+?)}/g, match => { + const v = unref(this.vars)[match.slice(1, -1).trim()]; + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public callAiScript(fn: string) { + try { + if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []); + } catch (err) {} + } + + @autobind + public registerCanvas(id: string, canvas: any) { + this.canvases[id] = canvas; + } + + @autobind + public updatePageVar(name: string, value: any) { + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar !== undefined) { + pageVar.value = value; + if (this.pageVarUpdatedCallback) { + if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]); + } + } else { + throw new HpmlError(`No such page var '${name}'`); + } + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + this.envVars.SEED = seed; + } + + @autobind + private _interpolateScope(str: string, scope: HpmlScope) { + return str.replace(/{(.+?)}/g, match => { + const v = scope.getState(match.slice(1, -1).trim()); + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public evaluateVars(): Record { + const values: Record = {}; + + for (const [k, v] of Object.entries(this.envVars)) { + values[k] = v; + } + + for (const v of this.pageVars) { + values[v.name] = v.value; + } + + for (const v of this.variables) { + values[v.name] = this.evaluate(v, new HpmlScope([values])); + } + + return values; + } + + @autobind + private evaluate(expr: Expr, scope: HpmlScope): any { + if (isLiteralValue(expr)) { + if (expr.type === null) { + return null; + } + + if (expr.type === 'number') { + return parseInt((expr.value as any), 10); + } + + if (expr.type === 'text' || expr.type === 'multiLineText') { + return this._interpolateScope(expr.value || '', scope); + } + + if (expr.type === 'textList') { + return this._interpolateScope(expr.value || '', scope).trim().split('\n'); + } + + if (expr.type === 'ref') { + return scope.getState(expr.value); + } + + if (expr.type === 'aiScriptVar') { + if (this.aiscript) { + try { + return utils.valToJs(this.aiscript.scope.get(expr.value)); + } catch (err) { + return null; + } + } else { + return null; + } + } + + // Define user function + if (expr.type === 'fn') { + return { + slots: expr.value.slots.map(x => x.name), + exec: (slotArg: Record) => { + return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id)); + }, + } as Fn; + } + return; + } + + // Call user function + if (expr.type.startsWith('fn:')) { + const fnName = expr.type.split(':')[1]; + const fn = scope.getState(fnName); + const args = {} as Record; + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + args[name] = this.evaluate(expr.args[i], scope); + } + return fn.exec(args); + } + + if (expr.args === undefined) return null; + + const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor); + + // Call function + const fnName = expr.type; + const fn = (funcs as any)[fnName]; + if (fn == null) { + throw new HpmlError(`No such function '${fnName}'`); + } else { + return fn(...expr.args.map(x => this.evaluate(x, scope))); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts new file mode 100644 index 0000000000..18c7c2a14b --- /dev/null +++ b/packages/frontend/src/scripts/hpml/expr.ts @@ -0,0 +1,79 @@ +import { literalDefs, Type } from '.'; + +export type ExprBase = { + id: string; +}; + +// value + +export type EmptyValue = ExprBase & { + type: null; + value: null; +}; + +export type TextValue = ExprBase & { + type: 'text'; + value: string; +}; + +export type MultiLineTextValue = ExprBase & { + type: 'multiLineText'; + value: string; +}; + +export type TextListValue = ExprBase & { + type: 'textList'; + value: string; +}; + +export type NumberValue = ExprBase & { + type: 'number'; + value: number; +}; + +export type RefValue = ExprBase & { + type: 'ref'; + value: string; // value is variable name +}; + +export type AiScriptRefValue = ExprBase & { + type: 'aiScriptVar'; + value: string; // value is variable name +}; + +export type UserFnValue = ExprBase & { + type: 'fn'; + value: UserFnInnerValue; +}; +type UserFnInnerValue = { + slots: { + name: string; + type: Type; + }[]; + expression: Expr; +}; + +export type Value = + EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue; + +export function isLiteralValue(expr: Expr): expr is Value { + if (expr.type == null) return true; + if (literalDefs[expr.type]) return true; + return false; +} + +// call function + +export type CallFn = ExprBase & { // "fn:hoge" or string + type: string; + args: Expr[]; + value: null; +}; + +// variable +export type Variable = (Value | CallFn) & { + name: string; +}; + +// expression +export type Expr = Variable | Value | CallFn; diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts new file mode 100644 index 0000000000..9a55a5c286 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/index.ts @@ -0,0 +1,103 @@ +/** + * Hpml + */ + +import autobind from 'autobind-decorator'; +import { Hpml } from './evaluator'; +import { funcDefs } from './lib'; + +export type Fn = { + slots: string[]; + exec: (args: Record) => ReturnType; +}; + +export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; + +export const literalDefs: Record = { + text: { out: 'string', category: 'value', icon: 'ti ti-quote' }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' }, + ref: { out: null, category: 'value', icon: 'fas fa-magic' }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' }, +}; + +export const blockDefs = [ + ...Object.entries(literalDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), +]; + +export type PageVar = { name: string; value: any; type: Type; }; + +export const envVarsDef: Record = { + AI: 'string', + URL: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + SEED: null, + YMD: 'string', + AISCRIPT_DISABLED: 'boolean', + NULL: null, +}; + +export class HpmlScope { + private layerdStates: Record[]; + public name: string; + + constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { + this.layerdStates = layerdStates; + this.name = name || 'anonymous'; + } + + @autobind + public createChildScope(states: Record, name?: HpmlScope['name']): HpmlScope { + const layer = [states, ...this.layerdStates]; + return new HpmlScope(layer, name); + } + + /** + * 指定した名前の変数の値を取得します + * @param name 変数名 + */ + @autobind + public getState(name: string): any { + for (const later of this.layerdStates) { + const state = later[name]; + if (state !== undefined) { + return state; + } + } + + throw new HpmlError( + `No such variable '${name}' in scope '${this.name}'`, { + scope: this.layerdStates, + }); + } +} + +export class HpmlError extends Error { + public info?: any; + + constructor(message: string, info?: any) { + super(message); + + this.info = info; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HpmlError); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts new file mode 100644 index 0000000000..b684876a7f --- /dev/null +++ b/packages/frontend/src/scripts/hpml/lib.ts @@ -0,0 +1,247 @@ +import tinycolor from 'tinycolor2'; +import { Hpml } from './evaluator'; +import { values, utils } from '@syuilo/aiscript'; +import { Fn, HpmlScope } from '.'; +import { Expr } from './expr'; +import seedrandom from 'seedrandom'; + +/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color +// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs +Chart.pluginService.register({ + beforeDraw: (chart, easing) => { + if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) { + const ctx = chart.chart.ctx; + ctx.save(); + ctx.fillStyle = chart.config.options.chartArea.backgroundColor; + ctx.fillRect(0, 0, chart.chart.width, chart.chart.height); + ctx.restore(); + } + } +}); +*/ + +export function initAiLib(hpml: Hpml) { + return { + 'MkPages:updated': values.FN_NATIVE(([callback]) => { + hpml.pageVarUpdatedCallback = (callback as values.VFn); + }), + 'MkPages:get_canvas': values.FN_NATIVE(([id]) => { + utils.assertString(id); + const canvas = hpml.canvases[id.value]; + const ctx = canvas.getContext('2d'); + return values.OBJ(new Map([ + ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })], + ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })], + ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })], + ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })], + ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })], + ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })], + ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })], + ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })], + ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })], + ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })], + ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })], + ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })], + ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })], + ['fill', values.FN_NATIVE(() => { ctx.fill(); })], + ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })], + ])); + }), + 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { + /* TODO + utils.assertString(id); + utils.assertObject(opts); + const canvas = hpml.canvases[id.value]; + const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); + Chart.defaults.color = '#555'; + const chart = new Chart(canvas, { + type: opts.value.get('type').value, + data: { + labels: opts.value.get('labels').value.map(x => x.value), + datasets: opts.value.get('datasets').value.map(x => ({ + label: x.value.has('label') ? x.value.get('label').value : '', + data: x.value.get('data').value.map(x => x.value), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.value.has('color') ? x.value.get('color') : color, + backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(), + })) + }, + options: { + responsive: false, + devicePixelRatio: 1.5, + title: { + display: opts.value.has('title'), + text: opts.value.has('title') ? opts.value.get('title').value : '', + fontSize: 14, + }, + layout: { + padding: { + left: 32, + right: 32, + top: opts.value.has('title') ? 16 : 32, + bottom: 16 + } + }, + legend: { + display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true, + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + tooltips: { + enabled: false, + }, + chartArea: { + backgroundColor: '#fff' + }, + ...(opts.value.get('type').value === 'radar' ? { + scale: { + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + maxTicksLimit: 8, + }, + pointLabels: { + fontSize: 12 + } + } + } : { + scales: { + yAxes: [{ + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + } + }] + } + }) + } + }); + */ + }), + }; +} + +export const funcDefs: Record = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping +}; + +export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { + const date = new Date(); + const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + + // SHOULD be fine to ignore since it's intended + function shape isn't defined + // eslint-disable-next-line @typescript-eslint/ban-types + const funcs: Record = { + not: (a: boolean) => !a, + or: (a: boolean, b: boolean) => a || b, + and: (a: boolean, b: boolean) => a && b, + eq: (a: any, b: any) => a === b, + notEq: (a: any, b: any) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + gtEq: (a: number, b: number) => a >= b, + ltEq: (a: number, b: number) => a <= b, + if: (bool: boolean, a: any, b: any) => bool ? a : b, + for: (times: number, fn: Fn) => { + const result: any[] = []; + for (let i = 0; i < times; i++) { + result.push(fn.exec({ + [fn.slots[0]]: i + 1, + })); + } + return result; + }, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + multiply: (a: number, b: number) => a * b, + divide: (a: number, b: number) => a / b, + mod: (a: number, b: number) => a % b, + round: (a: number) => Math.round(a), + strLen: (a: string) => a.length, + strPick: (a: string, b: number) => a[b - 1], + strReplace: (a: string, b: string, c: string) => a.split(b).join(c), + strReverse: (a: string) => a.split('').reverse().join(''), + join: (texts: string[], separator: string) => texts.join(separator || ''), + stringToNumber: (a: string) => parseInt(a), + numberToString: (a: number) => a.toString(), + splitStrByLine: (a: string) => a.split('\n'), + pick: (list: any[], i: number) => list[i - 1], + listLen: (list: any[]) => list.length, + random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability, + rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)), + randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)], + dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability, + dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)), + dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)], + seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, + seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), + seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], + DRPWPM: (list: string[]) => { + const xs: any[] = []; + let totalFactor = 0; + for (const x of list) { + const parts = x.split(' '); + const factor = parseInt(parts.pop()!, 10); + const text = parts.join(' '); + totalFactor += factor; + xs.push({ factor, text }); + } + const r = seedrandom(`${day}:${expr.id}`)() * totalFactor; + let stackedFactor = 0; + for (const x of xs) { + if (r >= stackedFactor && r <= stackedFactor + x.factor) { + return x.text; + } else { + stackedFactor += x.factor; + } + } + return xs[0].text; + }, + }; + + return funcs; +} diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts new file mode 100644 index 0000000000..24c9ed8bcb --- /dev/null +++ b/packages/frontend/src/scripts/hpml/type-checker.ts @@ -0,0 +1,191 @@ +import autobind from 'autobind-decorator'; +import { isLiteralValue } from './expr'; +import { funcDefs } from './lib'; +import { envVarsDef } from '.'; +import type { Type, PageVar } from '.'; +import type { Expr, Variable } from './expr'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +/** + * Hpml type checker + */ +export class HpmlTypeChecker { + public variables: Variable[]; + public pageVars: PageVar[]; + + constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) { + this.variables = variables; + this.pageVars = pageVars; + } + + @autobind + public typeCheck(v: Expr): TypeError | null { + if (isLiteralValue(v)) return null; + + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type, + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type, + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Expr, slot: number): Type { + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] ?? null; + } else { + return def.in[slot]; + } + } + + @autobind + public infer(v: Expr): Type { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.infer(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = envVarsDef[v.value || '']; + if (envVar !== undefined) { + return envVar; + } + + return null; + } + if (v.type === 'aiScriptVar') return null; + if (v.type === 'fn') return null; // todo + if (v.type.startsWith('fn:')) return null; // todo + + const generic: Type[] = []; + + const def = funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.infer(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarByName(name: string): Variable { + const v = this.variables.find(x => x.name === name); + if (v !== undefined) { + return v; + } else { + throw new Error(`No such variable '${name}'`); + } + } + + @autobind + public getVarsByType(type: Type): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); + } + + @autobind + public getEnvVarsByType(type: Type): string[] { + if (type == null) return Object.keys(envVarsDef); + return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts new file mode 100644 index 0000000000..54184386da --- /dev/null +++ b/packages/frontend/src/scripts/i18n.ts @@ -0,0 +1,29 @@ +export class I18n> { + public ts: T; + + constructor(locale: T) { + this.ts = locale; + + //#region BIND + this.t = this.t.bind(this); + //#endregion + } + + // string にしているのは、ドット区切りでのパス指定を許可するため + // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも + public t(key: string, args?: Record): string { + try { + let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + + if (args) { + for (const [k, v] of Object.entries(args)) { + str = str.replace(`{${k}}`, v.toString()); + } + } + return str; + } catch (err) { + console.warn(`missing localization '${key}'`); + return key; + } + } +} diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts new file mode 100644 index 0000000000..77bb84463c --- /dev/null +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -0,0 +1,36 @@ +// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、 +// indexedDBが使えない環境ではlocalStorageを使う +import { + get as iget, + set as iset, + del as idel, +} from 'idb-keyval'; + +const fallbackName = (key: string) => `idbfallback::${key}`; + +let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true; + +if (idbAvailable) { + iset('idb-test', 'test').catch(err => { + console.error('idb error', err); + console.error('indexedDB is unavailable. It will use localStorage.'); + idbAvailable = false; + }); +} else { + console.error('indexedDB is unavailable. It will use localStorage.'); +} + +export async function get(key: string) { + if (idbAvailable) return iget(key); + return JSON.parse(localStorage.getItem(fallbackName(key))); +} + +export async function set(key: string, val: any) { + if (idbAvailable) return iset(key, val); + return localStorage.setItem(fallbackName(key), JSON.stringify(val)); +} + +export async function del(key: string) { + if (idbAvailable) return idel(key); + return localStorage.removeItem(fallbackName(key)); +} diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts new file mode 100644 index 0000000000..de52f30523 --- /dev/null +++ b/packages/frontend/src/scripts/initialize-sw.ts @@ -0,0 +1,13 @@ +import { lang } from '@/config'; + +export async function initializeSw() { + if (!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' }); + navigator.serviceWorker.ready.then(registration => { + registration.active?.postMessage({ + msg: 'initialize', + lang, + }); + }); +} diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts new file mode 100644 index 0000000000..854f38e517 --- /dev/null +++ b/packages/frontend/src/scripts/is-device-darkmode.ts @@ -0,0 +1,3 @@ +export function isDeviceDarkmode() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts new file mode 100644 index 0000000000..69f6a82803 --- /dev/null +++ b/packages/frontend/src/scripts/keycode.ts @@ -0,0 +1,33 @@ +export default (input: string): string[] => { + if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) { + const codes = aliases[input]; + return Array.isArray(codes) ? codes : [codes]; + } else { + return [input]; + } +}; + +export const aliases = { + 'esc': 'Escape', + 'enter': ['Enter', 'NumpadEnter'], + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['NumpadAdd', 'Semicolon'], +}; + +/*! +* Programmatically add the following +*/ + +// lower case chars +for (let i = 97; i < 123; i++) { + const char = String.fromCharCode(i); + aliases[char] = `Key${char.toUpperCase()}`; +} + +// numbers +for (let i = 0; i < 10; i++) { + aliases[i] = [`Numpad${i}`, `Digit${i}`]; +} diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts new file mode 100644 index 0000000000..25f5b366c8 --- /dev/null +++ b/packages/frontend/src/scripts/langmap.ts @@ -0,0 +1,666 @@ +// TODO: sharedに置いてバックエンドのと統合したい +export const langmap = { + 'ach': { + nativeName: 'Lwo', + }, + 'ady': { + nativeName: 'Адыгэбзэ', + }, + 'af': { + nativeName: 'Afrikaans', + }, + 'af-NA': { + nativeName: 'Afrikaans (Namibia)', + }, + 'af-ZA': { + nativeName: 'Afrikaans (South Africa)', + }, + 'ak': { + nativeName: 'Tɕɥi', + }, + 'ar': { + nativeName: 'العربية', + }, + 'ar-AR': { + nativeName: 'العربية', + }, + 'ar-MA': { + nativeName: 'العربية', + }, + 'ar-SA': { + nativeName: 'العربية (السعودية)', + }, + 'ay-BO': { + nativeName: 'Aymar aru', + }, + 'az': { + nativeName: 'Azərbaycan dili', + }, + 'az-AZ': { + nativeName: 'Azərbaycan dili', + }, + 'be-BY': { + nativeName: 'Беларуская', + }, + 'bg': { + nativeName: 'Български', + }, + 'bg-BG': { + nativeName: 'Български', + }, + 'bn': { + nativeName: 'বাংলা', + }, + 'bn-IN': { + nativeName: 'বাংলা (ভারত)', + }, + 'bn-BD': { + nativeName: 'বাংলা(বাংলাদেশ)', + }, + 'br': { + nativeName: 'Brezhoneg', + }, + 'bs-BA': { + nativeName: 'Bosanski', + }, + 'ca': { + nativeName: 'Català', + }, + 'ca-ES': { + nativeName: 'Català', + }, + 'cak': { + nativeName: 'Maya Kaqchikel', + }, + 'ck-US': { + nativeName: 'ᏣᎳᎩ (tsalagi)', + }, + 'cs': { + nativeName: 'Čeština', + }, + 'cs-CZ': { + nativeName: 'Čeština', + }, + 'cy': { + nativeName: 'Cymraeg', + }, + 'cy-GB': { + nativeName: 'Cymraeg', + }, + 'da': { + nativeName: 'Dansk', + }, + 'da-DK': { + nativeName: 'Dansk', + }, + 'de': { + nativeName: 'Deutsch', + }, + 'de-AT': { + nativeName: 'Deutsch (Österreich)', + }, + 'de-DE': { + nativeName: 'Deutsch (Deutschland)', + }, + 'de-CH': { + nativeName: 'Deutsch (Schweiz)', + }, + 'dsb': { + nativeName: 'Dolnoserbšćina', + }, + 'el': { + nativeName: 'Ελληνικά', + }, + 'el-GR': { + nativeName: 'Ελληνικά', + }, + 'en': { + nativeName: 'English', + }, + 'en-GB': { + nativeName: 'English (UK)', + }, + 'en-AU': { + nativeName: 'English (Australia)', + }, + 'en-CA': { + nativeName: 'English (Canada)', + }, + 'en-IE': { + nativeName: 'English (Ireland)', + }, + 'en-IN': { + nativeName: 'English (India)', + }, + 'en-PI': { + nativeName: 'English (Pirate)', + }, + 'en-SG': { + nativeName: 'English (Singapore)', + }, + 'en-UD': { + nativeName: 'English (Upside Down)', + }, + 'en-US': { + nativeName: 'English (US)', + }, + 'en-ZA': { + nativeName: 'English (South Africa)', + }, + 'en@pirate': { + nativeName: 'English (Pirate)', + }, + 'eo': { + nativeName: 'Esperanto', + }, + 'eo-EO': { + nativeName: 'Esperanto', + }, + 'es': { + nativeName: 'Español', + }, + 'es-AR': { + nativeName: 'Español (Argentine)', + }, + 'es-419': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-CL': { + nativeName: 'Español (Chile)', + }, + 'es-CO': { + nativeName: 'Español (Colombia)', + }, + 'es-EC': { + nativeName: 'Español (Ecuador)', + }, + 'es-ES': { + nativeName: 'Español (España)', + }, + 'es-LA': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-NI': { + nativeName: 'Español (Nicaragua)', + }, + 'es-MX': { + nativeName: 'Español (México)', + }, + 'es-US': { + nativeName: 'Español (Estados Unidos)', + }, + 'es-VE': { + nativeName: 'Español (Venezuela)', + }, + 'et': { + nativeName: 'eesti keel', + }, + 'et-EE': { + nativeName: 'Eesti (Estonia)', + }, + 'eu': { + nativeName: 'Euskara', + }, + 'eu-ES': { + nativeName: 'Euskara', + }, + 'fa': { + nativeName: 'فارسی', + }, + 'fa-IR': { + nativeName: 'فارسی', + }, + 'fb-LT': { + nativeName: 'Leet Speak', + }, + 'ff': { + nativeName: 'Fulah', + }, + 'fi': { + nativeName: 'Suomi', + }, + 'fi-FI': { + nativeName: 'Suomi', + }, + 'fo': { + nativeName: 'Føroyskt', + }, + 'fo-FO': { + nativeName: 'Føroyskt (Færeyjar)', + }, + 'fr': { + nativeName: 'Français', + }, + 'fr-CA': { + nativeName: 'Français (Canada)', + }, + 'fr-FR': { + nativeName: 'Français (France)', + }, + 'fr-BE': { + nativeName: 'Français (Belgique)', + }, + 'fr-CH': { + nativeName: 'Français (Suisse)', + }, + 'fy-NL': { + nativeName: 'Frysk', + }, + 'ga': { + nativeName: 'Gaeilge', + }, + 'ga-IE': { + nativeName: 'Gaeilge', + }, + 'gd': { + nativeName: 'Gàidhlig', + }, + 'gl': { + nativeName: 'Galego', + }, + 'gl-ES': { + nativeName: 'Galego', + }, + 'gn-PY': { + nativeName: 'Avañe\'ẽ', + }, + 'gu-IN': { + nativeName: 'ગુજરાતી', + }, + 'gv': { + nativeName: 'Gaelg', + }, + 'gx-GR': { + nativeName: 'Ἑλληνική ἀρχαία', + }, + 'he': { + nativeName: 'עברית‏', + }, + 'he-IL': { + nativeName: 'עברית‏', + }, + 'hi': { + nativeName: 'हिन्दी', + }, + 'hi-IN': { + nativeName: 'हिन्दी', + }, + 'hr': { + nativeName: 'Hrvatski', + }, + 'hr-HR': { + nativeName: 'Hrvatski', + }, + 'hsb': { + nativeName: 'Hornjoserbšćina', + }, + 'ht': { + nativeName: 'Kreyòl', + }, + 'hu': { + nativeName: 'Magyar', + }, + 'hu-HU': { + nativeName: 'Magyar', + }, + 'hy': { + nativeName: 'Հայերեն', + }, + 'hy-AM': { + nativeName: 'Հայերեն (Հայաստան)', + }, + 'id': { + nativeName: 'Bahasa Indonesia', + }, + 'id-ID': { + nativeName: 'Bahasa Indonesia', + }, + 'is': { + nativeName: 'Íslenska', + }, + 'is-IS': { + nativeName: 'Íslenska (Iceland)', + }, + 'it': { + nativeName: 'Italiano', + }, + 'it-IT': { + nativeName: 'Italiano', + }, + 'ja': { + nativeName: '日本語', + }, + 'ja-JP': { + nativeName: '日本語 (日本)', + }, + 'jv-ID': { + nativeName: 'Basa Jawa', + }, + 'ka-GE': { + nativeName: 'ქართული', + }, + 'kk-KZ': { + nativeName: 'Қазақша', + }, + 'km': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kl': { + nativeName: 'kalaallisut', + }, + 'km-KH': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kab': { + nativeName: 'Taqbaylit', + }, + 'kn': { + nativeName: 'ಕನ್ನಡ', + }, + 'kn-IN': { + nativeName: 'ಕನ್ನಡ (India)', + }, + 'ko': { + nativeName: '한국어', + }, + 'ko-KR': { + nativeName: '한국어 (한국)', + }, + 'ku-TR': { + nativeName: 'Kurdî', + }, + 'kw': { + nativeName: 'Kernewek', + }, + 'la': { + nativeName: 'Latin', + }, + 'la-VA': { + nativeName: 'Latin', + }, + 'lb': { + nativeName: 'Lëtzebuergesch', + }, + 'li-NL': { + nativeName: 'Lèmbörgs', + }, + 'lt': { + nativeName: 'Lietuvių', + }, + 'lt-LT': { + nativeName: 'Lietuvių', + }, + 'lv': { + nativeName: 'Latviešu', + }, + 'lv-LV': { + nativeName: 'Latviešu', + }, + 'mai': { + nativeName: 'मैथिली, মৈথিলী', + }, + 'mg-MG': { + nativeName: 'Malagasy', + }, + 'mk': { + nativeName: 'Македонски', + }, + 'mk-MK': { + nativeName: 'Македонски (Македонски)', + }, + 'ml': { + nativeName: 'മലയാളം', + }, + 'ml-IN': { + nativeName: 'മലയാളം', + }, + 'mn-MN': { + nativeName: 'Монгол', + }, + 'mr': { + nativeName: 'मराठी', + }, + 'mr-IN': { + nativeName: 'मराठी', + }, + 'ms': { + nativeName: 'Bahasa Melayu', + }, + 'ms-MY': { + nativeName: 'Bahasa Melayu', + }, + 'mt': { + nativeName: 'Malti', + }, + 'mt-MT': { + nativeName: 'Malti', + }, + 'my': { + nativeName: 'ဗမာစကာ', + }, + 'no': { + nativeName: 'Norsk', + }, + 'nb': { + nativeName: 'Norsk (bokmål)', + }, + 'nb-NO': { + nativeName: 'Norsk (bokmål)', + }, + 'ne': { + nativeName: 'नेपाली', + }, + 'ne-NP': { + nativeName: 'नेपाली', + }, + 'nl': { + nativeName: 'Nederlands', + }, + 'nl-BE': { + nativeName: 'Nederlands (België)', + }, + 'nl-NL': { + nativeName: 'Nederlands (Nederland)', + }, + 'nn-NO': { + nativeName: 'Norsk (nynorsk)', + }, + 'oc': { + nativeName: 'Occitan', + }, + 'or-IN': { + nativeName: 'ଓଡ଼ିଆ', + }, + 'pa': { + nativeName: 'ਪੰਜਾਬੀ', + }, + 'pa-IN': { + nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)', + }, + 'pl': { + nativeName: 'Polski', + }, + 'pl-PL': { + nativeName: 'Polski', + }, + 'ps-AF': { + nativeName: 'پښتو', + }, + 'pt': { + nativeName: 'Português', + }, + 'pt-BR': { + nativeName: 'Português (Brasil)', + }, + 'pt-PT': { + nativeName: 'Português (Portugal)', + }, + 'qu-PE': { + nativeName: 'Qhichwa', + }, + 'rm-CH': { + nativeName: 'Rumantsch', + }, + 'ro': { + nativeName: 'Română', + }, + 'ro-RO': { + nativeName: 'Română', + }, + 'ru': { + nativeName: 'Русский', + }, + 'ru-RU': { + nativeName: 'Русский', + }, + 'sa-IN': { + nativeName: 'संस्कृतम्', + }, + 'se-NO': { + nativeName: 'Davvisámegiella', + }, + 'sh': { + nativeName: 'српскохрватски', + }, + 'si-LK': { + nativeName: 'සිංහල', + }, + 'sk': { + nativeName: 'Slovenčina', + }, + 'sk-SK': { + nativeName: 'Slovenčina (Slovakia)', + }, + 'sl': { + nativeName: 'Slovenščina', + }, + 'sl-SI': { + nativeName: 'Slovenščina', + }, + 'so-SO': { + nativeName: 'Soomaaliga', + }, + 'sq': { + nativeName: 'Shqip', + }, + 'sq-AL': { + nativeName: 'Shqip', + }, + 'sr': { + nativeName: 'Српски', + }, + 'sr-RS': { + nativeName: 'Српски (Serbia)', + }, + 'su': { + nativeName: 'Basa Sunda', + }, + 'sv': { + nativeName: 'Svenska', + }, + 'sv-SE': { + nativeName: 'Svenska', + }, + 'sw': { + nativeName: 'Kiswahili', + }, + 'sw-KE': { + nativeName: 'Kiswahili', + }, + 'ta': { + nativeName: 'தமிழ்', + }, + 'ta-IN': { + nativeName: 'தமிழ்', + }, + 'te': { + nativeName: 'తెలుగు', + }, + 'te-IN': { + nativeName: 'తెలుగు', + }, + 'tg': { + nativeName: 'забо́ни тоҷикӣ́', + }, + 'tg-TJ': { + nativeName: 'тоҷикӣ', + }, + 'th': { + nativeName: 'ภาษาไทย', + }, + 'th-TH': { + nativeName: 'ภาษาไทย (ประเทศไทย)', + }, + 'fil': { + nativeName: 'Filipino', + }, + 'tlh': { + nativeName: 'tlhIngan-Hol', + }, + 'tr': { + nativeName: 'Türkçe', + }, + 'tr-TR': { + nativeName: 'Türkçe', + }, + 'tt-RU': { + nativeName: 'татарча', + }, + 'uk': { + nativeName: 'Українська', + }, + 'uk-UA': { + nativeName: 'Українська', + }, + 'ur': { + nativeName: 'اردو', + }, + 'ur-PK': { + nativeName: 'اردو', + }, + 'uz': { + nativeName: 'O\'zbek', + }, + 'uz-UZ': { + nativeName: 'O\'zbek', + }, + 'vi': { + nativeName: 'Tiếng Việt', + }, + 'vi-VN': { + nativeName: 'Tiếng Việt', + }, + 'xh-ZA': { + nativeName: 'isiXhosa', + }, + 'yi': { + nativeName: 'ייִדיש', + }, + 'yi-DE': { + nativeName: 'ייִדיש (German)', + }, + 'zh': { + nativeName: '中文', + }, + 'zh-Hans': { + nativeName: '中文简体', + }, + 'zh-Hant': { + nativeName: '中文繁體', + }, + 'zh-CN': { + nativeName: '中文(中国大陆)', + }, + 'zh-HK': { + nativeName: '中文(香港)', + }, + 'zh-SG': { + nativeName: '中文(新加坡)', + }, + 'zh-TW': { + nativeName: '中文(台灣)', + }, + 'zu-ZA': { + nativeName: 'isiZulu', + }, +}; diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts new file mode 100644 index 0000000000..0f9c6be4a9 --- /dev/null +++ b/packages/frontend/src/scripts/login-id.ts @@ -0,0 +1,11 @@ +export function getUrlWithLoginId(url: string, loginId: string) { + const u = new URL(url, origin); + u.searchParams.append('loginId', loginId); + return u.toString(); +} + +export function getUrlWithoutLoginId(url: string) { + const u = new URL(url); + u.searchParams.delete('loginId'); + return u.toString(); +} diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/lookup-user.ts new file mode 100644 index 0000000000..3ab9d55300 --- /dev/null +++ b/packages/frontend/src/scripts/lookup-user.ts @@ -0,0 +1,36 @@ +import * as Acct from 'misskey-js/built/acct'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export async function lookupUser() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.usernameOrUserId, + }); + if (canceled) return; + + const show = (user) => { + os.pageWindow(`/user-info/${user.id}`); + }; + + const usernamePromise = os.api('users/show', Acct.parse(result)); + const idPromise = os.api('users/show', { userId: result }); + let _notFound = false; + const notFound = () => { + if (_notFound) { + os.alert({ + type: 'error', + text: i18n.ts.noSuchUser, + }); + } else { + _notFound = true; + } + }; + usernamePromise.then(show).catch(err => { + if (err.code === 'NO_SUCH_USER') { + notFound(); + } + }); + idPromise.then(show).catch(err => { + notFound(); + }); +} diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts new file mode 100644 index 0000000000..aaf7f9e610 --- /dev/null +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -0,0 +1,15 @@ +import { query } from '@/scripts/url'; +import { url } from '@/config'; + +export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string { + return `${url}/proxy/image.webp?${query({ + url: imageUrl, + fallback: '1', + ...(type ? { [type]: '1' } : {}), + })}`; +} + +export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null { + if (imageUrl == null) return null; + return getProxiedImageUrl(imageUrl, type); +} diff --git a/packages/frontend/src/scripts/mfm-tags.ts b/packages/frontend/src/scripts/mfm-tags.ts new file mode 100644 index 0000000000..18e8d7038a --- /dev/null +++ b/packages/frontend/src/scripts/mfm-tags.ts @@ -0,0 +1 @@ +export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate']; diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts new file mode 100644 index 0000000000..0db8369f9d --- /dev/null +++ b/packages/frontend/src/scripts/page-metadata.ts @@ -0,0 +1,41 @@ +import * as misskey from 'misskey-js'; +import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue'; + +export const setPageMetadata = Symbol('setPageMetadata'); +export const pageMetadataProvider = Symbol('pageMetadataProvider'); + +export type PageMetadata = { + title: string; + subtitle?: string; + icon?: string | null; + avatar?: misskey.entities.User | null; + userName?: misskey.entities.User | null; + bg?: string; +}; + +export function definePageMetadata(metadata: PageMetadata | null | Ref | ComputedRef): void { + const _metadata = isRef(metadata) ? metadata : ref(metadata); + + provide(pageMetadataProvider, _metadata); + + const set = inject(setPageMetadata) as any; + if (set) { + set(_metadata); + + onMounted(() => { + set(_metadata); + }); + + onActivated(() => { + set(_metadata); + }); + } +} + +export function provideMetadataReceiver(callback: (info: ComputedRef) => void): void { + provide(setPageMetadata, callback); +} + +export function injectPageMetadata(): PageMetadata | undefined { + return inject(pageMetadataProvider); +} diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts new file mode 100644 index 0000000000..efda80f074 --- /dev/null +++ b/packages/frontend/src/scripts/physics.ts @@ -0,0 +1,152 @@ +import * as Matter from 'matter-js'; + +export function physics(container: HTMLElement) { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const containerCenterX = containerWidth / 2; + + // サイズ固定化(要らないかも?) + container.style.position = 'relative'; + container.style.boxSizing = 'border-box'; + container.style.width = `${containerWidth}px`; + container.style.height = `${containerHeight}px`; + + // create engine + const engine = Matter.Engine.create({ + constraintIterations: 4, + positionIterations: 8, + velocityIterations: 8, + }); + + const world = engine.world; + + // create renderer + const render = Matter.Render.create({ + engine: engine, + //element: document.getElementById('debug'), + options: { + width: containerWidth, + height: containerHeight, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + }, + }); + + // Disable to hide debug + Matter.Render.run(render); + + // create runner + const runner = Matter.Runner.create(); + Matter.Runner.run(runner, engine); + + const groundThickness = 1024; + const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, { + isStatic: true, + restitution: 0.1, + friction: 2, + }); + + //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts); + //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts); + + Matter.World.add(world, [ + ground, + //wallRight, + //wallLeft, + ]); + + const objEls = Array.from(container.children) as HTMLElement[]; + const objs: Matter.Body[] = []; + for (const objEl of objEls) { + const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft; + const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop; + + let obj: Matter.Body; + if (objEl.classList.contains('_physics_circle_')) { + obj = Matter.Bodies.circle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2, + { + restitution: 0.5, + }, + ); + } else { + const style = window.getComputedStyle(objEl); + obj = Matter.Bodies.rectangle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + objEl.offsetWidth, + objEl.offsetHeight, + { + chamfer: { radius: parseInt(style.borderRadius || '0', 10) }, + restitution: 0.5, + }, + ); + } + objEl.id = obj.id.toString(); + objs.push(obj); + } + + Matter.World.add(engine.world, objs); + + // Add mouse control + + const mouse = Matter.Mouse.create(container); + const mouseConstraint = Matter.MouseConstraint.create(engine, { + mouse: mouse, + constraint: { + stiffness: 0.1, + render: { + visible: false, + }, + }, + }); + + Matter.World.add(engine.world, mouseConstraint); + + // keep the mouse in sync with rendering + render.mouse = mouse; + + for (const objEl of objEls) { + objEl.style.position = 'absolute'; + objEl.style.top = '0'; + objEl.style.left = '0'; + objEl.style.margin = '0'; + } + + window.requestAnimationFrame(update); + + let stop = false; + + function update() { + for (const objEl of objEls) { + const obj = objs.find(obj => obj.id.toString() === objEl.id.toString()); + if (obj == null) continue; + + const x = (obj.position.x - objEl.offsetWidth / 2); + const y = (obj.position.y - objEl.offsetHeight / 2); + const angle = obj.angle; + objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`; + } + + if (!stop) { + window.requestAnimationFrame(update); + } + } + + // 奈落に落ちたオブジェクトは消す + const intervalId = window.setInterval(() => { + for (const obj of objs) { + if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj); + } + }, 1000 * 10); + + return { + stop: () => { + stop = true; + Matter.Runner.stop(runner); + window.clearInterval(intervalId); + }, + }; +} diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts new file mode 100644 index 0000000000..b8fb853cc1 --- /dev/null +++ b/packages/frontend/src/scripts/please-login.ts @@ -0,0 +1,21 @@ +import { defineAsyncComponent } from 'vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { popup } from '@/os'; + +export function pleaseLogin(path?: string) { + if ($i) return; + + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { + autoSet: true, + message: i18n.ts.signinRequired, + }, { + cancelled: () => { + if (path) { + window.location.href = path; + } + }, + }, 'closed'); + + if (!path) throw new Error('signin required'); +} diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts new file mode 100644 index 0000000000..580031d0a3 --- /dev/null +++ b/packages/frontend/src/scripts/popout.ts @@ -0,0 +1,23 @@ +import * as config from '@/config'; +import { appendQuery } from './url'; + +export function popout(path: string, w?: HTMLElement) { + let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; + url = appendQuery(url, 'zen'); + if (w) { + const position = w.getBoundingClientRect(); + const width = parseInt(getComputedStyle(w, '').width, 10); + const height = parseInt(getComputedStyle(w, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + } else { + const width = 400; + const height = 500; + const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + window.open(url, url, + `width=${width}, height=${height}, top=${x}, left=${y}`); + } +} diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts new file mode 100644 index 0000000000..e84eebf103 --- /dev/null +++ b/packages/frontend/src/scripts/popup-position.ts @@ -0,0 +1,158 @@ +import { Ref } from 'vue'; + +export function calcPopupPosition(el: HTMLElement, props: { + anchorElement: HTMLElement | null; + innerMargin: number; + direction: 'top' | 'bottom' | 'left' | 'right'; + align: 'top' | 'bottom' | 'left' | 'right' | 'center'; + alignOffset?: number; + x?: number; + y?: number; +}): { top: number; left: number; transformOrigin: string; } { + const contentWidth = el.offsetWidth; + const contentHeight = el.offsetHeight; + + let rect: DOMRect; + + if (props.anchorElement) { + rect = props.anchorElement.getBoundingClientRect(); + } + + const calcPosWhenTop = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; + } else { + left = props.x; + top = (props.y - contentHeight) - props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenBottom = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; + } else { + left = props.x; + top = (props.y) + props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenLeft = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + } else { + left = (props.x - contentWidth) - props.innerMargin; + top = props.y; + } + + top -= (el.offsetHeight / 2); + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenRight = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; + + if (props.align === 'top') { + top = rect.top + window.pageYOffset; + if (props.alignOffset != null) top += props.alignOffset; + } else if (props.align === 'bottom') { + // TODO + } else { // center + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + top -= (el.offsetHeight / 2); + } + } else { + left = props.x + props.innerMargin; + top = props.y; + top -= (el.offsetHeight / 2); + } + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calc = (): { + left: number; + top: number; + transformOrigin: string; + } => { + switch (props.direction) { + case 'top': { + const [left, top] = calcPosWhenTop(); + + // ツールチップを上に向かって表示するスペースがなければ下に向かって出す + if (top - window.pageYOffset < 0) { + const [left, top] = calcPosWhenBottom(); + return { left, top, transformOrigin: 'center top' }; + } + + return { left, top, transformOrigin: 'center bottom' }; + } + + case 'bottom': { + const [left, top] = calcPosWhenBottom(); + // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す + return { left, top, transformOrigin: 'center top' }; + } + + case 'left': { + const [left, top] = calcPosWhenLeft(); + + // ツールチップを左に向かって表示するスペースがなければ右に向かって出す + if (left - window.pageXOffset < 0) { + const [left, top] = calcPosWhenRight(); + return { left, top, transformOrigin: 'left center' }; + } + + return { left, top, transformOrigin: 'right center' }; + } + + case 'right': { + const [left, top] = calcPosWhenRight(); + // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す + return { left, top, transformOrigin: 'left center' }; + } + } + }; + + return calc(); +} diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts new file mode 100644 index 0000000000..fe32e719da --- /dev/null +++ b/packages/frontend/src/scripts/reaction-picker.ts @@ -0,0 +1,41 @@ +import { defineAsyncComponent, Ref, ref } from 'vue'; +import { popup } from '@/os'; + +class ReactionPicker { + private src: Ref = ref(null); + private manualShowing = ref(false); + private onChosen?: (reaction: string) => void; + private onClosed?: () => void; + + constructor() { + // nop + } + + public async init() { + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src: this.src, + asReactionPicker: true, + manualShowing: this.manualShowing, + }, { + done: reaction => { + this.onChosen!(reaction); + }, + close: () => { + this.manualShowing.value = false; + }, + closed: () => { + this.src.value = null; + this.onClosed!(); + }, + }); + } + + public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) { + this.src.value = src; + this.manualShowing.value = true; + this.onChosen = onChosen; + this.onClosed = onClosed; + } +} + +export const reactionPicker = new ReactionPicker(); diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts new file mode 100644 index 0000000000..301b56d7fd --- /dev/null +++ b/packages/frontend/src/scripts/safe-uri-decode.ts @@ -0,0 +1,7 @@ +export function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts new file mode 100644 index 0000000000..f5bc6bf9ce --- /dev/null +++ b/packages/frontend/src/scripts/scroll.ts @@ -0,0 +1,85 @@ +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; + +export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { + if (el == null || el.tagName === 'HTML') return null; + const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y'); + if (overflow === 'scroll' || overflow === 'auto') { + return el; + } else { + return getScrollContainer(el.parentElement); + } +} + +export function getScrollPosition(el: Element | null): number { + const container = getScrollContainer(el); + return container == null ? window.scrollY : container.scrollTop; +} + +export function isTopVisible(el: Element | null): boolean { + const scrollTop = getScrollPosition(el); + const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる + + return scrollTop <= topPosition; +} + +export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { + if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance; + return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance; +} + +export function onScrollTop(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + if (isTopVisible(el)) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function onScrollBottom(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + const pos = getScrollPosition(el); + if (pos + el.clientHeight > el.scrollHeight - 1) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function scroll(el: Element, options: { + top?: number; + left?: number; + behavior?: ScrollBehavior; +}) { + const container = getScrollContainer(el); + if (container == null) { + window.scroll(options); + } else { + container.scroll(options); + } +} + +export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 0, ...options }); +} + +export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する +} + +export function isBottom(el: Element, asobi = 0) { + const container = getScrollContainer(el); + const current = container + ? el.scrollTop + el.offsetHeight + : window.scrollY + window.innerHeight; + const max = container + ? el.scrollHeight + : document.body.offsetHeight; + return current >= (max - asobi); +} diff --git a/packages/frontend/src/scripts/search.ts b/packages/frontend/src/scripts/search.ts new file mode 100644 index 0000000000..64914d3d65 --- /dev/null +++ b/packages/frontend/src/scripts/search.ts @@ -0,0 +1,63 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; + +export async function search() { + const { canceled, result: query } = await os.inputText({ + title: i18n.ts.search, + }); + if (canceled || query == null || query === '') return; + + const q = query.trim(); + + if (q.startsWith('@') && !q.includes(' ')) { + mainRouter.push(`/${q}`); + return; + } + + if (q.startsWith('#')) { + mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`); + return; + } + + // like 2018/03/12 + if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) { + const date = new Date(q.replace(/-/g, '/')); + + // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは + // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので + // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の + // 結果になってしまい、2018/03/12 のコンテンツは含まれない) + if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { + date.setHours(23, 59, 59, 999); + } + + // TODO + //v.$root.$emit('warp', date); + os.alert({ + icon: 'fas fa-history', + iconOnly: true, autoClose: true, + }); + return; + } + + if (q.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: q, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + mainRouter.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + mainRouter.push(`/notes/${res.object.id}`); + } + + return; + } + + mainRouter.push(`/search?q=${encodeURIComponent(q)}`); +} diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts new file mode 100644 index 0000000000..ec5f8f65e9 --- /dev/null +++ b/packages/frontend/src/scripts/select-file.ts @@ -0,0 +1,103 @@ +import { ref } from 'vue'; +import { DriveFile } from 'misskey-js/built/entities'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { uploadFile } from '@/scripts/upload'; + +function select(src: any, label: string | null, multiple: boolean): Promise { + return new Promise((res, rej) => { + const keepOriginal = ref(defaultStore.state.keepOriginalUploading); + + const chooseFileFromPc = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = multiple; + input.onchange = () => { + const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value)); + + Promise.all(promises).then(driveFiles => { + res(multiple ? driveFiles : driveFiles[0]); + }).catch(err => { + // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない + }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }; + + const chooseFileFromDrive = () => { + os.selectDriveFile(multiple).then(files => { + res(files); + }); + }; + + const chooseFileFromUrl = () => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + const connection = stream.useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(multiple ? [urlResponse.file] : urlResponse.file); + connection.dispose(); + } + }); + + os.api('drive/files/upload-from-url', { + url: url, + folderId: defaultStore.state.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }; + + os.popupMenu([label ? { + text: label, + type: 'label', + } : undefined, { + type: 'switch', + text: i18n.ts.keepOriginalUploading, + ref: keepOriginal, + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: chooseFileFromPc, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: chooseFileFromDrive, + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: chooseFileFromUrl, + }], src); + }); +} + +export function selectFile(src: any, label: string | null = null): Promise { + return select(src, label, false) as Promise; +} + +export function selectFiles(src: any, label: string | null = null): Promise { + return select(src, label, true) as Promise; +} diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts new file mode 100644 index 0000000000..e11569ecd4 --- /dev/null +++ b/packages/frontend/src/scripts/show-suspended-dialog.ts @@ -0,0 +1,10 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +export function showSuspendedDialog() { + return os.alert({ + type: 'error', + title: i18n.ts.yourAccountSuspendedTitle, + text: i18n.ts.yourAccountSuspendedDescription, + }); +} diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts new file mode 100644 index 0000000000..05e6cdfbcf --- /dev/null +++ b/packages/frontend/src/scripts/shuffle.ts @@ -0,0 +1,19 @@ +/** + * 配列をシャッフル (破壊的) + */ +export function shuffle(array: T): T { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], array[currentIndex]]; + } + + return array; +} diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts new file mode 100644 index 0000000000..9d1f603235 --- /dev/null +++ b/packages/frontend/src/scripts/sound.ts @@ -0,0 +1,66 @@ +import { ColdDeviceStorage } from '@/store'; + +const cache = new Map(); + +export const soundsTypes = [ + null, + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + 'syuilo/poi1', + 'syuilo/poi2', + 'syuilo/pirori', + 'syuilo/pirori-wet', + 'syuilo/pirori-square-wet', + 'syuilo/square-pico', + 'syuilo/reverved', + 'syuilo/ryukyu', + 'syuilo/kick', + 'syuilo/snare', + 'syuilo/queue-jammed', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba1', + 'noizenecio/kick_gaba2', + 'noizenecio/kick_gaba3', + 'noizenecio/kick_gaba4', + 'noizenecio/kick_gaba5', + 'noizenecio/kick_gaba6', + 'noizenecio/kick_gaba7', +] as const; + +export function getAudio(file: string, useCache = true): HTMLAudioElement { + let audio: HTMLAudioElement; + if (useCache && cache.has(file)) { + audio = cache.get(file); + } else { + audio = new Audio(`/client-assets/sounds/${file}.mp3`); + if (useCache) cache.set(file, audio); + } + return audio; +} + +export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + audio.volume = masterVolume - ((1 - volume) * masterVolume); + return audio; +} + +export function play(type: string) { + const sound = ColdDeviceStorage.get('sound_' + type as any); + if (sound.type == null) return; + playFile(sound.type, sound.volume); +} + +export function playFile(file: string, volume: number) { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + if (masterVolume === 0) return; + + const audio = setVolume(getAudio(file), volume); + audio.play(); +} diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts new file mode 100644 index 0000000000..c67b8f37ac --- /dev/null +++ b/packages/frontend/src/scripts/sticky-sidebar.ts @@ -0,0 +1,50 @@ +export class StickySidebar { + private lastScrollTop = 0; + private container: HTMLElement; + private el: HTMLElement; + private spacer: HTMLElement; + private marginTop: number; + private isTop = false; + private isBottom = false; + private offsetTop: number; + private globalHeaderHeight: number = 59; + + constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) { + this.container = container; + this.el = this.container.children[0] as HTMLElement; + this.el.style.position = 'sticky'; + this.spacer = document.createElement('div'); + this.container.prepend(this.spacer); + this.marginTop = marginTop; + this.offsetTop = this.container.getBoundingClientRect().top; + this.globalHeaderHeight = globalHeaderHeight; + } + + public calc(scrollTop: number) { + if (scrollTop > this.lastScrollTop) { // downscroll + const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight); + this.el.style.bottom = null; + this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`; + + this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight); + + if (this.isTop) { + this.isTop = false; + this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`; + } + } else { // upscroll + const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight; + this.el.style.top = null; + this.el.style.bottom = `${-overflow}px`; + + this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop; + + if (this.isBottom) { + this.isBottom = false; + this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`; + } + } + + this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; + } +} diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts new file mode 100644 index 0000000000..944875ff15 --- /dev/null +++ b/packages/frontend/src/scripts/theme-editor.ts @@ -0,0 +1,81 @@ +import { v4 as uuid } from 'uuid'; + +import { themeProps, Theme } from './theme'; + +export type Default = null; +export type Color = string; +export type FuncName = 'alpha' | 'darken' | 'lighten'; +export type Func = { type: 'func'; name: FuncName; arg: number; value: string; }; +export type RefProp = { type: 'refProp'; key: string; }; +export type RefConst = { type: 'refConst'; key: string; }; +export type Css = { type: 'css'; value: string; }; + +export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default; + +export type ThemeViewModel = [ string, ThemeValue ][]; + +export const fromThemeString = (str?: string) : ThemeValue => { + if (!str) return null; + if (str.startsWith(':')) { + const parts = str.slice(1).split('<'); + const name = parts[0] as FuncName; + const arg = parseFloat(parts[1]); + const value = parts[2].startsWith('@') ? parts[2].slice(1) : ''; + return { type: 'func', name, arg, value }; + } else if (str.startsWith('@')) { + return { + type: 'refProp', + key: str.slice(1), + }; + } else if (str.startsWith('$')) { + return { + type: 'refConst', + key: str.slice(1), + }; + } else if (str.startsWith('"')) { + return { + type: 'css', + value: str.substr(1).trim(), + }; + } else { + return str; + } +}; + +export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => { + if (typeof value === 'string') return value; + switch (value.type) { + case 'func': return `:${value.name}<${value.arg}<@${value.value}`; + case 'refProp': return `@${value.key}`; + case 'refConst': return `$${value.key}`; + case 'css': return `" ${value.value}`; + } +}; + +export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => { + const props = { } as { [key: string]: string }; + for (const [key, value] of vm) { + if (value === null) continue; + props[key] = toThemeString(value); + } + + return { + id: uuid(), + name, desc, author, props, base, + }; +}; + +export const convertToViewModel = (theme: Theme): ThemeViewModel => { + const vm: ThemeViewModel = []; + // プロパティの登録 + vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ])); + + // 定数の登録 + const consts = Object + .keys(theme.props) + .filter(k => k.startsWith('$')) + .map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]); + + vm.push(...consts); + return vm; +}; diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts new file mode 100644 index 0000000000..62a2b9459a --- /dev/null +++ b/packages/frontend/src/scripts/theme.ts @@ -0,0 +1,148 @@ +import { ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import { globalEvents } from '@/events'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record; +}; + +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { deepClone } from './clone'; + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const getBuiltinThemes = () => Promise.all( + [ + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', + ].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)), +); + +export const getBuiltinThemesRef = () => { + const builtinThemes = ref([]); + getBuiltinThemes().then(themes => builtinThemes.value = themes); + return builtinThemes; +}; + +let timeout = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) window.clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorSchema = theme.base === 'dark' ? 'dark' : 'light'; + + // Deep copy + const _theme = deepClone(theme); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['htmlThemeColor']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + document.documentElement.style.setProperty('color-schema', colorSchema); + + if (persist) { + localStorage.setItem('theme', JSON.stringify(props)); + localStorage.setItem('colorSchema', colorSchema); + } + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); +} + +function compile(theme: Theme): Record { + function getColor(val: string): tinycolor.Instance { + // ref (prop) + if (val[0] === '@') { + return getColor(theme.props[val.substr(1)]); + } + + // ref (const) + else if (val[0] === '$') { + return getColor(theme.props[val]); + } + + // func + else if (val[0] === ':') { + const parts = val.split('<'); + const func = parts.shift().substr(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} + +export function validateTheme(theme: Record): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +} diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts new file mode 100644 index 0000000000..34e8b6b17c --- /dev/null +++ b/packages/frontend/src/scripts/time.ts @@ -0,0 +1,39 @@ +const dateTimeIntervals = { + 'day': 86400000, + 'hour': 3600000, + 'ms': 1, +}; + +export function dateUTC(time: number[]): Date { + const d = time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw 'wrong number of arguments'; + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts new file mode 100644 index 0000000000..8ce07323f6 --- /dev/null +++ b/packages/frontend/src/scripts/timezones.ts @@ -0,0 +1,49 @@ +export const timezones = [{ + name: 'UTC', + abbrev: 'UTC', + offset: 0, +}, { + name: 'Europe/Berlin', + abbrev: 'CET', + offset: 60, +}, { + name: 'Asia/Tokyo', + abbrev: 'JST', + offset: 540, +}, { + name: 'Asia/Seoul', + abbrev: 'KST', + offset: 540, +}, { + name: 'Asia/Shanghai', + abbrev: 'CST', + offset: 480, +}, { + name: 'Australia/Sydney', + abbrev: 'AEST', + offset: 600, +}, { + name: 'Australia/Darwin', + abbrev: 'ACST', + offset: 570, +}, { + name: 'Australia/Perth', + abbrev: 'AWST', + offset: 480, +}, { + name: 'America/New_York', + abbrev: 'EST', + offset: -300, +}, { + name: 'America/Mexico_City', + abbrev: 'CST', + offset: -360, +}, { + name: 'America/Phoenix', + abbrev: 'MST', + offset: -420, +}, { + name: 'America/Los_Angeles', + abbrev: 'PST', + offset: -480, +}]; diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts new file mode 100644 index 0000000000..5251bc2e27 --- /dev/null +++ b/packages/frontend/src/scripts/touch.ts @@ -0,0 +1,23 @@ +const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; + +export let isTouchUsing = false; + +export let isScreenTouching = false; + +if (isTouchSupported) { + window.addEventListener('touchstart', () => { + // maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも + // タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする + isTouchUsing = true; + + isScreenTouching = true; + }, { passive: true }); + + window.addEventListener('touchend', () => { + // 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、 + // touchendイベントでもtouchstartイベントと同様にtrueにする + isTouchUsing = true; + + isScreenTouching = false; + }, { passive: true }); +} diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts new file mode 100644 index 0000000000..59af584c1b --- /dev/null +++ b/packages/frontend/src/scripts/unison-reload.ts @@ -0,0 +1,15 @@ +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; + +export const reloadChannel = new BroadcastChannel('reload'); + +// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。 +export function unisonReload(path?: string) { + if (path !== undefined) { + reloadChannel.postMessage(path); + location.href = path; + } else { + reloadChannel.postMessage(null); + location.reload(); + } +} diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts new file mode 100644 index 0000000000..9a39652ef5 --- /dev/null +++ b/packages/frontend/src/scripts/upload.ts @@ -0,0 +1,137 @@ +import { reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { readAndCompressImage } from 'browser-image-resizer'; +import { getCompressionConfig } from './upload/compress-config'; +import { defaultStore } from '@/store'; +import { apiUrl } from '@/config'; +import { $i } from '@/account'; +import { alert } from '@/os'; +import { i18n } from '@/i18n'; + +type Uploading = { + id: string; + name: string; + progressMax: number | undefined; + progressValue: number | undefined; + img: string; +}; +export const uploads = ref([]); + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +export function uploadFile( + file: File, + folder?: any, + name?: string, + keepOriginal: boolean = defaultStore.state.keepOriginalUploading, +): Promise { + if ($i == null) throw new Error('Not logged in'); + + if (folder && typeof folder === 'object') folder = folder.id; + + return new Promise((resolve, reject) => { + const id = Math.random().toString(); + + const reader = new FileReader(); + reader.onload = async (): Promise => { + const ctx = reactive({ + id: id, + name: name ?? file.name ?? 'untitled', + progressMax: undefined, + progressValue: undefined, + img: window.URL.createObjectURL(file), + }); + + uploads.value.push(ctx); + + const config = !keepOriginal ? await getCompressionConfig(file) : undefined; + let resizedImage: Blob | undefined; + if (config) { + try { + const resized = await readAndCompressImage(file, config); + if (resized.size < file.size || file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + resizedImage = resized; + } + if (_DEV_) { + const saved = ((1 - resized.size / file.size) * 100).toFixed(2); + console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); + } + + ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; + } catch (err) { + console.error('Failed to resize image', err); + } + } + + const formData = new FormData(); + formData.append('i', $i.token); + formData.append('force', 'true'); + formData.append('file', resizedImage ?? file); + formData.append('name', ctx.name); + if (folder) formData.append('folderId', folder); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい + uploads.value = uploads.value.filter(x => x.id !== id); + + if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + + resolve(driveFile); + + uploads.value = uploads.value.filter(x => x.id !== id); + }) as (ev: ProgressEvent) => any; + + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + ctx.progressMax = ev.total; + ctx.progressValue = ev.loaded; + } + }; + + xhr.send(formData); + }; + reader.readAsArrayBuffer(file); + }); +} diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts new file mode 100644 index 0000000000..793c78ad20 --- /dev/null +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -0,0 +1,23 @@ +import isAnimated from 'is-file-animated'; +import type { BrowserImageResizerConfig } from 'browser-image-resizer'; + +const compressTypeMap = { + 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/png': { quality: 1, mimeType: 'image/png' }, + 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, +} as const; + +export async function getCompressionConfig(file: File): Promise { + const imgConfig = compressTypeMap[file.type]; + if (!imgConfig || await isAnimated(file)) { + return; + } + + return { + maxWidth: 2048, + maxHeight: 2048, + debug: true, + ...imgConfig, + }; +} diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts new file mode 100644 index 0000000000..86735de9f0 --- /dev/null +++ b/packages/frontend/src/scripts/url.ts @@ -0,0 +1,13 @@ +export function query(obj: Record): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record); + + return Object.entries(params) + .map((p) => `${p[0]}=${encodeURIComponent(p[1])}`) + .join('&'); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +} diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts new file mode 100644 index 0000000000..881e5e9ad5 --- /dev/null +++ b/packages/frontend/src/scripts/use-chart-tooltip.ts @@ -0,0 +1,54 @@ +import { onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import MkChartTooltip from '@/components/MkChartTooltip.vue'; + +export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref(null); + const tooltipSeries = ref(null); + let disposeTooltipComponent; + + os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}).then(({ dispose }) => { + disposeTooltipComponent = dispose; + }); + + onUnmounted(() => { + if (disposeTooltipComponent) disposeTooltipComponent(); + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; + if (opts.position === 'top') { + tooltipY.value = rect.top + window.pageYOffset; + } else if (opts.position === 'middle') { + tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; + } + } + + return { + handler, + }; +} diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts new file mode 100644 index 0000000000..201ba417ef --- /dev/null +++ b/packages/frontend/src/scripts/use-interval.ts @@ -0,0 +1,24 @@ +import { onMounted, onUnmounted } from 'vue'; + +export function useInterval(fn: () => void, interval: number, options: { + immediate: boolean; + afterMounted: boolean; +}): void { + if (Number.isNaN(interval)) return; + + let intervalId: number | null = null; + + if (options.afterMounted) { + onMounted(() => { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + } else { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + } + + onUnmounted(() => { + if (intervalId) window.clearInterval(intervalId); + }); +} diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts new file mode 100644 index 0000000000..a93b84d1fe --- /dev/null +++ b/packages/frontend/src/scripts/use-leave-guard.ts @@ -0,0 +1,47 @@ +import { inject, onUnmounted, Ref } from 'vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export function useLeaveGuard(enabled: Ref) { + /* TODO + const setLeaveGuard = inject('setLeaveGuard'); + + if (setLeaveGuard) { + setLeaveGuard(async () => { + if (!enabled.value) return false; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return canceled; + }); + } else { + onBeforeRouteLeave(async (to, from) => { + if (!enabled.value) return true; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return !canceled; + }); + } + */ + + /* + function onBeforeLeave(ev: BeforeUnloadEvent) { + if (enabled.value) { + ev.preventDefault(); + ev.returnValue = ''; + } + } + + window.addEventListener('beforeunload', onBeforeLeave); + onUnmounted(() => { + window.removeEventListener('beforeunload', onBeforeLeave); + }); + */ +} diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts new file mode 100644 index 0000000000..e6bdb345c4 --- /dev/null +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -0,0 +1,110 @@ +import { onUnmounted, Ref } from 'vue'; +import * as misskey from 'misskey-js'; +import { stream } from '@/stream'; +import { $i } from '@/account'; + +export function useNoteCapture(props: { + rootEl: Ref; + note: Ref; + isDeletedRef: Ref; +}) { + const note = props.note; + const connection = $i ? stream : null; + + function onStreamNoteUpdated(noteData): void { + const { type, id, body } = noteData; + + if (id !== note.value.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + if (body.emoji) { + const emojis = note.value.emojis || []; + if (!emojis.includes(body.emoji)) { + note.value.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = currentCount + 1; + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = reaction; + } + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = Math.max(0, currentCount - 1); + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = null; + } + break; + } + + case 'pollVoted': { + const choice = body.choice; + + const choices = [...note.value.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...($i && (body.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + note.value.poll.choices = choices; + break; + } + + case 'deleted': { + props.isDeletedRef.value = true; + break; + } + } + } + + function capture(withHandler = false): void { + if (connection) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); + } + } + + function decapture(withHandler = false): void { + if (connection) { + connection.send('un', { + id: note.value.id, + }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); + } + } + + function onStreamConnected() { + capture(false); + } + + capture(true); + if (connection) { + connection.on('_connected_', onStreamConnected); + } + + onUnmounted(() => { + decapture(true); + if (connection) { + connection.off('_connected_', onStreamConnected); + } + }); +} diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts new file mode 100644 index 0000000000..1f6e0fb6ce --- /dev/null +++ b/packages/frontend/src/scripts/use-tooltip.ts @@ -0,0 +1,86 @@ +import { Ref, ref, watch, onUnmounted } from 'vue'; + +export function useTooltip( + elRef: Ref, + onShow: (showing: Ref) => void, + delay = 300, +): void { + let isHovering = false; + + // iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ + // 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる + // TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...? + let shouldIgnoreMouseover = false; + + let timeoutId: number; + + let changeShowingState: (() => void) | null; + + const open = () => { + close(); + if (!isHovering) return; + if (elRef.value == null) return; + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + + const showing = ref(true); + onShow(showing); + changeShowingState = () => { + showing.value = false; + }; + }; + + const close = () => { + if (changeShowingState != null) { + changeShowingState(); + changeShowingState = null; + } + }; + + const onMouseover = () => { + if (isHovering) return; + if (shouldIgnoreMouseover) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onMouseleave = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + const onTouchstart = () => { + shouldIgnoreMouseover = true; + if (isHovering) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onTouchend = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + const stop = watch(elRef, () => { + if (elRef.value) { + stop(); + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + el.addEventListener('mouseover', onMouseover, { passive: true }); + el.addEventListener('mouseleave', onMouseleave, { passive: true }); + el.addEventListener('touchstart', onTouchstart, { passive: true }); + el.addEventListener('touchend', onTouchend, { passive: true }); + el.addEventListener('click', close, { passive: true }); + } + }, { + immediate: true, + flush: 'post', + }); + + onUnmounted(() => { + close(); + }); +} diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts new file mode 100644 index 0000000000..1bedab5fad --- /dev/null +++ b/packages/frontend/src/store.ts @@ -0,0 +1,383 @@ +import { markRaw, ref } from 'vue'; +import { Storage } from './pizzax'; +import { Theme } from './scripts/theme'; + +interface PostFormAction { + title: string, + handler: (form: T, update: (key: unknown, value: unknown) => void) => void; +} + +interface UserAction { + title: string, + handler: (user: UserDetailed) => void; +} + +interface NoteAction { + title: string, + handler: (note: Note) => void; +} + +interface NoteViewInterruptor { + handler: (note: Note) => unknown; +} + +interface NotePostInterruptor { + handler: (note: FIXME) => unknown; +} + +export const postFormActions: PostFormAction[] = []; +export const userActions: UserAction[] = []; +export const noteActions: NoteAction[] = []; +export const noteViewInterruptors: NoteViewInterruptor[] = []; +export const notePostInterruptors: NotePostInterruptor[] = []; + +// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) +// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない +export const defaultStore = markRaw(new Storage('base', { + tutorial: { + where: 'account', + default: 0, + }, + keepCw: { + where: 'account', + default: true, + }, + showFullAcct: { + where: 'account', + default: false, + }, + rememberNoteVisibility: { + where: 'account', + default: false, + }, + defaultNoteVisibility: { + where: 'account', + default: 'public', + }, + defaultNoteLocalOnly: { + where: 'account', + default: false, + }, + uploadFolder: { + where: 'account', + default: null as string | null, + }, + pastedFileName: { + where: 'account', + default: 'yyyy-MM-dd HH-mm-ss [{{number}}]', + }, + keepOriginalUploading: { + where: 'account', + default: false, + }, + memo: { + where: 'account', + default: null, + }, + reactions: { + where: 'account', + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + }, + mutedWords: { + where: 'account', + default: [], + }, + mutedAds: { + where: 'account', + default: [] as string[], + }, + + menu: { + where: 'deviceAccount', + default: [ + 'notifications', + 'favorites', + 'drive', + 'followRequests', + '-', + 'explore', + 'announcements', + 'search', + '-', + 'ui', + ], + }, + visibility: { + where: 'deviceAccount', + default: 'public' as 'public' | 'home' | 'followers' | 'specified', + }, + localOnly: { + where: 'deviceAccount', + default: false, + }, + statusbars: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record; + }[], + }, + widgets: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + place: string | null; + data: Record; + }[], + }, + tl: { + where: 'deviceAccount', + default: { + src: 'home' as 'home' | 'local' | 'social' | 'global', + arg: null, + }, + }, + + overridedDeviceKind: { + where: 'device', + default: null as null | 'smartphone' | 'tablet' | 'desktop', + }, + serverDisconnectedBehavior: { + where: 'device', + default: 'quiet' as 'quiet' | 'reload' | 'dialog', + }, + nsfw: { + where: 'device', + default: 'respect' as 'respect' | 'force' | 'ignore', + }, + animation: { + where: 'device', + default: true, + }, + animatedMfm: { + where: 'device', + default: false, + }, + loadRawImages: { + where: 'device', + default: false, + }, + imageNewTab: { + where: 'device', + default: false, + }, + disableShowingAnimatedImages: { + where: 'device', + default: false, + }, + disablePagesScript: { + where: 'device', + default: false, + }, + emojiStyle: { + where: 'device', + default: 'twemoji', // twemoji / fluentEmoji / native + }, + disableDrawer: { + where: 'device', + default: false, + }, + useBlurEffectForModal: { + where: 'device', + default: true, + }, + useBlurEffect: { + where: 'device', + default: true, + }, + showFixedPostForm: { + where: 'device', + default: false, + }, + enableInfiniteScroll: { + where: 'device', + default: true, + }, + useReactionPickerForContextMenu: { + where: 'device', + default: false, + }, + showGapBetweenNotesInTimeline: { + where: 'device', + default: false, + }, + darkMode: { + where: 'device', + default: false, + }, + instanceTicker: { + where: 'device', + default: 'remote' as 'none' | 'remote' | 'always', + }, + reactionPickerSize: { + where: 'device', + default: 1, + }, + reactionPickerWidth: { + where: 'device', + default: 1, + }, + reactionPickerHeight: { + where: 'device', + default: 2, + }, + reactionPickerUseDrawerForMobile: { + where: 'device', + default: true, + }, + recentlyUsedEmojis: { + where: 'device', + default: [] as string[], + }, + recentlyUsedUsers: { + where: 'device', + default: [] as string[], + }, + defaultSideView: { + where: 'device', + default: false, + }, + menuDisplay: { + where: 'device', + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', + }, + reportError: { + where: 'device', + default: false, + }, + squareAvatars: { + where: 'device', + default: false, + }, + postFormWithHashtags: { + where: 'device', + default: false, + }, + postFormHashtags: { + where: 'device', + default: '', + }, + themeInitial: { + where: 'device', + default: true, + }, + numberOfPageCache: { + where: 'device', + default: 5, + }, + aiChanMode: { + where: 'device', + default: false, + }, +})); + +// TODO: 他のタブと永続化されたstateを同期 + +const PREFIX = 'miux:'; + +type Plugin = { + id: string; + name: string; + active: boolean; + configData: Record; + token: string; + ast: any[]; +}; + +interface Watcher { + key: string; + callback: (value: unknown) => void; +} + +/** + * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ) + */ +import lightTheme from '@/themes/l-light.json5'; +import darkTheme from '@/themes/d-green-lime.json5'; +import { Note, UserDetailed } from 'misskey-js/built/entities'; + +export class ColdDeviceStorage { + public static default = { + lightTheme, + darkTheme, + syncDeviceDarkMode: true, + plugins: [] as Plugin[], + mediaVolume: 0.5, + sound_masterVolume: 0.3, + sound_note: { type: 'syuilo/down', volume: 1 }, + sound_noteMy: { type: 'syuilo/up', volume: 1 }, + sound_notification: { type: 'syuilo/pope2', volume: 1 }, + sound_chat: { type: 'syuilo/pope1', volume: 1 }, + sound_chatBg: { type: 'syuilo/waon', volume: 1 }, + sound_antenna: { type: 'syuilo/triple', volume: 1 }, + sound_channel: { type: 'syuilo/square-pico', volume: 1 }, + }; + + public static watchers: Watcher[] = []; + + public static get(key: T): typeof ColdDeviceStorage.default[T] { + // TODO: indexedDBにする + // ただしその際はnullチェックではなくキー存在チェックにしないとダメ + // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) + const value = localStorage.getItem(PREFIX + key); + if (value == null) { + return ColdDeviceStorage.default[key]; + } else { + return JSON.parse(value); + } + } + + public static set(key: T, value: typeof ColdDeviceStorage.default[T]): void { + // 呼び出し側のバグ等で undefined が来ることがある + // undefined を文字列として localStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value === undefined) { + console.error(`attempt to store undefined value for key '${key}'`); + return; + } + + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + + for (const watcher of this.watchers) { + if (watcher.key === key) watcher.callback(value); + } + } + + public static watch(key, callback) { + this.watchers.push({ key, callback }); + } + + // TODO: VueのcustomRef使うと良い感じになるかも + public static ref(key: T) { + const v = ColdDeviceStorage.get(key); + const r = ref(v); + // TODO: このままではwatcherがリークするので開放する方法を考える + this.watch(key, v => { + r.value = v; + }); + return r; + } + + /** + * 特定のキーの、簡易的なgetter/setterを作ります + * 主にvue場で設定コントロールのmodelとして使う用 + */ + public static makeGetterSetter(key: K) { + // TODO: VueのcustomRef使うと良い感じになるかも + const valueRef = ColdDeviceStorage.ref(key); + return { + get: () => { + return valueRef.value; + }, + set: (value: unknown) => { + const val = value; + ColdDeviceStorage.set(key, val); + }, + }; + } +} diff --git a/packages/frontend/src/stream.ts b/packages/frontend/src/stream.ts new file mode 100644 index 0000000000..dea3459b86 --- /dev/null +++ b/packages/frontend/src/stream.ts @@ -0,0 +1,8 @@ +import * as Misskey from 'misskey-js'; +import { markRaw } from 'vue'; +import { $i } from '@/account'; +import { url } from '@/config'; + +export const stream = markRaw(new Misskey.Stream(url, $i ? { + token: $i.token, +} : null)); diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss new file mode 100644 index 0000000000..8b7a846863 --- /dev/null +++ b/packages/frontend/src/style.scss @@ -0,0 +1,584 @@ +@charset "utf-8"; + +:root { + --radius: 12px; + --marginFull: 16px; + --marginHalf: 10px; + + --margin: var(--marginFull); + + @media (max-width: 500px) { + --margin: var(--marginHalf); + } + + //--ad: rgb(255 169 0 / 10%); +} + +::selection { + color: #fff; + background-color: var(--accent); +} + +html { + touch-action: manipulation; + background-color: var(--bg); + background-attachment: fixed; + background-size: cover; + background-position: center; + color: var(--fg); + accent-color: var(--accent); + overflow: auto; + overflow-wrap: break-word; + font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; + font-size: 14px; + line-height: 1.35; + text-size-adjust: 100%; + tab-size: 2; + + &, * { + scrollbar-color: var(--scrollbarHandle) inherit; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + &::-webkit-scrollbar-track { + background: inherit; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbarHandle); + + &:hover { + background: var(--scrollbarHandleHover); + } + + &:active { + background: var(--accent); + } + } + } + + &.f-1 { + font-size: 15px; + } + + &.f-2 { + font-size: 16px; + } + + &.f-3 { + font-size: 17px; + } + + &.useSystemFont { + font-family: 'Hiragino Maru Gothic Pro', sans-serif; + } +} + +html._themeChanging_ { + &, * { + transition: background 1s ease, border 1s ease !important; + } +} + +html, body { + margin: 0; + padding: 0; + scroll-behavior: smooth; +} + +a { + text-decoration: none; + cursor: pointer; + color: inherit; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + + &:hover { + text-decoration: underline; + } +} + +textarea, input { + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; +} + +optgroup, option { + background: var(--panel); + color: var(--fg); +} + +hr { + margin: var(--margin) 0 var(--margin) 0; + border: none; + height: 1px; + background: var(--divider); +} + +.ti { + vertical-align: -10%; + line-height: 0.9em; + + &:before { + font-size: 130%; + } +} + +.ti-fw { + display: inline-block; + text-align: center; +} + +._indicatorCircle { + display: inline-block; + width: 1em; + height: 1em; + border-radius: 100%; + background: currentColor; +} + +._noSelect { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +._ghost { + &, * { + @extend ._noSelect; + pointer-events: none; + } +} + +._modalBg { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--modalBg); + -webkit-backdrop-filter: var(--modalBgFilter); + backdrop-filter: var(--modalBgFilter); +} + +._shadow { + box-shadow: 0px 4px 32px var(--shadow) !important; +} + +._button { + appearance: none; + display: inline-block; + padding: 0; + margin: 0; // for Safari + background: none; + border: none; + cursor: pointer; + color: inherit; + touch-action: manipulation; + tap-highlight-color: transparent; + -webkit-tap-highlight-color: transparent; + font-size: 1em; + font-family: inherit; + line-height: inherit; + max-width: 100%; + + &, * { + @extend ._noSelect; + } + + * { + pointer-events: none; + } + + &:focus-visible { + outline: none; + } + + &:disabled { + opacity: 0.5; + cursor: default; + } +} + +._buttonPrimary { + @extend ._button; + color: var(--fgOnAccent); + background: var(--accent); + + &:not(:disabled):hover { + background: var(--X8); + } + + &:not(:disabled):active { + background: var(--X9); + } +} + +._buttonGradate { + @extend ._buttonPrimary; + color: var(--fgOnAccent); + background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB)); + + &:not(:disabled):hover { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } + + &:not(:disabled):active { + background: linear-gradient(90deg, var(--X8), var(--X8)); + } +} + +._help { + color: var(--accent); + cursor: help +} + +._textButton { + @extend ._button; + color: var(--accent); + + &:not(:disabled):hover { + text-decoration: underline; + } +} + +._inputs { + display: flex; + margin: 32px 0; + + &:first-child { + margin-top: 8px; + } + + &:last-child { + margin-bottom: 8px; + } + + > * { + flex: 1; + margin: 0 !important; + + &:not(:first-child) { + margin-left: 8px !important; + } + + &:not(:last-child) { + margin-right: 8px !important; + } + } +} + +._panel { + background: var(--panel); + border-radius: var(--radius); + overflow: clip; +} + +._block { + @extend ._panel; + + & + ._block { + margin-top: var(--margin); + } +} + +._gap { + margin: var(--margin) 0; +} + +// TODO: 廃止 +._card { + @extend ._panel; + + // TODO: _cardTitle に + > ._title { + margin: 0; + padding: 22px 32px; + font-size: 1em; + border-bottom: solid 1px var(--panelHeaderDivider); + font-weight: bold; + background: var(--panelHeaderBg); + color: var(--panelHeaderFg); + + @media (max-width: 500px) { + padding: 16px; + font-size: 1em; + } + } + + // TODO: _cardContent に + > ._content { + padding: 32px; + + @media (max-width: 500px) { + padding: 16px; + } + + &._noPad { + padding: 0 !important; + } + + & + ._content { + border-top: solid 0.5px var(--divider); + } + } + + // TODO: _cardFooter に + > ._footer { + border-top: solid 0.5px var(--divider); + padding: 24px 32px; + + @media (max-width: 500px) { + padding: 16px; + } + } +} + +._borderButton { + @extend ._button; + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border: solid 0.5px var(--divider); + border-radius: var(--radius); + + &:active { + border-color: var(--accent); + } +} + +._popup { + background: var(--popup); + border-radius: var(--radius); + contain: content; +} + +// TODO: 廃止 +._monolithic_ { + ._section:not(:empty) { + box-sizing: border-box; + padding: var(--root-margin, 32px); + + @media (max-width: 500px) { + --root-margin: 10px; + } + + & + ._section:not(:empty) { + border-top: solid 0.5px var(--divider); + } + } +} + +._narrow_ ._card { + > ._title { + padding: 16px; + font-size: 1em; + } + + > ._content { + padding: 16px; + } + + > ._footer { + padding: 16px; + } +} + +._acrylic { + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +._formBlock { + margin: 1.5em 0; +} + +._formRoot { + > ._formBlock:first-child { + margin-top: 0; + } + + > ._formBlock:last-child { + margin-bottom: 0; + } +} + +._formLinksGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-gap: 12px; +} + +._formLinks { + > *:not(:last-child) { + margin-bottom: 8px; + } +} + +._beta { + margin-left: 0.7em; + font-size: 65%; + padding: 2px 3px; + color: var(--accent); + border: solid 1px var(--accent); + border-radius: 4px; + vertical-align: top; +} + +._table { + > ._row { + display: flex; + + &:not(:last-child) { + margin-bottom: 16px; + + @media (max-width: 500px) { + margin-bottom: 8px; + } + } + + > ._cell { + flex: 1; + + > ._label { + font-size: 80%; + opacity: 0.7; + + > ._icon { + margin-right: 4px; + display: none; + } + } + } + } +} + +._fullinfo { + padding: 64px 32px; + text-align: center; + + > img { + vertical-align: bottom; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; + } +} + +._keyValue { + display: flex; + + > * { + flex: 1; + } +} + +._link { + color: var(--link); +} + +._caption { + font-size: 0.8em; + opacity: 0.7; +} + +._monospace { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace !important; +} + +._code { + @extend ._monospace; + background: #2d2d2d; + color: #ccc; + font-size: 14px; + line-height: 1.5; + padding: 5px; +} + +.prism-editor__textarea:focus { + outline: none; +} + +._zoom { + transition-duration: 0.5s, 0.5s; + transition-property: opacity, transform; + transition-timing-function: cubic-bezier(0,.5,.5,1); +} + +.zoom-enter-active, .zoom-leave-active { + transition: opacity 0.5s, transform 0.5s !important; +} +.zoom-enter-from, .zoom-leave-to { + opacity: 0; + transform: scale(0.9); +} + +@keyframes blink { + 0% { opacity: 1; transform: scale(1); } + 30% { opacity: 1; transform: scale(1); } + 90% { opacity: 0; transform: scale(0.5); } +} + +@keyframes tada { + from { + transform: scale3d(1, 1, 1); + } + + 10%, + 20% { + transform: scale3d(0.9, 0.9, 0.9) rotate3d(0, 0, 1, -3deg); + } + + 30%, + 50%, + 70%, + 90% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, 3deg); + } + + 40%, + 60%, + 80% { + transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg); + } + + to { + transform: scale3d(1, 1, 1); + } +} + +._anime_bounce { + will-change: transform; + animation: bounce ease 0.7s; + animation-iteration-count: 1; + transform-origin: 50% 50%; +} +._anime_bounce_ready { + will-change: transform; + transform: scaleX(0.90) scaleY(0.90) ; +} +._anime_bounce_standBy { + transition: transform 0.1s ease; +} + +@keyframes bounce{ + 0% { + transform: scaleX(0.90) scaleY(0.90) ; + } + 19% { + transform: scaleX(1.10) scaleY(1.10) ; + } + 48% { + transform: scaleX(0.95) scaleY(0.95) ; + } + 100% { + transform: scaleX(1.00) scaleY(1.00) ; + } +} diff --git a/packages/frontend/src/theme-store.ts b/packages/frontend/src/theme-store.ts new file mode 100644 index 0000000000..fdc92ed793 --- /dev/null +++ b/packages/frontend/src/theme-store.ts @@ -0,0 +1,34 @@ +import { api } from '@/os'; +import { $i } from '@/account'; +import { Theme } from './scripts/theme'; + +const lsCacheKey = $i ? `themes:${$i.id}` : ''; + +export function getThemes(): Theme[] { + return JSON.parse(localStorage.getItem(lsCacheKey) || '[]'); +} + +export async function fetchThemes(): Promise { + if ($i == null) return; + + try { + const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') return; + throw err; + } +} + +export async function addTheme(theme: Theme): Promise { + await fetchThemes(); + const themes = getThemes().concat(theme); + await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); +} + +export async function removeTheme(theme: Theme): Promise { + const themes = getThemes().filter(t => t.id !== theme.id); + await api('i/registry/set', { scope: ['client'], key: 'themes', value: themes }); + localStorage.setItem(lsCacheKey, JSON.stringify(themes)); +} diff --git a/packages/frontend/src/themes/_dark.json5 b/packages/frontend/src/themes/_dark.json5 new file mode 100644 index 0000000000..88ec8a5459 --- /dev/null +++ b/packages/frontend/src/themes/_dark.json5 @@ -0,0 +1,99 @@ +// ダークテーマのベーステーマ +// このテーマが直接使われることは無い +{ + id: 'dark', + + name: 'Dark', + author: 'syuilo', + desc: 'Default dark theme', + kind: 'dark', + + props: { + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + accentedBg: ':alpha<0.15<@accent', + focus: ':alpha<0.3<@accent', + bg: '#000', + acrylicBg: ':alpha<0.5<@bg', + fg: '#dadada', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', + fgHighlighted: ':lighten<3<@fg', + fgOnAccent: '#fff', + divider: 'rgba(255, 255, 255, 0.1)', + indicator: '@accent', + panel: ':lighten<3<@bg', + panelHighlight: ':lighten<3<@panel', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + panelBorder: '" solid 1px var(--divider)', + acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', + popup: ':lighten<3<@panel', + shadow: 'rgba(0, 0, 0, 0.3)', + header: ':alpha<0.7<@panel', + navBg: '@panel', + navFg: '@fg', + navHoverFg: ':lighten<17<@fg', + navActive: '@accent', + navIndicator: '@indicator', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + mentionMe: '@mention', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.5)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + dateLabelFg: '@fg', + infoBg: '#253142', + infoFg: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + switchBg: 'rgba(255, 255, 255, 0.15)', + cwBg: '#687390', + cwFg: '#393f4f', + cwHoverBg: '#707b97', + buttonBg: 'rgba(255, 255, 255, 0.05)', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(255, 255, 255, 0.1)', + swutchOffFg: '@fg', + swutchOnBg: '@accentedBg', + swutchOnFg: '@accent', + inputBorder: 'rgba(255, 255, 255, 0.1)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + badge: '#31b1ce', + messageBg: '@bg', + success: '#86b300', + error: '#ec4137', + warn: '#ecb637', + codeString: '#ffb675', + codeNumber: '#cfff9e', + codeBoolean: '#c59eff', + deckDivider: '#000', + htmlThemeColor: '@bg', + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/frontend/src/themes/_light.json5 b/packages/frontend/src/themes/_light.json5 new file mode 100644 index 0000000000..bad1291c83 --- /dev/null +++ b/packages/frontend/src/themes/_light.json5 @@ -0,0 +1,99 @@ +// ライトテーマのベーステーマ +// このテーマが直接使われることは無い +{ + id: 'light', + + name: 'Light', + author: 'syuilo', + desc: 'Default light theme', + kind: 'light', + + props: { + accent: '#86b300', + accentDarken: ':darken<10<@accent', + accentLighten: ':lighten<10<@accent', + accentedBg: ':alpha<0.15<@accent', + focus: ':alpha<0.3<@accent', + bg: '#fff', + acrylicBg: ':alpha<0.5<@bg', + fg: '#5f5f5f', + fgTransparentWeak: ':alpha<0.75<@fg', + fgTransparent: ':alpha<0.5<@fg', + fgHighlighted: ':darken<3<@fg', + fgOnAccent: '#fff', + divider: 'rgba(0, 0, 0, 0.1)', + indicator: '@accent', + panel: ':lighten<3<@bg', + panelHighlight: ':darken<3<@panel', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + panelBorder: '" solid 1px var(--divider)', + acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', + popup: ':lighten<3<@panel', + shadow: 'rgba(0, 0, 0, 0.1)', + header: ':alpha<0.7<@panel', + navBg: '@panel', + navFg: '@fg', + navHoverFg: ':darken<17<@fg', + navActive: '@accent', + navIndicator: '@indicator', + link: '#44a4c1', + hashtag: '#ff9156', + mention: '@accent', + mentionMe: '@mention', + renote: '#229e82', + modalBg: 'rgba(0, 0, 0, 0.3)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + dateLabelFg: '@fg', + infoBg: '#e5f5ff', + infoFg: '#72818a', + infoWarnBg: '#fff0db', + infoWarnFg: '#8f6e31', + switchBg: 'rgba(0, 0, 0, 0.15)', + cwBg: '#b1b9c1', + cwFg: '#fff', + cwHoverBg: '#bbc4ce', + buttonBg: 'rgba(0, 0, 0, 0.05)', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(0, 0, 0, 0.1)', + swutchOffFg: '@panel', + swutchOnBg: '@accent', + swutchOnFg: '@fgOnAccent', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + driveFolderBg: ':alpha<0.3<@accent', + wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', + badge: '#31b1ce', + messageBg: '@bg', + success: '#86b300', + error: '#ec4137', + warn: '#ecb637', + codeString: '#b98710', + codeNumber: '#0fbbbb', + codeBoolean: '#62b70c', + deckDivider: ':darken<3<@bg', + htmlThemeColor: '@bg', + X2: ':darken<2<@panel', + X3: 'rgba(0, 0, 0, 0.05)', + X4: 'rgba(0, 0, 0, 0.1)', + X5: 'rgba(0, 0, 0, 0.05)', + X6: 'rgba(0, 0, 0, 0.25)', + X7: 'rgba(0, 0, 0, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.1)', + X12: 'rgba(0, 0, 0, 0.1)', + X13: 'rgba(0, 0, 0, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/frontend/src/themes/d-astro.json5 b/packages/frontend/src/themes/d-astro.json5 new file mode 100644 index 0000000000..c6a927ec3a --- /dev/null +++ b/packages/frontend/src/themes/d-astro.json5 @@ -0,0 +1,78 @@ +{ + id: '080a01c5-377d-4fbb-88cc-6bb5d04977ea', + base: 'dark', + name: 'Mi Astro Dark', + author: 'syuilo', + props: { + bg: '#232125', + fg: '#efdab9', + cwBg: '#687390', + cwFg: '#393f4f', + link: '#78b0a0', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: '#2a272b', + accent: '#81c08b', + header: ':alpha<0.7<@bg', + infoBg: '#253142', + infoFg: '#fff', + renote: '#659CC8', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: '#ff9156', + mention: '#ffd152', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '#fb5d38', + messageBg: '@bg', + navActive: '@accent', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@accent', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + buttonGradateA: '@accent', + buttonGradateB: ':hue<-20<@accent', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + }, +} diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5 new file mode 100644 index 0000000000..c03b95e2d7 --- /dev/null +++ b/packages/frontend/src/themes/d-botanical.json5 @@ -0,0 +1,26 @@ +{ + id: '504debaf-4912-6a4c-5059-1db08a76b737', + + name: 'Mi Botanical Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(148, 179, 0)', + bg: 'rgb(37, 38, 36)', + fg: 'rgb(216, 212, 199)', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: 'rgb(47, 47, 44)', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + header: ':alpha<0.7<@panel', + navBg: '#363636', + renote: '@accent', + mention: 'rgb(212, 153, 76)', + mentionMe: 'rgb(212, 210, 76)', + hashtag: '#5bcbb0', + link: '@accent', + }, +} diff --git a/packages/frontend/src/themes/d-cherry.json5 b/packages/frontend/src/themes/d-cherry.json5 new file mode 100644 index 0000000000..a7e1ad1c80 --- /dev/null +++ b/packages/frontend/src/themes/d-cherry.json5 @@ -0,0 +1,20 @@ +{ + id: '679b3b87-a4e9-4789-8696-b56c15cc33b0', + + name: 'Mi Cherry Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(255, 89, 117)', + bg: 'rgb(28, 28, 37)', + fg: 'rgb(236, 239, 244)', + panel: 'rgb(35, 35, 47)', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + divider: 'rgb(63, 63, 80)', + }, +} diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5 new file mode 100644 index 0000000000..d24ce4df69 --- /dev/null +++ b/packages/frontend/src/themes/d-dark.json5 @@ -0,0 +1,26 @@ +{ + id: '8050783a-7f63-445a-b270-36d0f6ba1677', + + name: 'Mi Dark', + author: 'syuilo', + desc: 'Default light theme', + + base: 'dark', + + props: { + bg: '#232323', + fg: 'rgb(199, 209, 216)', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: '#2d2d2d', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + header: ':alpha<0.7<@panel', + navBg: '#363636', + renote: '@accent', + mention: '#da6d35', + mentionMe: '#d44c4c', + hashtag: '#4cb8d4', + link: '@accent', + }, +} diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5 new file mode 100644 index 0000000000..b6fa1ab0c1 --- /dev/null +++ b/packages/frontend/src/themes/d-future.json5 @@ -0,0 +1,27 @@ +{ + id: '32a637ef-b47a-4775-bb7b-bacbb823f865', + + name: 'Mi Future Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#63e2b7', + bg: '#101014', + fg: '#D5D5D6', + fgHighlighted: '#fff', + fgOnAccent: '#000', + divider: 'rgba(255, 255, 255, 0.1)', + panel: '#18181c', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + renote: '@accent', + mention: '#f2c97d', + mentionMe: '@accent', + hashtag: '#70c0e8', + link: '#e88080', + buttonGradateA: '@accent', + buttonGradateB: ':saturate<30<:hue<30<@accent', + }, +} diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5 new file mode 100644 index 0000000000..a6983b9ac2 --- /dev/null +++ b/packages/frontend/src/themes/d-green-lime.json5 @@ -0,0 +1,24 @@ +{ + id: '02816013-8107-440f-877e-865083ffe194', + + name: 'Mi Green+Lime Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#b4e900', + bg: '#0C1210', + fg: '#dee7e4', + fgHighlighted: '#fff', + fgOnAccent: '#192320', + divider: '#e7fffb24', + panel: '#192320', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + popup: '#293330', + renote: '@accent', + mentionMe: '#ffaa00', + link: '#24d7ce', + }, +} diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5 new file mode 100644 index 0000000000..62adc39e29 --- /dev/null +++ b/packages/frontend/src/themes/d-green-orange.json5 @@ -0,0 +1,24 @@ +{ + id: 'dc489603-27b5-424a-9b25-1ff6aec9824a', + + name: 'Mi Green+Orange Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#e97f00', + bg: '#0C1210', + fg: '#dee7e4', + fgHighlighted: '#fff', + fgOnAccent: '#192320', + divider: '#e7fffb24', + panel: '#192320', + panelHeaderBg: '@panel', + panelHeaderDivider: '@divider', + popup: '#293330', + renote: '@accent', + mentionMe: '#b4e900', + link: '#24d7ce', + }, +} diff --git a/packages/frontend/src/themes/d-ice.json5 b/packages/frontend/src/themes/d-ice.json5 new file mode 100644 index 0000000000..179b060dcf --- /dev/null +++ b/packages/frontend/src/themes/d-ice.json5 @@ -0,0 +1,13 @@ +{ + id: '66e7e5a9-cd43-42cd-837d-12f47841fa34', + + name: 'Mi Ice Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: '#47BFE8', + bg: '#212526', + }, +} diff --git a/packages/frontend/src/themes/d-persimmon.json5 b/packages/frontend/src/themes/d-persimmon.json5 new file mode 100644 index 0000000000..e36265ff10 --- /dev/null +++ b/packages/frontend/src/themes/d-persimmon.json5 @@ -0,0 +1,25 @@ +{ + id: 'c503d768-7c70-4db2-a4e6-08264304bc8d', + + name: 'Mi Persimmon Dark', + author: 'syuilo', + + base: 'dark', + + props: { + accent: 'rgb(206, 102, 65)', + bg: 'rgb(31, 33, 31)', + fg: '#cdd8c7', + fgHighlighted: '#fff', + divider: 'rgba(255, 255, 255, 0.14)', + panel: 'rgb(41, 43, 41)', + infoFg: '@fg', + infoBg: '#333c3b', + navBg: '#141714', + renote: '@accent', + mention: '@accent', + mentionMe: '#de6161', + hashtag: '#68bad0', + link: '#a1c758', + }, +} diff --git a/packages/frontend/src/themes/d-u0.json5 b/packages/frontend/src/themes/d-u0.json5 new file mode 100644 index 0000000000..b270f809ac --- /dev/null +++ b/packages/frontend/src/themes/d-u0.json5 @@ -0,0 +1,88 @@ +{ + id: '7a5bc13b-df8f-4d44-8e94-4452f0c634bb', + base: 'dark', + name: 'Mi U0 Dark', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#172426', + fg: '#dadada', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: '@accent', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: '#00a497', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: 'rgba(255, 255, 255, 0.1)', + hashtag: '#e6b422', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: 'rgba(255, 255, 255, 0.05)', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(255, 255, 255, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: 'rgba(255, 255, 255, 0.2)', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + deckDivider: '#142022', + }, +} diff --git a/packages/frontend/src/themes/l-apricot.json5 b/packages/frontend/src/themes/l-apricot.json5 new file mode 100644 index 0000000000..1ed5525575 --- /dev/null +++ b/packages/frontend/src/themes/l-apricot.json5 @@ -0,0 +1,22 @@ +{ + id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b', + + name: 'Mi Apricot Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: 'rgb(234, 154, 82)', + bg: '#e6e5e2', + fg: 'rgb(149, 143, 139)', + panel: '#EEECE8', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + infoBg: 'rgb(226, 235, 241)', + }, +} diff --git a/packages/frontend/src/themes/l-cherry.json5 b/packages/frontend/src/themes/l-cherry.json5 new file mode 100644 index 0000000000..5ad240241e --- /dev/null +++ b/packages/frontend/src/themes/l-cherry.json5 @@ -0,0 +1,21 @@ +{ + id: 'ac168876-f737-4074-a3fc-a370c732ef48', + + name: 'Mi Cherry Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: 'rgb(219, 96, 114)', + bg: 'rgb(254, 248, 249)', + fg: 'rgb(152, 13, 26)', + panel: 'rgb(255, 255, 255)', + renote: '@accent', + link: 'rgb(156, 187, 5)', + mention: '@accent', + hashtag: '@accent', + divider: 'rgba(134, 51, 51, 0.1)', + inputBorderHover: 'rgb(238, 221, 222)', + }, +} diff --git a/packages/frontend/src/themes/l-coffee.json5 b/packages/frontend/src/themes/l-coffee.json5 new file mode 100644 index 0000000000..fbcd4fa9ef --- /dev/null +++ b/packages/frontend/src/themes/l-coffee.json5 @@ -0,0 +1,21 @@ +{ + id: '6ed80faa-74f0-42c2-98e4-a64d9e138eab', + + name: 'Mi Coffee Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#9f8989', + bg: '#f5f3f3', + fg: '#7f6666', + panel: '#fff', + divider: 'rgba(87, 68, 68, 0.1)', + renote: 'rgb(160, 172, 125)', + link: 'rgb(137, 151, 159)', + mention: '@accent', + mentionMe: 'rgb(170, 149, 98)', + hashtag: '@accent', + }, +} diff --git a/packages/frontend/src/themes/l-light.json5 b/packages/frontend/src/themes/l-light.json5 new file mode 100644 index 0000000000..248355c945 --- /dev/null +++ b/packages/frontend/src/themes/l-light.json5 @@ -0,0 +1,20 @@ +{ + id: '4eea646f-7afa-4645-83e9-83af0333cd37', + + name: 'Mi Light', + author: 'syuilo', + desc: 'Default light theme', + + base: 'light', + + props: { + bg: '#f9f9f9', + fg: '#676767', + divider: '#e8e8e8', + header: ':alpha<0.7<@panel', + navBg: '#fff', + panel: '#fff', + panelHeaderDivider: '@divider', + mentionMe: 'rgb(0, 179, 70)', + }, +} diff --git a/packages/frontend/src/themes/l-rainy.json5 b/packages/frontend/src/themes/l-rainy.json5 new file mode 100644 index 0000000000..283dd74c6c --- /dev/null +++ b/packages/frontend/src/themes/l-rainy.json5 @@ -0,0 +1,21 @@ +{ + id: 'a58a0abb-ff8c-476a-8dec-0ad7837e7e96', + + name: 'Mi Rainy Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#5db0da', + bg: 'rgb(246 248 249)', + fg: '#636b71', + panel: '#fff', + divider: 'rgb(230 233 234)', + panelHeaderDivider: '@divider', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '@accent', + }, +} diff --git a/packages/frontend/src/themes/l-sushi.json5 b/packages/frontend/src/themes/l-sushi.json5 new file mode 100644 index 0000000000..5846927d65 --- /dev/null +++ b/packages/frontend/src/themes/l-sushi.json5 @@ -0,0 +1,18 @@ +{ + id: '213273e5-7d20-d5f0-6e36-1b6a4f67115c', + + name: 'Mi Sushi Light', + author: 'syuilo', + + base: 'light', + + props: { + accent: '#e36749', + bg: '#f0eee9', + fg: '#5f5f5f', + renote: '@accent', + link: '@accent', + mention: '@accent', + hashtag: '#229e82', + }, +} diff --git a/packages/frontend/src/themes/l-u0.json5 b/packages/frontend/src/themes/l-u0.json5 new file mode 100644 index 0000000000..03b114ba39 --- /dev/null +++ b/packages/frontend/src/themes/l-u0.json5 @@ -0,0 +1,87 @@ +{ + id: 'e2c940b5-6e9a-4c03-b738-261c720c426d', + base: 'light', + name: 'Mi U0 Light', + props: { + X2: ':darken<2<@panel', + X3: 'rgba(255, 255, 255, 0.05)', + X4: 'rgba(255, 255, 255, 0.1)', + X5: 'rgba(255, 255, 255, 0.05)', + X6: 'rgba(255, 255, 255, 0.15)', + X7: 'rgba(255, 255, 255, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + bg: '#e7e7eb', + fg: '#5f5f5f', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.3)', + X12: 'rgba(255, 255, 255, 0.1)', + X13: 'rgba(255, 255, 255, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + cwBg: '#687390', + cwFg: '#393f4f', + link: '@accent', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: ':lighten<3<@bg', + popup: ':lighten<3<@panel', + accent: '#478384', + header: ':alpha<0.7<@panel', + infoBg: '#253142', + infoFg: '#fff', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.3)', + divider: '#4646461a', + hashtag: '#1f3134', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.5)', + success: '#86b300', + buttonBg: '#0000000d', + switchBg: 'rgba(255, 255, 255, 0.15)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#707b97', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + accentedBg: ':alpha<0.15<@accent', + codeNumber: '#cfff9e', + codeString: '#ffb675', + fgOnAccent: '#fff', + infoWarnBg: '#42321c', + infoWarnFg: '#ffbd3e', + navHoverFg: ':lighten<17<@fg', + codeBoolean: '#c59eff', + dateLabelFg: '@fg', + inputBorder: 'rgba(255, 255, 255, 0.1)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@indicator', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: '#0000001a', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':lighten<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + buttonGradateA: '@accent', + buttonGradateB: ':hue<20<@accent', + htmlThemeColor: '@bg', + panelHighlight: ':lighten<3<@panel', + listItemHoverBg: 'rgba(255, 255, 255, 0.03)', + scrollbarHandle: '#74747433', + inputBorderHover: 'rgba(255, 255, 255, 0.2)', + wallpaperOverlay: 'rgba(0, 0, 0, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', + scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)', + }, +} diff --git a/packages/frontend/src/themes/l-vivid.json5 b/packages/frontend/src/themes/l-vivid.json5 new file mode 100644 index 0000000000..b3c08f38ae --- /dev/null +++ b/packages/frontend/src/themes/l-vivid.json5 @@ -0,0 +1,82 @@ +{ + id: '6128c2a9-5c54-43fe-a47d-17942356470b', + + name: 'Mi Vivid Light', + author: 'syuilo', + + base: 'light', + + props: { + bg: '#fafafa', + fg: '#444', + cwBg: '#b1b9c1', + cwFg: '#fff', + link: '#ff9400', + warn: '#ecb637', + badge: '#31b1ce', + error: '#ec4137', + focus: ':alpha<0.3<@accent', + navBg: '@panel', + navFg: '@fg', + panel: '#fff', + accent: '#008cff', + header: ':alpha<0.7<@panel', + infoBg: '#e5f5ff', + infoFg: '#72818a', + renote: '@accent', + shadow: 'rgba(0, 0, 0, 0.1)', + divider: 'rgba(0, 0, 0, 0.08)', + hashtag: '#92d400', + mention: '@accent', + modalBg: 'rgba(0, 0, 0, 0.3)', + success: '#86b300', + buttonBg: 'rgba(0, 0, 0, 0.05)', + acrylicBg: ':alpha<0.5<@bg', + cwHoverBg: '#bbc4ce', + indicator: '@accent', + mentionMe: '@mention', + messageBg: '@bg', + navActive: '@accent', + infoWarnBg: '#fff0db', + infoWarnFg: '#8f6e31', + navHoverFg: ':darken<17<@fg', + dateLabelFg: '@fg', + inputBorder: 'rgba(0, 0, 0, 0.1)', + inputBorderHover: 'rgba(0, 0, 0, 0.2)', + panelBorder: '" solid 1px var(--divider)', + accentDarken: ':darken<10<@accent', + acrylicPanel: ':alpha<0.5<@panel', + navIndicator: '@accent', + accentLighten: ':lighten<10<@accent', + buttonHoverBg: 'rgba(0, 0, 0, 0.1)', + driveFolderBg: ':alpha<0.3<@accent', + fgHighlighted: ':darken<3<@fg', + fgTransparent: ':alpha<0.5<@fg', + panelHeaderBg: ':lighten<3<@panel', + panelHeaderFg: '@fg', + htmlThemeColor: '@bg', + panelHighlight: ':darken<3<@panel', + listItemHoverBg: 'rgba(0, 0, 0, 0.03)', + scrollbarHandle: 'rgba(0, 0, 0, 0.2)', + wallpaperOverlay: 'rgba(255, 255, 255, 0.5)', + fgTransparentWeak: ':alpha<0.75<@fg', + panelHeaderDivider: '@divider', + scrollbarHandleHover: 'rgba(0, 0, 0, 0.4)', + X2: ':darken<2<@panel', + X3: 'rgba(0, 0, 0, 0.05)', + X4: 'rgba(0, 0, 0, 0.1)', + X5: 'rgba(0, 0, 0, 0.05)', + X6: 'rgba(0, 0, 0, 0.25)', + X7: 'rgba(0, 0, 0, 0.05)', + X8: ':lighten<5<@accent', + X9: ':darken<5<@accent', + X10: ':alpha<0.4<@accent', + X11: 'rgba(0, 0, 0, 0.1)', + X12: 'rgba(0, 0, 0, 0.1)', + X13: 'rgba(0, 0, 0, 0.15)', + X14: ':alpha<0.5<@navBg', + X15: ':alpha<0<@panel', + X16: ':alpha<0.7<@panel', + X17: ':alpha<0.8<@bg', + }, +} diff --git a/packages/frontend/src/types/menu.ts b/packages/frontend/src/types/menu.ts new file mode 100644 index 0000000000..972f6db214 --- /dev/null +++ b/packages/frontend/src/types/menu.ts @@ -0,0 +1,21 @@ +import * as Misskey from 'misskey-js'; +import { Ref } from 'vue'; + +export type MenuAction = (ev: MouseEvent) => void; + +export type MenuDivider = null; +export type MenuNull = undefined; +export type MenuLabel = { type: 'label', text: string }; +export type MenuLink = { type: 'link', to: string, text: string, icon?: string, indicate?: boolean, avatar?: Misskey.entities.User }; +export type MenuA = { type: 'a', href: string, target?: string, download?: string, text: string, icon?: string, indicate?: boolean }; +export type MenuUser = { type: 'user', user: Misskey.entities.User, active?: boolean, indicate?: boolean, action: MenuAction }; +export type MenuSwitch = { type: 'switch', ref: Ref, text: string, disabled?: boolean }; +export type MenuButton = { type?: 'button', text: string, icon?: string, indicate?: boolean, danger?: boolean, active?: boolean, avatar?: Misskey.entities.User; action: MenuAction }; +export type MenuParent = { type: 'parent', text: string, icon?: string, children: OuterMenuItem[] }; + +export type MenuPending = { type: 'pending' }; + +type OuterMenuItem = MenuDivider | MenuNull | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; +type OuterPromiseMenuItem = Promise; +export type MenuItem = OuterMenuItem | OuterPromiseMenuItem; +export type InnerMenuItem = MenuDivider | MenuPending | MenuLabel | MenuLink | MenuA | MenuUser | MenuSwitch | MenuButton | MenuParent; diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue new file mode 100644 index 0000000000..7f3fc0e4af --- /dev/null +++ b/packages/frontend/src/ui/_common_/common.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue new file mode 100644 index 0000000000..50b28de063 --- /dev/null +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/navbar.vue b/packages/frontend/src/ui/_common_/navbar.vue new file mode 100644 index 0000000000..b82da15f13 --- /dev/null +++ b/packages/frontend/src/ui/_common_/navbar.vue @@ -0,0 +1,521 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue new file mode 100644 index 0000000000..24fc4f6f6d --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue new file mode 100644 index 0000000000..e7f88e4984 --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue new file mode 100644 index 0000000000..f4d989c387 --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue new file mode 100644 index 0000000000..114ca5be8c --- /dev/null +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/stream-indicator.vue b/packages/frontend/src/ui/_common_/stream-indicator.vue new file mode 100644 index 0000000000..a855de8ab9 --- /dev/null +++ b/packages/frontend/src/ui/_common_/stream-indicator.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/packages/frontend/src/ui/_common_/sw-inject.ts b/packages/frontend/src/ui/_common_/sw-inject.ts new file mode 100644 index 0000000000..8676d2d48d --- /dev/null +++ b/packages/frontend/src/ui/_common_/sw-inject.ts @@ -0,0 +1,35 @@ +import { inject } from 'vue'; +import { post } from '@/os'; +import { $i, login } from '@/account'; +import { defaultStore } from '@/store'; +import { getAccountFromId } from '@/scripts/get-account-from-id'; +import { mainRouter } from '@/router'; + +export function swInject() { + navigator.serviceWorker.addEventListener('message', ev => { + if (_DEV_) { + console.log('sw msg', ev.data); + } + + if (ev.data.type !== 'order') return; + + if (ev.data.loginId !== $i?.id) { + return getAccountFromId(ev.data.loginId).then(account => { + if (!account) return; + return login(account.token, ev.data.url); + }); + } + + switch (ev.data.order) { + case 'post': + return post(ev.data.options); + case 'push': + if (mainRouter.currentRoute.value.path === ev.data.url) { + return window.scroll({ top: 0, behavior: 'smooth' }); + } + return mainRouter.push(ev.data.url); + default: + return; + } + }); +} diff --git a/packages/frontend/src/ui/_common_/upload.vue b/packages/frontend/src/ui/_common_/upload.vue new file mode 100644 index 0000000000..70882bd251 --- /dev/null +++ b/packages/frontend/src/ui/_common_/upload.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/packages/frontend/src/ui/classic.header.vue b/packages/frontend/src/ui/classic.header.vue new file mode 100644 index 0000000000..46d79e6355 --- /dev/null +++ b/packages/frontend/src/ui/classic.header.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/packages/frontend/src/ui/classic.sidebar.vue b/packages/frontend/src/ui/classic.sidebar.vue new file mode 100644 index 0000000000..dac09ea703 --- /dev/null +++ b/packages/frontend/src/ui/classic.sidebar.vue @@ -0,0 +1,268 @@ + + + + + diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue new file mode 100644 index 0000000000..0e726c11ed --- /dev/null +++ b/packages/frontend/src/ui/classic.vue @@ -0,0 +1,320 @@ + + + + + diff --git a/packages/frontend/src/ui/classic.widgets.vue b/packages/frontend/src/ui/classic.widgets.vue new file mode 100644 index 0000000000..163ec982ce --- /dev/null +++ b/packages/frontend/src/ui/classic.widgets.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue new file mode 100644 index 0000000000..f3415cfd09 --- /dev/null +++ b/packages/frontend/src/ui/deck.vue @@ -0,0 +1,435 @@ + + + + + diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue new file mode 100644 index 0000000000..ba14530662 --- /dev/null +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue new file mode 100644 index 0000000000..30c0dc5e1c --- /dev/null +++ b/packages/frontend/src/ui/deck/column-core.vue @@ -0,0 +1,34 @@ + + + diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue new file mode 100644 index 0000000000..2a99b621e6 --- /dev/null +++ b/packages/frontend/src/ui/deck/column.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts new file mode 100644 index 0000000000..56db7398e5 --- /dev/null +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -0,0 +1,296 @@ +import { throttle } from 'throttle-debounce'; +import { markRaw } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import { Storage } from '../../pizzax'; +import { i18n } from '@/i18n'; +import { api } from '@/os'; +import { deepClone } from '@/scripts/clone'; + +type ColumnWidget = { + name: string; + id: string; + data: Record; +}; + +export type Column = { + id: string; + type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct'; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; + flexible?: boolean; + antennaId?: string; + listId?: string; + includingTypes?: typeof notificationTypes[number][]; + tl?: 'home' | 'local' | 'social' | 'global'; +}; + +export const deckStore = markRaw(new Storage('deck', { + profile: { + where: 'deviceAccount', + default: 'default', + }, + columns: { + where: 'deviceAccount', + default: [] as Column[], + }, + layout: { + where: 'deviceAccount', + default: [] as Column['id'][][], + }, + columnAlign: { + where: 'deviceAccount', + default: 'left' as 'left' | 'right' | 'center', + }, + alwaysShowMainColumn: { + where: 'deviceAccount', + default: true, + }, + navWindow: { + where: 'deviceAccount', + default: true, + }, +})); + +export const loadDeck = async () => { + let deck; + + try { + deck = await api('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + }); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') { + // 後方互換性のため + if (deckStore.state.profile === 'default') { + saveDeck(); + return; + } + + deckStore.set('columns', []); + deckStore.set('layout', []); + return; + } + throw err; + } + + deckStore.set('columns', deck.columns); + deckStore.set('layout', deck.layout); +}; + +// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する +export const saveDeck = throttle(1000, () => { + api('i/registry/set', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + value: { + columns: deckStore.reactiveState.columns.value, + layout: deckStore.reactiveState.layout.value, + }, + }); +}); + +export async function getProfiles(): Promise { + return await api('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }); +} + +export async function deleteProfile(key: string): Promise { + return await api('i/registry/remove', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); +} + +export function addColumn(column: Column) { + if (column.name === undefined) column.name = null; + deckStore.push('columns', column); + deckStore.push('layout', [column.id]); + saveDeck(); +} + +export function removeColumn(id: Column['id']) { + deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); + deckStore.set('layout', deckStore.state.layout + .map(ids => ids.filter(_id => _id !== id)) + .filter(ids => ids.length > 0)); + saveDeck(); +} + +export function swapColumn(a: Column['id'], b: Column['id']) { + const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1); + const aY = deckStore.state.layout[aX].findIndex(id => id === a); + const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1); + const bY = deckStore.state.layout[bX].findIndex(id => id === b); + const layout = deepClone(deckStore.state.layout); + layout[aX][aY] = b; + layout[bX][bY] = a; + deckStore.set('layout', layout); + saveDeck(); +} + +export function swapLeftColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const left = deckStore.state.layout[i - 1]; + if (left) { + layout[i - 1] = deckStore.state.layout[i]; + layout[i] = left; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapRightColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const right = deckStore.state.layout[i + 1]; + if (right) { + layout[i + 1] = deckStore.state.layout[i]; + layout[i] = right; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapUpColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const up = ids[i - 1]; + if (up) { + ids[i - 1] = id; + ids[i] = up; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapDownColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const down = ids[i + 1]; + if (down) { + ids[i + 1] = id; + ids[i] = down; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function stackLeftColumn(id: Column['id']) { + let layout = deepClone(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout[i - 1].push(id); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + saveDeck(); +} + +export function popRightColumn(id: Column['id']) { + let layout = deepClone(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const affected = layout[i]; + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout.splice(i + 1, 0, [id]); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + + const columns = deepClone(deckStore.state.columns); + for (const column of columns) { + if (affected.includes(column.id)) { + column.active = true; + } + } + deckStore.set('columns', columns); + + saveDeck(); +} + +export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(widget); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.filter(w => w.id !== widget.id); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = widgets; + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: widgetData, + } : w); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumn(id: Column['id'], column: Partial) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const currentColumn = deepClone(deckStore.state.columns[columnIndex]); + if (currentColumn == null) return; + for (const [k, v] of Object.entries(column)) { + currentColumn[k] = v; + } + columns[columnIndex] = currentColumn; + deckStore.set('columns', columns); + saveDeck(); +} diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue new file mode 100644 index 0000000000..75b018cacd --- /dev/null +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue new file mode 100644 index 0000000000..d9f3f7b4e7 --- /dev/null +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue new file mode 100644 index 0000000000..0c66172397 --- /dev/null +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -0,0 +1,68 @@ + + + diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue new file mode 100644 index 0000000000..16962956a0 --- /dev/null +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -0,0 +1,28 @@ + + + diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue new file mode 100644 index 0000000000..9d133035fe --- /dev/null +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue new file mode 100644 index 0000000000..49b29145ff --- /dev/null +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue new file mode 100644 index 0000000000..fc61d18ff6 --- /dev/null +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue new file mode 100644 index 0000000000..b91bf476e8 --- /dev/null +++ b/packages/frontend/src/ui/universal.vue @@ -0,0 +1,390 @@ + + + + + + + diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue new file mode 100644 index 0000000000..33fb492836 --- /dev/null +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue new file mode 100644 index 0000000000..ec9150d346 --- /dev/null +++ b/packages/frontend/src/ui/visitor.vue @@ -0,0 +1,19 @@ + + + diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue new file mode 100644 index 0000000000..f8db7a9d09 --- /dev/null +++ b/packages/frontend/src/ui/visitor/a.vue @@ -0,0 +1,259 @@ + + + + + + + diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue new file mode 100644 index 0000000000..275008a8f8 --- /dev/null +++ b/packages/frontend/src/ui/visitor/b.vue @@ -0,0 +1,248 @@ + + + + + + + diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue new file mode 100644 index 0000000000..7300b12a75 --- /dev/null +++ b/packages/frontend/src/ui/visitor/header.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue new file mode 100644 index 0000000000..51e47f277d --- /dev/null +++ b/packages/frontend/src/ui/visitor/kanban.vue @@ -0,0 +1,257 @@ + + + + + + diff --git a/packages/frontend/src/ui/zen.vue b/packages/frontend/src/ui/zen.vue new file mode 100644 index 0000000000..84c96a1dae --- /dev/null +++ b/packages/frontend/src/ui/zen.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/frontend/src/widgets/activity.calendar.vue b/packages/frontend/src/widgets/activity.calendar.vue new file mode 100644 index 0000000000..84f6af1c13 --- /dev/null +++ b/packages/frontend/src/widgets/activity.calendar.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/frontend/src/widgets/activity.chart.vue b/packages/frontend/src/widgets/activity.chart.vue new file mode 100644 index 0000000000..b61e419f94 --- /dev/null +++ b/packages/frontend/src/widgets/activity.chart.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/frontend/src/widgets/activity.vue b/packages/frontend/src/widgets/activity.vue new file mode 100644 index 0000000000..238a05ca09 --- /dev/null +++ b/packages/frontend/src/widgets/activity.vue @@ -0,0 +1,90 @@ + + + diff --git a/packages/frontend/src/widgets/aichan.vue b/packages/frontend/src/widgets/aichan.vue new file mode 100644 index 0000000000..828490fd9c --- /dev/null +++ b/packages/frontend/src/widgets/aichan.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/packages/frontend/src/widgets/aiscript.vue b/packages/frontend/src/widgets/aiscript.vue new file mode 100644 index 0000000000..4009edb8b8 --- /dev/null +++ b/packages/frontend/src/widgets/aiscript.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/packages/frontend/src/widgets/button.vue b/packages/frontend/src/widgets/button.vue new file mode 100644 index 0000000000..f0148d7f4e --- /dev/null +++ b/packages/frontend/src/widgets/button.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/packages/frontend/src/widgets/calendar.vue b/packages/frontend/src/widgets/calendar.vue new file mode 100644 index 0000000000..99bd36e2fc --- /dev/null +++ b/packages/frontend/src/widgets/calendar.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/packages/frontend/src/widgets/clock.vue b/packages/frontend/src/widgets/clock.vue new file mode 100644 index 0000000000..dc99b6631e --- /dev/null +++ b/packages/frontend/src/widgets/clock.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/packages/frontend/src/widgets/digital-clock.vue b/packages/frontend/src/widgets/digital-clock.vue new file mode 100644 index 0000000000..d2bfd523f3 --- /dev/null +++ b/packages/frontend/src/widgets/digital-clock.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/packages/frontend/src/widgets/federation.vue b/packages/frontend/src/widgets/federation.vue new file mode 100644 index 0000000000..3374783b0c --- /dev/null +++ b/packages/frontend/src/widgets/federation.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts new file mode 100644 index 0000000000..39826f13c8 --- /dev/null +++ b/packages/frontend/src/widgets/index.ts @@ -0,0 +1,53 @@ +import { App, defineAsyncComponent } from 'vue'; + +export default function(app: App) { + app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue'))); + app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue'))); + app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue'))); + app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue'))); + app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue'))); + app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue'))); + app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue'))); + app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue'))); + app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue'))); + app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue'))); + app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue'))); + app.component('MkwUnixClock', defineAsyncComponent(() => import('./unix-clock.vue'))); + app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue'))); + app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue'))); + app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue'))); + app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue'))); + app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue'))); + app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue'))); + app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue'))); + app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); + app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue'))); + app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); + app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue'))); +} + +export const widgets = [ + 'memo', + 'notifications', + 'timeline', + 'calendar', + 'rss', + 'rssTicker', + 'trends', + 'clock', + 'activity', + 'photos', + 'digitalClock', + 'unixClock', + 'federation', + 'instanceCloud', + 'postForm', + 'slideshow', + 'serverMetric', + 'onlineUsers', + 'jobQueue', + 'button', + 'aiscript', + 'aichan', + 'userList', +]; diff --git a/packages/frontend/src/widgets/instance-cloud.vue b/packages/frontend/src/widgets/instance-cloud.vue new file mode 100644 index 0000000000..4965616995 --- /dev/null +++ b/packages/frontend/src/widgets/instance-cloud.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/packages/frontend/src/widgets/job-queue.vue b/packages/frontend/src/widgets/job-queue.vue new file mode 100644 index 0000000000..9f19c51825 --- /dev/null +++ b/packages/frontend/src/widgets/job-queue.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/packages/frontend/src/widgets/memo.vue b/packages/frontend/src/widgets/memo.vue new file mode 100644 index 0000000000..1cc0e10bba --- /dev/null +++ b/packages/frontend/src/widgets/memo.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/packages/frontend/src/widgets/notifications.vue b/packages/frontend/src/widgets/notifications.vue new file mode 100644 index 0000000000..e697209444 --- /dev/null +++ b/packages/frontend/src/widgets/notifications.vue @@ -0,0 +1,70 @@ + + + diff --git a/packages/frontend/src/widgets/online-users.vue b/packages/frontend/src/widgets/online-users.vue new file mode 100644 index 0000000000..e9ab79b111 --- /dev/null +++ b/packages/frontend/src/widgets/online-users.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/packages/frontend/src/widgets/photos.vue b/packages/frontend/src/widgets/photos.vue new file mode 100644 index 0000000000..4ad5324053 --- /dev/null +++ b/packages/frontend/src/widgets/photos.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/packages/frontend/src/widgets/post-form.vue b/packages/frontend/src/widgets/post-form.vue new file mode 100644 index 0000000000..f1708775ba --- /dev/null +++ b/packages/frontend/src/widgets/post-form.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/frontend/src/widgets/rss-ticker.vue b/packages/frontend/src/widgets/rss-ticker.vue new file mode 100644 index 0000000000..44c21d1836 --- /dev/null +++ b/packages/frontend/src/widgets/rss-ticker.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/packages/frontend/src/widgets/rss.vue b/packages/frontend/src/widgets/rss.vue new file mode 100644 index 0000000000..c0338c8e47 --- /dev/null +++ b/packages/frontend/src/widgets/rss.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue new file mode 100644 index 0000000000..80a8e427e1 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/packages/frontend/src/widgets/server-metric/cpu.vue b/packages/frontend/src/widgets/server-metric/cpu.vue new file mode 100644 index 0000000000..e7b2226d1f --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/cpu.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/frontend/src/widgets/server-metric/disk.vue b/packages/frontend/src/widgets/server-metric/disk.vue new file mode 100644 index 0000000000..3d22d05383 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/disk.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue new file mode 100644 index 0000000000..bc3fca6fc1 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/index.vue @@ -0,0 +1,87 @@ + + + diff --git a/packages/frontend/src/widgets/server-metric/mem.vue b/packages/frontend/src/widgets/server-metric/mem.vue new file mode 100644 index 0000000000..6018eb4265 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/mem.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue new file mode 100644 index 0000000000..ab8b0fe471 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/net.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue new file mode 100644 index 0000000000..868dbc0484 --- /dev/null +++ b/packages/frontend/src/widgets/server-metric/pie.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/packages/frontend/src/widgets/slideshow.vue b/packages/frontend/src/widgets/slideshow.vue new file mode 100644 index 0000000000..e317b8ab94 --- /dev/null +++ b/packages/frontend/src/widgets/slideshow.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/packages/frontend/src/widgets/timeline.vue b/packages/frontend/src/widgets/timeline.vue new file mode 100644 index 0000000000..e48444d33f --- /dev/null +++ b/packages/frontend/src/widgets/timeline.vue @@ -0,0 +1,129 @@ + + + diff --git a/packages/frontend/src/widgets/trends.vue b/packages/frontend/src/widgets/trends.vue new file mode 100644 index 0000000000..02eec0431e --- /dev/null +++ b/packages/frontend/src/widgets/trends.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/packages/frontend/src/widgets/unix-clock.vue b/packages/frontend/src/widgets/unix-clock.vue new file mode 100644 index 0000000000..cf85ac782c --- /dev/null +++ b/packages/frontend/src/widgets/unix-clock.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/packages/frontend/src/widgets/user-list.vue b/packages/frontend/src/widgets/user-list.vue new file mode 100644 index 0000000000..9ffbf0d8e3 --- /dev/null +++ b/packages/frontend/src/widgets/user-list.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts new file mode 100644 index 0000000000..8bd56a5966 --- /dev/null +++ b/packages/frontend/src/widgets/widget.ts @@ -0,0 +1,73 @@ +import { reactive, watch } from 'vue'; +import { throttle } from 'throttle-debounce'; +import { Form, GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import { deepClone } from '@/scripts/clone'; + +export type Widget

> = { + id: string; + data: Partial

; +}; + +export type WidgetComponentProps

> = { + widget?: Widget

; +}; + +export type WidgetComponentEmits

> = { + (ev: 'updateProps', props: P); +}; + +export type WidgetComponentExpose = { + name: string; + id: string | null; + configure: () => void; +}; + +export const useWidgetPropsManager = >( + name: string, + propsDef: F, + props: Readonly>>, + emit: WidgetComponentEmits>, +): { + widgetProps: GetFormResultType; + save: () => void; + configure: () => void; +} => { + const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {}); + + const mergeProps = () => { + for (const prop of Object.keys(propsDef)) { + if (typeof widgetProps[prop] === 'undefined') { + widgetProps[prop] = propsDef[prop].default; + } + } + }; + watch(widgetProps, () => { + mergeProps(); + }, { deep: true, immediate: true }); + + const save = throttle(3000, () => { + emit('updateProps', widgetProps); + }); + + const configure = async () => { + const form = deepClone(propsDef); + for (const item of Object.keys(form)) { + form[item].default = widgetProps[item]; + } + const { canceled, result } = await os.form(name, form); + if (canceled) return; + + for (const key of Object.keys(result)) { + widgetProps[key] = result[key]; + } + + save(); + }; + + return { + widgetProps, + save, + configure, + }; +}; diff --git a/packages/frontend/tsconfig.json b/packages/frontend/tsconfig.json new file mode 100644 index 0000000000..86109f600a --- /dev/null +++ b/packages/frontend/tsconfig.json @@ -0,0 +1,47 @@ +{ + "compilerOptions": { + "allowJs": true, + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "noFallthroughCasesInSwitch": true, + "declaration": false, + "sourceMap": false, + "target": "es2017", + "module": "esnext", + "moduleResolution": "node", + "removeComments": false, + "noLib": false, + "strict": true, + "strictNullChecks": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "useDefineForClassFields": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + }, + "typeRoots": [ + "node_modules/@types", + "@types", + ], + "types": [ + "vite/client", + ], + "lib": [ + "esnext", + "dom" + ], + "jsx": "preserve" + }, + "compileOnSave": false, + "include": [ + ".eslintrc.js", + "./**/*.ts", + "./**/*.vue" + ] +} diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts new file mode 100644 index 0000000000..1acf5301b7 --- /dev/null +++ b/packages/frontend/vite.config.ts @@ -0,0 +1,70 @@ +import pluginVue from '@vitejs/plugin-vue'; +import { defineConfig } from 'vite'; + +import locales from '../../locales'; +import meta from '../../package.json'; +import pluginJson5 from './vite.json5'; + +const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; + +export default defineConfig(({ command, mode }) => { + + return { + base: '/vite/', + + plugins: [ + pluginVue({ + reactivityTransform: true, + }), + pluginJson5(), + ], + + resolve: { + extensions, + alias: { + '@/': __dirname + '/src/', + '/client-assets/': __dirname + '/assets/', + '/static-assets/': __dirname + '/../backend/assets/', + }, + }, + + define: { + _VERSION_: JSON.stringify(meta.version), + _LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])), + _ENV_: JSON.stringify(process.env.NODE_ENV), + _DEV_: process.env.NODE_ENV !== 'production', + _PERF_PREFIX_: JSON.stringify('Misskey:'), + _DATA_TRANSFER_DRIVE_FILE_: JSON.stringify('mk_drive_file'), + _DATA_TRANSFER_DRIVE_FOLDER_: JSON.stringify('mk_drive_folder'), + _DATA_TRANSFER_DECK_COLUMN_: JSON.stringify('mk_deck_column'), + __VUE_OPTIONS_API__: true, + __VUE_PROD_DEVTOOLS__: false, + }, + + build: { + target: [ + 'chrome100', + 'firefox100', + 'safari15', + 'es2017', // TODO: そのうち消す + ], + manifest: 'manifest.json', + rollupOptions: { + input: { + app: './src/init.ts', + }, + output: { + manualChunks: { + vue: ['vue'], + }, + }, + }, + cssCodeSplit: true, + outDir: __dirname + '/../../built/_vite_', + assetsDir: '.', + emptyOutDir: false, + sourcemap: process.env.NODE_ENV === 'development', + reportCompressedSize: false, + }, + }; +}); diff --git a/packages/frontend/vite.json5.ts b/packages/frontend/vite.json5.ts new file mode 100644 index 0000000000..0a37fbff44 --- /dev/null +++ b/packages/frontend/vite.json5.ts @@ -0,0 +1,38 @@ +// Original: https://github.com/rollup/plugins/tree/8835dd2aed92f408d7dc72d7cc25a9728e16face/packages/json + +import JSON5 from 'json5'; +import { Plugin } from 'rollup'; +import { createFilter, dataToEsm } from '@rollup/pluginutils'; +import { RollupJsonOptions } from '@rollup/plugin-json'; + +export default function json5(options: RollupJsonOptions = {}): Plugin { + const filter = createFilter(options.include, options.exclude); + const indent = 'indent' in options ? options.indent : '\t'; + + return { + name: 'json5', + + // eslint-disable-next-line no-shadow + transform(json, id) { + if (id.slice(-6) !== '.json5' || !filter(id)) return null; + + try { + const parsed = JSON5.parse(json); + return { + code: dataToEsm(parsed, { + preferConst: options.preferConst, + compact: options.compact, + namedExports: options.namedExports, + indent, + }), + map: { mappings: '' }, + }; + } catch (err) { + const message = 'Could not parse JSON file'; + const position = parseInt(/[\d]/.exec(err.message)[0], 10); + this.warn({ message, id, position }); + return null; + } + }, + }; +} -- cgit v1.2.3-freya