summaryrefslogtreecommitdiff
path: root/packages/client/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2021-11-12 02:02:25 +0900
commit0e4a111f81cceed275d9bec2695f6e401fb654d8 (patch)
tree40874799472fa07416f17b50a398ac33b7771905 /packages/client/src
parentupdate deps (diff)
downloadmisskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.gz
misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.tar.bz2
misskey-0e4a111f81cceed275d9bec2695f6e401fb654d8.zip
refactoring
Resolve #7779
Diffstat (limited to 'packages/client/src')
-rw-r--r--packages/client/src/account.ts211
-rw-r--r--packages/client/src/components/abuse-report-window.vue79
-rw-r--r--packages/client/src/components/analog-clock.vue150
-rw-r--r--packages/client/src/components/autocomplete.vue501
-rw-r--r--packages/client/src/components/avatars.vue30
-rw-r--r--packages/client/src/components/captcha.vue123
-rw-r--r--packages/client/src/components/channel-follow-button.vue140
-rw-r--r--packages/client/src/components/channel-preview.vue165
-rw-r--r--packages/client/src/components/chart.vue691
-rw-r--r--packages/client/src/components/code-core.vue35
-rw-r--r--packages/client/src/components/code.vue27
-rw-r--r--packages/client/src/components/cw-button.vue70
-rw-r--r--packages/client/src/components/date-separated-list.vue188
-rw-r--r--packages/client/src/components/debobigego/base.vue65
-rw-r--r--packages/client/src/components/debobigego/button.vue81
-rw-r--r--packages/client/src/components/debobigego/debobigego.scss52
-rw-r--r--packages/client/src/components/debobigego/group.vue78
-rw-r--r--packages/client/src/components/debobigego/info.vue47
-rw-r--r--packages/client/src/components/debobigego/input.vue292
-rw-r--r--packages/client/src/components/debobigego/key-value-view.vue38
-rw-r--r--packages/client/src/components/debobigego/link.vue103
-rw-r--r--packages/client/src/components/debobigego/object-view.vue102
-rw-r--r--packages/client/src/components/debobigego/pagination.vue42
-rw-r--r--packages/client/src/components/debobigego/radios.vue112
-rw-r--r--packages/client/src/components/debobigego/range.vue122
-rw-r--r--packages/client/src/components/debobigego/select.vue145
-rw-r--r--packages/client/src/components/debobigego/suspense.vue101
-rw-r--r--packages/client/src/components/debobigego/switch.vue132
-rw-r--r--packages/client/src/components/debobigego/textarea.vue161
-rw-r--r--packages/client/src/components/debobigego/tuple.vue36
-rw-r--r--packages/client/src/components/dialog.vue212
-rw-r--r--packages/client/src/components/drive-file-thumbnail.vue108
-rw-r--r--packages/client/src/components/drive-select-dialog.vue70
-rw-r--r--packages/client/src/components/drive-window.vue44
-rw-r--r--packages/client/src/components/drive.file.vue374
-rw-r--r--packages/client/src/components/drive.folder.vue326
-rw-r--r--packages/client/src/components/drive.nav-folder.vue135
-rw-r--r--packages/client/src/components/drive.vue784
-rw-r--r--packages/client/src/components/emoji-picker-dialog.vue76
-rw-r--r--packages/client/src/components/emoji-picker-window.vue197
-rw-r--r--packages/client/src/components/emoji-picker.section.vue50
-rw-r--r--packages/client/src/components/emoji-picker.vue501
-rw-r--r--packages/client/src/components/featured-photos.vue32
-rw-r--r--packages/client/src/components/file-type-icon.vue28
-rw-r--r--packages/client/src/components/follow-button.vue210
-rw-r--r--packages/client/src/components/forgot-password.vue84
-rw-r--r--packages/client/src/components/form-dialog.vue125
-rw-r--r--packages/client/src/components/form/input.vue315
-rw-r--r--packages/client/src/components/form/radio.vue122
-rw-r--r--packages/client/src/components/form/radios.vue54
-rw-r--r--packages/client/src/components/form/range.vue139
-rw-r--r--packages/client/src/components/form/section.vue31
-rw-r--r--packages/client/src/components/form/select.vue312
-rw-r--r--packages/client/src/components/form/slot.vue50
-rw-r--r--packages/client/src/components/form/switch.vue150
-rw-r--r--packages/client/src/components/form/textarea.vue252
-rw-r--r--packages/client/src/components/formula-core.vue34
-rw-r--r--packages/client/src/components/formula.vue23
-rw-r--r--packages/client/src/components/gallery-post-preview.vue126
-rw-r--r--packages/client/src/components/global/a.vue138
-rw-r--r--packages/client/src/components/global/acct.vue38
-rw-r--r--packages/client/src/components/global/ad.vue200
-rw-r--r--packages/client/src/components/global/avatar.vue163
-rw-r--r--packages/client/src/components/global/ellipsis.vue34
-rw-r--r--packages/client/src/components/global/emoji.vue125
-rw-r--r--packages/client/src/components/global/error.vue46
-rw-r--r--packages/client/src/components/global/header.vue360
-rw-r--r--packages/client/src/components/global/i18n.ts42
-rw-r--r--packages/client/src/components/global/loading.vue92
-rw-r--r--packages/client/src/components/global/misskey-flavored-markdown.vue157
-rw-r--r--packages/client/src/components/global/spacer.vue76
-rw-r--r--packages/client/src/components/global/sticky-container.vue74
-rw-r--r--packages/client/src/components/global/time.vue73
-rw-r--r--packages/client/src/components/global/url.vue142
-rw-r--r--packages/client/src/components/global/user-name.vue20
-rw-r--r--packages/client/src/components/google.vue64
-rw-r--r--packages/client/src/components/image-viewer.vue85
-rw-r--r--packages/client/src/components/img-with-blurhash.vue100
-rw-r--r--packages/client/src/components/index.ts37
-rw-r--r--packages/client/src/components/instance-stats.vue80
-rw-r--r--packages/client/src/components/instance-ticker.vue62
-rw-r--r--packages/client/src/components/launch-pad.vue152
-rw-r--r--packages/client/src/components/link.vue92
-rw-r--r--packages/client/src/components/media-banner.vue107
-rw-r--r--packages/client/src/components/media-caption.vue259
-rw-r--r--packages/client/src/components/media-image.vue155
-rw-r--r--packages/client/src/components/media-list.vue167
-rw-r--r--packages/client/src/components/media-video.vue97
-rw-r--r--packages/client/src/components/mention.vue84
-rw-r--r--packages/client/src/components/mfm.ts321
-rw-r--r--packages/client/src/components/mini-chart.vue90
-rw-r--r--packages/client/src/components/modal-page-window.vue223
-rw-r--r--packages/client/src/components/note-detailed.vue1229
-rw-r--r--packages/client/src/components/note-header.vue115
-rw-r--r--packages/client/src/components/note-preview.vue98
-rw-r--r--packages/client/src/components/note-simple.vue113
-rw-r--r--packages/client/src/components/note.sub.vue146
-rw-r--r--packages/client/src/components/note.vue1228
-rw-r--r--packages/client/src/components/notes.vue130
-rw-r--r--packages/client/src/components/notification-setting-window.vue99
-rw-r--r--packages/client/src/components/notification.vue362
-rw-r--r--packages/client/src/components/notifications.vue159
-rw-r--r--packages/client/src/components/number-diff.vue47
-rw-r--r--packages/client/src/components/page-preview.vue162
-rw-r--r--packages/client/src/components/page-window.vue167
-rw-r--r--packages/client/src/components/page/page.block.vue44
-rw-r--r--packages/client/src/components/page/page.button.vue66
-rw-r--r--packages/client/src/components/page/page.canvas.vue49
-rw-r--r--packages/client/src/components/page/page.counter.vue52
-rw-r--r--packages/client/src/components/page/page.if.vue31
-rw-r--r--packages/client/src/components/page/page.image.vue40
-rw-r--r--packages/client/src/components/page/page.note.vue47
-rw-r--r--packages/client/src/components/page/page.number-input.vue55
-rw-r--r--packages/client/src/components/page/page.post.vue109
-rw-r--r--packages/client/src/components/page/page.radio-button.vue45
-rw-r--r--packages/client/src/components/page/page.section.vue60
-rw-r--r--packages/client/src/components/page/page.switch.vue55
-rw-r--r--packages/client/src/components/page/page.text-input.vue55
-rw-r--r--packages/client/src/components/page/page.text.vue68
-rw-r--r--packages/client/src/components/page/page.textarea-input.vue47
-rw-r--r--packages/client/src/components/page/page.textarea.vue39
-rw-r--r--packages/client/src/components/page/page.vue86
-rw-r--r--packages/client/src/components/particle.vue114
-rw-r--r--packages/client/src/components/poll-editor.vue251
-rw-r--r--packages/client/src/components/poll.vue174
-rw-r--r--packages/client/src/components/post-form-attaches.vue193
-rw-r--r--packages/client/src/components/post-form-dialog.vue19
-rw-r--r--packages/client/src/components/post-form.vue980
-rw-r--r--packages/client/src/components/queue-chart.vue232
-rw-r--r--packages/client/src/components/reaction-icon.vue25
-rw-r--r--packages/client/src/components/reaction-tooltip.vue51
-rw-r--r--packages/client/src/components/reactions-viewer.details.vue91
-rw-r--r--packages/client/src/components/reactions-viewer.reaction.vue183
-rw-r--r--packages/client/src/components/reactions-viewer.vue48
-rw-r--r--packages/client/src/components/remote-caution.vue35
-rw-r--r--packages/client/src/components/sample.vue116
-rw-r--r--packages/client/src/components/signin-dialog.vue42
-rw-r--r--packages/client/src/components/signin.vue240
-rw-r--r--packages/client/src/components/signup-dialog.vue50
-rw-r--r--packages/client/src/components/signup.vue268
-rw-r--r--packages/client/src/components/sparkle.vue179
-rw-r--r--packages/client/src/components/sub-note-content.vue62
-rw-r--r--packages/client/src/components/tab.vue73
-rw-r--r--packages/client/src/components/taskmanager.api-window.vue72
-rw-r--r--packages/client/src/components/taskmanager.vue233
-rw-r--r--packages/client/src/components/timeline.vue183
-rw-r--r--packages/client/src/components/toast.vue73
-rw-r--r--packages/client/src/components/token-generate-window.vue117
-rw-r--r--packages/client/src/components/ui/button.vue262
-rw-r--r--packages/client/src/components/ui/container.vue262
-rw-r--r--packages/client/src/components/ui/context-menu.vue97
-rw-r--r--packages/client/src/components/ui/folder.vue156
-rw-r--r--packages/client/src/components/ui/hr.vue16
-rw-r--r--packages/client/src/components/ui/info.vue45
-rw-r--r--packages/client/src/components/ui/menu.vue278
-rw-r--r--packages/client/src/components/ui/modal-window.vue148
-rw-r--r--packages/client/src/components/ui/modal.vue292
-rw-r--r--packages/client/src/components/ui/pagination.vue69
-rw-r--r--packages/client/src/components/ui/popup-menu.vue42
-rw-r--r--packages/client/src/components/ui/popup.vue213
-rw-r--r--packages/client/src/components/ui/super-menu.vue148
-rw-r--r--packages/client/src/components/ui/tooltip.vue92
-rw-r--r--packages/client/src/components/ui/window.vue525
-rw-r--r--packages/client/src/components/updated.vue62
-rw-r--r--packages/client/src/components/url-preview-popup.vue60
-rw-r--r--packages/client/src/components/url-preview.vue334
-rw-r--r--packages/client/src/components/user-info.vue142
-rw-r--r--packages/client/src/components/user-list.vue91
-rw-r--r--packages/client/src/components/user-online-indicator.vue50
-rw-r--r--packages/client/src/components/user-preview.vue192
-rw-r--r--packages/client/src/components/user-select-dialog.vue199
-rw-r--r--packages/client/src/components/users-dialog.vue147
-rw-r--r--packages/client/src/components/visibility-picker.vue167
-rw-r--r--packages/client/src/components/waiting-dialog.vue92
-rw-r--r--packages/client/src/components/widgets.vue152
-rw-r--r--packages/client/src/config.ts15
-rw-r--r--packages/client/src/directives/anim.ts18
-rw-r--r--packages/client/src/directives/appear.ts22
-rw-r--r--packages/client/src/directives/click-anime.ts29
-rw-r--r--packages/client/src/directives/follow-append.ts35
-rw-r--r--packages/client/src/directives/get-size.ts34
-rw-r--r--packages/client/src/directives/hotkey.ts24
-rw-r--r--packages/client/src/directives/index.ts26
-rw-r--r--packages/client/src/directives/particle.ts18
-rw-r--r--packages/client/src/directives/size.ts68
-rw-r--r--packages/client/src/directives/sticky-container.ts15
-rw-r--r--packages/client/src/directives/tooltip.ts87
-rw-r--r--packages/client/src/directives/user-preview.ts118
-rw-r--r--packages/client/src/emojilist.json1749
-rw-r--r--packages/client/src/events.ts4
-rw-r--r--packages/client/src/filters/bytes.ts9
-rw-r--r--packages/client/src/filters/note.ts3
-rw-r--r--packages/client/src/filters/number.ts1
-rw-r--r--packages/client/src/filters/user.ts15
-rw-r--r--packages/client/src/i18n.ts13
-rw-r--r--packages/client/src/init.ts420
-rw-r--r--packages/client/src/instance.ts52
-rw-r--r--packages/client/src/menu.ts224
-rw-r--r--packages/client/src/os.ts501
-rw-r--r--packages/client/src/pages/_error_.vue94
-rw-r--r--packages/client/src/pages/_loading_.vue10
-rw-r--r--packages/client/src/pages/about-misskey.vue238
-rw-r--r--packages/client/src/pages/about.vue123
-rw-r--r--packages/client/src/pages/admin/abuses.vue170
-rw-r--r--packages/client/src/pages/admin/ads.vue138
-rw-r--r--packages/client/src/pages/admin/announcements.vue125
-rw-r--r--packages/client/src/pages/admin/bot-protection.vue138
-rw-r--r--packages/client/src/pages/admin/database.vue61
-rw-r--r--packages/client/src/pages/admin/email-settings.vue128
-rw-r--r--packages/client/src/pages/admin/emoji-edit-dialog.vue120
-rw-r--r--packages/client/src/pages/admin/emojis.vue263
-rw-r--r--packages/client/src/pages/admin/file-dialog.vue129
-rw-r--r--packages/client/src/pages/admin/files-settings.vue93
-rw-r--r--packages/client/src/pages/admin/files.vue209
-rw-r--r--packages/client/src/pages/admin/index.vue388
-rw-r--r--packages/client/src/pages/admin/instance-block.vue72
-rw-r--r--packages/client/src/pages/admin/instance.vue321
-rw-r--r--packages/client/src/pages/admin/integrations-discord.vue85
-rw-r--r--packages/client/src/pages/admin/integrations-github.vue85
-rw-r--r--packages/client/src/pages/admin/integrations-twitter.vue85
-rw-r--r--packages/client/src/pages/admin/integrations.vue74
-rw-r--r--packages/client/src/pages/admin/metrics.vue472
-rw-r--r--packages/client/src/pages/admin/object-storage.vue155
-rw-r--r--packages/client/src/pages/admin/other-settings.vue83
-rw-r--r--packages/client/src/pages/admin/overview.vue236
-rw-r--r--packages/client/src/pages/admin/proxy-account.vue87
-rw-r--r--packages/client/src/pages/admin/queue.chart.vue102
-rw-r--r--packages/client/src/pages/admin/queue.vue73
-rw-r--r--packages/client/src/pages/admin/relays.vue99
-rw-r--r--packages/client/src/pages/admin/security.vue83
-rw-r--r--packages/client/src/pages/admin/service-worker.vue85
-rw-r--r--packages/client/src/pages/admin/settings.vue151
-rw-r--r--packages/client/src/pages/admin/users.vue254
-rw-r--r--packages/client/src/pages/advanced-theme-editor.vue352
-rw-r--r--packages/client/src/pages/announcements.vue74
-rw-r--r--packages/client/src/pages/antenna-timeline.vue147
-rw-r--r--packages/client/src/pages/api-console.vue93
-rw-r--r--packages/client/src/pages/auth.form.vue60
-rw-r--r--packages/client/src/pages/auth.vue95
-rw-r--r--packages/client/src/pages/channel-editor.vue129
-rw-r--r--packages/client/src/pages/channel.vue186
-rw-r--r--packages/client/src/pages/channels.vue77
-rw-r--r--packages/client/src/pages/clip.vue154
-rw-r--r--packages/client/src/pages/drive.vue28
-rw-r--r--packages/client/src/pages/emojis.category.vue135
-rw-r--r--packages/client/src/pages/emojis.emoji.vue94
-rw-r--r--packages/client/src/pages/emojis.vue36
-rw-r--r--packages/client/src/pages/explore.vue261
-rw-r--r--packages/client/src/pages/favorites.vue60
-rw-r--r--packages/client/src/pages/featured.vue43
-rw-r--r--packages/client/src/pages/federation.vue265
-rw-r--r--packages/client/src/pages/follow-requests.vue153
-rw-r--r--packages/client/src/pages/follow.vue65
-rw-r--r--packages/client/src/pages/gallery/edit.vue168
-rw-r--r--packages/client/src/pages/gallery/index.vue152
-rw-r--r--packages/client/src/pages/gallery/post.vue282
-rw-r--r--packages/client/src/pages/instance-info.vue238
-rw-r--r--packages/client/src/pages/mentions.vue42
-rw-r--r--packages/client/src/pages/messages.vue45
-rw-r--r--packages/client/src/pages/messaging/index.vue307
-rw-r--r--packages/client/src/pages/messaging/messaging-room.form.vue348
-rw-r--r--packages/client/src/pages/messaging/messaging-room.message.vue350
-rw-r--r--packages/client/src/pages/messaging/messaging-room.vue470
-rw-r--r--packages/client/src/pages/mfm-cheat-sheet.vue365
-rw-r--r--packages/client/src/pages/miauth.vue100
-rw-r--r--packages/client/src/pages/my-antennas/create.vue51
-rw-r--r--packages/client/src/pages/my-antennas/edit.vue56
-rw-r--r--packages/client/src/pages/my-antennas/editor.vue190
-rw-r--r--packages/client/src/pages/my-antennas/index.vue71
-rw-r--r--packages/client/src/pages/my-clips/index.vue104
-rw-r--r--packages/client/src/pages/my-groups/group.vue184
-rw-r--r--packages/client/src/pages/my-groups/index.vue121
-rw-r--r--packages/client/src/pages/my-lists/index.vue88
-rw-r--r--packages/client/src/pages/my-lists/list.vue170
-rw-r--r--packages/client/src/pages/not-found.vue25
-rw-r--r--packages/client/src/pages/note.vue209
-rw-r--r--packages/client/src/pages/notifications.vue88
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.button.vue84
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue50
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.counter.vue46
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.if.vue84
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.image.vue72
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.note.vue65
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue46
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.post.vue43
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue50
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.section.vue96
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.switch.vue46
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue39
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.text.vue57
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue40
-rw-r--r--packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue57
-rw-r--r--packages/client/src/pages/page-editor/page-editor.blocks.vue78
-rw-r--r--packages/client/src/pages/page-editor/page-editor.container.vue159
-rw-r--r--packages/client/src/pages/page-editor/page-editor.script-block.vue281
-rw-r--r--packages/client/src/pages/page-editor/page-editor.vue561
-rw-r--r--packages/client/src/pages/page.vue311
-rw-r--r--packages/client/src/pages/pages.vue96
-rw-r--r--packages/client/src/pages/preview.vue32
-rw-r--r--packages/client/src/pages/reset-password.vue69
-rw-r--r--packages/client/src/pages/reversi/game.board.vue528
-rw-r--r--packages/client/src/pages/reversi/game.setting.vue390
-rw-r--r--packages/client/src/pages/reversi/game.vue76
-rw-r--r--packages/client/src/pages/reversi/index.vue279
-rw-r--r--packages/client/src/pages/room/preview.vue107
-rw-r--r--packages/client/src/pages/room/room.vue285
-rw-r--r--packages/client/src/pages/scratchpad.vue149
-rw-r--r--packages/client/src/pages/search.vue53
-rw-r--r--packages/client/src/pages/settings/2fa.vue247
-rw-r--r--packages/client/src/pages/settings/account-info.vue185
-rw-r--r--packages/client/src/pages/settings/accounts.vue149
-rw-r--r--packages/client/src/pages/settings/api.vue65
-rw-r--r--packages/client/src/pages/settings/apps.vue113
-rw-r--r--packages/client/src/pages/settings/custom-css.vue73
-rw-r--r--packages/client/src/pages/settings/deck.vue107
-rw-r--r--packages/client/src/pages/settings/delete-account.vue68
-rw-r--r--packages/client/src/pages/settings/drive.vue147
-rw-r--r--packages/client/src/pages/settings/email-address.vue70
-rw-r--r--packages/client/src/pages/settings/email-notification.vue91
-rw-r--r--packages/client/src/pages/settings/email.vue66
-rw-r--r--packages/client/src/pages/settings/experimental-features.vue52
-rw-r--r--packages/client/src/pages/settings/general.vue223
-rw-r--r--packages/client/src/pages/settings/import-export.vue112
-rw-r--r--packages/client/src/pages/settings/index.vue326
-rw-r--r--packages/client/src/pages/settings/integration.vue141
-rw-r--r--packages/client/src/pages/settings/menu.vue117
-rw-r--r--packages/client/src/pages/settings/mute-block.vue85
-rw-r--r--packages/client/src/pages/settings/notifications.vue77
-rw-r--r--packages/client/src/pages/settings/other.vue97
-rw-r--r--packages/client/src/pages/settings/plugin.install.vue147
-rw-r--r--packages/client/src/pages/settings/plugin.manage.vue115
-rw-r--r--packages/client/src/pages/settings/plugin.vue44
-rw-r--r--packages/client/src/pages/settings/privacy.vue120
-rw-r--r--packages/client/src/pages/settings/profile.vue281
-rw-r--r--packages/client/src/pages/settings/reaction.vue152
-rw-r--r--packages/client/src/pages/settings/registry.keys.vue114
-rw-r--r--packages/client/src/pages/settings/registry.value.vue149
-rw-r--r--packages/client/src/pages/settings/registry.vue90
-rw-r--r--packages/client/src/pages/settings/security.vue158
-rw-r--r--packages/client/src/pages/settings/sounds.vue155
-rw-r--r--packages/client/src/pages/settings/theme.install.vue105
-rw-r--r--packages/client/src/pages/settings/theme.manage.vue105
-rw-r--r--packages/client/src/pages/settings/theme.vue424
-rw-r--r--packages/client/src/pages/settings/update.vue95
-rw-r--r--packages/client/src/pages/settings/word-mute.vue110
-rw-r--r--packages/client/src/pages/share.vue184
-rw-r--r--packages/client/src/pages/signup-complete.vue50
-rw-r--r--packages/client/src/pages/tag.vue57
-rw-r--r--packages/client/src/pages/test.vue259
-rw-r--r--packages/client/src/pages/theme-editor.vue306
-rw-r--r--packages/client/src/pages/timeline.tutorial.vue131
-rw-r--r--packages/client/src/pages/timeline.vue225
-rw-r--r--packages/client/src/pages/user-ap-info.vue124
-rw-r--r--packages/client/src/pages/user-info.vue245
-rw-r--r--packages/client/src/pages/user-list-timeline.vue147
-rw-r--r--packages/client/src/pages/user/clips.vue50
-rw-r--r--packages/client/src/pages/user/follow-list.vue65
-rw-r--r--packages/client/src/pages/user/gallery.vue56
-rw-r--r--packages/client/src/pages/user/index.activity.vue34
-rw-r--r--packages/client/src/pages/user/index.photos.vue107
-rw-r--r--packages/client/src/pages/user/index.timeline.vue68
-rw-r--r--packages/client/src/pages/user/index.vue829
-rw-r--r--packages/client/src/pages/user/pages.vue49
-rw-r--r--packages/client/src/pages/user/reactions.vue81
-rw-r--r--packages/client/src/pages/v.vue29
-rw-r--r--packages/client/src/pages/welcome.entrance.a.vue320
-rw-r--r--packages/client/src/pages/welcome.entrance.b.vue236
-rw-r--r--packages/client/src/pages/welcome.entrance.c.vue305
-rw-r--r--packages/client/src/pages/welcome.setup.vue102
-rw-r--r--packages/client/src/pages/welcome.timeline.vue99
-rw-r--r--packages/client/src/pages/welcome.vue38
-rw-r--r--packages/client/src/pizzax.ts153
-rw-r--r--packages/client/src/plugin.ts124
-rw-r--r--packages/client/src/router.ts149
-rw-r--r--packages/client/src/scripts/2fa.ts33
-rw-r--r--packages/client/src/scripts/aiscript/api.ts44
-rw-r--r--packages/client/src/scripts/array.ts138
-rw-r--r--packages/client/src/scripts/autocomplete.ts276
-rw-r--r--packages/client/src/scripts/check-word-mute.ts26
-rw-r--r--packages/client/src/scripts/collect-page-vars.ts48
-rw-r--r--packages/client/src/scripts/contains.ts9
-rw-r--r--packages/client/src/scripts/copy-to-clipboard.ts33
-rw-r--r--packages/client/src/scripts/emojilist.ts7
-rw-r--r--packages/client/src/scripts/extract-avg-color-from-blurhash.ts9
-rw-r--r--packages/client/src/scripts/extract-mentions.ts11
-rw-r--r--packages/client/src/scripts/extract-url-from-mfm.ts19
-rw-r--r--packages/client/src/scripts/focus.ts27
-rw-r--r--packages/client/src/scripts/form.ts31
-rw-r--r--packages/client/src/scripts/format-time-string.ts50
-rw-r--r--packages/client/src/scripts/games/reversi/core.ts263
-rw-r--r--packages/client/src/scripts/games/reversi/maps.ts896
-rw-r--r--packages/client/src/scripts/games/reversi/package.json18
-rw-r--r--packages/client/src/scripts/games/reversi/tsconfig.json21
-rw-r--r--packages/client/src/scripts/gen-search-query.ts31
-rw-r--r--packages/client/src/scripts/get-account-from-id.ts7
-rw-r--r--packages/client/src/scripts/get-md5.ts10
-rw-r--r--packages/client/src/scripts/get-note-summary.ts55
-rw-r--r--packages/client/src/scripts/get-static-image-url.ts16
-rw-r--r--packages/client/src/scripts/get-user-menu.ts205
-rw-r--r--packages/client/src/scripts/hotkey.ts88
-rw-r--r--packages/client/src/scripts/hpml/block.ts109
-rw-r--r--packages/client/src/scripts/hpml/evaluator.ts234
-rw-r--r--packages/client/src/scripts/hpml/expr.ts79
-rw-r--r--packages/client/src/scripts/hpml/index.ts103
-rw-r--r--packages/client/src/scripts/hpml/lib.ts246
-rw-r--r--packages/client/src/scripts/hpml/type-checker.ts189
-rw-r--r--packages/client/src/scripts/i18n.ts29
-rw-r--r--packages/client/src/scripts/idb-proxy.ts37
-rw-r--r--packages/client/src/scripts/initialize-sw.ts68
-rw-r--r--packages/client/src/scripts/is-device-darkmode.ts3
-rw-r--r--packages/client/src/scripts/is-device-touch.ts1
-rw-r--r--packages/client/src/scripts/is-mobile.ts2
-rw-r--r--packages/client/src/scripts/keycode.ts33
-rw-r--r--packages/client/src/scripts/loading.ts11
-rw-r--r--packages/client/src/scripts/login-id.ts11
-rw-r--r--packages/client/src/scripts/lookup-user.ts37
-rw-r--r--packages/client/src/scripts/mfm-tags.ts1
-rw-r--r--packages/client/src/scripts/paging.ts246
-rw-r--r--packages/client/src/scripts/physics.ts152
-rw-r--r--packages/client/src/scripts/please-login.ts14
-rw-r--r--packages/client/src/scripts/popout.ts22
-rw-r--r--packages/client/src/scripts/reaction-picker.ts41
-rw-r--r--packages/client/src/scripts/room/furniture.ts21
-rw-r--r--packages/client/src/scripts/room/furnitures.json5407
-rw-r--r--packages/client/src/scripts/room/room.ts775
-rw-r--r--packages/client/src/scripts/scroll.ts80
-rw-r--r--packages/client/src/scripts/search.ts64
-rw-r--r--packages/client/src/scripts/select-file.ts89
-rw-r--r--packages/client/src/scripts/show-suspended-dialog.ts10
-rw-r--r--packages/client/src/scripts/sound.ts34
-rw-r--r--packages/client/src/scripts/sticky-sidebar.ts50
-rw-r--r--packages/client/src/scripts/theme-editor.ts81
-rw-r--r--packages/client/src/scripts/theme.ts127
-rw-r--r--packages/client/src/scripts/time.ts39
-rw-r--r--packages/client/src/scripts/twemoji-base.ts1
-rw-r--r--packages/client/src/scripts/unison-reload.ts15
-rw-r--r--packages/client/src/scripts/url.ts13
-rw-r--r--packages/client/src/store.ts318
-rw-r--r--packages/client/src/style.scss562
-rw-r--r--packages/client/src/sw/compose-notification.ts103
-rw-r--r--packages/client/src/sw/sw.ts123
-rw-r--r--packages/client/src/symbols.ts1
-rw-r--r--packages/client/src/theme-store.ts34
-rw-r--r--packages/client/src/themes/_dark.json590
-rw-r--r--packages/client/src/themes/_light.json590
-rw-r--r--packages/client/src/themes/d-astro.json578
-rw-r--r--packages/client/src/themes/d-black.json517
-rw-r--r--packages/client/src/themes/d-botanical.json526
-rw-r--r--packages/client/src/themes/d-dark.json526
-rw-r--r--packages/client/src/themes/d-future.json527
-rw-r--r--packages/client/src/themes/d-persimmon.json525
-rw-r--r--packages/client/src/themes/d-pumpkin.json588
-rw-r--r--packages/client/src/themes/l-apricot.json522
-rw-r--r--packages/client/src/themes/l-light.json520
-rw-r--r--packages/client/src/themes/l-rainy.json521
-rw-r--r--packages/client/src/themes/l-sushi.json518
-rw-r--r--packages/client/src/themes/l-vivid.json582
-rw-r--r--packages/client/src/ui/_common_/common.vue89
-rw-r--r--packages/client/src/ui/_common_/sidebar.vue388
-rw-r--r--packages/client/src/ui/_common_/stream-indicator.vue70
-rw-r--r--packages/client/src/ui/_common_/upload.vue134
-rw-r--r--packages/client/src/ui/chat/date-separated-list.vue163
-rw-r--r--packages/client/src/ui/chat/header-clock.vue62
-rw-r--r--packages/client/src/ui/chat/index.vue467
-rw-r--r--packages/client/src/ui/chat/note-header.vue112
-rw-r--r--packages/client/src/ui/chat/note-preview.vue112
-rw-r--r--packages/client/src/ui/chat/note.sub.vue137
-rw-r--r--packages/client/src/ui/chat/note.vue1144
-rw-r--r--packages/client/src/ui/chat/notes.vue94
-rw-r--r--packages/client/src/ui/chat/pages/channel.vue259
-rw-r--r--packages/client/src/ui/chat/pages/timeline.vue221
-rw-r--r--packages/client/src/ui/chat/post-form.vue772
-rw-r--r--packages/client/src/ui/chat/side.vue157
-rw-r--r--packages/client/src/ui/chat/store.ts17
-rw-r--r--packages/client/src/ui/chat/sub-note-content.vue62
-rw-r--r--packages/client/src/ui/chat/widgets.vue62
-rw-r--r--packages/client/src/ui/classic.header.vue210
-rw-r--r--packages/client/src/ui/classic.side.vue158
-rw-r--r--packages/client/src/ui/classic.sidebar.vue263
-rw-r--r--packages/client/src/ui/classic.vue471
-rw-r--r--packages/client/src/ui/classic.widgets.vue84
-rw-r--r--packages/client/src/ui/deck.vue229
-rw-r--r--packages/client/src/ui/deck/antenna-column.vue80
-rw-r--r--packages/client/src/ui/deck/column-core.vue52
-rw-r--r--packages/client/src/ui/deck/column.vue408
-rw-r--r--packages/client/src/ui/deck/deck-store.ts298
-rw-r--r--packages/client/src/ui/deck/direct-column.vue55
-rw-r--r--packages/client/src/ui/deck/list-column.vue80
-rw-r--r--packages/client/src/ui/deck/main-column.vue91
-rw-r--r--packages/client/src/ui/deck/mentions-column.vue52
-rw-r--r--packages/client/src/ui/deck/notifications-column.vue53
-rw-r--r--packages/client/src/ui/deck/tl-column.vue137
-rw-r--r--packages/client/src/ui/deck/widgets-column.vue71
-rw-r--r--packages/client/src/ui/desktop.vue70
-rw-r--r--packages/client/src/ui/universal.vue402
-rw-r--r--packages/client/src/ui/universal.widgets.vue79
-rw-r--r--packages/client/src/ui/visitor.vue19
-rw-r--r--packages/client/src/ui/visitor/a.vue260
-rw-r--r--packages/client/src/ui/visitor/b.vue282
-rw-r--r--packages/client/src/ui/visitor/header.vue228
-rw-r--r--packages/client/src/ui/visitor/kanban.vue256
-rw-r--r--packages/client/src/ui/zen.vue106
-rw-r--r--packages/client/src/widgets/activity.calendar.vue85
-rw-r--r--packages/client/src/widgets/activity.chart.vue107
-rw-r--r--packages/client/src/widgets/activity.vue82
-rw-r--r--packages/client/src/widgets/aichan.vue59
-rw-r--r--packages/client/src/widgets/aiscript.vue163
-rw-r--r--packages/client/src/widgets/button.vue95
-rw-r--r--packages/client/src/widgets/calendar.vue204
-rw-r--r--packages/client/src/widgets/clock.vue55
-rw-r--r--packages/client/src/widgets/define.ts75
-rw-r--r--packages/client/src/widgets/digital-clock.vue79
-rw-r--r--packages/client/src/widgets/federation.vue145
-rw-r--r--packages/client/src/widgets/index.ts45
-rw-r--r--packages/client/src/widgets/job-queue.vue183
-rw-r--r--packages/client/src/widgets/memo.vue106
-rw-r--r--packages/client/src/widgets/notifications.vue65
-rw-r--r--packages/client/src/widgets/online-users.vue67
-rw-r--r--packages/client/src/widgets/photos.vue113
-rw-r--r--packages/client/src/widgets/post-form.vue23
-rw-r--r--packages/client/src/widgets/rss.vue89
-rw-r--r--packages/client/src/widgets/server-metric/cpu-mem.vue174
-rw-r--r--packages/client/src/widgets/server-metric/cpu.vue76
-rw-r--r--packages/client/src/widgets/server-metric/disk.vue70
-rw-r--r--packages/client/src/widgets/server-metric/index.vue82
-rw-r--r--packages/client/src/widgets/server-metric/mem.vue85
-rw-r--r--packages/client/src/widgets/server-metric/net.vue148
-rw-r--r--packages/client/src/widgets/server-metric/pie.vue65
-rw-r--r--packages/client/src/widgets/slideshow.vue167
-rw-r--r--packages/client/src/widgets/timeline.vue116
-rw-r--r--packages/client/src/widgets/trends.vue111
531 files changed, 75230 insertions, 0 deletions
diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts
new file mode 100644
index 0000000000..ef7eb8f60a
--- /dev/null
+++ b/packages/client/src/account.ts
@@ -0,0 +1,211 @@
+import { del, get, set } from '@/scripts/idb-proxy';
+import { reactive } from 'vue';
+import { apiUrl } from '@/config';
+import { waiting, api, popup, popupMenu, success } from '@/os';
+import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
+import { showSuspendedDialog } from './scripts/show-suspended-dialog';
+import { i18n } from './i18n';
+
+// TODO: 他のタブと永続化されたstateを同期
+
+type Account = {
+ id: string;
+ token: string;
+ isModerator: boolean;
+ isAdmin: boolean;
+ isDeleted: boolean;
+};
+
+const data = localStorage.getItem('account');
+
+// TODO: 外部からはreadonlyに
+export const $i = data ? reactive(JSON.parse(data) as Account) : null;
+
+export async function signout() {
+ waiting();
+ localStorage.removeItem('account');
+
+ //#region Remove account
+ const accounts = await getAccounts();
+ accounts.splice(accounts.findIndex(x => x.id === $i.id), 1);
+
+ if (accounts.length > 0) await set('accounts', accounts);
+ else await del('accounts');
+ //#endregion
+
+ //#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 fetch(`${apiUrl}/sw/unregister`, {
+ method: 'POST',
+ body: JSON.stringify({
+ i: $i.token,
+ endpoint: push.endpoint,
+ }),
+ });
+ }
+ }
+
+ if (accounts.length === 0) {
+ await navigator.serviceWorker.getRegistrations()
+ .then(registrations => {
+ return Promise.all(registrations.map(registration => registration.unregister()));
+ });
+ }
+ } catch (e) {}
+ //#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 }]));
+ }
+}
+
+function fetchAccount(token): Promise<Account> {
+ return new Promise((done, fail) => {
+ // Fetch user
+ fetch(`${apiUrl}/i`, {
+ method: 'POST',
+ body: JSON.stringify({
+ i: token
+ })
+ })
+ .then(res => res.json())
+ .then(res => {
+ if (res.error) {
+ if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
+ showSuspendedDialog().then(() => {
+ signout();
+ });
+ } else {
+ signout();
+ }
+ } else {
+ res.token = token;
+ done(res);
+ }
+ })
+ .catch(fail);
+ });
+}
+
+export function updateAccount(data) {
+ for (const [key, value] of Object.entries(data)) {
+ $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));
+ await addAccount(me.id, token);
+
+ if (redirect) {
+ // 他のタブは再読み込みするだけ
+ reloadChannel.postMessage(null);
+ // このページはredirectで指定された先に移動
+ location.href = redirect;
+ return;
+ }
+
+ unisonReload();
+}
+
+export async function openAccountMenu(ev: MouseEvent) {
+ function showSigninDialog() {
+ popup(import('@/components/signin-dialog.vue'), {}, {
+ done: res => {
+ addAccount(res.id, res.i);
+ success();
+ },
+ }, 'closed');
+ }
+
+ function createAccount() {
+ popup(import('@/components/signup-dialog.vue'), {}, {
+ done: res => {
+ addAccount(res.id, res.i);
+ switchAccountWithToken(res.i);
+ },
+ }, 'closed');
+ }
+
+ async function switchAccount(account: any) {
+ 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) });
+
+ 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({
+ type: 'user',
+ user: account,
+ action: () => { switchAccount(account); }
+ });
+ });
+ }));
+
+ popupMenu([...[{
+ type: 'link',
+ text: i18n.locale.profile,
+ to: `/@${ $i.username }`,
+ avatar: $i,
+ }, null, ...accountItemPromises, {
+ icon: 'fas fa-plus',
+ text: i18n.locale.addAccount,
+ action: () => {
+ popupMenu([{
+ text: i18n.locale.existingAccount,
+ action: () => { showSigninDialog(); },
+ }, {
+ text: i18n.locale.createAccount,
+ action: () => { createAccount(); },
+ }], ev.currentTarget || ev.target);
+ },
+ }, {
+ type: 'link',
+ icon: 'fas fa-users',
+ text: i18n.locale.manageAccounts,
+ to: `/settings/accounts`,
+ }]], ev.currentTarget || ev.target, {
+ align: 'left'
+ });
+}
+
+// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない
+declare module '@vue/runtime-core' {
+ interface ComponentCustomProperties {
+ $i: typeof $i;
+ }
+}
diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue
new file mode 100644
index 0000000000..700ce30bb2
--- /dev/null
+++ b/packages/client/src/components/abuse-report-window.vue
@@ -0,0 +1,79 @@
+<template>
+<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
+ <template #header>
+ <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i>
+ <I18n :src="$ts.reportAbuseOf" tag="span">
+ <template #name>
+ <b><MkAcct :user="user"/></b>
+ </template>
+ </I18n>
+ </template>
+ <div class="dpvffvvy _monolithic_">
+ <div class="_section">
+ <MkTextarea v-model="comment">
+ <template #label>{{ $ts.details }}</template>
+ <template #caption>{{ $ts.fillAbuseReportDescription }}</template>
+ </MkTextarea>
+ </div>
+ <div class="_section">
+ <MkButton @click="send" primary full :disabled="comment.length === 0">{{ $ts.send }}</MkButton>
+ </div>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XWindow from '@/components/ui/window.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ MkTextarea,
+ MkButton,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ initialComment: {
+ type: String,
+ required: false,
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ comment: this.initialComment || '',
+ };
+ },
+
+ methods: {
+ send() {
+ os.apiWithDialog('users/report-abuse', {
+ userId: this.user.id,
+ comment: this.comment,
+ }, undefined, res => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.abuseReported
+ });
+ this.$refs.window.close();
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.dpvffvvy {
+ --root-margin: 16px;
+}
+</style>
diff --git a/packages/client/src/components/analog-clock.vue b/packages/client/src/components/analog-clock.vue
new file mode 100644
index 0000000000..bc572e5fff
--- /dev/null
+++ b/packages/client/src/components/analog-clock.vue
@@ -0,0 +1,150 @@
+<template>
+<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
+ <circle v-for="(angle, i) in graduations"
+ :cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
+ :cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
+ :r="i % 5 == 0 ? 0.125 : 0.05"
+ :fill="i % 5 == 0 ? majorGraduationColor : minorGraduationColor"
+ :key="i"
+ />
+
+ <line
+ :x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
+ :y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
+ :x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
+ :y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
+ :stroke="sHandColor"
+ :stroke-width="thickness / 2"
+ stroke-linecap="round"
+ />
+
+ <line
+ :x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))"
+ :y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))"
+ :x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
+ :y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
+ :stroke="mHandColor"
+ :stroke-width="thickness"
+ stroke-linecap="round"
+ />
+
+ <line
+ :x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))"
+ :y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))"
+ :x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
+ :y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
+ :stroke="hHandColor"
+ :stroke-width="thickness"
+ stroke-linecap="round"
+ />
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+
+export default defineComponent({
+ props: {
+ thickness: {
+ type: Number,
+ default: 0.1
+ }
+ },
+
+ data() {
+ return {
+ now: new Date(),
+ enabled: true,
+
+ graduationsPadding: 0.5,
+ handsPadding: 1,
+ handsTailLength: 0.7,
+ hHandLengthRatio: 0.75,
+ mHandLengthRatio: 1,
+ sHandLengthRatio: 1,
+
+ computedStyle: getComputedStyle(document.documentElement)
+ };
+ },
+
+ computed: {
+ dark(): boolean {
+ return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark();
+ },
+
+ majorGraduationColor(): string {
+ return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
+ },
+ minorGraduationColor(): string {
+ return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
+ },
+
+ sHandColor(): string {
+ return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
+ },
+ mHandColor(): string {
+ return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString();
+ },
+ hHandColor(): string {
+ return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString();
+ },
+
+ s(): number {
+ return this.now.getSeconds();
+ },
+ m(): number {
+ return this.now.getMinutes();
+ },
+ h(): number {
+ return this.now.getHours();
+ },
+
+ hAngle(): number {
+ return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6;
+ },
+ mAngle(): number {
+ return Math.PI * (this.m + this.s / 60) / 30;
+ },
+ sAngle(): number {
+ return Math.PI * this.s / 30;
+ },
+
+ graduations(): any {
+ const angles = [];
+ for (let i = 0; i < 60; i++) {
+ const angle = Math.PI * i / 30;
+ angles.push(angle);
+ }
+
+ return angles;
+ }
+ },
+
+ mounted() {
+ const update = () => {
+ if (this.enabled) {
+ this.tick();
+ setTimeout(update, 1000);
+ }
+ };
+ update();
+ },
+
+ beforeUnmount() {
+ this.enabled = false;
+ },
+
+ methods: {
+ tick() {
+ this.now = new Date();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mbcofsoe {
+ display: block;
+}
+</style>
diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue
new file mode 100644
index 0000000000..a7d2d507e0
--- /dev/null
+++ b/packages/client/src/components/autocomplete.vue
@@ -0,0 +1,501 @@
+<template>
+<div class="swhvrteh _popup _shadow" @contextmenu.prevent="() => {}">
+ <ol class="users" ref="suggests" v-if="type === 'user'">
+ <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user">
+ <img class="avatar" :src="user.avatarUrl"/>
+ <span class="name">
+ <MkUserName :user="user" :key="user.id"/>
+ </span>
+ <span class="username">@{{ acct(user) }}</span>
+ </li>
+ <li @click="chooseUser()" @keydown="onKeydown" tabindex="-1" class="choose">{{ $ts.selectUser }}</li>
+ </ol>
+ <ol class="hashtags" ref="suggests" v-else-if="hashtags.length > 0">
+ <li v-for="hashtag in hashtags" @click="complete(type, hashtag)" @keydown="onKeydown" tabindex="-1">
+ <span class="name">{{ hashtag }}</span>
+ </li>
+ </ol>
+ <ol class="emojis" ref="suggests" v-else-if="emojis.length > 0">
+ <li v-for="emoji in emojis" @click="complete(type, emoji.emoji)" @keydown="onKeydown" tabindex="-1">
+ <span class="emoji" v-if="emoji.isCustomEmoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
+ <span class="emoji" v-else-if="!$store.state.useOsNativeEmojis"><img :src="emoji.url" :alt="emoji.emoji"/></span>
+ <span class="emoji" v-else>{{ emoji.emoji }}</span>
+ <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
+ <span class="alias" v-if="emoji.aliasOf">({{ emoji.aliasOf }})</span>
+ </li>
+ </ol>
+ <ol class="mfmTags" ref="suggests" v-else-if="mfmTags.length > 0">
+ <li v-for="tag in mfmTags" @click="complete(type, tag)" @keydown="onKeydown" tabindex="-1">
+ <span class="tag">{{ tag }}</span>
+ </li>
+ </ol>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { emojilist } from '@/scripts/emojilist';
+import contains from '@/scripts/contains';
+import { twemojiSvgBase } from '@/scripts/twemoji-base';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import { instance } from '@/instance';
+import { MFM_TAGS } from '@/scripts/mfm-tags';
+
+type EmojiDef = {
+ emoji: string;
+ name: string;
+ aliasOf?: string;
+ url?: string;
+ isCustomEmoji?: boolean;
+};
+
+const lib = emojilist.filter(x => x.category !== 'flags');
+
+const char2file = (char: 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);
+ return codes.join('-');
+};
+
+const emjdb: EmojiDef[] = lib.map(x => ({
+ emoji: x.char,
+ name: x.name,
+ aliasOf: null,
+ url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
+}));
+
+for (const x of lib) {
+ if (x.keywords) {
+ for (const k of x.keywords) {
+ emjdb.push({
+ emoji: x.char,
+ name: k,
+ aliasOf: x.name,
+ url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
+ });
+ }
+ }
+}
+
+emjdb.sort((a, b) => a.name.length - b.name.length);
+
+//#region Construct Emoji DB
+const customEmojis = instance.emojis;
+const emojiDefinitions: EmojiDef[] = [];
+
+for (const x of customEmojis) {
+ emojiDefinitions.push({
+ name: x.name,
+ emoji: `:${x.name}:`,
+ url: x.url,
+ isCustomEmoji: true
+ });
+
+ if (x.aliases) {
+ for (const alias of x.aliases) {
+ emojiDefinitions.push({
+ name: alias,
+ aliasOf: x.name,
+ emoji: `:${x.name}:`,
+ url: x.url,
+ isCustomEmoji: true
+ });
+ }
+ }
+}
+
+emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
+
+const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
+//#endregion
+
+export default defineComponent({
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+
+ q: {
+ type: String,
+ required: false,
+ },
+
+ textarea: {
+ type: HTMLTextAreaElement,
+ required: true,
+ },
+
+ close: {
+ type: Function,
+ required: true,
+ },
+
+ x: {
+ type: Number,
+ required: true,
+ },
+
+ y: {
+ type: Number,
+ required: true,
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ getStaticImageUrl,
+ fetching: true,
+ users: [],
+ hashtags: [],
+ emojis: [],
+ items: [],
+ mfmTags: [],
+ select: -1,
+ }
+ },
+
+ updated() {
+ this.setPosition();
+ this.items = (this.$refs.suggests as Element | undefined)?.children || [];
+ },
+
+ mounted() {
+ this.setPosition();
+
+ this.textarea.addEventListener('keydown', this.onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+
+ this.$nextTick(() => {
+ this.exec();
+
+ this.$watch('q', () => {
+ this.$nextTick(() => {
+ this.exec();
+ });
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.textarea.removeEventListener('keydown', this.onKeydown);
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+
+ methods: {
+ complete(type, value) {
+ this.$emit('done', { type, value });
+ this.$emit('closed');
+
+ if (type === 'emoji') {
+ let recents = this.$store.state.recentlyUsedEmojis;
+ recents = recents.filter((e: any) => e !== value);
+ recents.unshift(value);
+ this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
+ }
+ },
+
+ setPosition() {
+ if (this.x + this.$el.offsetWidth > window.innerWidth) {
+ this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
+ } else {
+ this.$el.style.left = this.x + 'px';
+ }
+
+ if (this.y + this.$el.offsetHeight > window.innerHeight) {
+ this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
+ this.$el.style.marginTop = '0';
+ } else {
+ this.$el.style.top = this.y + 'px';
+ this.$el.style.marginTop = 'calc(1em + 8px)';
+ }
+ },
+
+ exec() {
+ this.select = -1;
+ if (this.$refs.suggests) {
+ for (const el of Array.from(this.items)) {
+ el.removeAttribute('data-selected');
+ }
+ }
+
+ if (this.type === 'user') {
+ if (this.q == null) {
+ this.users = [];
+ this.fetching = false;
+ return;
+ }
+
+ const cacheKey = `autocomplete:user:${this.q}`;
+ const cache = sessionStorage.getItem(cacheKey);
+ if (cache) {
+ const users = JSON.parse(cache);
+ this.users = users;
+ this.fetching = false;
+ } else {
+ os.api('users/search-by-username-and-host', {
+ username: this.q,
+ limit: 10,
+ detail: false
+ }).then(users => {
+ this.users = users;
+ this.fetching = false;
+
+ // キャッシュ
+ sessionStorage.setItem(cacheKey, JSON.stringify(users));
+ });
+ }
+ } else if (this.type === 'hashtag') {
+ if (this.q == null || this.q == '') {
+ this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
+ this.fetching = false;
+ } else {
+ const cacheKey = `autocomplete:hashtag:${this.q}`;
+ const cache = sessionStorage.getItem(cacheKey);
+ if (cache) {
+ const hashtags = JSON.parse(cache);
+ this.hashtags = hashtags;
+ this.fetching = false;
+ } else {
+ os.api('hashtags/search', {
+ query: this.q,
+ limit: 30
+ }).then(hashtags => {
+ this.hashtags = hashtags;
+ this.fetching = false;
+
+ // キャッシュ
+ sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
+ });
+ }
+ }
+ } else if (this.type === 'emoji') {
+ if (this.q == null || this.q == '') {
+ // 最近使った絵文字をサジェスト
+ this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null);
+ return;
+ }
+
+ const matched = [];
+ const max = 30;
+
+ emojiDb.some(x => {
+ if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+ return matched.length == max;
+ });
+ if (matched.length < max) {
+ emojiDb.some(x => {
+ if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+ return matched.length == max;
+ });
+ }
+ if (matched.length < max) {
+ emojiDb.some(x => {
+ if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
+ return matched.length == max;
+ });
+ }
+
+ this.emojis = matched;
+ } else if (this.type === 'mfmTag') {
+ if (this.q == null || this.q == '') {
+ this.mfmTags = MFM_TAGS;
+ return;
+ }
+
+ this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q));
+ }
+ },
+
+ onMousedown(e) {
+ if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
+ },
+
+ onKeydown(e) {
+ const cancel = () => {
+ e.preventDefault();
+ e.stopPropagation();
+ };
+
+ switch (e.which) {
+ case 10: // [ENTER]
+ case 13: // [ENTER]
+ if (this.select !== -1) {
+ cancel();
+ (this.items[this.select] as any).click();
+ } else {
+ this.close();
+ }
+ break;
+
+ case 27: // [ESC]
+ cancel();
+ this.close();
+ break;
+
+ case 38: // [↑]
+ if (this.select !== -1) {
+ cancel();
+ this.selectPrev();
+ } else {
+ this.close();
+ }
+ break;
+
+ case 9: // [TAB]
+ case 40: // [↓]
+ cancel();
+ this.selectNext();
+ break;
+
+ default:
+ e.stopPropagation();
+ this.textarea.focus();
+ }
+ },
+
+ selectNext() {
+ if (++this.select >= this.items.length) this.select = 0;
+ if (this.items.length === 0) this.select = -1;
+ this.applySelect();
+ },
+
+ selectPrev() {
+ if (--this.select < 0) this.select = this.items.length - 1;
+ this.applySelect();
+ },
+
+ applySelect() {
+ for (const el of Array.from(this.items)) {
+ el.removeAttribute('data-selected');
+ }
+
+ if (this.select !== -1) {
+ this.items[this.select].setAttribute('data-selected', 'true');
+ (this.items[this.select] as any).focus();
+ }
+ },
+
+ chooseUser() {
+ this.close();
+ os.selectUser().then(user => {
+ this.complete('user', user);
+ this.textarea.focus();
+ });
+ },
+
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.swhvrteh {
+ position: fixed;
+ z-index: 65535;
+ max-width: 100%;
+ margin-top: calc(1em + 8px);
+ overflow: hidden;
+ transition: top 0.1s ease, left 0.1s ease;
+
+ > ol {
+ display: block;
+ margin: 0;
+ padding: 4px 0;
+ max-height: 190px;
+ max-width: 500px;
+ overflow: auto;
+ list-style: none;
+
+ > li {
+ display: flex;
+ align-items: center;
+ padding: 4px 12px;
+ white-space: nowrap;
+ overflow: hidden;
+ font-size: 0.9em;
+ cursor: default;
+
+ &, * {
+ user-select: none;
+ }
+
+ * {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &:hover {
+ background: var(--X3);
+ }
+
+ &[data-selected='true'] {
+ background: var(--accent);
+
+ &, * {
+ color: #fff !important;
+ }
+ }
+
+ &:active {
+ background: var(--accentDarken);
+
+ &, * {
+ color: #fff !important;
+ }
+ }
+ }
+ }
+
+ > .users > li {
+
+ .avatar {
+ min-width: 28px;
+ min-height: 28px;
+ max-width: 28px;
+ max-height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 100%;
+ }
+
+ .name {
+ margin: 0 8px 0 0;
+ }
+ }
+
+ > .emojis > li {
+
+ .emoji {
+ display: inline-block;
+ margin: 0 4px 0 0;
+ width: 24px;
+
+ > img {
+ width: 24px;
+ vertical-align: bottom;
+ }
+ }
+
+ .alias {
+ margin: 0 0 0 8px;
+ }
+ }
+
+ > .mfmTags > li {
+
+ .name {
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/avatars.vue b/packages/client/src/components/avatars.vue
new file mode 100644
index 0000000000..e843d26daa
--- /dev/null
+++ b/packages/client/src/components/avatars.vue
@@ -0,0 +1,30 @@
+<template>
+<div>
+ <div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
+ <MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ userIds: {
+ required: true
+ },
+ },
+ data() {
+ return {
+ us: []
+ };
+ },
+ async created() {
+ this.us = await os.api('users/show', {
+ userIds: this.userIds
+ });
+ }
+});
+</script>
diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue
new file mode 100644
index 0000000000..baa922506e
--- /dev/null
+++ b/packages/client/src/components/captcha.vue
@@ -0,0 +1,123 @@
+<template>
+<div>
+ <span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span>
+ <div ref="captcha"></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+type Captcha = {
+ render(container: string | Node, options: {
+ readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
+ }): string;
+ remove(id: string): void;
+ execute(id: string): void;
+ reset(id: string): void;
+ getResponse(id: string): string;
+};
+
+type CaptchaProvider = 'hcaptcha' | 'recaptcha';
+
+type CaptchaContainer = {
+ readonly [_ in CaptchaProvider]?: Captcha;
+};
+
+declare global {
+ interface Window extends CaptchaContainer {
+ }
+}
+
+export default defineComponent({
+ props: {
+ provider: {
+ type: String as PropType<CaptchaProvider>,
+ required: true,
+ },
+ sitekey: {
+ type: String,
+ required: true,
+ },
+ modelValue: {
+ type: String,
+ },
+ },
+
+ data() {
+ return {
+ available: false,
+ };
+ },
+
+ computed: {
+ variable(): string {
+ switch (this.provider) {
+ case 'hcaptcha': return 'hcaptcha';
+ case 'recaptcha': return 'grecaptcha';
+ }
+ },
+ loaded(): boolean {
+ return !!window[this.variable];
+ },
+ src(): string {
+ const endpoint = ({
+ hcaptcha: 'https://hcaptcha.com/1',
+ recaptcha: 'https://www.recaptcha.net/recaptcha',
+ } as Record<CaptchaProvider, string>)[this.provider];
+
+ return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
+ },
+ captcha(): Captcha {
+ return window[this.variable] || {} as unknown as Captcha;
+ },
+ },
+
+ created() {
+ if (this.loaded) {
+ this.available = true;
+ } else {
+ (document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
+ async: true,
+ id: this.provider,
+ src: this.src,
+ })))
+ .addEventListener('load', () => this.available = true);
+ }
+ },
+
+ mounted() {
+ if (this.available) {
+ this.requestRender();
+ } else {
+ this.$watch('available', this.requestRender);
+ }
+ },
+
+ beforeUnmount() {
+ this.reset();
+ },
+
+ methods: {
+ reset() {
+ if (this.captcha?.reset) this.captcha.reset();
+ },
+ requestRender() {
+ if (this.captcha.render && this.$refs.captcha instanceof Element) {
+ this.captcha.render(this.$refs.captcha, {
+ sitekey: this.sitekey,
+ theme: this.$store.state.darkMode ? 'dark' : 'light',
+ callback: this.callback,
+ 'expired-callback': this.callback,
+ 'error-callback': this.callback,
+ });
+ } else {
+ setTimeout(this.requestRender.bind(this), 1);
+ }
+ },
+ callback(response?: string) {
+ this.$emit('update:modelValue', typeof response == 'string' ? response : null);
+ },
+ },
+});
+</script>
diff --git a/packages/client/src/components/channel-follow-button.vue b/packages/client/src/components/channel-follow-button.vue
new file mode 100644
index 0000000000..9af65325bb
--- /dev/null
+++ b/packages/client/src/components/channel-follow-button.vue
@@ -0,0 +1,140 @@
+<template>
+<button class="hdcaacmi _button"
+ :class="{ wait, active: isFollowing, full }"
+ @click="onClick"
+ :disabled="wait"
+>
+ <template v-if="!wait">
+ <template v-if="isFollowing">
+ <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
+ </template>
+ <template v-else>
+ <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
+ </template>
+ </template>
+ <template v-else>
+ <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
+ </template>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ channel: {
+ type: Object,
+ required: true
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ isFollowing: this.channel.isFollowing,
+ wait: false,
+ };
+ },
+
+ methods: {
+ async onClick() {
+ this.wait = true;
+
+ try {
+ if (this.isFollowing) {
+ await os.api('channels/unfollow', {
+ channelId: this.channel.id
+ });
+ this.isFollowing = false;
+ } else {
+ await os.api('channels/follow', {
+ channelId: this.channel.id
+ });
+ this.isFollowing = true;
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ this.wait = false;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hdcaacmi {
+ position: relative;
+ display: inline-block;
+ font-weight: bold;
+ color: var(--accent);
+ background: transparent;
+ border: solid 1px var(--accent);
+ padding: 0;
+ height: 31px;
+ font-size: 16px;
+ border-radius: 32px;
+ background: #fff;
+
+ &.full {
+ padding: 0 8px 0 12px;
+ font-size: 14px;
+ }
+
+ &:not(.full) {
+ width: 31px;
+ }
+
+ &:focus-visible {
+ &:after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ bottom: -5px;
+ left: -5px;
+ border: 2px solid var(--focus);
+ border-radius: 32px;
+ }
+ }
+
+ &:hover {
+ //background: mix($primary, #fff, 20);
+ }
+
+ &:active {
+ //background: mix($primary, #fff, 40);
+ }
+
+ &.active {
+ color: #fff;
+ background: var(--accent);
+
+ &:hover {
+ background: var(--accentLighten);
+ border-color: var(--accentLighten);
+ }
+
+ &:active {
+ background: var(--accentDarken);
+ border-color: var(--accentDarken);
+ }
+ }
+
+ &.wait {
+ cursor: wait !important;
+ opacity: 0.7;
+ }
+
+ > span {
+ margin-right: 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/channel-preview.vue b/packages/client/src/components/channel-preview.vue
new file mode 100644
index 0000000000..eb00052a78
--- /dev/null
+++ b/packages/client/src/components/channel-preview.vue
@@ -0,0 +1,165 @@
+<template>
+<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
+ <div class="banner" :style="bannerStyle">
+ <div class="fade"></div>
+ <div class="name"><i class="fas fa-satellite-dish"></i> {{ channel.name }}</div>
+ <div class="status">
+ <div>
+ <i class="fas fa-users fa-fw"></i>
+ <I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;">
+ <template #n>
+ <b>{{ channel.usersCount }}</b>
+ </template>
+ </I18n>
+ </div>
+ <div>
+ <i class="fas fa-pencil-alt fa-fw"></i>
+ <I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;">
+ <template #n>
+ <b>{{ channel.notesCount }}</b>
+ </template>
+ </I18n>
+ </div>
+ </div>
+ </div>
+ <article v-if="channel.description">
+ <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
+ </article>
+ <footer>
+ <span v-if="channel.lastNotedAt">
+ {{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
+ </span>
+ </footer>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ channel: {
+ type: Object,
+ required: true
+ },
+ },
+
+ computed: {
+ bannerStyle() {
+ if (this.channel.bannerUrl) {
+ return { backgroundImage: `url(${this.channel.bannerUrl})` };
+ } else {
+ return { backgroundColor: '#4c5e6d' };
+ }
+ }
+ },
+
+ data() {
+ return {
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.eftoefju {
+ display: block;
+ overflow: hidden;
+ width: 100%;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > .banner {
+ position: relative;
+ width: 100%;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+
+ > .name {
+ position: absolute;
+ top: 16px;
+ left: 16px;
+ padding: 12px 16px;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ font-size: 1.2em;
+ }
+
+ > .status {
+ position: absolute;
+ z-index: 1;
+ bottom: 16px;
+ right: 16px;
+ padding: 8px 12px;
+ font-size: 80%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px;
+ color: #fff;
+ }
+ }
+
+ > article {
+ padding: 16px;
+
+ > p {
+ margin: 0;
+ font-size: 1em;
+ }
+ }
+
+ > footer {
+ padding: 12px 16px;
+ border-top: solid 0.5px var(--divider);
+
+ > span {
+ opacity: 0.7;
+ font-size: 0.9em;
+ }
+ }
+
+ @media (max-width: 550px) {
+ font-size: 0.9em;
+
+ > .banner {
+ height: 80px;
+
+ > .status {
+ display: none;
+ }
+ }
+
+ > article {
+ padding: 12px;
+ }
+
+ > footer {
+ display: none;
+ }
+ }
+
+ @media (max-width: 500px) {
+ font-size: 0.8em;
+
+ > .banner {
+ height: 70px;
+ }
+
+ > article {
+ padding: 8px;
+ }
+ }
+}
+
+</style>
diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue
new file mode 100644
index 0000000000..c4d0eb85dd
--- /dev/null
+++ b/packages/client/src/components/chart.vue
@@ -0,0 +1,691 @@
+<template>
+<div class="cbbedffa">
+ <canvas ref="chartEl"></canvas>
+ <div v-if="fetching" class="fetching">
+ <MkLoading/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref, watch, PropType } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import 'chartjs-adapter-date-fns';
+import { enUS } from 'date-fns/locale';
+import zoomPlugin from 'chartjs-plugin-zoom';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+ zoomPlugin,
+);
+
+const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
+const negate = arr => arr.map(x => -x);
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560'];
+const getColor = (i) => {
+ return colors[i % colors.length];
+};
+
+export default defineComponent({
+ props: {
+ src: {
+ type: String,
+ required: true,
+ },
+ args: {
+ type: Object,
+ required: false,
+ },
+ limit: {
+ type: Number,
+ required: false,
+ default: 90
+ },
+ span: {
+ type: String as PropType<'hour' | 'day'>,
+ required: true,
+ },
+ detailed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ stacked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ aspectRatio: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ },
+
+ setup(props) {
+ const now = new Date();
+ let chartInstance: Chart = null;
+ let data: {
+ series: {
+ name: string;
+ type: 'line' | 'area';
+ color?: string;
+ borderDash?: number[];
+ hidden?: boolean;
+ data: {
+ x: number;
+ y: number;
+ }[];
+ }[];
+ } = null;
+
+ const chartEl = ref<HTMLCanvasElement>(null);
+ const fetching = ref(true);
+
+ const getDate = (ago: number) => {
+ const y = now.getFullYear();
+ const m = now.getMonth();
+ const d = now.getDate();
+ const h = now.getHours();
+
+ return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
+ };
+
+ const format = (arr) => {
+ return arr.map((v, i) => ({
+ x: getDate(i).getTime(),
+ y: v
+ }));
+ };
+
+ const render = () => {
+ if (chartInstance) {
+ chartInstance.destroy();
+ }
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
+ datasets: data.series.map((x, i) => ({
+ parsing: false,
+ label: x.name,
+ data: x.data.slice().reverse(),
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: x.color ? x.color : getColor(i),
+ borderDash: x.borderDash || [],
+ borderJoinStyle: 'round',
+ backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1),
+ fill: x.type === 'area',
+ hidden: !!x.hidden,
+ })),
+ },
+ options: {
+ aspectRatio: props.aspectRatio || 2.5,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 8,
+ },
+ },
+ scales: {
+ x: {
+ type: 'time',
+ time: {
+ stepSize: 1,
+ unit: props.span === 'day' ? 'month' : 'day',
+ },
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: props.detailed,
+ },
+ adapters: {
+ date: {
+ locale: enUS,
+ },
+ },
+ min: getDate(props.limit).getTime(),
+ },
+ y: {
+ position: 'left',
+ stacked: props.stacked,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: props.detailed,
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ display: props.detailed,
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ tooltip: {
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ },
+ zoom: {
+ pan: {
+ enabled: true,
+ },
+ zoom: {
+ wheel: {
+ enabled: true,
+ },
+ pinch: {
+ enabled: true,
+ },
+ drag: {
+ enabled: false,
+ },
+ mode: 'x',
+ },
+ limits: {
+ x: {
+ min: 'original',
+ max: 'original',
+ },
+ y: {
+ min: 'original',
+ max: 'original',
+ },
+ }
+ },
+ },
+ },
+ });
+ };
+
+ const exportData = () => {
+ // TODO
+ };
+
+ const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/federation', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Instances',
+ type: 'area',
+ data: format(total
+ ? raw.instance.total
+ : sum(raw.instance.inc, negate(raw.instance.dec))
+ ),
+ }],
+ };
+ };
+
+ const fetchNotesChart = async (type: string): Promise<typeof data> => {
+ const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(type == 'combined'
+ ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+ : sum(raw[type].inc, negate(raw[type].dec))
+ ),
+ }, {
+ name: 'Renotes',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
+ : raw[type].diffs.renote
+ ),
+ }, {
+ name: 'Replies',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
+ : raw[type].diffs.reply
+ ),
+ }, {
+ name: 'Normal',
+ type: 'area',
+ data: format(type == 'combined'
+ ? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
+ : raw[type].diffs.normal
+ ),
+ }],
+ };
+ };
+
+ const fetchNotesTotalChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.total, raw.remote.total)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.total),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.total),
+ }],
+ };
+ };
+
+ const fetchUsersChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/users', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(total
+ ? sum(raw.local.total, raw.remote.total)
+ : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
+ ),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(total
+ ? raw.local.total
+ : sum(raw.local.inc, negate(raw.local.dec))
+ ),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(total
+ ? raw.remote.total
+ : sum(raw.remote.inc, negate(raw.remote.dec))
+ ),
+ }],
+ };
+ };
+
+ const fetchActiveUsersChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.users, raw.remote.users)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.users),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.users),
+ }],
+ };
+ };
+
+ const fetchDriveChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ bytes: true,
+ series: [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(
+ sum(
+ raw.local.incSize,
+ negate(raw.local.decSize),
+ raw.remote.incSize,
+ negate(raw.remote.decSize)
+ )
+ ),
+ }, {
+ name: 'Local +',
+ type: 'area',
+ data: format(raw.local.incSize),
+ }, {
+ name: 'Local -',
+ type: 'area',
+ data: format(negate(raw.local.decSize)),
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ data: format(raw.remote.incSize),
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ data: format(negate(raw.remote.decSize)),
+ }],
+ };
+ };
+
+ const fetchDriveTotalChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ bytes: true,
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.totalSize, raw.remote.totalSize)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.totalSize),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.totalSize),
+ }],
+ };
+ };
+
+ const fetchDriveFilesChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(
+ sum(
+ raw.local.incCount,
+ negate(raw.local.decCount),
+ raw.remote.incCount,
+ negate(raw.remote.decCount)
+ )
+ ),
+ }, {
+ name: 'Local +',
+ type: 'area',
+ data: format(raw.local.incCount),
+ }, {
+ name: 'Local -',
+ type: 'area',
+ data: format(negate(raw.local.decCount)),
+ }, {
+ name: 'Remote +',
+ type: 'area',
+ data: format(raw.remote.incCount),
+ }, {
+ name: 'Remote -',
+ type: 'area',
+ data: format(negate(raw.remote.decCount)),
+ }],
+ };
+ };
+
+ const fetchDriveFilesTotalChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Combined',
+ type: 'line',
+ data: format(sum(raw.local.totalCount, raw.remote.totalCount)),
+ }, {
+ name: 'Local',
+ type: 'area',
+ data: format(raw.local.totalCount),
+ }, {
+ name: 'Remote',
+ type: 'area',
+ data: format(raw.remote.totalCount),
+ }],
+ };
+ };
+
+ const fetchInstanceRequestsChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'In',
+ type: 'area',
+ color: '#008FFB',
+ data: format(raw.requests.received)
+ }, {
+ name: 'Out (succ)',
+ type: 'area',
+ color: '#00E396',
+ data: format(raw.requests.succeeded)
+ }, {
+ name: 'Out (fail)',
+ type: 'area',
+ color: '#FEB019',
+ data: format(raw.requests.failed)
+ }]
+ };
+ };
+
+ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Users',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.users.total
+ : sum(raw.users.inc, negate(raw.users.dec))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Notes',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.notes.total
+ : sum(raw.notes.inc, negate(raw.notes.dec))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceFfChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Following',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.following.total
+ : sum(raw.following.inc, negate(raw.following.dec))
+ )
+ }, {
+ name: 'Followers',
+ type: 'area',
+ color: '#00E396',
+ data: format(total
+ ? raw.followers.total
+ : sum(raw.followers.inc, negate(raw.followers.dec))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ bytes: true,
+ series: [{
+ name: 'Drive usage',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.drive.totalUsage
+ : sum(raw.drive.incUsage, negate(raw.drive.decUsage))
+ )
+ }]
+ };
+ };
+
+ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof data> => {
+ const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
+ return {
+ series: [{
+ name: 'Drive files',
+ type: 'area',
+ color: '#008FFB',
+ data: format(total
+ ? raw.drive.totalFiles
+ : sum(raw.drive.incFiles, negate(raw.drive.decFiles))
+ )
+ }]
+ };
+ };
+
+ const fetchPerUserNotesChart = async (): Promise<typeof data> => {
+ const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span });
+ return {
+ series: [...(props.args.withoutAll ? [] : [{
+ name: 'All',
+ type: 'line',
+ borderDash: [5, 5],
+ data: format(sum(raw.inc, negate(raw.dec))),
+ }]), {
+ name: 'Renotes',
+ type: 'area',
+ data: format(raw.diffs.renote),
+ }, {
+ name: 'Replies',
+ type: 'area',
+ data: format(raw.diffs.reply),
+ }, {
+ name: 'Normal',
+ type: 'area',
+ data: format(raw.diffs.normal),
+ }],
+ };
+ };
+
+ const fetchAndRender = async () => {
+ const fetchData = () => {
+ switch (props.src) {
+ case 'federation-instances': return fetchFederationInstancesChart(false);
+ case 'federation-instances-total': return fetchFederationInstancesChart(true);
+ case 'users': return fetchUsersChart(false);
+ case 'users-total': return fetchUsersChart(true);
+ case 'active-users': return fetchActiveUsersChart();
+ case 'notes': return fetchNotesChart('combined');
+ case 'local-notes': return fetchNotesChart('local');
+ case 'remote-notes': return fetchNotesChart('remote');
+ case 'notes-total': return fetchNotesTotalChart();
+ case 'drive': return fetchDriveChart();
+ case 'drive-total': return fetchDriveTotalChart();
+ case 'drive-files': return fetchDriveFilesChart();
+ case 'drive-files-total': return fetchDriveFilesTotalChart();
+
+ case 'instance-requests': return fetchInstanceRequestsChart();
+ case 'instance-users': return fetchInstanceUsersChart(false);
+ case 'instance-users-total': return fetchInstanceUsersChart(true);
+ case 'instance-notes': return fetchInstanceNotesChart(false);
+ case 'instance-notes-total': return fetchInstanceNotesChart(true);
+ case 'instance-ff': return fetchInstanceFfChart(false);
+ case 'instance-ff-total': return fetchInstanceFfChart(true);
+ case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false);
+ case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true);
+ case 'instance-drive-files': return fetchInstanceDriveFilesChart(false);
+ case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
+
+ case 'per-user-notes': return fetchPerUserNotesChart();
+ }
+ };
+ fetching.value = true;
+ data = await fetchData();
+ fetching.value = false;
+ render();
+ };
+
+ watch(() => [props.src, props.span], fetchAndRender);
+
+ onMounted(() => {
+ fetchAndRender();
+ });
+
+ return {
+ chartEl,
+ fetching,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.cbbedffa {
+ position: relative;
+
+ > .fetching {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(12px));
+ backdrop-filter: var(--blur, blur(12px));
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: wait;
+ }
+}
+</style>
diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue
new file mode 100644
index 0000000000..9cff7b4448
--- /dev/null
+++ b/packages/client/src/components/code-core.vue
@@ -0,0 +1,35 @@
+<template>
+<code v-if="inline" v-html="html" :class="`language-${prismLang}`"></code>
+<pre v-else :class="`language-${prismLang}`"><code v-html="html" :class="`language-${prismLang}`"></code></pre>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import 'prismjs';
+import 'prismjs/themes/prism-okaidia.css';
+
+export default defineComponent({
+ props: {
+ code: {
+ type: String,
+ required: true
+ },
+ lang: {
+ type: String,
+ required: false
+ },
+ inline: {
+ type: Boolean,
+ required: false
+ }
+ },
+ computed: {
+ prismLang() {
+ return Prism.languages[this.lang] ? this.lang : 'js';
+ },
+ html() {
+ return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang);
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/code.vue b/packages/client/src/components/code.vue
new file mode 100644
index 0000000000..f5d6c5673a
--- /dev/null
+++ b/packages/client/src/components/code.vue
@@ -0,0 +1,27 @@
+<template>
+<XCode :code="code" :lang="lang" :inline="inline"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+
+export default defineComponent({
+ components: {
+ XCode: defineAsyncComponent(() => import('./code-core.vue'))
+ },
+ props: {
+ code: {
+ type: String,
+ required: true
+ },
+ lang: {
+ type: String,
+ required: false
+ },
+ inline: {
+ type: Boolean,
+ required: false
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/cw-button.vue b/packages/client/src/components/cw-button.vue
new file mode 100644
index 0000000000..4bec7b511e
--- /dev/null
+++ b/packages/client/src/components/cw-button.vue
@@ -0,0 +1,70 @@
+<template>
+<button class="nrvgflfu _button" @click="toggle">
+ <b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b>
+ <span v-if="!modelValue">{{ label }}</span>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { length } from 'stringz';
+import { concat } from '@/scripts/array';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ type: Boolean,
+ required: true
+ },
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+
+ computed: {
+ label(): string {
+ return concat([
+ this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [],
+ this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [],
+ this.note.poll != null ? [this.$ts.poll] : []
+ ] as string[][]).join(' / ');
+ }
+ },
+
+ methods: {
+ length,
+
+ toggle() {
+ this.$emit('update:modelValue', !this.modelValue);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.nrvgflfu {
+ display: inline-block;
+ padding: 4px 8px;
+ font-size: 0.7em;
+ color: var(--cwFg);
+ background: var(--cwBg);
+ border-radius: 2px;
+
+ &:hover {
+ background: var(--cwHoverBg);
+ }
+
+ > span {
+ margin-left: 4px;
+
+ &:before {
+ content: '(';
+ }
+
+ &:after {
+ content: ')';
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/date-separated-list.vue b/packages/client/src/components/date-separated-list.vue
new file mode 100644
index 0000000000..1aea9fd353
--- /dev/null
+++ b/packages/client/src/components/date-separated-list.vue
@@ -0,0 +1,188 @@
+<script lang="ts">
+import { defineComponent, h, PropType, TransitionGroup } from 'vue';
+import MkAd from '@/components/global/ad.vue';
+
+export default defineComponent({
+ props: {
+ items: {
+ type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
+ required: true,
+ },
+ direction: {
+ type: String,
+ required: false,
+ default: 'down'
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ methods: {
+ focus() {
+ this.$slots.default[0].elm.focus();
+ },
+
+ getDateText(time: string) {
+ const date = new Date(time).getDate();
+ const month = new Date(time).getMonth() + 1;
+ return this.$t('monthAndDay', {
+ month: month.toString(),
+ day: date.toString()
+ });
+ }
+ },
+
+ render() {
+ if (this.items.length === 0) return;
+
+ const renderChildren = () => this.items.map((item, i) => {
+ const el = this.$slots.default({
+ item: item
+ })[0];
+ if (el.key == null && item.id) el.key = item.id;
+
+ if (
+ i != this.items.length - 1 &&
+ new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
+ ) {
+ const separator = h('div', {
+ class: 'separator',
+ key: item.id + ':separator',
+ }, h('p', {
+ class: 'date'
+ }, [
+ h('span', [
+ h('i', {
+ class: 'fas fa-angle-up icon',
+ }),
+ this.getDateText(item.createdAt)
+ ]),
+ h('span', [
+ this.getDateText(this.items[i + 1].createdAt),
+ h('i', {
+ class: 'fas fa-angle-down icon',
+ })
+ ])
+ ]));
+
+ return [el, separator];
+ } else {
+ if (this.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ class: 'a', // advertiseの意(ブロッカー対策)
+ key: item.id + ':ad',
+ prefer: ['horizontal', 'horizontal-big'],
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ });
+
+ return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? {
+ class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
+ name: 'list',
+ tag: 'div',
+ 'data-direction': this.direction,
+ 'data-reversed': this.reversed ? 'true' : 'false',
+ } : {
+ class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
+ }, {
+ default: renderChildren
+ });
+ },
+});
+</script>
+
+<style lang="scss">
+.sqadhkmv {
+ > *:empty {
+ display: none;
+ }
+
+ > *:not(:last-child) {
+ margin-bottom: var(--margin);
+ }
+
+ > .list-move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+
+ > .list-enter-active {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+
+ &[data-direction="up"] {
+ > .list-enter-from {
+ opacity: 0;
+ transform: translateY(64px);
+ }
+ }
+
+ &[data-direction="down"] {
+ > .list-enter-from {
+ opacity: 0;
+ transform: translateY(-64px);
+ }
+ }
+
+ > .separator {
+ text-align: center;
+
+ > .date {
+ display: inline-block;
+ position: relative;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--dateLabelFg);
+
+ > span {
+ &:first-child {
+ margin-right: 8px;
+
+ > .icon {
+ margin-right: 8px;
+ }
+ }
+
+ &:last-child {
+ margin-left: 8px;
+
+ > .icon {
+ margin-left: 8px;
+ }
+ }
+ }
+ }
+ }
+
+ &.noGap {
+ > * {
+ margin: 0 !important;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+
+ &:not(:last-child) {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/base.vue b/packages/client/src/components/debobigego/base.vue
new file mode 100644
index 0000000000..f551a3478b
--- /dev/null
+++ b/packages/client/src/components/debobigego/base.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }">
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ forceWide: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rbusrurv {
+ // 他のCSSからも参照されるので消さないように
+ --debobigegoXPadding: 32px;
+ --debobigegoYPadding: 32px;
+
+ --debobigegoContentHMargin: 16px;
+
+ font-size: 95%;
+ line-height: 1.3em;
+ background: var(--bg);
+ padding: var(--debobigegoYPadding) var(--debobigegoXPadding);
+ max-width: 750px;
+ margin: 0 auto;
+
+ &:not(.wide).max-width_400px {
+ --debobigegoXPadding: 0px;
+
+ > ::v-deep(*) {
+ ._debobigegoPanel {
+ border: solid 0.5px var(--divider);
+ border-radius: 0;
+ border-left: none;
+ border-right: none;
+ }
+
+ ._debobigego_group {
+ > *:not(._debobigegoNoConcat) {
+ &:not(:last-child):not(._debobigegoNoConcatPrev) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-bottom: solid 0.5px var(--divider);
+ }
+ }
+
+ &:not(:first-child):not(._debobigegoNoConcatNext) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-top: none;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/button.vue b/packages/client/src/components/debobigego/button.vue
new file mode 100644
index 0000000000..b883e817a4
--- /dev/null
+++ b/packages/client/src/components/debobigego/button.vue
@@ -0,0 +1,81 @@
+<template>
+<div class="yzpgjkxe _debobigegoItem">
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }">
+ <slot></slot>
+ <div class="suffix">
+ <slot name="suffix"></slot>
+ <div class="icon">
+ <slot name="suffixIcon"></slot>
+ </div>
+ </div>
+ </button>
+ <div class="_debobigegoCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ primary: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ danger: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ center: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.yzpgjkxe {
+ > .main {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 14px 16px;
+ text-align: left;
+ align-items: center;
+
+ &.center {
+ display: block;
+ text-align: center;
+ }
+
+ &.primary {
+ color: var(--accent);
+ }
+
+ &.danger {
+ color: #ff2a2a;
+ }
+
+ > .suffix {
+ display: inline-flex;
+ margin-left: auto;
+ opacity: 0.7;
+
+ > .icon {
+ margin-left: 1em;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/debobigego.scss b/packages/client/src/components/debobigego/debobigego.scss
new file mode 100644
index 0000000000..833b656b66
--- /dev/null
+++ b/packages/client/src/components/debobigego/debobigego.scss
@@ -0,0 +1,52 @@
+._debobigegoPanel {
+ background: var(--panel);
+ border-radius: var(--radius);
+ transition: background 0.2s ease;
+
+ &._debobigegoClickable {
+ &:hover {
+ //background: var(--panelHighlight);
+ }
+
+ &:active {
+ background: var(--panelHighlight);
+ transition: background 0s;
+ }
+ }
+}
+
+._debobigegoLabel,
+._debobigegoCaption {
+ font-size: 80%;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+}
+
+._debobigegoLabel {
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ z-index: 2;
+ margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1);
+ padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding));
+ background: var(--X17);
+ -webkit-backdrop-filter: var(--blur, blur(10px));
+ backdrop-filter: var(--blur, blur(10px));
+}
+
+._themeChanging_ ._debobigegoLabel {
+ transition: none !important;
+ background: transparent;
+}
+
+._debobigegoCaption {
+ padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin);
+}
+
+._debobigegoItem {
+ & + ._debobigegoItem {
+ margin-top: 24px;
+ }
+}
diff --git a/packages/client/src/components/debobigego/group.vue b/packages/client/src/components/debobigego/group.vue
new file mode 100644
index 0000000000..cba2c6ec94
--- /dev/null
+++ b/packages/client/src/components/debobigego/group.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="vrtktovg _debobigegoItem _debobigegoNoConcat" v-size="{ max: [500] }" v-sticky-container>
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <div class="main _debobigego_group" ref="child">
+ <slot></slot>
+ </div>
+ <div class="_debobigegoCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref } from 'vue';
+
+export default defineComponent({
+ setup(props, context) {
+ const child = ref<HTMLElement | null>(null);
+
+ const scanChild = () => {
+ if (child.value == null) return;
+ const els = Array.from(child.value.children);
+ for (let i = 0; i < els.length; i++) {
+ const el = els[i];
+ if (el.classList.contains('_debobigegoNoConcat')) {
+ if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev');
+ if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext');
+ }
+ }
+ };
+
+ onMounted(() => {
+ scanChild();
+
+ const observer = new MutationObserver(records => {
+ scanChild();
+ });
+
+ observer.observe(child.value, {
+ childList: true,
+ subtree: false,
+ attributes: false,
+ characterData: false,
+ });
+ });
+
+ return {
+ child
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vrtktovg {
+ > .main {
+ > ::v-deep(*):not(._debobigegoNoConcat) {
+ &:not(._debobigegoNoConcatNext) {
+ margin: 0;
+ }
+
+ &:not(:last-child):not(._debobigegoNoConcatPrev) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-bottom: solid 0.5px var(--divider);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+ }
+
+ &:not(:first-child):not(._debobigegoNoConcatNext) {
+ &._debobigegoPanel, ._debobigegoPanel {
+ border-top: none;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/info.vue b/packages/client/src/components/debobigego/info.vue
new file mode 100644
index 0000000000..41afb03304
--- /dev/null
+++ b/packages/client/src/components/debobigego/info.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="fzenkabp _debobigegoItem">
+ <div class="_debobigegoPanel" :class="{ warn }">
+ <i v-if="warn" class="fas fa-exclamation-triangle"></i>
+ <i v-else class="fas fa-info-circle"></i>
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ warn: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fzenkabp {
+ > div {
+ padding: 14px 16px;
+ font-size: 90%;
+ background: var(--infoBg);
+ color: var(--infoFg);
+
+ &.warn {
+ background: var(--infoWarnBg);
+ color: var(--infoWarnFg);
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/input.vue b/packages/client/src/components/debobigego/input.vue
new file mode 100644
index 0000000000..d113f04d27
--- /dev/null
+++ b/packages/client/src/components/debobigego/input.vue
@@ -0,0 +1,292 @@
+<template>
+<FormGroup class="_debobigegoItem">
+ <template #label><slot></slot></template>
+ <div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }">
+ <div class="icon" ref="icon"><slot name="icon"></slot></div>
+ <div class="input _debobigegoPanel">
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <input ref="inputEl"
+ :type="type"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ :list="id"
+ >
+ <datalist :id="id" v-if="datalist">
+ <option v-for="data in datalist" :value="data"/>
+ </datalist>
+ <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+ </div>
+ </div>
+ <template #caption><slot name="desc"></slot></template>
+
+ <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import './debobigego.scss';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+
+export default defineComponent({
+ components: {
+ FormGroup,
+ FormButton,
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ type: {
+ type: String,
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ pattern: {
+ type: String,
+ required: false
+ },
+ placeholder: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ autocomplete: {
+ required: false
+ },
+ spellcheck: {
+ required: false
+ },
+ step: {
+ required: false
+ },
+ datalist: {
+ type: Array,
+ required: false,
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+ setup(props, context) {
+ const { modelValue, type, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const id = Math.random().toString(); // TODO: uuid?
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ if (type?.value === 'number') {
+ context.emit('update:modelValue', parseFloat(v.value));
+ } else {
+ context.emit('update:modelValue', v.value);
+ }
+ };
+
+ watch(modelValue.value, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
+
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
+ });
+ });
+
+ return {
+ id,
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ztzhwixg {
+ position: relative;
+
+ > .icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 24px;
+ text-align: center;
+ line-height: 32px;
+
+ &:not(:empty) + .input {
+ margin-left: 28px;
+ }
+ }
+
+ > .input {
+ $height: 48px;
+ position: relative;
+
+ > input {
+ display: block;
+ height: $height;
+ width: 100%;
+ margin: 0;
+ padding: 0 16px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ line-height: $height;
+ color: var(--inputText);
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+
+ &[type='file'] {
+ display: none;
+ }
+ }
+
+ > .prefix,
+ > .suffix {
+ display: block;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 16px;
+ font-size: 1em;
+ line-height: $height;
+ color: var(--inputLabel);
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: inline-block;
+ min-width: 16px;
+ max-width: 150px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .prefix {
+ left: 0;
+ padding-right: 8px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 8px;
+ }
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/key-value-view.vue b/packages/client/src/components/debobigego/key-value-view.vue
new file mode 100644
index 0000000000..0e034a2d54
--- /dev/null
+++ b/packages/client/src/components/debobigego/key-value-view.vue
@@ -0,0 +1,38 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoPanel anocepby">
+ <span class="key"><slot name="key"></slot></span>
+ <span class="value"><slot name="value"></slot></span>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.anocepby {
+ display: flex;
+ align-items: center;
+ padding: 14px var(--debobigegoContentHMargin);
+
+ > .key {
+ margin-right: 12px;
+ white-space: nowrap;
+ }
+
+ > .value {
+ margin-left: auto;
+ opacity: 0.7;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/link.vue b/packages/client/src/components/debobigego/link.vue
new file mode 100644
index 0000000000..885579eadf
--- /dev/null
+++ b/packages/client/src/components/debobigego/link.vue
@@ -0,0 +1,103 @@
+<template>
+<div class="qmfkfnzi _debobigegoItem">
+ <a class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank" v-if="external">
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i class="fas fa-external-link-alt icon"></i>
+ </span>
+ </a>
+ <MkA class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior" v-else>
+ <span class="icon"><slot name="icon"></slot></span>
+ <span class="text"><slot></slot></span>
+ <span class="right">
+ <span class="text"><slot name="suffix"></slot></span>
+ <i class="fas fa-chevron-right icon"></i>
+ </span>
+ </MkA>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ to: {
+ type: String,
+ required: true
+ },
+ active: {
+ type: Boolean,
+ required: false
+ },
+ external: {
+ type: Boolean,
+ required: false
+ },
+ behavior: {
+ type: String,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qmfkfnzi {
+ > .main {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 14px 16px 14px 14px;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--panelHighlight);
+ }
+
+ > .icon {
+ width: 32px;
+ margin-right: 2px;
+ flex-shrink: 0;
+ text-align: center;
+ opacity: 0.8;
+
+ &:empty {
+ display: none;
+
+ & + .text {
+ padding-left: 4px;
+ }
+ }
+ }
+
+ > .text {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-right: 12px;
+ }
+
+ > .right {
+ margin-left: auto;
+ opacity: 0.7;
+
+ > .text:not(:empty) {
+ margin-right: 0.75em;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/object-view.vue b/packages/client/src/components/debobigego/object-view.vue
new file mode 100644
index 0000000000..ea79daa915
--- /dev/null
+++ b/packages/client/src/components/debobigego/object-view.vue
@@ -0,0 +1,102 @@
+<template>
+<FormGroup class="_debobigegoItem">
+ <template #label><slot></slot></template>
+ <div class="drooglns _debobigegoItem" :class="{ tall }">
+ <div class="input _debobigegoPanel">
+ <textarea class="_monospace"
+ v-model="v"
+ readonly
+ :spellcheck="false"
+ ></textarea>
+ </div>
+ </div>
+ <template #caption><slot name="desc"></slot></template>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, toRefs, watch } from 'vue';
+import * as JSON5 from 'json5';
+import './debobigego.scss';
+import FormGroup from './group.vue';
+
+export default defineComponent({
+ components: {
+ FormGroup,
+ },
+ props: {
+ value: {
+ required: false
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ setup(props, context) {
+ const { value } = toRefs(props);
+ const v = ref('');
+
+ watch(() => value, newValue => {
+ v.value = JSON5.stringify(newValue.value, null, '\t');
+ }, {
+ immediate: true
+ });
+
+ return {
+ v,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.drooglns {
+ position: relative;
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 16px var(--debobigegoContentHMargin);
+ box-sizing: border-box;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ color: var(--fg);
+ tab-size: 2;
+ white-space: pre;
+ }
+ }
+
+ &.tall {
+ > .input {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/pagination.vue b/packages/client/src/components/debobigego/pagination.vue
new file mode 100644
index 0000000000..07012cb759
--- /dev/null
+++ b/packages/client/src/components/debobigego/pagination.vue
@@ -0,0 +1,42 @@
+<template>
+<FormGroup class="uljviswt _debobigegoItem">
+ <template #label><slot name="label"></slot></template>
+ <slot :items="items"></slot>
+ <div class="empty" v-if="empty" key="_empty_">
+ <slot name="empty"></slot>
+ </div>
+ <FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+import paging from '@/scripts/paging';
+
+export default defineComponent({
+ components: {
+ FormButton,
+ FormGroup,
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.uljviswt {
+}
+</style>
diff --git a/packages/client/src/components/debobigego/radios.vue b/packages/client/src/components/debobigego/radios.vue
new file mode 100644
index 0000000000..b4c5841337
--- /dev/null
+++ b/packages/client/src/components/debobigego/radios.vue
@@ -0,0 +1,112 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from '@/components/form/radio.vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ components: {
+ MkRadio
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ },
+ data() {
+ return {
+ value: this.modelValue,
+ }
+ },
+ watch: {
+ modelValue() {
+ this.value = this.modelValue;
+ },
+ value() {
+ this.$emit('update:modelValue', this.value);
+ }
+ },
+ render() {
+ const label = this.$slots.desc();
+ let options = this.$slots.default();
+
+ // なぜかFragmentになることがあるため
+ if (options.length === 1 && options[0].props == null) options = options[0].children;
+
+ return h('div', {
+ class: 'cnklmpwm _debobigegoItem'
+ }, [
+ h('div', {
+ class: '_debobigegoLabel',
+ }, label),
+ ...options.map(option => h('button', {
+ class: '_button _debobigegoPanel _debobigegoClickable',
+ key: option.key,
+ onClick: () => this.value = option.props.value,
+ }, [h('span', {
+ class: ['check', { checked: this.value === option.props.value }],
+ }), option.children]))
+ ]);
+ }
+});
+</script>
+
+<style lang="scss">
+.cnklmpwm {
+ > button {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 14px 18px;
+ text-align: left;
+
+ &:not(:first-of-type) {
+ border-top: none !important;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ &:not(:last-of-type) {
+ border-bottom: solid 0.5px var(--divider);
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ > .check {
+ display: inline-block;
+ vertical-align: bottom;
+ position: relative;
+ width: 16px;
+ height: 16px;
+ margin-right: 8px;
+ background: none;
+ border: 2px solid var(--inputBorder);
+ border-radius: 100%;
+ transition: inherit;
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ bottom: 3px;
+ left: 3px;
+ border-radius: 100%;
+ opacity: 0;
+ transform: scale(0);
+ transition: .4s cubic-bezier(.25,.8,.25,1);
+ }
+
+ &.checked {
+ border-color: var(--accent);
+
+ &:after {
+ background-color: var(--accent);
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/range.vue b/packages/client/src/components/debobigego/range.vue
new file mode 100644
index 0000000000..26fb0f37c6
--- /dev/null
+++ b/packages/client/src/components/debobigego/range.vue
@@ -0,0 +1,122 @@
+<template>
+<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }">
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <div class="_debobigegoPanel main">
+ <input
+ type="range"
+ ref="input"
+ v-model="v"
+ :disabled="disabled"
+ :min="min"
+ :max="max"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="$emit('update:value', $event.target.value)"
+ />
+ </div>
+ <div class="_debobigegoCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ min: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ max: {
+ type: Number,
+ required: false,
+ default: 100
+ },
+ step: {
+ type: Number,
+ required: false,
+ default: 1
+ },
+ },
+ data() {
+ return {
+ v: this.value,
+ focused: false
+ };
+ },
+ watch: {
+ value(v) {
+ this.v = parseFloat(v);
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ifitouly {
+ position: relative;
+
+ > .main {
+ padding: 22px 16px;
+
+ > input {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: var(--X10);
+ height: 4px;
+ width: 100%;
+ box-sizing: border-box;
+ margin: 0;
+ outline: 0;
+ border: 0;
+ border-radius: 7px;
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ box-sizing: content-box;
+ }
+
+ &::-moz-range-thumb {
+ -moz-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/select.vue b/packages/client/src/components/debobigego/select.vue
new file mode 100644
index 0000000000..7a31371afc
--- /dev/null
+++ b/packages/client/src/components/debobigego/select.vue
@@ -0,0 +1,145 @@
+<template>
+<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }">
+ <div class="_debobigegoLabel"><slot name="label"></slot></div>
+ <div class="icon" ref="icon"><slot name="icon"></slot></div>
+ <div class="input _debobigegoPanel _debobigegoClickable" @click="focus">
+ <div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
+ <select ref="input"
+ v-model="v"
+ :required="required"
+ :disabled="disabled"
+ @focus="focused = true"
+ @blur="focused = false"
+ >
+ <slot></slot>
+ </select>
+ <div class="suffix">
+ <i class="fas fa-chevron-down"></i>
+ </div>
+ </div>
+ <div class="_debobigegoCaption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ };
+ },
+ computed: {
+ v: {
+ get() {
+ return this.modelValue;
+ },
+ set(v) {
+ this.$emit('update:modelValue', v);
+ }
+ },
+ },
+ methods: {
+ focus() {
+ this.$refs.input.focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yrtfrpux {
+ position: relative;
+
+ > .icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 24px;
+ text-align: center;
+ line-height: 32px;
+
+ &:not(:empty) + .input {
+ margin-left: 28px;
+ }
+ }
+
+ > .input {
+ display: flex;
+ position: relative;
+
+ > select {
+ display: block;
+ flex: 1;
+ width: 100%;
+ padding: 0 16px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ height: 48px;
+ background: none;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ appearance: none;
+ -webkit-appearance: none;
+ color: var(--fg);
+
+ option,
+ optgroup {
+ color: var(--fg);
+ background: var(--bg);
+ }
+ }
+
+ > .prefix,
+ > .suffix {
+ display: block;
+ align-self: center;
+ justify-self: center;
+ font-size: 1em;
+ line-height: 32px;
+ color: var(--inputLabel);
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: block;
+ min-width: 16px;
+ }
+ }
+
+ > .prefix {
+ padding-right: 4px;
+ }
+
+ > .suffix {
+ padding: 0 16px 0 0;
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/suspense.vue b/packages/client/src/components/debobigego/suspense.vue
new file mode 100644
index 0000000000..b5ba63b4b5
--- /dev/null
+++ b/packages/client/src/components/debobigego/suspense.vue
@@ -0,0 +1,101 @@
+<template>
+<transition name="fade" mode="out-in">
+ <div class="_debobigegoItem" v-if="pending">
+ <div class="_debobigegoPanel">
+ <MkLoading/>
+ </div>
+ </div>
+ <div v-else-if="resolved" class="_debobigegoItem">
+ <slot :result="result"></slot>
+ </div>
+ <div class="_debobigegoItem" v-else>
+ <div class="_debobigegoPanel eiurkvay">
+ <div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div>
+ <MkButton inline @click="retry" class="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, ref, watch } from 'vue';
+import './debobigego.scss';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ p: {
+ type: Function as PropType<() => Promise<any>>,
+ required: true,
+ }
+ },
+
+ setup(props, context) {
+ const pending = ref(true);
+ const resolved = ref(false);
+ const rejected = ref(false);
+ const result = ref(null);
+
+ const process = () => {
+ if (props.p == null) {
+ return;
+ }
+ const promise = props.p();
+ pending.value = true;
+ resolved.value = false;
+ rejected.value = false;
+ promise.then((_result) => {
+ pending.value = false;
+ resolved.value = true;
+ result.value = _result;
+ });
+ promise.catch(() => {
+ pending.value = false;
+ rejected.value = true;
+ });
+ };
+
+ watch(() => props.p, () => {
+ process();
+ }, {
+ immediate: true
+ });
+
+ const retry = () => {
+ process();
+ };
+
+ return {
+ pending,
+ resolved,
+ rejected,
+ result,
+ retry,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.eiurkvay {
+ padding: 16px;
+ text-align: center;
+
+ > .retry {
+ margin-top: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/switch.vue b/packages/client/src/components/debobigego/switch.vue
new file mode 100644
index 0000000000..9a69e18302
--- /dev/null
+++ b/packages/client/src/components/debobigego/switch.vue
@@ -0,0 +1,132 @@
+<template>
+<div class="ijnpvmgr _debobigegoItem">
+ <div class="main _debobigegoPanel _debobigegoClickable"
+ :class="{ disabled, checked }"
+ :aria-checked="checked"
+ :aria-disabled="disabled"
+ @click.prevent="toggle"
+ >
+ <input
+ type="checkbox"
+ ref="input"
+ :disabled="disabled"
+ @keydown.enter="toggle"
+ >
+ <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff">
+ <span class="handle"></span>
+ </span>
+ <span class="label">
+ <span><slot></slot></span>
+ </span>
+ </div>
+ <div class="_debobigegoCaption"><slot name="desc"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import './debobigego.scss';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ type: Boolean,
+ default: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ checked(): boolean {
+ return this.modelValue;
+ }
+ },
+ methods: {
+ toggle() {
+ if (this.disabled) return;
+ this.$emit('update:modelValue', !this.checked);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ijnpvmgr {
+ > .main {
+ position: relative;
+ display: flex;
+ padding: 14px 16px;
+ cursor: pointer;
+
+ > * {
+ user-select: none;
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ margin: 0;
+ width: 34px;
+ height: 22px;
+ background: var(--switchBg);
+ outline: none;
+ border-radius: 999px;
+ transition: all 0.3s;
+ cursor: pointer;
+
+ > .handle {
+ position: absolute;
+ top: 0;
+ left: 3px;
+ bottom: 0;
+ margin: auto 0;
+ border-radius: 100%;
+ transition: background-color 0.3s, transform 0.3s;
+ width: 16px;
+ height: 16px;
+ background-color: #fff;
+ pointer-events: none;
+ }
+ }
+
+ > .label {
+ margin-left: 12px;
+ display: block;
+ transition: inherit;
+ color: var(--fg);
+
+ > span {
+ display: block;
+ line-height: 20px;
+ transition: inherit;
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.checked {
+ > .button {
+ background-color: var(--accent);
+
+ > .handle {
+ transform: translateX(12px);
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/textarea.vue b/packages/client/src/components/debobigego/textarea.vue
new file mode 100644
index 0000000000..64e8d47126
--- /dev/null
+++ b/packages/client/src/components/debobigego/textarea.vue
@@ -0,0 +1,161 @@
+<template>
+<FormGroup class="_debobigegoItem">
+ <template #label><slot></slot></template>
+ <div class="rivhosbp _debobigegoItem" :class="{ tall, pre }">
+ <div class="input _debobigegoPanel">
+ <textarea ref="input" :class="{ code, _monospace: code }"
+ v-model="v"
+ :required="required"
+ :readonly="readonly"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="!code"
+ @input="onInput"
+ @focus="focused = true"
+ @blur="focused = false"
+ ></textarea>
+ </div>
+ </div>
+ <template #caption><slot name="desc"></slot></template>
+
+ <FormButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+</FormGroup>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, toRefs, watch } from 'vue';
+import './debobigego.scss';
+import FormButton from './button.vue';
+import FormGroup from './group.vue';
+
+export default defineComponent({
+ components: {
+ FormGroup,
+ FormButton,
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ pattern: {
+ type: String,
+ required: false
+ },
+ autocomplete: {
+ type: String,
+ required: false
+ },
+ code: {
+ type: Boolean,
+ required: false
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ setup(props, context) {
+ const { modelValue } = toRefs(props);
+ const v = ref(modelValue.value);
+ const changed = ref(false);
+ const inputEl = ref(null);
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ watch(modelValue.value, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+ });
+
+ return {
+ v,
+ updated,
+ changed,
+ focus,
+ onInput,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rivhosbp {
+ position: relative;
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 16px;
+ box-sizing: border-box;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ background: transparent;
+ border: none;
+ border-radius: 0;
+ outline: none;
+ box-shadow: none;
+ color: var(--fg);
+
+ &.code {
+ tab-size: 2;
+ }
+ }
+ }
+
+ &.tall {
+ > .input {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+ }
+
+ &.pre {
+ > .input {
+ > textarea {
+ white-space: pre;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/debobigego/tuple.vue b/packages/client/src/components/debobigego/tuple.vue
new file mode 100644
index 0000000000..8a4599fd64
--- /dev/null
+++ b/packages/client/src/components/debobigego/tuple.vue
@@ -0,0 +1,36 @@
+<template>
+<div class="wthhikgt _debobigegoItem" v-size="{ max: [500] }">
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+});
+</script>
+
+<style lang="scss" scoped>
+.wthhikgt {
+ position: relative;
+ display: flex;
+
+ > ::v-deep(*) {
+ flex: 1;
+ margin: 0;
+
+ &:not(:last-child) {
+ margin-right: 16px;
+ }
+ }
+
+ &.max-width_500px {
+ display: block;
+
+ > ::v-deep(*) {
+ margin: inherit;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/dialog.vue b/packages/client/src/components/dialog.vue
new file mode 100644
index 0000000000..90086fd430
--- /dev/null
+++ b/packages/client/src/components/dialog.vue
@@ -0,0 +1,212 @@
+<template>
+<MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
+ <div class="mk-dialog">
+ <div class="icon" v-if="icon">
+ <i :class="icon"></i>
+ </div>
+ <div class="icon" v-else-if="!input && !select" :class="type">
+ <i v-if="type === 'success'" class="fas fa-check"></i>
+ <i v-else-if="type === 'error'" class="fas fa-times-circle"></i>
+ <i v-else-if="type === 'warning'" class="fas fa-exclamation-triangle"></i>
+ <i v-else-if="type === 'info'" class="fas fa-info-circle"></i>
+ <i v-else-if="type === 'question'" class="fas fa-question-circle"></i>
+ <i v-else-if="type === 'waiting'" class="fas fa-spinner fa-pulse"></i>
+ </div>
+ <header v-if="title"><Mfm :text="title"/></header>
+ <div class="body" v-if="text"><Mfm :text="text"/></div>
+ <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown"></MkInput>
+ <MkSelect v-if="select" v-model="selectedValue" autofocus>
+ <template v-if="select.items">
+ <option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
+ </template>
+ <template v-else>
+ <optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
+ <option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
+ </optgroup>
+ </template>
+ </MkSelect>
+ <div class="buttons" v-if="(showOkButton || showCancelButton) && !actions">
+ <MkButton inline @click="ok" v-if="showOkButton" primary :autofocus="!input && !select">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton>
+ <MkButton inline @click="cancel" v-if="showCancelButton || input || select">{{ $ts.cancel }}</MkButton>
+ </div>
+ <div class="buttons" v-if="actions">
+ <MkButton v-for="action in actions" inline @click="() => { action.callback(); close(); }" :primary="action.primary" :key="action.text">{{ action.text }}</MkButton>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkButton,
+ MkInput,
+ MkSelect,
+ },
+
+ props: {
+ type: {
+ type: String,
+ required: false,
+ default: 'info'
+ },
+ title: {
+ type: String,
+ required: false
+ },
+ text: {
+ type: String,
+ required: false
+ },
+ input: {
+ required: false
+ },
+ select: {
+ required: false
+ },
+ icon: {
+ required: false
+ },
+ actions: {
+ required: false
+ },
+ showOkButton: {
+ type: Boolean,
+ default: true
+ },
+ showCancelButton: {
+ type: Boolean,
+ default: false
+ },
+ cancelableByBgClick: {
+ type: Boolean,
+ default: true
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ inputValue: this.input && this.input.default ? this.input.default : null,
+ selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null,
+ };
+ },
+
+ mounted() {
+ document.addEventListener('keydown', this.onKeydown);
+ },
+
+ beforeUnmount() {
+ document.removeEventListener('keydown', this.onKeydown);
+ },
+
+ methods: {
+ done(canceled, result?) {
+ this.$emit('done', { canceled, result });
+ this.$refs.modal.close();
+ },
+
+ async ok() {
+ if (!this.showOkButton) return;
+
+ const result =
+ this.input ? this.inputValue :
+ this.select ? this.selectedValue :
+ true;
+ this.done(false, result);
+ },
+
+ cancel() {
+ this.done(true);
+ },
+
+ onBgClick() {
+ if (this.cancelableByBgClick) {
+ this.cancel();
+ }
+ },
+
+ onKeydown(e) {
+ if (e.which === 27) { // ESC
+ this.cancel();
+ }
+ },
+
+ onInputKeydown(e) {
+ if (e.which === 13) { // Enter
+ e.preventDefault();
+ e.stopPropagation();
+ this.ok();
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-dialog {
+ position: relative;
+ padding: 32px;
+ min-width: 320px;
+ max-width: 480px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .icon {
+ font-size: 32px;
+
+ &.success {
+ color: var(--success);
+ }
+
+ &.error {
+ color: var(--error);
+ }
+
+ &.warning {
+ color: var(--warn);
+ }
+
+ > * {
+ display: block;
+ margin: 0 auto;
+ }
+
+ & + header {
+ margin-top: 16px;
+ }
+ }
+
+ > header {
+ margin: 0 0 8px 0;
+ font-weight: bold;
+ font-size: 20px;
+
+ & + .body {
+ margin-top: 8px;
+ }
+ }
+
+ > .body {
+ margin: 16px 0 0 0;
+ }
+
+ > .buttons {
+ margin-top: 16px;
+
+ > * {
+ margin: 0 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue
new file mode 100644
index 0000000000..9b6a0c9a0d
--- /dev/null
+++ b/packages/client/src/components/drive-file-thumbnail.vue
@@ -0,0 +1,108 @@
+<template>
+<div class="zdjebgpv" ref="thumbnail">
+ <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/>
+ <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i>
+ <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i>
+ <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i>
+ <i v-else-if="is === 'csv'" class="fas fa-file-csv icon"></i>
+ <i v-else-if="is === 'pdf'" class="fas fa-file-pdf icon"></i>
+ <i v-else-if="is === 'textfile'" class="fas fa-file-alt icon"></i>
+ <i v-else-if="is === 'archive'" class="fas fa-file-archive icon"></i>
+ <i v-else class="fas fa-file icon"></i>
+
+ <i v-if="isThumbnailAvailable && is === 'video'" class="fas fa-film icon-sub"></i>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ components: {
+ ImgWithBlurhash
+ },
+ props: {
+ file: {
+ type: Object,
+ required: true
+ },
+ fit: {
+ type: String,
+ required: false,
+ default: 'cover'
+ },
+ },
+ data() {
+ return {
+ isContextmenuShowing: false,
+ isDragging: false,
+
+ };
+ },
+ computed: {
+ is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' {
+ if (this.file.type.startsWith('image/')) return 'image';
+ if (this.file.type.startsWith('video/')) return 'video';
+ if (this.file.type === 'audio/midi') return 'midi';
+ if (this.file.type.startsWith('audio/')) return 'audio';
+ if (this.file.type.endsWith('/csv')) return 'csv';
+ if (this.file.type.endsWith('/pdf')) return 'pdf';
+ if (this.file.type.startsWith('text/')) return 'textfile';
+ if ([
+ "application/zip",
+ "application/x-cpio",
+ "application/x-bzip",
+ "application/x-bzip2",
+ "application/java-archive",
+ "application/x-rar-compressed",
+ "application/x-tar",
+ "application/gzip",
+ "application/x-7z-compressed"
+ ].some(e => e === this.file.type)) return 'archive';
+ return 'unknown';
+ },
+ isThumbnailAvailable(): boolean {
+ return this.file.thumbnailUrl
+ ? (this.is === 'image' || this.is === 'video')
+ : false;
+ },
+ },
+ mounted() {
+ const audioTag = this.$refs.volumectrl as HTMLAudioElement;
+ if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
+ },
+ methods: {
+ volumechange() {
+ const audioTag = this.$refs.volumectrl as HTMLAudioElement;
+ ColdDeviceStorage.set('mediaVolume', audioTag.volume);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zdjebgpv {
+ position: relative;
+
+ > .icon-sub {
+ position: absolute;
+ width: 30%;
+ height: auto;
+ margin: 0;
+ right: 4%;
+ bottom: 4%;
+ }
+
+ > * {
+ margin: auto;
+ }
+
+ > .icon {
+ pointer-events: none;
+ height: 65%;
+ width: 65%;
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive-select-dialog.vue b/packages/client/src/components/drive-select-dialog.vue
new file mode 100644
index 0000000000..f9a4025452
--- /dev/null
+++ b/packages/client/src/components/drive-select-dialog.vue
@@ -0,0 +1,70 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="800"
+ :height="500"
+ :with-ok-button="true"
+ :ok-button-disabled="(type === 'file') && (selected.length === 0)"
+ @click="cancel()"
+ @close="cancel()"
+ @ok="ok()"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ {{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }}
+ <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
+ </template>
+ <XDrive :multiple="multiple" @changeSelection="onChangeSelection" @selected="ok()" :select="type"/>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XDrive from './drive.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ XDrive,
+ XModalWindow,
+ },
+
+ props: {
+ type: {
+ type: String,
+ required: false,
+ default: 'file'
+ },
+ multiple: {
+ type: Boolean,
+ default: false
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ selected: []
+ };
+ },
+
+ methods: {
+ ok() {
+ this.$emit('done', this.selected);
+ this.$refs.dialog.close();
+ },
+
+ cancel() {
+ this.$emit('done');
+ this.$refs.dialog.close();
+ },
+
+ onChangeSelection(xs) {
+ this.selected = xs;
+ },
+
+ number
+ }
+});
+</script>
diff --git a/packages/client/src/components/drive-window.vue b/packages/client/src/components/drive-window.vue
new file mode 100644
index 0000000000..43f07ebe76
--- /dev/null
+++ b/packages/client/src/components/drive-window.vue
@@ -0,0 +1,44 @@
+<template>
+<XWindow ref="window"
+ :initial-width="800"
+ :initial-height="500"
+ :can-resize="true"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ {{ $ts.drive }}
+ </template>
+ <XDrive :initial-folder="initialFolder"/>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XDrive from './drive.vue';
+import XWindow from '@/components/ui/window.vue';
+
+export default defineComponent({
+ components: {
+ XDrive,
+ XWindow,
+ },
+
+ props: {
+ initialFolder: {
+ type: Object,
+ required: false
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+
+ }
+});
+</script>
diff --git a/packages/client/src/components/drive.file.vue b/packages/client/src/components/drive.file.vue
new file mode 100644
index 0000000000..86f4ee0f8d
--- /dev/null
+++ b/packages/client/src/components/drive.file.vue
@@ -0,0 +1,374 @@
+<template>
+<div class="ncvczrfv"
+ :class="{ isSelected }"
+ @click="onClick"
+ @contextmenu.stop="onContextmenu"
+ draggable="true"
+ @dragstart="onDragstart"
+ @dragend="onDragend"
+ :title="title"
+>
+ <div class="label" v-if="$i.avatarId == file.id">
+ <img src="/client-assets/label.svg"/>
+ <p>{{ $ts.avatar }}</p>
+ </div>
+ <div class="label" v-if="$i.bannerId == file.id">
+ <img src="/client-assets/label.svg"/>
+ <p>{{ $ts.banner }}</p>
+ </div>
+ <div class="label red" v-if="file.isSensitive">
+ <img src="/client-assets/label-red.svg"/>
+ <p>{{ $ts.nsfw }}</p>
+ </div>
+
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+
+ <p class="name">
+ <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
+ <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
+ </p>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import MkDriveFileThumbnail from './drive-file-thumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkDriveFileThumbnail
+ },
+
+ props: {
+ file: {
+ type: Object,
+ required: true,
+ },
+ isSelected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ isDragging: false
+ };
+ },
+
+ computed: {
+ // TODO: parentへの参照を無くす
+ browser(): any {
+ return this.$parent;
+ },
+ title(): string {
+ return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`;
+ }
+ },
+
+ methods: {
+ getMenu() {
+ return [{
+ text: this.$ts.rename,
+ icon: 'fas fa-i-cursor',
+ action: this.rename
+ }, {
+ text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
+ icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash',
+ action: this.toggleSensitive
+ }, {
+ text: this.$ts.describeFile,
+ icon: 'fas fa-i-cursor',
+ action: this.describe
+ }, null, {
+ text: this.$ts.copyUrl,
+ icon: 'fas fa-link',
+ action: this.copyUrl
+ }, {
+ type: 'a',
+ href: this.file.url,
+ target: '_blank',
+ text: this.$ts.download,
+ icon: 'fas fa-download',
+ download: this.file.name
+ }, null, {
+ text: this.$ts.delete,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: this.deleteFile
+ }];
+ },
+
+ onClick(ev) {
+ if (this.selectMode) {
+ this.$emit('chosen', this.file);
+ } else {
+ os.popupMenu(this.getMenu(), ev.currentTarget || ev.target);
+ }
+ },
+
+ onContextmenu(e) {
+ os.contextMenu(this.getMenu(), e);
+ },
+
+ onDragstart(e) {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file));
+ this.isDragging = true;
+
+ // 親ブラウザに対して、ドラッグが開始されたフラグを立てる
+ // (=あなたの子供が、ドラッグを開始しましたよ)
+ this.browser.isDragSource = true;
+ },
+
+ onDragend(e) {
+ this.isDragging = false;
+ this.browser.isDragSource = false;
+ },
+
+ rename() {
+ os.dialog({
+ title: this.$ts.renameFile,
+ input: {
+ placeholder: this.$ts.inputNewFileName,
+ default: this.file.name,
+ allowEmpty: false
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/files/update', {
+ fileId: this.file.id,
+ name: name
+ });
+ });
+ },
+
+ describe() {
+ os.popup(import('@/components/media-caption.vue'), {
+ title: this.$ts.describeFile,
+ input: {
+ placeholder: this.$ts.inputNewDescription,
+ default: this.file.comment !== null ? this.file.comment : '',
+ },
+ image: this.file
+ }, {
+ done: result => {
+ if (!result || result.canceled) return;
+ let comment = result.result;
+ os.api('drive/files/update', {
+ fileId: this.file.id,
+ comment: comment.length == 0 ? null : comment
+ });
+ }
+ }, 'closed');
+ },
+
+ toggleSensitive() {
+ os.api('drive/files/update', {
+ fileId: this.file.id,
+ isSensitive: !this.file.isSensitive
+ });
+ },
+
+ copyUrl() {
+ copyToClipboard(this.file.url);
+ os.success();
+ },
+
+ setAsAvatar() {
+ os.updateAvatar(this.file);
+ },
+
+ setAsBanner() {
+ os.updateBanner(this.file);
+ },
+
+ addApp() {
+ alert('not implemented yet');
+ },
+
+ async deleteFile() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('driveFileDeleteConfirm', { name: this.file.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.api('drive/files/delete', {
+ fileId: this.file.id
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ncvczrfv {
+ position: relative;
+ padding: 8px 0 0 0;
+ min-height: 180px;
+ border-radius: 4px;
+
+ &, * {
+ cursor: pointer;
+ }
+
+ > * {
+ pointer-events: none;
+ }
+
+ &:hover {
+ background: rgba(#000, 0.05);
+
+ > .label {
+ &:before,
+ &:after {
+ background: #0b65a5;
+ }
+
+ &.red {
+ &:before,
+ &:after {
+ background: #c12113;
+ }
+ }
+ }
+ }
+
+ &:active {
+ background: rgba(#000, 0.1);
+
+ > .label {
+ &:before,
+ &:after {
+ background: #0b588c;
+ }
+
+ &.red {
+ &:before,
+ &:after {
+ background: #ce2212;
+ }
+ }
+ }
+ }
+
+ &.isSelected {
+ background: var(--accent);
+
+ &:hover {
+ background: var(--accentLighten);
+ }
+
+ &:active {
+ background: var(--accentDarken);
+ }
+
+ > .label {
+ &:before,
+ &:after {
+ display: none;
+ }
+ }
+
+ > .name {
+ color: #fff;
+ }
+
+ > .thumbnail {
+ color: #fff;
+ }
+ }
+
+ > .label {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+
+ &:before,
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ z-index: 1;
+ background: #0c7ac9;
+ }
+
+ &:before {
+ top: 0;
+ left: 57px;
+ width: 28px;
+ height: 8px;
+ }
+
+ &:after {
+ top: 57px;
+ left: 0;
+ width: 8px;
+ height: 28px;
+ }
+
+ &.red {
+ &:before,
+ &:after {
+ background: #c12113;
+ }
+ }
+
+ > img {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ left: 0;
+ }
+
+ > p {
+ position: absolute;
+ z-index: 3;
+ top: 19px;
+ left: -28px;
+ width: 120px;
+ margin: 0;
+ text-align: center;
+ line-height: 28px;
+ color: #fff;
+ transform: rotate(-45deg);
+ }
+ }
+
+ > .thumbnail {
+ width: 110px;
+ height: 110px;
+ margin: auto;
+ }
+
+ > .name {
+ display: block;
+ margin: 4px 0 0 0;
+ font-size: 0.8em;
+ text-align: center;
+ word-break: break-all;
+ color: var(--fg);
+ overflow: hidden;
+
+ > .ext {
+ opacity: 0.5;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive.folder.vue b/packages/client/src/components/drive.folder.vue
new file mode 100644
index 0000000000..91e27cc8a1
--- /dev/null
+++ b/packages/client/src/components/drive.folder.vue
@@ -0,0 +1,326 @@
+<template>
+<div class="rghtznwe"
+ :class="{ draghover }"
+ @click="onClick"
+ @contextmenu.stop="onContextmenu"
+ @mouseover="onMouseover"
+ @mouseout="onMouseout"
+ @dragover.prevent.stop="onDragover"
+ @dragenter.prevent="onDragenter"
+ @dragleave="onDragleave"
+ @drop.prevent.stop="onDrop"
+ draggable="true"
+ @dragstart="onDragstart"
+ @dragend="onDragend"
+ :title="title"
+>
+ <p class="name">
+ <template v-if="hover"><i class="fas fa-folder-open fa-fw"></i></template>
+ <template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template>
+ {{ folder.name }}
+ </p>
+ <p class="upload" v-if="$store.state.uploadFolder == folder.id">
+ {{ $ts.uploadFolder }}
+ </p>
+ <button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ folder: {
+ type: Object,
+ required: true,
+ },
+ isSelected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ hover: false,
+ draghover: false,
+ isDragging: false,
+ };
+ },
+
+ computed: {
+ browser(): any {
+ return this.$parent;
+ },
+ title(): string {
+ return this.folder.name;
+ }
+ },
+
+ methods: {
+ checkboxClicked(e) {
+ this.$emit('chosen', this.folder);
+ },
+
+ onClick() {
+ this.browser.move(this.folder);
+ },
+
+ onMouseover() {
+ this.hover = true;
+ },
+
+ onMouseout() {
+ this.hover = false
+ },
+
+ onDragover(e) {
+ // 自分自身がドラッグされている場合
+ if (this.isDragging) {
+ // 自分自身にはドロップさせない
+ e.dataTransfer.dropEffect = 'none';
+ return;
+ }
+
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
+
+ if (isFile || isDriveFile || isDriveFolder) {
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+ },
+
+ onDragenter() {
+ if (!this.isDragging) this.draghover = true;
+ },
+
+ onDragleave() {
+ this.draghover = false;
+ },
+
+ onDrop(e) {
+ this.draghover = false;
+
+ // ファイルだったら
+ if (e.dataTransfer.files.length > 0) {
+ for (const file of Array.from(e.dataTransfer.files)) {
+ this.browser.upload(file, this.folder);
+ }
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.browser.removeFile(file.id);
+ os.api('drive/files/update', {
+ fileId: file.id,
+ folderId: this.folder.id
+ });
+ }
+ //#endregion
+
+ //#region ドライブのフォルダ
+ const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+ if (driveFolder != null && driveFolder != '') {
+ const folder = JSON.parse(driveFolder);
+
+ // 移動先が自分自身ならreject
+ if (folder.id == this.folder.id) return;
+
+ this.browser.removeFolder(folder.id);
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ parentId: this.folder.id
+ }).then(() => {
+ // noop
+ }).catch(err => {
+ switch (err) {
+ case 'detected-circular-definition':
+ os.dialog({
+ title: this.$ts.unableToProcess,
+ text: this.$ts.circularReferenceFolder
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ }
+ });
+ }
+ //#endregion
+ },
+
+ onDragstart(e) {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder));
+ this.isDragging = true;
+
+ // 親ブラウザに対して、ドラッグが開始されたフラグを立てる
+ // (=あなたの子供が、ドラッグを開始しましたよ)
+ this.browser.isDragSource = true;
+ },
+
+ onDragend() {
+ this.isDragging = false;
+ this.browser.isDragSource = false;
+ },
+
+ go() {
+ this.browser.move(this.folder.id);
+ },
+
+ newWindow() {
+ this.browser.newWindow(this.folder);
+ },
+
+ rename() {
+ os.dialog({
+ title: this.$ts.renameFolder,
+ input: {
+ placeholder: this.$ts.inputNewFolderName,
+ default: this.folder.name
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/folders/update', {
+ folderId: this.folder.id,
+ name: name
+ });
+ });
+ },
+
+ deleteFolder() {
+ os.api('drive/folders/delete', {
+ folderId: this.folder.id
+ }).then(() => {
+ if (this.$store.state.uploadFolder === this.folder.id) {
+ this.$store.set('uploadFolder', null);
+ }
+ }).catch(err => {
+ switch(err.id) {
+ case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
+ os.dialog({
+ type: 'error',
+ title: this.$ts.unableToDelete,
+ text: this.$ts.hasChildFilesOrFolders
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.unableToDelete
+ });
+ }
+ });
+ },
+
+ setAsUploadFolder() {
+ this.$store.set('uploadFolder', this.folder.id);
+ },
+
+ onContextmenu(e) {
+ os.contextMenu([{
+ text: this.$ts.openInWindow,
+ icon: 'fas fa-window-restore',
+ action: () => {
+ os.popup(import('./drive-window.vue'), {
+ initialFolder: this.folder
+ }, {
+ }, 'closed');
+ }
+ }, null, {
+ text: this.$ts.rename,
+ icon: 'fas fa-i-cursor',
+ action: this.rename
+ }, null, {
+ text: this.$ts.delete,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: this.deleteFolder
+ }], e);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rghtznwe {
+ position: relative;
+ padding: 8px;
+ height: 64px;
+ background: var(--driveFolderBg);
+ border-radius: 4px;
+
+ &, * {
+ cursor: pointer;
+ }
+
+ *:not(.checkbox) {
+ pointer-events: none;
+ }
+
+ > .checkbox {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ width: 16px;
+ height: 16px;
+ background: #fff;
+ border: solid 1px #000;
+
+ &.checked {
+ background: var(--accent);
+ }
+ }
+
+ &.draghover {
+ &:after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ bottom: -4px;
+ left: -4px;
+ border: 2px dashed var(--focus);
+ border-radius: 4px;
+ }
+ }
+
+ > .name {
+ margin: 0;
+ font-size: 0.9em;
+ color: var(--desktopDriveFolderFg);
+
+ > i {
+ margin-right: 4px;
+ margin-left: 2px;
+ text-align: left;
+ }
+ }
+
+ > .upload {
+ margin: 4px 4px;
+ font-size: 0.8em;
+ text-align: right;
+ color: var(--desktopDriveFolderFg);
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive.nav-folder.vue b/packages/client/src/components/drive.nav-folder.vue
new file mode 100644
index 0000000000..4f0e6ce0e9
--- /dev/null
+++ b/packages/client/src/components/drive.nav-folder.vue
@@ -0,0 +1,135 @@
+<template>
+<div class="drylbebk"
+ :class="{ draghover }"
+ @click="onClick"
+ @dragover.prevent.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.stop="onDrop"
+>
+ <i v-if="folder == null" class="fas fa-cloud"></i>
+ <span>{{ folder == null ? $ts.drive : folder.name }}</span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ folder: {
+ type: Object,
+ required: false,
+ }
+ },
+
+ data() {
+ return {
+ hover: false,
+ draghover: false,
+ };
+ },
+
+ computed: {
+ browser(): any {
+ return this.$parent;
+ }
+ },
+
+ methods: {
+ onClick() {
+ this.browser.move(this.folder);
+ },
+
+ onMouseover() {
+ this.hover = true;
+ },
+
+ onMouseout() {
+ this.hover = false;
+ },
+
+ onDragover(e) {
+ // このフォルダがルートかつカレントディレクトリならドロップ禁止
+ if (this.folder == null && this.browser.folder == null) {
+ e.dataTransfer.dropEffect = 'none';
+ }
+
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
+
+ if (isFile || isDriveFile || isDriveFolder) {
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+
+ return false;
+ },
+
+ onDragenter() {
+ if (this.folder || this.browser.folder) this.draghover = true;
+ },
+
+ onDragleave() {
+ if (this.folder || this.browser.folder) this.draghover = false;
+ },
+
+ onDrop(e) {
+ this.draghover = false;
+
+ // ファイルだったら
+ if (e.dataTransfer.files.length > 0) {
+ for (const file of Array.from(e.dataTransfer.files)) {
+ this.browser.upload(file, this.folder);
+ }
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.browser.removeFile(file.id);
+ os.api('drive/files/update', {
+ fileId: file.id,
+ folderId: this.folder ? this.folder.id : null
+ });
+ }
+ //#endregion
+
+ //#region ドライブのフォルダ
+ const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+ if (driveFolder != null && driveFolder != '') {
+ const folder = JSON.parse(driveFolder);
+ // 移動先が自分自身ならreject
+ if (this.folder && folder.id == this.folder.id) return;
+ this.browser.removeFolder(folder.id);
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ parentId: this.folder ? this.folder.id : null
+ });
+ }
+ //#endregion
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.drylbebk {
+ > * {
+ pointer-events: none;
+ }
+
+ &.draghover {
+ background: #eee;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/drive.vue b/packages/client/src/components/drive.vue
new file mode 100644
index 0000000000..2b72a0a1c6
--- /dev/null
+++ b/packages/client/src/components/drive.vue
@@ -0,0 +1,784 @@
+<template>
+<div class="yfudmmck">
+ <nav>
+ <div class="path" @contextmenu.prevent.stop="() => {}">
+ <XNavFolder :class="{ current: folder == null }"/>
+ <template v-for="f in hierarchyFolders">
+ <span class="separator"><i class="fas fa-angle-right"></i></span>
+ <XNavFolder :folder="f"/>
+ </template>
+ <span class="separator" v-if="folder != null"><i class="fas fa-angle-right"></i></span>
+ <span class="folder current" v-if="folder != null">{{ folder.name }}</span>
+ </div>
+ <button @click="showMenu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
+ </nav>
+ <div class="main" :class="{ uploading: uploadings.length > 0, fetching }"
+ ref="main"
+ @dragover.prevent.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.prevent.stop="onDrop"
+ @contextmenu.stop="onContextmenu"
+ >
+ <div class="contents" ref="contents">
+ <div class="folders" ref="foldersContainer" v-show="folders.length > 0">
+ <XFolder v-for="(f, i) in folders" :key="f.id" class="folder" :folder="f" :select-mode="select === 'folder'" :is-selected="selectedFolders.some(x => x.id === f.id)" @chosen="chooseFolder" v-anim="i"/>
+ <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
+ <div class="padding" v-for="(n, i) in 16" :key="i"></div>
+ <MkButton ref="moreFolders" v-if="moreFolders">{{ $ts.loadMore }}</MkButton>
+ </div>
+ <div class="files" ref="filesContainer" v-show="files.length > 0">
+ <XFile v-for="(file, i) in files" :key="file.id" class="file" :file="file" :select-mode="select === 'file'" :is-selected="selectedFiles.some(x => x.id === file.id)" @chosen="chooseFile" v-anim="i"/>
+ <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
+ <div class="padding" v-for="(n, i) in 16" :key="i"></div>
+ <MkButton ref="loadMoreFiles" @click="fetchMoreFiles" v-show="moreFiles">{{ $ts.loadMore }}</MkButton>
+ </div>
+ <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching">
+ <p v-if="draghover">{{ $t('empty-draghover') }}</p>
+ <p v-if="!draghover && folder == null"><strong>{{ $ts.emptyDrive }}</strong><br/>{{ $t('empty-drive-description') }}</p>
+ <p v-if="!draghover && folder != null">{{ $ts.emptyFolder }}</p>
+ </div>
+ </div>
+ <MkLoading v-if="fetching"/>
+ </div>
+ <div class="dropzone" v-if="draghover"></div>
+ <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XNavFolder from './drive.nav-folder.vue';
+import XFolder from './drive.folder.vue';
+import XFile from './drive.file.vue';
+import MkButton from './ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNavFolder,
+ XFolder,
+ XFile,
+ MkButton,
+ },
+
+ props: {
+ initialFolder: {
+ type: Object,
+ required: false
+ },
+ type: {
+ type: String,
+ required: false,
+ default: undefined
+ },
+ multiple: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ select: {
+ type: String,
+ required: false,
+ default: null
+ }
+ },
+
+ emits: ['selected', 'change-selection', 'move-root', 'cd', 'open-folder'],
+
+ data() {
+ return {
+ /**
+ * 現在の階層(フォルダ)
+ * * null でルートを表す
+ */
+ folder: null,
+
+ files: [],
+ folders: [],
+ moreFiles: false,
+ moreFolders: false,
+ hierarchyFolders: [],
+ selectedFiles: [],
+ selectedFolders: [],
+ uploadings: os.uploads,
+ connection: null,
+
+ /**
+ * ドロップされようとしているか
+ */
+ draghover: false,
+
+ /**
+ * 自信の所有するアイテムがドラッグをスタートさせたか
+ * (自分自身の階層にドロップできないようにするためのフラグ)
+ */
+ isDragSource: false,
+
+ fetching: true,
+
+ ilFilesObserver: new IntersectionObserver(
+ (entries) => entries.some((entry) => entry.isIntersecting)
+ && !this.fetching && this.moreFiles &&
+ this.fetchMoreFiles()
+ ),
+ moreFilesElement: null as Element,
+
+ };
+ },
+
+ watch: {
+ folder() {
+ this.$emit('cd', this.folder);
+ }
+ },
+
+ mounted() {
+ if (this.$store.state.enableInfiniteScroll && this.$refs.loadMoreFiles) {
+ this.$nextTick(() => {
+ this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el)
+ });
+ }
+
+ this.connection = markRaw(os.stream.useChannel('drive'));
+
+ this.connection.on('fileCreated', this.onStreamDriveFileCreated);
+ this.connection.on('fileUpdated', this.onStreamDriveFileUpdated);
+ this.connection.on('fileDeleted', this.onStreamDriveFileDeleted);
+ this.connection.on('folderCreated', this.onStreamDriveFolderCreated);
+ this.connection.on('folderUpdated', this.onStreamDriveFolderUpdated);
+ this.connection.on('folderDeleted', this.onStreamDriveFolderDeleted);
+
+ if (this.initialFolder) {
+ this.move(this.initialFolder);
+ } else {
+ this.fetch();
+ }
+ },
+
+ activated() {
+ if (this.$store.state.enableInfiniteScroll) {
+ this.$nextTick(() => {
+ this.ilFilesObserver.observe((this.$refs.loadMoreFiles as Vue).$el)
+ });
+ }
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ this.ilFilesObserver.disconnect();
+ },
+
+ methods: {
+ onStreamDriveFileCreated(file) {
+ this.addFile(file, true);
+ },
+
+ onStreamDriveFileUpdated(file) {
+ const current = this.folder ? this.folder.id : null;
+ if (current != file.folderId) {
+ this.removeFile(file);
+ } else {
+ this.addFile(file, true);
+ }
+ },
+
+ onStreamDriveFileDeleted(fileId) {
+ this.removeFile(fileId);
+ },
+
+ onStreamDriveFolderCreated(folder) {
+ this.addFolder(folder, true);
+ },
+
+ onStreamDriveFolderUpdated(folder) {
+ const current = this.folder ? this.folder.id : null;
+ if (current != folder.parentId) {
+ this.removeFolder(folder);
+ } else {
+ this.addFolder(folder, true);
+ }
+ },
+
+ onStreamDriveFolderDeleted(folderId) {
+ this.removeFolder(folderId);
+ },
+
+ onDragover(e): any {
+ // ドラッグ元が自分自身の所有するアイテムだったら
+ if (this.isDragSource) {
+ // 自分自身にはドロップさせない
+ e.dataTransfer.dropEffect = 'none';
+ return;
+ }
+
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
+
+ if (isFile || isDriveFile || isDriveFolder) {
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+
+ return false;
+ },
+
+ onDragenter(e) {
+ if (!this.isDragSource) this.draghover = true;
+ },
+
+ onDragleave(e) {
+ this.draghover = false;
+ },
+
+ onDrop(e): any {
+ this.draghover = false;
+
+ // ドロップされてきたものがファイルだったら
+ if (e.dataTransfer.files.length > 0) {
+ for (const file of Array.from(e.dataTransfer.files)) {
+ this.upload(file, this.folder);
+ }
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ if (this.files.some(f => f.id == file.id)) return;
+ this.removeFile(file.id);
+ os.api('drive/files/update', {
+ fileId: file.id,
+ folderId: this.folder ? this.folder.id : null
+ });
+ }
+ //#endregion
+
+ //#region ドライブのフォルダ
+ const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
+ if (driveFolder != null && driveFolder != '') {
+ const folder = JSON.parse(driveFolder);
+
+ // 移動先が自分自身ならreject
+ if (this.folder && folder.id == this.folder.id) return false;
+ if (this.folders.some(f => f.id == folder.id)) return false;
+ this.removeFolder(folder.id);
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ parentId: this.folder ? this.folder.id : null
+ }).then(() => {
+ // noop
+ }).catch(err => {
+ switch (err) {
+ case 'detected-circular-definition':
+ os.dialog({
+ title: this.$ts.unableToProcess,
+ text: this.$ts.circularReferenceFolder
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ }
+ });
+ }
+ //#endregion
+ },
+
+ selectLocalFile() {
+ (this.$refs.fileInput as any).click();
+ },
+
+ urlUpload() {
+ os.dialog({
+ title: this.$ts.uploadFromUrl,
+ input: {
+ placeholder: this.$ts.uploadFromUrlDescription
+ }
+ }).then(({ canceled, result: url }) => {
+ if (canceled) return;
+ os.api('drive/files/upload-from-url', {
+ url: url,
+ folderId: this.folder ? this.folder.id : undefined
+ });
+
+ os.dialog({
+ title: this.$ts.uploadFromUrlRequested,
+ text: this.$ts.uploadFromUrlMayTakeTime
+ });
+ });
+ },
+
+ createFolder() {
+ os.dialog({
+ title: this.$ts.createFolder,
+ input: {
+ placeholder: this.$ts.folderName
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/folders/create', {
+ name: name,
+ parentId: this.folder ? this.folder.id : undefined
+ }).then(folder => {
+ this.addFolder(folder, true);
+ });
+ });
+ },
+
+ renameFolder(folder) {
+ os.dialog({
+ title: this.$ts.renameFolder,
+ input: {
+ placeholder: this.$ts.inputNewFolderName,
+ default: folder.name
+ }
+ }).then(({ canceled, result: name }) => {
+ if (canceled) return;
+ os.api('drive/folders/update', {
+ folderId: folder.id,
+ name: name
+ }).then(folder => {
+ // FIXME: 画面を更新するために自分自身に移動
+ this.move(folder);
+ });
+ });
+ },
+
+ deleteFolder(folder) {
+ os.api('drive/folders/delete', {
+ folderId: folder.id
+ }).then(() => {
+ // 削除時に親フォルダに移動
+ this.move(folder.parentId);
+ }).catch(err => {
+ switch(err.id) {
+ case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
+ os.dialog({
+ type: 'error',
+ title: this.$ts.unableToDelete,
+ text: this.$ts.hasChildFilesOrFolders
+ });
+ break;
+ default:
+ os.dialog({
+ type: 'error',
+ text: this.$ts.unableToDelete
+ });
+ }
+ });
+ },
+
+ onChangeFileInput() {
+ for (const file of Array.from((this.$refs.fileInput as any).files)) {
+ this.upload(file, this.folder);
+ }
+ },
+
+ upload(file, folder) {
+ if (folder && typeof folder == 'object') folder = folder.id;
+ os.upload(file, folder).then(res => {
+ this.addFile(res, true);
+ });
+ },
+
+ chooseFile(file) {
+ const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id);
+ if (this.multiple) {
+ if (isAlreadySelected) {
+ this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id);
+ } else {
+ this.selectedFiles.push(file);
+ }
+ this.$emit('change-selection', this.selectedFiles);
+ } else {
+ if (isAlreadySelected) {
+ this.$emit('selected', file);
+ } else {
+ this.selectedFiles = [file];
+ this.$emit('change-selection', [file]);
+ }
+ }
+ },
+
+ chooseFolder(folder) {
+ const isAlreadySelected = this.selectedFolders.some(f => f.id == folder.id);
+ if (this.multiple) {
+ if (isAlreadySelected) {
+ this.selectedFolders = this.selectedFolders.filter(f => f.id != folder.id);
+ } else {
+ this.selectedFolders.push(folder);
+ }
+ this.$emit('change-selection', this.selectedFolders);
+ } else {
+ if (isAlreadySelected) {
+ this.$emit('selected', folder);
+ } else {
+ this.selectedFolders = [folder];
+ this.$emit('change-selection', [folder]);
+ }
+ }
+ },
+
+ move(target) {
+ if (target == null) {
+ this.goRoot();
+ return;
+ } else if (typeof target == 'object') {
+ target = target.id;
+ }
+
+ this.fetching = true;
+
+ os.api('drive/folders/show', {
+ folderId: target
+ }).then(folder => {
+ this.folder = folder;
+ this.hierarchyFolders = [];
+
+ const dive = folder => {
+ this.hierarchyFolders.unshift(folder);
+ if (folder.parent) dive(folder.parent);
+ };
+
+ if (folder.parent) dive(folder.parent);
+
+ this.$emit('open-folder', folder);
+ this.fetch();
+ });
+ },
+
+ addFolder(folder, unshift = false) {
+ const current = this.folder ? this.folder.id : null;
+ if (current != folder.parentId) return;
+
+ if (this.folders.some(f => f.id == folder.id)) {
+ const exist = this.folders.map(f => f.id).indexOf(folder.id);
+ this.folders[exist] = folder;
+ return;
+ }
+
+ if (unshift) {
+ this.folders.unshift(folder);
+ } else {
+ this.folders.push(folder);
+ }
+ },
+
+ addFile(file, unshift = false) {
+ const current = this.folder ? this.folder.id : null;
+ if (current != file.folderId) return;
+
+ if (this.files.some(f => f.id == file.id)) {
+ const exist = this.files.map(f => f.id).indexOf(file.id);
+ this.files[exist] = file;
+ return;
+ }
+
+ if (unshift) {
+ this.files.unshift(file);
+ } else {
+ this.files.push(file);
+ }
+ },
+
+ removeFolder(folder) {
+ if (typeof folder == 'object') folder = folder.id;
+ this.folders = this.folders.filter(f => f.id != folder);
+ },
+
+ removeFile(file) {
+ if (typeof file == 'object') file = file.id;
+ this.files = this.files.filter(f => f.id != file);
+ },
+
+ appendFile(file) {
+ this.addFile(file);
+ },
+
+ appendFolder(folder) {
+ this.addFolder(folder);
+ },
+
+ prependFile(file) {
+ this.addFile(file, true);
+ },
+
+ prependFolder(folder) {
+ this.addFolder(folder, true);
+ },
+
+ goRoot() {
+ // 既にrootにいるなら何もしない
+ if (this.folder == null) return;
+
+ this.folder = null;
+ this.hierarchyFolders = [];
+ this.$emit('move-root');
+ this.fetch();
+ },
+
+ fetch() {
+ this.folders = [];
+ this.files = [];
+ this.moreFolders = false;
+ this.moreFiles = false;
+ this.fetching = true;
+
+ let fetchedFolders = null;
+ let fetchedFiles = null;
+
+ const foldersMax = 30;
+ const filesMax = 30;
+
+ // フォルダ一覧取得
+ os.api('drive/folders', {
+ folderId: this.folder ? this.folder.id : null,
+ limit: foldersMax + 1
+ }).then(folders => {
+ if (folders.length == foldersMax + 1) {
+ this.moreFolders = true;
+ folders.pop();
+ }
+ fetchedFolders = folders;
+ complete();
+ });
+
+ // ファイル一覧取得
+ os.api('drive/files', {
+ folderId: this.folder ? this.folder.id : null,
+ type: this.type,
+ limit: filesMax + 1
+ }).then(files => {
+ if (files.length == filesMax + 1) {
+ this.moreFiles = true;
+ files.pop();
+ }
+ fetchedFiles = files;
+ complete();
+ });
+
+ let flag = false;
+ const complete = () => {
+ if (flag) {
+ for (const x of fetchedFolders) this.appendFolder(x);
+ for (const x of fetchedFiles) this.appendFile(x);
+ this.fetching = false;
+ } else {
+ flag = true;
+ }
+ };
+ },
+
+ fetchMoreFiles() {
+ this.fetching = true;
+
+ const max = 30;
+
+ // ファイル一覧取得
+ os.api('drive/files', {
+ folderId: this.folder ? this.folder.id : null,
+ type: this.type,
+ untilId: this.files[this.files.length - 1].id,
+ limit: max + 1
+ }).then(files => {
+ if (files.length == max + 1) {
+ this.moreFiles = true;
+ files.pop();
+ } else {
+ this.moreFiles = false;
+ }
+ for (const x of files) this.appendFile(x);
+ this.fetching = false;
+ });
+ },
+
+ getMenu() {
+ return [{
+ text: this.$ts.addFile,
+ type: 'label'
+ }, {
+ text: this.$ts.upload,
+ icon: 'fas fa-upload',
+ action: () => { this.selectLocalFile(); }
+ }, {
+ text: this.$ts.fromUrl,
+ icon: 'fas fa-link',
+ action: () => { this.urlUpload(); }
+ }, null, {
+ text: this.folder ? this.folder.name : this.$ts.drive,
+ type: 'label'
+ }, this.folder ? {
+ text: this.$ts.renameFolder,
+ icon: 'fas fa-i-cursor',
+ action: () => { this.renameFolder(this.folder); }
+ } : undefined, this.folder ? {
+ text: this.$ts.deleteFolder,
+ icon: 'fas fa-trash-alt',
+ action: () => { this.deleteFolder(this.folder); }
+ } : undefined, {
+ text: this.$ts.createFolder,
+ icon: 'fas fa-folder-plus',
+ action: () => { this.createFolder(); }
+ }];
+ },
+
+ showMenu(ev) {
+ os.popupMenu(this.getMenu(), ev.currentTarget || ev.target);
+ },
+
+ onContextmenu(ev) {
+ os.contextMenu(this.getMenu(), ev);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yfudmmck {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > nav {
+ display: flex;
+ z-index: 2;
+ width: 100%;
+ padding: 0 8px;
+ box-sizing: border-box;
+ overflow: auto;
+ font-size: 0.9em;
+ box-shadow: 0 1px 0 var(--divider);
+
+ &, * {
+ user-select: none;
+ }
+
+ > .path {
+ display: inline-block;
+ vertical-align: bottom;
+ line-height: 38px;
+ white-space: nowrap;
+
+ > * {
+ display: inline-block;
+ margin: 0;
+ padding: 0 8px;
+ line-height: 38px;
+ cursor: pointer;
+
+ * {
+ pointer-events: none;
+ }
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.current {
+ font-weight: bold;
+ cursor: default;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+
+ &.separator {
+ margin: 0;
+ padding: 0;
+ opacity: 0.5;
+ cursor: default;
+
+ > i {
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ > .menu {
+ margin-left: auto;
+ }
+ }
+
+ > .main {
+ flex: 1;
+ overflow: auto;
+ padding: var(--margin);
+
+ &, * {
+ user-select: none;
+ }
+
+ &.fetching {
+ cursor: wait !important;
+
+ * {
+ pointer-events: none;
+ }
+
+ > .contents {
+ opacity: 0.5;
+ }
+ }
+
+ &.uploading {
+ height: calc(100% - 38px - 100px);
+ }
+
+ > .contents {
+
+ > .folders,
+ > .files {
+ display: flex;
+ flex-wrap: wrap;
+
+ > .folder,
+ > .file {
+ flex-grow: 1;
+ width: 128px;
+ margin: 4px;
+ box-sizing: border-box;
+ }
+
+ > .padding {
+ flex-grow: 1;
+ pointer-events: none;
+ width: 128px + 8px;
+ }
+ }
+
+ > .empty {
+ padding: 16px;
+ text-align: center;
+ pointer-events: none;
+ opacity: 0.5;
+
+ > p {
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ > .dropzone {
+ position: absolute;
+ left: 0;
+ top: 38px;
+ width: 100%;
+ height: calc(100% - 38px);
+ border: dashed 2px var(--focus);
+ pointer-events: none;
+ }
+
+ > input {
+ display: none;
+ }
+}
+</style>
diff --git a/packages/client/src/components/emoji-picker-dialog.vue b/packages/client/src/components/emoji-picker-dialog.vue
new file mode 100644
index 0000000000..1d48bbb8a3
--- /dev/null
+++ b/packages/client/src/components/emoji-picker-dialog.vue
@@ -0,0 +1,76 @@
+<template>
+<MkPopup ref="popup" :manual-showing="manualShowing" :src="src" :front="true" @click="$refs.popup.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')" #default="{point}">
+ <MkEmojiPicker class="ryghynhb _popup _shadow" :class="{ pointer: point === 'top' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen" ref="picker"/>
+</MkPopup>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkPopup from '@/components/ui/popup.vue';
+import MkEmojiPicker from '@/components/emoji-picker.vue';
+
+export default defineComponent({
+ components: {
+ MkPopup,
+ MkEmojiPicker,
+ },
+
+ props: {
+ manualShowing: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ src: {
+ required: false
+ },
+ showPinned: {
+ required: false,
+ default: true
+ },
+ asReactionPicker: {
+ required: false
+ },
+ },
+
+ emits: ['done', 'close', 'closed'],
+
+ data() {
+ return {
+
+ };
+ },
+
+ methods: {
+ chosen(emoji: any) {
+ this.$emit('done', emoji);
+ this.$refs.popup.close();
+ },
+
+ opening() {
+ this.$refs.picker.reset();
+ this.$refs.picker.focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ryghynhb {
+ &.pointer {
+ &:before {
+ --size: 8px;
+ content: '';
+ display: block;
+ position: absolute;
+ top: calc(0px - (var(--size) * 2));
+ left: 0;
+ right: 0;
+ width: 0;
+ margin: auto;
+ border: solid var(--size) transparent;
+ border-bottom-color: var(--popup);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/emoji-picker-window.vue b/packages/client/src/components/emoji-picker-window.vue
new file mode 100644
index 0000000000..0ffa0c1187
--- /dev/null
+++ b/packages/client/src/components/emoji-picker-window.vue
@@ -0,0 +1,197 @@
+<template>
+<MkWindow ref="window"
+ :initial-width="null"
+ :initial-height="null"
+ :can-resize="false"
+ :mini="true"
+ :front="true"
+ @closed="$emit('closed')"
+>
+ <MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/>
+</MkWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkWindow from '@/components/ui/window.vue';
+import MkEmojiPicker from '@/components/emoji-picker.vue';
+
+export default defineComponent({
+ components: {
+ MkWindow,
+ MkEmojiPicker,
+ },
+
+ props: {
+ src: {
+ required: false
+ },
+ showPinned: {
+ required: false,
+ default: true
+ },
+ asReactionPicker: {
+ required: false
+ },
+ },
+
+ emits: ['chosen', 'closed'],
+
+ data() {
+ return {
+
+ };
+ },
+
+ methods: {
+ chosen(emoji: any) {
+ this.$emit('chosen', emoji);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.omfetrab {
+ $pad: 8px;
+ --eachSize: 40px;
+
+ display: flex;
+ flex-direction: column;
+ contain: content;
+
+ &.big {
+ --eachSize: 44px;
+ }
+
+ &.w1 {
+ width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
+ }
+
+ &.w2 {
+ width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.w3 {
+ width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
+ }
+
+ &.h1 {
+ --height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
+ }
+
+ &.h2 {
+ --height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.h3 {
+ --height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+ }
+
+ > .search {
+ width: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ font-size: 1em;
+ outline: none;
+ border: none;
+ background: transparent;
+ color: var(--fg);
+
+ &:not(.filled) {
+ order: 1;
+ z-index: 2;
+ box-shadow: 0px -1px 0 0px var(--divider);
+ }
+ }
+
+ > .emojis {
+ height: var(--height);
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > .index {
+ min-height: var(--height);
+ position: relative;
+ border-bottom: solid 0.5px var(--divider);
+
+ > .arrow {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 16px 0;
+ text-align: center;
+ opacity: 0.5;
+ pointer-events: none;
+ }
+ }
+
+ section {
+ > header {
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ padding: 8px;
+ font-size: 12px;
+ }
+
+ > div {
+ padding: $pad;
+
+ > button {
+ position: relative;
+ padding: 0;
+ width: var(--eachSize);
+ height: var(--eachSize);
+ border-radius: 4px;
+
+ &:focus-visible {
+ outline: solid 2px var(--focus);
+ z-index: 1;
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &:active {
+ background: var(--accent);
+ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
+ }
+
+ > * {
+ font-size: 24px;
+ height: 1.25em;
+ vertical-align: -.25em;
+ pointer-events: none;
+ }
+ }
+ }
+
+ &.result {
+ border-bottom: solid 0.5px var(--divider);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ &.unicode {
+ min-height: 384px;
+ }
+
+ &.custom {
+ min-height: 64px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue
new file mode 100644
index 0000000000..2401eca2a5
--- /dev/null
+++ b/packages/client/src/components/emoji-picker.section.vue
@@ -0,0 +1,50 @@
+<template>
+<section>
+ <header class="_acrylic" @click="shown = !shown">
+ <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
+ </header>
+ <div v-if="shown">
+ <button v-for="emoji in emojis"
+ class="_button"
+ @click="chosen(emoji, $event)"
+ :key="emoji"
+ >
+ <MkEmoji :emoji="emoji" :normal="true"/>
+ </button>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+
+export default defineComponent({
+ props: {
+ emojis: {
+ required: true,
+ },
+ initialShown: {
+ required: false
+ }
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ getStaticImageUrl,
+ shown: this.initialShown,
+ };
+ },
+
+ methods: {
+ chosen(emoji: any, ev) {
+ this.$parent.chosen(emoji, ev);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue
new file mode 100644
index 0000000000..015e201269
--- /dev/null
+++ b/packages/client/src/components/emoji-picker.vue
@@ -0,0 +1,501 @@
+<template>
+<div class="omfetrab" :class="['w' + width, 'h' + height, { big }]">
+ <input ref="search" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" v-model.trim="q" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()">
+ <div class="emojis" ref="emojis">
+ <section class="result">
+ <div v-if="searchResultCustom.length > 0">
+ <button v-for="emoji in searchResultCustom"
+ class="_button"
+ :title="emoji.name"
+ @click="chosen(emoji, $event)"
+ :key="emoji"
+ tabindex="0"
+ >
+ <MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>
+ <img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
+ </button>
+ </div>
+ <div v-if="searchResultUnicode.length > 0">
+ <button v-for="emoji in searchResultUnicode"
+ class="_button"
+ :title="emoji.name"
+ @click="chosen(emoji, $event)"
+ :key="emoji.name"
+ tabindex="0"
+ >
+ <MkEmoji :emoji="emoji.char"/>
+ </button>
+ </div>
+ </section>
+
+ <div class="index" v-if="tab === 'index'">
+ <section v-if="showPinned">
+ <div>
+ <button v-for="emoji in pinned"
+ class="_button"
+ @click="chosen(emoji, $event)"
+ tabindex="0"
+ :key="emoji"
+ >
+ <MkEmoji :emoji="emoji" :normal="true"/>
+ </button>
+ </div>
+ </section>
+
+ <section>
+ <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header>
+ <div>
+ <button v-for="emoji in $store.state.recentlyUsedEmojis"
+ class="_button"
+ @click="chosen(emoji, $event)"
+ :key="emoji"
+ >
+ <MkEmoji :emoji="emoji" :normal="true"/>
+ </button>
+ </div>
+ </section>
+ </div>
+ <div>
+ <header class="_acrylic">{{ $ts.customEmojis }}</header>
+ <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection>
+ </div>
+ <div>
+ <header class="_acrylic">{{ $ts.emoji }}</header>
+ <XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection>
+ </div>
+ </div>
+ <div class="tabs">
+ <button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="fas fa-asterisk fa-fw"></i></button>
+ <button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="fas fa-laugh fa-fw"></i></button>
+ <button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="fas fa-leaf fa-fw"></i></button>
+ <button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="fas fa-hashtag fa-fw"></i></button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import { emojilist } from '@/scripts/emojilist';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import Particle from '@/components/particle.vue';
+import * as os from '@/os';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import { isMobile } from '@/scripts/is-mobile';
+import { emojiCategories } from '@/instance';
+import XSection from './emoji-picker.section.vue';
+
+export default defineComponent({
+ components: {
+ XSection
+ },
+
+ props: {
+ showPinned: {
+ required: false,
+ default: true
+ },
+ asReactionPicker: {
+ required: false
+ },
+ },
+
+ emits: ['chosen'],
+
+ data() {
+ return {
+ emojilist: markRaw(emojilist),
+ getStaticImageUrl,
+ pinned: this.$store.reactiveState.reactions,
+ width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3,
+ height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2,
+ big: this.asReactionPicker ? isDeviceTouch : false,
+ customEmojiCategories: emojiCategories,
+ customEmojis: this.$instance.emojis,
+ q: null,
+ searchResultCustom: [],
+ searchResultUnicode: [],
+ tab: 'index',
+ categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'],
+ };
+ },
+
+ watch: {
+ q() {
+ this.$refs.emojis.scrollTop = 0;
+
+ if (this.q == null || this.q === '') {
+ this.searchResultCustom = [];
+ this.searchResultUnicode = [];
+ return;
+ }
+
+ const q = this.q.replace(/:/g, '');
+
+ const searchCustom = () => {
+ const max = 8;
+ const emojis = this.customEmojis;
+ const matches = new Set();
+
+ const exactMatch = emojis.find(e => e.name === q);
+ if (exactMatch) matches.add(exactMatch);
+
+ if (q.includes(' ')) { // AND検索
+ const keywords = q.split(' ');
+
+ // 名前にキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ // 名前またはエイリアスにキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ } else {
+ for (const emoji of emojis) {
+ if (emoji.name.startsWith(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.aliases.some(alias => alias.startsWith(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.name.includes(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.aliases.some(alias => alias.includes(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ }
+
+ return matches;
+ };
+
+ const searchUnicode = () => {
+ const max = 8;
+ const emojis = this.emojilist;
+ const matches = new Set();
+
+ const exactMatch = emojis.find(e => e.name === q);
+ if (exactMatch) matches.add(exactMatch);
+
+ if (q.includes(' ')) { // AND検索
+ const keywords = q.split(' ');
+
+ // 名前にキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ // 名前またはエイリアスにキーワードが含まれている
+ for (const emoji of emojis) {
+ if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ } else {
+ for (const emoji of emojis) {
+ if (emoji.name.startsWith(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.keywords.some(keyword => keyword.startsWith(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.name.includes(q)) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ if (matches.size >= max) return matches;
+
+ for (const emoji of emojis) {
+ if (emoji.keywords.some(keyword => keyword.includes(q))) {
+ matches.add(emoji);
+ if (matches.size >= max) break;
+ }
+ }
+ }
+
+ return matches;
+ };
+
+ this.searchResultCustom = Array.from(searchCustom());
+ this.searchResultUnicode = Array.from(searchUnicode());
+ }
+ },
+
+ mounted() {
+ this.focus();
+ },
+
+ methods: {
+ focus() {
+ if (!isMobile && !isDeviceTouch) {
+ this.$refs.search.focus({
+ preventScroll: true
+ });
+ }
+ },
+
+ reset() {
+ this.$refs.emojis.scrollTop = 0;
+ this.q = '';
+ },
+
+ getKey(emoji: any) {
+ return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`);
+ },
+
+ chosen(emoji: any, ev) {
+ if (ev) {
+ const el = ev.currentTarget || ev.target;
+ const rect = el.getBoundingClientRect();
+ const x = rect.left + (el.clientWidth / 2);
+ const y = rect.top + (el.clientHeight / 2);
+ os.popup(Particle, { x, y }, {}, 'end');
+ }
+
+ const key = this.getKey(emoji);
+ this.$emit('chosen', key);
+
+ // 最近使った絵文字更新
+ if (!this.pinned.includes(key)) {
+ let recents = this.$store.state.recentlyUsedEmojis;
+ recents = recents.filter((e: any) => e !== key);
+ recents.unshift(key);
+ this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
+ }
+ },
+
+ paste(event) {
+ const paste = (event.clipboardData || window.clipboardData).getData('text');
+ if (this.done(paste)) {
+ event.preventDefault();
+ }
+ },
+
+ done(query) {
+ if (query == null) query = this.q;
+ if (query == null) return;
+ const q = query.replace(/:/g, '');
+ const exactMatchCustom = this.customEmojis.find(e => e.name === q);
+ if (exactMatchCustom) {
+ this.chosen(exactMatchCustom);
+ return true;
+ }
+ const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q);
+ if (exactMatchUnicode) {
+ this.chosen(exactMatchUnicode);
+ return true;
+ }
+ if (this.searchResultCustom.length > 0) {
+ this.chosen(this.searchResultCustom[0]);
+ return true;
+ }
+ if (this.searchResultUnicode.length > 0) {
+ this.chosen(this.searchResultUnicode[0]);
+ return true;
+ }
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.omfetrab {
+ $pad: 8px;
+ --eachSize: 40px;
+
+ display: flex;
+ flex-direction: column;
+
+ &.big {
+ --eachSize: 44px;
+ }
+
+ &.w1 {
+ width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
+ }
+
+ &.w2 {
+ width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.w3 {
+ width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
+ }
+
+ &.h1 {
+ --height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
+ }
+
+ &.h2 {
+ --height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
+ }
+
+ &.h3 {
+ --height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
+ }
+
+ > .search {
+ width: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ font-size: 1em;
+ outline: none;
+ border: none;
+ background: transparent;
+ color: var(--fg);
+
+ &:not(.filled) {
+ order: 1;
+ z-index: 2;
+ box-shadow: 0px -1px 0 0px var(--divider);
+ }
+ }
+
+ > .tabs {
+ display: flex;
+ display: none;
+
+ > .tab {
+ flex: 1;
+ height: 38px;
+ border-top: solid 0.5px var(--divider);
+
+ &.active {
+ border-top: solid 1px var(--accent);
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .emojis {
+ height: var(--height);
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ > div {
+ &:not(.index) {
+ padding: 4px 0 8px 0;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > header {
+ /*position: sticky;
+ top: 0;
+ left: 0;*/
+ height: 32px;
+ line-height: 32px;
+ z-index: 2;
+ padding: 0 8px;
+ font-size: 12px;
+ }
+ }
+
+ ::v-deep(section) {
+ > header {
+ position: sticky;
+ top: 0;
+ left: 0;
+ height: 32px;
+ line-height: 32px;
+ z-index: 1;
+ padding: 0 8px;
+ font-size: 12px;
+ cursor: pointer;
+
+ &:hover {
+ color: var(--accent);
+ }
+ }
+
+ > div {
+ position: relative;
+ padding: $pad;
+
+ > button {
+ position: relative;
+ padding: 0;
+ width: var(--eachSize);
+ height: var(--eachSize);
+ border-radius: 4px;
+
+ &:focus-visible {
+ outline: solid 2px var(--focus);
+ z-index: 1;
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &:active {
+ background: var(--accent);
+ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
+ }
+
+ > * {
+ font-size: 24px;
+ height: 1.25em;
+ vertical-align: -.25em;
+ pointer-events: none;
+ }
+ }
+ }
+
+ &.result {
+ border-bottom: solid 0.5px var(--divider);
+
+ &:empty {
+ display: none;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/featured-photos.vue b/packages/client/src/components/featured-photos.vue
new file mode 100644
index 0000000000..276344dfb4
--- /dev/null
+++ b/packages/client/src/components/featured-photos.vue
@@ -0,0 +1,32 @@
+<template>
+<div class="xfbouadm" v-if="meta" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ },
+
+ data() {
+ return {
+ meta: null,
+ };
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.xfbouadm {
+ background-position: center;
+ background-size: cover;
+}
+</style>
diff --git a/packages/client/src/components/file-type-icon.vue b/packages/client/src/components/file-type-icon.vue
new file mode 100644
index 0000000000..be1af5e501
--- /dev/null
+++ b/packages/client/src/components/file-type-icon.vue
@@ -0,0 +1,28 @@
+<template>
+<span class="mk-file-type-icon">
+ <template v-if="kind == 'image'"><i class="fas fa-file-image"></i></template>
+</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ type: {
+ type: String,
+ required: true,
+ }
+ },
+ data() {
+ return {
+ };
+ },
+ computed: {
+ kind(): string {
+ return this.type.split('/')[0];
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/follow-button.vue b/packages/client/src/components/follow-button.vue
new file mode 100644
index 0000000000..a96899027f
--- /dev/null
+++ b/packages/client/src/components/follow-button.vue
@@ -0,0 +1,210 @@
+<template>
+<button class="kpoogebi _button"
+ :class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
+ @click="onClick"
+ :disabled="wait"
+>
+ <template v-if="!wait">
+ <template v-if="hasPendingFollowRequestFromYou && user.isLocked">
+ <span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i>
+ </template>
+ <template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合。 -->
+ <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i>
+ </template>
+ <template v-else-if="isFollowing">
+ <span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
+ </template>
+ <template v-else-if="!isFollowing && user.isLocked">
+ <span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i>
+ </template>
+ <template v-else-if="!isFollowing && !user.isLocked">
+ <span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
+ </template>
+ </template>
+ <template v-else>
+ <span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
+ </template>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ large: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ isFollowing: this.user.isFollowing,
+ hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou,
+ wait: false,
+ connection: null,
+ };
+ },
+
+ created() {
+ // 渡されたユーザー情報が不完全な場合
+ if (this.user.isFollowing == null) {
+ os.api('users/show', {
+ userId: this.user.id
+ }).then(u => {
+ this.isFollowing = u.isFollowing;
+ this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou;
+ });
+ }
+ },
+
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('main'));
+
+ this.connection.on('follow', this.onFollowChange);
+ this.connection.on('unfollow', this.onFollowChange);
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ onFollowChange(user) {
+ if (user.id == this.user.id) {
+ this.isFollowing = user.isFollowing;
+ this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
+ }
+ },
+
+ async onClick() {
+ this.wait = true;
+
+ try {
+ if (this.isFollowing) {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }),
+ showCancelButton: true
+ });
+
+ if (canceled) return;
+
+ await os.api('following/delete', {
+ userId: this.user.id
+ });
+ } else {
+ if (this.hasPendingFollowRequestFromYou) {
+ await os.api('following/requests/cancel', {
+ userId: this.user.id
+ });
+ } else if (this.user.isLocked) {
+ await os.api('following/create', {
+ userId: this.user.id
+ });
+ this.hasPendingFollowRequestFromYou = true;
+ } else {
+ await os.api('following/create', {
+ userId: this.user.id
+ });
+ this.hasPendingFollowRequestFromYou = true;
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ } finally {
+ this.wait = false;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kpoogebi {
+ position: relative;
+ display: inline-block;
+ font-weight: bold;
+ color: var(--accent);
+ background: transparent;
+ border: solid 1px var(--accent);
+ padding: 0;
+ height: 31px;
+ font-size: 16px;
+ border-radius: 32px;
+ background: #fff;
+
+ &.full {
+ padding: 0 8px 0 12px;
+ font-size: 14px;
+ }
+
+ &.large {
+ font-size: 16px;
+ height: 38px;
+ padding: 0 12px 0 16px;
+ }
+
+ &:not(.full) {
+ width: 31px;
+ }
+
+ &:focus-visible {
+ &:after {
+ content: "";
+ pointer-events: none;
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ bottom: -5px;
+ left: -5px;
+ border: 2px solid var(--focus);
+ border-radius: 32px;
+ }
+ }
+
+ &:hover {
+ //background: mix($primary, #fff, 20);
+ }
+
+ &:active {
+ //background: mix($primary, #fff, 40);
+ }
+
+ &.active {
+ color: #fff;
+ background: var(--accent);
+
+ &:hover {
+ background: var(--accentLighten);
+ border-color: var(--accentLighten);
+ }
+
+ &:active {
+ background: var(--accentDarken);
+ border-color: var(--accentDarken);
+ }
+ }
+
+ &.wait {
+ cursor: wait !important;
+ opacity: 0.7;
+ }
+
+ > span {
+ margin-right: 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue
new file mode 100644
index 0000000000..a42ea5864a
--- /dev/null
+++ b/packages/client/src/components/forgot-password.vue
@@ -0,0 +1,84 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ :height="400"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.forgotPassword }}</template>
+
+ <form class="bafeceda" @submit.prevent="onSubmit" v-if="$instance.enableEmail">
+ <div class="main _formRoot">
+ <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required>
+ <template #label>{{ $ts.username }}</template>
+ <template #prefix>@</template>
+ </MkInput>
+
+ <MkInput class="_formBlock" v-model="email" type="email" spellcheck="false" required>
+ <template #label>{{ $ts.emailAddress }}</template>
+ <template #caption>{{ $ts._forgotPassword.enterEmail }}</template>
+ </MkInput>
+
+ <MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton>
+ </div>
+ <div class="sub">
+ <MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA>
+ </div>
+ </form>
+ <div v-else>
+ {{ $ts._forgotPassword.contactAdmin }}
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkButton,
+ MkInput,
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ username: '',
+ email: '',
+ processing: false,
+ };
+ },
+
+ methods: {
+ async onSubmit() {
+ this.processing = true;
+ await os.apiWithDialog('request-reset-password', {
+ username: this.username,
+ email: this.email,
+ });
+
+ this.$emit('done');
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bafeceda {
+ > .main {
+ padding: 24px;
+ }
+
+ > .sub {
+ border-top: solid 0.5px var(--divider);
+ padding: 24px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue
new file mode 100644
index 0000000000..172e6a5138
--- /dev/null
+++ b/packages/client/src/components/form-dialog.vue
@@ -0,0 +1,125 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="450"
+ :can-close="false"
+ :with-ok-button="true"
+ :ok-button-disabled="false"
+ @click="cancel()"
+ @ok="ok()"
+ @close="cancel()"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ {{ title }}
+ </template>
+ <FormBase class="xkpnjxcv">
+ <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
+ <FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1">
+ <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormInput>
+ <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text">
+ <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormInput>
+ <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]">
+ <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormTextarea>
+ <FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]">
+ <span v-text="form[item].label || item"></span>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormSwitch>
+ <FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]">
+ <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+ <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option>
+ </FormSelect>
+ <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]">
+ <template #desc><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+ <option v-for="item in form[item].options" :value="item.value" :key="item.value">{{ item.label }}</option>
+ </FormRadios>
+ <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step">
+ <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
+ <template v-if="form[item].description" #desc>{{ form[item].description }}</template>
+ </FormRange>
+ <FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)">
+ <span v-text="form[item].content || item"></span>
+ </FormButton>
+ </template>
+ </FormBase>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import FormBase from './debobigego/base.vue';
+import FormInput from './debobigego/input.vue';
+import FormTextarea from './debobigego/textarea.vue';
+import FormSwitch from './debobigego/switch.vue';
+import FormSelect from './debobigego/select.vue';
+import FormRange from './debobigego/range.vue';
+import FormButton from './debobigego/button.vue';
+import FormRadios from './debobigego/radios.vue';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ FormBase,
+ FormInput,
+ FormTextarea,
+ FormSwitch,
+ FormSelect,
+ FormRange,
+ FormButton,
+ FormRadios,
+ },
+
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ form: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ emits: ['done'],
+
+ data() {
+ return {
+ values: {}
+ };
+ },
+
+ created() {
+ for (const item in this.form) {
+ this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null;
+ }
+ },
+
+ methods: {
+ ok() {
+ this.$emit('done', {
+ result: this.values
+ });
+ this.$refs.dialog.close();
+ },
+
+ cancel() {
+ this.$emit('done', {
+ canceled: true
+ });
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xkpnjxcv {
+
+}
+</style>
diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue
new file mode 100644
index 0000000000..f2c1ead00c
--- /dev/null
+++ b/packages/client/src/components/form/input.vue
@@ -0,0 +1,315 @@
+<template>
+<div class="matxzzsk">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ inline, disabled, focused }">
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <input ref="inputEl"
+ :type="type"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ :step="step"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ :list="id"
+ >
+ <datalist :id="id" v-if="datalist">
+ <option v-for="data in datalist" :value="data"/>
+ </datalist>
+ <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import { debounce } from 'throttle-debounce';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true
+ },
+ type: {
+ type: String,
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ pattern: {
+ type: String,
+ required: false
+ },
+ placeholder: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ autocomplete: {
+ required: false
+ },
+ spellcheck: {
+ required: false
+ },
+ step: {
+ required: false
+ },
+ datalist: {
+ type: Array,
+ required: false,
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ debounce: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, type, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const id = Math.random().toString(); // TODO: uuid?
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ if (type?.value === 'number') {
+ context.emit('update:modelValue', parseFloat(v.value));
+ } else {
+ context.emit('update:modelValue', v.value);
+ }
+ };
+
+ const debouncedUpdated = debounce(1000, updated);
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
+
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
+ });
+ });
+
+ return {
+ id,
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.matxzzsk {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ $height: 42px;
+ position: relative;
+
+ > input {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: $height;
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 0.5px var(--inputBorder);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover);
+ }
+ }
+
+ > .prefix,
+ > .suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: $height;
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: inline-block;
+ min-width: 16px;
+ max-width: 150px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .prefix {
+ left: 0;
+ padding-right: 6px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 6px;
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.focused {
+ > input {
+ border-color: var(--accent);
+ //box-shadow: 0 0 0 4px var(--focus);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue
new file mode 100644
index 0000000000..0f31d8fa0a
--- /dev/null
+++ b/packages/client/src/components/form/radio.vue
@@ -0,0 +1,122 @@
+<template>
+<div
+ class="novjtctn"
+ :class="{ disabled, checked }"
+ :aria-checked="checked"
+ :aria-disabled="disabled"
+ @click="toggle"
+>
+ <input type="radio"
+ :disabled="disabled"
+ >
+ <span class="button">
+ <span></span>
+ </span>
+ <span class="label"><slot></slot></span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ required: false
+ },
+ value: {
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ checked(): boolean {
+ return this.modelValue === this.value;
+ }
+ },
+ methods: {
+ toggle() {
+ if (this.disabled) return;
+ this.$emit('update:modelValue', this.value);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.novjtctn {
+ position: relative;
+ display: inline-block;
+ margin: 8px 20px 0 0;
+ text-align: left;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ > * {
+ user-select: none;
+ }
+
+ &.disabled {
+ opacity: 0.6;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ &.checked {
+ > .button {
+ border-color: var(--accent);
+
+ &:after {
+ background-color: var(--accent);
+ transform: scale(1);
+ opacity: 1;
+ }
+ }
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ background: none;
+ border: solid 2px var(--inputBorder);
+ border-radius: 100%;
+ transition: inherit;
+
+ &:after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 3px;
+ right: 3px;
+ bottom: 3px;
+ left: 3px;
+ border-radius: 100%;
+ opacity: 0;
+ transform: scale(0);
+ transition: 0.4s cubic-bezier(0.25, 0.8, 0.25, 1);
+ }
+ }
+
+ > .label {
+ margin-left: 28px;
+ display: block;
+ font-size: 16px;
+ line-height: 20px;
+ cursor: pointer;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue
new file mode 100644
index 0000000000..998a738202
--- /dev/null
+++ b/packages/client/src/components/form/radios.vue
@@ -0,0 +1,54 @@
+<script lang="ts">
+import { defineComponent, h } from 'vue';
+import MkRadio from './radio.vue';
+
+export default defineComponent({
+ components: {
+ MkRadio
+ },
+ props: {
+ modelValue: {
+ required: false
+ },
+ },
+ data() {
+ return {
+ value: this.modelValue,
+ }
+ },
+ watch: {
+ value() {
+ this.$emit('update:modelValue', this.value);
+ }
+ },
+ render() {
+ let options = this.$slots.default();
+
+ // なぜかFragmentになることがあるため
+ if (options.length === 1 && options[0].props == null) options = options[0].children;
+
+ return h('div', {
+ class: 'novjtcto'
+ }, [
+ ...options.map(option => h(MkRadio, {
+ key: option.key,
+ value: option.props.value,
+ modelValue: this.value,
+ 'onUpdate:modelValue': value => this.value = value,
+ }, option.children))
+ ]);
+ }
+});
+</script>
+
+<style lang="scss">
+.novjtcto {
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue
new file mode 100644
index 0000000000..4cfe66a8fc
--- /dev/null
+++ b/packages/client/src/components/form/range.vue
@@ -0,0 +1,139 @@
+<template>
+<div class="timctyfi" :class="{ focused, disabled }">
+ <div class="icon"><slot name="icon"></slot></div>
+ <span class="label"><slot name="label"></slot></span>
+ <input
+ type="range"
+ ref="input"
+ v-model="v"
+ :disabled="disabled"
+ :min="min"
+ :max="max"
+ :step="step"
+ :autofocus="autofocus"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="$emit('update:value', $event.target.value)"
+ />
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ min: {
+ type: Number,
+ required: false,
+ default: 0
+ },
+ max: {
+ type: Number,
+ required: false,
+ default: 100
+ },
+ step: {
+ type: Number,
+ required: false,
+ default: 1
+ },
+ autofocus: {
+ type: Boolean,
+ required: false
+ }
+ },
+ data() {
+ return {
+ v: this.value,
+ focused: false
+ };
+ },
+ watch: {
+ value(v) {
+ this.v = parseFloat(v);
+ }
+ },
+ mounted() {
+ if (this.autofocus) {
+ this.$nextTick(() => {
+ this.$refs.input.focus();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.timctyfi {
+ position: relative;
+ margin: 8px;
+
+ > .icon {
+ display: inline-block;
+ width: 24px;
+ text-align: center;
+ }
+
+ > .title {
+ pointer-events: none;
+ font-size: 16px;
+ color: var(--inputLabel);
+ overflow: hidden;
+ }
+
+ > input {
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: var(--X10);
+ height: 7px;
+ margin: 0 8px;
+ outline: 0;
+ border: 0;
+ border-radius: 7px;
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ box-sizing: content-box;
+ }
+
+ &::-moz-range-thumb {
+ -moz-appearance: none;
+ appearance: none;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ display: block;
+ border-radius: 50%;
+ border: none;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/section.vue b/packages/client/src/components/form/section.vue
new file mode 100644
index 0000000000..8eac40a0db
--- /dev/null
+++ b/packages/client/src/components/form/section.vue
@@ -0,0 +1,31 @@
+<template>
+<div class="vrtktovh" v-size="{ max: [500] }" v-sticky-container>
+ <div class="label"><slot name="label"></slot></div>
+ <div class="main">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.vrtktovh {
+ border-top: solid 0.5px var(--divider);
+
+ > .label {
+ font-weight: bold;
+ padding: 24px 0 16px 0;
+ }
+
+ > .main {
+ margin-bottom: 32px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue
new file mode 100644
index 0000000000..f7eb5cd14d
--- /dev/null
+++ b/packages/client/src/components/form/select.vue
@@ -0,0 +1,312 @@
+<template>
+<div class="vblkjoeq">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick" ref="container">
+ <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
+ <select class="select" ref="inputEl"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ @focus="focused = true"
+ @blur="focused = false"
+ @input="onInput"
+ >
+ <slot></slot>
+ </select>
+ <div class="suffix" ref="suffixEl"><i class="fas fa-chevron-down"></i></div>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ placeholder: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['change', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+ const prefixEl = ref(null);
+ const suffixEl = ref(null);
+ const container = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ updated();
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+
+ // このコンポーネントが作成された時、非表示状態である場合がある
+ // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
+ const clock = setInterval(() => {
+ if (prefixEl.value) {
+ if (prefixEl.value.offsetWidth) {
+ inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
+ }
+ }
+ if (suffixEl.value) {
+ if (suffixEl.value.offsetWidth) {
+ inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
+ }
+ }
+ }, 100);
+
+ onUnmounted(() => {
+ clearInterval(clock);
+ });
+ });
+ });
+
+ const onClick = (ev: MouseEvent) => {
+ focused.value = true;
+
+ const menu = [];
+ let options = context.slots.default();
+
+ const pushOption = (option: VNode) => {
+ menu.push({
+ text: option.children,
+ active: v.value === option.props.value,
+ action: () => {
+ v.value = option.props.value;
+ },
+ });
+ };
+
+ const scanOptions = (options: VNode[]) => {
+ for (const vnode of options) {
+ if (vnode.type === 'optgroup') {
+ const optgroup = vnode;
+ menu.push({
+ type: 'label',
+ text: optgroup.props.label,
+ });
+ scanOptions(optgroup.children);
+ } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある
+ const fragment = vnode;
+ scanOptions(fragment.children);
+ } else {
+ const option = vnode;
+ pushOption(option);
+ }
+ }
+ };
+
+ scanOptions(options);
+
+ os.popupMenu(menu, container.value, {
+ width: container.value.offsetWidth,
+ }).then(() => {
+ focused.value = false;
+ });
+ };
+
+ return {
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ prefixEl,
+ suffixEl,
+ container,
+ focus,
+ onInput,
+ onClick,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vblkjoeq {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ $height: 42px;
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ > .select {
+ border-color: var(--inputBorderHover);
+ }
+ }
+
+ > .select {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ height: $height;
+ width: 100%;
+ margin: 0;
+ padding: 0 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 1px var(--inputBorder);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ cursor: pointer;
+ transition: border-color 0.1s ease-out;
+ pointer-events: none;
+ }
+
+ > .prefix,
+ > .suffix {
+ display: flex;
+ align-items: center;
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ padding: 0 12px;
+ font-size: 1em;
+ height: $height;
+ pointer-events: none;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ display: inline-block;
+ min-width: 16px;
+ max-width: 150px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .prefix {
+ left: 0;
+ padding-right: 6px;
+ }
+
+ > .suffix {
+ right: 0;
+ padding-left: 6px;
+ }
+
+ &.inline {
+ display: inline-block;
+ margin: 0;
+ }
+
+ &.focused {
+ > select {
+ border-color: var(--accent);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/slot.vue b/packages/client/src/components/form/slot.vue
new file mode 100644
index 0000000000..8580c1307d
--- /dev/null
+++ b/packages/client/src/components/form/slot.vue
@@ -0,0 +1,50 @@
+<template>
+<div class="adhpbeou">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+
+});
+</script>
+
+<style lang="scss" scoped>
+.adhpbeou {
+ margin: 1.5em 0;
+
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .content {
+ position: relative;
+ background: var(--panel);
+ border: solid 0.5px var(--inputBorder);
+ border-radius: 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue
new file mode 100644
index 0000000000..85f8b7c870
--- /dev/null
+++ b/packages/client/src/components/form/switch.vue
@@ -0,0 +1,150 @@
+<template>
+<div
+ class="ziffeoms"
+ :class="{ disabled, checked }"
+ role="switch"
+ :aria-checked="checked"
+ :aria-disabled="disabled"
+ @click.prevent="toggle"
+>
+ <input
+ type="checkbox"
+ ref="input"
+ :disabled="disabled"
+ @keydown.enter="toggle"
+ >
+ <span class="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff">
+ <span class="handle"></span>
+ </span>
+ <span class="label">
+ <span><slot></slot></span>
+ <p><slot name="caption"></slot></p>
+ </span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ type: Boolean,
+ default: false
+ },
+ disabled: {
+ type: Boolean,
+ default: false
+ }
+ },
+ computed: {
+ checked(): boolean {
+ return this.modelValue;
+ }
+ },
+ methods: {
+ toggle() {
+ if (this.disabled) return;
+ this.$emit('update:modelValue', !this.checked);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ziffeoms {
+ position: relative;
+ display: flex;
+ cursor: pointer;
+ transition: all 0.3s;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ > * {
+ user-select: none;
+ }
+
+ > input {
+ position: absolute;
+ width: 0;
+ height: 0;
+ opacity: 0;
+ margin: 0;
+ }
+
+ > .button {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+ margin: 0;
+ width: 36px;
+ height: 26px;
+ background: var(--switchBg);
+ outline: none;
+ border-radius: 999px;
+ transition: inherit;
+
+ > .handle {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 5px;
+ margin: auto 0;
+ border-radius: 100%;
+ transition: background-color 0.3s, transform 0.3s;
+ width: 16px;
+ height: 16px;
+ background-color: #fff;
+ }
+ }
+
+ > .label {
+ margin-left: 16px;
+ margin-top: 2px;
+ display: block;
+ cursor: pointer;
+ transition: inherit;
+ color: var(--fg);
+
+ > span {
+ display: block;
+ line-height: 20px;
+ transition: inherit;
+ }
+
+ > p {
+ margin: 0;
+ color: var(--fgTransparentWeak);
+ font-size: 90%;
+ }
+ }
+
+ &:hover {
+ > .button {
+ background-color: var(--accentedBg);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.checked {
+ > .button {
+ background-color: var(--accent);
+ border-color: var(--accent);
+
+ > .handle {
+ transform: translateX(10px);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/form/textarea.vue b/packages/client/src/components/form/textarea.vue
new file mode 100644
index 0000000000..fdb24f1e2b
--- /dev/null
+++ b/packages/client/src/components/form/textarea.vue
@@ -0,0 +1,252 @@
+<template>
+<div class="adhpbeos">
+ <div class="label" @click="focus"><slot name="label"></slot></div>
+ <div class="input" :class="{ disabled, focused, tall, pre }">
+ <textarea ref="inputEl"
+ :class="{ code, _monospace: code }"
+ v-model="v"
+ :disabled="disabled"
+ :required="required"
+ :readonly="readonly"
+ :placeholder="placeholder"
+ :pattern="pattern"
+ :autocomplete="autocomplete"
+ :spellcheck="spellcheck"
+ @focus="focused = true"
+ @blur="focused = false"
+ @keydown="onKeydown($event)"
+ @input="onInput"
+ ></textarea>
+ </div>
+ <div class="caption"><slot name="caption"></slot></div>
+
+ <MkButton v-if="manualSave && changed" @click="updated" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import { debounce } from 'throttle-debounce';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ props: {
+ modelValue: {
+ required: true
+ },
+ type: {
+ type: String,
+ required: false
+ },
+ required: {
+ type: Boolean,
+ required: false
+ },
+ readonly: {
+ type: Boolean,
+ required: false
+ },
+ disabled: {
+ type: Boolean,
+ required: false
+ },
+ pattern: {
+ type: String,
+ required: false
+ },
+ placeholder: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ autocomplete: {
+ required: false
+ },
+ spellcheck: {
+ required: false
+ },
+ code: {
+ type: Boolean,
+ required: false
+ },
+ tall: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ pre: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ debounce: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ manualSave: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['change', 'keydown', 'enter', 'update:modelValue'],
+
+ setup(props, context) {
+ const { modelValue, autofocus } = toRefs(props);
+ const v = ref(modelValue.value);
+ const focused = ref(false);
+ const changed = ref(false);
+ const invalid = ref(false);
+ const filled = computed(() => v.value !== '' && v.value != null);
+ const inputEl = ref(null);
+
+ const focus = () => inputEl.value.focus();
+ const onInput = (ev) => {
+ changed.value = true;
+ context.emit('change', ev);
+ };
+ const onKeydown = (ev: KeyboardEvent) => {
+ context.emit('keydown', ev);
+
+ if (ev.code === 'Enter') {
+ context.emit('enter');
+ }
+ };
+
+ const updated = () => {
+ changed.value = false;
+ context.emit('update:modelValue', v.value);
+ };
+
+ const debouncedUpdated = debounce(1000, updated);
+
+ watch(modelValue, newValue => {
+ v.value = newValue;
+ });
+
+ watch(v, newValue => {
+ if (!props.manualSave) {
+ if (props.debounce) {
+ debouncedUpdated();
+ } else {
+ updated();
+ }
+ }
+
+ invalid.value = inputEl.value.validity.badInput;
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (autofocus.value) {
+ focus();
+ }
+ });
+ });
+
+ return {
+ v,
+ focused,
+ invalid,
+ changed,
+ filled,
+ inputEl,
+ focus,
+ onInput,
+ onKeydown,
+ updated,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.adhpbeos {
+ > .label {
+ font-size: 0.85em;
+ padding: 0 0 8px 12px;
+ user-select: none;
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .caption {
+ font-size: 0.8em;
+ padding: 8px 0 0 12px;
+ color: var(--fgTransparentWeak);
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .input {
+ position: relative;
+
+ > textarea {
+ appearance: none;
+ -webkit-appearance: none;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ min-height: 130px;
+ margin: 0;
+ padding: 12px;
+ font: inherit;
+ font-weight: normal;
+ font-size: 1em;
+ color: var(--fg);
+ background: var(--panel);
+ border: solid 0.5px var(--inputBorder);
+ border-radius: 6px;
+ outline: none;
+ box-shadow: none;
+ box-sizing: border-box;
+ transition: border-color 0.1s ease-out;
+
+ &:hover {
+ border-color: var(--inputBorderHover);
+ }
+ }
+
+ &.focused {
+ > textarea {
+ border-color: var(--accent);
+ }
+ }
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ &.tall {
+ > textarea {
+ min-height: 200px;
+ }
+ }
+
+ &.pre {
+ > textarea {
+ white-space: pre;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/formula-core.vue
new file mode 100644
index 0000000000..cf8dee872b
--- /dev/null
+++ b/packages/client/src/components/formula-core.vue
@@ -0,0 +1,34 @@
+
+<template>
+<div v-if="block" v-html="compiledFormula"></div>
+<span v-else v-html="compiledFormula"></span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as katex from 'katex';import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ formula: {
+ type: String,
+ required: true
+ },
+ block: {
+ type: Boolean,
+ required: true
+ }
+ },
+ computed: {
+ compiledFormula(): any {
+ return katex.renderToString(this.formula, {
+ throwOnError: false
+ } as any);
+ }
+ }
+});
+</script>
+
+<style>
+@import "../../node_modules/katex/dist/katex.min.css";
+</style>
diff --git a/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue
new file mode 100644
index 0000000000..fbb40bace7
--- /dev/null
+++ b/packages/client/src/components/formula.vue
@@ -0,0 +1,23 @@
+<template>
+<XFormula :formula="formula" :block="block" />
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XFormula: defineAsyncComponent(() => import('./formula-core.vue'))
+ },
+ props: {
+ formula: {
+ type: String,
+ required: true
+ },
+ block: {
+ type: Boolean,
+ required: true
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/gallery-post-preview.vue b/packages/client/src/components/gallery-post-preview.vue
new file mode 100644
index 0000000000..8245902976
--- /dev/null
+++ b/packages/client/src/components/gallery-post-preview.vue
@@ -0,0 +1,126 @@
+<template>
+<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
+ <div class="thumbnail">
+ <ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
+ </div>
+ <article>
+ <header>
+ <MkAvatar :user="post.user" class="avatar"/>
+ </header>
+ <footer>
+ <span class="title">{{ post.title }}</span>
+ </footer>
+ </article>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { userName } from '@/filters/user';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ ImgWithBlurhash
+ },
+ props: {
+ post: {
+ type: Object,
+ required: true
+ },
+ },
+ methods: {
+ userName
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ttasepnz {
+ display: block;
+ position: relative;
+ height: 200px;
+
+ &:hover {
+ text-decoration: none;
+ color: var(--accent);
+
+ > .thumbnail {
+ transform: scale(1.1);
+ }
+
+ > article {
+ > footer {
+ &:before {
+ opacity: 1;
+ }
+ }
+ }
+ }
+
+ > .thumbnail {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ transition: all 0.5s ease;
+
+ > .img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+ }
+
+ > article {
+ position: absolute;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+
+ > header {
+ position: absolute;
+ top: 0;
+ width: 100%;
+ padding: 12px;
+ box-sizing: border-box;
+ display: flex;
+
+ > .avatar {
+ margin-left: auto;
+ width: 32px;
+ height: 32px;
+ }
+ }
+
+ > footer {
+ position: absolute;
+ bottom: 0;
+ width: 100%;
+ padding: 16px;
+ box-sizing: border-box;
+ color: #fff;
+ text-shadow: 0 0 8px #000;
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
+ opacity: 0;
+ transition: opacity 0.5s ease;
+ }
+
+ > .title {
+ font-weight: bold;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue
new file mode 100644
index 0000000000..5db61203c6
--- /dev/null
+++ b/packages/client/src/components/global/a.vue
@@ -0,0 +1,138 @@
+<template>
+<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
+ <slot></slot>
+</a>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { router } from '@/router';
+import { url } from '@/config';
+import { popout } from '@/scripts/popout';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ inject: {
+ navHook: {
+ default: null
+ },
+ sideViewHook: {
+ default: null
+ }
+ },
+
+ props: {
+ to: {
+ type: String,
+ required: true,
+ },
+ activeClass: {
+ type: String,
+ required: false,
+ },
+ behavior: {
+ type: String,
+ required: false,
+ },
+ },
+
+ computed: {
+ active() {
+ if (this.activeClass == null) return false;
+ const resolved = router.resolve(this.to);
+ if (resolved.path == this.$route.path) return true;
+ if (resolved.name == null) return false;
+ if (this.$route.name == null) return false;
+ return resolved.name == this.$route.name;
+ }
+ },
+
+ methods: {
+ onContextmenu(e) {
+ if (window.getSelection().toString() !== '') return;
+ os.contextMenu([{
+ type: 'label',
+ text: this.to,
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(this.to);
+ }
+ }, this.sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.sideViewHook(this.to);
+ }
+ } : undefined, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: () => {
+ this.$router.push(this.to);
+ }
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.to, '_blank');
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(`${url}${this.to}`);
+ }
+ }], e);
+ },
+
+ window() {
+ os.pageWindow(this.to);
+ },
+
+ modalWindow() {
+ os.modalPageWindow(this.to);
+ },
+
+ popout() {
+ popout(this.to);
+ },
+
+ nav() {
+ if (this.behavior === 'browser') {
+ location.href = this.to;
+ return;
+ }
+
+ if (this.to.startsWith('/my/messaging')) {
+ if (ColdDeviceStorage.get('chatOpenBehavior') === 'window') return this.window();
+ if (ColdDeviceStorage.get('chatOpenBehavior') === 'popout') return this.popout();
+ }
+
+ if (this.behavior) {
+ if (this.behavior === 'window') {
+ return this.window();
+ } else if (this.behavior === 'modalWindow') {
+ return this.modalWindow();
+ }
+ }
+
+ if (this.navHook) {
+ this.navHook(this.to);
+ } else {
+ if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') {
+ return this.sideViewHook(this.to);
+ }
+
+ if (this.$router.currentRoute.value.path === this.to) {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ } else {
+ this.$router.push(this.to);
+ }
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/global/acct.vue b/packages/client/src/components/global/acct.vue
new file mode 100644
index 0000000000..b0c41c99c0
--- /dev/null
+++ b/packages/client/src/components/global/acct.vue
@@ -0,0 +1,38 @@
+<template>
+<span class="mk-acct">
+ <span class="name">@{{ user.username }}</span>
+ <span class="host" v-if="user.host || detail || $store.state.showFullAcct">@{{ user.host || host }}</span>
+</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import { host } from '@/config';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ detail: {
+ type: Boolean,
+ default: false
+ },
+ },
+ data() {
+ return {
+ host: toUnicode(host),
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-acct {
+ > .host {
+ opacity: 0.5;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/ad.vue b/packages/client/src/components/global/ad.vue
new file mode 100644
index 0000000000..71cb16740c
--- /dev/null
+++ b/packages/client/src/components/global/ad.vue
@@ -0,0 +1,200 @@
+<template>
+<div class="qiivuoyo" v-if="ad">
+ <div class="main" :class="ad.place" v-if="!showMenu">
+ <a :href="ad.url" target="_blank">
+ <img :src="ad.imageUrl">
+ <button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button>
+ </a>
+ </div>
+ <div class="menu" v-else>
+ <div class="body">
+ <div>Ads by {{ host }}</div>
+ <!--<MkButton class="button" primary>{{ $ts._ad.like }}</MkButton>-->
+ <MkButton v-if="ad.ratio !== 0" class="button" @click="reduceFrequency">{{ $ts._ad.reduceFrequencyOfThisAd }}</MkButton>
+ <button class="_textButton" @click="toggleMenu">{{ $ts._ad.back }}</button>
+ </div>
+ </div>
+</div>
+<div v-else></div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import { Instance, instance } from '@/instance';
+import { host } from '@/config';
+import MkButton from '@/components/ui/button.vue';
+import { defaultStore } from '@/store';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ prefer: {
+ type: Array,
+ required: true
+ },
+ specify: {
+ type: Object,
+ required: false
+ },
+ },
+
+ setup(props) {
+ const showMenu = ref(false);
+ const toggleMenu = () => {
+ showMenu.value = !showMenu.value;
+ };
+
+ const choseAd = (): Instance['ads'][number] | null => {
+ if (props.specify) {
+ return props.specify as Instance['ads'][number];
+ }
+
+ const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {
+ ...ad,
+ ratio: 0
+ } : ad);
+
+ let ads = allAds.filter(ad => props.prefer.includes(ad.place));
+
+ if (ads.length === 0) {
+ ads = allAds.filter(ad => ad.place === 'square');
+ }
+
+ const lowPriorityAds = ads.filter(ad => ad.ratio === 0);
+ ads = ads.filter(ad => ad.ratio !== 0);
+
+ if (ads.length === 0) {
+ if (lowPriorityAds.length !== 0) {
+ return lowPriorityAds[Math.floor(Math.random() * lowPriorityAds.length)];
+ } else {
+ return null;
+ }
+ }
+
+ const totalFactor = ads.reduce((a, b) => a + b.ratio, 0);
+ const r = Math.random() * totalFactor;
+
+ let stackedFactor = 0;
+ for (const ad of ads) {
+ if (r >= stackedFactor && r <= stackedFactor + ad.ratio) {
+ return ad;
+ } else {
+ stackedFactor += ad.ratio;
+ }
+ }
+
+ return null;
+ };
+
+ const chosen = ref(choseAd());
+
+ const reduceFrequency = () => {
+ if (chosen.value == null) return;
+ if (defaultStore.state.mutedAds.includes(chosen.value.id)) return;
+ defaultStore.push('mutedAds', chosen.value.id);
+ os.success();
+ chosen.value = choseAd();
+ showMenu.value = false;
+ };
+
+ return {
+ ad: chosen,
+ showMenu,
+ toggleMenu,
+ host,
+ reduceFrequency,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qiivuoyo {
+ background-size: auto auto;
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--ad) 8px, var(--ad) 14px );
+
+ > .main {
+ text-align: center;
+
+ > a {
+ display: inline-block;
+ position: relative;
+ vertical-align: bottom;
+
+ &:hover {
+ > img {
+ filter: contrast(120%);
+ }
+ }
+
+ > img {
+ display: block;
+ object-fit: contain;
+ margin: auto;
+ }
+
+ > .menu {
+ position: absolute;
+ top: 0;
+ right: 0;
+ background: var(--panel);
+ }
+ }
+
+ &.square {
+ > a ,
+ > a > img {
+ max-width: min(300px, 100%);
+ max-height: 300px;
+ }
+ }
+
+ &.horizontal {
+ padding: 8px;
+
+ > a ,
+ > a > img {
+ max-width: min(600px, 100%);
+ max-height: 80px;
+ }
+ }
+
+ &.horizontal-big {
+ padding: 8px;
+
+ > a ,
+ > a > img {
+ max-width: min(600px, 100%);
+ max-height: 250px;
+ }
+ }
+
+ &.vertical {
+ > a ,
+ > a > img {
+ max-width: min(100px, 100%);
+ }
+ }
+ }
+
+ > .menu {
+ padding: 8px;
+ text-align: center;
+
+ > .body {
+ padding: 8px;
+ margin: 0 auto;
+ max-width: 400px;
+ border: solid 1px var(--divider);
+
+ > .button {
+ margin: 8px auto;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/avatar.vue b/packages/client/src/components/global/avatar.vue
new file mode 100644
index 0000000000..e509e893da
--- /dev/null
+++ b/packages/client/src/components/global/avatar.vue
@@ -0,0 +1,163 @@
+<template>
+<span class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
+ <img class="inner" :src="url" decoding="async"/>
+ <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</span>
+<MkA class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
+ <img class="inner" :src="url" decoding="async"/>
+ <MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+import { acct, userPage } from '@/filters/user';
+import MkUserOnlineIndicator from '@/components/user-online-indicator.vue';
+
+export default defineComponent({
+ components: {
+ MkUserOnlineIndicator
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ target: {
+ required: false,
+ default: null
+ },
+ disableLink: {
+ required: false,
+ default: false
+ },
+ disablePreview: {
+ required: false,
+ default: false
+ },
+ showIndicator: {
+ required: false,
+ default: false
+ }
+ },
+ emits: ['click'],
+ computed: {
+ cat(): boolean {
+ return this.user.isCat;
+ },
+ url(): string {
+ return this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(this.user.avatarUrl)
+ : this.user.avatarUrl;
+ },
+ },
+ watch: {
+ 'user.avatarBlurhash'() {
+ if (this.$el == null) return;
+ this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
+ }
+ },
+ mounted() {
+ this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
+ },
+ methods: {
+ onClick(e) {
+ this.$emit('click', e);
+ },
+ acct,
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes earwiggleleft {
+ from { transform: rotate(37.6deg) skew(30deg); }
+ 25% { transform: rotate(10deg) skew(30deg); }
+ 50% { transform: rotate(20deg) skew(30deg); }
+ 75% { transform: rotate(0deg) skew(30deg); }
+ to { transform: rotate(37.6deg) skew(30deg); }
+}
+
+@keyframes earwiggleright {
+ from { transform: rotate(-37.6deg) skew(-30deg); }
+ 30% { transform: rotate(-10deg) skew(-30deg); }
+ 55% { transform: rotate(-20deg) skew(-30deg); }
+ 75% { transform: rotate(0deg) skew(-30deg); }
+ to { transform: rotate(-37.6deg) skew(-30deg); }
+}
+
+.eiwwqkts {
+ position: relative;
+ display: inline-block;
+ vertical-align: bottom;
+ flex-shrink: 0;
+ border-radius: 100%;
+ line-height: 16px;
+
+ > .inner {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ top: 0;
+ border-radius: 100%;
+ z-index: 1;
+ overflow: hidden;
+ object-fit: cover;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .indicator {
+ position: absolute;
+ z-index: 1;
+ bottom: 0;
+ left: 0;
+ width: 20%;
+ height: 20%;
+ }
+
+ &.square {
+ border-radius: 20%;
+
+ > .inner {
+ border-radius: 20%;
+ }
+ }
+
+ &.cat {
+ &:before, &:after {
+ background: #df548f;
+ border: solid 4px currentColor;
+ box-sizing: border-box;
+ content: '';
+ display: inline-block;
+ height: 50%;
+ width: 50%;
+ }
+
+ &:before {
+ border-radius: 0 75% 75%;
+ transform: rotate(37.5deg) skew(30deg);
+ }
+
+ &:after {
+ border-radius: 75% 0 75% 75%;
+ transform: rotate(-37.5deg) skew(-30deg);
+ }
+
+ &:hover {
+ &:before {
+ animation: earwiggleleft 1s infinite;
+ }
+
+ &:after {
+ animation: earwiggleright 1s infinite;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/ellipsis.vue b/packages/client/src/components/global/ellipsis.vue
new file mode 100644
index 0000000000..0a46f486d6
--- /dev/null
+++ b/packages/client/src/components/global/ellipsis.vue
@@ -0,0 +1,34 @@
+<template>
+ <span class="mk-ellipsis">
+ <span>.</span><span>.</span><span>.</span>
+ </span>
+</template>
+
+<style lang="scss" scoped>
+.mk-ellipsis {
+ > span {
+ animation: ellipsis 1.4s infinite ease-in-out both;
+
+ &:nth-child(1) {
+ animation-delay: 0s;
+ }
+
+ &:nth-child(2) {
+ animation-delay: 0.16s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.32s;
+ }
+ }
+}
+
+@keyframes ellipsis {
+ 0%, 80%, 100% {
+ opacity: 1;
+ }
+ 40% {
+ opacity: 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue
new file mode 100644
index 0000000000..67a3dea2c5
--- /dev/null
+++ b/packages/client/src/components/global/emoji.vue
@@ -0,0 +1,125 @@
+<template>
+<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/>
+<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/>
+<span v-else-if="char && useOsNativeEmojis">{{ char }}</span>
+<span v-else>{{ emoji }}</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { twemojiSvgBase } from '@/scripts/twemoji-base';
+
+export default defineComponent({
+ props: {
+ emoji: {
+ type: String,
+ required: true
+ },
+ normal: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ noStyle: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ customEmojis: {
+ required: false
+ },
+ isReaction: {
+ type: Boolean,
+ default: false
+ },
+ },
+
+ data() {
+ return {
+ url: null,
+ char: null,
+ customEmoji: null
+ }
+ },
+
+ computed: {
+ isCustom(): boolean {
+ return this.emoji.startsWith(':');
+ },
+
+ alt(): string {
+ return this.customEmoji ? `:${this.customEmoji.name}:` : this.char;
+ },
+
+ useOsNativeEmojis(): boolean {
+ return this.$store.state.useOsNativeEmojis && !this.isReaction;
+ },
+
+ ce() {
+ return this.customEmojis || this.$instance?.emojis || [];
+ }
+ },
+
+ watch: {
+ ce: {
+ handler() {
+ if (this.isCustom) {
+ const customEmoji = this.ce.find(x => x.name === this.emoji.substr(1, this.emoji.length - 2));
+ if (customEmoji) {
+ this.customEmoji = customEmoji;
+ this.url = this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(customEmoji.url)
+ : customEmoji.url;
+ }
+ }
+ },
+ immediate: true
+ },
+ },
+
+ created() {
+ if (!this.isCustom) {
+ this.char = this.emoji;
+ }
+
+ if (this.char) {
+ let codes = Array.from(this.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);
+
+ this.url = `${twemojiSvgBase}/${codes.join('-')}.svg`;
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-emoji {
+ height: 1.25em;
+ vertical-align: -0.25em;
+
+ &.custom {
+ height: 2.5em;
+ vertical-align: middle;
+ transition: transform 0.2s ease;
+
+ &:hover {
+ transform: scale(1.2);
+ }
+
+ &.normal {
+ height: 1.25em;
+ vertical-align: -0.25em;
+
+ &:hover {
+ transform: none;
+ }
+ }
+ }
+
+ &.noStyle {
+ height: auto !important;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/error.vue b/packages/client/src/components/global/error.vue
new file mode 100644
index 0000000000..8ce5d16ac6
--- /dev/null
+++ b/packages/client/src/components/global/error.vue
@@ -0,0 +1,46 @@
+<template>
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+ <div class="mjndxjcg">
+ <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</p>
+ <MkButton @click="() => $emit('retry')" class="button">{{ $ts.retry }}</MkButton>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+ data() {
+ return {
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mjndxjcg {
+ padding: 32px;
+ text-align: center;
+
+ > p {
+ margin: 0 0 8px 0;
+ }
+
+ > .button {
+ margin: 0 auto;
+ }
+
+ > img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue
new file mode 100644
index 0000000000..7d5e426f2b
--- /dev/null
+++ b/packages/client/src/components/global/header.vue
@@ -0,0 +1,360 @@
+<template>
+<div class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick" ref="el">
+ <template v-if="info">
+ <div class="titleContainer" @click="showTabsPopup" v-if="!hideTitle">
+ <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
+ <i v-else-if="info.icon" class="icon" :class="info.icon"></i>
+
+ <div class="title">
+ <MkUserName v-if="info.userName" :user="info.userName" :nowrap="false" class="title"/>
+ <div v-else-if="info.title" class="title">{{ info.title }}</div>
+ <div class="subtitle" v-if="!narrow && info.subtitle">
+ {{ info.subtitle }}
+ </div>
+ <div class="subtitle activeTab" v-if="narrow && hasTabs">
+ {{ info.tabs.find(tab => tab.active)?.title }}
+ <i class="chevron fas fa-chevron-down"></i>
+ </div>
+ </div>
+ </div>
+ <div class="tabs" v-if="!narrow || hideTitle">
+ <button class="tab _button" v-for="tab in info.tabs" :class="{ active: tab.active }" @click="tab.onClick" v-tooltip="tab.title">
+ <i v-if="tab.icon" class="icon" :class="tab.icon"></i>
+ <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
+ </button>
+ </div>
+ </template>
+ <div class="buttons right">
+ <template v-if="info && info.actions && !narrow">
+ <template v-for="action in info.actions">
+ <MkButton class="fullButton" v-if="action.asFullButton" @click.stop="action.handler" primary><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
+ <button v-else class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag" v-tooltip="action.text"><i :class="action.icon"></i></button>
+ </template>
+ </template>
+ <button v-if="shouldShowMenu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag" v-tooltip="$ts.menu"><i class="fas fa-ellipsis-h"></i></button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue';
+import * as tinycolor from 'tinycolor2';
+import { popupMenu } from '@/os';
+import { url } from '@/config';
+import { scrollToTop } from '@/scripts/scroll';
+import MkButton from '@/components/ui/button.vue';
+import { i18n } from '@/i18n';
+import { globalEvents } from '@/events';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ info: {
+ type: Object as PropType<{
+ actions?: {}[];
+ tabs?: {}[];
+ }>,
+ required: true
+ },
+ menu: {
+ required: false
+ },
+ thin: {
+ required: false,
+ default: false
+ },
+ },
+
+ setup(props) {
+ const el = ref<HTMLElement>(null);
+ const bg = ref(null);
+ const narrow = ref(false);
+ const height = ref(0);
+ const hasTabs = computed(() => {
+ return props.info.tabs && props.info.tabs.length > 0;
+ });
+ const shouldShowMenu = computed(() => {
+ if (props.info == null) return false;
+ if (props.info.actions != null && narrow.value) return true;
+ if (props.info.menu != null) return true;
+ if (props.info.share != null) return true;
+ if (props.menu != null) return true;
+ return false;
+ });
+
+ const share = () => {
+ navigator.share({
+ url: url + props.info.path,
+ ...props.info.share,
+ });
+ };
+
+ const showMenu = (ev: MouseEvent) => {
+ let menu = props.info.menu ? props.info.menu() : [];
+ if (narrow.value && props.info.actions) {
+ menu = [...props.info.actions.map(x => ({
+ text: x.text,
+ icon: x.icon,
+ action: x.handler
+ })), menu.length > 0 ? null : undefined, ...menu];
+ }
+ if (props.info.share) {
+ if (menu.length > 0) menu.push(null);
+ menu.push({
+ text: i18n.locale.share,
+ icon: 'fas fa-share-alt',
+ action: share
+ });
+ }
+ if (props.menu) {
+ if (menu.length > 0) menu.push(null);
+ menu = menu.concat(props.menu);
+ }
+ popupMenu(menu, ev.currentTarget || ev.target);
+ };
+
+ const showTabsPopup = (ev: MouseEvent) => {
+ if (!hasTabs.value) return;
+ if (!narrow.value) return;
+ ev.preventDefault();
+ ev.stopPropagation();
+ const menu = props.info.tabs.map(tab => ({
+ text: tab.title,
+ icon: tab.icon,
+ action: tab.onClick,
+ }));
+ popupMenu(menu, ev.currentTarget || ev.target);
+ };
+
+ const preventDrag = (ev: TouchEvent) => {
+ ev.stopPropagation();
+ };
+
+ const onClick = () => {
+ scrollToTop(el.value, { behavior: 'smooth' });
+ };
+
+ const calcBg = () => {
+ const rawBg = props.info?.bg || 'var(--bg)';
+ const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ tinyBg.setAlpha(0.85);
+ bg.value = tinyBg.toRgbString();
+ };
+
+ onMounted(() => {
+ calcBg();
+ globalEvents.on('themeChanged', calcBg);
+ onUnmounted(() => {
+ globalEvents.off('themeChanged', calcBg);
+ });
+
+ if (el.value.parentElement) {
+ narrow.value = el.value.parentElement.offsetWidth < 500;
+ const ro = new ResizeObserver((entries, observer) => {
+ if (el.value) {
+ narrow.value = el.value.parentElement.offsetWidth < 500;
+ }
+ });
+ ro.observe(el.value.parentElement);
+ onUnmounted(() => {
+ ro.disconnect();
+ });
+ }
+ });
+
+ return {
+ el,
+ bg,
+ narrow,
+ height,
+ hasTabs,
+ shouldShowMenu,
+ share,
+ showMenu,
+ showTabsPopup,
+ preventDrag,
+ onClick,
+ hideTitle: inject('shouldOmitHeaderTitle', false),
+ thin_: props.thin || inject('shouldHeaderThin', false)
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fdidabkb {
+ --height: 60px;
+ display: flex;
+ position: sticky;
+ top: var(--stickyTop, 0);
+ z-index: 1000;
+ width: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(15px));
+ backdrop-filter: var(--blur, blur(15px));
+ border-bottom: solid 0.5px var(--divider);
+
+ &.thin {
+ --height: 50px;
+
+ > .buttons {
+ > .button {
+ font-size: 0.9em;
+ }
+ }
+ }
+
+ &.slim {
+ text-align: center;
+
+ > .titleContainer {
+ flex: 1;
+ margin: 0 auto;
+ margin-left: var(--height);
+
+ > *:first-child {
+ margin-left: auto;
+ }
+
+ > *:last-child {
+ margin-right: auto;
+ }
+ }
+ }
+
+ > .buttons {
+ --margin: 8px;
+ display: flex;
+ align-items: center;
+ height: var(--height);
+ margin: 0 var(--margin);
+
+ &.right {
+ margin-left: auto;
+ }
+
+ &:empty {
+ width: var(--height);
+ }
+
+ > .button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: calc(var(--height) - (var(--margin) * 2));
+ width: calc(var(--height) - (var(--margin) * 2));
+ box-sizing: border-box;
+ position: relative;
+ border-radius: 5px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &.highlighted {
+ color: var(--accent);
+ }
+ }
+
+ > .fullButton {
+ & + .fullButton {
+ margin-left: 12px;
+ }
+ }
+ }
+
+ > .titleContainer {
+ display: flex;
+ align-items: center;
+ overflow: auto;
+ white-space: nowrap;
+ text-align: left;
+ font-weight: bold;
+ flex-shrink: 0;
+ margin-left: 24px;
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: bottom;
+ margin: 0 8px;
+ pointer-events: none;
+ }
+
+ > .icon {
+ margin-right: 8px;
+ }
+
+ > .title {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ line-height: 1.1;
+
+ > .subtitle {
+ opacity: 0.6;
+ font-size: 0.8em;
+ font-weight: normal;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &.activeTab {
+ text-align: center;
+
+ > .chevron {
+ display: inline-block;
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+ }
+
+ > .tabs {
+ margin-left: 16px;
+ font-size: 0.8em;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .tab {
+ display: inline-block;
+ position: relative;
+ padding: 0 10px;
+ height: 100%;
+ font-weight: normal;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ &.active {
+ opacity: 1;
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: 0 auto;
+ width: 100%;
+ height: 3px;
+ background: var(--accent);
+ }
+ }
+
+ > .icon + .title {
+ margin-left: 8px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/i18n.ts b/packages/client/src/components/global/i18n.ts
new file mode 100644
index 0000000000..abf0c96856
--- /dev/null
+++ b/packages/client/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/client/src/components/global/loading.vue b/packages/client/src/components/global/loading.vue
new file mode 100644
index 0000000000..7bde53c12e
--- /dev/null
+++ b/packages/client/src/components/global/loading.vue
@@ -0,0 +1,92 @@
+<template>
+<div class="yxspomdl" :class="{ inline, colored, mini }">
+ <div class="ring"></div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ colored: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ mini: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes ring {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.yxspomdl {
+ padding: 32px;
+ text-align: center;
+ cursor: wait;
+
+ --size: 48px;
+
+ &.colored {
+ color: var(--accent);
+ }
+
+ &.inline {
+ display: inline;
+ padding: 0;
+ --size: 32px;
+ }
+
+ &.mini {
+ padding: 16px;
+ --size: 32px;
+ }
+
+ > .ring {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+
+ &:before,
+ &:after {
+ content: " ";
+ display: block;
+ box-sizing: border-box;
+ width: var(--size);
+ height: var(--size);
+ border-radius: 50%;
+ border: solid 4px;
+ }
+
+ &:before {
+ border-color: currentColor;
+ opacity: 0.3;
+ }
+
+ &:after {
+ position: absolute;
+ top: 0;
+ border-color: currentColor transparent transparent transparent;
+ animation: ring 0.5s linear infinite;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/misskey-flavored-markdown.vue b/packages/client/src/components/global/misskey-flavored-markdown.vue
new file mode 100644
index 0000000000..ab20404909
--- /dev/null
+++ b/packages/client/src/components/global/misskey-flavored-markdown.vue
@@ -0,0 +1,157 @@
+<template>
+<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MfmCore from '@/components/mfm';
+
+export default defineComponent({
+ components: {
+ MfmCore
+ }
+});
+</script>
+
+<style lang="scss">
+._mfm_blur_ {
+ filter: blur(6px);
+ transition: filter 0.3s;
+
+ &:hover {
+ filter: blur(0px);
+ }
+}
+
+@keyframes mfm-spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+@keyframes mfm-spinX {
+ 0% { transform: perspective(128px) rotateX(0deg); }
+ 100% { transform: perspective(128px) rotateX(360deg); }
+}
+
+@keyframes mfm-spinY {
+ 0% { transform: perspective(128px) rotateY(0deg); }
+ 100% { transform: perspective(128px) rotateY(360deg); }
+}
+
+@keyframes mfm-jump {
+ 0% { transform: translateY(0); }
+ 25% { transform: translateY(-16px); }
+ 50% { transform: translateY(0); }
+ 75% { transform: translateY(-8px); }
+ 100% { transform: translateY(0); }
+}
+
+@keyframes mfm-bounce {
+ 0% { transform: translateY(0) scale(1, 1); }
+ 25% { transform: translateY(-16px) scale(1, 1); }
+ 50% { transform: translateY(0) scale(1, 1); }
+ 75% { transform: translateY(0) scale(1.5, 0.75); }
+ 100% { transform: translateY(0) scale(1, 1); }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 20) - 10}px, ${Math.floor(Math.random() * 20) - 10}px)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-twitch {
+ 0% { transform: translate(7px, -2px) }
+ 5% { transform: translate(-3px, 1px) }
+ 10% { transform: translate(-7px, -1px) }
+ 15% { transform: translate(0px, -1px) }
+ 20% { transform: translate(-8px, 6px) }
+ 25% { transform: translate(-4px, -3px) }
+ 30% { transform: translate(-4px, -6px) }
+ 35% { transform: translate(-8px, -8px) }
+ 40% { transform: translate(4px, 6px) }
+ 45% { transform: translate(-3px, 1px) }
+ 50% { transform: translate(2px, -10px) }
+ 55% { transform: translate(-7px, 0px) }
+ 60% { transform: translate(-2px, 4px) }
+ 65% { transform: translate(3px, -8px) }
+ 70% { transform: translate(6px, 7px) }
+ 75% { transform: translate(-7px, -2px) }
+ 80% { transform: translate(-7px, -8px) }
+ 85% { transform: translate(9px, 3px) }
+ 90% { transform: translate(-3px, -2px) }
+ 95% { transform: translate(-10px, 2px) }
+ 100% { transform: translate(-2px, -6px) }
+}
+
+// const val = () => `translate(${Math.floor(Math.random() * 6) - 3}px, ${Math.floor(Math.random() * 6) - 3}px) rotate(${Math.floor(Math.random() * 24) - 12}deg)`;
+// let css = '';
+// for (let i = 0; i <= 100; i += 5) { css += `${i}% { transform: ${val()} }\n`; }
+@keyframes mfm-shake {
+ 0% { transform: translate(-3px, -1px) rotate(-8deg) }
+ 5% { transform: translate(0px, -1px) rotate(-10deg) }
+ 10% { transform: translate(1px, -3px) rotate(0deg) }
+ 15% { transform: translate(1px, 1px) rotate(11deg) }
+ 20% { transform: translate(-2px, 1px) rotate(1deg) }
+ 25% { transform: translate(-1px, -2px) rotate(-2deg) }
+ 30% { transform: translate(-1px, 2px) rotate(-3deg) }
+ 35% { transform: translate(2px, 1px) rotate(6deg) }
+ 40% { transform: translate(-2px, -3px) rotate(-9deg) }
+ 45% { transform: translate(0px, -1px) rotate(-12deg) }
+ 50% { transform: translate(1px, 2px) rotate(10deg) }
+ 55% { transform: translate(0px, -3px) rotate(8deg) }
+ 60% { transform: translate(1px, -1px) rotate(8deg) }
+ 65% { transform: translate(0px, -1px) rotate(-7deg) }
+ 70% { transform: translate(-1px, -3px) rotate(6deg) }
+ 75% { transform: translate(0px, -2px) rotate(4deg) }
+ 80% { transform: translate(-2px, -1px) rotate(3deg) }
+ 85% { transform: translate(1px, -3px) rotate(-10deg) }
+ 90% { transform: translate(1px, 0px) rotate(3deg) }
+ 95% { transform: translate(-2px, 0px) rotate(-3deg) }
+ 100% { transform: translate(2px, 1px) rotate(2deg) }
+}
+
+@keyframes mfm-rubberBand {
+ from { transform: scale3d(1, 1, 1); }
+ 30% { transform: scale3d(1.25, 0.75, 1); }
+ 40% { transform: scale3d(0.75, 1.25, 1); }
+ 50% { transform: scale3d(1.15, 0.85, 1); }
+ 65% { transform: scale3d(0.95, 1.05, 1); }
+ 75% { transform: scale3d(1.05, 0.95, 1); }
+ to { transform: scale3d(1, 1, 1); }
+}
+
+@keyframes mfm-rainbow {
+ 0% { filter: hue-rotate(0deg) contrast(150%) saturate(150%); }
+ 100% { filter: hue-rotate(360deg) contrast(150%) saturate(150%); }
+}
+</style>
+
+<style lang="scss" scoped>
+.havbbuyv {
+ white-space: pre-wrap;
+
+ &.nowrap {
+ white-space: pre;
+ word-wrap: normal; // https://codeday.me/jp/qa/20190424/690106.html
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ ::v-deep(.quote) {
+ display: block;
+ margin: 8px;
+ padding: 6px 0 6px 12px;
+ color: var(--fg);
+ border-left: solid 3px var(--fg);
+ opacity: 0.7;
+ }
+
+ ::v-deep(pre) {
+ font-size: 0.8em;
+ }
+
+ > ::v-deep(code) {
+ font-size: 0.8em;
+ word-break: break-all;
+ padding: 4px 6px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue
new file mode 100644
index 0000000000..1129d54c71
--- /dev/null
+++ b/packages/client/src/components/global/spacer.vue
@@ -0,0 +1,76 @@
+<template>
+<div ref="root" :class="$style.root" :style="{ padding: margin + 'px' }">
+ <div ref="content" :class="$style.content">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+
+export default defineComponent({
+ props: {
+ contentMax: {
+ type: Number,
+ required: false,
+ default: null,
+ }
+ },
+
+ setup(props, context) {
+ let ro: ResizeObserver;
+ const root = ref<HTMLElement>(null);
+ const content = ref<HTMLElement>(null);
+ const margin = ref(0);
+ const adjust = (rect: { width: number; height: number; }) => {
+ if (rect.width > (props.contentMax || 500)) {
+ margin.value = 32;
+ } else {
+ margin.value = 12;
+ }
+ };
+
+ onMounted(() => {
+ ro = new ResizeObserver((entries) => {
+ /* iOSが対応していない
+ adjust({
+ width: entries[0].borderBoxSize[0].inlineSize,
+ height: entries[0].borderBoxSize[0].blockSize,
+ });
+ */
+ adjust({
+ width: root.value.offsetWidth,
+ height: root.value.offsetHeight,
+ });
+ });
+ ro.observe(root.value);
+
+ if (props.contentMax) {
+ content.value.style.maxWidth = `${props.contentMax}px`;
+ }
+ });
+
+ onUnmounted(() => {
+ ro.disconnect();
+ });
+
+ return {
+ root,
+ content,
+ margin,
+ };
+ },
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ box-sizing: border-box;
+ width: 100%;
+}
+
+.content {
+ margin: 0 auto;
+}
+</style>
diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue
new file mode 100644
index 0000000000..859b2c1d73
--- /dev/null
+++ b/packages/client/src/components/global/sticky-container.vue
@@ -0,0 +1,74 @@
+<template>
+<div ref="rootEl">
+ <slot name="header"></slot>
+ <div ref="bodyEl">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+
+export default defineComponent({
+ props: {
+ autoSticky: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ setup(props, context) {
+ const rootEl = ref<HTMLElement>(null);
+ const bodyEl = ref<HTMLElement>(null);
+
+ const calc = () => {
+ const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px';
+
+ const header = rootEl.value.children[0];
+ if (header === bodyEl.value) {
+ bodyEl.value.style.setProperty('--stickyTop', currentStickyTop);
+ } else {
+ bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
+
+ if (props.autoSticky) {
+ header.style.setProperty('--stickyTop', currentStickyTop);
+ header.style.position = 'sticky';
+ header.style.top = 'var(--stickyTop)';
+ header.style.zIndex = '1';
+ }
+ }
+ };
+
+ onMounted(() => {
+ calc();
+
+ const observer = new MutationObserver(() => {
+ setTimeout(() => {
+ calc();
+ }, 100);
+ });
+
+ observer.observe(rootEl.value, {
+ attributes: false,
+ childList: true,
+ subtree: false,
+ });
+
+ onUnmounted(() => {
+ observer.disconnect();
+ });
+ });
+
+ return {
+ rootEl,
+ bodyEl,
+ };
+ },
+});
+</script>
+
+<style lang="scss" module>
+
+</style>
diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue
new file mode 100644
index 0000000000..6a330a2307
--- /dev/null
+++ b/packages/client/src/components/global/time.vue
@@ -0,0 +1,73 @@
+<template>
+<time :title="absolute">
+ <template v-if="mode == 'relative'">{{ relative }}</template>
+ <template v-else-if="mode == 'absolute'">{{ absolute }}</template>
+ <template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template>
+</time>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ time: {
+ type: [Date, String],
+ required: true
+ },
+ mode: {
+ type: String,
+ default: 'relative'
+ }
+ },
+ data() {
+ return {
+ tickId: null,
+ now: new Date()
+ };
+ },
+ computed: {
+ _time(): Date {
+ return typeof this.time == 'string' ? new Date(this.time) : this.time;
+ },
+ absolute(): string {
+ return this._time.toLocaleString();
+ },
+ relative(): string {
+ const time = this._time;
+ const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
+ return (
+ ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
+ ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
+ ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
+ ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
+ ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
+ ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
+ ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
+ ago >= -1 ? this.$ts._ago.justNow :
+ ago < -1 ? this.$ts._ago.future :
+ this.$ts._ago.unknown);
+ }
+ },
+ created() {
+ if (this.mode == 'relative' || this.mode == 'detail') {
+ this.tickId = window.requestAnimationFrame(this.tick);
+ }
+ },
+ unmounted() {
+ if (this.mode === 'relative' || this.mode === 'detail') {
+ window.clearTimeout(this.tickId);
+ }
+ },
+ methods: {
+ tick() {
+ // TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
+ this.now = new Date();
+
+ this.tickId = setTimeout(() => {
+ window.requestAnimationFrame(this.tick);
+ }, 10000);
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/global/url.vue b/packages/client/src/components/global/url.vue
new file mode 100644
index 0000000000..092fe6620c
--- /dev/null
+++ b/packages/client/src/components/global/url.vue
@@ -0,0 +1,142 @@
+<template>
+<component :is="self ? 'MkA' : 'a'" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
+ @mouseover="onMouseover"
+ @mouseleave="onMouseleave"
+ @contextmenu.stop="() => {}"
+>
+ <template v-if="!self">
+ <span class="schema">{{ schema }}//</span>
+ <span class="hostname">{{ hostname }}</span>
+ <span class="port" v-if="port != ''">:{{ port }}</span>
+ </template>
+ <template v-if="pathname === '/' && self">
+ <span class="self">{{ hostname }}</span>
+ </template>
+ <span class="pathname" v-if="pathname != ''">{{ self ? pathname.substr(1) : pathname }}</span>
+ <span class="query">{{ query }}</span>
+ <span class="hash">{{ hash }}</span>
+ <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
+</component>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode as decodePunycode } from 'punycode/';
+import { url as local } from '@/config';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ rel: {
+ type: String,
+ required: false,
+ }
+ },
+ data() {
+ const self = this.url.startsWith(local);
+ return {
+ local,
+ schema: null as string | null,
+ hostname: null as string | null,
+ port: null as string | null,
+ pathname: null as string | null,
+ query: null as string | null,
+ hash: null as string | null,
+ self: self,
+ attr: self ? 'to' : 'href',
+ target: self ? null : '_blank',
+ showTimer: null,
+ hideTimer: null,
+ checkTimer: null,
+ close: null,
+ };
+ },
+ created() {
+ const url = new URL(this.url);
+ this.schema = url.protocol;
+ this.hostname = decodePunycode(url.hostname);
+ this.port = url.port;
+ this.pathname = decodeURIComponent(url.pathname);
+ this.query = decodeURIComponent(url.search);
+ this.hash = decodeURIComponent(url.hash);
+ },
+ methods: {
+ async showPreview() {
+ if (!document.body.contains(this.$el)) return;
+ if (this.close) return;
+
+ const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
+ url: this.url,
+ source: this.$el
+ });
+
+ this.close = () => {
+ dispose();
+ };
+
+ this.checkTimer = setInterval(() => {
+ if (!document.body.contains(this.$el)) this.closePreview();
+ }, 1000);
+ },
+ closePreview() {
+ if (this.close) {
+ clearInterval(this.checkTimer);
+ this.close();
+ this.close = null;
+ }
+ },
+ onMouseover() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.showTimer = setTimeout(this.showPreview, 500);
+ },
+ onMouseleave() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.hideTimer = setTimeout(this.closePreview, 500);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ieqqeuvs {
+ word-break: break-all;
+
+ > .icon {
+ padding-left: 2px;
+ font-size: .9em;
+ }
+
+ > .self {
+ font-weight: bold;
+ }
+
+ > .schema {
+ opacity: 0.5;
+ }
+
+ > .hostname {
+ font-weight: bold;
+ }
+
+ > .pathname {
+ opacity: 0.8;
+ }
+
+ > .query {
+ opacity: 0.5;
+ }
+
+ > .hash {
+ font-style: italic;
+ }
+}
+</style>
diff --git a/packages/client/src/components/global/user-name.vue b/packages/client/src/components/global/user-name.vue
new file mode 100644
index 0000000000..bc93a8ea30
--- /dev/null
+++ b/packages/client/src/components/global/user-name.vue
@@ -0,0 +1,20 @@
+<template>
+<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ nowrap: {
+ type: Boolean,
+ default: true
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/components/google.vue b/packages/client/src/components/google.vue
new file mode 100644
index 0000000000..c48feffbf1
--- /dev/null
+++ b/packages/client/src/components/google.vue
@@ -0,0 +1,64 @@
+<template>
+<div class="mk-google">
+ <input type="search" v-model="query" :placeholder="q">
+ <button @click="search"><i class="fas fa-search"></i> {{ $ts.search }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ q: {
+ type: String,
+ required: true,
+ }
+ },
+ data() {
+ return {
+ query: null,
+ };
+ },
+ mounted() {
+ this.query = this.q;
+ },
+ methods: {
+ search() {
+ window.open(`https://www.google.com/search?q=${this.query}`, '_blank');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-google {
+ display: flex;
+ margin: 8px 0;
+
+ > input {
+ flex-shrink: 1;
+ padding: 10px;
+ width: 100%;
+ height: 40px;
+ font-size: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 4px 0 0 4px;
+ -webkit-appearance: textfield;
+ }
+
+ > button {
+ flex-shrink: 0;
+ margin: 0;
+ padding: 0 16px;
+ border: solid 1px var(--divider);
+ border-left: none;
+ border-radius: 0 4px 4px 0;
+
+ &:active {
+ box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/image-viewer.vue b/packages/client/src/components/image-viewer.vue
new file mode 100644
index 0000000000..fc28c30b56
--- /dev/null
+++ b/packages/client/src/components/image-viewer.vue
@@ -0,0 +1,85 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="xubzgfga">
+ <header>{{ image.name }}</header>
+ <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
+ <footer>
+ <span>{{ image.type }}</span>
+ <span>{{ bytes(image.size) }}</span>
+ <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
+ </footer>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkModal from '@/components/ui/modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ props: {
+ image: {
+ type: Object,
+ required: true
+ },
+ },
+
+ emits: ['closed'],
+
+ methods: {
+ bytes,
+ number,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xubzgfga {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > header,
+ > footer {
+ align-self: center;
+ display: inline-block;
+ padding: 6px 9px;
+ font-size: 90%;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 6px;
+ color: #fff;
+ }
+
+ > header {
+ margin-bottom: 8px;
+ opacity: 0.9;
+ }
+
+ > img {
+ display: block;
+ flex: 1;
+ min-height: 0;
+ object-fit: contain;
+ width: 100%;
+ cursor: zoom-out;
+ image-orientation: from-image;
+ }
+
+ > footer {
+ margin-top: 8px;
+ opacity: 0.8;
+
+ > span + span {
+ margin-left: 0.5em;
+ padding-left: 0.5em;
+ border-left: solid 1px rgba(255, 255, 255, 0.5);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue
new file mode 100644
index 0000000000..7e80b00208
--- /dev/null
+++ b/packages/client/src/components/img-with-blurhash.vue
@@ -0,0 +1,100 @@
+<template>
+<div class="xubzgfgb" :class="{ cover }" :title="title">
+ <canvas ref="canvas" :width="size" :height="size" :title="title" v-if="!loaded"/>
+ <img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { decode } from 'blurhash';
+
+export default defineComponent({
+ props: {
+ src: {
+ type: String,
+ required: false,
+ default: null
+ },
+ hash: {
+ type: String,
+ required: true
+ },
+ alt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ title: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ size: {
+ type: Number,
+ required: false,
+ default: 64
+ },
+ cover: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }
+ },
+
+ data() {
+ return {
+ loaded: false,
+ };
+ },
+
+ mounted() {
+ this.draw();
+ },
+
+ methods: {
+ draw() {
+ if (this.hash == null) return;
+ const pixels = decode(this.hash, this.size, this.size);
+ const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d');
+ const imageData = ctx!.createImageData(this.size, this.size);
+ imageData.data.set(pixels);
+ ctx!.putImageData(imageData, 0, 0);
+ },
+
+ onLoad() {
+ this.loaded = true;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xubzgfgb {
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ > canvas,
+ > img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ > canvas {
+ position: absolute;
+ object-fit: cover;
+ }
+
+ > img {
+ object-fit: contain;
+ }
+
+ &.cover {
+ > img {
+ object-fit: cover;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts
new file mode 100644
index 0000000000..2340b228f8
--- /dev/null
+++ b/packages/client/src/components/index.ts
@@ -0,0 +1,37 @@
+import { App } from 'vue';
+
+import mfm from './global/misskey-flavored-markdown.vue';
+import a from './global/a.vue';
+import acct from './global/acct.vue';
+import avatar from './global/avatar.vue';
+import emoji from './global/emoji.vue';
+import userName from './global/user-name.vue';
+import ellipsis from './global/ellipsis.vue';
+import time from './global/time.vue';
+import url from './global/url.vue';
+import i18n from './global/i18n';
+import loading from './global/loading.vue';
+import error from './global/error.vue';
+import ad from './global/ad.vue';
+import header from './global/header.vue';
+import spacer from './global/spacer.vue';
+import stickyContainer from './global/sticky-container.vue';
+
+export default function(app: App) {
+ app.component('I18n', i18n);
+ app.component('Mfm', mfm);
+ app.component('MkA', a);
+ app.component('MkAcct', acct);
+ app.component('MkAvatar', avatar);
+ app.component('MkEmoji', emoji);
+ app.component('MkUserName', userName);
+ app.component('MkEllipsis', ellipsis);
+ app.component('MkTime', time);
+ app.component('MkUrl', url);
+ app.component('MkLoading', loading);
+ app.component('MkError', error);
+ app.component('MkAd', ad);
+ app.component('MkHeader', header);
+ app.component('MkSpacer', spacer);
+ app.component('MkStickyContainer', stickyContainer);
+}
diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue
new file mode 100644
index 0000000000..bc62998a4a
--- /dev/null
+++ b/packages/client/src/components/instance-stats.vue
@@ -0,0 +1,80 @@
+<template>
+<div class="zbcjwnqg" style="margin-top: -8px;">
+ <div class="selects" style="display: flex;">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <optgroup :label="$ts.federation">
+ <option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
+ <option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
+ </optgroup>
+ <optgroup :label="$ts.users">
+ <option value="users">{{ $ts._charts.usersIncDec }}</option>
+ <option value="users-total">{{ $ts._charts.usersTotal }}</option>
+ <option value="active-users">{{ $ts._charts.activeUsers }}</option>
+ </optgroup>
+ <optgroup :label="$ts.notes">
+ <option value="notes">{{ $ts._charts.notesIncDec }}</option>
+ <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option>
+ <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option>
+ <option value="notes-total">{{ $ts._charts.notesTotal }}</option>
+ </optgroup>
+ <optgroup :label="$ts.drive">
+ <option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
+ <option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
+ <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
+ <option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
+ </optgroup>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, ref, watch } from 'vue';
+import MkSelect from '@/components/form/select.vue';
+import MkChart from '@/components/chart.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ MkSelect,
+ MkChart,
+ },
+
+ props: {
+ chartLimit: {
+ type: Number,
+ required: false,
+ default: 90
+ },
+ detailed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ setup() {
+ const chartSpan = ref<'hour' | 'day'>('hour');
+ const chartSrc = ref('notes');
+
+ return {
+ chartSrc,
+ chartSpan,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.zbcjwnqg {
+ > .selects {
+ padding: 8px 16px 0 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/instance-ticker.vue b/packages/client/src/components/instance-ticker.vue
new file mode 100644
index 0000000000..1ce5a1c2c1
--- /dev/null
+++ b/packages/client/src/components/instance-ticker.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="hpaizdrt" :style="bg">
+ <img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/>
+ <span class="name">{{ info.name }}</span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { instanceName } from '@/config';
+
+export default defineComponent({
+ props: {
+ instance: {
+ type: Object,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ info: this.instance || {
+ faviconUrl: '/favicon.ico',
+ name: instanceName,
+ themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
+ }
+ }
+ },
+
+ computed: {
+ bg(): any {
+ const themeColor = this.info.themeColor || '#777777';
+ return {
+ background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
+ };
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hpaizdrt {
+ $height: 1.1rem;
+
+ height: $height;
+ border-radius: 4px 0 0 4px;
+ overflow: hidden;
+ color: #fff;
+
+ > .icon {
+ height: 100%;
+ }
+
+ > .name {
+ margin-left: 4px;
+ line-height: $height;
+ font-size: 0.9em;
+ vertical-align: top;
+ font-weight: bold;
+ }
+}
+</style>
diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue
new file mode 100644
index 0000000000..09f5f89f90
--- /dev/null
+++ b/packages/client/src/components/launch-pad.vue
@@ -0,0 +1,152 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="szkkfdyq _popup">
+ <div class="main">
+ <template v-for="item in items">
+ <button v-if="item.action" class="_button" @click="$event => { item.action($event); close(); }" v-click-anime>
+ <i class="icon" :class="item.icon"></i>
+ <div class="text">{{ item.text }}</div>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ <MkA v-else :to="item.to" @click.passive="close()" v-click-anime>
+ <i class="icon" :class="item.icon"></i>
+ <div class="text">{{ item.text }}</div>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </MkA>
+ </template>
+ </div>
+ <div class="sub">
+ <a href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()" v-click-anime>
+ <i class="fas fa-question-circle icon"></i>
+ <div class="text">{{ $ts.help }}</div>
+ </a>
+ <MkA to="/about" @click.passive="close()" v-click-anime>
+ <i class="fas fa-info-circle icon"></i>
+ <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div>
+ </MkA>
+ <MkA to="/about-misskey" @click.passive="close()" v-click-anime>
+ <img src="/static-assets/favicon.png" class="icon"/>
+ <div class="text">{{ $ts.aboutMisskey }}</div>
+ </MkA>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import { menuDef } from '@/menu';
+import { instanceName } from '@/config';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ menuDef: menuDef,
+ items: [],
+ instanceName,
+ };
+ },
+
+ computed: {
+ menu(): string[] {
+ return this.$store.state.menu;
+ },
+ },
+
+ created() {
+ this.items = Object.keys(this.menuDef).filter(k => !this.menu.includes(k)).map(k => this.menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
+ type: def.to ? 'link' : 'button',
+ text: this.$ts[def.title],
+ icon: def.icon,
+ to: def.to,
+ action: def.action,
+ indicate: def.indicated,
+ }));
+ },
+
+ methods: {
+ close() {
+ this.$refs.modal.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.szkkfdyq {
+ width: 100%;
+ max-height: 100%;
+ max-width: 800px;
+ padding: 32px;
+ box-sizing: border-box;
+ overflow: auto;
+ text-align: center;
+ border-radius: 16px;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ }
+
+ > .main, > .sub {
+ > * {
+ position: relative;
+ display: inline-flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ vertical-align: bottom;
+ width: 128px;
+ height: 128px;
+ border-radius: var(--radius);
+
+ @media (max-width: 500px) {
+ width: 100px;
+ height: 100px;
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ text-decoration: none;
+ }
+
+ > .icon {
+ font-size: 26px;
+ height: 32px;
+ }
+
+ > .text {
+ margin-top: 8px;
+ font-size: 0.9em;
+ line-height: 1.5em;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 32px;
+ left: 32px;
+ color: var(--indicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+
+ @media (max-width: 500px) {
+ top: 16px;
+ left: 16px;
+ }
+ }
+ }
+ }
+
+ > .sub {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/components/link.vue b/packages/client/src/components/link.vue
new file mode 100644
index 0000000000..a8e096e0a0
--- /dev/null
+++ b/packages/client/src/components/link.vue
@@ -0,0 +1,92 @@
+<template>
+<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
+ @mouseover="onMouseover"
+ @mouseleave="onMouseleave"
+ :title="url"
+>
+ <slot></slot>
+ <i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
+</component>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { url as local } from '@/config';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ rel: {
+ type: String,
+ required: false,
+ }
+ },
+ data() {
+ const self = this.url.startsWith(local);
+ return {
+ local,
+ self: self,
+ attr: self ? 'to' : 'href',
+ target: self ? null : '_blank',
+ showTimer: null,
+ hideTimer: null,
+ checkTimer: null,
+ close: null,
+ };
+ },
+ methods: {
+ async showPreview() {
+ if (!document.body.contains(this.$el)) return;
+ if (this.close) return;
+
+ const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
+ url: this.url,
+ source: this.$el
+ });
+
+ this.close = () => {
+ dispose();
+ };
+
+ this.checkTimer = setInterval(() => {
+ if (!document.body.contains(this.$el)) this.closePreview();
+ }, 1000);
+ },
+ closePreview() {
+ if (this.close) {
+ clearInterval(this.checkTimer);
+ this.close();
+ this.close = null;
+ }
+ },
+ onMouseover() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.showTimer = setTimeout(this.showPreview, 500);
+ },
+ onMouseleave() {
+ if (isDeviceTouch) return;
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.hideTimer = setTimeout(this.closePreview, 500);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xlcxczvw {
+ word-break: break-all;
+
+ > .icon {
+ padding-left: 2px;
+ font-size: .9em;
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-banner.vue b/packages/client/src/components/media-banner.vue
new file mode 100644
index 0000000000..2cf8c772e5
--- /dev/null
+++ b/packages/client/src/components/media-banner.vue
@@ -0,0 +1,107 @@
+<template>
+<div class="mk-media-banner">
+ <div class="sensitive" v-if="media.isSensitive && hide" @click="hide = false">
+ <span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
+ <b>{{ $ts.sensitive }}</b>
+ <span>{{ $ts.clickToShow }}</span>
+ </div>
+ <div class="audio" v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'">
+ <audio class="audio"
+ :src="media.url"
+ :title="media.name"
+ controls
+ ref="audio"
+ @volumechange="volumechange"
+ preload="metadata" />
+ </div>
+ <a class="download" v-else
+ :href="media.url"
+ :title="media.name"
+ :download="media.name"
+ >
+ <span class="icon"><i class="fas fa-download"></i></span>
+ <b>{{ media.name }}</b>
+ </a>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ props: {
+ media: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ hide: true,
+ };
+ },
+ mounted() {
+ const audioTag = this.$refs.audio as HTMLAudioElement;
+ if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
+ },
+ methods: {
+ volumechange() {
+ const audioTag = this.$refs.audio as HTMLAudioElement;
+ ColdDeviceStorage.set('mediaVolume', audioTag.volume);
+ },
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.mk-media-banner {
+ width: 100%;
+ border-radius: 4px;
+ margin-top: 4px;
+ overflow: hidden;
+
+ > .download,
+ > .sensitive {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ padding: 8px 12px;
+ white-space: nowrap;
+
+ > * {
+ display: block;
+ }
+
+ > b {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > *:not(:last-child) {
+ margin-right: .2em;
+ }
+
+ > .icon {
+ font-size: 1.6em;
+ }
+ }
+
+ > .download {
+ background: var(--noteAttachedFile);
+ }
+
+ > .sensitive {
+ background: #111;
+ color: #fff;
+ }
+
+ > .audio {
+ .audio {
+ display: block;
+ width: 100%;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-caption.vue b/packages/client/src/components/media-caption.vue
new file mode 100644
index 0000000000..08a3ca2b4c
--- /dev/null
+++ b/packages/client/src/components/media-caption.vue
@@ -0,0 +1,259 @@
+<template>
+ <MkModal ref="modal" @click="done(true)" @closed="$emit('closed')">
+ <div class="container">
+ <div class="fullwidth top-caption">
+ <div class="mk-dialog">
+ <header>
+ <Mfm v-if="title" class="title" :text="title"/>
+ <span class="text-count" :class="{ over: remainingLength < 0 }">{{ remainingLength }}</span>
+ </header>
+ <textarea autofocus v-model="inputValue" :placeholder="input.placeholder" @keydown="onInputKeydown"></textarea>
+ <div class="buttons" v-if="(showOkButton || showCancelButton)">
+ <MkButton inline @click="ok" primary :disabled="remainingLength < 0">{{ $ts.ok }}</MkButton>
+ <MkButton inline @click="cancel" >{{ $ts.cancel }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div class="hdrwpsaf fullwidth">
+ <header>{{ image.name }}</header>
+ <img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
+ <footer>
+ <span>{{ image.type }}</span>
+ <span>{{ bytes(image.size) }}</span>
+ <span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
+ </footer>
+ </div>
+ </div>
+ </MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { length } from 'stringz';
+import MkModal from '@/components/ui/modal.vue';
+import MkButton from '@/components/ui/button.vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkButton,
+ },
+
+ props: {
+ image: {
+ type: Object,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: false
+ },
+ input: {
+ required: true
+ },
+ showOkButton: {
+ type: Boolean,
+ default: true
+ },
+ showCancelButton: {
+ type: Boolean,
+ default: true
+ },
+ cancelableByBgClick: {
+ type: Boolean,
+ default: true
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ inputValue: this.input.default ? this.input.default : null
+ };
+ },
+
+ computed: {
+ remainingLength(): number {
+ if (typeof this.inputValue != "string") return 512;
+ return 512 - length(this.inputValue);
+ }
+ },
+
+ mounted() {
+ document.addEventListener('keydown', this.onKeydown);
+ },
+
+ beforeUnmount() {
+ document.removeEventListener('keydown', this.onKeydown);
+ },
+
+ methods: {
+ bytes,
+ number,
+
+ done(canceled, result?) {
+ this.$emit('done', { canceled, result });
+ this.$refs.modal.close();
+ },
+
+ async ok() {
+ if (!this.showOkButton) return;
+
+ const result = this.inputValue;
+ this.done(false, result);
+ },
+
+ cancel() {
+ this.done(true);
+ },
+
+ onBgClick() {
+ if (this.cancelableByBgClick) {
+ this.cancel();
+ }
+ },
+
+ onKeydown(e) {
+ if (e.which === 27) { // ESC
+ this.cancel();
+ }
+ },
+
+ onInputKeydown(e) {
+ if (e.which === 13) { // Enter
+ if (e.ctrlKey) {
+ e.preventDefault();
+ e.stopPropagation();
+ this.ok();
+ }
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.container {
+ display: flex;
+ width: 100%;
+ height: 100%;
+ flex-direction: row;
+}
+@media (max-width: 850px) {
+ .container {
+ flex-direction: column;
+ }
+ .top-caption {
+ padding-bottom: 8px;
+ }
+}
+.fullwidth {
+ width: 100%;
+ margin: auto;
+}
+.mk-dialog {
+ position: relative;
+ padding: 32px;
+ min-width: 320px;
+ max-width: 480px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+ margin: auto;
+
+ > header {
+ margin: 0 0 8px 0;
+ position: relative;
+
+ > .title {
+ font-weight: bold;
+ font-size: 20px;
+ }
+
+ > .text-count {
+ opacity: 0.7;
+ position: absolute;
+ right: 0;
+ }
+ }
+
+ > .buttons {
+ margin-top: 16px;
+
+ > * {
+ margin: 0 8px;
+ }
+ }
+
+ > textarea {
+ display: block;
+ box-sizing: border-box;
+ padding: 0 24px;
+ margin: 0;
+ width: 100%;
+ font-size: 16px;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--fg);
+ font-family: inherit;
+ max-width: 100%;
+ min-width: 100%;
+ min-height: 90px;
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ }
+ }
+}
+.hdrwpsaf {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > header,
+ > footer {
+ align-self: center;
+ display: inline-block;
+ padding: 6px 9px;
+ font-size: 90%;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 6px;
+ color: #fff;
+ }
+
+ > header {
+ margin-bottom: 8px;
+ opacity: 0.9;
+ }
+
+ > img {
+ display: block;
+ flex: 1;
+ min-height: 0;
+ object-fit: contain;
+ width: 100%;
+ cursor: zoom-out;
+ image-orientation: from-image;
+ }
+
+ > footer {
+ margin-top: 8px;
+ opacity: 0.8;
+
+ > span + span {
+ margin-left: 0.5em;
+ padding-left: 0.5em;
+ border-left: solid 1px rgba(255, 255, 255, 0.5);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/media-image.vue
new file mode 100644
index 0000000000..8843b63207
--- /dev/null
+++ b/packages/client/src/components/media-image.vue
@@ -0,0 +1,155 @@
+<template>
+<div class="qjewsnkg" v-if="hide" @click="hide = false">
+ <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
+ <div class="text">
+ <div>
+ <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
+ <span>{{ $ts.clickToShow }}</span>
+ </div>
+ </div>
+</div>
+<div class="gqnyydlz" :style="{ background: color }" v-else>
+ <a
+ :href="image.url"
+ :title="image.name"
+ >
+ <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
+ <div class="gif" v-if="image.type === 'image/gif'">GIF</div>
+ </a>
+ <i class="fas fa-eye-slash" @click="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
+import ImageViewer from './image-viewer.vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ ImgWithBlurhash
+ },
+ props: {
+ image: {
+ type: Object,
+ required: true
+ },
+ raw: {
+ default: false
+ }
+ },
+ data() {
+ return {
+ hide: true,
+ color: null,
+ };
+ },
+ computed: {
+ url(): any {
+ let url = this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(this.image.thumbnailUrl)
+ : this.image.thumbnailUrl;
+
+ if (this.raw || this.$store.state.loadRawImages) {
+ url = this.image.url;
+ }
+
+ return url;
+ }
+ },
+ created() {
+ // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
+ this.$watch('image', () => {
+ this.hide = (this.$store.state.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.nsfw !== 'ignore');
+ if (this.image.blurhash) {
+ this.color = extractAvgColorFromBlurhash(this.image.blurhash);
+ }
+ }, {
+ deep: true,
+ immediate: true,
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.qjewsnkg {
+ position: relative;
+
+ > .bg {
+ filter: brightness(0.5);
+ }
+
+ > .text {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ > div {
+ display: table-cell;
+ text-align: center;
+ font-size: 0.8em;
+ color: #fff;
+
+ > * {
+ display: block;
+ }
+ }
+ }
+}
+
+.gqnyydlz {
+ position: relative;
+ border: solid 0.5px var(--divider);
+
+ > i {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: var(--fg);
+ color: var(--accentLighten);
+ font-size: 14px;
+ opacity: .5;
+ padding: 3px 6px;
+ text-align: center;
+ cursor: pointer;
+ top: 12px;
+ right: 12px;
+ }
+
+ > a {
+ display: block;
+ cursor: zoom-in;
+ overflow: hidden;
+ width: 100%;
+ height: 100%;
+ background-position: center;
+ background-size: contain;
+ background-repeat: no-repeat;
+
+ > .gif {
+ background-color: var(--fg);
+ border-radius: 6px;
+ color: var(--accentLighten);
+ display: inline-block;
+ font-size: 14px;
+ font-weight: bold;
+ left: 12px;
+ opacity: .5;
+ padding: 0 6px;
+ text-align: center;
+ top: 12px;
+ pointer-events: none;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-list.vue b/packages/client/src/components/media-list.vue
new file mode 100644
index 0000000000..51eaa86f35
--- /dev/null
+++ b/packages/client/src/components/media-list.vue
@@ -0,0 +1,167 @@
+<template>
+<div class="hoawjimk">
+ <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :media="media" :key="media.id"/>
+ <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
+ <div :data-count="mediaList.filter(media => previewable(media)).length" ref="gallery">
+ <template v-for="media in mediaList">
+ <XVideo :video="media" :key="media.id" v-if="media.type.startsWith('video')"/>
+ <XImage class="image" :data-id="media.id" :image="media" :key="media.id" v-else-if="media.type.startsWith('image')" :raw="raw"/>
+ </template>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, ref } from 'vue';
+import * as misskey from 'misskey-js';
+import PhotoSwipeLightbox from 'photoswipe/dist/photoswipe-lightbox.esm.js';
+import PhotoSwipe from 'photoswipe/dist/photoswipe.esm.js';
+import 'photoswipe/dist/photoswipe.css';
+import XBanner from './media-banner.vue';
+import XImage from './media-image.vue';
+import XVideo from './media-video.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ XBanner,
+ XImage,
+ XVideo,
+ },
+ props: {
+ mediaList: {
+ type: Array as PropType<misskey.entities.DriveFile[]>,
+ required: true,
+ },
+ raw: {
+ default: false
+ },
+ },
+ setup(props) {
+ const gallery = ref(null);
+
+ onMounted(() => {
+ const lightbox = new PhotoSwipeLightbox({
+ dataSource: props.mediaList.filter(media => media.type.startsWith('image')).map(media => ({
+ src: media.url,
+ w: media.properties.width,
+ h: media.properties.height,
+ alt: media.name,
+ })),
+ gallery: gallery.value,
+ children: '.image',
+ thumbSelector: '.image',
+ pswpModule: PhotoSwipe
+ });
+
+ lightbox.on('itemData', (e) => {
+ const { itemData } = e;
+
+ // element is children
+ const { element } = itemData;
+
+ const id = element.dataset.id;
+ const file = props.mediaList.find(media => media.id === id);
+
+ itemData.src = file.url;
+ itemData.w = Number(file.properties.width);
+ itemData.h = Number(file.properties.height);
+ itemData.msrc = file.thumbnailUrl;
+ itemData.thumbCropped = true;
+ });
+
+ lightbox.init();
+ });
+
+ const previewable = (file: misskey.entities.DriveFile): boolean => {
+ return file.type.startsWith('video') || file.type.startsWith('image');
+ };
+
+ return {
+ previewable,
+ gallery,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hoawjimk {
+ > .gird-container {
+ position: relative;
+ width: 100%;
+ margin-top: 4px;
+
+ &:before {
+ content: '';
+ display: block;
+ padding-top: 56.25% // 16:9;
+ }
+
+ > div {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ display: grid;
+ grid-gap: 4px;
+
+ > * {
+ overflow: hidden;
+ border-radius: 6px;
+ }
+
+ &[data-count="1"] {
+ grid-template-rows: 1fr;
+ }
+
+ &[data-count="2"] {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr;
+ }
+
+ &[data-count="3"] {
+ grid-template-columns: 1fr 0.5fr;
+ grid-template-rows: 1fr 1fr;
+
+ > *:nth-child(1) {
+ grid-row: 1 / 3;
+ }
+
+ > *:nth-child(3) {
+ grid-column: 2 / 3;
+ grid-row: 2 / 3;
+ }
+ }
+
+ &[data-count="4"] {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+ }
+
+ > *:nth-child(1) {
+ grid-column: 1 / 2;
+ grid-row: 1 / 2;
+ }
+
+ > *:nth-child(2) {
+ grid-column: 2 / 3;
+ grid-row: 1 / 2;
+ }
+
+ > *:nth-child(3) {
+ grid-column: 1 / 2;
+ grid-row: 2 / 3;
+ }
+
+ > *:nth-child(4) {
+ grid-column: 2 / 3;
+ grid-row: 2 / 3;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/media-video.vue b/packages/client/src/components/media-video.vue
new file mode 100644
index 0000000000..aa885bd564
--- /dev/null
+++ b/packages/client/src/components/media-video.vue
@@ -0,0 +1,97 @@
+<template>
+<div class="icozogqfvdetwohsdglrbswgrejoxbdj" v-if="hide" @click="hide = false">
+ <div>
+ <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
+ <span>{{ $ts.clickToShow }}</span>
+ </div>
+</div>
+<div class="kkjnbbplepmiyuadieoenjgutgcmtsvu" v-else>
+ <video
+ :poster="video.thumbnailUrl"
+ :title="video.name"
+ preload="none"
+ controls
+ @contextmenu.stop
+ >
+ <source
+ :src="video.url"
+ :type="video.type"
+ >
+ </video>
+ <i class="fas fa-eye-slash" @click="hide = true"></i>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ video: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ hide: true,
+ };
+ },
+ created() {
+ this.hide = (this.$store.state.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.nsfw !== 'ignore');
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.kkjnbbplepmiyuadieoenjgutgcmtsvu {
+ position: relative;
+
+ > i {
+ display: block;
+ position: absolute;
+ border-radius: 6px;
+ background-color: var(--fg);
+ color: var(--accentLighten);
+ font-size: 14px;
+ opacity: .5;
+ padding: 3px 6px;
+ text-align: center;
+ cursor: pointer;
+ top: 12px;
+ right: 12px;
+ }
+
+ > video {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ font-size: 3.5em;
+ overflow: hidden;
+ background-position: center;
+ background-size: cover;
+ width: 100%;
+ height: 100%;
+ }
+}
+
+.icozogqfvdetwohsdglrbswgrejoxbdj {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: #111;
+ color: #fff;
+
+ > div {
+ display: table-cell;
+ text-align: center;
+ font-size: 12px;
+
+ > b {
+ display: block;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue
new file mode 100644
index 0000000000..a5be3fab22
--- /dev/null
+++ b/packages/client/src/components/mention.vue
@@ -0,0 +1,84 @@
+<template>
+<MkA v-if="url.startsWith('/')" class="ldlomzub" :class="{ isMe }" :to="url" v-user-preview="canonical" :style="{ background: bg }">
+ <img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
+ <span class="main">
+ <span class="username">@{{ username }}</span>
+ <span class="host" v-if="(host != localHost) || $store.state.showFullAcct">@{{ toUnicode(host) }}</span>
+ </span>
+</MkA>
+<a v-else class="ldlomzub" :href="url" target="_blank" rel="noopener" :style="{ background: bg }">
+ <span class="main">
+ <span class="username">@{{ username }}</span>
+ <span class="host">@{{ toUnicode(host) }}</span>
+ </span>
+</a>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+import { toUnicode } from 'punycode';
+import { host as localHost } from '@/config';
+import { $i } from '@/account';
+
+export default defineComponent({
+ props: {
+ username: {
+ type: String,
+ required: true
+ },
+ host: {
+ type: String,
+ required: true
+ }
+ },
+
+ setup(props) {
+ const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
+
+ const url = `/${canonical}`;
+
+ const isMe = $i && (
+ `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
+ );
+
+ const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
+ bg.setAlpha(0.1);
+
+ return {
+ localHost,
+ isMe,
+ url,
+ canonical,
+ toUnicode,
+ bg: bg.toRgbString(),
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ldlomzub {
+ display: inline-block;
+ padding: 4px 8px 4px 4px;
+ border-radius: 999px;
+ color: var(--mention);
+
+ &.isMe {
+ color: var(--mentionMe);
+ }
+
+ > .icon {
+ width: 1.5em;
+ margin: 0 0.2em 0 0;
+ vertical-align: bottom;
+ border-radius: 100%;
+ }
+
+ > .main {
+ > .host {
+ opacity: 0.5;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts
new file mode 100644
index 0000000000..d41cf6fc2b
--- /dev/null
+++ b/packages/client/src/components/mfm.ts
@@ -0,0 +1,321 @@
+import { VNode, defineComponent, h } from 'vue';
+import * as mfm from 'mfm-js';
+import MkUrl from '@/components/global/url.vue';
+import MkLink from '@/components/link.vue';
+import MkMention from '@/components/mention.vue';
+import MkEmoji from '@/components/global/emoji.vue';
+import { concat } from '@/scripts/array';
+import MkFormula from '@/components/formula.vue';
+import MkCode from '@/components/code.vue';
+import MkGoogle from '@/components/google.vue';
+import MkSparkle from '@/components/sparkle.vue';
+import MkA from '@/components/global/a.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.parsePlain : 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[]) => concat(ast.map((token): VNode[] => {
+ switch (token.type) {
+ case 'text': {
+ const text = token.props.text.replace(/(\r\n|\n|\r)/g, '\n');
+
+ if (!this.plain) {
+ const res = [];
+ 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': {
+ style = `font-size: 150%;` + (this.$store.state.animatedMfm ? 'animation: tada 1s 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': {
+ style = this.$store.state.animatedMfm ? 'animation: mfm-jump 0.75s linear infinite;' : '';
+ break;
+ }
+ case 'bounce': {
+ style = this.$store.state.animatedMfm ? 'animation: mfm-bounce 0.75s 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': {
+ style = `font-size: 200%;`;
+ break;
+ }
+ case 'x3': {
+ style = `font-size: 400%;`;
+ break;
+ }
+ case 'x4': {
+ style = `font-size: 600%;`;
+ break;
+ }
+ 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': {
+ style = this.$store.state.animatedMfm ? 'animation: mfm-rainbow 1s linear infinite;' : '';
+ break;
+ }
+ case 'sparkle': {
+ if (!this.$store.state.animatedMfm) {
+ return genEl(token.children);
+ }
+ let count = token.props.args.count ? parseInt(token.props.args.count) : 10;
+ if (count > 100) {
+ count = 100;
+ }
+ const speed = token.props.args.speed ? parseFloat(token.props.args.speed) : 1;
+ return h(MkSparkle, {
+ count, speed,
+ }, genEl(token.children));
+ }
+ }
+ 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
+ })];
+ }
+
+ default: {
+ console.error('unrecognized ast type:', token.type);
+
+ return [];
+ }
+ }
+ }));
+
+ // Parse ast to DOM
+ return h('span', genEl(ast));
+ }
+});
diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue
new file mode 100644
index 0000000000..2eb9ae8cbe
--- /dev/null
+++ b/packages/client/src/components/mini-chart.vue
@@ -0,0 +1,90 @@
+<template>
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible">
+ <defs>
+ <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0">
+ <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop>
+ <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop>
+ </linearGradient>
+ <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+ <polygon
+ :points="polygonPoints"
+ fill="#fff"
+ fill-opacity="0.5"/>
+ <polyline
+ :points="polylinePoints"
+ fill="none"
+ stroke="#fff"
+ stroke-width="2"/>
+ <circle
+ :cx="headX"
+ :cy="headY"
+ r="3"
+ fill="#fff"/>
+ </mask>
+ </defs>
+ <rect
+ x="-10" y="-10"
+ :width="viewBoxX + 20" :height="viewBoxY + 20"
+ :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/>
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ src: {
+ type: Array,
+ required: true
+ }
+ },
+ data() {
+ return {
+ viewBoxX: 50,
+ viewBoxY: 30,
+ gradientId: uuid(),
+ maskId: uuid(),
+ polylinePoints: '',
+ polygonPoints: '',
+ headX: null,
+ headY: null,
+ clock: null
+ };
+ },
+ watch: {
+ src() {
+ this.draw();
+ }
+ },
+ created() {
+ this.draw();
+
+ // Vueが何故かWatchを発動させない場合があるので
+ this.clock = setInterval(this.draw, 1000);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ draw() {
+ const stats = this.src.slice().reverse();
+ const peak = Math.max.apply(null, stats) || 1;
+
+ const polylinePoints = stats.map((n, i) => [
+ i * (this.viewBoxX / (stats.length - 1)),
+ (1 - (n / peak)) * this.viewBoxY
+ ]);
+
+ this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+ this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`;
+
+ this.headX = polylinePoints[polylinePoints.length - 1][0];
+ this.headY = polylinePoints[polylinePoints.length - 1][1];
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue
new file mode 100644
index 0000000000..2086736683
--- /dev/null
+++ b/packages/client/src/components/modal-page-window.vue
@@ -0,0 +1,223 @@
+<template>
+<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
+ <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
+ <div class="header" @contextmenu="onContextmenu">
+ <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button>
+ <span v-else style="display: inline-block; width: 20px"></span>
+ <span v-if="pageInfo" class="title">
+ <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i>
+ <span>{{ pageInfo.title }}</span>
+ </span>
+ <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button>
+ </div>
+ <div class="body">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <keep-alive>
+ <component :is="component" v-bind="props" :ref="changePage"/>
+ </keep-alive>
+ </MkStickyContainer>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import { popout } from '@/scripts/popout';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ inject: {
+ sideViewHook: {
+ default: null
+ }
+ },
+
+ provide() {
+ return {
+ navHook: (path) => {
+ this.navigate(path);
+ },
+ shouldHeaderThin: true,
+ };
+ },
+
+ props: {
+ initialPath: {
+ type: String,
+ required: true,
+ },
+ initialComponent: {
+ type: Object,
+ required: true,
+ },
+ initialProps: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ width: 860,
+ height: 660,
+ pageInfo: null,
+ path: this.initialPath,
+ component: this.initialComponent,
+ props: this.initialProps,
+ history: [],
+ };
+ },
+
+ computed: {
+ url(): string {
+ return url + this.path;
+ },
+
+ contextmenu() {
+ return [{
+ type: 'label',
+ text: this.path,
+ }, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: this.expand
+ }, this.sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.sideViewHook(this.path);
+ this.$refs.window.close();
+ }
+ } : undefined, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.popout,
+ action: this.popout
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.url, '_blank');
+ this.$refs.window.close();
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(this.url);
+ }
+ }];
+ },
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ navigate(path, record = true) {
+ if (record) this.history.push(this.path);
+ this.path = path;
+ const { component, props } = resolve(path);
+ this.component = component;
+ this.props = props;
+ },
+
+ back() {
+ this.navigate(this.history.pop(), false);
+ },
+
+ expand() {
+ this.$router.push(this.path);
+ this.$refs.window.close();
+ },
+
+ popout() {
+ popout(this.path, this.$el);
+ this.$refs.window.close();
+ },
+
+ onContextmenu(e) {
+ os.contextMenu(this.contextmenu, e);
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hrmcaedk {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+
+ --root-margin: 24px;
+
+ @media (max-width: 500px) {
+ --root-margin: 16px;
+ }
+
+ > .header {
+ $height: 52px;
+ $height-narrow: 42px;
+ display: flex;
+ flex-shrink: 0;
+ height: $height;
+ line-height: $height;
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ box-shadow: 0px 1px var(--divider);
+
+ > button {
+ height: $height;
+ width: $height;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+
+ @media (max-width: 500px) {
+ height: $height-narrow;
+ line-height: $height-narrow;
+ padding-left: 16px;
+
+ > button {
+ height: $height-narrow;
+ width: $height-narrow;
+ }
+ }
+
+ > .title {
+ flex: 1;
+
+ > .icon {
+ margin-right: 0.5em;
+ }
+ }
+ }
+
+ > .body {
+ overflow: auto;
+ background: var(--bg);
+ }
+}
+</style>
diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue
new file mode 100644
index 0000000000..8b6905a0e4
--- /dev/null
+++ b/packages/client/src/components/note-detailed.vue
@@ -0,0 +1,1229 @@
+<template>
+<div
+ class="lxwezrsl _block"
+ v-if="!muted"
+ v-show="!isDeleted"
+ :tabindex="!isDeleted ? '-1' : null"
+ :class="{ renote: isRenote }"
+ v-hotkey="keymap"
+ v-size="{ max: [500, 450, 350, 300] }"
+>
+ <XSub v-for="note in conversation" class="reply-to-more" :key="note.id" :note="note"/>
+ <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+ <div class="renote" v-if="isRenote">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <i class="fas fa-retweet"></i>
+ <I18n :src="$ts.renotedBy" tag="span">
+ <template #user>
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ </template>
+ </I18n>
+ <div class="info">
+ <button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
+ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
+ <MkTime :time="note.createdAt"/>
+ </button>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ </div>
+ <article class="article" @contextmenu.stop="onContextmenu">
+ <header class="header">
+ <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
+ <div class="body">
+ <div class="top">
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.user.id">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ <span class="is-bot" v-if="appearNote.user.isBot">bot</span>
+ <span class="admin" v-if="appearNote.user.isAdmin"><i class="fas fa-bookmark"></i></span>
+ <span class="moderator" v-if="!appearNote.user.isAdmin && appearNote.user.isModerator"><i class="far fa-bookmark"></i></span>
+ <span class="visibility" v-if="appearNote.visibility !== 'public'">
+ <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="appearNote.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ <div class="username"><MkAcct :user="appearNote.user"/></div>
+ <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+ </div>
+ </header>
+ <div class="main">
+ <div class="body">
+ <p v-if="appearNote.cw != null" class="cw">
+ <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <XCwButton v-model="showContent" :note="appearNote"/>
+ </p>
+ <div class="content" v-show="appearNote.cw == null || showContent">
+ <div class="text">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <a class="rp" v-if="appearNote.renote != null">RN:</a>
+ <div class="translation" v-if="translating || translation">
+ <MkLoading v-if="translating" mini/>
+ <div class="translated" v-else>
+ <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
+ {{ translation.text }}
+ </div>
+ </div>
+ </div>
+ <div class="files" v-if="appearNote.files.length > 0">
+ <XMediaList :media-list="appearNote.files"/>
+ </div>
+ <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="true" class="url-preview"/>
+ <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
+ </div>
+ <footer class="footer">
+ <div class="info">
+ <span class="mobile" v-if="appearNote.viaMobile"><i class="fas fa-mobile-alt"></i></span>
+ <MkTime class="created-at" :time="appearNote.createdAt" mode="detail"/>
+ </div>
+ <XReactionsViewer :note="appearNote" ref="reactionsViewer"/>
+ <button @click="reply()" class="button _button">
+ <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template>
+ <template v-else><i class="fas fa-reply"></i></template>
+ <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
+ <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="button _button">
+ <i class="fas fa-ban"></i>
+ </button>
+ <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
+ <i class="fas fa-plus"></i>
+ </button>
+ <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
+ <i class="fas fa-minus"></i>
+ </button>
+ <button class="button _button" @click="menu()" ref="menuButton">
+ <i class="fas fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+ <XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
+</div>
+<div v-else class="_panel muted" @click="muted = false">
+ <I18n :src="$ts.userSaysSomething" tag="small">
+ <template #name>
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as mfm from 'mfm-js';
+import { sum } from '@/scripts/array';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNoteSimple from './note-simple.vue';
+import XReactionsViewer from './reactions-viewer.vue';
+import XMediaList from './media-list.vue';
+import XCwButton from './cw-button.vue';
+import XPoll from './poll.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+// TODO: note.vueとほぼ同じなので共通化したい
+export default defineComponent({
+ components: {
+ XSub,
+ XNoteHeader,
+ XNoteSimple,
+ XReactionsViewer,
+ XMediaList,
+ XCwButton,
+ XPoll,
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+ },
+
+ inject: {
+ inChannel: {
+ default: null
+ },
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+
+ emits: ['update:note'],
+
+ data() {
+ return {
+ connection: null,
+ conversation: [],
+ replies: [],
+ showContent: false,
+ isDeleted: false,
+ muted: false,
+ translation: null,
+ translating: false,
+ };
+ },
+
+ computed: {
+ rs() {
+ return this.$store.state.reactions;
+ },
+ keymap(): any {
+ return {
+ 'r': () => this.reply(true),
+ 'e|a|plus': () => this.react(true),
+ 'q': () => this.renote(true),
+ 'f|b': this.favorite,
+ 'delete|ctrl+d': this.del,
+ 'ctrl+q': this.renoteDirectly,
+ 'up|k|shift+tab': this.focusBefore,
+ 'down|j|tab': this.focusAfter,
+ 'esc': this.blur,
+ 'm|o': () => this.menu(true),
+ 's': this.toggleShowContent,
+ '1': () => this.reactDirectly(this.rs[0]),
+ '2': () => this.reactDirectly(this.rs[1]),
+ '3': () => this.reactDirectly(this.rs[2]),
+ '4': () => this.reactDirectly(this.rs[3]),
+ '5': () => this.reactDirectly(this.rs[4]),
+ '6': () => this.reactDirectly(this.rs[5]),
+ '7': () => this.reactDirectly(this.rs[6]),
+ '8': () => this.reactDirectly(this.rs[7]),
+ '9': () => this.reactDirectly(this.rs[8]),
+ '0': () => this.reactDirectly(this.rs[9]),
+ };
+ },
+
+ isRenote(): boolean {
+ return (this.note.renote &&
+ this.note.text == null &&
+ this.note.fileIds.length == 0 &&
+ this.note.poll == null);
+ },
+
+ appearNote(): any {
+ return this.isRenote ? this.note.renote : this.note;
+ },
+
+ isMyNote(): boolean {
+ return this.$i && (this.$i.id === this.appearNote.userId);
+ },
+
+ isMyRenote(): boolean {
+ return this.$i && (this.$i.id === this.note.userId);
+ },
+
+ canRenote(): boolean {
+ return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
+ },
+
+ reactionsCount(): number {
+ return this.appearNote.reactions
+ ? sum(Object.values(this.appearNote.reactions))
+ : 0;
+ },
+
+ urls(): string[] {
+ if (this.appearNote.text) {
+ return extractUrlFromMfm(mfm.parse(this.appearNote.text));
+ } else {
+ return null;
+ }
+ },
+
+ showTicker() {
+ if (this.$store.state.instanceTicker === 'always') return true;
+ if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
+ return false;
+ }
+ },
+
+ async created() {
+ if (this.$i) {
+ this.connection = os.stream;
+ }
+
+ this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
+
+ // plugin
+ if (noteViewInterruptors.length > 0) {
+ let result = this.note;
+ for (const interruptor of noteViewInterruptors) {
+ result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
+ }
+ this.$emit('update:note', Object.freeze(result));
+ }
+
+ os.api('notes/children', {
+ noteId: this.appearNote.id,
+ limit: 30
+ }).then(replies => {
+ this.replies = replies;
+ });
+
+ if (this.appearNote.replyId) {
+ os.api('notes/conversation', {
+ noteId: this.appearNote.replyId
+ }).then(conversation => {
+ this.conversation = conversation.reverse();
+ });
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$i) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+ },
+
+ beforeUnmount() {
+ this.decapture(true);
+
+ if (this.$i) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ }
+ },
+
+ methods: {
+ updateAppearNote(v) {
+ this.$emit('update:note', Object.freeze(this.isRenote ? {
+ ...this.note,
+ renote: {
+ ...this.note.renote,
+ ...v
+ }
+ } : {
+ ...this.note,
+ ...v
+ }));
+ },
+
+ readPromo() {
+ os.api('promo/read', {
+ noteId: this.appearNote.id
+ });
+ this.isDeleted = true;
+ },
+
+ capture(withHandler = false) {
+ if (this.$i) {
+ // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+ this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
+ if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$i) {
+ this.connection.send('un', {
+ id: this.appearNote.id
+ });
+ if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ onStreamConnected() {
+ this.capture();
+ },
+
+ onStreamNoteUpdated(data) {
+ const { type, id, body } = data;
+
+ if (id !== this.appearNote.id) return;
+
+ switch (type) {
+ case 'reacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ if (body.emoji) {
+ const emojis = this.appearNote.emojis || [];
+ if (!emojis.includes(body.emoji)) {
+ n.emojis = [...emojis, body.emoji];
+ }
+ }
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Increment the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: currentCount + 1
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = reaction;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Decrement the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: Math.max(0, currentCount - 1)
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = null;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ const choices = [...this.appearNote.poll.choices];
+ choices[choice] = {
+ ...choices[choice],
+ votes: choices[choice].votes + 1,
+ ...(body.userId === this.$i.id ? {
+ isVoted: true
+ } : {})
+ };
+
+ n.poll = {
+ ...this.appearNote.poll,
+ choices: choices
+ };
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'deleted': {
+ this.isDeleted = true;
+ break;
+ }
+ }
+ },
+
+ reply(viaKeyboard = false) {
+ pleaseLogin();
+ os.post({
+ reply: this.appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ this.focus();
+ });
+ },
+
+ renote(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ os.popupMenu([{
+ text: this.$ts.renote,
+ icon: 'fas fa-retweet',
+ action: () => {
+ os.api('notes/create', {
+ renoteId: this.appearNote.id
+ });
+ }
+ }, {
+ text: this.$ts.quote,
+ icon: 'fas fa-quote-right',
+ action: () => {
+ os.post({
+ renote: this.appearNote,
+ });
+ }
+ }], this.$refs.renoteButton, {
+ viaKeyboard
+ });
+ },
+
+ renoteDirectly() {
+ os.apiWithDialog('notes/create', {
+ renoteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.renoted,
+ });
+ }, (e: Error) => {
+ if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantRenote,
+ });
+ } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantReRenote,
+ });
+ }
+ });
+ },
+
+ react(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ reactionPicker.show(this.$refs.reactButton, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ }, () => {
+ this.focus();
+ });
+ },
+
+ reactDirectly(reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ },
+
+ undoReact(note) {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+ },
+
+ favorite() {
+ pleaseLogin();
+ os.apiWithDialog('notes/favorites/create', {
+ noteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.favorited,
+ });
+ }, (e: Error) => {
+ if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.alreadyFavorited,
+ });
+ } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantFavorite,
+ });
+ }
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.noteDeleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+ });
+ },
+
+ delEdit() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteAndEditConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+
+ os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
+ });
+ },
+
+ toggleFavorite(favorite: boolean) {
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleWatch(watch: boolean) {
+ os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleThreadMute(mute: boolean) {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ getMenu() {
+ let menu;
+ if (this.$i) {
+ const statePromise = os.api('notes/state', {
+ noteId: this.appearNote.id
+ });
+
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined,
+ {
+ icon: 'fas fa-share-alt',
+ text: this.$ts.share,
+ action: this.share
+ },
+ this.$instance.translatorAvailable ? {
+ icon: 'fas fa-language',
+ text: this.$ts.translate,
+ action: this.translate
+ } : undefined,
+ null,
+ statePromise.then(state => state.isFavorited ? {
+ icon: 'fas fa-star',
+ text: this.$ts.unfavorite,
+ action: () => this.toggleFavorite(false)
+ } : {
+ icon: 'fas fa-star',
+ text: this.$ts.favorite,
+ action: () => this.toggleFavorite(true)
+ }),
+ {
+ icon: 'fas fa-paperclip',
+ text: this.$ts.clip,
+ action: () => this.clip()
+ },
+ (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
+ icon: 'fas fa-eye-slash',
+ text: this.$ts.unwatch,
+ action: () => this.toggleWatch(false)
+ } : {
+ icon: 'fas fa-eye',
+ text: this.$ts.watch,
+ action: () => this.toggleWatch(true)
+ }) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.unmuteThread,
+ action: () => this.toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.muteThread,
+ action: () => this.toggleThreadMute(true)
+ }),
+ this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.unpin,
+ action: () => this.togglePin(false)
+ } : {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.pin,
+ action: () => this.togglePin(true)
+ } : undefined,
+ ...(this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ {
+ icon: 'fas fa-bullhorn',
+ text: this.$ts.promote,
+ action: this.promote
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId != this.$i.id ? [
+ null,
+ {
+ icon: 'fas fa-exclamation-circle',
+ text: this.$ts.reportAbuse,
+ action: () => {
+ const u = `${url}/notes/${this.appearNote.id}`;
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: this.appearNote.user,
+ initialComment: `Note: ${u}\n-----\n`
+ }, {}, 'closed');
+ }
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ this.appearNote.userId == this.$i.id ? {
+ icon: 'fas fa-edit',
+ text: this.$ts.deleteAndEdit,
+ action: this.delEdit
+ } : undefined,
+ {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: this.del
+ }]
+ : []
+ )]
+ .filter(x => x !== undefined);
+ } else {
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined]
+ .filter(x => x !== undefined);
+ }
+
+ if (noteActions.length > 0) {
+ menu = menu.concat([null, ...noteActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(this.appearNote);
+ }
+ }))]);
+ }
+
+ return menu;
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') return;
+
+ if (this.$store.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ this.react();
+ } else {
+ os.contextMenu(this.getMenu(), e).then(this.focus);
+ }
+ },
+
+ menu(viaKeyboard = false) {
+ os.popupMenu(this.getMenu(), this.$refs.menuButton, {
+ viaKeyboard
+ }).then(this.focus);
+ },
+
+ showRenoteMenu(viaKeyboard = false) {
+ if (!this.isMyRenote) return;
+ os.popupMenu([{
+ text: this.$ts.unrenote,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => {
+ os.api('notes/delete', {
+ noteId: this.note.id
+ });
+ this.isDeleted = true;
+ }
+ }], this.$refs.renoteTime, {
+ viaKeyboard: viaKeyboard
+ });
+ },
+
+ toggleShowContent() {
+ this.showContent = !this.showContent;
+ },
+
+ copyContent() {
+ copyToClipboard(this.appearNote.text);
+ os.success();
+ },
+
+ copyLink() {
+ copyToClipboard(`${url}/notes/${this.appearNote.id}`);
+ os.success();
+ },
+
+ togglePin(pin: boolean) {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: this.appearNote.id
+ }, undefined, null, e => {
+ if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.pinLimitExceeded
+ });
+ }
+ });
+ },
+
+ async clip() {
+ const clips = await os.api('clips/list');
+ os.popupMenu([{
+ icon: 'fas fa-plus',
+ text: this.$ts.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }, null, ...clips.map(clip => ({
+ text: clip.name,
+ action: () => {
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }))], this.$refs.menuButton, {
+ }).then(this.focus);
+ },
+
+ async promote() {
+ const { canceled, result: days } = await os.dialog({
+ title: this.$ts.numberOfDays,
+ input: { type: 'number' }
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: this.appearNote.id,
+ expiresAt: Date.now() + (86400000 * days)
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.$t('noteOf', { user: this.appearNote.user.name }),
+ text: this.appearNote.text,
+ url: `${url}/notes/${this.appearNote.id}`
+ });
+ },
+
+ async translate() {
+ if (this.translation != null) return;
+ this.translating = true;
+ const res = await os.api('notes/translate', {
+ noteId: this.appearNote.id,
+ targetLang: localStorage.getItem('lang') || navigator.language,
+ });
+ this.translating = false;
+ this.translation = res;
+ },
+
+ focus() {
+ this.$el.focus();
+ },
+
+ blur() {
+ this.$el.blur();
+ },
+
+ focusBefore() {
+ focusPrev(this.$el);
+ },
+
+ focusAfter() {
+ focusNext(this.$el);
+ },
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lxwezrsl {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ overflow: hidden;
+ contain: content;
+
+ &:focus-visible {
+ outline: none;
+
+ &:after {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ z-index: 10;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: calc(100% - 8px);
+ height: calc(100% - 8px);
+ border: dashed 1px var(--focus);
+ border-radius: var(--radius);
+ box-sizing: border-box;
+ }
+ }
+
+ &:hover > .article > .main > .footer > .button {
+ opacity: 1;
+ }
+
+ > .reply-to {
+ opacity: 0.7;
+ padding-bottom: 0;
+ }
+
+ > .reply-to-more {
+ opacity: 0.7;
+ }
+
+ > .renote {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+
+ > .avatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 6px;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > span {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+
+ > .info {
+ margin-left: auto;
+ font-size: 0.9em;
+
+ > .time {
+ flex-shrink: 0;
+ color: inherit;
+
+ > .dropdownIcon {
+ margin-right: 4px;
+ }
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > .renote + .article {
+ padding-top: 8px;
+ }
+
+ > .article {
+ padding: 32px;
+ font-size: 1.1em;
+
+ > .header {
+ display: flex;
+ position: relative;
+ margin-bottom: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ width: 58px;
+ height: 58px;
+ }
+
+ > .body {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-left: 16px;
+ font-size: 0.95em;
+
+ > .top {
+ > .name {
+ font-weight: bold;
+ }
+
+ > .is-bot {
+ flex-shrink: 0;
+ align-self: center;
+ margin: 0 0.5em;
+ padding: 4px 6px;
+ font-size: 80%;
+ border: solid 0.5px var(--divider);
+ border-radius: 4px;
+ }
+
+ > .admin,
+ > .moderator {
+ margin-right: 0.5em;
+ color: var(--badge);
+ }
+ }
+ }
+ }
+
+ > .main {
+ > .body {
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ overflow-wrap: break-word;
+
+ > .reply {
+ color: var(--accent);
+ margin-right: 0.5em;
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+
+ > .translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+ }
+ }
+
+ > .url-preview {
+ margin-top: 8px;
+ }
+
+ > .poll {
+ font-size: 80%;
+ }
+
+ > .renote {
+ padding: 8px 0;
+
+ > * {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .channel {
+ opacity: 0.7;
+ font-size: 80%;
+ }
+ }
+
+ > .footer {
+ > .info {
+ margin: 16px 0;
+ opacity: 0.7;
+ font-size: 0.9em;
+ }
+
+ > .button {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+
+ > .count {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+ }
+
+ &.reacted {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ &.max-width_500px {
+ font-size: 0.9em;
+ }
+
+ &.max-width_450px {
+ > .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .article {
+ padding: 16px;
+
+ > .header {
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+ }
+ }
+ }
+
+ &.max-width_350px {
+ > .article {
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 18px;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_300px {
+ font-size: 0.825em;
+
+ > .article {
+ > .header {
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+ }
+
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+.muted {
+ padding: 8px;
+ text-align: center;
+ opacity: 0.7;
+}
+</style>
diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue
new file mode 100644
index 0000000000..c61ec41dd1
--- /dev/null
+++ b/packages/client/src/components/note-header.vue
@@ -0,0 +1,115 @@
+<template>
+<header class="kkwtjztg">
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ <div class="is-bot" v-if="note.user.isBot">bot</div>
+ <div class="username"><MkAcct :user="note.user"/></div>
+ <div class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></div>
+ <div class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></div>
+ <div class="info">
+ <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span>
+ <MkA class="created-at" :to="notePage(note)">
+ <MkTime :time="note.createdAt"/>
+ </MkA>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+</header>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import notePage from '@/filters/note';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ notePage,
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kkwtjztg {
+ display: flex;
+ align-items: baseline;
+ white-space: nowrap;
+
+ > .name {
+ flex-shrink: 1;
+ display: block;
+ margin: 0 .5em 0 0;
+ padding: 0;
+ overflow: hidden;
+ font-size: 1em;
+ font-weight: bold;
+ text-decoration: none;
+ text-overflow: ellipsis;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ > .is-bot {
+ flex-shrink: 0;
+ align-self: center;
+ margin: 0 .5em 0 0;
+ padding: 1px 6px;
+ font-size: 80%;
+ border: solid 0.5px var(--divider);
+ border-radius: 3px;
+ }
+
+ > .admin,
+ > .moderator {
+ flex-shrink: 0;
+ margin-right: 0.5em;
+ color: var(--badge);
+ }
+
+ > .username {
+ flex-shrink: 9999999;
+ margin: 0 .5em 0 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .info {
+ flex-shrink: 0;
+ margin-left: auto;
+ font-size: 0.9em;
+
+ > .mobile {
+ margin-right: 8px;
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue
new file mode 100644
index 0000000000..a474a01341
--- /dev/null
+++ b/packages/client/src/components/note-preview.vue
@@ -0,0 +1,98 @@
+<template>
+<div class="fefdfafb" v-size="{ min: [350, 500] }">
+ <MkAvatar class="avatar" :user="$i"/>
+ <div class="main">
+ <div class="header">
+ <MkUserName :user="$i"/>
+ </div>
+ <div class="body">
+ <div class="content">
+ <Mfm :text="text" :author="$i" :i="$i"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ components: {
+ },
+
+ props: {
+ text: {
+ type: String,
+ required: true
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fefdfafb {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ overflow: clip;
+ font-size: 0.95em;
+
+ &.min-width_350px {
+ > .avatar {
+ margin: 0 10px 0 0;
+ width: 44px;
+ height: 44px;
+ }
+ }
+
+ &.min-width_500px {
+ > .avatar {
+ margin: 0 12px 0 0;
+ width: 48px;
+ height: 48px;
+ }
+ }
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 10px 0 0;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .header {
+ margin-bottom: 2px;
+ }
+
+ > .body {
+
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ cursor: default;
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue
new file mode 100644
index 0000000000..2f19bd6e0b
--- /dev/null
+++ b/packages/client/src/components/note-simple.vue
@@ -0,0 +1,113 @@
+<template>
+<div class="yohlumlk" v-size="{ min: [350, 500] }">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <div class="main">
+ <XNoteHeader class="header" :note="note" :mini="true"/>
+ <div class="body">
+ <p v-if="note.cw != null" class="cw">
+ <span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+ <XCwButton v-model="showContent" :note="note"/>
+ </p>
+ <div class="content" v-show="note.cw == null || showContent">
+ <XSubNote-content class="text" :note="note"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from './cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNoteHeader,
+ XSubNoteContent,
+ XCwButton,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ showContent: false
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yohlumlk {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ overflow: clip;
+ font-size: 0.95em;
+
+ &.min-width_350px {
+ > .avatar {
+ margin: 0 10px 0 0;
+ width: 44px;
+ height: 44px;
+ }
+ }
+
+ &.min-width_500px {
+ > .avatar {
+ margin: 0 12px 0 0;
+ width: 48px;
+ height: 48px;
+ }
+ }
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 10px 0 0;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .header {
+ margin-bottom: 2px;
+ }
+
+ > .body {
+
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ cursor: default;
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/note.sub.vue b/packages/client/src/components/note.sub.vue
new file mode 100644
index 0000000000..45204854be
--- /dev/null
+++ b/packages/client/src/components/note.sub.vue
@@ -0,0 +1,146 @@
+<template>
+<div class="wrpstxzv" :class="{ children }" v-size="{ max: [450] }">
+ <div class="main">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <div class="body">
+ <XNoteHeader class="header" :note="note" :mini="true"/>
+ <div class="body">
+ <p v-if="note.cw != null" class="cw">
+ <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" />
+ <XCwButton v-model="showContent" :note="note"/>
+ </p>
+ <div class="content" v-show="note.cw == null || showContent">
+ <XSubNote-content class="text" :note="note"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from './cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ name: 'XSub',
+
+ components: {
+ XNoteHeader,
+ XSubNoteContent,
+ XCwButton,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ detail: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ children: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ // TODO
+ truncate: {
+ type: Boolean,
+ default: true
+ }
+ },
+
+ data() {
+ return {
+ showContent: false,
+ replies: [],
+ };
+ },
+
+ created() {
+ if (this.detail) {
+ os.api('notes/children', {
+ noteId: this.note.id,
+ limit: 5
+ }).then(replies => {
+ this.replies = replies;
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.wrpstxzv {
+ padding: 16px 32px;
+ font-size: 0.9em;
+
+ &.max-width_450px {
+ padding: 14px 16px;
+ }
+
+ &.children {
+ padding: 10px 0 0 16px;
+ font-size: 1em;
+
+ &.max-width_450px {
+ padding: 10px 0 0 8px;
+ }
+ }
+
+ > .main {
+ display: flex;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 8px 0 0;
+ width: 38px;
+ height: 38px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ flex: 1;
+ min-width: 0;
+
+ > .header {
+ margin-bottom: 2px;
+ }
+
+ > .body {
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-left: solid 0.5px var(--divider);
+ margin-top: 10px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue
new file mode 100644
index 0000000000..b1ec674b67
--- /dev/null
+++ b/packages/client/src/components/note.vue
@@ -0,0 +1,1228 @@
+<template>
+<div
+ class="tkcbzcuz"
+ v-if="!muted"
+ v-show="!isDeleted"
+ :tabindex="!isDeleted ? '-1' : null"
+ :class="{ renote: isRenote }"
+ v-hotkey="keymap"
+ v-size="{ max: [500, 450, 350, 300] }"
+>
+ <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+ <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div>
+ <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div>
+ <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
+ <div class="renote" v-if="isRenote">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <i class="fas fa-retweet"></i>
+ <I18n :src="$ts.renotedBy" tag="span">
+ <template #user>
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ </template>
+ </I18n>
+ <div class="info">
+ <button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
+ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
+ <MkTime :time="note.createdAt"/>
+ </button>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ </div>
+ <article class="article" @contextmenu.stop="onContextmenu">
+ <MkAvatar class="avatar" :user="appearNote.user"/>
+ <div class="main">
+ <XNoteHeader class="header" :note="appearNote" :mini="true"/>
+ <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+ <div class="body">
+ <p v-if="appearNote.cw != null" class="cw">
+ <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <XCwButton v-model="showContent" :note="appearNote"/>
+ </p>
+ <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent">
+ <div class="text">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <a class="rp" v-if="appearNote.renote != null">RN:</a>
+ <div class="translation" v-if="translating || translation">
+ <MkLoading v-if="translating" mini/>
+ <div class="translated" v-else>
+ <b>{{ $t('translatedFrom', { x: translation.sourceLang }) }}:</b>
+ {{ translation.text }}
+ </div>
+ </div>
+ </div>
+ <div class="files" v-if="appearNote.files.length > 0">
+ <XMediaList :media-list="appearNote.files"/>
+ </div>
+ <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/>
+ <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div>
+ <button v-if="collapsed" class="fade _button" @click="collapsed = false">
+ <span>{{ $ts.showMore }}</span>
+ </button>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
+ </div>
+ <footer class="footer">
+ <XReactionsViewer :note="appearNote" ref="reactionsViewer"/>
+ <button @click="reply()" class="button _button">
+ <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template>
+ <template v-else><i class="fas fa-reply"></i></template>
+ <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
+ <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="button _button">
+ <i class="fas fa-ban"></i>
+ </button>
+ <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton">
+ <i class="fas fa-plus"></i>
+ </button>
+ <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton">
+ <i class="fas fa-minus"></i>
+ </button>
+ <button class="button _button" @click="menu()" ref="menuButton">
+ <i class="fas fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+</div>
+<div v-else class="muted" @click="muted = false">
+ <I18n :src="$ts.userSaysSomething" tag="small">
+ <template #name>
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as mfm from 'mfm-js';
+import { sum } from '@/scripts/array';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNoteSimple from './note-simple.vue';
+import XReactionsViewer from './reactions-viewer.vue';
+import XMediaList from './media-list.vue';
+import XCwButton from './cw-button.vue';
+import XPoll from './poll.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+export default defineComponent({
+ components: {
+ XSub,
+ XNoteHeader,
+ XNoteSimple,
+ XReactionsViewer,
+ XMediaList,
+ XCwButton,
+ XPoll,
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+ },
+
+ inject: {
+ inChannel: {
+ default: null
+ },
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ pinned: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['update:note'],
+
+ data() {
+ return {
+ connection: null,
+ replies: [],
+ showContent: false,
+ collapsed: false,
+ isDeleted: false,
+ muted: false,
+ translation: null,
+ translating: false,
+ };
+ },
+
+ computed: {
+ rs() {
+ return this.$store.state.reactions;
+ },
+ keymap(): any {
+ return {
+ 'r': () => this.reply(true),
+ 'e|a|plus': () => this.react(true),
+ 'q': () => this.renote(true),
+ 'f|b': this.favorite,
+ 'delete|ctrl+d': this.del,
+ 'ctrl+q': this.renoteDirectly,
+ 'up|k|shift+tab': this.focusBefore,
+ 'down|j|tab': this.focusAfter,
+ 'esc': this.blur,
+ 'm|o': () => this.menu(true),
+ 's': this.toggleShowContent,
+ '1': () => this.reactDirectly(this.rs[0]),
+ '2': () => this.reactDirectly(this.rs[1]),
+ '3': () => this.reactDirectly(this.rs[2]),
+ '4': () => this.reactDirectly(this.rs[3]),
+ '5': () => this.reactDirectly(this.rs[4]),
+ '6': () => this.reactDirectly(this.rs[5]),
+ '7': () => this.reactDirectly(this.rs[6]),
+ '8': () => this.reactDirectly(this.rs[7]),
+ '9': () => this.reactDirectly(this.rs[8]),
+ '0': () => this.reactDirectly(this.rs[9]),
+ };
+ },
+
+ isRenote(): boolean {
+ return (this.note.renote &&
+ this.note.text == null &&
+ this.note.fileIds.length == 0 &&
+ this.note.poll == null);
+ },
+
+ appearNote(): any {
+ return this.isRenote ? this.note.renote : this.note;
+ },
+
+ isMyNote(): boolean {
+ return this.$i && (this.$i.id === this.appearNote.userId);
+ },
+
+ isMyRenote(): boolean {
+ return this.$i && (this.$i.id === this.note.userId);
+ },
+
+ canRenote(): boolean {
+ return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
+ },
+
+ reactionsCount(): number {
+ return this.appearNote.reactions
+ ? sum(Object.values(this.appearNote.reactions))
+ : 0;
+ },
+
+ urls(): string[] {
+ if (this.appearNote.text) {
+ return extractUrlFromMfm(mfm.parse(this.appearNote.text));
+ } else {
+ return null;
+ }
+ },
+
+ showTicker() {
+ if (this.$store.state.instanceTicker === 'always') return true;
+ if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
+ return false;
+ }
+ },
+
+ async created() {
+ if (this.$i) {
+ this.connection = os.stream;
+ }
+
+ this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
+ (this.appearNote.text.split('\n').length > 9) ||
+ (this.appearNote.text.length > 500)
+ );
+ this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
+
+ // plugin
+ if (noteViewInterruptors.length > 0) {
+ let result = this.note;
+ for (const interruptor of noteViewInterruptors) {
+ result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
+ }
+ this.$emit('update:note', Object.freeze(result));
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$i) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+ },
+
+ beforeUnmount() {
+ this.decapture(true);
+
+ if (this.$i) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ }
+ },
+
+ methods: {
+ updateAppearNote(v) {
+ this.$emit('update:note', Object.freeze(this.isRenote ? {
+ ...this.note,
+ renote: {
+ ...this.note.renote,
+ ...v
+ }
+ } : {
+ ...this.note,
+ ...v
+ }));
+ },
+
+ readPromo() {
+ os.api('promo/read', {
+ noteId: this.appearNote.id
+ });
+ this.isDeleted = true;
+ },
+
+ capture(withHandler = false) {
+ if (this.$i) {
+ // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+ this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
+ if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$i) {
+ this.connection.send('un', {
+ id: this.appearNote.id
+ });
+ if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ onStreamConnected() {
+ this.capture();
+ },
+
+ onStreamNoteUpdated(data) {
+ const { type, id, body } = data;
+
+ if (id !== this.appearNote.id) return;
+
+ switch (type) {
+ case 'reacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ if (body.emoji) {
+ const emojis = this.appearNote.emojis || [];
+ if (!emojis.includes(body.emoji)) {
+ n.emojis = [...emojis, body.emoji];
+ }
+ }
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Increment the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: currentCount + 1
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = reaction;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Decrement the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: Math.max(0, currentCount - 1)
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = null;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ const choices = [...this.appearNote.poll.choices];
+ choices[choice] = {
+ ...choices[choice],
+ votes: choices[choice].votes + 1,
+ ...(body.userId === this.$i.id ? {
+ isVoted: true
+ } : {})
+ };
+
+ n.poll = {
+ ...this.appearNote.poll,
+ choices: choices
+ };
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'deleted': {
+ this.isDeleted = true;
+ break;
+ }
+ }
+ },
+
+ reply(viaKeyboard = false) {
+ pleaseLogin();
+ os.post({
+ reply: this.appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ this.focus();
+ });
+ },
+
+ renote(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ os.popupMenu([{
+ text: this.$ts.renote,
+ icon: 'fas fa-retweet',
+ action: () => {
+ os.api('notes/create', {
+ renoteId: this.appearNote.id
+ });
+ }
+ }, {
+ text: this.$ts.quote,
+ icon: 'fas fa-quote-right',
+ action: () => {
+ os.post({
+ renote: this.appearNote,
+ });
+ }
+ }], this.$refs.renoteButton, {
+ viaKeyboard
+ });
+ },
+
+ renoteDirectly() {
+ os.apiWithDialog('notes/create', {
+ renoteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.renoted,
+ });
+ }, (e: Error) => {
+ if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantRenote,
+ });
+ } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantReRenote,
+ });
+ }
+ });
+ },
+
+ react(viaKeyboard = false) {
+ pleaseLogin();
+ this.blur();
+ reactionPicker.show(this.$refs.reactButton, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ }, () => {
+ this.focus();
+ });
+ },
+
+ reactDirectly(reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ },
+
+ undoReact(note) {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+ },
+
+ favorite() {
+ pleaseLogin();
+ os.apiWithDialog('notes/favorites/create', {
+ noteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.favorited,
+ });
+ }, (e: Error) => {
+ if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.alreadyFavorited,
+ });
+ } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantFavorite,
+ });
+ }
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.noteDeleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+ });
+ },
+
+ delEdit() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteAndEditConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+
+ os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
+ });
+ },
+
+ toggleFavorite(favorite: boolean) {
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleWatch(watch: boolean) {
+ os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleThreadMute(mute: boolean) {
+ os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ getMenu() {
+ let menu;
+ if (this.$i) {
+ const statePromise = os.api('notes/state', {
+ noteId: this.appearNote.id
+ });
+
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined,
+ {
+ icon: 'fas fa-share-alt',
+ text: this.$ts.share,
+ action: this.share
+ },
+ this.$instance.translatorAvailable ? {
+ icon: 'fas fa-language',
+ text: this.$ts.translate,
+ action: this.translate
+ } : undefined,
+ null,
+ statePromise.then(state => state.isFavorited ? {
+ icon: 'fas fa-star',
+ text: this.$ts.unfavorite,
+ action: () => this.toggleFavorite(false)
+ } : {
+ icon: 'fas fa-star',
+ text: this.$ts.favorite,
+ action: () => this.toggleFavorite(true)
+ }),
+ {
+ icon: 'fas fa-paperclip',
+ text: this.$ts.clip,
+ action: () => this.clip()
+ },
+ (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
+ icon: 'fas fa-eye-slash',
+ text: this.$ts.unwatch,
+ action: () => this.toggleWatch(false)
+ } : {
+ icon: 'fas fa-eye',
+ text: this.$ts.watch,
+ action: () => this.toggleWatch(true)
+ }) : undefined,
+ statePromise.then(state => state.isMutedThread ? {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.unmuteThread,
+ action: () => this.toggleThreadMute(false)
+ } : {
+ icon: 'fas fa-comment-slash',
+ text: this.$ts.muteThread,
+ action: () => this.toggleThreadMute(true)
+ }),
+ this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.unpin,
+ action: () => this.togglePin(false)
+ } : {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.pin,
+ action: () => this.togglePin(true)
+ } : undefined,
+ ...(this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ {
+ icon: 'fas fa-bullhorn',
+ text: this.$ts.promote,
+ action: this.promote
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId != this.$i.id ? [
+ null,
+ {
+ icon: 'fas fa-exclamation-circle',
+ text: this.$ts.reportAbuse,
+ action: () => {
+ const u = `${url}/notes/${this.appearNote.id}`;
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: this.appearNote.user,
+ initialComment: `Note: ${u}\n-----\n`
+ }, {}, 'closed');
+ }
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ this.appearNote.userId == this.$i.id ? {
+ icon: 'fas fa-edit',
+ text: this.$ts.deleteAndEdit,
+ action: this.delEdit
+ } : undefined,
+ {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: this.del
+ }]
+ : []
+ )]
+ .filter(x => x !== undefined);
+ } else {
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined]
+ .filter(x => x !== undefined);
+ }
+
+ if (noteActions.length > 0) {
+ menu = menu.concat([null, ...noteActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(this.appearNote);
+ }
+ }))]);
+ }
+
+ return menu;
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') return;
+
+ if (this.$store.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ this.react();
+ } else {
+ os.contextMenu(this.getMenu(), e).then(this.focus);
+ }
+ },
+
+ menu(viaKeyboard = false) {
+ os.popupMenu(this.getMenu(), this.$refs.menuButton, {
+ viaKeyboard
+ }).then(this.focus);
+ },
+
+ showRenoteMenu(viaKeyboard = false) {
+ if (!this.isMyRenote) return;
+ os.popupMenu([{
+ text: this.$ts.unrenote,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => {
+ os.api('notes/delete', {
+ noteId: this.note.id
+ });
+ this.isDeleted = true;
+ }
+ }], this.$refs.renoteTime, {
+ viaKeyboard: viaKeyboard
+ });
+ },
+
+ toggleShowContent() {
+ this.showContent = !this.showContent;
+ },
+
+ copyContent() {
+ copyToClipboard(this.appearNote.text);
+ os.success();
+ },
+
+ copyLink() {
+ copyToClipboard(`${url}/notes/${this.appearNote.id}`);
+ os.success();
+ },
+
+ togglePin(pin: boolean) {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: this.appearNote.id
+ }, undefined, null, e => {
+ if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.pinLimitExceeded
+ });
+ }
+ });
+ },
+
+ async clip() {
+ const clips = await os.api('clips/list');
+ os.popupMenu([{
+ icon: 'fas fa-plus',
+ text: this.$ts.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }, null, ...clips.map(clip => ({
+ text: clip.name,
+ action: () => {
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }))], this.$refs.menuButton, {
+ }).then(this.focus);
+ },
+
+ async promote() {
+ const { canceled, result: days } = await os.dialog({
+ title: this.$ts.numberOfDays,
+ input: { type: 'number' }
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: this.appearNote.id,
+ expiresAt: Date.now() + (86400000 * days)
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.$t('noteOf', { user: this.appearNote.user.name }),
+ text: this.appearNote.text,
+ url: `${url}/notes/${this.appearNote.id}`
+ });
+ },
+
+ async translate() {
+ if (this.translation != null) return;
+ this.translating = true;
+ const res = await os.api('notes/translate', {
+ noteId: this.appearNote.id,
+ targetLang: localStorage.getItem('lang') || navigator.language,
+ });
+ this.translating = false;
+ this.translation = res;
+ },
+
+ focus() {
+ this.$el.focus();
+ },
+
+ blur() {
+ this.$el.blur();
+ },
+
+ focusBefore() {
+ focusPrev(this.$el);
+ },
+
+ focusAfter() {
+ focusNext(this.$el);
+ },
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tkcbzcuz {
+ position: relative;
+ transition: box-shadow 0.1s ease;
+ overflow: clip;
+ contain: content;
+
+ // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+ // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+ // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+ // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+ // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
+ //content-visibility: auto;
+ //contain-intrinsic-size: 0 128px;
+
+ &:focus-visible {
+ outline: none;
+
+ &:after {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ z-index: 10;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: calc(100% - 8px);
+ height: calc(100% - 8px);
+ border: dashed 1px var(--focus);
+ border-radius: var(--radius);
+ box-sizing: border-box;
+ }
+ }
+
+ &:hover > .article > .main > .footer > .button {
+ opacity: 1;
+ }
+
+ > .info {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 24px;
+ font-size: 90%;
+ white-space: pre;
+ color: #d28a3f;
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > .hide {
+ margin-left: auto;
+ color: inherit;
+ }
+ }
+
+ > .info + .article {
+ padding-top: 8px;
+ }
+
+ > .reply-to {
+ opacity: 0.7;
+ padding-bottom: 0;
+ }
+
+ > .renote {
+ display: flex;
+ align-items: center;
+ padding: 16px 32px 8px 32px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+
+ > .avatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 6px;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > span {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+
+ > .info {
+ margin-left: auto;
+ font-size: 0.9em;
+
+ > .time {
+ flex-shrink: 0;
+ color: inherit;
+
+ > .dropdownIcon {
+ margin-right: 4px;
+ }
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > .renote + .article {
+ padding-top: 8px;
+ }
+
+ > .article {
+ display: flex;
+ padding: 28px 32px 18px;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 14px 8px 0;
+ width: 58px;
+ height: 58px;
+ position: sticky;
+ top: calc(22px + var(--stickyTop, 0px));
+ left: 0;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .body {
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ &.collapsed {
+ position: relative;
+ max-height: 9em;
+ overflow: hidden;
+
+ > .fade {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+
+ > span {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+ }
+
+ &:hover {
+ > span {
+ background: var(--panelHighlight);
+ }
+ }
+ }
+ }
+
+ > .text {
+ overflow-wrap: break-word;
+
+ > .reply {
+ color: var(--accent);
+ margin-right: 0.5em;
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+
+ > .translation {
+ border: solid 0.5px var(--divider);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-top: 8px;
+ }
+ }
+
+ > .url-preview {
+ margin-top: 8px;
+ }
+
+ > .poll {
+ font-size: 80%;
+ }
+
+ > .renote {
+ padding: 8px 0;
+
+ > * {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .channel {
+ opacity: 0.7;
+ font-size: 80%;
+ }
+ }
+
+ > .footer {
+ > .button {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:not(:last-child) {
+ margin-right: 28px;
+ }
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+
+ > .count {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+ }
+
+ &.reacted {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ &.max-width_500px {
+ font-size: 0.9em;
+ }
+
+ &.max-width_450px {
+ > .renote {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .info {
+ padding: 8px 16px 0 16px;
+ }
+
+ > .article {
+ padding: 14px 16px 9px;
+
+ > .avatar {
+ margin: 0 10px 8px 0;
+ width: 50px;
+ height: 50px;
+ top: calc(14px + var(--stickyTop, 0px));
+ }
+ }
+ }
+
+ &.max-width_350px {
+ > .article {
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 18px;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_300px {
+ font-size: 0.825em;
+
+ > .article {
+ > .avatar {
+ width: 44px;
+ height: 44px;
+ }
+
+ > .main {
+ > .footer {
+ > .button {
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+.muted {
+ padding: 8px;
+ text-align: center;
+ opacity: 0.7;
+}
+</style>
diff --git a/packages/client/src/components/notes.vue b/packages/client/src/components/notes.vue
new file mode 100644
index 0000000000..1e7da7a2b0
--- /dev/null
+++ b/packages/client/src/components/notes.vue
@@ -0,0 +1,130 @@
+<template>
+<transition name="fade" mode="out-in">
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-else-if="error" @retry="init()"/>
+
+ <div class="_fullinfo" v-else-if="empty">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noNotes }}</div>
+ </div>
+
+ <div v-else class="giivymft" :class="{ noGap }">
+ <div v-show="more && reversed" style="margin-bottom: var(--margin);">
+ <MkButton style="margin: 0 auto;" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+
+ <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
+ <XNote class="qtqtichx" :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
+ </XList>
+
+ <div v-show="more && !reversed" style="margin-top: var(--margin);">
+ <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import XNote from './note.vue';
+import XList from './date-separated-list.vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ XNote, XList, MkButton,
+ },
+
+ mixins: [
+ paging({
+ before: (self) => {
+ self.$emit('before');
+ },
+
+ after: (self, e) => {
+ self.$emit('after', e);
+ }
+ }),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+ prop: {
+ type: String,
+ required: false
+ },
+ noGap: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['before', 'after'],
+
+ computed: {
+ notes(): any[] {
+ return this.prop ? this.items.map(item => item[this.prop]) : this.items;
+ },
+
+ reversed(): boolean {
+ return this.pagination.reversed;
+ }
+ },
+
+ methods: {
+ updated(oldValue, newValue) {
+ const i = this.notes.findIndex(n => n === oldValue);
+ if (this.prop) {
+ this.items[i][this.prop] = newValue;
+ } else {
+ this.items[i] = newValue;
+ }
+ },
+
+ focus() {
+ this.$refs.notes.focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.giivymft {
+ &.noGap {
+ > .notes {
+ background: var(--panel);
+ }
+ }
+
+ &:not(.noGap) {
+ > .notes {
+ background: var(--bg);
+
+ .qtqtichx {
+ background: var(--panel);
+ border-radius: var(--radius);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue
new file mode 100644
index 0000000000..ec1efec261
--- /dev/null
+++ b/packages/client/src/components/notification-setting-window.vue
@@ -0,0 +1,99 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="400"
+ :height="450"
+ :with-ok-button="true"
+ :ok-button-disabled="false"
+ @ok="ok()"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.notificationSetting }}</template>
+ <div class="_monolithic_">
+ <div v-if="showGlobalToggle" class="_section">
+ <MkSwitch v-model="useGlobalSetting">
+ {{ $ts.useGlobalSetting }}
+ <template #caption>{{ $ts.useGlobalSettingDesc }}</template>
+ </MkSwitch>
+ </div>
+ <div v-if="!useGlobalSetting" class="_section">
+ <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo>
+ <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton>
+ <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton>
+ <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkSwitch from './form/switch.vue';
+import MkInfo from './ui/info.vue';
+import MkButton from './ui/button.vue';
+import { notificationTypes } from 'misskey-js';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkSwitch,
+ MkInfo,
+ MkButton
+ },
+
+ props: {
+ includingTypes: {
+ // TODO: これで型に合わないものを弾いてくれるのかどうか要調査
+ type: Array as PropType<typeof notificationTypes[number][]>,
+ required: false,
+ default: null,
+ },
+ showGlobalToggle: {
+ type: Boolean,
+ required: false,
+ default: true,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ typesMap: {} as Record<typeof notificationTypes[number], boolean>,
+ useGlobalSetting: false,
+ notificationTypes,
+ };
+ },
+
+ created() {
+ this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle;
+
+ for (const type of this.notificationTypes) {
+ this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type);
+ }
+ },
+
+ methods: {
+ ok() {
+ const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][])
+ .filter(type => this.typesMap[type]);
+
+ this.$emit('done', { includingTypes });
+ this.$refs.dialog.close();
+ },
+
+ disableAll() {
+ for (const type in this.typesMap) {
+ this.typesMap[type as typeof notificationTypes[number]] = false;
+ }
+ },
+
+ enableAll() {
+ for (const type in this.typesMap) {
+ this.typesMap[type as typeof notificationTypes[number]] = true;
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue
new file mode 100644
index 0000000000..b629820043
--- /dev/null
+++ b/packages/client/src/components/notification.vue
@@ -0,0 +1,362 @@
+<template>
+<div class="qglefbjs" :class="notification.type" v-size="{ max: [500, 600] }" ref="elRef">
+ <div class="head">
+ <MkAvatar v-if="notification.user" class="icon" :user="notification.user"/>
+ <img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
+ <div class="sub-icon" :class="notification.type">
+ <i v-if="notification.type === 'follow'" class="fas fa-plus"></i>
+ <i v-else-if="notification.type === 'receiveFollowRequest'" class="fas fa-clock"></i>
+ <i v-else-if="notification.type === 'followRequestAccepted'" class="fas fa-check"></i>
+ <i v-else-if="notification.type === 'groupInvited'" class="fas fa-id-card-alt"></i>
+ <i v-else-if="notification.type === 'renote'" class="fas fa-retweet"></i>
+ <i v-else-if="notification.type === 'reply'" class="fas fa-reply"></i>
+ <i v-else-if="notification.type === 'mention'" class="fas fa-at"></i>
+ <i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i>
+ <i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i>
+ <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
+ <XReactionIcon v-else-if="notification.type === 'reaction'"
+ :reaction="notification.reaction ? notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : notification.reaction"
+ :custom-emojis="notification.note.emojis"
+ :no-style="true"
+ @touchstart.passive="onReactionMouseover"
+ @mouseover="onReactionMouseover"
+ @mouseleave="onReactionMouseleave"
+ @touchend="onReactionMouseleave"
+ ref="reactionRef"
+ />
+ </div>
+ </div>
+ <div class="tail">
+ <header>
+ <MkA v-if="notification.user" class="name" :to="userPage(notification.user)" v-user-preview="notification.user.id"><MkUserName :user="notification.user"/></MkA>
+ <span v-else>{{ notification.header }}</span>
+ <MkTime :time="notification.createdAt" v-if="withTime" class="time"/>
+ </header>
+ <MkA v-if="notification.type === 'reaction'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <i class="fas fa-quote-left"></i>
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ <i class="fas fa-quote-right"></i>
+ </MkA>
+ <MkA v-if="notification.type === 'renote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note.renote)">
+ <i class="fas fa-quote-left"></i>
+ <Mfm :text="getNoteSummary(notification.note.renote)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.renote.emojis"/>
+ <i class="fas fa-quote-right"></i>
+ </MkA>
+ <MkA v-if="notification.type === 'reply'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ </MkA>
+ <MkA v-if="notification.type === 'mention'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ </MkA>
+ <MkA v-if="notification.type === 'quote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ </MkA>
+ <MkA v-if="notification.type === 'pollVote'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
+ <i class="fas fa-quote-left"></i>
+ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
+ <i class="fas fa-quote-right"></i>
+ </MkA>
+ <span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
+ <span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span>
+ <span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span>
+ <span v-if="notification.type === 'groupInvited'" class="text" style="opacity: 0.6;">{{ $ts.groupInvited }}: <b>{{ notification.invitation.group.name }}</b><div v-if="full && !groupInviteDone"><button class="_textButton" @click="acceptGroupInvitation()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectGroupInvitation()">{{ $ts.reject }}</button></div></span>
+ <span v-if="notification.type === 'app'" class="text">
+ <Mfm :text="notification.body" :nowrap="!full"/>
+ </span>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, onMounted, onUnmounted } from 'vue';
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import XReactionIcon from './reaction-icon.vue';
+import MkFollowButton from './follow-button.vue';
+import XReactionTooltip from './reaction-tooltip.vue';
+import notePage from '@/filters/note';
+import { userPage } from '@/filters/user';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XReactionIcon, MkFollowButton
+ },
+
+ props: {
+ notification: {
+ type: Object,
+ required: true,
+ },
+ withTime: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ setup(props) {
+ const elRef = ref<HTMLElement>(null);
+ const reactionRef = ref(null);
+
+ onMounted(() => {
+ let readObserver: IntersectionObserver = null;
+ let connection = null;
+
+ if (!props.notification.isRead) {
+ readObserver = new IntersectionObserver((entries, observer) => {
+ if (!entries.some(entry => entry.isIntersecting)) return;
+ os.stream.send('readNotification', {
+ id: props.notification.id
+ });
+ entries.map(({ target }) => observer.unobserve(target));
+ });
+
+ readObserver.observe(elRef.value);
+
+ connection = os.stream.useChannel('main');
+ connection.on('readAllNotifications', () => readObserver.unobserve(elRef.value));
+ }
+
+ onUnmounted(() => {
+ if (readObserver) readObserver.unobserve(elRef.value);
+ if (connection) connection.dispose();
+ });
+ });
+
+ const followRequestDone = ref(false);
+ const groupInviteDone = ref(false);
+
+ const acceptFollowRequest = () => {
+ followRequestDone.value = true;
+ os.api('following/requests/accept', { userId: props.notification.user.id });
+ };
+
+ const rejectFollowRequest = () => {
+ followRequestDone.value = true;
+ os.api('following/requests/reject', { userId: props.notification.user.id });
+ };
+
+ const acceptGroupInvitation = () => {
+ groupInviteDone.value = true;
+ os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id });
+ };
+
+ const rejectGroupInvitation = () => {
+ groupInviteDone.value = true;
+ os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id });
+ };
+
+ let isReactionHovering = false;
+ let reactionTooltipTimeoutId;
+
+ const onReactionMouseover = () => {
+ if (isReactionHovering) return;
+ isReactionHovering = true;
+ reactionTooltipTimeoutId = setTimeout(openReactionTooltip, 300);
+ };
+
+ const onReactionMouseleave = () => {
+ if (!isReactionHovering) return;
+ isReactionHovering = false;
+ clearTimeout(reactionTooltipTimeoutId);
+ closeReactionTooltip();
+ };
+
+ let changeReactionTooltipShowingState: () => void;
+
+ const openReactionTooltip = () => {
+ closeReactionTooltip();
+ if (!isReactionHovering) return;
+
+ const showing = ref(true);
+ os.popup(XReactionTooltip, {
+ showing,
+ reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction,
+ emojis: props.notification.note.emojis,
+ source: reactionRef.value.$el,
+ }, {}, 'closed');
+
+ changeReactionTooltipShowingState = () => {
+ showing.value = false;
+ };
+ };
+
+ const closeReactionTooltip = () => {
+ if (changeReactionTooltipShowingState != null) {
+ changeReactionTooltipShowingState();
+ changeReactionTooltipShowingState = null;
+ }
+ };
+
+ return {
+ getNoteSummary: (text: string) => getNoteSummary(text, i18n.locale),
+ followRequestDone,
+ groupInviteDone,
+ notePage,
+ userPage,
+ acceptFollowRequest,
+ rejectFollowRequest,
+ acceptGroupInvitation,
+ rejectGroupInvitation,
+ onReactionMouseover,
+ onReactionMouseleave,
+ elRef,
+ reactionRef,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.qglefbjs {
+ position: relative;
+ box-sizing: border-box;
+ padding: 24px 32px;
+ font-size: 0.9em;
+ overflow-wrap: break-word;
+ display: flex;
+ contain: content;
+
+ &.max-width_600px {
+ padding: 16px;
+ font-size: 0.9em;
+ }
+
+ &.max-width_500px {
+ padding: 12px;
+ font-size: 0.8em;
+ }
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > .head {
+ position: sticky;
+ top: 0;
+ flex-shrink: 0;
+ width: 42px;
+ height: 42px;
+ margin-right: 8px;
+
+ > .icon {
+ display: block;
+ width: 100%;
+ height: 100%;
+ border-radius: 6px;
+ }
+
+ > .sub-icon {
+ position: absolute;
+ z-index: 1;
+ bottom: -2px;
+ right: -2px;
+ width: 20px;
+ height: 20px;
+ box-sizing: border-box;
+ border-radius: 100%;
+ background: var(--panel);
+ box-shadow: 0 0 0 3px var(--panel);
+ font-size: 12px;
+ text-align: center;
+
+ &:empty {
+ display: none;
+ }
+
+ > * {
+ color: #fff;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.follow, &.followRequestAccepted, &.receiveFollowRequest, &.groupInvited {
+ padding: 3px;
+ background: #36aed2;
+ pointer-events: none;
+ }
+
+ &.renote {
+ padding: 3px;
+ background: #36d298;
+ pointer-events: none;
+ }
+
+ &.quote {
+ padding: 3px;
+ background: #36d298;
+ pointer-events: none;
+ }
+
+ &.reply {
+ padding: 3px;
+ background: #007aff;
+ pointer-events: none;
+ }
+
+ &.mention {
+ padding: 3px;
+ background: #88a6b7;
+ pointer-events: none;
+ }
+
+ &.pollVote {
+ padding: 3px;
+ background: #88a6b7;
+ pointer-events: none;
+ }
+ }
+ }
+
+ > .tail {
+ flex: 1;
+ min-width: 0;
+
+ > header {
+ display: flex;
+ align-items: baseline;
+ white-space: nowrap;
+
+ > .name {
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ > .time {
+ margin-left: auto;
+ font-size: 0.9em;
+ }
+ }
+
+ > .text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > i {
+ vertical-align: super;
+ font-size: 50%;
+ opacity: 0.5;
+ }
+
+ > i:first-child {
+ margin-right: 4px;
+ }
+
+ > i:last-child {
+ margin-left: 4px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
new file mode 100644
index 0000000000..4ebb12c44b
--- /dev/null
+++ b/packages/client/src/components/notifications.vue
@@ -0,0 +1,159 @@
+<template>
+<transition name="fade" mode="out-in">
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-else-if="error" @retry="init()"/>
+
+ <p class="mfcuwfyp" v-else-if="empty">{{ $ts.noNotifications }}</p>
+
+ <div v-else>
+ <XList class="elsfgstc" :items="items" v-slot="{ item: notification }" :no-gap="true">
+ <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/>
+ <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/>
+ </XList>
+
+ <MkButton primary style="margin: var(--margin) auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, markRaw } from 'vue';
+import paging from '@/scripts/paging';
+import XNotification from './notification.vue';
+import XList from './date-separated-list.vue';
+import XNote from './note.vue';
+import { notificationTypes } from 'misskey-js';
+import * as os from '@/os';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ XNotification,
+ XList,
+ XNote,
+ MkButton,
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ includeTypes: {
+ type: Array as PropType<typeof notificationTypes[number][]>,
+ required: false,
+ default: null,
+ },
+ unreadOnly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ connection: null,
+ pagination: {
+ endpoint: 'i/notifications',
+ limit: 10,
+ params: () => ({
+ includeTypes: this.allIncludeTypes || undefined,
+ unreadOnly: this.unreadOnly,
+ })
+ },
+ };
+ },
+
+ computed: {
+ allIncludeTypes() {
+ return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
+ }
+ },
+
+ watch: {
+ includeTypes: {
+ handler() {
+ this.reload();
+ },
+ deep: true
+ },
+ unreadOnly: {
+ handler() {
+ this.reload();
+ },
+ },
+ // TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、
+ // mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す
+ '$i.mutingNotificationTypes': {
+ handler() {
+ if (this.includeTypes === null) {
+ this.reload();
+ }
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('main'));
+ this.connection.on('notification', this.onNotification);
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ onNotification(notification) {
+ const isMuted = !this.allIncludeTypes.includes(notification.type);
+ if (isMuted || document.visibilityState === 'visible') {
+ os.stream.send('readNotification', {
+ id: notification.id
+ });
+ }
+
+ if (!isMuted) {
+ this.prepend({
+ ...notification,
+ isRead: document.visibilityState === 'visible'
+ });
+ }
+ },
+
+ noteUpdated(oldValue, newValue) {
+ const i = this.items.findIndex(n => n.note === oldValue);
+ this.items[i] = {
+ ...this.items[i],
+ note: newValue
+ };
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.mfcuwfyp {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+ color: var(--fg);
+}
+
+.elsfgstc {
+ background: var(--panel);
+}
+</style>
diff --git a/packages/client/src/components/number-diff.vue b/packages/client/src/components/number-diff.vue
new file mode 100644
index 0000000000..9889c97ec3
--- /dev/null
+++ b/packages/client/src/components/number-diff.vue
@@ -0,0 +1,47 @@
+<template>
+<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }">
+ <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot>
+</span>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import number from '@/filters/number';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: true
+ },
+ },
+
+ setup(props) {
+ const isPlus = computed(() => props.value > 0);
+ const isMinus = computed(() => props.value < 0);
+ const isZero = computed(() => props.value === 0);
+ return {
+ isPlus,
+ isMinus,
+ isZero,
+ number,
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ceaaebcd {
+ &.isPlus {
+ color: var(--success);
+ }
+
+ &.isMinus {
+ color: var(--error);
+ }
+
+ &.isZero {
+ opacity: 0.5;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/page-preview.vue
new file mode 100644
index 0000000000..05df1dc16e
--- /dev/null
+++ b/packages/client/src/components/page-preview.vue
@@ -0,0 +1,162 @@
+<template>
+<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1">
+ <div class="thumbnail" v-if="page.eyeCatchingImage" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
+ <article>
+ <header>
+ <h1 :title="page.title">{{ page.title }}</h1>
+ </header>
+ <p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
+ <footer>
+ <img class="icon" :src="page.user.avatarUrl"/>
+ <p>{{ userName(page.user) }}</p>
+ </footer>
+ </article>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { userName } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ page: {
+ type: Object,
+ required: true
+ },
+ },
+ methods: {
+ userName
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vhpxefrj {
+ display: block;
+
+ &:hover {
+ text-decoration: none;
+ color: var(--accent);
+ }
+
+ > .thumbnail {
+ width: 100%;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ > button {
+ font-size: 3.5em;
+ opacity: 0.7;
+
+ &:hover {
+ font-size: 4em;
+ opacity: 0.9;
+ }
+ }
+
+ & + article {
+ left: 100px;
+ width: calc(100% - 100px);
+ }
+ }
+
+ > article {
+ padding: 16px;
+
+ > header {
+ margin-bottom: 8px;
+
+ > h1 {
+ margin: 0;
+ font-size: 1em;
+ color: var(--urlPreviewTitle);
+ }
+ }
+
+ > p {
+ margin: 0;
+ color: var(--urlPreviewText);
+ font-size: 0.8em;
+ }
+
+ > footer {
+ margin-top: 8px;
+ height: 16px;
+
+ > img {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-right: 4px;
+ vertical-align: top;
+ }
+
+ > p {
+ display: inline-block;
+ margin: 0;
+ color: var(--urlPreviewInfo);
+ font-size: 0.8em;
+ line-height: 16px;
+ vertical-align: top;
+ }
+ }
+ }
+
+ @media (max-width: 700px) {
+ > .thumbnail {
+ position: relative;
+ width: 100%;
+ height: 100px;
+
+ & + article {
+ left: 0;
+ width: 100%;
+ }
+ }
+ }
+
+ @media (max-width: 550px) {
+ font-size: 12px;
+
+ > .thumbnail {
+ height: 80px;
+ }
+
+ > article {
+ padding: 12px;
+ }
+ }
+
+ @media (max-width: 500px) {
+ font-size: 10px;
+
+ > .thumbnail {
+ height: 70px;
+ }
+
+ > article {
+ padding: 8px;
+
+ > header {
+ margin-bottom: 4px;
+ }
+
+ > footer {
+ margin-top: 4px;
+
+ > img {
+ width: 12px;
+ height: 12px;
+ }
+ }
+ }
+ }
+}
+
+</style>
diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue
new file mode 100644
index 0000000000..b6be114cd7
--- /dev/null
+++ b/packages/client/src/components/page-window.vue
@@ -0,0 +1,167 @@
+<template>
+<XWindow ref="window"
+ :initial-width="500"
+ :initial-height="500"
+ :can-resize="true"
+ :close-button="true"
+ :contextmenu="contextmenu"
+ @closed="$emit('closed')"
+>
+ <template #header>
+ <template v-if="pageInfo">
+ <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i>
+ <span>{{ pageInfo.title }}</span>
+ </template>
+ </template>
+ <template #headerLeft>
+ <button v-if="history.length > 0" class="_button" @click="back()" v-tooltip="$ts.goBack"><i class="fas fa-arrow-left"></i></button>
+ </template>
+ <div class="yrolvcoq">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <component :is="component" v-bind="props" :ref="changePage"/>
+ </MkStickyContainer>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XWindow from '@/components/ui/window.vue';
+import { popout } from '@/scripts/popout';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ },
+
+ inject: {
+ sideViewHook: {
+ default: null
+ }
+ },
+
+ provide() {
+ return {
+ navHook: (path) => {
+ this.navigate(path);
+ },
+ shouldHeaderThin: true,
+ };
+ },
+
+ props: {
+ initialPath: {
+ type: String,
+ required: true,
+ },
+ initialComponent: {
+ type: Object,
+ required: true,
+ },
+ initialProps: {
+ type: Object,
+ required: false,
+ default: () => {},
+ },
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ pageInfo: null,
+ path: this.initialPath,
+ component: this.initialComponent,
+ props: this.initialProps,
+ history: [],
+ };
+ },
+
+ computed: {
+ url(): string {
+ return url + this.path;
+ },
+
+ contextmenu() {
+ return [{
+ type: 'label',
+ text: this.path,
+ }, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: this.expand
+ }, this.sideViewHook ? {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.sideViewHook(this.path);
+ this.$refs.window.close();
+ }
+ } : undefined, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.popout,
+ action: this.popout
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.url, '_blank');
+ this.$refs.window.close();
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(this.url);
+ }
+ }];
+ },
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ navigate(path, record = true) {
+ if (record) this.history.push(this.path);
+ this.path = path;
+ const { component, props } = resolve(path);
+ this.component = component;
+ this.props = props;
+ },
+
+ back() {
+ this.navigate(this.history.pop(), false);
+ },
+
+ close() {
+ this.$refs.window.close();
+ },
+
+ expand() {
+ this.$router.push(this.path);
+ this.$refs.window.close();
+ },
+
+ popout() {
+ popout(this.path, this.$el);
+ this.$refs.window.close();
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.yrolvcoq {
+ min-height: 100%;
+}
+</style>
diff --git a/packages/client/src/components/page/page.block.vue b/packages/client/src/components/page/page.block.vue
new file mode 100644
index 0000000000..54b8b30276
--- /dev/null
+++ b/packages/client/src/components/page/page.block.vue
@@ -0,0 +1,44 @@
+<template>
+<component :is="'x-' + block.type" :block="block" :hpml="hpml" :key="block.id" :h="h"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import XText from './page.text.vue';
+import XSection from './page.section.vue';
+import XImage from './page.image.vue';
+import XButton from './page.button.vue';
+import XNumberInput from './page.number-input.vue';
+import XTextInput from './page.text-input.vue';
+import XTextareaInput from './page.textarea-input.vue';
+import XSwitch from './page.switch.vue';
+import XIf from './page.if.vue';
+import XTextarea from './page.textarea.vue';
+import XPost from './page.post.vue';
+import XCounter from './page.counter.vue';
+import XRadioButton from './page.radio-button.vue';
+import XCanvas from './page.canvas.vue';
+import XNote from './page.note.vue';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { Block } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton, XCanvas, XNote
+ },
+ props: {
+ block: {
+ type: Object as PropType<Block>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ },
+ h: {
+ type: Number,
+ required: true
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/components/page/page.button.vue b/packages/client/src/components/page/page.button.vue
new file mode 100644
index 0000000000..51da84bd49
--- /dev/null
+++ b/packages/client/src/components/page/page.button.vue
@@ -0,0 +1,66 @@
+<template>
+<div>
+ <MkButton class="kudkigyw" @click="click()" :primary="block.primary">{{ hpml.interpolate(block.text) }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType, unref } from 'vue';
+import MkButton from '../ui/button.vue';
+import * as os from '@/os';
+import { ButtonBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ props: {
+ block: {
+ type: Object as PropType<ButtonBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ methods: {
+ click() {
+ if (this.block.action === 'dialog') {
+ this.hpml.eval();
+ os.dialog({
+ text: this.hpml.interpolate(this.block.content)
+ });
+ } else if (this.block.action === 'resetRandom') {
+ this.hpml.updateRandomSeed(Math.random());
+ this.hpml.eval();
+ } else if (this.block.action === 'pushEvent') {
+ os.api('page-push', {
+ pageId: this.hpml.page.id,
+ event: this.block.event,
+ ...(this.block.var ? {
+ var: unref(this.hpml.vars)[this.block.var]
+ } : {})
+ });
+
+ os.dialog({
+ type: 'success',
+ text: this.hpml.interpolate(this.block.message)
+ });
+ } else if (this.block.action === 'callAiScript') {
+ this.hpml.callAiScript(this.block.fn);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+ display: inline-block;
+ min-width: 200px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.canvas.vue b/packages/client/src/components/page/page.canvas.vue
new file mode 100644
index 0000000000..8f49b88e5e
--- /dev/null
+++ b/packages/client/src/components/page/page.canvas.vue
@@ -0,0 +1,49 @@
+<template>
+<div class="ysrxegms">
+ <canvas ref="canvas" :width="block.width" :height="block.height"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+import * as os from '@/os';
+import { CanvasBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ props: {
+ block: {
+ type: Object as PropType<CanvasBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const canvas: Ref<any> = ref(null);
+
+ onMounted(() => {
+ props.hpml.registerCanvas(props.block.name, canvas.value);
+ });
+
+ return {
+ canvas
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ysrxegms {
+ display: inline-block;
+ vertical-align: bottom;
+ overflow: auto;
+ max-width: 100%;
+
+ > canvas {
+ display: block;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.counter.vue b/packages/client/src/components/page/page.counter.vue
new file mode 100644
index 0000000000..b1af8954b0
--- /dev/null
+++ b/packages/client/src/components/page/page.counter.vue
@@ -0,0 +1,52 @@
+<template>
+<div>
+ <MkButton class="llumlmnx" @click="click()">{{ hpml.interpolate(block.text) }}</MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkButton from '../ui/button.vue';
+import * as os from '@/os';
+import { CounterVarBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ props: {
+ block: {
+ type: Object as PropType<CounterVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function click() {
+ props.hpml.updatePageVar(props.block.name, value.value + (props.block.inc || 1));
+ props.hpml.eval();
+ }
+
+ return {
+ click
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.llumlmnx {
+ display: inline-block;
+ min-width: 300px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.if.vue b/packages/client/src/components/page/page.if.vue
new file mode 100644
index 0000000000..ec25332db0
--- /dev/null
+++ b/packages/client/src/components/page/page.if.vue
@@ -0,0 +1,31 @@
+<template>
+<div v-show="hpml.vars.value[block.var]">
+ <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h"/>
+</div>
+</template>
+
+<script lang="ts">
+import { IfBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineComponent, defineAsyncComponent, PropType } from 'vue';
+
+export default defineComponent({
+ components: {
+ XBlock: defineAsyncComponent(() => import('./page.block.vue'))
+ },
+ props: {
+ block: {
+ type: Object as PropType<IfBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ },
+ h: {
+ type: Number,
+ required: true
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/components/page/page.image.vue b/packages/client/src/components/page/page.image.vue
new file mode 100644
index 0000000000..04ce74bd7c
--- /dev/null
+++ b/packages/client/src/components/page/page.image.vue
@@ -0,0 +1,40 @@
+<template>
+<div class="lzyxtsnt">
+ <img v-if="image" :src="image.url"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import * as os from '@/os';
+import { ImageBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ props: {
+ block: {
+ type: Object as PropType<ImageBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const image = props.hpml.page.attachedFiles.find(x => x.id === props.block.fileId);
+
+ return {
+ image
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lzyxtsnt {
+ > img {
+ max-width: 100%;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.note.vue b/packages/client/src/components/page/page.note.vue
new file mode 100644
index 0000000000..925844c1bd
--- /dev/null
+++ b/packages/client/src/components/page/page.note.vue
@@ -0,0 +1,47 @@
+<template>
+<div class="voxdxuby">
+ <XNote v-if="note && !block.detailed" v-model:note="note" :key="note.id + ':normal'"/>
+ <XNoteDetailed v-if="note && block.detailed" v-model:note="note" :key="note.id + ':detail'"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, PropType, Ref, ref } from 'vue';
+import XNote from '@/components/note.vue';
+import XNoteDetailed from '@/components/note-detailed.vue';
+import * as os from '@/os';
+import { NoteBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ XNote,
+ XNoteDetailed,
+ },
+ props: {
+ block: {
+ type: Object as PropType<NoteBlock>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const note: Ref<Record<string, any> | null> = ref(null);
+
+ onMounted(() => {
+ os.api('notes/show', { noteId: props.block.note })
+ .then(result => {
+ note.value = result;
+ });
+ });
+
+ return {
+ note
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.voxdxuby {
+ margin: 1em 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.number-input.vue b/packages/client/src/components/page/page.number-input.vue
new file mode 100644
index 0000000000..b5120d0f85
--- /dev/null
+++ b/packages/client/src/components/page/page.number-input.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+ <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="number">
+ <template #label>{{ hpml.interpolate(block.text) }}</template>
+ </MkInput>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkInput from '../form/input.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { NumberInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkInput
+ },
+ props: {
+ block: {
+ type: Object as PropType<NumberInputVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+ display: inline-block;
+ min-width: 300px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.post.vue b/packages/client/src/components/page/page.post.vue
new file mode 100644
index 0000000000..1b86ea1ab9
--- /dev/null
+++ b/packages/client/src/components/page/page.post.vue
@@ -0,0 +1,109 @@
+<template>
+<div class="ngbfujlo">
+ <MkTextarea :model-value="text" readonly style="margin: 0;"></MkTextarea>
+ <MkButton class="button" primary @click="post()" :disabled="posting || posted">
+ <i v-if="posted" class="fas fa-check"></i>
+ <i v-else class="fas fa-paper-plane"></i>
+ </MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+import MkButton from '../ui/button.vue';
+import { apiUrl } from '@/config';
+import * as os from '@/os';
+import { PostBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ MkTextarea,
+ MkButton,
+ },
+ props: {
+ block: {
+ type: Object as PropType<PostBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ data() {
+ return {
+ text: this.hpml.interpolate(this.block.text),
+ posted: false,
+ posting: false,
+ };
+ },
+ watch: {
+ 'hpml.vars': {
+ handler() {
+ this.text = this.hpml.interpolate(this.block.text);
+ },
+ deep: true
+ }
+ },
+ methods: {
+ upload() {
+ const promise = new Promise((ok) => {
+ const canvas = this.hpml.canvases[this.block.canvasId];
+ canvas.toBlob(blob => {
+ const data = new FormData();
+ data.append('file', blob);
+ data.append('i', this.$i.token);
+ if (this.$store.state.uploadFolder) {
+ data.append('folderId', this.$store.state.uploadFolder);
+ }
+
+ fetch(apiUrl + '/drive/files/create', {
+ method: 'POST',
+ body: data
+ })
+ .then(response => response.json())
+ .then(f => {
+ ok(f);
+ })
+ });
+ });
+ os.promiseDialog(promise);
+ return promise;
+ },
+ async post() {
+ this.posting = true;
+ const file = this.block.attachCanvasImage ? await this.upload() : null;
+ os.apiWithDialog('notes/create', {
+ text: this.text === '' ? null : this.text,
+ fileIds: file ? [file.id] : undefined,
+ }).then(() => {
+ this.posted = true;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ngbfujlo {
+ position: relative;
+ padding: 32px;
+ border-radius: 6px;
+ box-shadow: 0 2px 8px var(--shadow);
+ z-index: 1;
+
+ > .button {
+ margin-top: 32px;
+ }
+
+ @media (max-width: 600px) {
+ padding: 16px;
+
+ > .button {
+ margin-top: 16px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.radio-button.vue b/packages/client/src/components/page/page.radio-button.vue
new file mode 100644
index 0000000000..4d3c03291e
--- /dev/null
+++ b/packages/client/src/components/page/page.radio-button.vue
@@ -0,0 +1,45 @@
+<template>
+<div>
+ <div>{{ hpml.interpolate(block.title) }}</div>
+ <MkRadio v-for="item in block.values" :modelValue="value" @update:modelValue="updateValue($event)" :value="item" :key="item">{{ item }}</MkRadio>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkRadio from '../form/radio.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { RadioButtonVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkRadio
+ },
+ props: {
+ block: {
+ type: Object as PropType<RadioButtonVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue: string) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
diff --git a/packages/client/src/components/page/page.section.vue b/packages/client/src/components/page/page.section.vue
new file mode 100644
index 0000000000..d32f5dc732
--- /dev/null
+++ b/packages/client/src/components/page/page.section.vue
@@ -0,0 +1,60 @@
+<template>
+<section class="sdgxphyu">
+ <component :is="'h' + h">{{ block.title }}</component>
+
+ <div class="children">
+ <XBlock v-for="child in block.children" :block="child" :hpml="hpml" :key="child.id" :h="h + 1"/>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, PropType } from 'vue';
+import * as os from '@/os';
+import { SectionBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+
+export default defineComponent({
+ components: {
+ XBlock: defineAsyncComponent(() => import('./page.block.vue'))
+ },
+ props: {
+ block: {
+ type: Object as PropType<SectionBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ },
+ h: {
+ required: true
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.sdgxphyu {
+ margin: 1.5em 0;
+
+ > h2 {
+ font-size: 1.35em;
+ margin: 0 0 0.5em 0;
+ }
+
+ > h3 {
+ font-size: 1em;
+ margin: 0 0 0.5em 0;
+ }
+
+ > h4 {
+ font-size: 1em;
+ margin: 0 0 0.5em 0;
+ }
+
+ > .children {
+ //padding 16px
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.switch.vue b/packages/client/src/components/page/page.switch.vue
new file mode 100644
index 0000000000..1ece88157f
--- /dev/null
+++ b/packages/client/src/components/page/page.switch.vue
@@ -0,0 +1,55 @@
+<template>
+<div class="hkcxmtwj">
+ <MkSwitch :model-value="value" @update:modelValue="updateValue($event)">{{ hpml.interpolate(block.text) }}</MkSwitch>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkSwitch from '../form/switch.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { SwitchVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkSwitch
+ },
+ props: {
+ block: {
+ type: Object as PropType<SwitchVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue: boolean) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hkcxmtwj {
+ display: inline-block;
+ margin: 16px auto;
+
+ & + .hkcxmtwj {
+ margin-left: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.text-input.vue b/packages/client/src/components/page/page.text-input.vue
new file mode 100644
index 0000000000..e4d3f6039a
--- /dev/null
+++ b/packages/client/src/components/page/page.text-input.vue
@@ -0,0 +1,55 @@
+<template>
+<div>
+ <MkInput class="kudkigyw" :model-value="value" @update:modelValue="updateValue($event)" type="text">
+ <template #label>{{ hpml.interpolate(block.text) }}</template>
+ </MkInput>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkInput from '../form/input.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { TextInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkInput
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextInputVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kudkigyw {
+ display: inline-block;
+ min-width: 300px;
+ max-width: 450px;
+ margin: 8px 0;
+}
+</style>
diff --git a/packages/client/src/components/page/page.text.vue b/packages/client/src/components/page/page.text.vue
new file mode 100644
index 0000000000..7dd41ed869
--- /dev/null
+++ b/packages/client/src/components/page/page.text.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="mrdgzndn">
+ <Mfm :text="text" :is-note="false" :i="$i" :key="text"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" class="url"/>
+</div>
+</template>
+
+<script lang="ts">
+import { TextBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineAsyncComponent, defineComponent, PropType } from 'vue';
+import * as mfm from 'mfm-js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+export default defineComponent({
+ components: {
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ data() {
+ return {
+ text: this.hpml.interpolate(this.block.text),
+ };
+ },
+ computed: {
+ urls(): string[] {
+ if (this.text) {
+ return extractUrlFromMfm(mfm.parse(this.text));
+ } else {
+ return [];
+ }
+ }
+ },
+ watch: {
+ 'hpml.vars': {
+ handler() {
+ this.text = this.hpml.interpolate(this.block.text);
+ },
+ deep: true
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mrdgzndn {
+ &:not(:first-child) {
+ margin-top: 0.5em;
+ }
+
+ &:not(:last-child) {
+ margin-bottom: 0.5em;
+ }
+
+ > .url {
+ margin: 0.5em 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/page/page.textarea-input.vue b/packages/client/src/components/page/page.textarea-input.vue
new file mode 100644
index 0000000000..6e082b2bef
--- /dev/null
+++ b/packages/client/src/components/page/page.textarea-input.vue
@@ -0,0 +1,47 @@
+<template>
+<div>
+ <MkTextarea :model-value="value" @update:modelValue="updateValue($event)">
+ <template #label>{{ hpml.interpolate(block.text) }}</template>
+ </MkTextarea>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+import * as os from '@/os';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { HpmlTextInput } from '@/scripts/hpml';
+import { TextInputVarBlock } from '@/scripts/hpml/block';
+
+export default defineComponent({
+ components: {
+ MkTextarea
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextInputVarBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ setup(props, ctx) {
+ const value = computed(() => {
+ return props.hpml.vars.value[props.block.name];
+ });
+
+ function updateValue(newValue) {
+ props.hpml.updatePageVar(props.block.name, newValue);
+ props.hpml.eval();
+ }
+
+ return {
+ value,
+ updateValue
+ };
+ }
+});
+</script>
diff --git a/packages/client/src/components/page/page.textarea.vue b/packages/client/src/components/page/page.textarea.vue
new file mode 100644
index 0000000000..5b4ee2b452
--- /dev/null
+++ b/packages/client/src/components/page/page.textarea.vue
@@ -0,0 +1,39 @@
+<template>
+<MkTextarea :model-value="text" readonly></MkTextarea>
+</template>
+
+<script lang="ts">
+import { TextBlock } from '@/scripts/hpml/block';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { defineComponent, PropType } from 'vue';
+import MkTextarea from '../form/textarea.vue';
+
+export default defineComponent({
+ components: {
+ MkTextarea
+ },
+ props: {
+ block: {
+ type: Object as PropType<TextBlock>,
+ required: true
+ },
+ hpml: {
+ type: Object as PropType<Hpml>,
+ required: true
+ }
+ },
+ data() {
+ return {
+ text: this.hpml.interpolate(this.block.text),
+ };
+ },
+ watch: {
+ 'hpml.vars': {
+ handler() {
+ this.text = this.hpml.interpolate(this.block.text);
+ },
+ deep: true
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue
new file mode 100644
index 0000000000..6d1c419a40
--- /dev/null
+++ b/packages/client/src/components/page/page.vue
@@ -0,0 +1,86 @@
+<template>
+<div class="iroscrza" :class="{ center: page.alignCenter, serif: page.font === 'serif' }" v-if="hpml">
+ <XBlock v-for="child in page.content" :block="child" :hpml="hpml" :key="child.id" :h="2"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, nextTick, onUnmounted, PropType } from 'vue';
+import { parse } from '@syuilo/aiscript';
+import XBlock from './page.block.vue';
+import { Hpml } from '@/scripts/hpml/evaluator';
+import { url } from '@/config';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ XBlock
+ },
+ props: {
+ page: {
+ type: Object as PropType<Record<string, any>>,
+ required: true
+ },
+ },
+ setup(props, ctx) {
+
+ const hpml = new Hpml(props.page, {
+ randomSeed: Math.random(),
+ visitor: $i,
+ url: url,
+ enableAiScript: !defaultStore.state.disablePagesScript
+ });
+
+ onMounted(() => {
+ nextTick(() => {
+ if (props.page.script && hpml.aiscript) {
+ let ast;
+ try {
+ ast = parse(props.page.script);
+ } catch (e) {
+ console.error(e);
+ /*os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });*/
+ return;
+ }
+ hpml.aiscript.exec(ast).then(() => {
+ hpml.eval();
+ }).catch(e => {
+ console.error(e);
+ /*os.dialog({
+ type: 'error',
+ text: e
+ });*/
+ });
+ } else {
+ hpml.eval();
+ }
+ });
+ onUnmounted(() => {
+ if (hpml.aiscript) hpml.aiscript.abort();
+ });
+ });
+
+ return {
+ hpml,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.iroscrza {
+ &.serif {
+ > div {
+ font-family: serif;
+ }
+ }
+
+ &.center {
+ text-align: center;
+ }
+}
+</style>
diff --git a/packages/client/src/components/particle.vue b/packages/client/src/components/particle.vue
new file mode 100644
index 0000000000..d82705c1e8
--- /dev/null
+++ b/packages/client/src/components/particle.vue
@@ -0,0 +1,114 @@
+<template>
+<div class="vswabwbm" :style="{ top: `${y - 64}px`, left: `${x - 64}px` }" :class="{ active }">
+ <svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
+ <circle fill="none" cx="64" cy="64">
+ <animate attributeName="r"
+ begin="0s" dur="0.5s"
+ values="4; 32"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.165, 0.84, 0.44, 1"
+ repeatCount="1"
+ />
+ <animate attributeName="stroke-width"
+ begin="0s" dur="0.5s"
+ values="16; 0"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.3, 0.61, 0.355, 1"
+ repeatCount="1"
+ />
+ </circle>
+ <g fill="none" fill-rule="evenodd">
+ <circle v-for="(particle, i) in particles" :key="i" :fill="particle.color">
+ <animate attributeName="r"
+ begin="0s" dur="0.8s"
+ :values="`${particle.size}; 0`"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.165, 0.84, 0.44, 1"
+ repeatCount="1"
+ />
+ <animate attributeName="cx"
+ begin="0s" dur="0.8s"
+ :values="`${particle.xA}; ${particle.xB}`"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.3, 0.61, 0.355, 1"
+ repeatCount="1"
+ />
+ <animate attributeName="cy"
+ begin="0s" dur="0.8s"
+ :values="`${particle.yA}; ${particle.yB}`"
+ calcMode="spline"
+ keyTimes="0; 1"
+ keySplines="0.3, 0.61, 0.355, 1"
+ repeatCount="1"
+ />
+ </circle>
+ </g>
+ </svg>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ x: {
+ type: Number,
+ required: true
+ },
+ y: {
+ type: Number,
+ required: true
+ }
+ },
+ emits: ['end'],
+ data() {
+ const particles = [];
+ const origin = 64;
+ const colors = ['#FF1493', '#00FFFF', '#FFE202'];
+
+ for (let i = 0; i < 12; i++) {
+ const angle = Math.random() * (Math.PI * 2);
+ const pos = Math.random() * 16;
+ const velocity = 16 + (Math.random() * 48);
+ particles.push({
+ size: 4 + (Math.random() * 8),
+ xA: origin + (Math.sin(angle) * pos),
+ yA: origin + (Math.cos(angle) * pos),
+ xB: origin + (Math.sin(angle) * (pos + velocity)),
+ yB: origin + (Math.cos(angle) * (pos + velocity)),
+ color: colors[Math.floor(Math.random() * colors.length)]
+ });
+ }
+
+ return {
+ particles
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.$emit('end');
+ }, 1100);
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vswabwbm {
+ pointer-events: none;
+ position: fixed;
+ z-index: 1000000;
+ width: 128px;
+ height: 128px;
+
+ > svg {
+ > circle {
+ stroke: var(--accent);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue
new file mode 100644
index 0000000000..aa213cfe49
--- /dev/null
+++ b/packages/client/src/components/poll-editor.vue
@@ -0,0 +1,251 @@
+<template>
+<div class="zmdxowus">
+ <p class="caution" v-if="choices.length < 2">
+ <i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }}
+ </p>
+ <ul ref="choices">
+ <li v-for="(choice, i) in choices" :key="i">
+ <MkInput class="input" :model-value="choice" @update:modelValue="onInput(i, $event)" :placeholder="$t('_poll.choiceN', { n: i + 1 })">
+ </MkInput>
+ <button @click="remove(i)" class="_button">
+ <i class="fas fa-times"></i>
+ </button>
+ </li>
+ </ul>
+ <MkButton class="add" v-if="choices.length < 10" @click="add">{{ $ts.add }}</MkButton>
+ <MkButton class="add" v-else disabled>{{ $ts._poll.noMore }}</MkButton>
+ <section>
+ <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch>
+ <div>
+ <MkSelect v-model="expiration">
+ <template #label>{{ $ts._poll.expiration }}</template>
+ <option value="infinite">{{ $ts._poll.infinite }}</option>
+ <option value="at">{{ $ts._poll.at }}</option>
+ <option value="after">{{ $ts._poll.after }}</option>
+ </MkSelect>
+ <section v-if="expiration === 'at'">
+ <MkInput v-model="atDate" type="date" class="input">
+ <template #label>{{ $ts._poll.deadlineDate }}</template>
+ </MkInput>
+ <MkInput v-model="atTime" type="time" class="input">
+ <template #label>{{ $ts._poll.deadlineTime }}</template>
+ </MkInput>
+ </section>
+ <section v-if="expiration === 'after'">
+ <MkInput v-model="after" type="number" class="input">
+ <template #label>{{ $ts._poll.duration }}</template>
+ </MkInput>
+ <MkSelect v-model="unit">
+ <option value="second">{{ $ts._time.second }}</option>
+ <option value="minute">{{ $ts._time.minute }}</option>
+ <option value="hour">{{ $ts._time.hour }}</option>
+ <option value="day">{{ $ts._time.day }}</option>
+ </MkSelect>
+ </section>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { addTime } from '@/scripts/time';
+import { formatDateTimeString } from '@/scripts/format-time-string';
+import MkInput from './form/input.vue';
+import MkSelect from './form/select.vue';
+import MkSwitch from './form/switch.vue';
+import MkButton from './ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkInput,
+ MkSelect,
+ MkSwitch,
+ MkButton,
+ },
+
+ props: {
+ poll: {
+ type: Object,
+ required: true
+ }
+ },
+
+ emits: ['updated'],
+
+ data() {
+ return {
+ choices: this.poll.choices,
+ multiple: this.poll.multiple,
+ expiration: 'infinite',
+ atDate: formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'),
+ atTime: '00:00',
+ after: 0,
+ unit: 'second',
+ };
+ },
+
+ watch: {
+ choices: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ deep: true
+ },
+ multiple: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ expiration: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ atDate: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ after: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ unit: {
+ handler() {
+ this.$emit('updated', this.get());
+ },
+ },
+ },
+
+ created() {
+ const poll = this.poll;
+ if (poll.expiresAt) {
+ this.expiration = 'at';
+ this.atDate = this.atTime = poll.expiresAt;
+ } else if (typeof poll.expiredAfter === 'number') {
+ this.expiration = 'after';
+ this.after = poll.expiredAfter / 1000;
+ } else {
+ this.expiration = 'infinite';
+ }
+ },
+
+ methods: {
+ onInput(i, e) {
+ this.choices[i] = e;
+ },
+
+ add() {
+ this.choices.push('');
+ this.$nextTick(() => {
+ // TODO
+ //(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
+ });
+ },
+
+ remove(i) {
+ this.choices = this.choices.filter((_, _i) => _i != i);
+ },
+
+ get() {
+ const at = () => {
+ return new Date(`${this.atDate} ${this.atTime}`).getTime();
+ };
+
+ const after = () => {
+ let base = parseInt(this.after);
+ switch (this.unit) {
+ case 'day': base *= 24;
+ case 'hour': base *= 60;
+ case 'minute': base *= 60;
+ case 'second': return base *= 1000;
+ default: return null;
+ }
+ };
+
+ return {
+ choices: this.choices,
+ multiple: this.multiple,
+ ...(
+ this.expiration === 'at' ? { expiresAt: at() } :
+ this.expiration === 'after' ? { expiredAfter: after() } : {}
+ )
+ };
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zmdxowus {
+ padding: 8px;
+
+ > .caution {
+ margin: 0 0 8px 0;
+ font-size: 0.8em;
+ color: #f00;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+
+ > ul {
+ display: block;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ > li {
+ display: flex;
+ margin: 8px 0;
+ padding: 0;
+ width: 100%;
+
+ > .input {
+ flex: 1;
+ margin-top: 16px;
+ margin-bottom: 0;
+ }
+
+ > button {
+ width: 32px;
+ padding: 4px 0;
+ }
+ }
+ }
+
+ > .add {
+ margin: 8px 0 0 0;
+ z-index: 1;
+ }
+
+ > section {
+ margin: 16px 0 -16px 0;
+
+ > div {
+ margin: 0 8px;
+
+ &:last-child {
+ flex: 1 0 auto;
+
+ > section {
+ align-items: center;
+ display: flex;
+ margin: -32px 0 0;
+
+ > &:first-child {
+ margin-right: 16px;
+ }
+
+ > .input {
+ flex: 1 0 auto;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue
new file mode 100644
index 0000000000..049fe3a435
--- /dev/null
+++ b/packages/client/src/components/poll.vue
@@ -0,0 +1,174 @@
+<template>
+<div class="tivcixzd" :class="{ done: closed || isVoted }">
+ <ul>
+ <li v-for="(choice, i) in poll.choices" :key="i" @click="vote(i)" :class="{ voted: choice.voted }">
+ <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
+ <span>
+ <template v-if="choice.isVoted"><i class="fas fa-check"></i></template>
+ <Mfm :text="choice.text" :plain="true" :custom-emojis="note.emojis"/>
+ <span class="votes" v-if="showResult">({{ $t('_poll.votesCount', { n: choice.votes }) }})</span>
+ </span>
+ </li>
+ </ul>
+ <p v-if="!readOnly">
+ <span>{{ $t('_poll.totalVotes', { n: total }) }}</span>
+ <span> · </span>
+ <a v-if="!closed && !isVoted" @click="toggleShowResult">{{ showResult ? $ts._poll.vote : $ts._poll.showResult }}</a>
+ <span v-if="isVoted">{{ $ts._poll.voted }}</span>
+ <span v-else-if="closed">{{ $ts._poll.closed }}</span>
+ <span v-if="remaining > 0"> · {{ timer }}</span>
+ </p>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { sum } from '@/scripts/array';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ readOnly: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+ data() {
+ return {
+ remaining: -1,
+ showResult: false,
+ };
+ },
+ computed: {
+ poll(): any {
+ return this.note.poll;
+ },
+ total(): number {
+ return sum(this.poll.choices.map(x => x.votes));
+ },
+ closed(): boolean {
+ return !this.remaining;
+ },
+ timer(): string {
+ return this.$t(
+ this.remaining >= 86400 ? '_poll.remainingDays' :
+ this.remaining >= 3600 ? '_poll.remainingHours' :
+ this.remaining >= 60 ? '_poll.remainingMinutes' : '_poll.remainingSeconds', {
+ s: Math.floor(this.remaining % 60),
+ m: Math.floor(this.remaining / 60) % 60,
+ h: Math.floor(this.remaining / 3600) % 24,
+ d: Math.floor(this.remaining / 86400)
+ });
+ },
+ isVoted(): boolean {
+ return !this.poll.multiple && this.poll.choices.some(c => c.isVoted);
+ }
+ },
+ created() {
+ this.showResult = this.readOnly || this.isVoted;
+
+ if (this.note.poll.expiresAt) {
+ const update = () => {
+ if (this.remaining = Math.floor(Math.max(new Date(this.note.poll.expiresAt).getTime() - Date.now(), 0) / 1000))
+ requestAnimationFrame(update);
+ else
+ this.showResult = true;
+ };
+
+ update();
+ }
+ },
+ methods: {
+ toggleShowResult() {
+ this.showResult = !this.showResult;
+ },
+ vote(id) {
+ if (this.readOnly || this.closed || !this.poll.multiple && this.poll.choices.some(c => c.isVoted)) return;
+ os.api('notes/polls/vote', {
+ noteId: this.note.id,
+ choice: id
+ }).then(() => {
+ if (!this.showResult) this.showResult = !this.poll.multiple;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tivcixzd {
+ > ul {
+ display: block;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ > li {
+ display: block;
+ position: relative;
+ margin: 4px 0;
+ padding: 4px 8px;
+ border: solid 0.5px var(--divider);
+ border-radius: 4px;
+ overflow: hidden;
+ cursor: pointer;
+
+ &:hover {
+ background: rgba(#000, 0.05);
+ }
+
+ &:active {
+ background: rgba(#000, 0.1);
+ }
+
+ > .backdrop {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ background: var(--accent);
+ transition: width 1s ease;
+ }
+
+ > span {
+ position: relative;
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > .votes {
+ margin-left: 4px;
+ }
+ }
+ }
+ }
+
+ > p {
+ color: var(--fg);
+
+ a {
+ color: inherit;
+ }
+ }
+
+ &.done {
+ > ul > li {
+ cursor: default;
+
+ &:hover {
+ background: transparent;
+ }
+
+ &:active {
+ background: transparent;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue
new file mode 100644
index 0000000000..dff0dec21e
--- /dev/null
+++ b/packages/client/src/components/post-form-attaches.vue
@@ -0,0 +1,193 @@
+<template>
+<div class="skeikyzd" v-show="files.length != 0">
+ <XDraggable class="files" v-model="_files" item-key="id" animation="150" delay="100" delay-on-touch-only="true">
+ <template #item="{element}">
+ <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
+ <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
+ <div class="sensitive" v-if="element.isSensitive">
+ <i class="fas fa-exclamation-triangle icon"></i>
+ </div>
+ </div>
+ </template>
+ </XDraggable>
+ <p class="remain">{{ 4 - files.length }}/4</p>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import MkDriveFileThumbnail from './drive-file-thumbnail.vue'
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ MkDriveFileThumbnail
+ },
+
+ props: {
+ files: {
+ type: Array,
+ required: true
+ },
+ detachMediaFn: {
+ type: Function,
+ required: false
+ }
+ },
+
+ emits: ['updated', 'detach', 'changeSensitive', 'changeName'],
+
+ data() {
+ return {
+ menu: null as Promise<null> | null,
+
+ };
+ },
+
+ computed: {
+ _files: {
+ get() {
+ return this.files;
+ },
+ set(value) {
+ this.$emit('updated', value);
+ }
+ }
+ },
+
+ methods: {
+ detachMedia(id) {
+ if (this.detachMediaFn) {
+ this.detachMediaFn(id);
+ } else {
+ this.$emit('detach', id);
+ }
+ },
+ toggleSensitive(file) {
+ os.api('drive/files/update', {
+ fileId: file.id,
+ isSensitive: !file.isSensitive
+ }).then(() => {
+ this.$emit('changeSensitive', file, !file.isSensitive);
+ });
+ },
+ async rename(file) {
+ const { canceled, result } = await os.dialog({
+ title: this.$ts.enterFileName,
+ input: {
+ default: file.name
+ },
+ allowEmpty: false
+ });
+ if (canceled) return;
+ os.api('drive/files/update', {
+ fileId: file.id,
+ name: result
+ }).then(() => {
+ this.$emit('changeName', file, result);
+ file.name = result;
+ });
+ },
+
+ async describe(file) {
+ os.popup(import("@/components/media-caption.vue"), {
+ title: this.$ts.describeFile,
+ input: {
+ placeholder: this.$ts.inputNewDescription,
+ default: file.comment !== null ? file.comment : "",
+ },
+ image: file
+ }, {
+ done: result => {
+ if (!result || result.canceled) return;
+ let comment = result.result;
+ os.api('drive/files/update', {
+ fileId: file.id,
+ comment: comment.length == 0 ? null : comment
+ });
+ }
+ }, 'closed');
+ },
+
+ showFileMenu(file, ev: MouseEvent) {
+ if (this.menu) return;
+ this.menu = os.popupMenu([{
+ text: this.$ts.renameFile,
+ icon: 'fas fa-i-cursor',
+ action: () => { this.rename(file) }
+ }, {
+ text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
+ icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye',
+ action: () => { this.toggleSensitive(file) }
+ }, {
+ text: this.$ts.describeFile,
+ icon: 'fas fa-i-cursor',
+ action: () => { this.describe(file) }
+ }, {
+ text: this.$ts.attachCancel,
+ icon: 'fas fa-times-circle',
+ action: () => { this.detachMedia(file.id) }
+ }], ev.currentTarget || ev.target).then(() => this.menu = null);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.skeikyzd {
+ padding: 8px 16px;
+ position: relative;
+
+ > .files {
+ display: flex;
+ flex-wrap: wrap;
+
+ > div {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ margin-right: 4px;
+ border-radius: 4px;
+ overflow: hidden;
+ cursor: move;
+
+ &:hover > .remove {
+ display: block;
+ }
+
+ > .thumbnail {
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ color: var(--fg);
+ }
+
+ > .sensitive {
+ display: flex;
+ position: absolute;
+ width: 64px;
+ height: 64px;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ background: rgba(17, 17, 17, .7);
+ color: #fff;
+
+ > .icon {
+ margin: auto;
+ }
+ }
+ }
+ }
+
+ > .remain {
+ display: block;
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ margin: 0;
+ padding: 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/post-form-dialog.vue b/packages/client/src/components/post-form-dialog.vue
new file mode 100644
index 0000000000..ae1cd7f01e
--- /dev/null
+++ b/packages/client/src/components/post-form-dialog.vue
@@ -0,0 +1,19 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')" :position="'top'">
+ <MkPostForm @posted="$refs.modal.close()" @cancel="$refs.modal.close()" @esc="$refs.modal.close()" v-bind="$attrs"/>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import MkPostForm from '@/components/post-form.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkPostForm,
+ },
+ emits: ['closed'],
+});
+</script>
diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue
new file mode 100644
index 0000000000..ce6b7db3ee
--- /dev/null
+++ b/packages/client/src/components/post-form.vue
@@ -0,0 +1,980 @@
+<template>
+<div class="gafaadew" :class="{ modal, _popup: modal }"
+ v-size="{ max: [310, 500] }"
+ @dragover.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.stop="onDrop"
+>
+ <header>
+ <button v-if="!fixed" class="cancel _button" @click="cancel"><i class="fas fa-times"></i></button>
+ <div>
+ <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
+ <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span>
+ <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
+ <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
+ <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span>
+ <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
+ <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
+ </button>
+ <button class="_button preview" @click="showPreview = !showPreview" :class="{ active: showPreview }" v-tooltip="$ts.previewNoteText"><i class="fas fa-file-code"></i></button>
+ <button class="submit _buttonGradate" :disabled="!canPost" @click="post" data-cy-open-post-form-submit>{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button>
+ </div>
+ </header>
+ <div class="form" :class="{ fixed }">
+ <XNoteSimple class="preview" v-if="reply" :note="reply"/>
+ <XNoteSimple class="preview" v-if="renote" :note="renote"/>
+ <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div>
+ <div v-if="visibility === 'specified'" class="to-specified">
+ <span style="margin-right: 8px;">{{ $ts.recipient }}</span>
+ <div class="visibleUsers">
+ <span v-for="u in visibleUsers" :key="u.id">
+ <MkAcct :user="u"/>
+ <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button>
+ </span>
+ <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button>
+ </div>
+ </div>
+ <MkInfo warn v-if="hasNotSpecifiedMentions" class="hasNotSpecifiedMentions">{{ $ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ $ts.add }}</button></MkInfo>
+ <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
+ <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" data-cy-post-form-text/>
+ <input v-show="withHashtags" ref="hashtags" class="hashtags" v-model="hashtags" :placeholder="$ts.hashtags" list="hashtags">
+ <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
+ <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
+ <XNotePreview class="preview" v-if="showPreview" :text="text"/>
+ <footer>
+ <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button>
+ <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button>
+ <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button>
+ <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button>
+ <button class="_button" @click="withHashtags = !withHashtags" :class="{ active: withHashtags }" v-tooltip="$ts.hashtags"><i class="fas fa-hashtag"></i></button>
+ <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button>
+ <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button>
+ </footer>
+ <datalist id="hashtags">
+ <option v-for="hashtag in recentHashtags" :value="hashtag" :key="hashtag"/>
+ </datalist>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import { length } from 'stringz';
+import { toASCII } from 'punycode/';
+import XNoteSimple from './note-simple.vue';
+import XNotePreview from './note-preview.vue';
+import * as mfm from 'mfm-js';
+import { host, url } from '@/config';
+import { erase, unique } from '@/scripts/array';
+import { extractMentions } from '@/scripts/extract-mentions';
+import * as Acct from 'misskey-js/built/acct';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { Autocomplete } from '@/scripts/autocomplete';
+import { noteVisibilities } from 'misskey-js';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
+import { isMobile } from '@/scripts/is-mobile';
+import { throttle } from 'throttle-debounce';
+import MkInfo from '@/components/ui/info.vue';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ XNoteSimple,
+ XNotePreview,
+ XPostFormAttaches: defineAsyncComponent(() => import('./post-form-attaches.vue')),
+ XPollEditor: defineAsyncComponent(() => import('./poll-editor.vue')),
+ MkInfo,
+ },
+
+ inject: ['modal'],
+
+ props: {
+ reply: {
+ type: Object,
+ required: false
+ },
+ renote: {
+ type: Object,
+ required: false
+ },
+ channel: {
+ type: Object,
+ required: false
+ },
+ mention: {
+ type: Object,
+ required: false
+ },
+ specified: {
+ type: Object,
+ required: false
+ },
+ initialText: {
+ type: String,
+ required: false
+ },
+ initialVisibility: {
+ type: String,
+ required: false
+ },
+ initialFiles: {
+ type: Array,
+ required: false
+ },
+ initialLocalOnly: {
+ type: Boolean,
+ required: false
+ },
+ visibleUsers: {
+ type: Array,
+ required: false,
+ default: () => []
+ },
+ initialNote: {
+ type: Object,
+ required: false
+ },
+ share: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ fixed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ },
+
+ emits: ['posted', 'cancel', 'esc'],
+
+ data() {
+ return {
+ posting: false,
+ text: '',
+ files: [],
+ poll: null,
+ useCw: false,
+ showPreview: false,
+ cw: null,
+ localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
+ visibility: (this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility) as typeof noteVisibilities[number],
+ autocomplete: null,
+ draghover: false,
+ quoteId: null,
+ hasNotSpecifiedMentions: false,
+ recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
+ imeText: '',
+ typing: throttle(3000, () => {
+ if (this.channel) {
+ os.stream.send('typingOnChannel', { channel: this.channel.id });
+ }
+ }),
+ postFormActions,
+ };
+ },
+
+ computed: {
+ draftKey(): string {
+ let key = this.channel ? `channel:${this.channel.id}` : '';
+
+ if (this.renote) {
+ key += `renote:${this.renote.id}`;
+ } else if (this.reply) {
+ key += `reply:${this.reply.id}`;
+ } else {
+ key += 'note';
+ }
+
+ return key;
+ },
+
+ placeholder(): string {
+ if (this.renote) {
+ return this.$ts._postForm.quotePlaceholder;
+ } else if (this.reply) {
+ return this.$ts._postForm.replyPlaceholder;
+ } else if (this.channel) {
+ return this.$ts._postForm.channelPlaceholder;
+ } else {
+ const xs = [
+ this.$ts._postForm._placeholders.a,
+ this.$ts._postForm._placeholders.b,
+ this.$ts._postForm._placeholders.c,
+ this.$ts._postForm._placeholders.d,
+ this.$ts._postForm._placeholders.e,
+ this.$ts._postForm._placeholders.f
+ ];
+ return xs[Math.floor(Math.random() * xs.length)];
+ }
+ },
+
+ submitText(): string {
+ return this.renote
+ ? this.$ts.quote
+ : this.reply
+ ? this.$ts.reply
+ : this.$ts.note;
+ },
+
+ textLength(): number {
+ return length((this.text + this.imeText).trim());
+ },
+
+ canPost(): boolean {
+ return !this.posting &&
+ (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
+ (this.textLength <= this.max) &&
+ (!this.poll || this.poll.choices.length >= 2);
+ },
+
+ max(): number {
+ return this.$instance ? this.$instance.maxNoteTextLength : 1000;
+ },
+
+ withHashtags: defaultStore.makeGetterSetter('postFormWithHashtags'),
+ hashtags: defaultStore.makeGetterSetter('postFormHashtags'),
+ },
+
+ watch: {
+ text() {
+ this.checkMissingMention();
+ },
+ visibleUsers: {
+ handler() {
+ this.checkMissingMention();
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ if (this.initialText) {
+ this.text = this.initialText;
+ }
+
+ if (this.initialVisibility) {
+ this.visibility = this.initialVisibility;
+ }
+
+ if (this.initialFiles) {
+ this.files = this.initialFiles;
+ }
+
+ if (typeof this.initialLocalOnly === 'boolean') {
+ this.localOnly = this.initialLocalOnly;
+ }
+
+ if (this.mention) {
+ this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
+ this.text += ' ';
+ }
+
+ if (this.reply && this.reply.user.host != null) {
+ this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
+ }
+
+ if (this.reply && this.reply.text != null) {
+ const ast = mfm.parse(this.reply.text);
+
+ for (const x of extractMentions(ast)) {
+ const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
+
+ // 自分は除外
+ if (this.$i.username == x.username && x.host == null) continue;
+ if (this.$i.username == x.username && x.host == host) continue;
+
+ // 重複は除外
+ if (this.text.indexOf(`${mention} `) != -1) continue;
+
+ this.text += `${mention} `;
+ }
+ }
+
+ if (this.channel) {
+ this.visibility = 'public';
+ this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+ }
+
+ // 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+ if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
+ this.visibility = this.reply.visibility;
+ if (this.reply.visibility === 'specified') {
+ os.api('users/show', {
+ userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
+ }).then(users => {
+ this.visibleUsers.push(...users);
+ });
+
+ if (this.reply.userId !== this.$i.id) {
+ os.api('users/show', { userId: this.reply.userId }).then(user => {
+ this.visibleUsers.push(user);
+ });
+ }
+ }
+ }
+
+ if (this.specified) {
+ this.visibility = 'specified';
+ this.visibleUsers.push(this.specified);
+ }
+
+ // keep cw when reply
+ if (this.$store.state.keepCw && this.reply && this.reply.cw) {
+ this.useCw = true;
+ this.cw = this.reply.cw;
+ }
+
+ if (this.autofocus) {
+ this.focus();
+
+ this.$nextTick(() => {
+ this.focus();
+ });
+ }
+
+ // TODO: detach when unmount
+ new Autocomplete(this.$refs.text, this, { model: 'text' });
+ new Autocomplete(this.$refs.cw, this, { model: 'cw' });
+ new Autocomplete(this.$refs.hashtags, this, { model: 'hashtags' });
+
+ this.$nextTick(() => {
+ // 書きかけの投稿を復元
+ if (!this.share && !this.mention && !this.specified) {
+ const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
+ if (draft) {
+ this.text = draft.data.text;
+ this.useCw = draft.data.useCw;
+ this.cw = draft.data.cw;
+ this.visibility = draft.data.visibility;
+ this.localOnly = draft.data.localOnly;
+ this.files = (draft.data.files || []).filter(e => e);
+ if (draft.data.poll) {
+ this.poll = draft.data.poll;
+ }
+ }
+ }
+
+ // 削除して編集
+ if (this.initialNote) {
+ const init = this.initialNote;
+ this.text = init.text ? init.text : '';
+ this.files = init.files;
+ this.cw = init.cw;
+ this.useCw = init.cw != null;
+ if (init.poll) {
+ this.poll = {
+ choices: init.poll.choices.map(x => x.text),
+ multiple: init.poll.multiple,
+ expiresAt: init.poll.expiresAt,
+ expiredAfter: init.poll.expiredAfter,
+ };
+ }
+ this.visibility = init.visibility;
+ this.localOnly = init.localOnly;
+ this.quoteId = init.renote ? init.renote.id : null;
+ }
+
+ this.$nextTick(() => this.watch());
+ });
+ },
+
+ methods: {
+ watch() {
+ this.$watch('text', () => this.saveDraft());
+ this.$watch('useCw', () => this.saveDraft());
+ this.$watch('cw', () => this.saveDraft());
+ this.$watch('poll', () => this.saveDraft());
+ this.$watch('files', () => this.saveDraft(), { deep: true });
+ this.$watch('visibility', () => this.saveDraft());
+ this.$watch('localOnly', () => this.saveDraft());
+ },
+
+ checkMissingMention() {
+ if (this.visibility === 'specified') {
+ const ast = mfm.parse(this.text);
+
+ for (const x of extractMentions(ast)) {
+ if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+ this.hasNotSpecifiedMentions = true;
+ return;
+ }
+ }
+ this.hasNotSpecifiedMentions = false;
+ }
+ },
+
+ addMissingMention() {
+ const ast = mfm.parse(this.text);
+
+ for (const x of extractMentions(ast)) {
+ if (!this.visibleUsers.some(u => (u.username === x.username) && (u.host == x.host))) {
+ os.api('users/show', { username: x.username, host: x.host }).then(user => {
+ this.visibleUsers.push(user);
+ });
+ }
+ }
+ },
+
+ togglePoll() {
+ if (this.poll) {
+ this.poll = null;
+ } else {
+ this.poll = {
+ choices: ['', ''],
+ multiple: false,
+ expiresAt: null,
+ expiredAfter: null,
+ };
+ }
+ },
+
+ addTag(tag: string) {
+ insertTextAtCursor(this.$refs.text, ` #${tag} `);
+ },
+
+ focus() {
+ (this.$refs.text as any).focus();
+ },
+
+ chooseFileFrom(ev) {
+ selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
+ for (const file of files) {
+ this.files.push(file);
+ }
+ });
+ },
+
+ detachFile(id) {
+ this.files = this.files.filter(x => x.id != id);
+ },
+
+ updateFiles(files) {
+ this.files = files;
+ },
+
+ updateFileSensitive(file, sensitive) {
+ this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+ },
+
+ updateFileName(file, name) {
+ this.files[this.files.findIndex(x => x.id === file.id)].name = name;
+ },
+
+ upload(file: File, name?: string) {
+ os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+ this.files.push(res);
+ });
+ },
+
+ onPollUpdate(poll) {
+ this.poll = poll;
+ this.saveDraft();
+ },
+
+ setVisibility() {
+ if (this.channel) {
+ // TODO: information dialog
+ return;
+ }
+
+ os.popup(import('./visibility-picker.vue'), {
+ currentVisibility: this.visibility,
+ currentLocalOnly: this.localOnly,
+ src: this.$refs.visibilityButton
+ }, {
+ changeVisibility: visibility => {
+ this.visibility = visibility;
+ if (this.$store.state.rememberNoteVisibility) {
+ this.$store.set('visibility', visibility);
+ }
+ },
+ changeLocalOnly: localOnly => {
+ this.localOnly = localOnly;
+ if (this.$store.state.rememberNoteVisibility) {
+ this.$store.set('localOnly', localOnly);
+ }
+ }
+ }, 'closed');
+ },
+
+ addVisibleUser() {
+ os.selectUser().then(user => {
+ this.visibleUsers.push(user);
+ });
+ },
+
+ removeVisibleUser(user) {
+ this.visibleUsers = erase(user, this.visibleUsers);
+ },
+
+ clear() {
+ this.text = '';
+ this.files = [];
+ this.poll = null;
+ this.quoteId = null;
+ },
+
+ onKeydown(e: KeyboardEvent) {
+ if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
+ if (e.which === 27) this.$emit('esc');
+ this.typing();
+ },
+
+ onCompositionUpdate(e: CompositionEvent) {
+ this.imeText = e.data;
+ this.typing();
+ },
+
+ onCompositionEnd(e: CompositionEvent) {
+ this.imeText = '';
+ },
+
+ async onPaste(e: ClipboardEvent) {
+ for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
+ if (item.kind == 'file') {
+ const file = item.getAsFile();
+ const lio = file.name.lastIndexOf('.');
+ const ext = lio >= 0 ? file.name.slice(lio) : '';
+ const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
+ this.upload(file, formatted);
+ }
+ }
+
+ const paste = e.clipboardData.getData('text');
+
+ if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
+ e.preventDefault();
+
+ os.dialog({
+ type: 'info',
+ text: this.$ts.quoteQuestion,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) {
+ insertTextAtCursor(this.$refs.text, paste);
+ return;
+ }
+
+ this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
+ });
+ }
+ },
+
+ onDragover(e) {
+ if (!e.dataTransfer.items[0]) return;
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ e.preventDefault();
+ this.draghover = true;
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ }
+ },
+
+ onDragenter(e) {
+ this.draghover = true;
+ },
+
+ onDragleave(e) {
+ this.draghover = false;
+ },
+
+ onDrop(e): void {
+ this.draghover = false;
+
+ // ファイルだったら
+ if (e.dataTransfer.files.length > 0) {
+ e.preventDefault();
+ for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.files.push(file);
+ e.preventDefault();
+ }
+ //#endregion
+ },
+
+ saveDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+ data[this.draftKey] = {
+ updatedAt: new Date(),
+ data: {
+ text: this.text,
+ useCw: this.useCw,
+ cw: this.cw,
+ visibility: this.visibility,
+ localOnly: this.localOnly,
+ files: this.files,
+ poll: this.poll
+ }
+ };
+
+ localStorage.setItem('drafts', JSON.stringify(data));
+ },
+
+ deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+ delete data[this.draftKey];
+
+ localStorage.setItem('drafts', JSON.stringify(data));
+ },
+
+ async post() {
+ let data = {
+ text: this.text == '' ? undefined : this.text,
+ fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
+ replyId: this.reply ? this.reply.id : undefined,
+ renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
+ channelId: this.channel ? this.channel.id : undefined,
+ poll: this.poll,
+ cw: this.useCw ? this.cw || '' : undefined,
+ localOnly: this.localOnly,
+ visibility: this.visibility,
+ visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
+ viaMobile: isMobile
+ };
+
+ if (this.withHashtags && this.hashtags && this.hashtags.trim() !== '') {
+ const hashtags = this.hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' ');
+ data.text = data.text ? `${data.text} ${hashtags}` : hashtags;
+ }
+
+ // plugin
+ if (notePostInterruptors.length > 0) {
+ for (const interruptor of notePostInterruptors) {
+ data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+ }
+ }
+
+ this.posting = true;
+ os.api('notes/create', data).then(() => {
+ this.clear();
+ this.$nextTick(() => {
+ this.deleteDraft();
+ this.$emit('posted');
+ if (data.text && data.text != '') {
+ const hashtags = mfm.parse(data.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
+ const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+ localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
+ }
+ this.posting = false;
+ });
+ }).catch(err => {
+ this.posting = false;
+ os.dialog({
+ type: 'error',
+ text: err.message + '\n' + (err as any).id,
+ });
+ });
+ },
+
+ cancel() {
+ this.$emit('cancel');
+ },
+
+ insertMention() {
+ os.selectUser().then(user => {
+ insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
+ });
+ },
+
+ async insertEmoji(ev) {
+ os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
+ },
+
+ showActions(ev) {
+ os.popupMenu(postFormActions.map(action => ({
+ text: action.title,
+ action: () => {
+ action.handler({
+ text: this.text
+ }, (key, value) => {
+ if (key === 'text') { this.text = value; }
+ });
+ }
+ })), ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.gafaadew {
+ position: relative;
+
+ &.modal {
+ width: 100%;
+ max-width: 520px;
+ }
+
+ > header {
+ z-index: 1000;
+ height: 66px;
+
+ > .cancel {
+ padding: 0;
+ font-size: 20px;
+ width: 64px;
+ line-height: 66px;
+ }
+
+ > div {
+ position: absolute;
+ top: 0;
+ right: 0;
+
+ > .text-count {
+ opacity: 0.7;
+ line-height: 66px;
+ }
+
+ > .visibility {
+ height: 34px;
+ width: 34px;
+ margin: 0 0 0 8px;
+
+ & + .localOnly {
+ margin-left: 0 !important;
+ }
+ }
+
+ > .local-only {
+ margin: 0 0 0 12px;
+ opacity: 0.7;
+ }
+
+ > .preview {
+ display: inline-block;
+ padding: 0;
+ margin: 0 8px 0 0;
+ font-size: 16px;
+ width: 34px;
+ height: 34px;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+
+ &.active {
+ color: var(--accent);
+ }
+ }
+
+ > .submit {
+ margin: 16px 16px 16px 0;
+ padding: 0 12px;
+ line-height: 34px;
+ font-weight: bold;
+ vertical-align: bottom;
+ border-radius: 4px;
+ font-size: 0.9em;
+
+ &:disabled {
+ opacity: 0.7;
+ }
+
+ > i {
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+
+ > .form {
+ > .preview {
+ padding: 16px;
+ }
+
+ > .with-quote {
+ margin: 0 0 8px 0;
+ color: var(--accent);
+
+ > button {
+ padding: 4px 8px;
+ color: var(--accentAlpha04);
+
+ &:hover {
+ color: var(--accentAlpha06);
+ }
+
+ &:active {
+ color: var(--accentDarken30);
+ }
+ }
+ }
+
+ > .to-specified {
+ padding: 6px 24px;
+ margin-bottom: 8px;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .visibleUsers {
+ display: inline;
+ top: -1px;
+ font-size: 14px;
+
+ > button {
+ padding: 4px;
+ border-radius: 8px;
+ }
+
+ > span {
+ margin-right: 14px;
+ padding: 8px 0 8px 8px;
+ border-radius: 8px;
+ background: var(--X4);
+
+ > button {
+ padding: 4px 8px;
+ }
+ }
+ }
+ }
+
+ > .hasNotSpecifiedMentions {
+ margin: 0 20px 16px 20px;
+ }
+
+ > .cw,
+ > .hashtags,
+ > .text {
+ display: block;
+ box-sizing: border-box;
+ padding: 0 24px;
+ margin: 0;
+ width: 100%;
+ font-size: 16px;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--fg);
+ font-family: inherit;
+
+ &:focus {
+ outline: none;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ }
+ }
+
+ > .cw {
+ z-index: 1;
+ padding-bottom: 8px;
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .hashtags {
+ z-index: 1;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .text {
+ max-width: 100%;
+ min-width: 100%;
+ min-height: 90px;
+
+ &.withCw {
+ padding-top: 8px;
+ }
+ }
+
+ > footer {
+ padding: 0 16px 16px 16px;
+
+ > button {
+ display: inline-block;
+ padding: 0;
+ margin: 0;
+ font-size: 16px;
+ width: 48px;
+ height: 48px;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+
+ &.active {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+
+ &.max-width_500px {
+ > header {
+ height: 50px;
+
+ > .cancel {
+ width: 50px;
+ line-height: 50px;
+ }
+
+ > div {
+ > .text-count {
+ line-height: 50px;
+ }
+
+ > .submit {
+ margin: 8px;
+ }
+ }
+ }
+
+ > .form {
+ > .to-specified {
+ padding: 6px 16px;
+ }
+
+ > .cw,
+ > .hashtags,
+ > .text {
+ padding: 0 16px;
+ }
+
+ > .text {
+ min-height: 80px;
+ }
+
+ > footer {
+ padding: 0 8px 8px 8px;
+ }
+ }
+ }
+
+ &.max-width_310px {
+ > .form {
+ > footer {
+ > button {
+ font-size: 14px;
+ width: 44px;
+ height: 44px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue
new file mode 100644
index 0000000000..7e0ed58cbd
--- /dev/null
+++ b/packages/client/src/components/queue-chart.vue
@@ -0,0 +1,232 @@
+<template>
+<canvas ref="chartEl"></canvas>
+</template>
+
+<script lang="ts">
+import { defineComponent, onMounted, onUnmounted, ref } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+} from 'chart.js';
+import number from '@/filters/number';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ TimeScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle,
+ Filler,
+);
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+
+export default defineComponent({
+ props: {
+ domain: {
+ type: String,
+ required: true,
+ },
+ connection: {
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const chartEl = ref<HTMLCanvasElement>(null);
+
+ const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+
+ // フォントカラー
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ onMounted(() => {
+ const chartInstance = new Chart(chartEl.value, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Process',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00E396',
+ backgroundColor: alpha('#00E396', 0.1),
+ data: []
+ }, {
+ label: 'Active',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#00BCD4',
+ backgroundColor: alpha('#00BCD4', 0.1),
+ data: []
+ }, {
+ label: 'Waiting',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#FFB300',
+ backgroundColor: alpha('#FFB300', 0.1),
+ yAxisID: 'y2',
+ data: []
+ }, {
+ label: 'Delayed',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderJoinStyle: 'round',
+ borderColor: '#E53935',
+ borderDash: [5, 5],
+ fill: false,
+ yAxisID: 'y2',
+ data: []
+ }],
+ },
+ options: {
+ aspectRatio: 2.5,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 8,
+ },
+ },
+ scales: {
+ x: {
+ grid: {
+ display: true,
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ ticks: {
+ display: false,
+ maxTicksLimit: 10
+ },
+ },
+ y: {
+ min: 0,
+ stack: 'queue',
+ stackWeight: 2,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ y2: {
+ min: 0,
+ offset: true,
+ stack: 'queue',
+ stackWeight: 1,
+ grid: {
+ color: gridColor,
+ borderColor: 'rgb(0, 0, 0, 0)',
+ },
+ },
+ },
+ interaction: {
+ intersect: false,
+ },
+ plugins: {
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ },
+ },
+ tooltip: {
+ mode: 'index',
+ animation: {
+ duration: 0,
+ },
+ },
+ },
+ },
+ });
+
+ const onStats = (stats) => {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ chartInstance.update();
+ };
+
+ const onStatsLog = (statsLog) => {
+ for (const stats of [...statsLog].reverse()) {
+ chartInstance.data.labels.push('');
+ chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick);
+ chartInstance.data.datasets[1].data.push(stats[props.domain].active);
+ chartInstance.data.datasets[2].data.push(stats[props.domain].waiting);
+ chartInstance.data.datasets[3].data.push(stats[props.domain].delayed);
+ if (chartInstance.data.datasets[0].data.length > 200) {
+ chartInstance.data.labels.shift();
+ chartInstance.data.datasets[0].data.shift();
+ chartInstance.data.datasets[1].data.shift();
+ chartInstance.data.datasets[2].data.shift();
+ chartInstance.data.datasets[3].data.shift();
+ }
+ }
+ chartInstance.update();
+ };
+
+ props.connection.on('stats', onStats);
+ props.connection.on('statsLog', onStatsLog);
+
+ onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ props.connection.off('statsLog', onStatsLog);
+ });
+ });
+
+ return {
+ chartEl,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/components/reaction-icon.vue b/packages/client/src/components/reaction-icon.vue
new file mode 100644
index 0000000000..c0ec955e32
--- /dev/null
+++ b/packages/client/src/components/reaction-icon.vue
@@ -0,0 +1,25 @@
+<template>
+<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ reaction: {
+ type: String,
+ required: true
+ },
+ customEmojis: {
+ required: false,
+ default: () => []
+ },
+ noStyle: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+});
+</script>
diff --git a/packages/client/src/components/reaction-tooltip.vue b/packages/client/src/components/reaction-tooltip.vue
new file mode 100644
index 0000000000..93143cbe81
--- /dev/null
+++ b/packages/client/src/components/reaction-tooltip.vue
@@ -0,0 +1,51 @@
+<template>
+<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340">
+ <div class="beeadbfb">
+ <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+ <div class="name">{{ reaction.replace('@.', '') }}</div>
+ </div>
+</MkTooltip>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+import XReactionIcon from './reaction-icon.vue';
+
+export default defineComponent({
+ components: {
+ MkTooltip,
+ XReactionIcon,
+ },
+ props: {
+ reaction: {
+ type: String,
+ required: true,
+ },
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ source: {
+ required: true,
+ }
+ },
+ emits: ['closed'],
+})
+</script>
+
+<style lang="scss" scoped>
+.beeadbfb {
+ text-align: center;
+
+ > .icon {
+ display: block;
+ width: 60px;
+ margin: 0 auto;
+ }
+
+ > .name {
+ font-size: 0.9em;
+ }
+}
+</style>
diff --git a/packages/client/src/components/reactions-viewer.details.vue b/packages/client/src/components/reactions-viewer.details.vue
new file mode 100644
index 0000000000..7c49bd1d9c
--- /dev/null
+++ b/packages/client/src/components/reactions-viewer.details.vue
@@ -0,0 +1,91 @@
+<template>
+<MkTooltip :source="source" ref="tooltip" @closed="$emit('closed')" :max-width="340">
+ <div class="bqxuuuey">
+ <div class="reaction">
+ <XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
+ <div class="name">{{ reaction.replace('@.', '') }}</div>
+ </div>
+ <div class="users">
+ <template v-if="users.length <= 10">
+ <b v-for="u in users" :key="u.id" style="margin-right: 12px;">
+ <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
+ <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/>
+ </b>
+ </template>
+ <template v-if="10 < users.length">
+ <b v-for="u in users" :key="u.id" style="margin-right: 12px;">
+ <MkAvatar :user="u" style="width: 24px; height: 24px; margin-right: 2px;"/>
+ <MkUserName :user="u" :nowrap="false" style="line-height: 24px;"/>
+ </b>
+ <span slot="omitted">+{{ count - 10 }}</span>
+ </template>
+ </div>
+ </div>
+</MkTooltip>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTooltip from './ui/tooltip.vue';
+import XReactionIcon from './reaction-icon.vue';
+
+export default defineComponent({
+ components: {
+ MkTooltip,
+ XReactionIcon
+ },
+ props: {
+ reaction: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ emojis: {
+ type: Array,
+ required: true,
+ },
+ source: {
+ required: true,
+ }
+ },
+ emits: ['closed'],
+})
+</script>
+
+<style lang="scss" scoped>
+.bqxuuuey {
+ display: flex;
+
+ > .reaction {
+ flex: 1;
+ max-width: 100px;
+ text-align: center;
+
+ > .icon {
+ display: block;
+ width: 60px;
+ margin: 0 auto;
+ }
+
+ > .name {
+ font-size: 0.9em;
+ }
+ }
+
+ > .users {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.9em;
+ border-left: solid 0.5px var(--divider);
+ padding-left: 10px;
+ margin-left: 10px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue
new file mode 100644
index 0000000000..47a3bb9720
--- /dev/null
+++ b/packages/client/src/components/reactions-viewer.reaction.vue
@@ -0,0 +1,183 @@
+<template>
+<button
+ class="hkzvhatu _button"
+ :class="{ reacted: note.myReaction == reaction, canToggle }"
+ @click="toggleReaction(reaction)"
+ v-if="count > 0"
+ @touchstart.passive="onMouseover"
+ @mouseover="onMouseover"
+ @mouseleave="onMouseleave"
+ @touchend="onMouseleave"
+ ref="reaction"
+ v-particle="canToggle"
+>
+ <XReactionIcon :reaction="reaction" :custom-emojis="note.emojis"/>
+ <span>{{ count }}</span>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref } from 'vue';
+import XDetails from '@/components/reactions-viewer.details.vue';
+import XReactionIcon from '@/components/reaction-icon.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XReactionIcon
+ },
+ props: {
+ reaction: {
+ type: String,
+ required: true,
+ },
+ count: {
+ type: Number,
+ required: true,
+ },
+ isInitial: {
+ type: Boolean,
+ required: true,
+ },
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ close: null,
+ detailsTimeoutId: null,
+ isHovering: false
+ };
+ },
+ computed: {
+ canToggle(): boolean {
+ return !this.reaction.match(/@\w/) && this.$i;
+ },
+ },
+ watch: {
+ count(newCount, oldCount) {
+ if (oldCount < newCount) this.anime();
+ if (this.close != null) this.openDetails();
+ },
+ },
+ mounted() {
+ if (!this.isInitial) this.anime();
+ },
+ methods: {
+ toggleReaction() {
+ if (!this.canToggle) return;
+
+ const oldReaction = this.note.myReaction;
+ if (oldReaction) {
+ os.api('notes/reactions/delete', {
+ noteId: this.note.id
+ }).then(() => {
+ if (oldReaction !== this.reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.note.id,
+ reaction: this.reaction
+ });
+ }
+ });
+ } else {
+ os.api('notes/reactions/create', {
+ noteId: this.note.id,
+ reaction: this.reaction
+ });
+ }
+ },
+ onMouseover() {
+ if (this.isHovering) return;
+ this.isHovering = true;
+ this.detailsTimeoutId = setTimeout(this.openDetails, 300);
+ },
+ onMouseleave() {
+ if (!this.isHovering) return;
+ this.isHovering = false;
+ clearTimeout(this.detailsTimeoutId);
+ this.closeDetails();
+ },
+ openDetails() {
+ os.api('notes/reactions', {
+ noteId: this.note.id,
+ type: this.reaction,
+ limit: 11
+ }).then((reactions: any[]) => {
+ const users = reactions
+ .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
+ .map(x => x.user);
+
+ this.closeDetails();
+ if (!this.isHovering) return;
+
+ const showing = ref(true);
+ os.popup(XDetails, {
+ showing,
+ reaction: this.reaction,
+ emojis: this.note.emojis,
+ users,
+ count: this.count,
+ source: this.$refs.reaction
+ }, {}, 'closed');
+
+ this.close = () => {
+ showing.value = false;
+ };
+ });
+ },
+ closeDetails() {
+ if (this.close != null) {
+ this.close();
+ this.close = null;
+ }
+ },
+ anime() {
+ if (document.hidden) return;
+
+ // TODO
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hkzvhatu {
+ display: inline-block;
+ height: 32px;
+ margin: 2px;
+ padding: 0 6px;
+ border-radius: 4px;
+
+ &.canToggle {
+ background: rgba(0, 0, 0, 0.05);
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.1);
+ }
+ }
+
+ &:not(.canToggle) {
+ cursor: default;
+ }
+
+ &.reacted {
+ background: var(--accent);
+
+ &:hover {
+ background: var(--accent);
+ }
+
+ > span {
+ color: var(--fgOnAccent);
+ }
+ }
+
+ > span {
+ font-size: 0.9em;
+ line-height: 32px;
+ margin: 0 0 0 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/reactions-viewer.vue b/packages/client/src/components/reactions-viewer.vue
new file mode 100644
index 0000000000..94a0318734
--- /dev/null
+++ b/packages/client/src/components/reactions-viewer.vue
@@ -0,0 +1,48 @@
+<template>
+<div class="tdflqwzn" :class="{ isMe }">
+ <XReaction v-for="(count, reaction) in note.reactions" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note" :key="reaction"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XReaction from './reactions-viewer.reaction.vue';
+
+export default defineComponent({
+ components: {
+ XReaction
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+ data() {
+ return {
+ initialReactions: new Set(Object.keys(this.note.reactions))
+ };
+ },
+ computed: {
+ isMe(): boolean {
+ return this.$i && this.$i.id === this.note.userId;
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.tdflqwzn {
+ margin: 4px -2px 0 -2px;
+
+ &:empty {
+ display: none;
+ }
+
+ &.isMe {
+ > span {
+ cursor: default !important;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/remote-caution.vue
new file mode 100644
index 0000000000..c496ea8f48
--- /dev/null
+++ b/packages/client/src/components/remote-caution.vue
@@ -0,0 +1,35 @@
+<template>
+<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ href: {
+ type: String,
+ required: true
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jmgmzlwq {
+ font-size: 0.8em;
+ padding: 16px;
+ background: var(--infoWarnBg);
+ color: var(--infoWarnFg);
+
+ > a {
+ margin-left: 4px;
+ color: var(--accent);
+ }
+}
+</style>
diff --git a/packages/client/src/components/sample.vue b/packages/client/src/components/sample.vue
new file mode 100644
index 0000000000..ba6c682c44
--- /dev/null
+++ b/packages/client/src/components/sample.vue
@@ -0,0 +1,116 @@
+<template>
+<div class="_card">
+ <div class="_content">
+ <MkInput v-model="text">
+ <template #label>Text</template>
+ </MkInput>
+ <MkSwitch v-model="flag">
+ <span>Switch is now {{ flag ? 'on' : 'off' }}</span>
+ </MkSwitch>
+ <div style="margin: 32px 0;">
+ <MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
+ <MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
+ <MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
+ </div>
+ <MkButton inline>This is</MkButton>
+ <MkButton inline primary>the button</MkButton>
+ </div>
+ <div class="_content" style="pointer-events: none;">
+ <Mfm :text="mfm"/>
+ </div>
+ <div class="_content">
+ <MkButton inline primary @click="openMenu">Open menu</MkButton>
+ <MkButton inline primary @click="openDialog">Open dialog</MkButton>
+ <MkButton inline primary @click="openForm">Open form</MkButton>
+ <MkButton inline primary @click="openDrive">Open drive</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as config from '@/config';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSwitch,
+ MkTextarea,
+ MkRadio,
+ },
+
+ data() {
+ return {
+ text: '',
+ flag: true,
+ radio: 'misskey',
+ mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`
+ }
+ },
+
+ methods: {
+ async openDialog() {
+ os.dialog({
+ type: 'warning',
+ title: 'Oh my Aichan',
+ text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
+ });
+ },
+
+ async openForm() {
+ os.form('Example form', {
+ foo: {
+ type: 'boolean',
+ default: true,
+ label: 'This is a boolean property'
+ },
+ bar: {
+ type: 'number',
+ default: 300,
+ label: 'This is a number property'
+ },
+ baz: {
+ type: 'string',
+ default: 'Misskey makes you happy.',
+ label: 'This is a string property'
+ },
+ });
+ },
+
+ async openDrive() {
+ os.selectDriveFile();
+ },
+
+ async selectUser() {
+ os.selectUser();
+ },
+
+ async openMenu(ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: 'Fruits'
+ }, {
+ text: 'Create some apples',
+ action: () => {},
+ }, {
+ text: 'Read some oranges',
+ action: () => {},
+ }, {
+ text: 'Update some melons',
+ action: () => {},
+ }, null, {
+ text: 'Delete some bananas',
+ danger: true,
+ action: () => {},
+ }], ev.currentTarget || ev.target);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/components/signin-dialog.vue b/packages/client/src/components/signin-dialog.vue
new file mode 100644
index 0000000000..2edd10f539
--- /dev/null
+++ b/packages/client/src/components/signin-dialog.vue
@@ -0,0 +1,42 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ :height="400"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.login }}</template>
+
+ <MkSignin :auto-set="autoSet" @login="onLogin"/>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkSignin from './signin.vue';
+
+export default defineComponent({
+ components: {
+ MkSignin,
+ XModalWindow,
+ },
+
+ props: {
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ methods: {
+ onLogin(res) {
+ this.$emit('done', res);
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue
new file mode 100644
index 0000000000..68bbd5368e
--- /dev/null
+++ b/packages/client/src/components/signin.vue
@@ -0,0 +1,240 @@
+<template>
+<form class="eppvobhk _monolithic_" :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
+ <div class="auth _section _formRoot">
+ <div class="avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : null }" v-show="withAvatar"></div>
+ <div class="normal-signin" v-if="!totpLogin">
+ <MkInput class="_formBlock" v-model="username" :placeholder="$ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required @update:modelValue="onUsernameChange" data-cy-signin-username>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="password" :placeholder="$ts.password" type="password" :with-password-toggle="true" v-if="!user || user && !user.usePasswordLessLogin" required data-cy-signin-password>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #caption><button class="_textButton" @click="resetPassword" type="button">{{ $ts.forgotPassword }}</button></template>
+ </MkInput>
+ <MkButton class="_formBlock" type="submit" primary :disabled="signing" style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
+ </div>
+ <div class="2fa-signin" v-if="totpLogin" :class="{ securityKeys: user && user.securityKeys }">
+ <div v-if="user && user.securityKeys" class="twofa-group tap-group">
+ <p>{{ $ts.tapSecurityKey }}</p>
+ <MkButton @click="queryKey" v-if="!queryingKey">
+ {{ $ts.retry }}
+ </MkButton>
+ </div>
+ <div class="or-hr" v-if="user && user.securityKeys">
+ <p class="or-msg">{{ $ts.or }}</p>
+ </div>
+ <div class="twofa-group totp-group">
+ <p style="margin-bottom:0;">{{ $ts.twoStepAuthentication }}</p>
+ <MkInput v-model="password" type="password" :with-password-toggle="true" v-if="user && user.usePasswordLessLogin" required>
+ <template #label>{{ $ts.password }}</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ </MkInput>
+ <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required>
+ <template #label>{{ $ts.token }}</template>
+ <template #prefix><i class="fas fa-gavel"></i></template>
+ </MkInput>
+ <MkButton type="submit" :disabled="signing" primary style="margin: 0 auto;">{{ signing ? $ts.loggingIn : $ts.login }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div class="social _section">
+ <a class="_borderButton _gap" v-if="meta && meta.enableTwitterIntegration" :href="`${apiUrl}/signin/twitter`"><i class="fab fa-twitter" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Twitter' }) }}</a>
+ <a class="_borderButton _gap" v-if="meta && meta.enableGithubIntegration" :href="`${apiUrl}/signin/github`"><i class="fab fa-github" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'GitHub' }) }}</a>
+ <a class="_borderButton _gap" v-if="meta && meta.enableDiscordIntegration" :href="`${apiUrl}/signin/discord`"><i class="fab fa-discord" style="margin-right: 4px;"></i>{{ $t('signinWith', { x: 'Discord' }) }}</a>
+ </div>
+</form>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import { apiUrl, host } from '@/config';
+import { byteify, hexify } from '@/scripts/2fa';
+import * as os from '@/os';
+import { login } from '@/account';
+import { showSuspendedDialog } from '../scripts/show-suspended-dialog';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ },
+
+ props: {
+ withAvatar: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['login'],
+
+ data() {
+ return {
+ signing: false,
+ user: null,
+ username: '',
+ password: '',
+ token: '',
+ apiUrl,
+ host: toUnicode(host),
+ totpLogin: false,
+ credential: null,
+ challengeData: null,
+ queryingKey: false,
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+ },
+
+ methods: {
+ onUsernameChange() {
+ os.api('users/show', {
+ username: this.username
+ }).then(user => {
+ this.user = user;
+ }, () => {
+ this.user = null;
+ });
+ },
+
+ onLogin(res) {
+ if (this.autoSet) {
+ return login(res.i);
+ } else {
+ return;
+ }
+ },
+
+ queryKey() {
+ this.queryingKey = true;
+ return navigator.credentials.get({
+ publicKey: {
+ challenge: byteify(this.challengeData.challenge, 'base64'),
+ allowCredentials: this.challengeData.securityKeys.map(key => ({
+ id: byteify(key.id, 'hex'),
+ type: 'public-key',
+ transports: ['usb', 'nfc', 'ble', 'internal']
+ })),
+ timeout: 60 * 1000
+ }
+ }).catch(() => {
+ this.queryingKey = false;
+ return Promise.reject(null);
+ }).then(credential => {
+ this.queryingKey = false;
+ this.signing = true;
+ return os.api('signin', {
+ username: this.username,
+ password: this.password,
+ signature: hexify(credential.response.signature),
+ authenticatorData: hexify(credential.response.authenticatorData),
+ clientDataJSON: hexify(credential.response.clientDataJSON),
+ credentialId: credential.id,
+ challengeId: this.challengeData.challengeId
+ });
+ }).then(res => {
+ this.$emit('login', res);
+ return this.onLogin(res);
+ }).catch(err => {
+ if (err === null) return;
+ os.dialog({
+ type: 'error',
+ text: this.$ts.signinFailed
+ });
+ this.signing = false;
+ });
+ },
+
+ onSubmit() {
+ this.signing = true;
+ if (!this.totpLogin && this.user && this.user.twoFactorEnabled) {
+ if (window.PublicKeyCredential && this.user.securityKeys) {
+ os.api('signin', {
+ username: this.username,
+ password: this.password
+ }).then(res => {
+ this.totpLogin = true;
+ this.signing = false;
+ this.challengeData = res;
+ return this.queryKey();
+ }).catch(this.loginFailed);
+ } else {
+ this.totpLogin = true;
+ this.signing = false;
+ }
+ } else {
+ os.api('signin', {
+ username: this.username,
+ password: this.password,
+ token: this.user && this.user.twoFactorEnabled ? this.token : undefined
+ }).then(res => {
+ this.$emit('login', res);
+ this.onLogin(res);
+ }).catch(this.loginFailed);
+ }
+ },
+
+ loginFailed(err) {
+ switch (err.id) {
+ case '6cc579cc-885d-43d8-95c2-b8c7fc963280': {
+ os.dialog({
+ type: 'error',
+ title: this.$ts.loginFailed,
+ text: this.$ts.noSuchUser
+ });
+ break;
+ }
+ case 'e03a5f46-d309-4865-9b69-56282d94e1eb': {
+ showSuspendedDialog();
+ break;
+ }
+ default: {
+ os.dialog({
+ type: 'error',
+ title: this.$ts.loginFailed,
+ text: JSON.stringify(err)
+ });
+ }
+ }
+
+ this.challengeData = null;
+ this.totpLogin = false;
+ this.signing = false;
+ },
+
+ resetPassword() {
+ os.popup(import('@/components/forgot-password.vue'), {}, {
+ }, 'closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.eppvobhk {
+ > .auth {
+ > .avatar {
+ margin: 0 auto 0 auto;
+ width: 64px;
+ height: 64px;
+ background: #ddd;
+ background-position: center;
+ background-size: cover;
+ border-radius: 100%;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/signup-dialog.vue b/packages/client/src/components/signup-dialog.vue
new file mode 100644
index 0000000000..30fe3bf7d3
--- /dev/null
+++ b/packages/client/src/components/signup-dialog.vue
@@ -0,0 +1,50 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="366"
+ :height="500"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.signup }}</template>
+
+ <div class="_monolithic_">
+ <div class="_section">
+ <XSignup :auto-set="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import XSignup from './signup.vue';
+
+export default defineComponent({
+ components: {
+ XSignup,
+ XModalWindow,
+ },
+
+ props: {
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ methods: {
+ onSignup(res) {
+ this.$emit('done', res);
+ this.$refs.dialog.close();
+ },
+
+ onSignupEmailPending() {
+ this.$refs.dialog.close();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue
new file mode 100644
index 0000000000..621f30486f
--- /dev/null
+++ b/packages/client/src/components/signup.vue
@@ -0,0 +1,268 @@
+<template>
+<form class="qlvuhzng _formRoot" @submit.prevent="onSubmit" :autocomplete="Math.random()">
+ <template v-if="meta">
+ <MkInput class="_formBlock" v-if="meta.disableRegistration" v-model="invitationCode" type="text" :autocomplete="Math.random()" spellcheck="false" required>
+ <template #label>{{ $ts.invitationCode }}</template>
+ <template #prefix><i class="fas fa-key"></i></template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeUsername" data-cy-signup-username>
+ <template #label>{{ $ts.username }} <div class="_button _help" v-tooltip:dialog="$ts.usernameInfo"><i class="far fa-question-circle"></i></div></template>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ <template #caption>
+ <span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
+ <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
+ <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
+ <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
+ <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span>
+ <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span>
+ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-if="meta.emailRequiredForSignup" class="_formBlock" v-model="email" :debounce="true" type="email" :autocomplete="Math.random()" spellcheck="false" required @update:modelValue="onChangeEmail" data-cy-signup-email>
+ <template #label>{{ $ts.emailAddress }} <div class="_button _help" v-tooltip:dialog="$ts._signup.emailAddressInfo"><i class="far fa-question-circle"></i></div></template>
+ <template #prefix><i class="fas fa-envelope"></i></template>
+ <template #caption>
+ <span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
+ <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
+ <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span>
+ <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span>
+ <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span>
+ <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span>
+ <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span>
+ <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
+ <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
+ </template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="password" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePassword" data-cy-signup-password>
+ <template #label>{{ $ts.password }}</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span>
+ <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span>
+ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span>
+ </template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="retypedPassword" type="password" :autocomplete="Math.random()" required @update:modelValue="onChangePasswordRetype" data-cy-signup-password-retype>
+ <template #label>{{ $ts.password }} ({{ $ts.retype }})</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span>
+ <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span>
+ </template>
+ </MkInput>
+ <label v-if="meta.tosUrl" class="_formBlock tou">
+ <input type="checkbox" v-model="ToSAgreement">
+ <I18n :src="$ts.agreeTo">
+ <template #0>
+ <a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a>
+ </template>
+ </I18n>
+ </label>
+ <captcha v-if="meta.enableHcaptcha" class="_formBlock captcha" provider="hcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
+ <captcha v-if="meta.enableRecaptcha" class="_formBlock captcha" provider="recaptcha" ref="recaptcha" v-model="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/>
+ <MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton>
+ </template>
+</form>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+const getPasswordStrength = require('syuilo-password-strength');
+import { toUnicode } from 'punycode/';
+import { host, url } from '@/config';
+import MkButton from './ui/button.vue';
+import MkInput from './form/input.vue';
+import MkSwitch from './form/switch.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSwitch,
+ captcha: defineAsyncComponent(() => import('./captcha.vue')),
+ },
+
+ props: {
+ autoSet: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['signup'],
+
+ data() {
+ return {
+ host: toUnicode(host),
+ username: '',
+ password: '',
+ retypedPassword: '',
+ invitationCode: '',
+ email: '',
+ url,
+ usernameState: null,
+ emailState: null,
+ passwordStrength: '',
+ passwordRetypeState: null,
+ submitting: false,
+ ToSAgreement: false,
+ hCaptchaResponse: null,
+ reCaptchaResponse: null,
+ }
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+
+ shouldDisableSubmitting(): boolean {
+ return this.submitting ||
+ this.meta.tosUrl && !this.ToSAgreement ||
+ this.meta.enableHcaptcha && !this.hCaptchaResponse ||
+ this.meta.enableRecaptcha && !this.reCaptchaResponse ||
+ this.passwordRetypeState == 'not-match';
+ },
+
+ shouldShowProfileUrl(): boolean {
+ return (this.username != '' &&
+ this.usernameState != 'invalid-format' &&
+ this.usernameState != 'min-range' &&
+ this.usernameState != 'max-range');
+ }
+ },
+
+ methods: {
+ onChangeUsername() {
+ if (this.username == '') {
+ this.usernameState = null;
+ return;
+ }
+
+ const err =
+ !this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
+ this.username.length < 1 ? 'min-range' :
+ this.username.length > 20 ? 'max-range' :
+ null;
+
+ if (err) {
+ this.usernameState = err;
+ return;
+ }
+
+ this.usernameState = 'wait';
+
+ os.api('username/available', {
+ username: this.username
+ }).then(result => {
+ this.usernameState = result.available ? 'ok' : 'unavailable';
+ }).catch(err => {
+ this.usernameState = 'error';
+ });
+ },
+
+ onChangeEmail() {
+ if (this.email == '') {
+ this.emailState = null;
+ return;
+ }
+
+ this.emailState = 'wait';
+
+ os.api('email-address/available', {
+ emailAddress: this.email
+ }).then(result => {
+ this.emailState = result.available ? 'ok' :
+ result.reason === 'used' ? 'unavailable:used' :
+ result.reason === 'format' ? 'unavailable:format' :
+ result.reason === 'disposable' ? 'unavailable:disposable' :
+ result.reason === 'mx' ? 'unavailable:mx' :
+ result.reason === 'smtp' ? 'unavailable:smtp' :
+ 'unavailable';
+ }).catch(err => {
+ this.emailState = 'error';
+ });
+ },
+
+ onChangePassword() {
+ if (this.password == '') {
+ this.passwordStrength = '';
+ return;
+ }
+
+ const strength = getPasswordStrength(this.password);
+ this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
+ },
+
+ onChangePasswordRetype() {
+ if (this.retypedPassword == '') {
+ this.passwordRetypeState = null;
+ return;
+ }
+
+ this.passwordRetypeState = this.password == this.retypedPassword ? 'match' : 'not-match';
+ },
+
+ onSubmit() {
+ if (this.submitting) return;
+ this.submitting = true;
+
+ os.api('signup', {
+ username: this.username,
+ password: this.password,
+ emailAddress: this.email,
+ invitationCode: this.invitationCode,
+ 'hcaptcha-response': this.hCaptchaResponse,
+ 'g-recaptcha-response': this.reCaptchaResponse,
+ }).then(() => {
+ if (this.meta.emailRequiredForSignup) {
+ os.dialog({
+ type: 'success',
+ title: this.$ts._signup.almostThere,
+ text: this.$t('_signup.emailSent', { email: this.email }),
+ });
+ this.$emit('signupEmailPending');
+ } else {
+ os.api('signin', {
+ username: this.username,
+ password: this.password
+ }).then(res => {
+ this.$emit('signup', res);
+
+ if (this.autoSet) {
+ login(res.i);
+ }
+ });
+ }
+ }).catch(() => {
+ this.submitting = false;
+ this.$refs.hcaptcha?.reset?.();
+ this.$refs.recaptcha?.reset?.();
+
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qlvuhzng {
+ .captcha {
+ margin: 16px 0;
+ }
+
+ > .tou {
+ display: block;
+ margin: 16px 0;
+ cursor: pointer;
+ }
+}
+</style>
diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue
new file mode 100644
index 0000000000..3aaf03995d
--- /dev/null
+++ b/packages/client/src/components/sparkle.vue
@@ -0,0 +1,179 @@
+<template>
+<span class="mk-sparkle">
+ <span ref="content">
+ <slot></slot>
+ </span>
+ <canvas ref="canvas"></canvas>
+</span>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+const sprite = new Image();
+sprite.src = '/client-assets/sparkle-spritesheet.png';
+
+export default defineComponent({
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ speed: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ sprites: [0,6,13,20],
+ particles: [],
+ anim: null,
+ ctx: null,
+ };
+ },
+ methods: {
+ createSparkles(w, h, count) {
+ var holder = [];
+
+ for (var i = 0; i < count; i++) {
+
+ const color = '#' + ('000000' + Math.floor(Math.random() * 16777215).toString(16)).slice(-6);
+
+ holder[i] = {
+ position: {
+ x: Math.floor(Math.random() * w),
+ y: Math.floor(Math.random() * h)
+ },
+ style: this.sprites[ Math.floor(Math.random() * 4) ],
+ delta: {
+ x: Math.floor(Math.random() * 1000) - 500,
+ y: Math.floor(Math.random() * 1000) - 500
+ },
+ color: color,
+ opacity: Math.random(),
+ };
+
+ }
+
+ return holder;
+ },
+ draw(time) {
+ this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height);
+ this.ctx.beginPath();
+
+ const particleSize = Math.floor(this.fontSize / 2);
+ this.particles.forEach((particle) => {
+ var modulus = Math.floor(Math.random()*7);
+
+ if (Math.floor(time) % modulus === 0) {
+ particle.style = this.sprites[ Math.floor(Math.random()*4) ];
+ }
+
+ this.ctx.save();
+ this.ctx.globalAlpha = particle.opacity;
+ this.ctx.drawImage(sprite, particle.style, 0, 7, 7, particle.position.x, particle.position.y, particleSize, particleSize);
+
+ this.ctx.globalCompositeOperation = "source-atop";
+ this.ctx.globalAlpha = 0.5;
+ this.ctx.fillStyle = particle.color;
+ this.ctx.fillRect(particle.position.x, particle.position.y, particleSize, particleSize);
+
+ this.ctx.restore();
+ });
+ this.ctx.stroke();
+ },
+ tick() {
+ this.anim = window.requestAnimationFrame((time) => {
+ if (!this.$refs.canvas) {
+ return;
+ }
+ this.particles.forEach((particle) => {
+ if (!particle) {
+ return;
+ }
+ var randX = Math.random() > Math.random() * 2;
+ var randY = Math.random() > Math.random() * 3;
+
+ if (randX) {
+ particle.position.x += (particle.delta.x * this.speed) / 1500;
+ }
+
+ if (!randY) {
+ particle.position.y -= (particle.delta.y * this.speed) / 800;
+ }
+
+ if( particle.position.x > this.$refs.canvas.width ) {
+ particle.position.x = -7;
+ } else if (particle.position.x < -7) {
+ particle.position.x = this.$refs.canvas.width;
+ }
+
+ if (particle.position.y > this.$refs.canvas.height) {
+ particle.position.y = -7;
+ particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width);
+ } else if (particle.position.y < -7) {
+ particle.position.y = this.$refs.canvas.height;
+ particle.position.x = Math.floor(Math.random() * this.$refs.canvas.width);
+ }
+
+ particle.opacity -= 0.005;
+
+ if (particle.opacity <= 0) {
+ particle.opacity = 1;
+ }
+ });
+
+ this.draw(time);
+
+ this.tick();
+ });
+ },
+ resize() {
+ if (this.$refs.content) {
+ const contentRect = this.$refs.content.getBoundingClientRect();
+ this.fontSize = parseFloat(getComputedStyle(this.$refs.content).fontSize);
+ const padding = this.fontSize * 0.2;
+
+ this.$refs.canvas.width = parseInt(contentRect.width + padding);
+ this.$refs.canvas.height = parseInt(contentRect.height + padding);
+
+ this.particles = this.createSparkles(this.$refs.canvas.width, this.$refs.canvas.height, this.count);
+ }
+ },
+ },
+ mounted() {
+ this.ctx = this.$refs.canvas.getContext('2d');
+
+ new ResizeObserver(this.resize).observe(this.$refs.content);
+
+ this.resize();
+ this.tick();
+ },
+ updated() {
+ this.resize();
+ },
+ destroyed() {
+ window.cancelAnimationFrame(this.anim);
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-sparkle {
+ position: relative;
+ display: inline-block;
+
+ > span {
+ display: inline-block;
+ }
+
+ > canvas {
+ position: absolute;
+ top: -0.1em;
+ left: -0.1em;
+ pointer-events: none;
+ }
+}
+</style>
diff --git a/packages/client/src/components/sub-note-content.vue b/packages/client/src/components/sub-note-content.vue
new file mode 100644
index 0000000000..3f03f021cd
--- /dev/null
+++ b/packages/client/src/components/sub-note-content.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="wrmlmaau">
+ <div class="body">
+ <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
+ <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+ <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+ </div>
+ <details v-if="note.files.length > 0">
+ <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
+ <XMediaList :media-list="note.files"/>
+ </details>
+ <details v-if="note.poll">
+ <summary>{{ $ts.poll }}</summary>
+ <XPoll :note="note"/>
+ </details>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPoll from './poll.vue';
+import XMediaList from './media-list.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XPoll,
+ XMediaList,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wrmlmaau {
+ overflow-wrap: break-word;
+
+ > .body {
+ > .reply {
+ margin-right: 6px;
+ color: var(--accent);
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/tab.vue b/packages/client/src/components/tab.vue
new file mode 100644
index 0000000000..c629727358
--- /dev/null
+++ b/packages/client/src/components/tab.vue
@@ -0,0 +1,73 @@
+<script lang="ts">
+import { defineComponent, h, resolveDirective, withDirectives } from 'vue';
+
+export default defineComponent({
+ props: {
+ modelValue: {
+ required: true,
+ },
+ },
+ render() {
+ const options = this.$slots.default();
+
+ return withDirectives(h('div', {
+ class: 'pxhvhrfw',
+ }, options.map(option => withDirectives(h('button', {
+ class: ['_button', { active: this.modelValue === option.props.value }],
+ key: option.key,
+ disabled: this.modelValue === option.props.value,
+ onClick: () => {
+ this.$emit('update:modelValue', option.props.value);
+ }
+ }, option.children), [
+ [resolveDirective('click-anime')]
+ ]))), [
+ [resolveDirective('size'), { max: [500] }]
+ ]);
+ }
+});
+</script>
+
+<style lang="scss">
+.pxhvhrfw {
+ display: flex;
+ font-size: 90%;
+
+ > button {
+ flex: 1;
+ padding: 10px 8px;
+ border-radius: var(--radius);
+
+ &:disabled {
+ opacity: 1 !important;
+ cursor: default;
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--accentedBg);
+ }
+
+ &:not(.active):hover {
+ color: var(--fgHighlighted);
+ background: var(--panelHighlight);
+ }
+
+ &:not(:first-child) {
+ margin-left: 8px;
+ }
+
+ > .icon {
+ margin-right: 6px;
+ }
+ }
+
+ &.max-width_500px {
+ font-size: 80%;
+
+ > button {
+ padding: 11px 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/taskmanager.api-window.vue b/packages/client/src/components/taskmanager.api-window.vue
new file mode 100644
index 0000000000..6ec4da3a59
--- /dev/null
+++ b/packages/client/src/components/taskmanager.api-window.vue
@@ -0,0 +1,72 @@
+<template>
+<XWindow ref="window"
+ :initial-width="370"
+ :initial-height="450"
+ :can-resize="true"
+ @close="$refs.window.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>Req Viewer</template>
+
+ <div class="rlkneywz">
+ <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);">
+ <option value="req">Request</option>
+ <option value="res">Response</option>
+ </MkTab>
+
+ <code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code>
+ <code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import XWindow from '@/components/ui/window.vue';
+import MkTab from '@/components/tab.vue';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ MkTab,
+ },
+
+ props: {
+ req: {
+ required: true,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ tab: 'req',
+ reqStr: JSON5.stringify(this.req.req, null, '\t'),
+ resStr: JSON5.stringify(this.req.res, null, '\t'),
+ }
+ },
+
+ methods: {
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rlkneywz {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > code {
+ display: block;
+ flex: 1;
+ padding: 8px;
+ overflow: auto;
+ font-size: 0.9em;
+ tab-size: 2;
+ white-space: pre;
+ }
+}
+</style>
diff --git a/packages/client/src/components/taskmanager.vue b/packages/client/src/components/taskmanager.vue
new file mode 100644
index 0000000000..6efbf286e6
--- /dev/null
+++ b/packages/client/src/components/taskmanager.vue
@@ -0,0 +1,233 @@
+<template>
+<XWindow ref="window" :initial-width="650" :initial-height="420" :can-resize="true" @closed="$emit('closed')">
+ <template #header>
+ <i class="fas fa-terminal" style="margin-right: 0.5em;"></i>Task Manager
+ </template>
+ <div class="qljqmnzj _monospace">
+ <MkTab v-model="tab" style="border-bottom: solid 0.5px var(--divider);">
+ <option value="windows">Windows</option>
+ <option value="stream">Stream</option>
+ <option value="streamPool">Stream (Pool)</option>
+ <option value="api">API</option>
+ </MkTab>
+
+ <div class="content">
+ <div v-if="tab === 'windows'" class="windows" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Component</div>
+ <div>Action</div>
+ </div>
+ <div v-for="p in popups">
+ <div>#{{ p.id }}</div>
+ <div>{{ p.component.name ? p.component.name : '<anonymous>' }}</div>
+ <div><button class="_textButton" @click="killPopup(p)">Kill</button></div>
+ </div>
+ </div>
+ <div v-if="tab === 'stream'" class="stream" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Ch</div>
+ <div>Handle</div>
+ <div>In</div>
+ <div>Out</div>
+ </div>
+ <div v-for="c in connections">
+ <div>#{{ c.id }}</div>
+ <div>{{ c.channel }}</div>
+ <div v-if="c.users !== null">(shared)<span v-if="c.name">{{ ' ' + c.name }}</span></div>
+ <div v-else>{{ c.name ? c.name : '<anonymous>' }}</div>
+ <div>{{ c.in }}</div>
+ <div>{{ c.out }}</div>
+ </div>
+ </div>
+ <div v-if="tab === 'streamPool'" class="streamPool" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Ch</div>
+ <div>Users</div>
+ </div>
+ <div v-for="p in pools">
+ <div>#{{ p.id }}</div>
+ <div>{{ p.channel }}</div>
+ <div>{{ p.users }}</div>
+ </div>
+ </div>
+ <div v-if="tab === 'api'" class="api" v-follow>
+ <div class="header">
+ <div>#ID</div>
+ <div>Endpoint</div>
+ <div>State</div>
+ </div>
+ <div v-for="req in apiRequests" @click="showReq(req)">
+ <div>#{{ req.id }}</div>
+ <div>{{ req.endpoint }}</div>
+ <div class="state" :class="req.state">{{ req.state }}</div>
+ </div>
+ </div>
+ </div>
+
+ <footer>
+ <div><span class="label">Windows</span>{{ popups.length }}</div>
+ <div><span class="label">Stream</span>{{ connections.length }}</div>
+ <div><span class="label">Stream (Pool)</span>{{ pools.length }}</div>
+ </footer>
+ </div>
+</XWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw, onBeforeUnmount, ref, shallowRef } from 'vue';
+import XWindow from '@/components/ui/window.vue';
+import MkTab from '@/components/tab.vue';
+import MkButton from '@/components/ui/button.vue';
+import follow from '@/directives/follow-append';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XWindow,
+ MkTab,
+ MkButton,
+ },
+
+ directives: {
+ follow
+ },
+
+ props: {
+ },
+
+ emits: ['closed'],
+
+ setup() {
+ const connections = shallowRef([]);
+ const pools = shallowRef([]);
+ const refreshStreamInfo = () => {
+ console.log(os.stream.sharedConnectionPools, os.stream.sharedConnections, os.stream.nonSharedConnections);
+ const conn = os.stream.sharedConnections.map(c => ({
+ id: c.id, name: c.name, channel: c.channel, users: c.pool.users, in: c.inCount, out: c.outCount,
+ })).concat(os.stream.nonSharedConnections.map(c => ({
+ id: c.id, name: c.name, channel: c.channel, users: null, in: c.inCount, out: c.outCount,
+ })));
+ conn.sort((a, b) => (a.id > b.id) ? 1 : -1);
+ connections.value = conn;
+ pools.value = os.stream.sharedConnectionPools;
+ };
+ const interval = setInterval(refreshStreamInfo, 1000);
+ onBeforeUnmount(() => {
+ clearInterval(interval);
+ });
+
+ const killPopup = p => {
+ os.popups.value = os.popups.value.filter(x => x !== p);
+ };
+
+ const showReq = req => {
+ os.popup(import('./taskmanager.api-window.vue'), {
+ req: req
+ }, {
+ }, 'closed');
+ };
+
+ return {
+ tab: ref('stream'),
+ popups: os.popups,
+ apiRequests: os.apiRequests,
+ connections,
+ pools,
+ killPopup,
+ showReq,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.qljqmnzj {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ > .content {
+ flex: 1;
+ overflow: auto;
+
+ > div {
+ display: table;
+ width: 100%;
+ padding: 16px;
+ box-sizing: border-box;
+
+ > div {
+ display: table-row;
+
+ &:nth-child(even) {
+ //background: rgba(0, 0, 0, 0.1);
+ }
+
+ &.header {
+ opacity: 0.7;
+ }
+
+ > div {
+ display: table-cell;
+ white-space: nowrap;
+
+ &:not(:last-child) {
+ padding-right: 8px;
+ }
+ }
+ }
+
+ &.api {
+ > div {
+ &:not(.header) {
+ cursor: pointer;
+
+ &:hover {
+ color: var(--accent);
+ }
+ }
+
+ > .state {
+ &.pending {
+ color: var(--warn);
+ }
+
+ &.success {
+ color: var(--success);
+ }
+
+ &.failed {
+ color: var(--error);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ > footer {
+ display: flex;
+ width: 100%;
+ padding: 8px 16px;
+ box-sizing: border-box;
+ border-top: solid 0.5px var(--divider);
+ font-size: 0.9em;
+
+ > div {
+ flex: 1;
+
+ > .label {
+ opacity: 0.7;
+ margin-right: 0.5em;
+
+ &:after {
+ content: ":";
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/timeline.vue b/packages/client/src/components/timeline.vue
new file mode 100644
index 0000000000..fa7f4e7f4d
--- /dev/null
+++ b/packages/client/src/components/timeline.vue
@@ -0,0 +1,183 @@
+<template>
+<XNotes :no-gap="!$store.state.showGapBetweenNotesInTimeline" ref="tl" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)" @queue="$emit('queue', $event)"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XNotes from './notes.vue';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ provide() {
+ return {
+ inChannel: this.src === 'channel'
+ };
+ },
+
+ props: {
+ src: {
+ type: String,
+ required: true
+ },
+ list: {
+ type: String,
+ required: false
+ },
+ antenna: {
+ type: String,
+ required: false
+ },
+ channel: {
+ type: String,
+ required: false
+ },
+ sound: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['note', 'queue', 'before', 'after'],
+
+ data() {
+ return {
+ connection: null,
+ connection2: null,
+ pagination: null,
+ baseQuery: {
+ includeMyRenotes: this.$store.state.showMyRenotes,
+ includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
+ includeLocalRenotes: this.$store.state.showLocalRenotes
+ },
+ query: {},
+ date: null
+ };
+ },
+
+ created() {
+ const prepend = note => {
+ (this.$refs.tl as any).prepend(note);
+
+ this.$emit('note');
+
+ if (this.sound) {
+ sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
+ }
+ };
+
+ const onUserAdded = () => {
+ (this.$refs.tl as any).reload();
+ };
+
+ const onUserRemoved = () => {
+ (this.$refs.tl as any).reload();
+ };
+
+ const onChangeFollowing = () => {
+ if (!this.$refs.tl.backed) {
+ this.$refs.tl.reload();
+ }
+ };
+
+ let endpoint;
+
+ if (this.src == 'antenna') {
+ endpoint = 'antennas/notes';
+ this.query = {
+ antennaId: this.antenna
+ };
+ this.connection = markRaw(os.stream.useChannel('antenna', {
+ antennaId: this.antenna
+ }));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'home') {
+ endpoint = 'notes/timeline';
+ this.connection = markRaw(os.stream.useChannel('homeTimeline'));
+ this.connection.on('note', prepend);
+
+ this.connection2 = markRaw(os.stream.useChannel('main'));
+ this.connection2.on('follow', onChangeFollowing);
+ this.connection2.on('unfollow', onChangeFollowing);
+ } else if (this.src == 'local') {
+ endpoint = 'notes/local-timeline';
+ this.connection = markRaw(os.stream.useChannel('localTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'social') {
+ endpoint = 'notes/hybrid-timeline';
+ this.connection = markRaw(os.stream.useChannel('hybridTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'global') {
+ endpoint = 'notes/global-timeline';
+ this.connection = markRaw(os.stream.useChannel('globalTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'mentions') {
+ endpoint = 'notes/mentions';
+ this.connection = markRaw(os.stream.useChannel('main'));
+ this.connection.on('mention', prepend);
+ } else if (this.src == 'directs') {
+ endpoint = 'notes/mentions';
+ this.query = {
+ visibility: 'specified'
+ };
+ const onNote = note => {
+ if (note.visibility == 'specified') {
+ prepend(note);
+ }
+ };
+ this.connection = markRaw(os.stream.useChannel('main'));
+ this.connection.on('mention', onNote);
+ } else if (this.src == 'list') {
+ endpoint = 'notes/user-list-timeline';
+ this.query = {
+ listId: this.list
+ };
+ this.connection = markRaw(os.stream.useChannel('userList', {
+ listId: this.list
+ }));
+ this.connection.on('note', prepend);
+ this.connection.on('userAdded', onUserAdded);
+ this.connection.on('userRemoved', onUserRemoved);
+ } else if (this.src == 'channel') {
+ endpoint = 'channels/timeline';
+ this.query = {
+ channelId: this.channel
+ };
+ this.connection = markRaw(os.stream.useChannel('channel', {
+ channelId: this.channel
+ }));
+ this.connection.on('note', prepend);
+ }
+
+ this.pagination = {
+ endpoint: endpoint,
+ limit: 10,
+ params: init => ({
+ untilDate: this.date?.getTime(),
+ ...this.baseQuery, ...this.query
+ })
+ };
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ if (this.connection2) this.connection2.dispose();
+ },
+
+ methods: {
+ focus() {
+ this.$refs.tl.focus();
+ },
+
+ timetravel(date?: Date) {
+ this.date = date;
+ this.$refs.tl.reload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue
new file mode 100644
index 0000000000..fb0de68092
--- /dev/null
+++ b/packages/client/src/components/toast.vue
@@ -0,0 +1,73 @@
+<template>
+<div class="mk-toast">
+ <transition name="notification-slide" appear @after-leave="$emit('closed')">
+ <XNotification :notification="notification" class="notification _acrylic" v-if="showing"/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNotification from './notification.vue';
+
+export default defineComponent({
+ components: {
+ XNotification
+ },
+ props: {
+ notification: {
+ type: Object,
+ required: true
+ }
+ },
+ emits: ['closed'],
+ data() {
+ return {
+ showing: true
+ };
+ },
+ mounted() {
+ setTimeout(() => {
+ this.showing = false;
+ }, 6000);
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.notification-slide-enter-active, .notification-slide-leave-active {
+ transition: opacity 0.3s, transform 0.3s !important;
+}
+.notification-slide-enter-from, .notification-slide-leave-to {
+ opacity: 0;
+ transform: translateX(-250px);
+}
+
+.mk-toast {
+ position: fixed;
+ z-index: 10000;
+ left: 0;
+ width: 250px;
+ top: 32px;
+ padding: 0 32px;
+ pointer-events: none;
+
+ @media (max-width: 700px) {
+ top: initial;
+ bottom: 112px;
+ padding: 0 16px;
+ }
+
+ @media (max-width: 500px) {
+ bottom: 92px;
+ padding: 0 8px;
+ }
+
+ > .notification {
+ height: 100%;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+}
+</style>
diff --git a/packages/client/src/components/token-generate-window.vue b/packages/client/src/components/token-generate-window.vue
new file mode 100644
index 0000000000..bf5775d4d8
--- /dev/null
+++ b/packages/client/src/components/token-generate-window.vue
@@ -0,0 +1,117 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="400"
+ :height="450"
+ :with-ok-button="true"
+ :ok-button-disabled="false"
+ :can-close="false"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+ @ok="ok()"
+>
+ <template #header>{{ title || $ts.generateAccessToken }}</template>
+ <div v-if="information" class="_section">
+ <MkInfo warn>{{ information }}</MkInfo>
+ </div>
+ <div class="_section">
+ <MkInput v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ </div>
+ <div class="_section">
+ <div style="margin-bottom: 16px;"><b>{{ $ts.permission }}</b></div>
+ <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton>
+ <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton>
+ <MkSwitch v-for="kind in (initialPermissions || kinds)" :key="kind" v-model="permissions[kind]">{{ $t(`_permissions.${kind}`) }}</MkSwitch>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { permissions } from 'misskey-js';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkInput from './form/input.vue';
+import MkTextarea from './form/textarea.vue';
+import MkSwitch from './form/switch.vue';
+import MkButton from './ui/button.vue';
+import MkInfo from './ui/info.vue';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkInput,
+ MkTextarea,
+ MkSwitch,
+ MkButton,
+ MkInfo,
+ },
+
+ props: {
+ title: {
+ type: String,
+ required: false,
+ default: null
+ },
+ information: {
+ type: String,
+ required: false,
+ default: null
+ },
+ initialName: {
+ type: String,
+ required: false,
+ default: null
+ },
+ initialPermissions: {
+ type: Array,
+ required: false,
+ default: null
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ name: this.initialName,
+ permissions: {},
+ kinds: permissions
+ };
+ },
+
+ created() {
+ if (this.initialPermissions) {
+ for (const kind of this.initialPermissions) {
+ this.permissions[kind] = true;
+ }
+ } else {
+ for (const kind of this.kinds) {
+ this.permissions[kind] = false;
+ }
+ }
+ },
+
+ methods: {
+ ok() {
+ this.$emit('done', {
+ name: this.name,
+ permissions: Object.keys(this.permissions).filter(p => this.permissions[p])
+ });
+ this.$refs.dialog.close();
+ },
+
+ disableAll() {
+ for (const p in this.permissions) {
+ this.permissions[p] = false;
+ }
+ },
+
+ enableAll() {
+ for (const p in this.permissions) {
+ this.permissions[p] = true;
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue
new file mode 100644
index 0000000000..b5f4547c84
--- /dev/null
+++ b/packages/client/src/components/ui/button.vue
@@ -0,0 +1,262 @@
+<template>
+<button v-if="!link" class="bghgjjyj _button"
+ :class="{ inline, primary, gradate, danger, rounded, full }"
+ :type="type"
+ @click="$emit('click', $event)"
+ @mousedown="onMousedown"
+>
+ <div ref="ripples" class="ripples"></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+</button>
+<MkA v-else class="bghgjjyj _button"
+ :class="{ inline, primary, gradate, danger, rounded, full }"
+ :to="to"
+ @mousedown="onMousedown"
+>
+ <div ref="ripples" class="ripples"></div>
+ <div class="content">
+ <slot></slot>
+ </div>
+</MkA>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ type: {
+ type: String,
+ required: false
+ },
+ primary: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ gradate: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ rounded: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ inline: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ link: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ to: {
+ type: String,
+ required: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ wait: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ danger: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ full: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ emits: ['click'],
+ mounted() {
+ if (this.autofocus) {
+ this.$nextTick(() => {
+ this.$el.focus();
+ });
+ }
+ },
+ methods: {
+ onMousedown(e: MouseEvent) {
+ function distance(p, q) {
+ return Math.hypot(p.x - q.x, p.y - q.y);
+ }
+
+ function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY) {
+ const origin = {x: circleCenterX, y: circleCenterY};
+ const dist1 = distance({x: 0, y: 0}, origin);
+ const dist2 = distance({x: boxW, y: 0}, origin);
+ const dist3 = distance({x: 0, y: boxH}, origin);
+ const dist4 = distance({x: boxW, y: boxH }, origin);
+ return Math.max(dist1, dist2, dist3, dist4) * 2;
+ }
+
+ const rect = e.target.getBoundingClientRect();
+
+ const ripple = document.createElement('div');
+ ripple.style.top = (e.clientY - rect.top - 1).toString() + 'px';
+ ripple.style.left = (e.clientX - rect.left - 1).toString() + 'px';
+
+ this.$refs.ripples.appendChild(ripple);
+
+ const circleCenterX = e.clientX - rect.left;
+ const circleCenterY = e.clientY - rect.top;
+
+ const scale = calcCircleScale(e.target.clientWidth, e.target.clientHeight, circleCenterX, circleCenterY);
+
+ setTimeout(() => {
+ ripple.style.transform = 'scale(' + (scale / 2) + ')';
+ }, 1);
+ setTimeout(() => {
+ ripple.style.transition = 'all 1s ease';
+ ripple.style.opacity = '0';
+ }, 1000);
+ setTimeout(() => {
+ if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
+ }, 2000);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bghgjjyj {
+ position: relative;
+ z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
+ display: block;
+ min-width: 100px;
+ width: max-content;
+ padding: 8px 14px;
+ text-align: center;
+ font-weight: normal;
+ font-size: 0.8em;
+ line-height: 22px;
+ box-shadow: none;
+ text-decoration: none;
+ background: var(--buttonBg);
+ border-radius: 4px;
+ overflow: clip;
+ box-sizing: border-box;
+ transition: background 0.1s ease;
+
+ &:not(:disabled):hover {
+ background: var(--buttonHoverBg);
+ }
+
+ &:not(:disabled):active {
+ background: var(--buttonHoverBg);
+ }
+
+ &.full {
+ width: 100%;
+ }
+
+ &.rounded {
+ border-radius: 999px;
+ }
+
+ &.primary {
+ font-weight: bold;
+ color: var(--fgOnAccent) !important;
+ background: var(--accent);
+
+ &:not(:disabled):hover {
+ background: var(--X8);
+ }
+
+ &:not(:disabled):active {
+ background: var(--X8);
+ }
+ }
+
+ &.gradate {
+ font-weight: bold;
+ color: var(--fgOnAccent) !important;
+ 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));
+ }
+ }
+
+ &.danger {
+ color: #ff2a2a;
+
+ &.primary {
+ color: #fff;
+ background: #ff2a2a;
+
+ &:not(:disabled):hover {
+ background: #ff4242;
+ }
+
+ &:not(:disabled):active {
+ background: #d42e2e;
+ }
+ }
+ }
+
+ &:disabled {
+ opacity: 0.7;
+ }
+
+ &:focus-visible {
+ outline: solid 2px var(--focus);
+ outline-offset: 2px;
+ }
+
+ &.inline {
+ display: inline-block;
+ width: auto;
+ min-width: 100px;
+ }
+
+ > .ripples {
+ position: absolute;
+ z-index: 0;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 6px;
+ overflow: hidden;
+
+ ::v-deep(div) {
+ position: absolute;
+ width: 2px;
+ height: 2px;
+ border-radius: 100%;
+ background: rgba(0, 0, 0, 0.1);
+ opacity: 1;
+ transform: scale(1);
+ transition: all 0.5s cubic-bezier(0,.5,0,1);
+ }
+ }
+
+ &.primary > .ripples ::v-deep(div) {
+ background: rgba(0, 0, 0, 0.15);
+ }
+
+ > .content {
+ position: relative;
+ z-index: 1;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue
new file mode 100644
index 0000000000..14673dfcd7
--- /dev/null
+++ b/packages/client/src/components/ui/container.vue
@@ -0,0 +1,262 @@
+<template>
+<div class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }" v-size="{ max: [380] }">
+ <header v-if="showHeader" ref="header">
+ <div class="title"><slot name="header"></slot></div>
+ <div class="sub">
+ <slot name="func"></slot>
+ <button class="_button" v-if="foldable" @click="() => showBody = !showBody">
+ <template v-if="showBody"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ </div>
+ </header>
+ <transition name="container-toggle"
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ >
+ <div v-show="showBody" class="content" :class="{ omitted }" ref="content">
+ <slot></slot>
+ <button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }">
+ <span>{{ $ts.showMore }}</span>
+ </button>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ showHeader: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ thin: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ naked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ foldable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ scrollable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ maxHeight: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ },
+ data() {
+ return {
+ showBody: this.expanded,
+ omitted: null,
+ ignoreOmit: false,
+ };
+ },
+ mounted() {
+ this.$watch('showBody', showBody => {
+ const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
+ this.$el.style.minHeight = `${headerHeight}px`;
+ if (showBody) {
+ this.$el.style.flexBasis = `auto`;
+ } else {
+ this.$el.style.flexBasis = `${headerHeight}px`;
+ }
+ }, {
+ immediate: true
+ });
+
+ this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
+
+ const calcOmit = () => {
+ if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
+ const height = this.$refs.content.offsetHeight;
+ this.omitted = height > this.maxHeight;
+ };
+
+ calcOmit();
+ new ResizeObserver((entries, observer) => {
+ calcOmit();
+ }).observe(this.$refs.content);
+ },
+ methods: {
+ toggleContent(show: boolean) {
+ if (!this.foldable) return;
+ this.showBody = show;
+ },
+
+ enter(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = 0;
+ el.offsetHeight; // reflow
+ el.style.height = elementHeight + 'px';
+ },
+ afterEnter(el) {
+ el.style.height = null;
+ },
+ leave(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = elementHeight + 'px';
+ el.offsetHeight; // reflow
+ el.style.height = 0;
+ },
+ afterLeave(el) {
+ el.style.height = null;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.container-toggle-enter-active, .container-toggle-leave-active {
+ overflow-y: hidden;
+ transition: opacity 0.5s, height 0.5s !important;
+}
+.container-toggle-enter-from {
+ opacity: 0;
+}
+.container-toggle-leave-to {
+ opacity: 0;
+}
+
+.ukygtjoj {
+ position: relative;
+ overflow: clip;
+
+ &.naked {
+ background: transparent !important;
+ box-shadow: none !important;
+ }
+
+ &.scrollable {
+ display: flex;
+ flex-direction: column;
+
+ > .content {
+ overflow: auto;
+ }
+ }
+
+ > header {
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ left: 0;
+ color: var(--panelHeaderFg);
+ background: var(--panelHeaderBg);
+ border-bottom: solid 0.5px var(--panelHeaderDivider);
+ z-index: 2;
+ line-height: 1.4em;
+
+ > .title {
+ margin: 0;
+ padding: 12px 16px;
+
+ > ::v-deep(i) {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .sub {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+ height: 100%;
+
+ > ::v-deep(button) {
+ width: 42px;
+ height: 100%;
+ }
+ }
+ }
+
+ > .content {
+ --stickyTop: 0px;
+
+ &.omitted {
+ position: relative;
+ max-height: var(--maxHeight);
+ overflow: hidden;
+
+ > .fade {
+ display: block;
+ position: absolute;
+ z-index: 10;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+
+ > span {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+ }
+
+ &:hover {
+ > span {
+ background: var(--panelHighlight);
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_380px, &.thin {
+ > header {
+ > .title {
+ padding: 8px 10px;
+ font-size: 0.9em;
+ }
+ }
+
+ > .content {
+ }
+ }
+}
+
+._forceContainerFull_ .ukygtjoj {
+ > header {
+ > .title {
+ padding: 12px 16px !important;
+ }
+ }
+}
+
+._forceContainerFull_.ukygtjoj {
+ > header {
+ > .title {
+ padding: 12px 16px !important;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/context-menu.vue b/packages/client/src/components/ui/context-menu.vue
new file mode 100644
index 0000000000..561099cbe0
--- /dev/null
+++ b/packages/client/src/components/ui/context-menu.vue
@@ -0,0 +1,97 @@
+<template>
+<transition :name="$store.state.animation ? 'fade' : ''" appear>
+ <div class="nvlagfpb" @contextmenu.prevent.stop="() => {}">
+ <MkMenu :items="items" @close="$emit('closed')" class="_popup _shadow" :align="'left'"/>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import contains from '@/scripts/contains';
+import MkMenu from './menu.vue';
+
+export default defineComponent({
+ components: {
+ MkMenu,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ ev: {
+ required: true
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ },
+ emits: ['closed'],
+ computed: {
+ keymap(): any {
+ return {
+ 'esc': () => this.$emit('closed'),
+ };
+ },
+ },
+ mounted() {
+ let left = this.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+ let top = this.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
+
+ const width = this.$el.offsetWidth;
+ const height = this.$el.offsetHeight;
+
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset;
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ this.$el.style.top = top + 'px';
+ this.$el.style.left = left + 'px';
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+ },
+ beforeUnmount() {
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+ methods: {
+ onMousedown(e) {
+ if (!contains(this.$el, e.target) && (this.$el != e.target)) this.$emit('closed');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.nvlagfpb {
+ position: absolute;
+ z-index: 65535;
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
+ transform-origin: left top;
+}
+
+.fade-enter-from, .fade-leave-to {
+ opacity: 0;
+ transform: scale(0.9);
+}
+</style>
diff --git a/packages/client/src/components/ui/folder.vue b/packages/client/src/components/ui/folder.vue
new file mode 100644
index 0000000000..3997421d08
--- /dev/null
+++ b/packages/client/src/components/ui/folder.vue
@@ -0,0 +1,156 @@
+<template>
+<div class="ssazuxis" v-size="{ max: [500] }">
+ <header @click="showBody = !showBody" class="_button" :style="{ background: bg }">
+ <div class="title"><slot name="header"></slot></div>
+ <div class="divider"></div>
+ <button class="_button">
+ <template v-if="showBody"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ </header>
+ <transition name="folder-toggle"
+ @enter="enter"
+ @after-enter="afterEnter"
+ @leave="leave"
+ @after-leave="afterLeave"
+ >
+ <div v-show="showBody">
+ <slot></slot>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+
+const localStoragePrefix = 'ui:folder:';
+
+export default defineComponent({
+ props: {
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: true
+ },
+ persistKey: {
+ type: String,
+ required: false,
+ default: null
+ },
+ },
+ data() {
+ return {
+ bg: null,
+ showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
+ };
+ },
+ watch: {
+ showBody() {
+ if (this.persistKey) {
+ localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
+ }
+ }
+ },
+ mounted() {
+ function getParentBg(el: Element | null): string {
+ if (el == null || el.tagName === 'BODY') return 'var(--bg)';
+ const bg = el.style.background || el.style.backgroundColor;
+ if (bg) {
+ return bg;
+ } else {
+ return getParentBg(el.parentElement);
+ }
+ }
+ const rawBg = getParentBg(this.$el);
+ const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
+ bg.setAlpha(0.85);
+ this.bg = bg.toRgbString();
+ },
+ methods: {
+ toggleContent(show: boolean) {
+ this.showBody = show;
+ },
+
+ enter(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = 0;
+ el.offsetHeight; // reflow
+ el.style.height = elementHeight + 'px';
+ },
+ afterEnter(el) {
+ el.style.height = null;
+ },
+ leave(el) {
+ const elementHeight = el.getBoundingClientRect().height;
+ el.style.height = elementHeight + 'px';
+ el.offsetHeight; // reflow
+ el.style.height = 0;
+ },
+ afterLeave(el) {
+ el.style.height = null;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.folder-toggle-enter-active, .folder-toggle-leave-active {
+ overflow-y: hidden;
+ transition: opacity 0.5s, height 0.5s !important;
+}
+.folder-toggle-enter-from {
+ opacity: 0;
+}
+.folder-toggle-leave-to {
+ opacity: 0;
+}
+
+.ssazuxis {
+ position: relative;
+
+ > header {
+ display: flex;
+ position: relative;
+ z-index: 10;
+ position: sticky;
+ top: var(--stickyTop, 0px);
+ padding: var(--x-padding);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(20px));
+
+ > .title {
+ margin: 0;
+ padding: 12px 16px 12px 0;
+
+ > i {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .divider {
+ flex: 1;
+ margin: auto;
+ height: 1px;
+ background: var(--divider);
+ }
+
+ > button {
+ padding: 12px 0 12px 16px;
+ }
+ }
+
+ &.max-width_500px {
+ > header {
+ > .title {
+ padding: 8px 10px 8px 0;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue
new file mode 100644
index 0000000000..6b075cb440
--- /dev/null
+++ b/packages/client/src/components/ui/hr.vue
@@ -0,0 +1,16 @@
+<template>
+<div class="evrzpitu"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';import * as os from '@/os';
+
+export default defineComponent({});
+</script>
+
+<style lang="scss" scoped>
+.evrzpitu
+ margin 16px 0
+ border-bottom solid var(--lineWidth) var(--faceDivider)
+
+</style>
diff --git a/packages/client/src/components/ui/info.vue b/packages/client/src/components/ui/info.vue
new file mode 100644
index 0000000000..8f5986baf7
--- /dev/null
+++ b/packages/client/src/components/ui/info.vue
@@ -0,0 +1,45 @@
+<template>
+<div class="fpezltsf" :class="{ warn }">
+ <i v-if="warn" class="fas fa-exclamation-triangle"></i>
+ <i v-else class="fas fa-info-circle"></i>
+ <slot></slot>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ warn: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fpezltsf {
+ padding: 16px;
+ font-size: 90%;
+ background: var(--infoBg);
+ color: var(--infoFg);
+ border-radius: var(--radius);
+
+ &.warn {
+ background: var(--infoWarnBg);
+ color: var(--infoWarnFg);
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue
new file mode 100644
index 0000000000..5938fb00a1
--- /dev/null
+++ b/packages/client/src/components/ui/menu.vue
@@ -0,0 +1,278 @@
+<template>
+<div class="rrevdjwt" :class="{ center: align === 'center' }"
+ :style="{ width: width ? width + 'px' : null }"
+ ref="items"
+ @contextmenu.self="e => e.preventDefault()"
+ v-hotkey="keymap"
+>
+ <template v-for="(item, i) in _items">
+ <div v-if="item === null" class="divider"></div>
+ <span v-else-if="item.type === 'label'" class="label item">
+ <span>{{ item.text }}</span>
+ </span>
+ <span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
+ <span><MkEllipsis/></span>
+ </span>
+ <MkA v-else-if="item.type === 'link'" :to="item.to" @click.passive="close()" :tabindex="i" class="_button item">
+ <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
+ <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+ <span>{{ item.text }}</span>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </MkA>
+ <a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" @click="close()" :tabindex="i" class="_button item">
+ <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
+ <span>{{ item.text }}</span>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </a>
+ <button v-else-if="item.type === 'user'" @click="clicked(item.action, $event)" :tabindex="i" class="_button item">
+ <MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ <button v-else @click="clicked(item.action, $event)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active">
+ <i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
+ <MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
+ <span>{{ item.text }}</span>
+ <span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ </template>
+ <span v-if="_items.length === 0" class="none item">
+ <span>{{ $ts.none }}</span>
+ </span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, unref } from 'vue';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import contains from '@/scripts/contains';
+
+export default defineComponent({
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ align: {
+ type: String,
+ requried: false
+ },
+ width: {
+ type: Number,
+ required: false
+ },
+ },
+ emits: ['close'],
+ data() {
+ return {
+ _items: [],
+ };
+ },
+ computed: {
+ keymap(): any {
+ return {
+ 'up|k|shift+tab': this.focusUp,
+ 'down|j|tab': this.focusDown,
+ 'esc': this.close,
+ };
+ },
+ },
+ watch: {
+ items: {
+ handler() {
+ const items = ref(unref(this.items).filter(item => item !== undefined));
+
+ for (let i = 0; i < items.value.length; i++) {
+ const item = items.value[i];
+
+ if (item && item.then) { // if item is Promise
+ items.value[i] = { type: 'pending' };
+ item.then(actualItem => {
+ items.value[i] = actualItem;
+ });
+ }
+ }
+
+ this._items = items;
+ },
+ immediate: true
+ }
+ },
+ mounted() {
+ if (this.viaKeyboard) {
+ this.$nextTick(() => {
+ focusNext(this.$refs.items.children[0], true, false);
+ });
+ }
+
+ if (this.contextmenuEvent) {
+ this.$el.style.top = this.contextmenuEvent.pageY + 'px';
+ this.$el.style.left = this.contextmenuEvent.pageX + 'px';
+
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.addEventListener('mousedown', this.onMousedown);
+ }
+ }
+ },
+ beforeUnmount() {
+ for (const el of Array.from(document.querySelectorAll('body *'))) {
+ el.removeEventListener('mousedown', this.onMousedown);
+ }
+ },
+ methods: {
+ clicked(fn, ev) {
+ fn(ev);
+ this.close();
+ },
+ close() {
+ this.$emit('close');
+ },
+ focusUp() {
+ focusPrev(document.activeElement);
+ },
+ focusDown() {
+ focusNext(document.activeElement);
+ },
+ onMousedown(e) {
+ if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rrevdjwt {
+ padding: 8px 0;
+ min-width: 200px;
+ max-height: 90vh;
+ overflow: auto;
+
+ &.center {
+ > .item {
+ text-align: center;
+ }
+ }
+
+ > .item {
+ display: block;
+ position: relative;
+ padding: 8px 18px;
+ width: 100%;
+ box-sizing: border-box;
+ white-space: nowrap;
+ font-size: 0.9em;
+ line-height: 20px;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: calc(100% - 16px);
+ height: 100%;
+ border-radius: 6px;
+ }
+
+ > * {
+ position: relative;
+ }
+
+ &.danger {
+ color: #ff2a2a;
+
+ &:hover {
+ color: #fff;
+
+ &:before {
+ background: #ff4242;
+ }
+ }
+
+ &:active {
+ color: #fff;
+
+ &:before {
+ background: #d42e2e;
+ }
+ }
+ }
+
+ &.active {
+ color: var(--fgOnAccent);
+ opacity: 1;
+
+ &:before {
+ background: var(--accent);
+ }
+ }
+
+ &:not(:disabled):hover {
+ color: var(--accent);
+ text-decoration: none;
+
+ &:before {
+ background: var(--accentedBg);
+ }
+ }
+
+ &:not(:active):focus-visible {
+ box-shadow: 0 0 0 2px var(--focus) inset;
+ }
+
+ &.label {
+ pointer-events: none;
+ font-size: 0.7em;
+ padding-bottom: 4px;
+
+ > span {
+ opacity: 0.7;
+ }
+ }
+
+ &.pending {
+ pointer-events: none;
+ opacity: 0.7;
+ }
+
+ &.none {
+ pointer-events: none;
+ opacity: 0.7;
+ }
+
+ > i {
+ margin-right: 5px;
+ width: 20px;
+ }
+
+ > .avatar {
+ margin-right: 5px;
+ width: 20px;
+ height: 20px;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 5px;
+ left: 13px;
+ color: var(--indicator);
+ font-size: 12px;
+ animation: blink 1s infinite;
+ }
+ }
+
+ > .divider {
+ margin: 8px 0;
+ height: 1px;
+ background: var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue
new file mode 100644
index 0000000000..da98192b87
--- /dev/null
+++ b/packages/client/src/components/ui/modal-window.vue
@@ -0,0 +1,148 @@
+<template>
+<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
+ <div class="ebkgoccj _window _narrow_" @keydown="onKeydown" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }">
+ <div class="header">
+ <button class="_button" v-if="withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button>
+ <span class="title">
+ <slot name="header"></slot>
+ </span>
+ <button class="_button" v-if="!withOkButton" @click="$emit('close')"><i class="fas fa-times"></i></button>
+ <button class="_button" v-if="withOkButton" @click="$emit('ok')" :disabled="okButtonDisabled"><i class="fas fa-check"></i></button>
+ </div>
+ <div class="body" v-if="padding">
+ <div class="_section">
+ <slot></slot>
+ </div>
+ </div>
+ <div class="body" v-else>
+ <slot></slot>
+ </div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from './modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal
+ },
+ props: {
+ withOkButton: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ okButtonDisabled: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ padding: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ width: {
+ type: Number,
+ required: false,
+ default: 400
+ },
+ height: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ canClose: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ scroll: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+
+ emits: ['click', 'close', 'closed', 'ok'],
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ close() {
+ this.$refs.modal.close();
+ },
+
+ onKeydown(e) {
+ if (e.which === 27) { // Esc
+ e.preventDefault();
+ e.stopPropagation();
+ this.close();
+ }
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ebkgoccj {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+
+ --root-margin: 24px;
+
+ @media (max-width: 500px) {
+ --root-margin: 16px;
+ }
+
+ > .header {
+ $height: 58px;
+ $height-narrow: 42px;
+ display: flex;
+ flex-shrink: 0;
+ box-shadow: 0px 1px var(--divider);
+
+ > button {
+ height: $height;
+ width: $height;
+
+ @media (max-width: 500px) {
+ height: $height-narrow;
+ width: $height-narrow;
+ }
+ }
+
+ > .title {
+ flex: 1;
+ line-height: $height;
+ padding-left: 32px;
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+
+ @media (max-width: 500px) {
+ line-height: $height-narrow;
+ padding-left: 16px;
+ }
+ }
+
+ > button + .title {
+ padding-left: 0;
+ }
+ }
+
+ > .body {
+ overflow: auto;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue
new file mode 100644
index 0000000000..33fcdb687f
--- /dev/null
+++ b/packages/client/src/components/ui/modal.vue
@@ -0,0 +1,292 @@
+<template>
+<transition :name="$store.state.animation ? popup ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? popup ? 500 : 300 : 0" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
+ <div v-show="manualShowing != null ? manualShowing : showing" class="qzhlnise" :class="{ front }" v-hotkey.global="keymap" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
+ <div class="bg _modalBg" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
+ <div class="content" :class="{ popup, fixed, top: position === 'top' }" @click.self="onBgClick" ref="content">
+ <slot></slot>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+function getFixedContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const position = window.getComputedStyle(el).getPropertyValue('position');
+ if (position === 'fixed') {
+ return el;
+ } else {
+ return getFixedContainer(el.parentElement);
+ }
+}
+
+export default defineComponent({
+ provide: {
+ modal: true
+ },
+ props: {
+ manualShowing: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ srcCenter: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ required: false,
+ },
+ position: {
+ required: false
+ },
+ front: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+ emits: ['opening', 'click', 'esc', 'close', 'closed'],
+ data() {
+ return {
+ showing: true,
+ fixed: false,
+ transformOrigin: 'center',
+ contentClicking: false,
+ };
+ },
+ computed: {
+ keymap(): any {
+ return {
+ 'esc': () => this.$emit('esc'),
+ };
+ },
+ popup(): boolean {
+ return this.src != null;
+ }
+ },
+ mounted() {
+ this.$watch('src', () => {
+ this.fixed = getFixedContainer(this.src) != null;
+ this.$nextTick(() => {
+ this.align();
+ });
+ }, { immediate: true });
+
+ this.$nextTick(() => {
+ const popover = this.$refs.content as any;
+ new ResizeObserver((entries, observer) => {
+ this.align();
+ }).observe(popover);
+ });
+ },
+ methods: {
+ align() {
+ if (!this.popup) return;
+
+ const popover = this.$refs.content as any;
+
+ if (popover == null) return;
+
+ const rect = this.src.getBoundingClientRect();
+
+ const width = popover.offsetWidth;
+ const height = popover.offsetHeight;
+
+ let left;
+ let top;
+
+ if (this.srcCenter) {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2);
+ left = (x - (width / 2));
+ top = (y - (height / 2));
+ } else {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight;
+ left = (x - (width / 2));
+ top = y;
+ }
+
+ if (this.fixed) {
+ if (left + width > window.innerWidth) {
+ left = window.innerWidth - width;
+ }
+
+ if (top + height > window.innerHeight) {
+ top = window.innerHeight - height;
+ }
+ } else {
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset - 1;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset - 1;
+ }
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
+ this.transformOrigin = 'center top';
+ } else {
+ this.transformOrigin = 'center';
+ }
+
+ popover.style.left = left + 'px';
+ popover.style.top = top + 'px';
+ },
+
+ childRendered() {
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const content = this.$refs.content.children[0];
+ content.addEventListener('mousedown', e => {
+ this.contentClicking = true;
+ window.addEventListener('mouseup', e => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ setTimeout(() => {
+ this.contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+ },
+
+ close() {
+ this.showing = false;
+ this.$emit('close');
+ },
+
+ onBgClick() {
+ if (this.contentClicking) return;
+ this.$emit('click');
+ },
+
+ onClosed() {
+ this.$emit('closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss">
+.modal-popup-enter-active, .modal-popup-leave-active,
+.modal-enter-from, .modal-leave-to {
+ > .content {
+ transform-origin: var(--transformOrigin);
+ }
+}
+</style>
+
+<style lang="scss" scoped>
+.modal-enter-active, .modal-leave-active {
+ > .bg {
+ transition: opacity 0.3s !important;
+ }
+
+ > .content {
+ transition: opacity 0.3s, transform 0.3s !important;
+ }
+}
+.modal-enter-from, .modal-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+ }
+}
+
+.modal-popup-enter-active, .modal-popup-leave-active {
+ > .bg {
+ transition: opacity 0.3s !important;
+ }
+
+ > .content {
+ transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1) !important;
+ }
+}
+.modal-popup-enter-from, .modal-popup-leave-to {
+ > .bg {
+ opacity: 0;
+ }
+
+ > .content {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+ }
+}
+
+.qzhlnise {
+ > .bg {
+ z-index: 10000;
+ }
+
+ > .content:not(.popup) {
+ position: fixed;
+ z-index: 10000;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ padding: 32px;
+ // TODO: mask-imageはiOSだとやたら重い。なんとかしたい
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
+ overflow: auto;
+ display: flex;
+
+ @media (max-width: 500px) {
+ padding: 16px;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
+ }
+
+ > ::v-deep(*) {
+ margin: auto;
+ }
+
+ &.top {
+ > ::v-deep(*) {
+ margin-top: 0;
+ }
+ }
+ }
+
+ > .content.popup {
+ position: absolute;
+ z-index: 10000;
+
+ &.fixed {
+ position: fixed;
+ }
+ }
+
+ &.front {
+ > .bg {
+ z-index: 20000;
+ }
+
+ > .content:not(.popup) {
+ z-index: 20000;
+ }
+
+ > .content.popup {
+ z-index: 20000;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue
new file mode 100644
index 0000000000..f6a457d88f
--- /dev/null
+++ b/packages/client/src/components/ui/pagination.vue
@@ -0,0 +1,69 @@
+<template>
+<transition name="fade" mode="out-in">
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-else-if="error" @retry="init()"/>
+
+ <div class="empty" v-else-if="empty" key="_empty_">
+ <slot name="empty"></slot>
+ </div>
+
+ <div v-else class="cxiknjgy">
+ <slot :items="items"></slot>
+ <div class="more _gap" v-show="more" key="_more_">
+ <MkButton class="button" v-appear="($store.state.enableInfiniteScroll && !disableAutoLoad) ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary>
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from './button.vue';
+import paging from '@/scripts/paging';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+
+ disableAutoLoad: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.cxiknjgy {
+ > .more > .button {
+ margin-left: auto;
+ margin-right: auto;
+ height: 48px;
+ min-width: 150px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/popup-menu.vue b/packages/client/src/components/ui/popup-menu.vue
new file mode 100644
index 0000000000..3ff4c658b1
--- /dev/null
+++ b/packages/client/src/components/ui/popup-menu.vue
@@ -0,0 +1,42 @@
+<template>
+<MkPopup ref="popup" :src="src" @closed="$emit('closed')">
+ <MkMenu :items="items" :align="align" :width="width" @close="$refs.popup.close()" class="_popup _shadow"/>
+</MkPopup>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPopup from './popup.vue';
+import MkMenu from './menu.vue';
+
+export default defineComponent({
+ components: {
+ MkPopup,
+ MkMenu,
+ },
+
+ props: {
+ items: {
+ type: Array,
+ required: true
+ },
+ align: {
+ type: String,
+ required: false
+ },
+ width: {
+ type: Number,
+ required: false
+ },
+ viaKeyboard: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ required: false
+ },
+ },
+
+ emits: ['close', 'closed'],
+});
+</script>
diff --git a/packages/client/src/components/ui/popup.vue b/packages/client/src/components/ui/popup.vue
new file mode 100644
index 0000000000..0fb1780cc5
--- /dev/null
+++ b/packages/client/src/components/ui/popup.vue
@@ -0,0 +1,213 @@
+<template>
+<transition :name="$store.state.animation ? 'popup-menu' : ''" appear @after-leave="onClosed" @enter="$emit('opening')" @after-enter="childRendered">
+ <div v-show="manualShowing != null ? manualShowing : showing" class="ccczpooj" :class="{ front, fixed, top: position === 'top' }" ref="content" :style="{ pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
+ <slot></slot>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent, PropType } from 'vue';
+
+function getFixedContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const position = window.getComputedStyle(el).getPropertyValue('position');
+ if (position === 'fixed') {
+ return el;
+ } else {
+ return getFixedContainer(el.parentElement);
+ }
+}
+
+export default defineComponent({
+ props: {
+ manualShowing: {
+ type: Boolean,
+ required: false,
+ default: null,
+ },
+ srcCenter: {
+ type: Boolean,
+ required: false
+ },
+ src: {
+ type: Object as PropType<HTMLElement>,
+ required: false,
+ },
+ position: {
+ required: false
+ },
+ front: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ emits: ['opening', 'click', 'esc', 'close', 'closed'],
+
+ data() {
+ return {
+ showing: true,
+ fixed: false,
+ transformOrigin: 'center',
+ contentClicking: false,
+ };
+ },
+
+ mounted() {
+ this.$watch('src', () => {
+ if (this.src) {
+ this.src.style.pointerEvents = 'none';
+ }
+ this.fixed = getFixedContainer(this.src) != null;
+ this.$nextTick(() => {
+ this.align();
+ });
+ }, { immediate: true });
+
+ this.$nextTick(() => {
+ const popover = this.$refs.content as any;
+ new ResizeObserver((entries, observer) => {
+ this.align();
+ }).observe(popover);
+ });
+
+ document.addEventListener('mousedown', this.onDocumentClick, { passive: true });
+ },
+
+ beforeUnmount() {
+ document.removeEventListener('mousedown', this.onDocumentClick);
+ },
+
+ methods: {
+ align() {
+ if (this.src == null) return;
+
+ const popover = this.$refs.content as any;
+
+ if (popover == null) return;
+
+ const rect = this.src.getBoundingClientRect();
+
+ const width = popover.offsetWidth;
+ const height = popover.offsetHeight;
+
+ let left;
+ let top;
+
+ if (this.srcCenter) {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + (this.src.offsetHeight / 2);
+ left = (x - (width / 2));
+ top = (y - (height / 2));
+ } else {
+ const x = rect.left + (this.fixed ? 0 : window.pageXOffset) + (this.src.offsetWidth / 2);
+ const y = rect.top + (this.fixed ? 0 : window.pageYOffset) + this.src.offsetHeight;
+ left = (x - (width / 2));
+ top = y;
+ }
+
+ if (this.fixed) {
+ if (left + width > window.innerWidth) {
+ left = window.innerWidth - width;
+ }
+
+ if (top + height > window.innerHeight) {
+ top = window.innerHeight - height;
+ }
+ } else {
+ if (left + width - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - width + window.pageXOffset - 1;
+ }
+
+ if (top + height - window.pageYOffset > window.innerHeight) {
+ top = window.innerHeight - height + window.pageYOffset - 1;
+ }
+ }
+
+ if (top < 0) {
+ top = 0;
+ }
+
+ if (left < 0) {
+ left = 0;
+ }
+
+ if (top > rect.top + (this.fixed ? 0 : window.pageYOffset)) {
+ this.transformOrigin = 'center top';
+ } else {
+ this.transformOrigin = 'center';
+ }
+
+ popover.style.left = left + 'px';
+ popover.style.top = top + 'px';
+ },
+
+ childRendered() {
+ // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
+ const content = this.$refs.content.children[0];
+ content.addEventListener('mousedown', e => {
+ this.contentClicking = true;
+ window.addEventListener('mouseup', e => {
+ // click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
+ setTimeout(() => {
+ this.contentClicking = false;
+ }, 100);
+ }, { passive: true, once: true });
+ }, { passive: true });
+ },
+
+ close() {
+ if (this.src) this.src.style.pointerEvents = 'auto';
+ this.showing = false;
+ this.$emit('close');
+ },
+
+ onClosed() {
+ this.$emit('closed');
+ },
+
+ onDocumentClick(ev) {
+ const flyoutElement = this.$refs.content;
+ let targetElement = ev.target;
+ do {
+ if (targetElement === flyoutElement) {
+ return;
+ }
+ targetElement = targetElement.parentNode;
+ } while (targetElement);
+ this.close();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.popup-menu-enter-active {
+ transform-origin: var(--transformOrigin);
+ transition: opacity 0.2s cubic-bezier(0, 0, 0.2, 1), transform 0.2s cubic-bezier(0, 0, 0.2, 1) !important;
+}
+.popup-menu-leave-active {
+ transform-origin: var(--transformOrigin);
+ transition: opacity 0.2s cubic-bezier(0.4, 0, 1, 1), transform 0.2s cubic-bezier(0.4, 0, 1, 1) !important;
+}
+.popup-menu-enter-from, .popup-menu-leave-to {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.ccczpooj {
+ position: absolute;
+ z-index: 10000;
+
+ &.fixed {
+ position: fixed;
+ }
+
+ &.front {
+ z-index: 20000;
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/super-menu.vue b/packages/client/src/components/ui/super-menu.vue
new file mode 100644
index 0000000000..195cc57326
--- /dev/null
+++ b/packages/client/src/components/ui/super-menu.vue
@@ -0,0 +1,148 @@
+<template>
+<div class="rrevdjwu" :class="{ grid }">
+ <div class="group" v-for="group in def">
+ <div class="title" v-if="group.title">{{ group.title }}</div>
+
+ <div class="items">
+ <template v-for="(item, i) in group.items">
+ <a v-if="item.type === 'a'" :href="item.href" :target="item.target" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+ <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
+ <span class="text">{{ item.text }}</span>
+ </a>
+ <button v-else-if="item.type === 'button'" @click="ev => item.action(ev)" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active">
+ <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
+ <span class="text">{{ item.text }}</span>
+ </button>
+ <MkA v-else :to="item.to" :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }">
+ <i v-if="item.icon" class="icon fa-fw" :class="item.icon"></i>
+ <span class="text">{{ item.text }}</span>
+ </MkA>
+ </template>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, ref, unref } from 'vue';
+
+export default defineComponent({
+ props: {
+ def: {
+ type: Array,
+ required: true
+ },
+ grid: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.rrevdjwu {
+ > .group {
+ & + .group {
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .title {
+ font-size: 0.9em;
+ opacity: 0.7;
+ margin: 0 0 8px 12px;
+ }
+
+ > .items {
+ > .item {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 16px 10px 8px;
+ border-radius: 9px;
+ font-size: 0.9em;
+
+ &:hover {
+ text-decoration: none;
+ background: var(--panelHighlight);
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--accentedBg);
+ }
+
+ &.danger {
+ color: var(--error);
+ }
+
+ > .icon {
+ width: 32px;
+ margin-right: 2px;
+ flex-shrink: 0;
+ text-align: center;
+ opacity: 0.8;
+ }
+
+ > .text {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding-right: 12px;
+ }
+
+ }
+ }
+ }
+
+ &.grid {
+ > .group {
+ & + .group {
+ padding-top: 0;
+ border-top: none;
+ }
+
+ margin-left: 0;
+ margin-right: 0;
+
+ > .title {
+ font-size: 1em;
+ opacity: 0.7;
+ margin: 0 0 8px 16px;
+ }
+
+ > .items {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ grid-gap: 8px;
+ padding: 0 16px;
+
+ > .item {
+ flex-direction: column;
+ padding: 18px 16px 16px 16px;
+ background: var(--panel);
+ border-radius: 8px;
+ text-align: center;
+
+ > .icon {
+ display: block;
+ margin-right: 0;
+ margin-bottom: 12px;
+ font-size: 1.5em;
+ }
+
+ > .text {
+ padding-right: 0;
+ width: 100%;
+ font-size: 0.8em;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue
new file mode 100644
index 0000000000..c003895c14
--- /dev/null
+++ b/packages/client/src/components/ui/tooltip.vue
@@ -0,0 +1,92 @@
+<template>
+<transition name="tooltip" appear @after-leave="$emit('closed')">
+ <div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content" :style="{ maxWidth: maxWidth + 'px' }">
+ <slot>{{ text }}</slot>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ showing: {
+ type: Boolean,
+ required: true,
+ },
+ source: {
+ required: true,
+ },
+ text: {
+ type: String,
+ required: false
+ },
+ maxWidth: {
+ type: Number,
+ required: false,
+ default: 250,
+ },
+ },
+
+ emits: ['closed'],
+
+ mounted() {
+ this.$nextTick(() => {
+ if (this.source == null) {
+ this.$emit('closed');
+ return;
+ }
+
+ const rect = this.source.getBoundingClientRect();
+
+ const contentWidth = this.$refs.content.offsetWidth;
+ const contentHeight = this.$refs.content.offsetHeight;
+
+ let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
+ let top = rect.top + window.pageYOffset - contentHeight;
+
+ left -= (this.$el.offsetWidth / 2);
+
+ if (left + contentWidth - window.pageXOffset > window.innerWidth) {
+ left = window.innerWidth - contentWidth + window.pageXOffset - 1;
+ }
+
+ if (top - window.pageYOffset < 0) {
+ top = rect.top + window.pageYOffset + this.source.offsetHeight;
+ this.$refs.content.style.transformOrigin = 'center top';
+ }
+
+ this.$el.style.left = left + 'px';
+ this.$el.style.top = top + 'px';
+ });
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.tooltip-enter-active,
+.tooltip-leave-active {
+ opacity: 1;
+ transform: scale(1);
+ transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tooltip-enter-from,
+.tooltip-leave-active {
+ opacity: 0;
+ transform: scale(0.75);
+}
+
+.buebdbiu {
+ position: absolute;
+ z-index: 11000;
+ font-size: 0.8em;
+ padding: 8px 12px;
+ box-sizing: border-box;
+ text-align: center;
+ border-radius: 4px;
+ border: solid 0.5px var(--divider);
+ pointer-events: none;
+ transform-origin: center bottom;
+}
+</style>
diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue
new file mode 100644
index 0000000000..b7093b6641
--- /dev/null
+++ b/packages/client/src/components/ui/window.vue
@@ -0,0 +1,525 @@
+<template>
+<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')">
+ <div class="ebkgocck" :class="{ front }" v-if="showing">
+ <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown">
+ <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
+ <span class="left">
+ <slot name="headerLeft"></slot>
+ </span>
+ <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown">
+ <slot name="header"></slot>
+ </span>
+ <span class="right">
+ <slot name="headerRight"></slot>
+ <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button>
+ </span>
+ </div>
+ <div class="body" v-if="padding">
+ <div class="_section">
+ <slot></slot>
+ </div>
+ </div>
+ <div class="body" v-else>
+ <slot></slot>
+ </div>
+ </div>
+ <template v-if="canResize">
+ <div class="handle top" @mousedown.prevent="onTopHandleMousedown"></div>
+ <div class="handle right" @mousedown.prevent="onRightHandleMousedown"></div>
+ <div class="handle bottom" @mousedown.prevent="onBottomHandleMousedown"></div>
+ <div class="handle left" @mousedown.prevent="onLeftHandleMousedown"></div>
+ <div class="handle top-left" @mousedown.prevent="onTopLeftHandleMousedown"></div>
+ <div class="handle top-right" @mousedown.prevent="onTopRightHandleMousedown"></div>
+ <div class="handle bottom-right" @mousedown.prevent="onBottomRightHandleMousedown"></div>
+ <div class="handle bottom-left" @mousedown.prevent="onBottomLeftHandleMousedown"></div>
+ </template>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import contains from '@/scripts/contains';
+import * as os from '@/os';
+
+const minHeight = 50;
+const minWidth = 250;
+
+function dragListen(fn) {
+ window.addEventListener('mousemove', fn);
+ window.addEventListener('touchmove', fn);
+ window.addEventListener('mouseleave', dragClear.bind(null, fn));
+ window.addEventListener('mouseup', dragClear.bind(null, fn));
+ window.addEventListener('touchend', dragClear.bind(null, fn));
+}
+
+function dragClear(fn) {
+ window.removeEventListener('mousemove', fn);
+ window.removeEventListener('touchmove', fn);
+ window.removeEventListener('mouseleave', dragClear);
+ window.removeEventListener('mouseup', dragClear);
+ window.removeEventListener('touchend', dragClear);
+}
+
+export default defineComponent({
+ provide: {
+ inWindow: true
+ },
+
+ props: {
+ padding: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ initialWidth: {
+ type: Number,
+ required: false,
+ default: 400
+ },
+ initialHeight: {
+ type: Number,
+ required: false,
+ default: null
+ },
+ canResize: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ closeButton: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ mini: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ front: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ contextmenu: {
+ type: Array,
+ required: false,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ showing: true,
+ id: Math.random().toString(), // TODO: UUIDとかにする
+ };
+ },
+
+ mounted() {
+ if (this.initialWidth) this.applyTransformWidth(this.initialWidth);
+ if (this.initialHeight) this.applyTransformHeight(this.initialHeight);
+
+ this.applyTransformTop((window.innerHeight / 2) - (this.$el.offsetHeight / 2));
+ this.applyTransformLeft((window.innerWidth / 2) - (this.$el.offsetWidth / 2));
+
+ os.windows.set(this.id, {
+ z: Number(document.defaultView.getComputedStyle(this.$el, null).zIndex)
+ });
+
+ // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする
+ this.top();
+
+ window.addEventListener('resize', this.onBrowserResize);
+ },
+
+ unmounted() {
+ os.windows.delete(this.id);
+ window.removeEventListener('resize', this.onBrowserResize);
+ },
+
+ methods: {
+ close() {
+ this.showing = false;
+ },
+
+ onKeydown(e) {
+ if (e.which === 27) { // Esc
+ e.preventDefault();
+ e.stopPropagation();
+ this.close();
+ }
+ },
+
+ onContextmenu(e) {
+ if (this.contextmenu) {
+ os.contextMenu(this.contextmenu, e);
+ }
+ },
+
+ // 最前面へ移動
+ top() {
+ let z = 0;
+ const ws = Array.from(os.windows.entries()).filter(([k, v]) => k !== this.id).map(([k, v]) => v);
+ for (const w of ws) {
+ if (w.z > z) z = w.z;
+ }
+ if (z > 0) {
+ (this.$el as any).style.zIndex = z + 1;
+ os.windows.set(this.id, {
+ z: z + 1
+ });
+ }
+ },
+
+ onBodyMousedown() {
+ this.top();
+ },
+
+ onHeaderMousedown(e) {
+ const main = this.$el as any;
+
+ if (!contains(main, document.activeElement)) main.focus();
+
+ const position = main.getBoundingClientRect();
+
+ const clickX = e.touches && e.touches.length > 0 ? e.touches[0].clientX : e.clientX;
+ const clickY = e.touches && e.touches.length > 0 ? e.touches[0].clientY : e.clientY;
+ const moveBaseX = clickX - position.left;
+ const moveBaseY = clickY - position.top;
+ const browserWidth = window.innerWidth;
+ const browserHeight = window.innerHeight;
+ const windowWidth = main.offsetWidth;
+ const windowHeight = main.offsetHeight;
+
+ // 動かした時
+ dragListen(me => {
+ const x = me.touches && me.touches.length > 0 ? me.touches[0].clientX : me.clientX;
+ const y = me.touches && me.touches.length > 0 ? me.touches[0].clientY : me.clientY;
+
+ let moveLeft = x - moveBaseX;
+ let moveTop = y - moveBaseY;
+
+ // 下はみ出し
+ if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight;
+
+ // 左はみ出し
+ if (moveLeft < 0) moveLeft = 0;
+
+ // 上はみ出し
+ if (moveTop < 0) moveTop = 0;
+
+ // 右はみ出し
+ if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth;
+
+ this.$el.style.left = moveLeft + 'px';
+ this.$el.style.top = moveTop + 'px';
+ });
+ },
+
+ // 上ハンドル掴み時
+ onTopHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientY;
+ const height = parseInt(getComputedStyle(main, '').height, 10);
+ const top = parseInt(getComputedStyle(main, '').top, 10);
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientY - base;
+ if (top + move > 0) {
+ if (height + -move > minHeight) {
+ this.applyTransformHeight(height + -move);
+ this.applyTransformTop(top + move);
+ } else { // 最小の高さより小さくなろうとした時
+ this.applyTransformHeight(minHeight);
+ this.applyTransformTop(top + (height - minHeight));
+ }
+ } else { // 上のはみ出し時
+ this.applyTransformHeight(top + height);
+ this.applyTransformTop(0);
+ }
+ });
+ },
+
+ // 右ハンドル掴み時
+ onRightHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientX;
+ const width = parseInt(getComputedStyle(main, '').width, 10);
+ const left = parseInt(getComputedStyle(main, '').left, 10);
+ const browserWidth = window.innerWidth;
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientX - base;
+ if (left + width + move < browserWidth) {
+ if (width + move > minWidth) {
+ this.applyTransformWidth(width + move);
+ } else { // 最小の幅より小さくなろうとした時
+ this.applyTransformWidth(minWidth);
+ }
+ } else { // 右のはみ出し時
+ this.applyTransformWidth(browserWidth - left);
+ }
+ });
+ },
+
+ // 下ハンドル掴み時
+ onBottomHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientY;
+ const height = parseInt(getComputedStyle(main, '').height, 10);
+ const top = parseInt(getComputedStyle(main, '').top, 10);
+ const browserHeight = window.innerHeight;
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientY - base;
+ if (top + height + move < browserHeight) {
+ if (height + move > minHeight) {
+ this.applyTransformHeight(height + move);
+ } else { // 最小の高さより小さくなろうとした時
+ this.applyTransformHeight(minHeight);
+ }
+ } else { // 下のはみ出し時
+ this.applyTransformHeight(browserHeight - top);
+ }
+ });
+ },
+
+ // 左ハンドル掴み時
+ onLeftHandleMousedown(e) {
+ const main = this.$el as any;
+
+ const base = e.clientX;
+ const width = parseInt(getComputedStyle(main, '').width, 10);
+ const left = parseInt(getComputedStyle(main, '').left, 10);
+
+ // 動かした時
+ dragListen(me => {
+ const move = me.clientX - base;
+ if (left + move > 0) {
+ if (width + -move > minWidth) {
+ this.applyTransformWidth(width + -move);
+ this.applyTransformLeft(left + move);
+ } else { // 最小の幅より小さくなろうとした時
+ this.applyTransformWidth(minWidth);
+ this.applyTransformLeft(left + (width - minWidth));
+ }
+ } else { // 左のはみ出し時
+ this.applyTransformWidth(left + width);
+ this.applyTransformLeft(0);
+ }
+ });
+ },
+
+ // 左上ハンドル掴み時
+ onTopLeftHandleMousedown(e) {
+ this.onTopHandleMousedown(e);
+ this.onLeftHandleMousedown(e);
+ },
+
+ // 右上ハンドル掴み時
+ onTopRightHandleMousedown(e) {
+ this.onTopHandleMousedown(e);
+ this.onRightHandleMousedown(e);
+ },
+
+ // 右下ハンドル掴み時
+ onBottomRightHandleMousedown(e) {
+ this.onBottomHandleMousedown(e);
+ this.onRightHandleMousedown(e);
+ },
+
+ // 左下ハンドル掴み時
+ onBottomLeftHandleMousedown(e) {
+ this.onBottomHandleMousedown(e);
+ this.onLeftHandleMousedown(e);
+ },
+
+ // 高さを適用
+ applyTransformHeight(height) {
+ if (height > window.innerHeight) height = window.innerHeight;
+ (this.$el as any).style.height = height + 'px';
+ },
+
+ // 幅を適用
+ applyTransformWidth(width) {
+ if (width > window.innerWidth) width = window.innerWidth;
+ (this.$el as any).style.width = width + 'px';
+ },
+
+ // Y座標を適用
+ applyTransformTop(top) {
+ (this.$el as any).style.top = top + 'px';
+ },
+
+ // X座標を適用
+ applyTransformLeft(left) {
+ (this.$el as any).style.left = left + 'px';
+ },
+
+ onBrowserResize() {
+ const main = this.$el as any;
+ const position = main.getBoundingClientRect();
+ const browserWidth = window.innerWidth;
+ const browserHeight = window.innerHeight;
+ const windowWidth = main.offsetWidth;
+ const windowHeight = main.offsetHeight;
+ if (position.left < 0) main.style.left = 0; // 左はみ出し
+ if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し
+ if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し
+ if (position.top < 0) main.style.top = 0; // 上はみ出し
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.window-enter-active, .window-leave-active {
+ transition: opacity 0.2s, transform 0.2s !important;
+}
+.window-enter-from, .window-leave-to {
+ pointer-events: none;
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.ebkgocck {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 10000; // mk-modalのと同じでなければならない
+
+ &.front {
+ z-index: 11000; // front指定の時は、mk-modalのよりも大きくなければならない
+ }
+
+ > .body {
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ contain: content;
+ width: 100%;
+ height: 100%;
+
+ > .header {
+ --height: 50px;
+
+ &.mini {
+ --height: 38px;
+ }
+
+ display: flex;
+ position: relative;
+ z-index: 1;
+ flex-shrink: 0;
+ user-select: none;
+ height: var(--height);
+ border-bottom: solid 1px var(--divider);
+
+ > .left, > .right {
+ > ::v-deep(button) {
+ height: var(--height);
+ width: var(--height);
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+ }
+
+ > .title {
+ flex: 1;
+ position: relative;
+ line-height: var(--height);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ text-align: center;
+ cursor: move;
+ }
+ }
+
+ > .body {
+ flex: 1;
+ overflow: auto;
+ }
+ }
+
+ > .handle {
+ $size: 8px;
+
+ position: absolute;
+
+ &.top {
+ top: -($size);
+ left: 0;
+ width: 100%;
+ height: $size;
+ cursor: ns-resize;
+ }
+
+ &.right {
+ top: 0;
+ right: -($size);
+ width: $size;
+ height: 100%;
+ cursor: ew-resize;
+ }
+
+ &.bottom {
+ bottom: -($size);
+ left: 0;
+ width: 100%;
+ height: $size;
+ cursor: ns-resize;
+ }
+
+ &.left {
+ top: 0;
+ left: -($size);
+ width: $size;
+ height: 100%;
+ cursor: ew-resize;
+ }
+
+ &.top-left {
+ top: -($size);
+ left: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nwse-resize;
+ }
+
+ &.top-right {
+ top: -($size);
+ right: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nesw-resize;
+ }
+
+ &.bottom-right {
+ bottom: -($size);
+ right: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nwse-resize;
+ }
+
+ &.bottom-left {
+ bottom: -($size);
+ left: -($size);
+ width: $size * 2;
+ height: $size * 2;
+ cursor: nesw-resize;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/updated.vue b/packages/client/src/components/updated.vue
new file mode 100644
index 0000000000..c021c60669
--- /dev/null
+++ b/packages/client/src/components/updated.vue
@@ -0,0 +1,62 @@
+<template>
+<MkModal ref="modal" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="ewlycnyt">
+ <div class="title">{{ $ts.misskeyUpdated }}</div>
+ <div class="version">✨{{ version }}🚀</div>
+ <MkButton full @click="whatIsNew">{{ $ts.whatIsNew }}</MkButton>
+ <MkButton class="gotIt" primary full @click="$refs.modal.close()">{{ $ts.gotIt }}</MkButton>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+import MkButton from '@/components/ui/button.vue';
+import { version } from '@/config';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ MkButton,
+ },
+
+ data() {
+ return {
+ version: version,
+ };
+ },
+
+ methods: {
+ whatIsNew() {
+ this.$refs.modal.close();
+ window.open(`https://misskey-hub.net/docs/releases.html#_${version.replace(/\./g, '-')}`, '_blank');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ewlycnyt {
+ position: relative;
+ padding: 32px;
+ min-width: 320px;
+ max-width: 480px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+
+ > .title {
+ font-weight: bold;
+ }
+
+ > .version {
+ margin: 1em 0;
+ }
+
+ > .gotIt {
+ margin: 8px 0 0 0;
+ }
+}
+</style>
diff --git a/packages/client/src/components/url-preview-popup.vue b/packages/client/src/components/url-preview-popup.vue
new file mode 100644
index 0000000000..0a402f793f
--- /dev/null
+++ b/packages/client/src/components/url-preview-popup.vue
@@ -0,0 +1,60 @@
+<template>
+<div class="fgmtyycl" :style="{ top: top + 'px', left: left + 'px' }">
+ <transition name="zoom" @after-leave="$emit('closed')">
+ <MkUrlPreview class="_popup _shadow" :url="url" v-if="showing"/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkUrlPreview from './url-preview.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkUrlPreview
+ },
+
+ props: {
+ url: {
+ type: String,
+ required: true
+ },
+ source: {
+ required: true
+ },
+ showing: {
+ type: Boolean,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ u: null,
+ top: 0,
+ left: 0,
+ };
+ },
+
+ mounted() {
+ const rect = this.source.getBoundingClientRect();
+ const x = Math.max((rect.left + (this.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset;
+ const y = rect.top + this.source.offsetHeight + window.pageYOffset;
+
+ this.top = y;
+ this.left = x;
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.fgmtyycl {
+ position: absolute;
+ z-index: 11000;
+ width: 500px;
+ max-width: calc(90vw - 12px);
+ pointer-events: none;
+}
+</style>
diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue
new file mode 100644
index 0000000000..0826ba5ccf
--- /dev/null
+++ b/packages/client/src/components/url-preview.vue
@@ -0,0 +1,334 @@
+<template>
+<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
+ <button class="disablePlayer" @click="playerEnabled = false" :title="$ts.disablePlayer"><i class="fas fa-times"></i></button>
+ <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen />
+</div>
+<div v-else-if="tweetId && tweetExpanded" class="twitter" ref="twitter">
+ <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', left: `${tweetLeft}px`, width: `${tweetLeft < 0 ? 'auto' : '100%'}`, height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
+</div>
+<div v-else class="mk-url-preview" v-size="{ max: [400, 350] }">
+ <transition name="zoom" mode="out-in">
+ <component :is="self ? 'MkA' : 'a'" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url" v-if="!fetching">
+ <div class="thumbnail" v-if="thumbnail" :style="`background-image: url('${thumbnail}')`">
+ <button class="_button" v-if="!playerEnabled && player.url" @click.prevent="playerEnabled = true" :title="$ts.enablePlayer"><i class="fas fa-play-circle"></i></button>
+ </div>
+ <article>
+ <header>
+ <h1 :title="title">{{ title }}</h1>
+ </header>
+ <p v-if="description" :title="description">{{ description.length > 85 ? description.slice(0, 85) + '…' : description }}</p>
+ <footer>
+ <img class="icon" v-if="icon" :src="icon"/>
+ <p :title="sitename">{{ sitename }}</p>
+ </footer>
+ </article>
+ </component>
+ </transition>
+ <div class="expandTweet" v-if="tweetId">
+ <a @click="tweetExpanded = true">
+ <i class="fab fa-twitter"></i> {{ $ts.expandTweet }}
+ </a>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { url as local, lang } from '@/config';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ url: {
+ type: String,
+ require: true
+ },
+
+ detail: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ data() {
+ const self = this.url.startsWith(local);
+ return {
+ local,
+ fetching: true,
+ title: null,
+ description: null,
+ thumbnail: null,
+ icon: null,
+ sitename: null,
+ player: {
+ url: null,
+ width: null,
+ height: null
+ },
+ tweetId: null,
+ tweetExpanded: this.detail,
+ embedId: `embed${Math.random().toString().replace(/\D/,'')}`,
+ tweetHeight: 150,
+ tweetLeft: 0,
+ playerEnabled: false,
+ self: self,
+ attr: self ? 'to' : 'href',
+ target: self ? null : '_blank',
+ };
+ },
+
+ created() {
+ const requestUrl = new URL(this.url);
+
+ if (requestUrl.hostname == 'twitter.com') {
+ const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
+ if (m) this.tweetId = m[1];
+ }
+
+ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) {
+ requestUrl.hostname = 'www.youtube.com';
+ }
+
+ const requestLang = (lang || 'ja-JP').replace('ja-KS', 'ja-JP');
+
+ requestUrl.hash = '';
+
+ fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${requestLang}`).then(res => {
+ res.json().then(info => {
+ if (info.url == null) return;
+ this.title = info.title;
+ this.description = info.description;
+ this.thumbnail = info.thumbnail;
+ this.icon = info.icon;
+ this.sitename = info.sitename;
+ this.fetching = false;
+ this.player = info.player;
+ })
+ });
+
+ (window as any).addEventListener('message', this.adjustTweetHeight);
+ },
+
+ mounted() {
+ // 300pxないと絶対右にはみ出るので左に移動してしまう
+ const areaWidth = (this.$el as any)?.clientWidth;
+ if (areaWidth && areaWidth < 300) this.tweetLeft = areaWidth - 241;
+ },
+
+ methods: {
+ adjustTweetHeight(message: any) {
+ if (message.origin !== 'https://platform.twitter.com') return;
+ const embed = message.data?.['twttr.embed'];
+ if (embed?.method !== 'twttr.private.resize') return;
+ if (embed?.id !== this.embedId) return;
+ const height = embed?.params[0]?.height;
+ if (height) this.tweetHeight = height;
+ },
+ },
+
+ beforeUnmount() {
+ (window as any).removeEventListener('message', this.adjustTweetHeight);
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.player {
+ position: relative;
+ width: 100%;
+
+ > button {
+ position: absolute;
+ top: -1.5em;
+ right: 0;
+ font-size: 1em;
+ width: 1.5em;
+ height: 1.5em;
+ padding: 0;
+ margin: 0;
+ color: var(--fg);
+ background: rgba(128, 128, 128, 0.2);
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 0.9;
+ }
+ }
+
+ > iframe {
+ height: 100%;
+ left: 0;
+ position: absolute;
+ top: 0;
+ width: 100%;
+ }
+}
+
+.mk-url-preview {
+ &.max-width_400px {
+ > a {
+ font-size: 12px;
+
+ > .thumbnail {
+ height: 80px;
+ }
+
+ > article {
+ padding: 12px;
+ }
+ }
+ }
+
+ &.max-width_350px {
+ > a {
+ font-size: 10px;
+
+ > .thumbnail {
+ height: 70px;
+ }
+
+ > article {
+ padding: 8px;
+
+ > header {
+ margin-bottom: 4px;
+ }
+
+ > footer {
+ margin-top: 4px;
+
+ > img {
+ width: 12px;
+ height: 12px;
+ }
+ }
+ }
+
+ &.compact {
+ > .thumbnail {
+ position: absolute;
+ width: 56px;
+ height: 100%;
+ }
+
+ > article {
+ left: 56px;
+ width: calc(100% - 56px);
+ padding: 4px;
+
+ > header {
+ margin-bottom: 2px;
+ }
+
+ > footer {
+ margin-top: 2px;
+ }
+ }
+ }
+ }
+ }
+
+ > a {
+ position: relative;
+ display: block;
+ font-size: 14px;
+ box-shadow: 0 0 0 1px var(--divider);
+ border-radius: 8px;
+ overflow: hidden;
+
+ &:hover {
+ text-decoration: none;
+ border-color: rgba(0, 0, 0, 0.2);
+
+ > article > header > h1 {
+ text-decoration: underline;
+ }
+ }
+
+ > .thumbnail {
+ position: absolute;
+ width: 100px;
+ height: 100%;
+ background-position: center;
+ background-size: cover;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ > button {
+ font-size: 3.5em;
+ opacity: 0.7;
+
+ &:hover {
+ font-size: 4em;
+ opacity: 0.9;
+ }
+ }
+
+ & + article {
+ left: 100px;
+ width: calc(100% - 100px);
+ }
+ }
+
+ > article {
+ position: relative;
+ box-sizing: border-box;
+ padding: 16px;
+
+ > header {
+ margin-bottom: 8px;
+
+ > h1 {
+ margin: 0;
+ font-size: 1em;
+ }
+ }
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+ }
+
+ > footer {
+ margin-top: 8px;
+ height: 16px;
+
+ > img {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ margin-right: 4px;
+ vertical-align: top;
+ }
+
+ > p {
+ display: inline-block;
+ margin: 0;
+ color: var(--urlPreviewInfo);
+ font-size: 0.8em;
+ line-height: 16px;
+ vertical-align: top;
+ }
+ }
+ }
+
+ &.compact {
+ > article {
+ > header h1, p, footer {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-info.vue b/packages/client/src/components/user-info.vue
new file mode 100644
index 0000000000..ce82443b84
--- /dev/null
+++ b/packages/client/src/components/user-info.vue
@@ -0,0 +1,142 @@
+<template>
+<div class="_panel vjnjpkug">
+ <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="title">
+ <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+ <p class="username"><MkAcct :user="user"/></p>
+ </div>
+ <div class="description">
+ <div class="mfm" v-if="user.description">
+ <Mfm :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ </div>
+ <span v-else style="opacity: 0.7;">{{ $ts.noAccountDescription }}</span>
+ </div>
+ <div class="status">
+ <div>
+ <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
+ </div>
+ </div>
+ <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkFollowButton from './follow-button.vue';
+import { userPage } from '@/filters/user';
+
+export default defineComponent({
+ components: {
+ MkFollowButton
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ userPage,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vjnjpkug {
+ position: relative;
+
+ > .banner {
+ height: 84px;
+ background-color: rgba(0, 0, 0, 0.1);
+ background-size: cover;
+ background-position: center;
+ }
+
+ > .avatar {
+ display: block;
+ position: absolute;
+ top: 62px;
+ left: 13px;
+ z-index: 2;
+ width: 58px;
+ height: 58px;
+ border: solid 4px var(--panel);
+ }
+
+ > .title {
+ display: block;
+ padding: 10px 0 10px 88px;
+
+ > .name {
+ display: inline-block;
+ margin: 0;
+ font-weight: bold;
+ line-height: 16px;
+ word-break: break-all;
+ }
+
+ > .username {
+ display: block;
+ margin: 0;
+ line-height: 16px;
+ font-size: 0.8em;
+ color: var(--fg);
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ padding: 16px;
+ font-size: 0.8em;
+ border-top: solid 0.5px var(--divider);
+
+ > .mfm {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+ }
+
+ > .status {
+ padding: 10px 16px;
+ border-top: solid 0.5px var(--divider);
+
+ > div {
+ display: inline-block;
+ width: 33%;
+
+ > p {
+ margin: 0;
+ font-size: 0.7em;
+ color: var(--fg);
+ }
+
+ > span {
+ font-size: 1em;
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .koudoku-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-list.vue b/packages/client/src/components/user-list.vue
new file mode 100644
index 0000000000..733dbe0ad7
--- /dev/null
+++ b/packages/client/src/components/user-list.vue
@@ -0,0 +1,91 @@
+<template>
+<MkError v-if="error" @retry="init()"/>
+
+<div v-else class="efvhhmdq _isolated">
+ <div class="no-users" v-if="empty">
+ <p>{{ $ts.noUsers }}</p>
+ </div>
+ <div class="users">
+ <MkUserInfo class="user" v-for="user in users" :user="user" :key="user.id"/>
+ </div>
+ <button class="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :class="{ fetching: moreFetching }" v-show="more" :disabled="moreFetching">
+ <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ moreFetching ? $ts.loading : $ts.loadMore }}
+ </button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import MkUserInfo from './user-info.vue';
+import { userPage } from '@/filters/user';
+
+export default defineComponent({
+ components: {
+ MkUserInfo,
+ },
+
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+ extract: {
+ required: false
+ },
+ expanded: {
+ type: Boolean,
+ default: true
+ },
+ },
+
+ computed: {
+ users() {
+ return this.extract ? this.extract(this.items) : this.items;
+ }
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.efvhhmdq {
+ > .no-users {
+ text-align: center;
+ }
+
+ > .users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+ }
+
+ > .more {
+ display: block;
+ width: 100%;
+ padding: 16px;
+
+ &:hover {
+ background: rgba(#000, 0.025);
+ }
+
+ &:active {
+ background: rgba(#000, 0.05);
+ }
+
+ &.fetching {
+ cursor: wait;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-online-indicator.vue b/packages/client/src/components/user-online-indicator.vue
new file mode 100644
index 0000000000..afaf0e8736
--- /dev/null
+++ b/packages/client/src/components/user-online-indicator.vue
@@ -0,0 +1,50 @@
+<template>
+<div class="fzgwjkgc" :class="user.onlineStatus" v-tooltip="text"></div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ computed: {
+ text(): string {
+ switch (this.user.onlineStatus) {
+ case 'online': return this.$ts.online;
+ case 'active': return this.$ts.active;
+ case 'offline': return this.$ts.offline;
+ case 'unknown': return this.$ts.unknown;
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fzgwjkgc {
+ box-shadow: 0 0 0 3px var(--panel);
+ border-radius: 120%; // Blinkのバグか知らんけど、100%ぴったりにすると何故か若干楕円でレンダリングされる
+
+ &.online {
+ background: #58d4c9;
+ }
+
+ &.active {
+ background: #e4bc48;
+ }
+
+ &.offline {
+ background: #ea5353;
+ }
+
+ &.unknown {
+ background: #888;
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-preview.vue b/packages/client/src/components/user-preview.vue
new file mode 100644
index 0000000000..f7fd3f6b64
--- /dev/null
+++ b/packages/client/src/components/user-preview.vue
@@ -0,0 +1,192 @@
+<template>
+<transition name="popup" appear @after-leave="$emit('closed')">
+ <div v-if="showing" class="fxxzrfni _popup _shadow" :style="{ top: top + 'px', left: left + 'px' }" @mouseover="() => { $emit('mouseover'); }" @mouseleave="() => { $emit('mouseleave'); }">
+ <div v-if="fetched" class="info">
+ <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div>
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="title">
+ <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA>
+ <p class="username"><MkAcct :user="user"/></p>
+ </div>
+ <div class="description">
+ <Mfm v-if="user.description" :text="user.description" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ </div>
+ <div class="status">
+ <div>
+ <p>{{ $ts.notes }}</p><span>{{ user.notesCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.following }}</p><span>{{ user.followingCount }}</span>
+ </div>
+ <div>
+ <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span>
+ </div>
+ </div>
+ <MkFollowButton class="koudoku-button" v-if="$i && user.id != $i.id" :user="user" mini/>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkFollowButton from './follow-button.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkFollowButton
+ },
+
+ props: {
+ showing: {
+ type: Boolean,
+ required: true
+ },
+ q: {
+ type: String,
+ required: true
+ },
+ source: {
+ required: true
+ }
+ },
+
+ emits: ['closed', 'mouseover', 'mouseleave'],
+
+ data() {
+ return {
+ user: null,
+ fetched: false,
+ top: 0,
+ left: 0,
+ };
+ },
+
+ mounted() {
+ if (typeof this.q == 'object') {
+ this.user = this.q;
+ this.fetched = true;
+ } else {
+ const query = this.q.startsWith('@') ?
+ Acct.parse(this.q.substr(1)) :
+ { userId: this.q };
+
+ os.api('users/show', query).then(user => {
+ if (!this.showing) return;
+ this.user = user;
+ this.fetched = true;
+ });
+ }
+
+ const rect = this.source.getBoundingClientRect();
+ const x = ((rect.left + (this.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset;
+ const y = rect.top + this.source.offsetHeight + window.pageYOffset;
+
+ this.top = y;
+ this.left = x;
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.popup-enter-active, .popup-leave-active {
+ transition: opacity 0.3s, transform 0.3s !important;
+}
+.popup-enter-from, .popup-leave-to {
+ opacity: 0;
+ transform: scale(0.9);
+}
+
+.fxxzrfni {
+ position: absolute;
+ z-index: 11000;
+ width: 300px;
+ overflow: hidden;
+ transform-origin: center top;
+
+ > .info {
+ > .banner {
+ height: 84px;
+ background-color: rgba(0, 0, 0, 0.1);
+ background-size: cover;
+ background-position: center;
+ }
+
+ > .avatar {
+ display: block;
+ position: absolute;
+ top: 62px;
+ left: 13px;
+ z-index: 2;
+ width: 58px;
+ height: 58px;
+ border: solid 3px var(--face);
+ border-radius: 8px;
+ }
+
+ > .title {
+ display: block;
+ padding: 8px 0 8px 82px;
+
+ > .name {
+ display: inline-block;
+ margin: 0;
+ font-weight: bold;
+ line-height: 16px;
+ word-break: break-all;
+ }
+
+ > .username {
+ display: block;
+ margin: 0;
+ line-height: 16px;
+ font-size: 0.8em;
+ color: var(--fg);
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ padding: 0 16px;
+ font-size: 0.8em;
+ color: var(--fg);
+ }
+
+ > .status {
+ padding: 8px 16px;
+
+ > div {
+ display: inline-block;
+ width: 33%;
+
+ > p {
+ margin: 0;
+ font-size: 0.7em;
+ color: var(--fg);
+ }
+
+ > span {
+ font-size: 1em;
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .koudoku-button {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/user-select-dialog.vue
new file mode 100644
index 0000000000..80f6293563
--- /dev/null
+++ b/packages/client/src/components/user-select-dialog.vue
@@ -0,0 +1,199 @@
+<template>
+<XModalWindow ref="dialog"
+ :with-ok-button="true"
+ :ok-button-disabled="selected == null"
+ @click="cancel()"
+ @close="cancel()"
+ @ok="ok()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ $ts.selectUser }}</template>
+ <div class="tbhwbxda _monolithic_">
+ <div class="_section">
+ <div class="_inputSplit">
+ <MkInput v-model="username" class="input" @update:modelValue="search" ref="username">
+ <template #label>{{ $ts.username }}</template>
+ <template #prefix>@</template>
+ </MkInput>
+ <MkInput v-model="host" class="input" @update:modelValue="search">
+ <template #label>{{ $ts.host }}</template>
+ <template #prefix>@</template>
+ </MkInput>
+ </div>
+ </div>
+ <div class="_section result" v-if="username != '' || host != ''" :class="{ hit: users.length > 0 }">
+ <div class="users" v-if="users.length > 0">
+ <div class="user" v-for="user in users" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="user" class="name"/>
+ <MkAcct :user="user" class="acct"/>
+ </div>
+ </div>
+ </div>
+ <div v-else class="empty">
+ <span>{{ $ts.noUsers }}</span>
+ </div>
+ </div>
+ <div class="_section recent" v-if="username == '' && host == ''">
+ <div class="users">
+ <div class="user" v-for="user in recentUsers" :key="user.id" :class="{ selected: selected && selected.id === user.id }" @click="selected = user" @dblclick="ok()">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="user" class="name"/>
+ <MkAcct :user="user" class="acct"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkInput from './form/input.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkInput,
+ XModalWindow,
+ },
+
+ props: {
+ },
+
+ emits: ['ok', 'cancel', 'closed'],
+
+ data() {
+ return {
+ username: '',
+ host: '',
+ recentUsers: [],
+ users: [],
+ selected: null,
+ };
+ },
+
+ async mounted() {
+ this.focus();
+
+ this.$nextTick(() => {
+ this.focus();
+ });
+
+ this.recentUsers = await os.api('users/show', {
+ userIds: this.$store.state.recentlyUsedUsers
+ });
+ },
+
+ methods: {
+ search() {
+ if (this.username == '' && this.host == '') {
+ this.users = [];
+ return;
+ }
+ os.api('users/search-by-username-and-host', {
+ username: this.username,
+ host: this.host,
+ limit: 10,
+ detail: false
+ }).then(users => {
+ this.users = users;
+ });
+ },
+
+ focus() {
+ this.$refs.username.focus();
+ },
+
+ ok() {
+ this.$emit('ok', this.selected);
+ this.$refs.dialog.close();
+
+ // 最近使ったユーザー更新
+ let recents = this.$store.state.recentlyUsedUsers;
+ recents = recents.filter(x => x !== this.selected.id);
+ recents.unshift(this.selected.id);
+ this.$store.set('recentlyUsedUsers', recents.splice(0, 16));
+ },
+
+ cancel() {
+ this.$emit('cancel');
+ this.$refs.dialog.close();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tbhwbxda {
+ > ._section {
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ height: 100%;
+
+ &.result.hit {
+ padding: 0;
+ }
+
+ &.recent {
+ padding: 0;
+ }
+
+ > .users {
+ flex: 1;
+ overflow: auto;
+ padding: 8px 0;
+
+ > .user {
+ display: flex;
+ align-items: center;
+ padding: 8px var(--root-margin);
+ font-size: 14px;
+
+ &:hover {
+ background: var(--X7);
+ }
+
+ &.selected {
+ background: var(--accent);
+ color: #fff;
+ }
+
+ > * {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ > .avatar {
+ width: 45px;
+ height: 45px;
+ }
+
+ > .body {
+ padding: 0 8px;
+ min-width: 0;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ > .empty {
+ opacity: 0.7;
+ text-align: center;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/users-dialog.vue b/packages/client/src/components/users-dialog.vue
new file mode 100644
index 0000000000..6eec5289b3
--- /dev/null
+++ b/packages/client/src/components/users-dialog.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="mk-users-dialog">
+ <div class="header">
+ <span>{{ title }}</span>
+ <button class="_button" @click="close()"><i class="fas fa-times"></i></button>
+ </div>
+
+ <div class="users">
+ <MkA v-for="item in items" class="user" :key="item.id" :to="userPage(extract ? extract(item) : item)">
+ <MkAvatar :user="extract ? extract(item) : item" class="avatar" :disable-link="true" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="extract ? extract(item) : item" class="name"/>
+ <MkAcct :user="extract ? extract(item) : item" class="acct"/>
+ </div>
+ </MkA>
+ </div>
+ <button class="more _button" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
+ </button>
+
+ <p class="empty" v-if="empty">{{ $ts.noUsers }}</p>
+
+ <MkError v-if="error" @retry="init()"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import { userPage } from '@/filters/user';
+
+export default defineComponent({
+ mixins: [
+ paging({}),
+ ],
+
+ props: {
+ title: {
+ required: true
+ },
+ pagination: {
+ required: true
+ },
+ extract: {
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-users-dialog {
+ width: 350px;
+ height: 350px;
+ background: var(--panel);
+ border-radius: var(--radius);
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+
+ > .header {
+ display: flex;
+ flex-shrink: 0;
+
+ > button {
+ height: 58px;
+ width: 58px;
+
+ @media (max-width: 500px) {
+ height: 42px;
+ width: 42px;
+ }
+ }
+
+ > span {
+ flex: 1;
+ line-height: 58px;
+ padding-left: 32px;
+ font-weight: bold;
+
+ @media (max-width: 500px) {
+ line-height: 42px;
+ padding-left: 16px;
+ }
+ }
+ }
+
+ > .users {
+ flex: 1;
+ overflow: auto;
+
+ &:empty {
+ display: none;
+ }
+
+ > .user {
+ display: flex;
+ align-items: center;
+ font-size: 14px;
+ padding: 8px 32px;
+
+ @media (max-width: 500px) {
+ padding: 8px 16px;
+ }
+
+ > * {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ > .avatar {
+ width: 45px;
+ height: 45px;
+ }
+
+ > .body {
+ padding: 0 8px;
+ overflow: hidden;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ > .empty {
+ text-align: center;
+ opacity: 0.5;
+ }
+}
+</style>
diff --git a/packages/client/src/components/visibility-picker.vue b/packages/client/src/components/visibility-picker.vue
new file mode 100644
index 0000000000..7a811b42f7
--- /dev/null
+++ b/packages/client/src/components/visibility-picker.vue
@@ -0,0 +1,167 @@
+<template>
+<MkModal ref="modal" :src="src" @click="$refs.modal.close()" @closed="$emit('closed')">
+ <div class="gqyayizv _popup">
+ <button class="_button" @click="choose('public')" :class="{ active: v == 'public' }" data-index="1" key="public">
+ <div><i class="fas fa-globe"></i></div>
+ <div>
+ <span>{{ $ts._visibility.public }}</span>
+ <span>{{ $ts._visibility.publicDescription }}</span>
+ </div>
+ </button>
+ <button class="_button" @click="choose('home')" :class="{ active: v == 'home' }" data-index="2" key="home">
+ <div><i class="fas fa-home"></i></div>
+ <div>
+ <span>{{ $ts._visibility.home }}</span>
+ <span>{{ $ts._visibility.homeDescription }}</span>
+ </div>
+ </button>
+ <button class="_button" @click="choose('followers')" :class="{ active: v == 'followers' }" data-index="3" key="followers">
+ <div><i class="fas fa-unlock"></i></div>
+ <div>
+ <span>{{ $ts._visibility.followers }}</span>
+ <span>{{ $ts._visibility.followersDescription }}</span>
+ </div>
+ </button>
+ <button :disabled="localOnly" class="_button" @click="choose('specified')" :class="{ active: v == 'specified' }" data-index="4" key="specified">
+ <div><i class="fas fa-envelope"></i></div>
+ <div>
+ <span>{{ $ts._visibility.specified }}</span>
+ <span>{{ $ts._visibility.specifiedDescription }}</span>
+ </div>
+ </button>
+ <div class="divider"></div>
+ <button class="_button localOnly" @click="localOnly = !localOnly" :class="{ active: localOnly }" data-index="5" key="localOnly">
+ <div><i class="fas fa-biohazard"></i></div>
+ <div>
+ <span>{{ $ts._visibility.localOnly }}</span>
+ <span>{{ $ts._visibility.localOnlyDescription }}</span>
+ </div>
+ <div><i :class="localOnly ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i></div>
+ </button>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+ props: {
+ currentVisibility: {
+ type: String,
+ required: true
+ },
+ currentLocalOnly: {
+ type: Boolean,
+ required: true
+ },
+ src: {
+ required: false
+ },
+ },
+ emits: ['change-visibility', 'change-local-only', 'closed'],
+ data() {
+ return {
+ v: this.currentVisibility,
+ localOnly: this.currentLocalOnly,
+ }
+ },
+ watch: {
+ localOnly() {
+ this.$emit('change-local-only', this.localOnly);
+ }
+ },
+ methods: {
+ choose(visibility) {
+ this.v = visibility;
+ this.$emit('change-visibility', visibility);
+ this.$nextTick(() => {
+ this.$refs.modal.close();
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.gqyayizv {
+ width: 240px;
+ padding: 8px 0;
+
+ > .divider {
+ margin: 8px 0;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > button {
+ display: flex;
+ padding: 8px 14px;
+ font-size: 12px;
+ text-align: left;
+ width: 100%;
+ box-sizing: border-box;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &:active {
+ background: rgba(0, 0, 0, 0.1);
+ }
+
+ &.active {
+ color: #fff;
+ background: var(--accent);
+ }
+
+ &.localOnly.active {
+ color: var(--accent);
+ background: inherit;
+ }
+
+ > *:nth-child(1) {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-right: 10px;
+ width: 16px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+
+ > *:nth-child(2) {
+ flex: 1 1 auto;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > span:first-child {
+ display: block;
+ font-weight: bold;
+ }
+
+ > span:last-child:not(:first-child) {
+ opacity: 0.6;
+ }
+ }
+
+ > *:nth-child(3) {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin-left: 10px;
+ width: 16px;
+ top: 0;
+ bottom: 0;
+ margin-top: auto;
+ margin-bottom: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/components/waiting-dialog.vue b/packages/client/src/components/waiting-dialog.vue
new file mode 100644
index 0000000000..35a760ea41
--- /dev/null
+++ b/packages/client/src/components/waiting-dialog.vue
@@ -0,0 +1,92 @@
+<template>
+<MkModal ref="modal" @click="success ? done() : () => {}" @closed="$emit('closed')">
+ <div class="iuyakobc" :class="{ iconOnly: (text == null) || success }">
+ <i v-if="success" class="fas fa-check icon success"></i>
+ <i v-else class="fas fa-spinner fa-pulse icon waiting"></i>
+ <div class="text" v-if="text && !success">{{ text }}<MkEllipsis/></div>
+ </div>
+</MkModal>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkModal from '@/components/ui/modal.vue';
+
+export default defineComponent({
+ components: {
+ MkModal,
+ },
+
+ props: {
+ success: {
+ type: Boolean,
+ required: true,
+ },
+ showing: {
+ type: Boolean,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: false,
+ },
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ };
+ },
+
+ watch: {
+ showing() {
+ if (!this.showing) this.done();
+ }
+ },
+
+ methods: {
+ done() {
+ this.$emit('done');
+ this.$refs.modal.close();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.iuyakobc {
+ position: relative;
+ padding: 32px;
+ box-sizing: border-box;
+ text-align: center;
+ background: var(--panel);
+ border-radius: var(--radius);
+ width: 250px;
+
+ &.iconOnly {
+ padding: 0;
+ width: 96px;
+ height: 96px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ > .icon {
+ font-size: 32px;
+
+ &.success {
+ color: var(--accent);
+ }
+
+ &.waiting {
+ opacity: 0.7;
+ }
+ }
+
+ > .text {
+ margin-top: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue
new file mode 100644
index 0000000000..8aec77796d
--- /dev/null
+++ b/packages/client/src/components/widgets.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="vjoppmmu">
+ <template v-if="edit">
+ <header>
+ <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)">
+ <template #label>{{ $ts.selectWidget }}</template>
+ <option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option>
+ </MkSelect>
+ <MkButton inline @click="addWidget" primary><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+ <MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton>
+ </header>
+ <XDraggable
+ v-model="_widgets"
+ item-key="id"
+ animation="150"
+ >
+ <template #item="{element}">
+ <div class="customize-container">
+ <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button>
+ <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button>
+ <component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" @updateProps="updateWidget(element.id, $event)"/>
+ </div>
+ </template>
+ </XDraggable>
+ </template>
+ <component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="updateWidget(widget.id, $event)"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import MkSelect from '@/components/form/select.vue';
+import MkButton from '@/components/ui/button.vue';
+import { widgets as widgetDefs } from '@/widgets';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ MkSelect,
+ MkButton,
+ },
+
+ props: {
+ widgets: {
+ type: Array,
+ required: true,
+ },
+ edit: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'],
+
+ data() {
+ return {
+ widgetAdderSelected: null,
+ widgetDefs,
+ settings: {},
+ };
+ },
+
+ computed: {
+ _widgets: {
+ get() {
+ return this.widgets;
+ },
+ set(value) {
+ this.$emit('updateWidgets', value);
+ }
+ }
+ },
+
+ methods: {
+ configWidget(id) {
+ this.settings[id]();
+ },
+
+ addWidget() {
+ if (this.widgetAdderSelected == null) return;
+
+ this.$emit('addWidget', {
+ name: this.widgetAdderSelected,
+ id: uuid(),
+ data: {}
+ });
+
+ this.widgetAdderSelected = null;
+ },
+
+ removeWidget(widget) {
+ this.$emit('removeWidget', widget);
+ },
+
+ updateWidget(id, data) {
+ this.$emit('updateWidget', { id, data });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vjoppmmu {
+ > header {
+ margin: 16px 0;
+
+ > * {
+ width: 100%;
+ padding: 4px;
+ }
+ }
+
+ > .widget, .customize-container {
+ margin: var(--margin) 0;
+
+ &:first-of-type {
+ margin-top: 0;
+ }
+ }
+
+ .customize-container {
+ position: relative;
+ cursor: move;
+
+ > *:not(.remove):not(.config) {
+ pointer-events: none;
+ }
+
+ > .config,
+ > .remove {
+ position: absolute;
+ z-index: 10000;
+ top: 8px;
+ width: 32px;
+ height: 32px;
+ color: #fff;
+ background: rgba(#000, 0.7);
+ border-radius: 4px;
+ }
+
+ > .config {
+ right: 8px + 8px + 32px;
+ }
+
+ > .remove {
+ right: 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/config.ts b/packages/client/src/config.ts
new file mode 100644
index 0000000000..f2022b0f02
--- /dev/null
+++ b/packages/client/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/client/src/directives/anim.ts b/packages/client/src/directives/anim.ts
new file mode 100644
index 0000000000..1ceef984d8
--- /dev/null
+++ b/packages/client/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) {
+ setTimeout(() => {
+ src.style.opacity = '1';
+ src.style.transform = 'none';
+ }, 1);
+ },
+} as Directive;
diff --git a/packages/client/src/directives/appear.ts b/packages/client/src/directives/appear.ts
new file mode 100644
index 0000000000..a504d11ef9
--- /dev/null
+++ b/packages/client/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/client/src/directives/click-anime.ts b/packages/client/src/directives/click-anime.ts
new file mode 100644
index 0000000000..001dcca46e
--- /dev/null
+++ b/packages/client/src/directives/click-anime.ts
@@ -0,0 +1,29 @@
+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/client/src/directives/follow-append.ts b/packages/client/src/directives/follow-append.ts
new file mode 100644
index 0000000000..b0e99628b0
--- /dev/null
+++ b/packages/client/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/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts
new file mode 100644
index 0000000000..e3b5dea0f3
--- /dev/null
+++ b/packages/client/src/directives/get-size.ts
@@ -0,0 +1,34 @@
+import { Directive } from 'vue';
+
+export default {
+ mounted(src, binding, vn) {
+ const calc = () => {
+ const height = src.clientHeight;
+ const width = src.clientWidth;
+
+ // 要素が(一時的に)DOMに存在しないときは計算スキップ
+ if (height === 0) return;
+
+ binding.value(width, height);
+ };
+
+ calc();
+
+ // Vue3では使えなくなった
+ // 無くても大丈夫か...?
+ // TODO: ↑大丈夫じゃなかったので解決策を探す
+ //vn.context.$on('hook:activated', calc);
+
+ const ro = new ResizeObserver((entries, observer) => {
+ calc();
+ });
+ ro.observe(src);
+
+ src._get_size_ro_ = ro;
+ },
+
+ unmounted(src, binding, vn) {
+ binding.value(0, 0);
+ src._get_size_ro_.unobserve(src);
+ }
+} as Directive;
diff --git a/packages/client/src/directives/hotkey.ts b/packages/client/src/directives/hotkey.ts
new file mode 100644
index 0000000000..d813a95074
--- /dev/null
+++ b/packages/client/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/client/src/directives/index.ts b/packages/client/src/directives/index.ts
new file mode 100644
index 0000000000..cd71bc26d3
--- /dev/null
+++ b/packages/client/src/directives/index.ts
@@ -0,0 +1,26 @@
+import { App } from 'vue';
+
+import userPreview from './user-preview';
+import size from './size';
+import getSize from './get-size';
+import particle from './particle';
+import tooltip from './tooltip';
+import hotkey from './hotkey';
+import appear from './appear';
+import anim from './anim';
+import stickyContainer from './sticky-container';
+import clickAnime from './click-anime';
+
+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('particle', particle);
+ app.directive('tooltip', tooltip);
+ app.directive('hotkey', hotkey);
+ app.directive('appear', appear);
+ app.directive('anim', anim);
+ app.directive('click-anime', clickAnime);
+ app.directive('sticky-container', stickyContainer);
+}
diff --git a/packages/client/src/directives/particle.ts b/packages/client/src/directives/particle.ts
new file mode 100644
index 0000000000..c90df89a5e
--- /dev/null
+++ b/packages/client/src/directives/particle.ts
@@ -0,0 +1,18 @@
+import Particle from '@/components/particle.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.clientWidth / 2);
+ const y = rect.top + (el.clientHeight / 2);
+
+ popup(Particle, { x, y }, {}, 'end');
+ });
+ }
+};
diff --git a/packages/client/src/directives/size.ts b/packages/client/src/directives/size.ts
new file mode 100644
index 0000000000..a72a97abcc
--- /dev/null
+++ b/packages/client/src/directives/size.ts
@@ -0,0 +1,68 @@
+import { Directive } from 'vue';
+
+//const observers = new Map<Element, ResizeObserver>();
+
+export default {
+ mounted(src, binding, vn) {
+ const query = binding.value;
+
+ const addClass = (el: Element, cls: string) => {
+ el.classList.add(cls);
+ };
+
+ const removeClass = (el: Element, cls: string) => {
+ el.classList.remove(cls);
+ };
+
+ const calc = () => {
+ const width = src.clientWidth;
+
+ // 要素が(一時的に)DOMに存在しないときは計算スキップ
+ if (width === 0) return;
+
+ if (query.max) {
+ for (const v of query.max) {
+ if (width <= v) {
+ addClass(src, 'max-width_' + v + 'px');
+ } else {
+ removeClass(src, 'max-width_' + v + 'px');
+ }
+ }
+ }
+ if (query.min) {
+ for (const v of query.min) {
+ if (width >= v) {
+ addClass(src, 'min-width_' + v + 'px');
+ } else {
+ removeClass(src, 'min-width_' + v + 'px');
+ }
+ }
+ }
+ };
+
+ calc();
+
+ window.addEventListener('resize', calc);
+
+ // Vue3では使えなくなった
+ // 無くても大丈夫か...?
+ // TODO: ↑大丈夫じゃなかったので解決策を探す
+ //vn.context.$on('hook:activated', calc);
+
+ //const ro = new ResizeObserver((entries, observer) => {
+ // calc();
+ //});
+
+ //ro.observe(el);
+
+ // TODO: 新たにプロパティを作るのをやめMapを使う
+ // ただメモリ的には↓の方が省メモリかもしれないので検討中
+ //el._ro_ = ro;
+ src._calc_ = calc;
+ },
+
+ unmounted(src, binding, vn) {
+ //el._ro_.unobserve(el);
+ window.removeEventListener('resize', src._calc_);
+ }
+} as Directive;
diff --git a/packages/client/src/directives/sticky-container.ts b/packages/client/src/directives/sticky-container.ts
new file mode 100644
index 0000000000..9610eba4da
--- /dev/null
+++ b/packages/client/src/directives/sticky-container.ts
@@ -0,0 +1,15 @@
+import { Directive } from 'vue';
+
+export default {
+ mounted(src, binding, vn) {
+ //const query = binding.value;
+
+ const header = src.children[0];
+ const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px';
+ src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
+ header.style.setProperty('--stickyTop', currentStickyTop);
+ header.style.position = 'sticky';
+ header.style.top = 'var(--stickyTop)';
+ header.style.zIndex = '1';
+ },
+} as Directive;
diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts
new file mode 100644
index 0000000000..1294f6b063
--- /dev/null
+++ b/packages/client/src/directives/tooltip.ts
@@ -0,0 +1,87 @@
+import { Directive, ref } from 'vue';
+import { isDeviceTouch } from '@/scripts/is-device-touch';
+import { popup, dialog } from '@/os';
+
+const start = isDeviceTouch ? 'touchstart' : 'mouseover';
+const end = isDeviceTouch ? 'touchend' : 'mouseleave';
+const delay = 100;
+
+export default {
+ mounted(el: HTMLElement, binding, vn) {
+ 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) {
+ clearInterval(self.checkTimer);
+ self._close();
+ self._close = null;
+ }
+ };
+
+ if (binding.arg === 'dialog') {
+ el.addEventListener('click', (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ dialog({
+ 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(import('@/components/ui/tooltip.vue'), {
+ showing,
+ text: self.text,
+ source: el
+ }, {}, 'closed');
+
+ self._close = () => {
+ showing.value = false;
+ };
+ };
+
+ el.addEventListener('selectstart', e => {
+ e.preventDefault();
+ });
+
+ el.addEventListener(start, () => {
+ clearTimeout(self.showTimer);
+ clearTimeout(self.hideTimer);
+ self.showTimer = setTimeout(self.show, delay);
+ }, { passive: true });
+
+ el.addEventListener(end, () => {
+ clearTimeout(self.showTimer);
+ clearTimeout(self.hideTimer);
+ self.hideTimer = setTimeout(self.close, delay);
+ }, { passive: true });
+
+ el.addEventListener('click', () => {
+ 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_;
+ clearInterval(self.checkTimer);
+ },
+} as Directive;
diff --git a/packages/client/src/directives/user-preview.ts b/packages/client/src/directives/user-preview.ts
new file mode 100644
index 0000000000..68d9e2816c
--- /dev/null
+++ b/packages/client/src/directives/user-preview.ts
@@ -0,0 +1,118 @@
+import { 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(import('@/components/user-preview.vue'), {
+ showing,
+ q: this.user,
+ source: this.el
+ }, {
+ mouseover: () => {
+ clearTimeout(this.hideTimer);
+ },
+ mouseleave: () => {
+ clearTimeout(this.showTimer);
+ this.hideTimer = setTimeout(this.close, 500);
+ },
+ }, 'closed');
+
+ this.promise = {
+ cancel: () => {
+ showing.value = false;
+ }
+ };
+
+ this.checkTimer = setInterval(() => {
+ if (!document.body.contains(this.el)) {
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.close();
+ }
+ }, 1000);
+ }
+
+ @autobind
+ private close() {
+ if (this.promise) {
+ clearInterval(this.checkTimer);
+ this.promise.cancel();
+ this.promise = null;
+ }
+ }
+
+ @autobind
+ private onMouseover() {
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.showTimer = setTimeout(this.show, 500);
+ }
+
+ @autobind
+ private onMouseleave() {
+ clearTimeout(this.showTimer);
+ clearTimeout(this.hideTimer);
+ this.hideTimer = setTimeout(this.close, 500);
+ }
+
+ @autobind
+ private onClick() {
+ 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);
+ 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/client/src/emojilist.json b/packages/client/src/emojilist.json
new file mode 100644
index 0000000000..75c424ab4b
--- /dev/null
+++ b/packages/client/src/emojilist.json
@@ -0,0 +1,1749 @@
+[
+ { "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": "💩", "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": "🤏", "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": "🦷", "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": "🤰", "name": "pregnant_woman", "keywords": ["baby"] },
+ { "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": "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": "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": "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": "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": "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": "🔌", "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": "💎", "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": "🧬", "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": "⛱", "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": "♾", "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/client/src/events.ts b/packages/client/src/events.ts
new file mode 100644
index 0000000000..dbbd908b8f
--- /dev/null
+++ b/packages/client/src/events.ts
@@ -0,0 +1,4 @@
+import { EventEmitter } from 'eventemitter3';
+
+// TODO: 型付け
+export const globalEvents = new EventEmitter();
diff --git a/packages/client/src/filters/bytes.ts b/packages/client/src/filters/bytes.ts
new file mode 100644
index 0000000000..50e63534b6
--- /dev/null
+++ b/packages/client/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/client/src/filters/note.ts b/packages/client/src/filters/note.ts
new file mode 100644
index 0000000000..5c000cf83b
--- /dev/null
+++ b/packages/client/src/filters/note.ts
@@ -0,0 +1,3 @@
+export default note => {
+ return `/notes/${note.id}`;
+};
diff --git a/packages/client/src/filters/number.ts b/packages/client/src/filters/number.ts
new file mode 100644
index 0000000000..880a848ca4
--- /dev/null
+++ b/packages/client/src/filters/number.ts
@@ -0,0 +1 @@
+export default n => n == null ? 'N/A' : n.toLocaleString();
diff --git a/packages/client/src/filters/user.ts b/packages/client/src/filters/user.ts
new file mode 100644
index 0000000000..ff2f7e2dae
--- /dev/null
+++ b/packages/client/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/client/src/i18n.ts b/packages/client/src/i18n.ts
new file mode 100644
index 0000000000..fbc10a0bad
--- /dev/null
+++ b/packages/client/src/i18n.ts
@@ -0,0 +1,13 @@
+import { markRaw } from 'vue';
+import { locale } from '@/config';
+import { I18n } from '@/scripts/i18n';
+
+export const i18n = markRaw(new I18n(locale));
+
+// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない
+declare module '@vue/runtime-core' {
+ interface ComponentCustomProperties {
+ $t: typeof i18n['t'];
+ $ts: typeof i18n['locale'];
+ }
+}
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
new file mode 100644
index 0000000000..da5b0489ab
--- /dev/null
+++ b/packages/client/src/init.ts
@@ -0,0 +1,420 @@
+/**
+ * Client entry point
+ */
+
+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 * as Sentry from '@sentry/browser';
+import { Integrations } from '@sentry/tracing';
+import { computed, createApp, watch, markRaw, version as vueVersion } from 'vue';
+import compareVersions from 'compare-versions';
+
+import widgets from '@/widgets';
+import directives from '@/directives';
+import components from '@/components';
+import { version, ui, lang, host } from '@/config';
+import { router } from '@/router';
+import { applyTheme } from '@/scripts/theme';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { i18n } from '@/i18n';
+import { stream, dialog, post, popup } from '@/os';
+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 { isMobile } from '@/scripts/is-mobile';
+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';
+
+console.info(`Misskey v${version}`);
+
+// boot.jsのやつを解除
+window.onerror = null;
+window.onunhandledrejection = null;
+
+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);
+ /*
+ dialog({
+ type: 'error',
+ title: 'DEV: Unhandled error',
+ text: event.message
+ });
+ */
+ });
+
+ window.addEventListener('unhandledrejection', event => {
+ console.error(event);
+ /*
+ dialog({
+ type: 'error',
+ title: 'DEV: Unhandled promise rejection',
+ text: event.reason
+ });
+ */
+ });
+}
+
+if (defaultStore.state.reportError && !_DEV_) {
+ Sentry.init({
+ dsn: 'https://fd273254a07a4b61857607a9ea05d629@o501808.ingest.sentry.io/5583438',
+ tracesSampleRate: 1.0,
+ });
+
+ Sentry.setTag('misskey_version', version);
+ Sentry.setTag('ui', ui);
+ Sentry.setTag('lang', lang);
+ Sentry.setTag('host', host);
+}
+
+// タッチデバイスでCSSの:hoverを機能させる
+document.addEventListener('touchend', () => {}, { passive: true });
+
+// 一斉リロード
+reloadChannel.addEventListener('message', path => {
+ if (path !== null) location.href = path;
+ else location.reload();
+});
+
+//#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+// TODO: いつの日にか消したい
+const vh = window.innerHeight * 0.01;
+document.documentElement.style.setProperty('--vh', `${vh}px`);
+window.addEventListener('resize', () => {
+ const vh = window.innerHeight * 0.01;
+ document.documentElement.style.setProperty('--vh', `${vh}px`);
+});
+//#endregion
+
+// If mobile, insert the viewport meta tag
+if (isMobile || window.innerWidth <= 1024) {
+ const viewport = document.getElementsByName('viewport').item(0);
+ viewport.setAttribute('content',
+ `${viewport.getAttribute('content')},minimum-scale=1,maximum-scale=1,user-scalable=no`);
+ document.head.appendChild(viewport);
+}
+
+//#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 = '<div>Please wait...</div>';
+ await login(i);
+ location.reload();
+ } catch (e) {
+ // Render the error screen
+ // TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな)
+ document.body.innerHTML = '<div id="err">Oops!</div>';
+ }
+ } else {
+ if (_DEV_) {
+ console.log('not signed in');
+ }
+ }
+}
+//#endregion
+
+fetchInstance().then(() => {
+ localStorage.setItem('v', instance.version);
+
+ // Init service worker
+ initializeSw();
+});
+
+const app = createApp(await (
+ window.location.search === '?zen' ? import('@/ui/zen.vue') :
+ !$i ? import('@/ui/visitor.vue') :
+ ui === 'deck' ? import('@/ui/deck.vue') :
+ ui === 'desktop' ? import('@/ui/desktop.vue') :
+ ui === 'chat' ? import('@/ui/chat/index.vue') :
+ ui === 'classic' ? import('@/ui/classic.vue') :
+ import('@/ui/universal.vue')
+).then(x => x.default));
+
+if (_DEV_) {
+ app.config.performance = true;
+}
+
+app.config.globalProperties = {
+ $i,
+ $store: defaultStore,
+ $instance: instance,
+ $t: i18n.t,
+ $ts: i18n.locale,
+};
+
+app.use(router);
+
+widgets(app);
+directives(app);
+components(app);
+
+await router.isReady();
+
+const splash = document.getElementById('splash');
+// 念のためnullチェック(HTMLが古い場合があるため(そのうち消す))
+if (splash) splash.addEventListener('transitionend', () => {
+ splash.remove();
+});
+
+const rootEl = document.createElement('div');
+document.body.appendChild(rootEl);
+app.mount(rootEl);
+
+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(import('@/components/updated.vue'), {}, {}, 'closed');
+ }
+ }
+ } catch (e) {
+ }
+}
+
+// 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
+
+// shortcut
+document.addEventListener('keydown', makeHotkey({
+ 'd': () => {
+ defaultStore.set('darkMode', !defaultStore.state.darkMode);
+ },
+ 'p|n': post,
+ 's': search,
+ //TODO: 'h|/': help
+}));
+
+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 dialog({
+ type: 'warning',
+ title: i18n.locale.disconnectedFromServer,
+ text: i18n.locale.reloadConfirm,
+ showCancelButton: true
+ });
+ reloadDialogShowing = false;
+ if (!canceled) {
+ location.reload();
+ }
+ }
+});
+
+stream.on('emojiAdded', data => {
+ // TODO
+ //store.commit('instance/set', );
+});
+
+for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
+ import('./plugin').then(({ install }) => {
+ install(plugin);
+ });
+}
+
+if ($i) {
+ if ($i.isDeleted) {
+ dialog({
+ type: 'warning',
+ text: i18n.locale.accountDeletionInProgress,
+ });
+ }
+
+ 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();
+ });
+}
diff --git a/packages/client/src/instance.ts b/packages/client/src/instance.ts
new file mode 100644
index 0000000000..6e912aa2e5
--- /dev/null
+++ b/packages/client/src/instance.ts
@@ -0,0 +1,52 @@
+import { computed, reactive } from 'vue';
+import * as Misskey from 'misskey-js';
+import { api } from './os';
+
+// TODO: 他のタブと永続化されたstateを同期
+
+const data = localStorage.getItem('instance');
+
+// TODO: instanceをリアクティブにするかは再考の余地あり
+
+export const instance: Misskey.entities.InstanceMetadata = reactive(data ? JSON.parse(data) : {
+ // 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);
+});
+
+// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない
+declare module '@vue/runtime-core' {
+ interface ComponentCustomProperties {
+ $instance: typeof instance;
+ }
+}
diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts
new file mode 100644
index 0000000000..ae74740bb8
--- /dev/null
+++ b/packages/client/src/menu.ts
@@ -0,0 +1,224 @@
+import { computed, ref } from 'vue';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { ui } from '@/config';
+import { $i } from './account';
+import { unisonReload } from '@/scripts/unison-reload';
+import { router } from './router';
+
+export const menuDef = {
+ notifications: {
+ title: 'notifications',
+ icon: 'fas fa-bell',
+ show: computed(() => $i != null),
+ indicated: computed(() => $i != null && $i.hasUnreadNotification),
+ to: '/my/notifications',
+ },
+ messaging: {
+ title: 'messaging',
+ icon: 'fas fa-comments',
+ show: computed(() => $i != null),
+ indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage),
+ to: '/my/messaging',
+ },
+ drive: {
+ title: 'drive',
+ icon: 'fas fa-cloud',
+ show: computed(() => $i != null),
+ to: '/my/drive',
+ },
+ followRequests: {
+ title: 'followRequests',
+ icon: 'fas fa-user-clock',
+ show: computed(() => $i != null && $i.isLocked),
+ indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
+ to: '/my/follow-requests',
+ },
+ featured: {
+ title: 'featured',
+ icon: 'fas fa-fire-alt',
+ to: '/featured',
+ },
+ explore: {
+ title: 'explore',
+ icon: 'fas fa-hashtag',
+ to: '/explore',
+ },
+ announcements: {
+ title: 'announcements',
+ icon: 'fas fa-broadcast-tower',
+ indicated: computed(() => $i != null && $i.hasUnreadAnnouncement),
+ to: '/announcements',
+ },
+ search: {
+ title: 'search',
+ icon: 'fas fa-search',
+ action: () => search(),
+ },
+ lists: {
+ title: 'lists',
+ icon: 'fas fa-list-ul',
+ show: computed(() => $i != null),
+ active: computed(() => router.currentRoute.value.path.startsWith('/timeline/list/') || router.currentRoute.value.path === '/my/lists' || router.currentRoute.value.path.startsWith('/my/lists/')),
+ action: (ev) => {
+ const items = ref([{
+ type: 'pending'
+ }]);
+ os.api('users/lists/list').then(lists => {
+ const _items = [...lists.map(list => ({
+ type: 'link',
+ text: list.name,
+ to: `/timeline/list/${list.id}`
+ })), null, {
+ type: 'link',
+ to: '/my/lists',
+ text: i18n.locale.manageLists,
+ icon: 'fas fa-cog',
+ }];
+ items.value = _items;
+ });
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+ },
+ groups: {
+ title: 'groups',
+ icon: 'fas fa-users',
+ show: computed(() => $i != null),
+ to: '/my/groups',
+ },
+ antennas: {
+ title: 'antennas',
+ icon: 'fas fa-satellite',
+ show: computed(() => $i != null),
+ active: computed(() => router.currentRoute.value.path.startsWith('/timeline/antenna/') || router.currentRoute.value.path === '/my/antennas' || router.currentRoute.value.path.startsWith('/my/antennas/')),
+ action: (ev) => {
+ const items = ref([{
+ type: 'pending'
+ }]);
+ os.api('antennas/list').then(antennas => {
+ const _items = [...antennas.map(antenna => ({
+ type: 'link',
+ text: antenna.name,
+ to: `/timeline/antenna/${antenna.id}`
+ })), null, {
+ type: 'link',
+ to: '/my/antennas',
+ text: i18n.locale.manageAntennas,
+ icon: 'fas fa-cog',
+ }];
+ items.value = _items;
+ });
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+ },
+ mentions: {
+ title: 'mentions',
+ icon: 'fas fa-at',
+ show: computed(() => $i != null),
+ indicated: computed(() => $i != null && $i.hasUnreadMentions),
+ to: '/my/mentions',
+ },
+ messages: {
+ title: 'directNotes',
+ icon: 'fas fa-envelope',
+ show: computed(() => $i != null),
+ indicated: computed(() => $i != null && $i.hasUnreadSpecifiedNotes),
+ to: '/my/messages',
+ },
+ favorites: {
+ title: 'favorites',
+ icon: 'fas fa-star',
+ show: computed(() => $i != null),
+ to: '/my/favorites',
+ },
+ pages: {
+ title: 'pages',
+ icon: 'fas fa-file-alt',
+ to: '/pages',
+ },
+ gallery: {
+ title: 'gallery',
+ icon: 'fas fa-icons',
+ to: '/gallery',
+ },
+ clips: {
+ title: 'clip',
+ icon: 'fas fa-paperclip',
+ show: computed(() => $i != null),
+ to: '/my/clips',
+ },
+ channels: {
+ title: 'channel',
+ icon: 'fas fa-satellite-dish',
+ to: '/channels',
+ },
+ federation: {
+ title: 'federation',
+ icon: 'fas fa-globe',
+ to: '/federation',
+ },
+ emojis: {
+ title: 'emojis',
+ icon: 'fas fa-laugh',
+ to: '/emojis',
+ },
+ games: {
+ title: 'games',
+ icon: 'fas fa-gamepad',
+ to: '/games/reversi',
+ },
+ scratchpad: {
+ title: 'scratchpad',
+ icon: 'fas fa-terminal',
+ to: '/scratchpad',
+ },
+ rooms: {
+ title: 'rooms',
+ icon: 'fas fa-door-closed',
+ show: computed(() => $i != null),
+ to: computed(() => `/@${$i.username}/room`),
+ },
+ ui: {
+ title: 'switchUi',
+ icon: 'fas fa-columns',
+ action: (ev) => {
+ os.popupMenu([{
+ text: i18n.locale.default,
+ active: ui === 'default' || ui === null,
+ action: () => {
+ localStorage.setItem('ui', 'default');
+ unisonReload();
+ }
+ }, {
+ text: i18n.locale.deck,
+ active: ui === 'deck',
+ action: () => {
+ localStorage.setItem('ui', 'deck');
+ unisonReload();
+ }
+ }, {
+ text: i18n.locale.classic,
+ active: ui === 'classic',
+ action: () => {
+ localStorage.setItem('ui', 'classic');
+ unisonReload();
+ }
+ }, {
+ text: 'Chat (β)',
+ active: ui === 'chat',
+ action: () => {
+ localStorage.setItem('ui', 'chat');
+ unisonReload();
+ }
+ }, /*{
+ text: i18n.locale.desktop + ' (β)',
+ active: ui === 'desktop',
+ action: () => {
+ localStorage.setItem('ui', 'desktop');
+ unisonReload();
+ }
+ }*/], ev.currentTarget || ev.target);
+ },
+ },
+};
diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts
new file mode 100644
index 0000000000..a570ffc9ed
--- /dev/null
+++ b/packages/client/src/os.ts
@@ -0,0 +1,501 @@
+// TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する
+
+import { Component, defineAsyncComponent, markRaw, reactive, Ref, ref } from 'vue';
+import { EventEmitter } from 'eventemitter3';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import * as Misskey from 'misskey-js';
+import * as Sentry from '@sentry/browser';
+import { apiUrl, debug, url } from '@/config';
+import MkPostFormDialog from '@/components/post-form-dialog.vue';
+import MkWaitingDialog from '@/components/waiting-dialog.vue';
+import { resolve } from '@/router';
+import { $i } from '@/account';
+import { defaultStore } from '@/store';
+
+export const stream = markRaw(new Misskey.Stream(url, $i));
+
+export const pendingApiRequestsCount = ref(0);
+let apiRequestsCount = 0; // for debug
+export const apiRequests = ref([]); // for debug
+
+export const windows = new Map();
+
+const apiClient = new Misskey.api.APIClient({
+ origin: url,
+});
+
+export const api = ((endpoint: string, data: Record<string, any> = {}, token?: string | null | undefined) => {
+ pendingApiRequestsCount.value++;
+
+ const onFinally = () => {
+ pendingApiRequestsCount.value--;
+ };
+
+ const log = debug ? reactive({
+ id: ++apiRequestsCount,
+ endpoint,
+ req: markRaw(data),
+ res: null,
+ state: 'pending',
+ }) : null;
+ if (debug) {
+ apiRequests.value.push(log);
+ if (apiRequests.value.length > 128) apiRequests.value.shift();
+ }
+
+ 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
+ fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, {
+ method: 'POST',
+ body: JSON.stringify(data),
+ credentials: 'omit',
+ cache: 'no-cache'
+ }).then(async (res) => {
+ const body = res.status === 204 ? null : await res.json();
+
+ if (res.status === 200) {
+ resolve(body);
+ if (debug) {
+ log!.res = markRaw(JSON.parse(JSON.stringify(body)));
+ log!.state = 'success';
+ }
+ } else if (res.status === 204) {
+ resolve();
+ if (debug) {
+ log!.state = 'success';
+ }
+ } else {
+ reject(body.error);
+ if (debug) {
+ log!.res = markRaw(body.error);
+ log!.state = 'failed';
+ }
+
+ if (defaultStore.state.reportError && !_DEV_) {
+ Sentry.withScope((scope) => {
+ scope.setTag('api_endpoint', endpoint);
+ scope.setContext('api params', data);
+ scope.setContext('api error info', body.info);
+ scope.setTag('api_error_id', body.id);
+ scope.setTag('api_error_code', body.code);
+ scope.setTag('api_error_kind', body.kind);
+ scope.setLevel(Sentry.Severity.Error);
+ Sentry.captureMessage('API error');
+ });
+ }
+ }
+ }).catch(reject);
+ });
+
+ promise.then(onFinally, onFinally);
+
+ return promise;
+}) as typeof apiClient.request;
+
+export const apiWithDialog = ((
+ endpoint: string,
+ data: Record<string, any> = {},
+ token?: string | null | undefined,
+) => {
+ const promise = api(endpoint, data, token);
+ promiseDialog(promise, null, (e) => {
+ dialog({
+ type: 'error',
+ text: e.message + '\n' + (e as any).id,
+ });
+ });
+
+ return promise;
+}) as typeof api;
+
+export function promiseDialog<T extends Promise<any>>(
+ promise: T,
+ onSuccess?: ((res: any) => void) | null,
+ onFailure?: ((e: 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;
+ setTimeout(() => {
+ showing.value = false;
+ }, 1000);
+ }
+ }).catch(e => {
+ showing.value = false;
+ if (onFailure) {
+ onFailure(e);
+ } else {
+ dialog({
+ type: 'error',
+ text: e
+ });
+ }
+ });
+
+ // NOTE: dynamic importすると挙動がおかしくなる(showingの変更が伝播しない)
+ popup(MkWaitingDialog, {
+ success: success,
+ showing: showing,
+ text: text,
+ }, {}, 'closed');
+
+ return promise;
+}
+
+function isModule(x: any): x is typeof import('*.vue') {
+ return x.default != null;
+}
+
+let popupIdCount = 0;
+export const popups = ref([]) as Ref<{
+ id: any;
+ component: any;
+ props: Record<string, any>;
+}[]>;
+
+export async function popup(component: Component | typeof import('*.vue') | Promise<Component | typeof import('*.vue')>, props: Record<string, any>, events = {}, disposeEvent?: string) {
+ if (component.then) component = await component;
+
+ if (isModule(component)) component = component.default;
+ markRaw(component);
+
+ const id = ++popupIdCount;
+ const dispose = () => {
+ if (_DEV_) console.log('os:popup close', id, component, props, events);
+ // このsetTimeoutが無いと挙動がおかしくなる(autocompleteが閉じなくなる)。Vueのバグ?
+ setTimeout(() => {
+ popups.value = popups.value.filter(popup => popup.id !== id);
+ }, 0);
+ };
+ const state = {
+ component,
+ props,
+ events: disposeEvent ? {
+ ...events,
+ [disposeEvent]: dispose
+ } : events,
+ id,
+ };
+
+ if (_DEV_) console.log('os:popup open', id, component, props, events);
+ popups.value.push(state);
+
+ return {
+ dispose,
+ };
+}
+
+export function pageWindow(path: string) {
+ const { component, props } = resolve(path);
+ popup(import('@/components/page-window.vue'), {
+ initialPath: path,
+ initialComponent: markRaw(component),
+ initialProps: props,
+ }, {}, 'closed');
+}
+
+export function modalPageWindow(path: string) {
+ const { component, props } = resolve(path);
+ popup(import('@/components/modal-page-window.vue'), {
+ initialPath: path,
+ initialComponent: markRaw(component),
+ initialProps: props,
+ }, {}, 'closed');
+}
+
+export function dialog(props: {
+ type: 'error' | 'info' | 'success' | 'warning' | 'waiting';
+ title?: string | null;
+ text?: string | null;
+}) {
+ return new Promise((resolve, reject) => {
+ popup(import('@/components/dialog.vue'), props, {
+ done: result => {
+ resolve(result ? result : { canceled: true });
+ },
+ }, 'closed');
+ });
+}
+
+export function success() {
+ return new Promise((resolve, reject) => {
+ const showing = ref(true);
+ setTimeout(() => {
+ showing.value = false;
+ }, 1000);
+ popup(import('@/components/waiting-dialog.vue'), {
+ success: true,
+ showing: showing
+ }, {
+ done: () => resolve(),
+ }, 'closed');
+ });
+}
+
+export function waiting() {
+ return new Promise((resolve, reject) => {
+ const showing = ref(true);
+ popup(import('@/components/waiting-dialog.vue'), {
+ success: false,
+ showing: showing
+ }, {
+ done: () => resolve(),
+ }, 'closed');
+ });
+}
+
+export function form(title, form) {
+ return new Promise((resolve, reject) => {
+ popup(import('@/components/form-dialog.vue'), { title, form }, {
+ done: result => {
+ resolve(result);
+ },
+ }, 'closed');
+ });
+}
+
+export async function selectUser() {
+ return new Promise((resolve, reject) => {
+ popup(import('@/components/user-select-dialog.vue'), {}, {
+ ok: user => {
+ resolve(user);
+ },
+ }, 'closed');
+ });
+}
+
+export async function selectDriveFile(multiple: boolean) {
+ return new Promise((resolve, reject) => {
+ popup(import('@/components/drive-select-dialog.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(import('@/components/drive-select-dialog.vue'), {
+ type: 'folder',
+ multiple
+ }, {
+ done: folders => {
+ if (folders) {
+ resolve(multiple ? folders : folders[0]);
+ }
+ },
+ }, 'closed');
+ });
+}
+
+export async function pickEmoji(src?: HTMLElement, opts) {
+ return new Promise((resolve, reject) => {
+ popup(import('@/components/emoji-picker-dialog.vue'), {
+ src,
+ ...opts
+ }, {
+ done: emoji => {
+ resolve(emoji);
+ },
+ }, 'closed');
+ });
+}
+
+type AwaitType<T> =
+ T extends Promise<infer U> ? U :
+ T extends (...args: any[]) => Promise<infer V> ? V :
+ T;
+let openingEmojiPicker: AwaitType<ReturnType<typeof popup>> | 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<NonNullable<typeof activeTextarea>>;
+ 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(import('@/components/emoji-picker-window.vue'), {
+ src,
+ ...opts
+ }, {
+ chosen: emoji => {
+ insertTextAtCursor(activeTextarea, emoji);
+ },
+ closed: () => {
+ openingEmojiPicker!.dispose();
+ openingEmojiPicker = null;
+ observer.disconnect();
+ }
+ });
+}
+
+export function popupMenu(items: any[] | Ref<any[]>, src?: HTMLElement, options?: {
+ align?: string;
+ width?: number;
+ viaKeyboard?: boolean;
+}) {
+ return new Promise((resolve, reject) => {
+ let dispose;
+ popup(import('@/components/ui/popup-menu.vue'), {
+ items,
+ src,
+ width: options?.width,
+ align: options?.align,
+ viaKeyboard: options?.viaKeyboard
+ }, {
+ closed: () => {
+ resolve();
+ dispose();
+ },
+ }).then(res => {
+ dispose = res.dispose;
+ });
+ });
+}
+
+export function contextMenu(items: any[], ev: MouseEvent) {
+ ev.preventDefault();
+ return new Promise((resolve, reject) => {
+ let dispose;
+ popup(import('@/components/ui/context-menu.vue'), {
+ items,
+ ev,
+ }, {
+ closed: () => {
+ resolve();
+ dispose();
+ },
+ }).then(res => {
+ dispose = res.dispose;
+ });
+ });
+}
+
+export function post(props: Record<string, any>) {
+ 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 const uploads = ref([]);
+
+export function upload(file: File, folder?: any, name?: string) {
+ if (folder && typeof folder == 'object') folder = folder.id;
+
+ return new Promise((resolve, reject) => {
+ const id = Math.random();
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const ctx = reactive({
+ id: id,
+ name: name || file.name || 'untitled',
+ progressMax: undefined,
+ progressValue: undefined,
+ img: window.URL.createObjectURL(file)
+ });
+
+ uploads.value.push(ctx);
+
+ const data = new FormData();
+ data.append('i', $i.token);
+ data.append('force', 'true');
+ data.append('file', file);
+
+ if (folder) data.append('folderId', folder);
+ if (name) data.append('name', name);
+
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', apiUrl + '/drive/files/create', true);
+ xhr.onload = (e: any) => {
+ const driveFile = JSON.parse(e.target.response);
+
+ resolve(driveFile);
+
+ uploads.value = uploads.value.filter(x => x.id != id);
+ };
+
+ xhr.upload.onprogress = e => {
+ if (e.lengthComputable) {
+ ctx.progressMax = e.total;
+ ctx.progressValue = e.loaded;
+ }
+ };
+
+ xhr.send(data);
+ };
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+/*
+export function checkExistence(fileData: ArrayBuffer): Promise<any> {
+ 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/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue
new file mode 100644
index 0000000000..c549751a27
--- /dev/null
+++ b/packages/client/src/pages/_error_.vue
@@ -0,0 +1,94 @@
+<template>
+<MkLoading v-if="!loaded" />
+<transition :name="$store.state.animation ? 'zoom' : ''" appear>
+ <div class="mjndxjch" v-show="loaded">
+ <img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
+ <p><b><i class="fas fa-exclamation-triangle"></i> {{ $ts.pageLoadError }}</b></p>
+ <p v-if="version === meta.version">{{ $ts.pageLoadErrorDescription }}</p>
+ <p v-else-if="serverIsDead">{{ $ts.serverIsDead }}</p>
+ <template v-else>
+ <p>{{ $ts.newVersionOfClientAvailable }}</p>
+ <p>{{ $ts.youShouldUpgradeClient }}</p>
+ <MkButton @click="reload" class="button primary">{{ $ts.reload }}</MkButton>
+ </template>
+ <p><MkA to="/docs/general/troubleshooting" class="_link">{{ $ts.troubleshooting }}</MkA></p>
+ <p v-if="error" class="error">ERROR: {{ error }}</p>
+ </div>
+</transition>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as symbols from '@/symbols';
+import { version } from '@/config';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+ props: {
+ error: {
+ required: false,
+ }
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.error,
+ icon: 'fas fa-exclamation-triangle'
+ },
+ loaded: false,
+ serverIsDead: false,
+ meta: {} as any,
+ version,
+ };
+ },
+ created() {
+ os.api('meta', {
+ detail: false
+ }).then(meta => {
+ this.loaded = true;
+ this.serverIsDead = false;
+ this.meta = meta;
+ localStorage.setItem('v', meta.version);
+ }, () => {
+ this.loaded = true;
+ this.serverIsDead = true;
+ });
+ },
+ methods: {
+ reload() {
+ unisonReload();
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mjndxjch {
+ padding: 32px;
+ text-align: center;
+
+ > p {
+ margin: 0 0 12px 0;
+ }
+
+ > .button {
+ margin: 8px auto;
+ }
+
+ > img {
+ vertical-align: bottom;
+ height: 128px;
+ margin-bottom: 24px;
+ border-radius: 16px;
+ }
+
+ > .error {
+ opacity: 0.7;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/_loading_.vue b/packages/client/src/pages/_loading_.vue
new file mode 100644
index 0000000000..05c6af1cd7
--- /dev/null
+++ b/packages/client/src/pages/_loading_.vue
@@ -0,0 +1,10 @@
+<template>
+<MkLoading/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({});
+</script>
diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue
new file mode 100644
index 0000000000..c428c1ad83
--- /dev/null
+++ b/packages/client/src/pages/about-misskey.vue
@@ -0,0 +1,238 @@
+<template>
+<div style="overflow: clip;">
+ <FormBase class="znqjceqz">
+ <div id="debug"></div>
+ <section class="_debobigegoItem about">
+ <div class="_debobigegoPanel panel" :class="{ playing: easterEggEngine != null }" ref="about">
+ <img src="/client-assets/about-icon.png" alt="" class="icon" @load="iconLoaded" draggable="false" @click="gravity"/>
+ <div class="misskey">Misskey</div>
+ <div class="version">v{{ version }}</div>
+ <span class="emoji" v-for="emoji in easterEggEmojis" :key="emoji.id" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span>
+ </div>
+ </section>
+ <section class="_debobigegoItem" style="text-align: center; padding: 0 16px;">
+ {{ $ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ $ts.learnMore }}</a>
+ </section>
+ <FormGroup>
+ <FormLink to="https://github.com/misskey-dev/misskey" external>
+ <template #icon><i class="fas fa-code"></i></template>
+ {{ $ts._aboutMisskey.source }}
+ <template #suffix>GitHub</template>
+ </FormLink>
+ <FormLink to="https://crowdin.com/project/misskey" external>
+ <template #icon><i class="fas fa-language"></i></template>
+ {{ $ts._aboutMisskey.translation }}
+ <template #suffix>Crowdin</template>
+ </FormLink>
+ <FormLink to="https://www.patreon.com/syuilo" external>
+ <template #icon><i class="fas fa-hand-holding-medical"></i></template>
+ {{ $ts._aboutMisskey.donate }}
+ <template #suffix>Patreon</template>
+ </FormLink>
+ </FormGroup>
+ <FormGroup>
+ <template #label>{{ $ts._aboutMisskey.contributors }}</template>
+ <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink>
+ <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink>
+ <FormLink to="https://github.com/mei23" external>@mei23</FormLink>
+ <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink>
+ <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink>
+ <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink>
+ <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink>
+ <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink>
+ <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink>
+ <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ $ts._aboutMisskey.allContributors }}</MkLink></template>
+ </FormGroup>
+ <FormGroup>
+ <template #label><Mfm text="[jelly ❤]"/> {{ $ts._aboutMisskey.patrons }}</template>
+ <FormKeyValueView v-for="patron in patrons" :key="patron"><template #key>{{ patron }}</template></FormKeyValueView>
+ <template #caption>{{ $ts._aboutMisskey.morePatrons }}</template>
+ </FormGroup>
+ </FormBase>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { version } from '@/config';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import MkLink from '@/components/link.vue';
+import { physics } from '@/scripts/physics';
+import * as symbols from '@/symbols';
+
+const patrons = [
+ 'Satsuki Yanagi',
+ 'noellabo',
+ 'mametsuko',
+ 'AureoleArk',
+ 'Gargron',
+ 'Nokotaro Takeda',
+ 'Suji Yan',
+ 'Hekovic',
+ 'Gitmo Life Services',
+ 'nenohi',
+ 'naga_rus',
+ 'Melilot',
+ 'Efertone',
+ 'oi_yekssim',
+ 'nanami kan',
+ 'motcha',
+ 'dansup',
+ 'Quinton Macejkovic',
+ 'YUKIMOCHI',
+ 'mewl hayabusa',
+ 'makokunsan',
+ 'Peter G.',
+ 'Nesakko',
+ 'regtan',
+ '見当かなみ',
+ 'natalie',
+ 'Jerry',
+ 'takimura',
+ 'sikyosyounin',
+ 'YuzuRyo61',
+ 'sheeta.s',
+ 'osapon',
+ 'mkatze',
+ 'CG',
+ 'nafuchoco',
+ 'Takumi Sugita',
+ 'chidori ninokura',
+ 'mydarkstar',
+ 'kiritan',
+ 'kabo2468y',
+ 'weepjp',
+ 'Liaizon Wakest',
+ 'Steffen K9',
+ 'Roujo',
+ 'uroco @99',
+ 'totokoro',
+ 'public_yusuke',
+ 'wara',
+ 'S Y',
+ 'Denshi',
+ 'Osushimaru',
+ '吴浥',
+ 'DignifiedSilence',
+ 't_w',
+];
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormKeyValueView,
+ MkLink,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.aboutMisskey,
+ icon: null
+ },
+ version,
+ patrons,
+ easterEggReady: false,
+ easterEggEmojis: [],
+ easterEggEngine: null,
+ }
+ },
+
+ beforeUnmount() {
+ if (this.easterEggEngine) {
+ this.easterEggEngine.stop();
+ }
+ },
+
+ methods: {
+ iconLoaded() {
+ const emojis = this.$store.state.reactions;
+ const containerWidth = this.$refs.about.offsetWidth;
+ for (let i = 0; i < 32; i++) {
+ this.easterEggEmojis.push({
+ id: i.toString(),
+ top: -(128 + (Math.random() * 256)),
+ left: (Math.random() * containerWidth),
+ emoji: emojis[Math.floor(Math.random() * emojis.length)],
+ });
+ }
+
+ this.$nextTick(() => {
+ this.easterEggReady = true;
+ });
+ },
+
+ gravity() {
+ if (!this.easterEggReady) return;
+ this.easterEggReady = false;
+ this.easterEggEngine = physics(this.$refs.about);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.znqjceqz {
+ max-width: 800px;
+ box-sizing: border-box;
+ margin: 0 auto;
+
+ > .about {
+ > .panel {
+ position: relative;
+ text-align: center;
+ padding: 16px;
+
+ &.playing {
+ &, * {
+ user-select: none;
+ }
+
+ * {
+ will-change: transform;
+ }
+
+ > .emoji {
+ visibility: visible;
+ }
+ }
+
+ > .icon {
+ display: block;
+ width: 100px;
+ margin: 0 auto;
+ border-radius: 16px;
+ }
+
+ > .misskey {
+ margin: 0.75em auto 0 auto;
+ width: max-content;
+ }
+
+ > .version {
+ margin: 0 auto;
+ width: max-content;
+ opacity: 0.5;
+ }
+
+ > .emoji {
+ position: absolute;
+ top: 0;
+ left: 0;
+ visibility: hidden;
+
+ > .emoji {
+ pointer-events: none;
+ font-size: 24px;
+ width: 24px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue
new file mode 100644
index 0000000000..dbdf0f6d91
--- /dev/null
+++ b/packages/client/src/pages/about.vue
@@ -0,0 +1,123 @@
+<template>
+<FormBase>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel fwhjspax">
+ <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
+ <span class="name">{{ $instance.name || host }}</span>
+ </div>
+ </div>
+
+ <FormTextarea readonly :value="$instance.description">
+ </FormTextarea>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Misskey</template>
+ <template #value>v{{ version }}</template>
+ </FormKeyValueView>
+ <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink>
+ </FormGroup>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.administrator }}</template>
+ <template #value>{{ $instance.maintainerName }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.contact }}</template>
+ <template #value>{{ $instance.maintainerEmail }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" external>{{ $ts.tos }}</FormLink>
+
+ <FormSuspense :p="initStats">
+ <FormGroup>
+ <template #label>{{ $ts.statistics }}</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.users }}</template>
+ <template #value>{{ number(stats.originalUsersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.notes }}</template>
+ <template #value>{{ number(stats.originalNotesCount) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+
+ <FormGroup>
+ <template #label>Well-known resources</template>
+ <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink>
+ <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+ <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+ <FormLink :to="`/robots.txt`" external>robots.txt</FormLink>
+ <FormLink :to="`/manifest.json`" external>manifest.json</FormLink>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { version, instanceName } from '@/config';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import * as symbols from '@/symbols';
+import { host } from '@/config';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormKeyValueView,
+ FormTextarea,
+ FormSuspense,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.instanceInfo,
+ icon: 'fas fa-info-circle'
+ },
+ host,
+ version,
+ instanceName,
+ stats: null,
+ initStats: () => os.api('stats', {
+ }).then((stats) => {
+ this.stats = stats;
+ })
+ }
+ },
+
+ methods: {
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fwhjspax {
+ padding: 16px;
+ text-align: center;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 64px;
+ border-radius: 8px;
+ }
+
+ > .name {
+ display: block;
+ margin-top: 12px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue
new file mode 100644
index 0000000000..ca94737781
--- /dev/null
+++ b/packages/client/src/pages/admin/abuses.vue
@@ -0,0 +1,170 @@
+<template>
+<div class="lcixvhis">
+ <div class="_section reports">
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <MkSelect v-model="state" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="unresolved">{{ $ts.unresolved }}</option>
+ <option value="resolved">{{ $ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporteeOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.reporterOrigin }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <!-- TODO
+ <div class="inputs" style="display: flex; padding-top: 1.2em;">
+ <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()">
+ <span>{{ $ts.username }}</span>
+ </MkInput>
+ <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.reports.reload()" :disabled="pagination.params().origin === 'local'">
+ <span>{{ $ts.host }}</span>
+ </MkInput>
+ </div>
+ -->
+
+ <MkPagination :pagination="pagination" #default="{items}" ref="reports" style="margin-top: var(--margin);">
+ <div class="bcekxzvu _card _gap" v-for="report in items" :key="report.id">
+ <div class="_content target">
+ <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/>
+ <div class="info">
+ <MkUserName class="name" :user="report.targetUser"/>
+ <div class="acct">@{{ acct(report.targetUser) }}</div>
+ </div>
+ </div>
+ <div class="_content">
+ <div>
+ <Mfm :text="report.comment"/>
+ </div>
+ <hr>
+ <div>Reporter: <MkAcct :user="report.reporter"/></div>
+ <div><MkTime :time="report.createdAt"/></div>
+ </div>
+ <div class="_footer">
+ <div v-if="report.assignee">Assignee: <MkAcct :user="report.assignee"/></div>
+ <MkButton @click="resolve(report)" primary v-if="!report.resolved">{{ $ts.abuseMarkAsResolved }}</MkButton>
+ </div>
+ </div>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.abuseReports,
+ icon: 'fas fa-exclamation-circle',
+ bg: 'var(--bg)',
+ },
+ searchUsername: '',
+ searchHost: '',
+ state: 'unresolved',
+ reporterOrigin: 'combined',
+ targetUserOrigin: 'combined',
+ pagination: {
+ endpoint: 'admin/abuse-user-reports',
+ limit: 10,
+ params: () => ({
+ state: this.state,
+ reporterOrigin: this.reporterOrigin,
+ targetUserOrigin: this.targetUserOrigin,
+ }),
+ },
+ }
+ },
+
+ watch: {
+ state() {
+ this.$refs.reports.reload();
+ },
+
+ reporterOrigin() {
+ this.$refs.reports.reload();
+ },
+
+ targetUserOrigin() {
+ this.$refs.reports.reload();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ acct,
+
+ resolve(report) {
+ os.apiWithDialog('admin/resolve-abuse-user-report', {
+ reportId: report.id,
+ }).then(() => {
+ this.$refs.reports.removeItem(item => item.id === report.id);
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lcixvhis {
+ margin: var(--margin);
+}
+
+.bcekxzvu {
+ > .target {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ > .avatar {
+ width: 42px;
+ height: 42px;
+ }
+
+ > .info {
+ margin-left: 0.3em;
+ padding: 0 8px;
+ flex: 1;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue
new file mode 100644
index 0000000000..df6c9d5d00
--- /dev/null
+++ b/packages/client/src/pages/admin/ads.vue
@@ -0,0 +1,138 @@
+<template>
+<div class="uqshojas">
+ <section class="_card _gap ads" v-for="ad in ads">
+ <div class="_content ad">
+ <MkAd v-if="ad.url" :specify="ad"/>
+ <MkInput v-model="ad.url" type="url">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="ad.imageUrl">
+ <template #label>{{ $ts.imageUrl }}</template>
+ </MkInput>
+ <div style="margin: 32px 0;">
+ <MkRadio v-model="ad.place" value="square">square</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal">horizontal</MkRadio>
+ <MkRadio v-model="ad.place" value="horizontal-big">horizontal-big</MkRadio>
+ </div>
+ <!--
+ <div style="margin: 32px 0;">
+ {{ $ts.priority }}
+ <MkRadio v-model="ad.priority" value="high">{{ $ts.high }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="middle">{{ $ts.middle }}</MkRadio>
+ <MkRadio v-model="ad.priority" value="low">{{ $ts.low }}</MkRadio>
+ </div>
+ -->
+ <MkInput v-model="ad.ratio" type="number">
+ <template #label>{{ $ts.ratio }}</template>
+ </MkInput>
+ <MkInput v-model="ad.expiresAt" type="date">
+ <template #label>{{ $ts.expiration }}</template>
+ </MkInput>
+ <MkTextarea v-model="ad.memo">
+ <template #label>{{ $ts.memo }}</template>
+ </MkTextarea>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(ad)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(ad)" danger><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkRadio,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.ads,
+ icon: 'fas fa-audio-description',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.add,
+ handler: this.add,
+ }],
+ },
+ ads: [],
+ }
+ },
+
+ created() {
+ os.api('admin/ad/list').then(ads => {
+ this.ads = ads;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.ads.unshift({
+ id: null,
+ memo: '',
+ place: 'square',
+ priority: 'middle',
+ ratio: 1,
+ url: '',
+ imageUrl: null,
+ expiresAt: null,
+ });
+ },
+
+ remove(ad) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: ad.url }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.ads = this.ads.filter(x => x != ad);
+ os.apiWithDialog('admin/ad/delete', {
+ id: ad.id
+ });
+ });
+ },
+
+ save(ad) {
+ if (ad.id == null) {
+ os.apiWithDialog('admin/ad/create', {
+ ...ad,
+ expiresAt: new Date(ad.expiresAt).getTime()
+ });
+ } else {
+ os.apiWithDialog('admin/ad/update', {
+ ...ad,
+ expiresAt: new Date(ad.expiresAt).getTime()
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.uqshojas {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue
new file mode 100644
index 0000000000..a64008967f
--- /dev/null
+++ b/packages/client/src/pages/admin/announcements.vue
@@ -0,0 +1,125 @@
+<template>
+<div class="ztgjmzrw">
+ <section class="_card _gap announcements" v-for="announcement in announcements">
+ <div class="_content announcement">
+ <MkInput v-model="announcement.title">
+ <template #label>{{ $ts.title }}</template>
+ </MkInput>
+ <MkTextarea v-model="announcement.text">
+ <template #label>{{ $ts.text }}</template>
+ </MkTextarea>
+ <MkInput v-model="announcement.imageUrl">
+ <template #label>{{ $ts.imageUrl }}</template>
+ </MkInput>
+ <p v-if="announcement.reads">{{ $t('nUsersRead', { n: announcement.reads }) }}</p>
+ <div class="buttons">
+ <MkButton class="button" inline @click="save(announcement)" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkTextarea,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.announcements,
+ icon: 'fas fa-broadcast-tower',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.add,
+ handler: this.add,
+ }],
+ },
+ announcements: [],
+ }
+ },
+
+ created() {
+ os.api('admin/announcements/list').then(announcements => {
+ this.announcements = announcements;
+ });
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ add() {
+ this.announcements.unshift({
+ id: null,
+ title: '',
+ text: '',
+ imageUrl: null
+ });
+ },
+
+ remove(announcement) {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: announcement.title }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ this.announcements = this.announcements.filter(x => x != announcement);
+ os.api('admin/announcements/delete', announcement);
+ });
+ },
+
+ save(announcement) {
+ if (announcement.id == null) {
+ os.api('admin/announcements/create', announcement).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.saved
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ } else {
+ os.api('admin/announcements/update', announcement).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.saved
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ztgjmzrw {
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue
new file mode 100644
index 0000000000..8f7873baa3
--- /dev/null
+++ b/packages/client/src/pages/admin/bot-protection.vue
@@ -0,0 +1,138 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormRadios v-model="provider">
+ <template #desc><i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}</template>
+ <option :value="null">{{ $ts.none }} ({{ $ts.notRecommended }})</option>
+ <option value="hcaptcha">hCaptcha</option>
+ <option value="recaptcha">reCAPTCHA</option>
+ </FormRadios>
+
+ <template v-if="provider === 'hcaptcha'">
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">hCaptcha</div>
+ <div class="main">
+ <FormInput v-model="hcaptchaSiteKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.hcaptchaSiteKey }}</span>
+ </FormInput>
+ <FormInput v-model="hcaptchaSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.hcaptchaSecretKey }}</span>
+ </FormInput>
+ </div>
+ </div>
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.preview }}</div>
+ <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
+ <MkCaptcha provider="hcaptcha" :sitekey="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
+ </div>
+ </div>
+ </template>
+ <template v-else-if="provider === 'recaptcha'">
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">reCAPTCHA</div>
+ <div class="main">
+ <FormInput v-model="recaptchaSiteKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.recaptchaSiteKey }}</span>
+ </FormInput>
+ <FormInput v-model="recaptchaSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>{{ $ts.recaptchaSecretKey }}</span>
+ </FormInput>
+ </div>
+ </div>
+ <div v-if="recaptchaSiteKey" class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.preview }}</div>
+ <div class="_debobigegoPanel" style="padding: var(--debobigegoContentHMargin);">
+ <MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
+ </div>
+ </div>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormRadios,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ MkCaptcha: defineAsyncComponent(() => import('@/components/captcha.vue')),
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.botProtection,
+ icon: 'fas fa-shield-alt'
+ },
+ provider: null,
+ enableHcaptcha: false,
+ hcaptchaSiteKey: null,
+ hcaptchaSecretKey: null,
+ enableRecaptcha: false,
+ recaptchaSiteKey: null,
+ recaptchaSecretKey: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableHcaptcha = meta.enableHcaptcha;
+ this.hcaptchaSiteKey = meta.hcaptchaSiteKey;
+ this.hcaptchaSecretKey = meta.hcaptchaSecretKey;
+ this.enableRecaptcha = meta.enableRecaptcha;
+ this.recaptchaSiteKey = meta.recaptchaSiteKey;
+ this.recaptchaSecretKey = meta.recaptchaSecretKey;
+
+ this.provider = this.enableHcaptcha ? 'hcaptcha' : this.enableRecaptcha ? 'recaptcha' : null;
+
+ this.$watch(() => this.provider, () => {
+ this.enableHcaptcha = this.provider === 'hcaptcha';
+ this.enableRecaptcha = this.provider === 'recaptcha';
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableHcaptcha: this.enableHcaptcha,
+ hcaptchaSiteKey: this.hcaptchaSiteKey,
+ hcaptchaSecretKey: this.hcaptchaSecretKey,
+ enableRecaptcha: this.enableRecaptcha,
+ recaptchaSiteKey: this.recaptchaSiteKey,
+ recaptchaSecretKey: this.recaptchaSecretKey,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue
new file mode 100644
index 0000000000..b550831e02
--- /dev/null
+++ b/packages/client/src/pages/admin/database.vue
@@ -0,0 +1,61 @@
+<template>
+<FormBase>
+ <FormSuspense :p="databasePromiseFactory" v-slot="{ result: database }">
+ <FormGroup v-for="table in database" :key="table[0]">
+ <template #label>{{ table[0] }}</template>
+ <FormKeyValueView>
+ <template #key>Size</template>
+ <template #value>{{ bytes(table[1].size) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>Records</template>
+ <template #value>{{ number(table[1].count) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ FormSuspense,
+ FormKeyValueView,
+ FormBase,
+ FormGroup,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.database,
+ icon: 'fas fa-database',
+ bg: 'var(--bg)',
+ },
+ databasePromiseFactory: () => os.api('admin/get-table-stats', {}).then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ bytes, number,
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue
new file mode 100644
index 0000000000..3733f53a23
--- /dev/null
+++ b/packages/client/src/pages/admin/email-settings.vue
@@ -0,0 +1,128 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableEmail">{{ $ts.enableEmail }}<template #desc>{{ $ts.emailConfigInfo }}</template></FormSwitch>
+
+ <template v-if="enableEmail">
+ <FormInput v-model="email" type="email">
+ <span>{{ $ts.emailAddress }}</span>
+ </FormInput>
+
+ <div class="_debobigegoItem _debobigegoNoConcat" v-sticky-container>
+ <div class="_debobigegoLabel">{{ $ts.smtpConfig }}</div>
+ <div class="main">
+ <FormInput v-model="smtpHost">
+ <span>{{ $ts.smtpHost }}</span>
+ </FormInput>
+ <FormInput v-model="smtpPort" type="number">
+ <span>{{ $ts.smtpPort }}</span>
+ </FormInput>
+ <FormInput v-model="smtpUser">
+ <span>{{ $ts.smtpUser }}</span>
+ </FormInput>
+ <FormInput v-model="smtpPass" type="password">
+ <span>{{ $ts.smtpPass }}</span>
+ </FormInput>
+ <FormInfo>{{ $ts.emptyToDisableSmtpAuth }}</FormInfo>
+ <FormSwitch v-model="smtpSecure">{{ $ts.smtpSecure }}<template #desc>{{ $ts.smtpSecureInfo }}</template></FormSwitch>
+ </div>
+ </div>
+
+ <FormButton @click="testEmail">{{ $ts.testEmail }}</FormButton>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.emailServer,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ enableEmail: false,
+ email: null,
+ smtpSecure: false,
+ smtpHost: '',
+ smtpPort: 0,
+ smtpUser: '',
+ smtpPass: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableEmail = meta.enableEmail;
+ this.email = meta.email;
+ this.smtpSecure = meta.smtpSecure;
+ this.smtpHost = meta.smtpHost;
+ this.smtpPort = meta.smtpPort;
+ this.smtpUser = meta.smtpUser;
+ this.smtpPass = meta.smtpPass;
+ },
+
+ async testEmail() {
+ const { canceled, result: destination } = await os.dialog({
+ title: this.$ts.destination,
+ input: {
+ placeholder: this.$instance.maintainerEmail
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('admin/send-email', {
+ to: destination,
+ subject: 'Test email',
+ text: 'Yo'
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableEmail: this.enableEmail,
+ email: this.email,
+ smtpSecure: this.smtpSecure,
+ smtpHost: this.smtpHost,
+ smtpPort: this.smtpPort,
+ smtpUser: this.smtpUser,
+ smtpPass: this.smtpPass,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/emoji-edit-dialog.vue b/packages/client/src/pages/admin/emoji-edit-dialog.vue
new file mode 100644
index 0000000000..e612855105
--- /dev/null
+++ b/packages/client/src/pages/admin/emoji-edit-dialog.vue
@@ -0,0 +1,120 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ :with-ok-button="true"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+ @ok="ok()"
+>
+ <template #header>:{{ emoji.name }}:</template>
+
+ <div class="_monolithic_">
+ <div class="yigymqpb _section">
+ <img :src="emoji.url" class="img"/>
+ <MkInput class="_formBlock" v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="category" :datalist="categories">
+ <template #label>{{ $ts.category }}</template>
+ </MkInput>
+ <MkInput class="_formBlock" v-model="aliases">
+ <template #label>{{ $ts.tags }}</template>
+ <template #caption>{{ $ts.setMultipleBySeparatingWithSpace }}</template>
+ </MkInput>
+ <MkButton danger @click="del()"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+import { unique } from '@/scripts/array';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkButton,
+ MkInput,
+ },
+
+ props: {
+ emoji: {
+ required: true,
+ }
+ },
+
+ emits: ['done', 'closed'],
+
+ data() {
+ return {
+ name: this.emoji.name,
+ category: this.emoji.category,
+ aliases: this.emoji.aliases?.join(' '),
+ categories: [],
+ }
+ },
+
+ created() {
+ os.api('meta', { detail: false }).then(({ emojis }) => {
+ this.categories = unique(emojis.map((x: any) => x.category || '').filter((x: string) => x !== ''));
+ });
+ },
+
+ methods: {
+ ok() {
+ this.update();
+ },
+
+ async update() {
+ await os.apiWithDialog('admin/emoji/update', {
+ id: this.emoji.id,
+ name: this.name,
+ category: this.category,
+ aliases: this.aliases.split(' '),
+ });
+
+ this.$emit('done', {
+ updated: {
+ name: this.name,
+ category: this.category,
+ aliases: this.aliases.split(' '),
+ }
+ });
+ this.$refs.dialog.close();
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.emoji.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.api('admin/emoji/remove', {
+ id: this.emoji.id
+ }).then(() => {
+ this.$emit('done', {
+ deleted: true
+ });
+ this.$refs.dialog.close();
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yigymqpb {
+ > .img {
+ display: block;
+ height: 64px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue
new file mode 100644
index 0000000000..c9ba193dd1
--- /dev/null
+++ b/packages/client/src/pages/admin/emojis.vue
@@ -0,0 +1,263 @@
+<template>
+<div class="ogwlenmc">
+ <div class="local" v-if="tab === 'local'">
+ <MkInput v-model="query" :debounce="true" type="search" style="margin: var(--margin);">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkPagination :pagination="pagination" ref="emojis">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <button class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="edit(emoji)">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.category }}</div>
+ </div>
+ </button>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+
+ <div class="remote" v-else-if="tab === 'remote'">
+ <MkInput v-model="queryRemote" :debounce="true" type="search" style="margin: var(--margin);">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.search }}</template>
+ </MkInput>
+ <MkInput v-model="host" :debounce="true" style="margin: var(--margin);">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ <MkPagination :pagination="remotePagination" ref="remoteEmojis">
+ <template #empty><span>{{ $ts.noCustomEmojis }}</span></template>
+ <template #default="{items}">
+ <div class="ldhfsamy">
+ <div class="emoji _panel _button" v-for="emoji in items" :key="emoji.id" @click="remoteMenu(emoji, $event)">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.host }}</div>
+ </div>
+ </div>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, toRef } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkTab from '@/components/tab.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTab,
+ MkButton,
+ MkInput,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.customEmojis,
+ icon: 'fas fa-laugh',
+ bg: 'var(--bg)',
+ actions: [{
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.addEmoji,
+ handler: this.add,
+ }],
+ tabs: [{
+ active: this.tab === 'local',
+ title: this.$ts.local,
+ onClick: () => { this.tab = 'local'; },
+ }, {
+ active: this.tab === 'remote',
+ title: this.$ts.remote,
+ onClick: () => { this.tab = 'remote'; },
+ },]
+ })),
+ tab: 'local',
+ query: null,
+ queryRemote: null,
+ host: '',
+ pagination: {
+ endpoint: 'admin/emoji/list',
+ limit: 30,
+ params: computed(() => ({
+ query: (this.query && this.query !== '') ? this.query : null
+ }))
+ },
+ remotePagination: {
+ endpoint: 'admin/emoji/list-remote',
+ limit: 30,
+ params: computed(() => ({
+ query: (this.queryRemote && this.queryRemote !== '') ? this.queryRemote : null,
+ host: (this.host && this.host !== '') ? this.host : null
+ }))
+ },
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', toRef(this, symbols.PAGE_INFO));
+ },
+
+ methods: {
+ async add(e) {
+ const files = await selectFile(e.currentTarget || e.target, null, true);
+
+ const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
+ fileId: file.id,
+ })));
+ promise.then(() => {
+ this.$refs.emojis.reload();
+ });
+ os.promiseDialog(promise);
+ },
+
+ edit(emoji) {
+ os.popup(import('./emoji-edit-dialog.vue'), {
+ emoji: emoji
+ }, {
+ done: result => {
+ if (result.updated) {
+ this.$refs.emojis.replaceItem(item => item.id === emoji.id, {
+ ...emoji,
+ ...result.updated
+ });
+ } else if (result.deleted) {
+ this.$refs.emojis.removeItem(item => item.id === emoji.id);
+ }
+ },
+ }, 'closed');
+ },
+
+ im(emoji) {
+ os.apiWithDialog('admin/emoji/copy', {
+ emojiId: emoji.id,
+ });
+ },
+
+ remoteMenu(emoji, ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: ':' + emoji.name + ':',
+ }, {
+ text: this.$ts.import,
+ icon: 'fas fa-plus',
+ action: () => { this.im(emoji) }
+ }], ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ogwlenmc {
+ > .local {
+ .empty {
+ margin: var(--margin);
+ }
+
+ .ldhfsamy {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .img {
+ width: 42px;
+ height: 42px;
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+
+ > .remote {
+ .empty {
+ margin: var(--margin);
+ }
+
+ .ldhfsamy {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+
+ > .emoji {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .img {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ font-size: 90%;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue
new file mode 100644
index 0000000000..016a012ea5
--- /dev/null
+++ b/packages/client/src/pages/admin/file-dialog.vue
@@ -0,0 +1,129 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="370"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header v-if="file">{{ file.name }}</template>
+ <div class="cxqhhsmd" v-if="file">
+ <div class="_section">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <div class="info">
+ <span style="margin-right: 1em;">{{ file.type }}</span>
+ <span>{{ bytes(file.size) }}</span>
+ <MkTime :time="file.createdAt" mode="detail" style="display: block;"/>
+ </div>
+ </div>
+ <div class="_section">
+ <div class="_content">
+ <MkSwitch @update:modelValue="toggleIsSensitive" v-model="isSensitive">NSFW</MkSwitch>
+ </div>
+ </div>
+ <div class="_section">
+ <div class="_content">
+ <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton>
+ <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ <div class="_section" v-if="info">
+ <details class="_content rawdata">
+ <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
+ </details>
+ </div>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import Progress from '@/scripts/loading';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ XModalWindow,
+ MkDriveFileThumbnail,
+ },
+
+ props: {
+ fileId: {
+ required: true,
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ file: null,
+ info: null,
+ isSensitive: false,
+ };
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ async fetch() {
+ Progress.start();
+ this.file = await os.api('drive/files/show', { fileId: this.fileId });
+ this.info = await os.api('admin/drive/show-file', { fileId: this.fileId });
+ this.isSensitive = this.file.isSensitive;
+ Progress.done();
+ },
+
+ showUser() {
+ os.pageWindow(`/user-info/${this.file.userId}`);
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.file.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('drive/files/delete', {
+ fileId: this.file.id
+ });
+ },
+
+ async toggleIsSensitive(v) {
+ await os.api('drive/files/update', { fileId: this.fileId, isSensitive: v });
+ this.isSensitive = v;
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cxqhhsmd {
+ > ._section {
+ > .thumbnail {
+ height: 150px;
+ max-width: 100%;
+ }
+
+ > .info {
+ text-align: center;
+ margin-top: 8px;
+ }
+
+ > .rawdata {
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/files-settings.vue b/packages/client/src/pages/admin/files-settings.vue
new file mode 100644
index 0000000000..03d8f3de1f
--- /dev/null
+++ b/packages/client/src/pages/admin/files-settings.vue
@@ -0,0 +1,93 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="cacheRemoteFiles">
+ {{ $ts.cacheRemoteFiles }}
+ <template #desc>{{ $ts.cacheRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="proxyRemoteFiles">
+ {{ $ts.proxyRemoteFiles }}
+ <template #desc>{{ $ts.proxyRemoteFilesDescription }}</template>
+ </FormSwitch>
+
+ <FormInput v-model="localDriveCapacityMb" type="number">
+ <span>{{ $ts.driveCapacityPerLocalAccount }}</span>
+ <template #suffix>MB</template>
+ <template #desc>{{ $ts.inMb }}</template>
+ </FormInput>
+
+ <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles">
+ <span>{{ $ts.driveCapacityPerRemoteAccount }}</span>
+ <template #suffix>MB</template>
+ <template #desc>{{ $ts.inMb }}</template>
+ </FormInput>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.files,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ },
+ cacheRemoteFiles: false,
+ proxyRemoteFiles: false,
+ localDriveCapacityMb: 0,
+ remoteDriveCapacityMb: 0,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.cacheRemoteFiles = meta.cacheRemoteFiles;
+ this.proxyRemoteFiles = meta.proxyRemoteFiles;
+ this.localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
+ this.remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ cacheRemoteFiles: this.cacheRemoteFiles,
+ proxyRemoteFiles: this.proxyRemoteFiles,
+ localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
+ remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue
new file mode 100644
index 0000000000..e291d97bbc
--- /dev/null
+++ b/packages/client/src/pages/admin/files.vue
@@ -0,0 +1,209 @@
+<template>
+<div class="xrmjdkdw">
+ <MkContainer :foldable="true" class="lookup">
+ <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template>
+ <div class="xrmjdkdw-lookup">
+ <MkInput class="item" v-model="q" type="text" @enter="find()">
+ <template #label>{{ $ts.fileIdOrUrl }}</template>
+ </MkInput>
+ <MkButton @click="find()" primary><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton>
+ </div>
+ </MkContainer>
+
+ <div class="_section">
+ <div class="_content">
+ <div class="inputs" style="display: flex;">
+ <MkSelect v-model="origin" style="margin: 0; flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params().origin === 'local'">
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </div>
+ <div class="inputs" style="display: flex; padding-top: 1.2em;">
+ <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;">
+ <template #label>MIME type</template>
+ </MkInput>
+ </div>
+ <MkPagination :pagination="pagination" #default="{items}" class="urempief" ref="files">
+ <button class="file _panel _button _gap" v-for="file in items" :key="file.id" @click="show(file, $event)">
+ <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+ <div class="body">
+ <div>
+ <small style="opacity: 0.7;">{{ file.name }}</small>
+ </div>
+ <div>
+ <MkAcct v-if="file.user" :user="file.user"/>
+ <div v-else>{{ $ts.system }}</div>
+ </div>
+ <div>
+ <span style="margin-right: 1em;">{{ file.type }}</span>
+ <span>{{ bytes(file.size) }}</span>
+ </div>
+ <div>
+ <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+ </div>
+ </div>
+ </button>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import bytes from '@/filters/bytes';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ MkContainer,
+ MkDriveFileThumbnail,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.files,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ actions: [{
+ text: this.$ts.clearCachedFiles,
+ icon: 'fas fa-trash-alt',
+ handler: this.clear
+ }]
+ },
+ q: null,
+ origin: 'local',
+ type: null,
+ searchHost: '',
+ pagination: {
+ endpoint: 'admin/drive/files',
+ limit: 10,
+ params: () => ({
+ type: (this.type && this.type !== '') ? this.type : null,
+ origin: this.origin,
+ hostname: (this.hostname && this.hostname !== '') ? this.hostname : null,
+ }),
+ },
+ }
+ },
+
+ watch: {
+ type() {
+ this.$refs.files.reload();
+ },
+ origin() {
+ this.$refs.files.reload();
+ },
+ searchHost() {
+ this.$refs.files.reload();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ clear() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.clearCachedFilesConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/drive/clean-remote-files', {});
+ });
+ },
+
+ show(file, ev) {
+ os.popup(import('./file-dialog.vue'), {
+ fileId: file.id
+ }, {}, 'closed');
+ },
+
+ find() {
+ os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => {
+ this.show(file);
+ }).catch(e => {
+ if (e.code === 'NO_SUCH_FILE') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.notFound
+ });
+ }
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xrmjdkdw {
+ margin: var(--margin);
+
+ > .lookup {
+ margin-bottom: 16px;
+ }
+
+ .urempief {
+ margin-top: var(--margin);
+
+ > .file {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .thumbnail {
+ width: 128px;
+ height: 128px;
+ }
+
+ > .body {
+ margin-left: 0.3em;
+ padding: 8px;
+ flex: 1;
+
+ @media (max-width: 500px) {
+ font-size: 14px;
+ }
+ }
+ }
+ }
+}
+
+.xrmjdkdw-lookup {
+ padding: 16px;
+
+ > .item {
+ margin-bottom: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
new file mode 100644
index 0000000000..d3f9406db7
--- /dev/null
+++ b/packages/client/src/pages/admin/index.vue
@@ -0,0 +1,388 @@
+<template>
+<div class="hiyeyicy" :class="{ wide: !narrow }" ref="el">
+ <div class="nav" v-if="!narrow || page == null">
+ <MkHeader :info="header"></MkHeader>
+
+ <MkSpacer :content-max="700">
+ <div class="lxpfedzu">
+ <div class="banner">
+ <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
+ </div>
+
+ <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/bot-protection" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+
+ <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu>
+ </div>
+ </MkSpacer>
+ </div>
+ <div class="main">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template>
+ <component :is="component" :key="page" @info="onInfo" v-bind="pageProps"/>
+ </MkStickyContainer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent, isRef, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkSuperMenu from '@/components/ui/super-menu.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import MkInfo from '@/components/ui/info.vue';
+import { scroll } from '@/scripts/scroll';
+import { instance } from '@/instance';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+import { lookupUser } from '@/scripts/lookup-user';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkSuperMenu,
+ FormGroup,
+ FormButton,
+ MkInfo,
+ },
+
+ provide: {
+ shouldOmitHeaderTitle: false,
+ },
+
+ props: {
+ initialPage: {
+ type: String,
+ required: false
+ }
+ },
+
+ setup(props, context) {
+ const indexInfo = {
+ title: i18n.locale.controlPanel,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ hideHeader: true,
+ };
+ const INFO = ref(indexInfo);
+ const childInfo = ref(null);
+ const page = ref(props.initialPage);
+ const narrow = ref(false);
+ const view = ref(null);
+ const el = ref(null);
+ const onInfo = (viewInfo) => {
+ if (isRef(viewInfo)) {
+ watch(viewInfo, () => {
+ childInfo.value = viewInfo.value;
+ }, { immediate: true });
+ } else {
+ childInfo.value = viewInfo;
+ }
+ };
+ const pageProps = ref({});
+
+ const isEmpty = (x: any) => x == null || x == '';
+
+ const noMaintainerInformation = ref(false);
+ const noBotProtection = ref(false);
+
+ os.api('meta', { detail: true }).then(meta => {
+ // TODO: 設定が完了しても残ったままになるので、ストリーミングでmeta更新イベントを受け取ってよしなに更新する
+ noMaintainerInformation.value = isEmpty(meta.maintainerName) || isEmpty(meta.maintainerEmail);
+ noBotProtection.value = !meta.enableHcaptcha && !meta.enableRecaptcha;
+ });
+
+ const menuDef = computed(() => [{
+ title: i18n.locale.quickAction,
+ items: [{
+ type: 'button',
+ icon: 'fas fa-search',
+ text: i18n.locale.lookup,
+ action: lookup,
+ }, ...(instance.disableRegistration ? [{
+ type: 'button',
+ icon: 'fas fa-user',
+ text: i18n.locale.invite,
+ action: invite,
+ }] : [])],
+ }, {
+ title: i18n.locale.administration,
+ items: [{
+ icon: 'fas fa-tachometer-alt',
+ text: i18n.locale.dashboard,
+ to: '/admin/overview',
+ active: page.value === 'overview',
+ }, {
+ icon: 'fas fa-users',
+ text: i18n.locale.users,
+ to: '/admin/users',
+ active: page.value === 'users',
+ }, {
+ icon: 'fas fa-laugh',
+ text: i18n.locale.customEmojis,
+ to: '/admin/emojis',
+ active: page.value === 'emojis',
+ }, {
+ icon: 'fas fa-globe',
+ text: i18n.locale.federation,
+ to: '/admin/federation',
+ active: page.value === 'federation',
+ }, {
+ icon: 'fas fa-clipboard-list',
+ text: i18n.locale.jobQueue,
+ to: '/admin/queue',
+ active: page.value === 'queue',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.files,
+ to: '/admin/files',
+ active: page.value === 'files',
+ }, {
+ icon: 'fas fa-broadcast-tower',
+ text: i18n.locale.announcements,
+ to: '/admin/announcements',
+ active: page.value === 'announcements',
+ }, {
+ icon: 'fas fa-audio-description',
+ text: i18n.locale.ads,
+ to: '/admin/ads',
+ active: page.value === 'ads',
+ }, {
+ icon: 'fas fa-exclamation-circle',
+ text: i18n.locale.abuseReports,
+ to: '/admin/abuses',
+ active: page.value === 'abuses',
+ }],
+ }, {
+ title: i18n.locale.settings,
+ items: [{
+ icon: 'fas fa-cog',
+ text: i18n.locale.general,
+ to: '/admin/settings',
+ active: page.value === 'settings',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.files,
+ to: '/admin/files-settings',
+ active: page.value === 'files-settings',
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.emailServer,
+ to: '/admin/email-settings',
+ active: page.value === 'email-settings',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.objectStorage,
+ to: '/admin/object-storage',
+ active: page.value === 'object-storage',
+ }, {
+ icon: 'fas fa-lock',
+ text: i18n.locale.security,
+ to: '/admin/security',
+ active: page.value === 'security',
+ }, {
+ icon: 'fas fa-bolt',
+ text: 'ServiceWorker',
+ to: '/admin/service-worker',
+ active: page.value === 'service-worker',
+ }, {
+ icon: 'fas fa-globe',
+ text: i18n.locale.relays,
+ to: '/admin/relays',
+ active: page.value === 'relays',
+ }, {
+ icon: 'fas fa-share-alt',
+ text: i18n.locale.integration,
+ to: '/admin/integrations',
+ active: page.value === 'integrations',
+ }, {
+ icon: 'fas fa-ban',
+ text: i18n.locale.instanceBlocking,
+ to: '/admin/instance-block',
+ active: page.value === 'instance-block',
+ }, {
+ icon: 'fas fa-ghost',
+ text: i18n.locale.proxyAccount,
+ to: '/admin/proxy-account',
+ active: page.value === 'proxy-account',
+ }, {
+ icon: 'fas fa-cogs',
+ text: i18n.locale.other,
+ to: '/admin/other-settings',
+ active: page.value === 'other-settings',
+ }],
+ }, {
+ title: i18n.locale.info,
+ items: [{
+ icon: 'fas fa-database',
+ text: i18n.locale.database,
+ to: '/admin/database',
+ active: page.value === 'database',
+ }],
+ }]);
+ const component = computed(() => {
+ if (page.value == null) return null;
+ switch (page.value) {
+ case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
+ case 'users': return defineAsyncComponent(() => import('./users.vue'));
+ case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
+ case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
+ case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
+ case 'files': return defineAsyncComponent(() => import('./files.vue'));
+ case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
+ case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
+ case 'database': return defineAsyncComponent(() => import('./database.vue'));
+ case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
+ case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
+ case 'files-settings': return defineAsyncComponent(() => import('./files-settings.vue'));
+ case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue'));
+ case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
+ case 'security': return defineAsyncComponent(() => import('./security.vue'));
+ case 'bot-protection': return defineAsyncComponent(() => import('./bot-protection.vue'));
+ case 'service-worker': return defineAsyncComponent(() => import('./service-worker.vue'));
+ case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
+ case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
+ case 'integrations/twitter': return defineAsyncComponent(() => import('./integrations-twitter.vue'));
+ case 'integrations/github': return defineAsyncComponent(() => import('./integrations-github.vue'));
+ case 'integrations/discord': return defineAsyncComponent(() => import('./integrations-discord.vue'));
+ case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
+ case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
+ case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue'));
+ }
+ });
+
+ watch(component, () => {
+ pageProps.value = {};
+
+ nextTick(() => {
+ scroll(el.value, { top: 0 });
+ });
+ }, { immediate: true });
+
+ watch(() => props.initialPage, () => {
+ if (props.initialPage == null && !narrow.value) {
+ page.value = 'overview';
+ } else {
+ page.value = props.initialPage;
+ if (props.initialPage == null) {
+ INFO.value = indexInfo;
+ }
+ }
+ });
+
+ onMounted(() => {
+ narrow.value = el.value.offsetWidth < 800;
+ if (!narrow.value) {
+ page.value = 'overview';
+ }
+ });
+
+ const invite = () => {
+ os.api('admin/invite').then(x => {
+ os.dialog({
+ type: 'info',
+ text: x.code
+ });
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ };
+
+ const lookup = (ev) => {
+ os.popupMenu([{
+ text: i18n.locale.user,
+ icon: 'fas fa-user',
+ action: () => {
+ lookupUser();
+ }
+ }, {
+ text: i18n.locale.note,
+ icon: 'fas fa-pencil-alt',
+ action: () => {
+ alert('TODO');
+ }
+ }, {
+ text: i18n.locale.file,
+ icon: 'fas fa-cloud',
+ action: () => {
+ alert('TODO');
+ }
+ }, {
+ text: i18n.locale.instance,
+ icon: 'fas fa-globe',
+ action: () => {
+ alert('TODO');
+ }
+ }], ev.currentTarget || ev.target);
+ };
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ menuDef,
+ header: {
+ title: i18n.locale.controlPanel,
+ },
+ noMaintainerInformation,
+ noBotProtection,
+ page,
+ narrow,
+ view,
+ el,
+ onInfo,
+ childInfo,
+ pageProps,
+ component,
+ invite,
+ lookup,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.hiyeyicy {
+ &.wide {
+ display: flex;
+ margin: 0 auto;
+ height: 100%;
+
+ > .nav {
+ width: 32%;
+ max-width: 280px;
+ box-sizing: border-box;
+ border-right: solid 0.5px var(--divider);
+ overflow: auto;
+ height: 100%;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ }
+ }
+
+ > .nav {
+ .lxpfedzu {
+ > .info {
+ margin: 16px 0;
+ }
+
+ > .banner {
+ margin: 16px;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 42px;
+ border-radius: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue
new file mode 100644
index 0000000000..f5b249698d
--- /dev/null
+++ b/packages/client/src/pages/admin/instance-block.vue
@@ -0,0 +1,72 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormTextarea v-model="blockedHosts">
+ <span>{{ $ts.blockedInstances }}</span>
+ <template #desc>{{ $ts.blockedInstancesDescription }}</template>
+ </FormTextarea>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.instanceBlocking,
+ icon: 'fas fa-ban',
+ bg: 'var(--bg)',
+ },
+ blockedHosts: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.blockedHosts = meta.blockedHosts.join('\n');
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ blockedHosts: this.blockedHosts.split('\n') || [],
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/instance.vue b/packages/client/src/pages/admin/instance.vue
new file mode 100644
index 0000000000..614eaa3048
--- /dev/null
+++ b/packages/client/src/pages/admin/instance.vue
@@ -0,0 +1,321 @@
+<template>
+<XModalWindow ref="dialog"
+ :width="520"
+ :height="500"
+ @close="$refs.dialog.close()"
+ @closed="$emit('closed')"
+>
+ <template #header>{{ instance.host }}</template>
+ <div class="mk-instance-info">
+ <div class="_table section">
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.software }}</div>
+ <div class="_data">{{ instance.softwareName || '?' }}</div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.version }}</div>
+ <div class="_data">{{ instance.softwareVersion || '?' }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="_table data section">
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.registeredAt }}</div>
+ <div class="_data">{{ new Date(instance.caughtAt).toLocaleString() }} (<MkTime :time="instance.caughtAt"/>)</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.following }}</div>
+ <button class="_data _textButton" @click="showFollowing()">{{ number(instance.followingCount) }}</button>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.followers }}</div>
+ <button class="_data _textButton" @click="showFollowers()">{{ number(instance.followersCount) }}</button>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.users }}</div>
+ <button class="_data _textButton" @click="showUsers()">{{ number(instance.usersCount) }}</button>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.notes }}</div>
+ <div class="_data">{{ number(instance.notesCount) }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.files }}</div>
+ <div class="_data">{{ number(instance.driveFiles) }}</div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.storageUsage }}</div>
+ <div class="_data">{{ bytes(instance.driveUsage) }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestRequestSentAt }}</div>
+ <div class="_data"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestStatus }}</div>
+ <div class="_data">{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</div>
+ </div>
+ </div>
+ <div class="_row">
+ <div class="_cell">
+ <div class="_label">{{ $ts.latestRequestReceivedAt }}</div>
+ <div class="_data"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ </div>
+ <div class="chart">
+ <div class="header">
+ <span class="label">{{ $ts.charts }}</span>
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+ <option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+ <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+ <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+ <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+ <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+ <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+ <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+ <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+ <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+ <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ </div>
+ <div class="chart">
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
+ </div>
+ </div>
+ <div class="operations section">
+ <span class="label">{{ $ts.operations }}</span>
+ <MkSwitch v-model="isSuspended" class="switch">{{ $ts.stopActivityDelivery }}</MkSwitch>
+ <MkSwitch :model-value="isBlocked" class="switch" @update:modelValue="changeBlock">{{ $ts.blockThisInstance }}</MkSwitch>
+ <details>
+ <summary>{{ $ts.deleteAllFiles }}</summary>
+ <MkButton @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-trash-alt"></i> {{ $ts.deleteAllFiles }}</MkButton>
+ </details>
+ <details>
+ <summary>{{ $ts.removeAllFollowing }}</summary>
+ <MkButton @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><i class="fas fa-minus-circle"></i> {{ $ts.removeAllFollowing }}</MkButton>
+ <MkInfo warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</MkInfo>
+ </details>
+ </div>
+ <details class="metadata section">
+ <summary class="label">{{ $ts.metadata }}</summary>
+ <pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre>
+ </details>
+ </div>
+</XModalWindow>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import XModalWindow from '@/components/ui/modal-window.vue';
+import MkUsersDialog from '@/components/users-dialog.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkChart from '@/components/chart.vue';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XModalWindow,
+ MkSelect,
+ MkButton,
+ MkSwitch,
+ MkInfo,
+ MkChart,
+ },
+
+ props: {
+ instance: {
+ type: Object,
+ required: true
+ }
+ },
+
+ emits: ['closed'],
+
+ data() {
+ return {
+ isSuspended: this.instance.isSuspended,
+ chartSrc: 'requests',
+ chartSpan: 'hour',
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+
+ isBlocked() {
+ return this.meta && this.meta.blockedHosts && this.meta.blockedHosts.includes(this.instance.host);
+ }
+ },
+
+ watch: {
+ isSuspended() {
+ os.api('admin/federation/update-instance', {
+ host: this.instance.host,
+ isSuspended: this.isSuspended
+ });
+ },
+ },
+
+ methods: {
+ changeBlock(e) {
+ os.api('admin/update-meta', {
+ blockedHosts: this.isBlocked ? this.meta.blockedHosts.concat([this.instance.host]) : this.meta.blockedHosts.filter(x => x !== this.instance.host)
+ });
+ },
+
+ removeAllFollowing() {
+ os.apiWithDialog('admin/federation/remove-all-following', {
+ host: this.instance.host
+ });
+ },
+
+ deleteAllFiles() {
+ os.apiWithDialog('admin/federation/delete-all-files', {
+ host: this.instance.host
+ });
+ },
+
+ showFollowing() {
+ os.modal(MkUsersDialog, {
+ title: this.$ts.instanceFollowing,
+ pagination: {
+ endpoint: 'federation/following',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ },
+ extract: item => item.follower
+ });
+ },
+
+ showFollowers() {
+ os.modal(MkUsersDialog, {
+ title: this.$ts.instanceFollowers,
+ pagination: {
+ endpoint: 'federation/followers',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ },
+ extract: item => item.followee
+ });
+ },
+
+ showUsers() {
+ os.modal(MkUsersDialog, {
+ title: this.$ts.instanceUsers,
+ pagination: {
+ endpoint: 'federation/users',
+ limit: 10,
+ params: {
+ host: this.instance.host
+ }
+ }
+ });
+ },
+
+ bytes,
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-instance-info {
+ overflow: auto;
+
+ > .section {
+ padding: 16px 32px;
+
+ @media (max-width: 500px) {
+ padding: 8px 16px;
+ }
+
+ &:not(:first-child) {
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+
+ > .chart {
+ border-top: solid 0.5px var(--divider);
+ padding: 16px 0 12px 0;
+
+ > .header {
+ padding: 0 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px;
+ }
+
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .selects {
+ display: flex;
+ }
+ }
+
+ > .chart {
+ padding: 0 16px;
+
+ @media (max-width: 500px) {
+ padding: 0;
+ }
+ }
+ }
+
+ > .operations {
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > .switch {
+ margin: 16px 0;
+ }
+ }
+
+ > .metadata {
+ > .label {
+ font-size: 80%;
+ opacity: 0.7;
+ }
+
+ > pre > code {
+ display: block;
+ max-height: 200px;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/integrations-discord.vue b/packages/client/src/pages/admin/integrations-discord.vue
new file mode 100644
index 0000000000..81e47499c6
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-discord.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableDiscordIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableDiscordIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/dc/cb` }}</FormInfo>
+
+ <FormInput v-model="discordClientId">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client ID
+ </FormInput>
+
+ <FormInput v-model="discordClientSecret">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client Secret
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Discord',
+ icon: 'fab fa-discord'
+ },
+ enableDiscordIntegration: false,
+ discordClientId: null,
+ discordClientSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ this.discordClientId = meta.discordClientId;
+ this.discordClientSecret = meta.discordClientSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableDiscordIntegration: this.enableDiscordIntegration,
+ discordClientId: this.discordClientId,
+ discordClientSecret: this.discordClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations-github.vue b/packages/client/src/pages/admin/integrations-github.vue
new file mode 100644
index 0000000000..2bbc3ae9a1
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-github.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableGithubIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableGithubIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/gh/cb` }}</FormInfo>
+
+ <FormInput v-model="githubClientId">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client ID
+ </FormInput>
+
+ <FormInput v-model="githubClientSecret">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Client Secret
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'GitHub',
+ icon: 'fab fa-github'
+ },
+ enableGithubIntegration: false,
+ githubClientId: null,
+ githubClientSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ this.githubClientId = meta.githubClientId;
+ this.githubClientSecret = meta.githubClientSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableGithubIntegration: this.enableGithubIntegration,
+ githubClientId: this.githubClientId,
+ githubClientSecret: this.githubClientSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations-twitter.vue b/packages/client/src/pages/admin/integrations-twitter.vue
new file mode 100644
index 0000000000..19ed216ab9
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations-twitter.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableTwitterIntegration">
+ {{ $ts.enable }}
+ </FormSwitch>
+
+ <template v-if="enableTwitterIntegration">
+ <FormInfo>Callback URL: {{ `${url}/api/tw/cb` }}</FormInfo>
+
+ <FormInput v-model="twitterConsumerKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Consumer Key
+ </FormInput>
+
+ <FormInput v-model="twitterConsumerSecret">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Consumer Secret
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormInfo,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Twitter',
+ icon: 'fab fa-twitter'
+ },
+ enableTwitterIntegration: false,
+ twitterConsumerKey: null,
+ twitterConsumerSecret: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.twitterConsumerKey = meta.twitterConsumerKey;
+ this.twitterConsumerSecret = meta.twitterConsumerSecret;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableTwitterIntegration: this.enableTwitterIntegration,
+ twitterConsumerKey: this.twitterConsumerKey,
+ twitterConsumerSecret: this.twitterConsumerSecret,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue
new file mode 100644
index 0000000000..c21eebc1c6
--- /dev/null
+++ b/packages/client/src/pages/admin/integrations.vue
@@ -0,0 +1,74 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormLink to="/admin/integrations/twitter">
+ <i class="fab fa-twitter"></i> Twitter
+ <template #suffix>{{ enableTwitterIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ <FormLink to="/admin/integrations/github">
+ <i class="fab fa-github"></i> GitHub
+ <template #suffix>{{ enableGithubIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ <FormLink to="/admin/integrations/discord">
+ <i class="fab fa-discord"></i> Discord
+ <template #suffix>{{ enableDiscordIntegration ? $ts.enabled : $ts.disabled }}</template>
+ </FormLink>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormLink,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.integration,
+ icon: 'fas fa-share-alt',
+ bg: 'var(--bg)',
+ },
+ enableTwitterIntegration: false,
+ enableGithubIntegration: false,
+ enableDiscordIntegration: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableTwitterIntegration = meta.enableTwitterIntegration;
+ this.enableGithubIntegration = meta.enableGithubIntegration;
+ this.enableDiscordIntegration = meta.enableDiscordIntegration;
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/metrics.vue b/packages/client/src/pages/admin/metrics.vue
new file mode 100644
index 0000000000..05b64b235c
--- /dev/null
+++ b/packages/client/src/pages/admin/metrics.vue
@@ -0,0 +1,472 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-microchip"></i> {{ $ts.cpuAndMemory }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="cpumem"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">MEM total</div>{{ bytes(serverInfo.mem.total) }}</div>
+ <div class="_cell"><div class="_label">MEM used</div>{{ bytes(memUsage) }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ <div class="_cell"><div class="_label">MEM free</div>{{ bytes(serverInfo.mem.total - memUsage) }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-hdd"></i> {{ $ts.disk }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="disk"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Disk total</div>{{ bytes(serverInfo.fs.total) }}</div>
+ <div class="_cell"><div class="_label">Disk used</div>{{ bytes(serverInfo.fs.used) }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ <div class="_cell"><div class="_label">Disk free</div>{{ bytes(serverInfo.fs.total - serverInfo.fs.used) }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><i class="fas fa-exchange-alt"></i> {{ $ts.network }}</div>
+ <div class="_debobigegoPanel xhexznfu">
+ <div>
+ <canvas :ref="net"></canvas>
+ </div>
+ <div v-if="serverInfo">
+ <div class="_table">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Interface</div>{{ serverInfo.net.interface }}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import {
+ Chart,
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle
+} from 'chart.js';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkwFederation from '../../widgets/federation.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkInstanceInfo from './instance.vue';
+
+Chart.register(
+ ArcElement,
+ LineElement,
+ BarElement,
+ PointElement,
+ BarController,
+ LineController,
+ CategoryScale,
+ LinearScale,
+ Legend,
+ Title,
+ Tooltip,
+ SubTitle
+);
+
+const alpha = (hex, a) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
+ const r = parseInt(result[1], 16);
+ const g = parseInt(result[2], 16);
+ const b = parseInt(result[3], 16);
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+};
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSelect,
+ MkInput,
+ MkContainer,
+ MkFolder,
+ MkwFederation,
+ },
+
+ data() {
+ return {
+ version,
+ url,
+ stats: null,
+ serverInfo: null,
+ connection: null,
+ queueConnection: markRaw(os.stream.useChannel('queueStats')),
+ memUsage: 0,
+ chartCpuMem: null,
+ chartNet: null,
+ jobs: [],
+ logs: [],
+ logLevel: 'all',
+ logDomain: '',
+ modLogs: [],
+ dbInfo: null,
+ overviewHeight: '1fr',
+ queueHeight: '1fr',
+ paused: false,
+ }
+ },
+
+ computed: {
+ gridColor() {
+ // TODO: var(--panel)の色が暗いか明るいかで判定する
+ return this.$store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
+ },
+ },
+
+ mounted() {
+ this.fetchJobs();
+
+ Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
+
+ os.api('admin/server-info', {}).then(res => {
+ this.serverInfo = res;
+
+ this.connection = markRaw(os.stream.useChannel('serverStats'));
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 150
+ });
+
+ this.$nextTick(() => {
+ this.queueConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ });
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ }
+ this.queueConnection.dispose();
+ },
+
+ methods: {
+ cpumem(el) {
+ if (this.chartCpuMem != null) return;
+ this.chartCpuMem = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'CPU',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#86b300',
+ backgroundColor: alpha('#86b300', 0.1),
+ data: []
+ }, {
+ label: 'MEM (active)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ backgroundColor: alpha('#935dbf', 0.02),
+ data: []
+ }, {
+ label: 'MEM (used)',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#935dbf',
+ borderDash: [5, 5],
+ fill: false,
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ max: 100
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ net(el) {
+ if (this.chartNet != null) return;
+ this.chartNet = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'In',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Out',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ disk(el) {
+ if (this.chartDisk != null) return;
+ this.chartDisk = markRaw(new Chart(el, {
+ type: 'line',
+ data: {
+ labels: [],
+ datasets: [{
+ label: 'Read',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#94a029',
+ backgroundColor: alpha('#94a029', 0.1),
+ data: []
+ }, {
+ label: 'Write',
+ pointRadius: 0,
+ tension: 0,
+ borderWidth: 2,
+ borderColor: '#ff9156',
+ backgroundColor: alpha('#ff9156', 0.1),
+ data: []
+ }]
+ },
+ options: {
+ aspectRatio: 3,
+ layout: {
+ padding: {
+ left: 16,
+ right: 16,
+ top: 16,
+ bottom: 0
+ }
+ },
+ legend: {
+ position: 'bottom',
+ labels: {
+ boxWidth: 16,
+ }
+ },
+ scales: {
+ x: {
+ gridLines: {
+ display: false,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false
+ }
+ },
+ y: {
+ position: 'right',
+ gridLines: {
+ display: true,
+ color: this.gridColor,
+ zeroLineColor: this.gridColor,
+ },
+ ticks: {
+ display: false,
+ }
+ }
+ },
+ tooltips: {
+ intersect: false,
+ mode: 'index',
+ }
+ }
+ }));
+ },
+
+ fetchJobs() {
+ os.api('admin/queue/deliver-delayed', {}).then(jobs => {
+ this.jobs = jobs;
+ });
+ },
+
+ onStats(stats) {
+ if (this.paused) return;
+
+ const cpu = (stats.cpu * 100).toFixed(0);
+ const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
+ const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
+ this.memUsage = stats.mem.active;
+
+ this.chartCpuMem.data.labels.push('');
+ this.chartCpuMem.data.datasets[0].data.push(cpu);
+ this.chartCpuMem.data.datasets[1].data.push(memActive);
+ this.chartCpuMem.data.datasets[2].data.push(memUsed);
+ this.chartNet.data.labels.push('');
+ this.chartNet.data.datasets[0].data.push(stats.net.rx);
+ this.chartNet.data.datasets[1].data.push(stats.net.tx);
+ this.chartDisk.data.labels.push('');
+ this.chartDisk.data.datasets[0].data.push(stats.fs.r);
+ this.chartDisk.data.datasets[1].data.push(stats.fs.w);
+ if (this.chartCpuMem.data.datasets[0].data.length > 150) {
+ this.chartCpuMem.data.labels.shift();
+ this.chartCpuMem.data.datasets[0].data.shift();
+ this.chartCpuMem.data.datasets[1].data.shift();
+ this.chartCpuMem.data.datasets[2].data.shift();
+ this.chartNet.data.labels.shift();
+ this.chartNet.data.datasets[0].data.shift();
+ this.chartNet.data.datasets[1].data.shift();
+ this.chartDisk.data.labels.shift();
+ this.chartDisk.data.datasets[0].data.shift();
+ this.chartDisk.data.datasets[1].data.shift();
+ }
+ this.chartCpuMem.update();
+ this.chartNet.update();
+ this.chartDisk.update();
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ bytes,
+
+ number,
+
+ pause() {
+ this.paused = true;
+ },
+
+ resume() {
+ this.paused = false;
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xhexznfu {
+ > div:nth-child(2) {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue
new file mode 100644
index 0000000000..0f1431c258
--- /dev/null
+++ b/packages/client/src/pages/admin/object-storage.vue
@@ -0,0 +1,155 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="useObjectStorage">{{ $ts.useObjectStorage }}</FormSwitch>
+
+ <template v-if="useObjectStorage">
+ <FormInput v-model="objectStorageBaseUrl">
+ <span>{{ $ts.objectStorageBaseUrl }}</span>
+ <template #desc>{{ $ts.objectStorageBaseUrlDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageBucket">
+ <span>{{ $ts.objectStorageBucket }}</span>
+ <template #desc>{{ $ts.objectStorageBucketDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStoragePrefix">
+ <span>{{ $ts.objectStoragePrefix }}</span>
+ <template #desc>{{ $ts.objectStoragePrefixDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageEndpoint">
+ <span>{{ $ts.objectStorageEndpoint }}</span>
+ <template #desc>{{ $ts.objectStorageEndpointDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageRegion">
+ <span>{{ $ts.objectStorageRegion }}</span>
+ <template #desc>{{ $ts.objectStorageRegionDesc }}</template>
+ </FormInput>
+
+ <FormInput v-model="objectStorageAccessKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>Access key</span>
+ </FormInput>
+
+ <FormInput v-model="objectStorageSecretKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ <span>Secret key</span>
+ </FormInput>
+
+ <FormSwitch v-model="objectStorageUseSSL">
+ {{ $ts.objectStorageUseSSL }}
+ <template #desc>{{ $ts.objectStorageUseSSLDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageUseProxy">
+ {{ $ts.objectStorageUseProxy }}
+ <template #desc>{{ $ts.objectStorageUseProxyDesc }}</template>
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageSetPublicRead">
+ {{ $ts.objectStorageSetPublicRead }}
+ </FormSwitch>
+
+ <FormSwitch v-model="objectStorageS3ForcePathStyle">
+ s3ForcePathStyle
+ </FormSwitch>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.objectStorage,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ },
+ useObjectStorage: false,
+ objectStorageBaseUrl: null,
+ objectStorageBucket: null,
+ objectStoragePrefix: null,
+ objectStorageEndpoint: null,
+ objectStorageRegion: null,
+ objectStoragePort: null,
+ objectStorageAccessKey: null,
+ objectStorageSecretKey: null,
+ objectStorageUseSSL: false,
+ objectStorageUseProxy: false,
+ objectStorageSetPublicRead: false,
+ objectStorageS3ForcePathStyle: true,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.useObjectStorage = meta.useObjectStorage;
+ this.objectStorageBaseUrl = meta.objectStorageBaseUrl;
+ this.objectStorageBucket = meta.objectStorageBucket;
+ this.objectStoragePrefix = meta.objectStoragePrefix;
+ this.objectStorageEndpoint = meta.objectStorageEndpoint;
+ this.objectStorageRegion = meta.objectStorageRegion;
+ this.objectStoragePort = meta.objectStoragePort;
+ this.objectStorageAccessKey = meta.objectStorageAccessKey;
+ this.objectStorageSecretKey = meta.objectStorageSecretKey;
+ this.objectStorageUseSSL = meta.objectStorageUseSSL;
+ this.objectStorageUseProxy = meta.objectStorageUseProxy;
+ this.objectStorageSetPublicRead = meta.objectStorageSetPublicRead;
+ this.objectStorageS3ForcePathStyle = meta.objectStorageS3ForcePathStyle;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ useObjectStorage: this.useObjectStorage,
+ objectStorageBaseUrl: this.objectStorageBaseUrl ? this.objectStorageBaseUrl : null,
+ objectStorageBucket: this.objectStorageBucket ? this.objectStorageBucket : null,
+ objectStoragePrefix: this.objectStoragePrefix ? this.objectStoragePrefix : null,
+ objectStorageEndpoint: this.objectStorageEndpoint ? this.objectStorageEndpoint : null,
+ objectStorageRegion: this.objectStorageRegion ? this.objectStorageRegion : null,
+ objectStoragePort: this.objectStoragePort ? this.objectStoragePort : null,
+ objectStorageAccessKey: this.objectStorageAccessKey ? this.objectStorageAccessKey : null,
+ objectStorageSecretKey: this.objectStorageSecretKey ? this.objectStorageSecretKey : null,
+ objectStorageUseSSL: this.objectStorageUseSSL,
+ objectStorageUseProxy: this.objectStorageUseProxy,
+ objectStorageSetPublicRead: this.objectStorageSetPublicRead,
+ objectStorageS3ForcePathStyle: this.objectStorageS3ForcePathStyle,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue
new file mode 100644
index 0000000000..e8f872bf0a
--- /dev/null
+++ b/packages/client/src/pages/admin/other-settings.vue
@@ -0,0 +1,83 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormGroup>
+ <FormInput v-model="summalyProxy">
+ <template #prefix><i class="fas fa-link"></i></template>
+ Summaly Proxy URL
+ </FormInput>
+ </FormGroup>
+ <FormGroup>
+ <FormInput v-model="deeplAuthKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ DeepL Auth Key
+ </FormInput>
+ <FormSwitch v-model="deeplIsPro">
+ Pro account
+ </FormSwitch>
+ </FormGroup>
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.other,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ summalyProxy: '',
+ deeplAuthKey: '',
+ deeplIsPro: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.summalyProxy = meta.summalyProxy;
+ this.deeplAuthKey = meta.deeplAuthKey;
+ this.deeplIsPro = meta.deeplIsPro;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ summalyProxy: this.summalyProxy,
+ deeplAuthKey: this.deeplAuthKey,
+ deeplIsPro: this.deeplIsPro,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue
new file mode 100644
index 0000000000..e1352945a1
--- /dev/null
+++ b/packages/client/src/pages/admin/overview.vue
@@ -0,0 +1,236 @@
+<template>
+<div class="edbbcaef" v-size="{ max: [740] }">
+ <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)">
+ <div class="number _panel">
+ <div class="label">Users</div>
+ <div class="value _monospace">
+ {{ number(stats.originalUsersCount) }}
+ <MkNumberDiff v-if="usersComparedToThePrevDay != null" class="diff" :value="usersComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Notes</div>
+ <div class="value _monospace">
+ {{ number(stats.originalNotesCount) }}
+ <MkNumberDiff v-if="notesComparedToThePrevDay != null" class="diff" :value="notesComparedToThePrevDay" v-tooltip="$ts.dayOverDayChanges"><template #before>(</template><template #after>)</template></MkNumberDiff>
+ </div>
+ </div>
+ </div>
+
+ <MkContainer :foldable="true" class="charts">
+ <template #header><i class="fas fa-chart-bar"></i>{{ $ts.charts }}</template>
+ <div style="padding-top: 12px;">
+ <MkInstanceStats :chart-limit="500" :detailed="true"/>
+ </div>
+ </MkContainer>
+
+ <div class="queue">
+ <MkContainer :foldable="true" :thin="true" class="deliver">
+ <template #header>Queue: deliver</template>
+ <MkQueueChart :connection="queueStatsConnection" domain="deliver"/>
+ </MkContainer>
+ <MkContainer :foldable="true" :thin="true" class="inbox">
+ <template #header>Queue: inbox</template>
+ <MkQueueChart :connection="queueStatsConnection" domain="inbox"/>
+ </MkContainer>
+ </div>
+
+ <!--<XMetrics/>-->
+
+ <MkFolder style="margin: var(--margin)">
+ <template #header><i class="fas fa-info-circle"></i> {{ $ts.info }}</template>
+ <div class="cfcdecdf">
+ <div class="number _panel">
+ <div class="label">Misskey</div>
+ <div class="value _monospace">{{ version }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">Node.js</div>
+ <div class="value _monospace">{{ serverInfo.node }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">PostgreSQL</div>
+ <div class="value _monospace">{{ serverInfo.psql }}</div>
+ </div>
+ <div class="number _panel" v-if="serverInfo">
+ <div class="label">Redis</div>
+ <div class="value _monospace">{{ serverInfo.redis }}</div>
+ </div>
+ <div class="number _panel">
+ <div class="label">Vue</div>
+ <div class="value _monospace">{{ vueVersion }}</div>
+ </div>
+ </div>
+ </MkFolder>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw, version as vueVersion } from 'vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import MkInstanceStats from '@/components/instance-stats.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkNumberDiff from '@/components/number-diff.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkQueueChart from '@/components/queue-chart.vue';
+import { version, url } from '@/config';
+import bytes from '@/filters/bytes';
+import number from '@/filters/number';
+import MkInstanceInfo from './instance.vue';
+import XMetrics from './metrics.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkNumberDiff,
+ FormKeyValueView,
+ MkInstanceStats,
+ MkContainer,
+ MkFolder,
+ MkQueueChart,
+ XMetrics,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.dashboard,
+ icon: 'fas fa-tachometer-alt',
+ bg: 'var(--bg)',
+ },
+ version,
+ vueVersion,
+ url,
+ stats: null,
+ meta: null,
+ serverInfo: null,
+ usersComparedToThePrevDay: null,
+ notesComparedToThePrevDay: null,
+ fetchJobs: () => os.api('admin/queue/deliver-delayed', {}),
+ fetchModLogs: () => os.api('admin/show-moderation-logs', {}),
+ queueStatsConnection: markRaw(os.stream.useChannel('queueStats')),
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+
+ os.api('stats', {}).then(stats => {
+ this.stats = stats;
+
+ os.api('charts/users', { limit: 2, span: 'day' }).then(chart => {
+ this.usersComparedToThePrevDay = this.stats.originalUsersCount - chart.local.total[1];
+ });
+
+ os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => {
+ this.notesComparedToThePrevDay = this.stats.originalNotesCount - chart.local.total[1];
+ });
+ });
+
+ os.api('admin/server-info', {}).then(serverInfo => {
+ this.serverInfo = serverInfo;
+ });
+
+ this.$nextTick(() => {
+ this.queueStatsConnection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.queueStatsConnection.dispose();
+ },
+
+ methods: {
+ async showInstanceInfo(q) {
+ let instance = q;
+ if (typeof q === 'string') {
+ instance = await os.api('federation/show-instance', {
+ host: q
+ });
+ }
+ os.popup(MkInstanceInfo, {
+ instance: instance
+ }, {}, 'closed');
+ },
+
+ bytes,
+
+ number,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.edbbcaef {
+ .cfcdecdf {
+ display: grid;
+ grid-gap: 8px;
+ grid-template-columns: repeat(auto-fill,minmax(150px,1fr));
+
+ > .number {
+ padding: 12px 16px;
+
+ > .label {
+ opacity: 0.7;
+ font-size: 0.8em;
+ }
+
+ > .value {
+ font-weight: bold;
+ font-size: 1.2em;
+
+ > .diff {
+ font-size: 0.8em;
+ }
+ }
+ }
+ }
+
+ > .charts {
+ margin: var(--margin);
+ }
+
+ > .queue {
+ margin: var(--margin);
+ display: flex;
+
+ > .deliver,
+ > .inbox {
+ flex: 1;
+ width: 50%;
+
+ &:not(:first-child) {
+ margin-left: var(--margin);
+ }
+ }
+ }
+
+ &.max-width_740px {
+ > .queue {
+ display: block;
+
+ > .deliver,
+ > .inbox {
+ width: 100%;
+
+ &:not(:first-child) {
+ margin-top: var(--margin);
+ margin-left: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue
new file mode 100644
index 0000000000..5852c6a20d
--- /dev/null
+++ b/packages/client/src/pages/admin/proxy-account.vue
@@ -0,0 +1,87 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.proxyAccount }}</template>
+ <template #value>{{ proxyAccount ? `@${proxyAccount.username}` : $ts.none }}</template>
+ </FormKeyValueView>
+ <template #caption>{{ $ts.proxyAccountDescription }}</template>
+ </FormGroup>
+
+ <FormButton @click="chooseProxyAccount" primary>{{ $ts.selectAccount }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormKeyValueView,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.proxyAccount,
+ icon: 'fas fa-ghost',
+ bg: 'var(--bg)',
+ },
+ proxyAccount: null,
+ proxyAccountId: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.proxyAccountId = meta.proxyAccountId;
+ if (this.proxyAccountId) {
+ this.proxyAccount = await os.api('users/show', { userId: this.proxyAccountId });
+ }
+ },
+
+ chooseProxyAccount() {
+ os.selectUser().then(user => {
+ this.proxyAccount = user;
+ this.proxyAccountId = user.id;
+ this.save();
+ });
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ proxyAccountId: this.proxyAccountId,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue
new file mode 100644
index 0000000000..136fb63bb6
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.chart.vue
@@ -0,0 +1,102 @@
+<template>
+<div class="_debobigegoItem">
+ <div class="_debobigegoLabel"><slot name="title"></slot></div>
+ <div class="_debobigegoPanel pumxzjhg">
+ <div class="_table status">
+ <div class="_row">
+ <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div>
+ <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div>
+ <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div>
+ <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div>
+ </div>
+ </div>
+ <div class="">
+ <MkQueueChart :domain="domain" :connection="connection"/>
+ </div>
+ <div class="jobs">
+ <div v-if="jobs.length > 0">
+ <div v-for="job in jobs" :key="job[0]">
+ <span>{{ job[0] }}</span>
+ <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span>
+ </div>
+ </div>
+ <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw, onMounted, onUnmounted, ref } from 'vue';
+import number from '@/filters/number';
+import MkQueueChart from '@/components/queue-chart.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkQueueChart
+ },
+
+ props: {
+ domain: {
+ type: String,
+ required: true,
+ },
+ connection: {
+ required: true,
+ },
+ },
+
+ setup(props) {
+ const activeSincePrevTick = ref(0);
+ const active = ref(0);
+ const waiting = ref(0);
+ const delayed = ref(0);
+ const jobs = ref([]);
+
+ onMounted(() => {
+ os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => {
+ jobs.value = result;
+ });
+
+ const onStats = (stats) => {
+ activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
+ active.value = stats[props.domain].active;
+ waiting.value = stats[props.domain].waiting;
+ delayed.value = stats[props.domain].delayed;
+ };
+
+ props.connection.on('stats', onStats);
+
+ onUnmounted(() => {
+ props.connection.off('stats', onStats);
+ });
+ });
+
+ return {
+ jobs,
+ activeSincePrevTick,
+ active,
+ waiting,
+ delayed,
+ number,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.pumxzjhg {
+ > .status {
+ padding: 16px;
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .jobs {
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ max-height: 180px;
+ overflow: auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue
new file mode 100644
index 0000000000..896298840c
--- /dev/null
+++ b/packages/client/src/pages/admin/queue.vue
@@ -0,0 +1,73 @@
+<template>
+<FormBase>
+ <XQueue :connection="connection" domain="inbox">
+ <template #title>In</template>
+ </XQueue>
+ <XQueue :connection="connection" domain="deliver">
+ <template #title>Out</template>
+ </XQueue>
+ <FormButton @click="clear()" danger><i class="fas fa-trash-alt"></i> {{ $ts.clearQueue }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XQueue from './queue.chart.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ MkButton,
+ XQueue,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.jobQueue,
+ icon: 'fas fa-clipboard-list',
+ bg: 'var(--bg)',
+ },
+ connection: markRaw(os.stream.useChannel('queueStats')),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ this.$nextTick(() => {
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 200
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ clear() {
+ os.dialog({
+ type: 'warning',
+ title: this.$ts.clearQueueConfirmTitle,
+ text: this.$ts.clearQueueConfirmText,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.apiWithDialog('admin/queue/clear', {});
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue
new file mode 100644
index 0000000000..fd0ce97d57
--- /dev/null
+++ b/packages/client/src/pages/admin/relays.vue
@@ -0,0 +1,99 @@
+<template>
+<FormBase class="relaycxt">
+ <FormButton @click="addRelay" primary><i class="fas fa-plus"></i> {{ $ts.addRelay }}</FormButton>
+
+ <div class="_debobigegoItem" v-for="relay in relays" :key="relay.inbox">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <div>{{ relay.inbox }}</div>
+ <div>{{ $t(`_relayStatus.${relay.status}`) }}</div>
+ <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ $ts.remove }}</MkButton>
+ </div>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ MkButton,
+ MkInput,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.relays,
+ icon: 'fas fa-globe',
+ bg: 'var(--bg)',
+ },
+ relays: [],
+ inbox: '',
+ }
+ },
+
+ created() {
+ this.refresh();
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async addRelay() {
+ const { canceled, result: inbox } = await os.dialog({
+ title: this.$ts.addRelay,
+ input: {
+ placeholder: this.$ts.inboxUrl
+ }
+ });
+ if (canceled) return;
+ os.api('admin/relays/add', {
+ inbox
+ }).then((relay: any) => {
+ this.refresh();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message || e
+ });
+ });
+ },
+
+ remove(inbox: string) {
+ os.api('admin/relays/remove', {
+ inbox
+ }).then(() => {
+ this.refresh();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message || e
+ });
+ });
+ },
+
+ refresh() {
+ os.api('admin/relays/list').then((relays: any) => {
+ this.relays = relays;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue
new file mode 100644
index 0000000000..ad53ec4fcf
--- /dev/null
+++ b/packages/client/src/pages/admin/security.vue
@@ -0,0 +1,83 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormLink to="/admin/bot-protection">
+ <i class="fas fa-shield-alt"></i> {{ $ts.botProtection }}
+ <template #suffix v-if="enableHcaptcha">hCaptcha</template>
+ <template #suffix v-else-if="enableRecaptcha">reCAPTCHA</template>
+ <template #suffix v-else>{{ $ts.none }} ({{ $ts.notRecommended }})</template>
+ </FormLink>
+
+ <FormSwitch v-model="enableRegistration">{{ $ts.enableRegistration }}</FormSwitch>
+
+ <FormSwitch v-model="emailRequiredForSignup">{{ $ts.emailRequiredForSignup }}</FormSwitch>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormLink,
+ FormSwitch,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.security,
+ icon: 'fas fa-lock',
+ bg: 'var(--bg)',
+ },
+ enableHcaptcha: false,
+ enableRecaptcha: false,
+ enableRegistration: false,
+ emailRequiredForSignup: false,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableHcaptcha = meta.enableHcaptcha;
+ this.enableRecaptcha = meta.enableRecaptcha;
+ this.enableRegistration = !meta.disableRegistration;
+ this.emailRequiredForSignup = meta.emailRequiredForSignup;
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ disableRegistration: !this.enableRegistration,
+ emailRequiredForSignup: this.emailRequiredForSignup,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/service-worker.vue b/packages/client/src/pages/admin/service-worker.vue
new file mode 100644
index 0000000000..9e91d6d64f
--- /dev/null
+++ b/packages/client/src/pages/admin/service-worker.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormSwitch v-model="enableServiceWorker">
+ {{ $ts.enableServiceworker }}
+ <template #desc>{{ $ts.serviceworkerInfo }}</template>
+ </FormSwitch>
+
+ <template v-if="enableServiceWorker">
+ <FormInput v-model="swPublicKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Public key
+ </FormInput>
+
+ <FormInput v-model="swPrivateKey">
+ <template #prefix><i class="fas fa-key"></i></template>
+ Private key
+ </FormInput>
+ </template>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'ServiceWorker',
+ icon: 'fas fa-bolt',
+ bg: 'var(--bg)',
+ },
+ enableServiceWorker: false,
+ swPublicKey: null,
+ swPrivateKey: null,
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.enableServiceWorker = meta.enableServiceWorker;
+ this.swPublicKey = meta.swPublickey;
+ this.swPrivateKey = meta.swPrivateKey;
+ },
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ enableServiceWorker: this.enableServiceWorker,
+ swPublicKey: this.swPublicKey,
+ swPrivateKey: this.swPrivateKey,
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue
new file mode 100644
index 0000000000..66aa3e21db
--- /dev/null
+++ b/packages/client/src/pages/admin/settings.vue
@@ -0,0 +1,151 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormInput v-model="name">
+ <span>{{ $ts.instanceName }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description">
+ <span>{{ $ts.instanceDescription }}</span>
+ </FormTextarea>
+
+ <FormInput v-model="iconUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.iconUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="bannerUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.bannerUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="backgroundImageUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.backgroundImageUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="tosUrl">
+ <template #prefix><i class="fas fa-link"></i></template>
+ <span>{{ $ts.tosUrl }}</span>
+ </FormInput>
+
+ <FormInput v-model="maintainerName">
+ <span>{{ $ts.maintainerName }}</span>
+ </FormInput>
+
+ <FormInput v-model="maintainerEmail" type="email">
+ <template #prefix><i class="fas fa-envelope"></i></template>
+ <span>{{ $ts.maintainerEmail }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="pinnedUsers">
+ <span>{{ $ts.pinnedUsers }}</span>
+ <template #desc>{{ $ts.pinnedUsersDescription }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="maxNoteTextLength" type="number">
+ <template #prefix><i class="fas fa-pencil-alt"></i></template>
+ <span>{{ $ts.maxNoteTextLength }}</span>
+ </FormInput>
+
+ <FormSwitch v-model="enableLocalTimeline">{{ $ts.enableLocalTimeline }}</FormSwitch>
+ <FormSwitch v-model="enableGlobalTimeline">{{ $ts.enableGlobalTimeline }}</FormSwitch>
+ <FormInfo>{{ $ts.disablingTimelinesInfo }}</FormInfo>
+
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { fetchInstance } from '@/instance';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormInput,
+ FormBase,
+ FormGroup,
+ FormButton,
+ FormTextarea,
+ FormInfo,
+ FormSuspense,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.general,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ },
+ name: null,
+ description: null,
+ tosUrl: null as string | null,
+ maintainerName: null,
+ maintainerEmail: null,
+ iconUrl: null,
+ bannerUrl: null,
+ backgroundImageUrl: null,
+ maxNoteTextLength: 0,
+ enableLocalTimeline: false,
+ enableGlobalTimeline: false,
+ pinnedUsers: '',
+ }
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async init() {
+ const meta = await os.api('meta', { detail: true });
+ this.name = meta.name;
+ this.description = meta.description;
+ this.tosUrl = meta.tosUrl;
+ this.iconUrl = meta.iconUrl;
+ this.bannerUrl = meta.bannerUrl;
+ this.backgroundImageUrl = meta.backgroundImageUrl;
+ this.maintainerName = meta.maintainerName;
+ this.maintainerEmail = meta.maintainerEmail;
+ this.maxNoteTextLength = meta.maxNoteTextLength;
+ this.enableLocalTimeline = !meta.disableLocalTimeline;
+ this.enableGlobalTimeline = !meta.disableGlobalTimeline;
+ this.pinnedUsers = meta.pinnedUsers.join('\n');
+ },
+
+ save() {
+ os.apiWithDialog('admin/update-meta', {
+ name: this.name,
+ description: this.description,
+ tosUrl: this.tosUrl,
+ iconUrl: this.iconUrl,
+ bannerUrl: this.bannerUrl,
+ backgroundImageUrl: this.backgroundImageUrl,
+ maintainerName: this.maintainerName,
+ maintainerEmail: this.maintainerEmail,
+ maxNoteTextLength: this.maxNoteTextLength,
+ disableLocalTimeline: !this.enableLocalTimeline,
+ disableGlobalTimeline: !this.enableGlobalTimeline,
+ pinnedUsers: this.pinnedUsers.split('\n'),
+ }).then(() => {
+ fetchInstance();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue
new file mode 100644
index 0000000000..f4a2ffa6d2
--- /dev/null
+++ b/packages/client/src/pages/admin/users.vue
@@ -0,0 +1,254 @@
+<template>
+<div class="lknzcolw">
+ <div class="users">
+ <div class="inputs">
+ <MkSelect v-model="sort" style="flex: 1;">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
+ <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
+ </MkSelect>
+ <MkSelect v-model="state" style="flex: 1;">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="available">{{ $ts.normal }}</option>
+ <option value="admin">{{ $ts.administrator }}</option>
+ <option value="moderator">{{ $ts.moderator }}</option>
+ <option value="silenced">{{ $ts.silence }}</option>
+ <option value="suspended">{{ $ts.suspend }}</option>
+ </MkSelect>
+ <MkSelect v-model="origin" style="flex: 1;">
+ <template #label>{{ $ts.instance }}</template>
+ <option value="combined">{{ $ts.all }}</option>
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ </MkSelect>
+ </div>
+ <div class="inputs">
+ <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.username }}</template>
+ </MkInput>
+ <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
+ <template #prefix>@</template>
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ </div>
+
+ <MkPagination :pagination="pagination" #default="{items}" class="users" ref="users">
+ <button class="user _panel _button _gap" v-for="user in items" :key="user.id" @click="show(user)">
+ <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
+ <div class="body">
+ <header>
+ <MkUserName class="name" :user="user"/>
+ <span class="acct">@{{ acct(user) }}</span>
+ <span class="staff" v-if="user.isAdmin"><i class="fas fa-bookmark"></i></span>
+ <span class="staff" v-if="user.isModerator"><i class="far fa-bookmark"></i></span>
+ <span class="punished" v-if="user.isSilenced"><i class="fas fa-microphone-slash"></i></span>
+ <span class="punished" v-if="user.isSuspended"><i class="fas fa-snowflake"></i></span>
+ </header>
+ <div>
+ <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
+ </div>
+ <div>
+ <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
+ </div>
+ </div>
+ </button>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { lookupUser } from '@/scripts/lookup-user';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.users,
+ icon: 'fas fa-users',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-search',
+ text: this.$ts.search,
+ handler: this.searchUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-plus',
+ text: this.$ts.addUser,
+ handler: this.addUser
+ }, {
+ asFullButton: true,
+ icon: 'fas fa-search',
+ text: this.$ts.lookup,
+ handler: this.lookupUser
+ }],
+ },
+ sort: '+createdAt',
+ state: 'all',
+ origin: 'local',
+ searchUsername: '',
+ searchHost: '',
+ pagination: {
+ endpoint: 'admin/show-users',
+ limit: 10,
+ params: () => ({
+ sort: this.sort,
+ state: this.state,
+ origin: this.origin,
+ username: this.searchUsername,
+ hostname: this.searchHost,
+ }),
+ offsetMode: true
+ },
+ }
+ },
+
+ watch: {
+ sort() {
+ this.$refs.users.reload();
+ },
+ state() {
+ this.$refs.users.reload();
+ },
+ origin() {
+ this.$refs.users.reload();
+ },
+ },
+
+ async mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ lookupUser,
+
+ searchUser() {
+ os.selectUser().then(user => {
+ this.show(user);
+ });
+ },
+
+ async addUser() {
+ const { canceled: canceled1, result: username } = await os.dialog({
+ title: this.$ts.username,
+ input: true
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: password } = await os.dialog({
+ title: this.$ts.password,
+ input: { type: 'password' }
+ });
+ if (canceled2) return;
+
+ os.apiWithDialog('admin/accounts/create', {
+ username: username,
+ password: password,
+ }).then(res => {
+ this.$refs.users.reload();
+ });
+ },
+
+ show(user) {
+ os.pageWindow(`/user-info/${user.id}`);
+ },
+
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lknzcolw {
+ > .users {
+ margin: var(--margin);
+
+ > .inputs {
+ display: flex;
+ margin-bottom: 16px;
+
+ > * {
+ margin-right: 16px;
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+ }
+
+ > .users {
+ margin-top: var(--margin);
+
+ > .user {
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ text-align: left;
+ align-items: center;
+ padding: 16px;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .avatar {
+ width: 60px;
+ height: 60px;
+ }
+
+ > .body {
+ margin-left: 0.3em;
+ padding: 0 8px;
+ flex: 1;
+
+ @media (max-width: 500px) {
+ font-size: 14px;
+ }
+
+ > header {
+ > .name {
+ font-weight: bold;
+ }
+
+ > .acct {
+ margin-left: 8px;
+ opacity: 0.7;
+ }
+
+ > .staff {
+ margin-left: 0.5em;
+ color: var(--badge);
+ }
+
+ > .punished {
+ margin-left: 0.5em;
+ color: #4dabf7;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/advanced-theme-editor.vue b/packages/client/src/pages/advanced-theme-editor.vue
new file mode 100644
index 0000000000..eebfc21b3f
--- /dev/null
+++ b/packages/client/src/pages/advanced-theme-editor.vue
@@ -0,0 +1,352 @@
+<template>
+<div class="t9makv94">
+ <section class="_section">
+ <div class="_content">
+ <details>
+ <summary>{{ $ts.import }}</summary>
+ <MkTextarea v-model="themeToImport">
+ {{ $ts._theme.importInfo }}
+ </MkTextarea>
+ <MkButton :disabled="!themeToImport.trim()" @click="importTheme">{{ $ts.import }}</MkButton>
+ </details>
+ </div>
+ </section>
+ <section class="_section">
+ <div class="_content _card _gap">
+ <div class="_content">
+ <MkInput v-model="name" required><span>{{ $ts.name }}</span></MkInput>
+ <MkInput v-model="author" required><span>{{ $ts.author }}</span></MkInput>
+ <MkTextarea v-model="description"><span>{{ $ts.description }}</span></MkTextarea>
+ <div class="_inputs">
+ <div v-text="$ts._theme.base" />
+ <MkRadio v-model="baseTheme" value="light">{{ $ts.light }}</MkRadio>
+ <MkRadio v-model="baseTheme" value="dark">{{ $ts.dark }}</MkRadio>
+ </div>
+ </div>
+ </div>
+ <div class="_content _card _gap">
+ <div class="list-view _content">
+ <div class="item" v-for="([ k, v ], i) in theme" :key="k">
+ <div class="_inputs">
+ <div>
+ {{ k.startsWith('$') ? `${k} (${$ts._theme.constant})` : $t('_theme.keys.' + k) }}
+ <button v-if="k.startsWith('$')" class="_button _link" @click="del(i)" v-text="$ts.delete" />
+ </div>
+ <div>
+ <div class="type" @click="chooseType($event, i)">
+ {{ getTypeOf(v) }} <i class="fas fa-chevron-down"></i>
+ </div>
+ <!-- default -->
+ <div v-if="v === null" v-text="baseProps[k]" class="default-value" />
+ <!-- color -->
+ <div v-else-if="typeof v === 'string'" class="color">
+ <input type="color" :value="v" @input="colorChanged($event.target.value, i)"/>
+ <MkInput class="select" :value="v" @update:modelValue="colorChanged($event, i)"/>
+ </div>
+ <!-- ref const -->
+ <MkInput v-else-if="v.type === 'refConst'" v-model="v.key">
+ <template #prefix>$</template>
+ <span>{{ $ts.name }}</span>
+ </MkInput>
+ <!-- ref props -->
+ <MkSelect class="select" v-else-if="v.type === 'refProp'" v-model="v.key">
+ <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+ </MkSelect>
+ <!-- func -->
+ <template v-else-if="v.type === 'func'">
+ <MkSelect class="select" v-model="v.name">
+ <template #label>{{ $ts._theme.funcKind }}</template>
+ <option v-for="n in ['alpha', 'darken', 'lighten']" :value="n" :key="n">{{ $t('_theme.' + n) }}</option>
+ </MkSelect>
+ <MkInput type="number" v-model="v.arg"><span>{{ $ts._theme.argument }}</span></MkInput>
+ <MkSelect class="select" v-model="v.value">
+ <template #label>{{ $ts._theme.basedProp }}</template>
+ <option v-for="key in themeProps" :value="key" :key="key">{{ $t('_theme.keys.' + key) }}</option>
+ </MkSelect>
+ </template>
+ <!-- CSS -->
+ <MkInput v-else-if="v.type === 'css'" v-model="v.value">
+ <span>CSS</span>
+ </MkInput>
+ </div>
+ </div>
+ </div>
+ <MkButton primary @click="addConst">{{ $ts._theme.addConstant }}</MkButton>
+ </div>
+ </div>
+ </section>
+ <section class="_section">
+ <details class="_content">
+ <summary>{{ $ts.sample }}</summary>
+ <MkSample/>
+ </details>
+ </section>
+ <section class="_section">
+ <div class="_content">
+ <MkButton inline @click="preview">{{ $ts.preview }}</MkButton>
+ <MkButton inline primary :disabled="!name || !author" @click="save">{{ $ts.save }}</MkButton>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import { toUnicode } from 'punycode/';
+
+import MkRadio from '@/components/form/radio.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSample from '@/components/sample.vue';
+
+import { convertToMisskeyTheme, ThemeValue, convertToViewModel, ThemeViewModel } from '@/scripts/theme-editor';
+import { Theme, applyTheme, lightTheme, darkTheme, themeProps, validateTheme } from '@/scripts/theme';
+import { host } from '@/config';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { addTheme } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkRadio,
+ MkButton,
+ MkInput,
+ MkTextarea,
+ MkSelect,
+ MkSample,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.themeEditor,
+ icon: 'fas fa-palette',
+ },
+ theme: [] as ThemeViewModel,
+ name: '',
+ description: '',
+ baseTheme: 'light' as 'dark' | 'light',
+ author: `@${this.$i.username}@${toUnicode(host)}`,
+ themeToImport: '',
+ changed: false,
+ lightTheme, darkTheme, themeProps,
+ }
+ },
+
+ computed: {
+ baseProps() {
+ return this.baseTheme === 'light' ? this.lightTheme.props : this.darkTheme.props;
+ },
+ },
+
+ beforeUnmount() {
+ window.removeEventListener('beforeunload', this.beforeunload);
+ },
+
+ async beforeRouteLeave(to, from, next) {
+ if (this.changed && !(await this.confirm())) {
+ next(false);
+ } else {
+ next();
+ }
+ },
+
+ mounted() {
+ this.init();
+ window.addEventListener('beforeunload', this.beforeunload);
+ const changed = () => this.changed = true;
+ this.$watch('name', changed);
+ this.$watch('description', changed);
+ this.$watch('baseTheme', changed);
+ this.$watch('author', changed);
+ this.$watch('theme', changed);
+ },
+
+ methods: {
+ beforeunload(e: BeforeUnloadEvent) {
+ if (this.changed) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ },
+
+ async confirm(): Promise<boolean> {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.leaveConfirm,
+ showCancelButton: true
+ });
+ return !canceled;
+ },
+
+ init() {
+ const t: ThemeViewModel = [];
+ for (const key of themeProps) {
+ t.push([ key, null ]);
+ }
+ this.theme = t;
+ },
+
+ async del(i: number) {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$t('_theme.deleteConstantConfirm', { const: this.theme[i][0] }),
+ });
+ if (canceled) return;
+ Vue.delete(this.theme, i);
+ },
+
+ async addConst() {
+ const { canceled, result } = await os.dialog({
+ title: this.$ts._theme.inputConstantName,
+ input: true
+ });
+ if (canceled) return;
+ this.theme.push([ '$' + result, '#000000']);
+ },
+
+ save() {
+ const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+ addTheme(theme);
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: theme.name })
+ });
+ this.changed = false;
+ },
+
+ preview() {
+ const theme = convertToMisskeyTheme(this.theme, this.name, this.description, this.author, this.baseTheme);
+ try {
+ applyTheme(theme, false);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ }
+ },
+
+ async importTheme() {
+ if (this.changed && (!await this.confirm())) return;
+
+ try {
+ const theme = JSON5.parse(this.themeToImport) as Theme;
+ if (!validateTheme(theme)) throw new Error(this.$ts._theme.invalid);
+
+ this.name = theme.name;
+ this.description = theme.desc || '';
+ this.author = theme.author;
+ this.baseTheme = theme.base || 'light';
+ this.theme = convertToViewModel(theme);
+ this.themeToImport = '';
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ }
+ },
+
+ colorChanged(color: string, i: number) {
+ this.theme[i] = [this.theme[i][0], color];
+ },
+
+ getTypeOf(v: ThemeValue) {
+ return v === null
+ ? this.$ts._theme.defaultValue
+ : typeof v === 'string'
+ ? this.$ts._theme.color
+ : this.$t('_theme.' + v.type);
+ },
+
+ async chooseType(e: MouseEvent, i: number) {
+ const newValue = await this.showTypeMenu(e);
+ this.theme[i] = [ this.theme[i][0], newValue ];
+ },
+
+ showTypeMenu(e: MouseEvent) {
+ return new Promise<ThemeValue>((resolve) => {
+ os.popupMenu([{
+ text: this.$ts._theme.defaultValue,
+ action: () => resolve(null),
+ }, {
+ text: this.$ts._theme.color,
+ action: () => resolve('#000000'),
+ }, {
+ text: this.$ts._theme.func,
+ action: () => resolve({
+ type: 'func', name: 'alpha', arg: 1, value: 'accent'
+ }),
+ }, {
+ text: this.$ts._theme.refProp,
+ action: () => resolve({
+ type: 'refProp', key: 'accent',
+ }),
+ }, {
+ text: this.$ts._theme.refConst,
+ action: () => resolve({
+ type: 'refConst', key: '',
+ }),
+ }, {
+ text: 'CSS',
+ action: () => resolve({
+ type: 'css', value: '',
+ }),
+ }], e.currentTarget || e.target);
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.t9makv94 {
+ > ._section {
+ > ._content {
+ > .list-view {
+ > .item {
+ min-height: 48px;
+ word-break: break-all;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ .select {
+ margin: 24px 0;
+ }
+
+ .type {
+ cursor: pointer;
+ }
+
+ .default-value {
+ opacity: 0.6;
+ pointer-events: none;
+ user-select: none;
+ }
+
+ .color {
+ > input {
+ display: inline-block;
+ width: 1.5em;
+ height: 1.5em;
+ }
+
+ > div {
+ margin-left: 8px;
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue
new file mode 100644
index 0000000000..946b368733
--- /dev/null
+++ b/packages/client/src/pages/announcements.vue
@@ -0,0 +1,74 @@
+<template>
+<MkSpacer :content-max="800">
+ <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content">
+ <section class="_card announcement" v-for="(announcement, i) in items" :key="announcement.id">
+ <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
+ <div class="_content">
+ <Mfm :text="announcement.text"/>
+ <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+ </div>
+ <div class="_footer" v-if="$i && !announcement.isRead">
+ <MkButton @click="read(items, announcement, i)" primary><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+ </div>
+ </section>
+ </MkPagination>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.announcements,
+ icon: 'fas fa-broadcast-tower',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'announcements',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい
+ read(items, announcement, i) {
+ items[i] = {
+ ...announcement,
+ isRead: true,
+ };
+ os.api('i/read-announcement', { announcementId: announcement.id });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ruryvtyk {
+ > .announcement {
+ &:not(:last-child) {
+ margin-bottom: var(--margin);
+ }
+
+ > ._content {
+ > img {
+ display: block;
+ max-height: 300px;
+ max-width: 100%;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue
new file mode 100644
index 0000000000..f7f6990fa8
--- /dev/null
+++ b/packages/client/src/pages/antenna-timeline.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="tqmomfks" v-hotkey.global="keymap" v-size="{ min: [800] }">
+ <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline ref="tl" class="tl"
+ :key="antennaId"
+ src="antenna"
+ :antenna="antennaId"
+ :sound="true"
+ @before="before()"
+ @after="after()"
+ @queue="queueUpdated"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import Progress from '@/scripts/loading';
+import XTimeline from '@/components/timeline.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XTimeline,
+ },
+
+ props: {
+ antennaId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ antenna: null,
+ queue: 0,
+ [symbols.PAGE_INFO]: computed(() => this.antenna ? {
+ title: this.antenna.name,
+ icon: 'fas fa-satellite',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }, {
+ icon: 'fas fa-cog',
+ text: this.$ts.settings,
+ handler: this.settings
+ }],
+ } : null),
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ },
+ },
+
+ watch: {
+ antennaId: {
+ async handler() {
+ this.antenna = await os.api('antennas/show', {
+ antennaId: this.antennaId
+ });
+ },
+ immediate: true
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ queueUpdated(q) {
+ this.queue = q;
+ },
+
+ top() {
+ scroll(this.$el, { top: 0 });
+ },
+
+ async timetravel() {
+ const { canceled, result: date } = await os.dialog({
+ title: this.$ts.date,
+ input: {
+ type: 'date'
+ }
+ });
+ if (canceled) return;
+
+ this.$refs.tl.timetravel(new Date(date));
+ },
+
+ settings() {
+ this.$router.push(`/my/antennas/${this.antennaId}`);
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tqmomfks {
+ padding: var(--margin);
+
+ > .new {
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 16px);
+ z-index: 1000;
+ width: 100%;
+
+ > button {
+ display: block;
+ margin: var(--margin) auto 0 auto;
+ padding: 8px 16px;
+ border-radius: 32px;
+ }
+ }
+
+ > .tl {
+ background: var(--bg);
+ border-radius: var(--radius);
+ overflow: clip;
+ }
+
+ &.min-width_800px {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
new file mode 100644
index 0000000000..48495df3c2
--- /dev/null
+++ b/packages/client/src/pages/api-console.vue
@@ -0,0 +1,93 @@
+<template>
+<div class="_root">
+ <div class="_block" style="padding: 24px;">
+ <MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()" class="">
+ <template #label>Endpoint</template>
+ </MkInput>
+ <MkTextarea v-model="body" code>
+ <template #label>Params (JSON or JSON5)</template>
+ </MkTextarea>
+ <MkSwitch v-model="withCredential">
+ With credential
+ </MkSwitch>
+ <MkButton primary full @click="send" :disabled="sending">
+ <template v-if="sending"><MkEllipsis/></template>
+ <template v-else><i class="fas fa-paper-plane"></i> Send</template>
+ </MkButton>
+ </div>
+ <div v-if="res" class="_block" style="padding: 24px;">
+ <MkTextarea v-model="res" code readonly tall>
+ <template #label>Response</template>
+ </MkTextarea>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton, MkInput, MkTextarea, MkSwitch,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'API console',
+ icon: 'fas fa-terminal'
+ },
+
+ endpoint: '',
+ body: '{}',
+ res: null,
+ sending: false,
+ endpoints: [],
+ withCredential: true,
+
+ };
+ },
+
+ created() {
+ os.api('endpoints').then(endpoints => {
+ this.endpoints = endpoints;
+ });
+ },
+
+ methods: {
+ send() {
+ this.sending = true;
+ os.api(this.endpoint, JSON5.parse(this.body)).then(res => {
+ this.sending = false;
+ this.res = JSON5.stringify(res, null, 2);
+ }, err => {
+ this.sending = false;
+ this.res = JSON5.stringify(err, null, 2);
+ });
+ },
+
+ onEndpointChange() {
+ os.api('endpoint', { endpoint: this.endpoint }, this.withCredential ? undefined : null).then(endpoint => {
+ const body = {};
+ for (const p of endpoint.params) {
+ body[p.name] =
+ p.type === 'String' ? '' :
+ p.type === 'Number' ? 0 :
+ p.type === 'Boolean' ? false :
+ p.type === 'Array' ? [] :
+ p.type === 'Object' ? {} :
+ null;
+ }
+ this.body = JSON5.stringify(body, null, 2);
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/auth.form.vue b/packages/client/src/pages/auth.form.vue
new file mode 100644
index 0000000000..8b2adc3e07
--- /dev/null
+++ b/packages/client/src/pages/auth.form.vue
@@ -0,0 +1,60 @@
+<template>
+<section class="_section">
+ <div class="_title">{{ $t('_auth.shareAccess', { name: app.name }) }}</div>
+ <div class="_content">
+ <h2>{{ app.name }}</h2>
+ <p class="id">{{ app.id }}</p>
+ <p class="description">{{ app.description }}</p>
+ </div>
+ <div class="_content">
+ <h2>{{ $ts._auth.permissionAsk }}</h2>
+ <ul>
+ <li v-for="p in app.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </ul>
+ </div>
+ <div class="_footer">
+ <MkButton @click="cancel" inline>{{ $ts.cancel }}</MkButton>
+ <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ props: ['session'],
+ computed: {
+ name(): string {
+ const el = document.createElement('div');
+ el.textContent = this.app.name
+ return el.innerHTML;
+ },
+ app(): any {
+ return this.session.app;
+ }
+ },
+ methods: {
+ cancel() {
+ os.api('auth/deny', {
+ token: this.session.token
+ }).then(() => {
+ this.$emit('denied');
+ });
+ },
+
+ accept() {
+ os.api('auth/accept', {
+ token: this.session.token
+ }).then(() => {
+ this.$emit('accepted');
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue
new file mode 100644
index 0000000000..522bd4cdf8
--- /dev/null
+++ b/packages/client/src/pages/auth.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="" v-if="$i && fetching">
+ <MkLoading/>
+</div>
+<div v-else-if="$i">
+ <XForm
+ class="form"
+ ref="form"
+ v-if="state == 'waiting'"
+ :session="session"
+ @denied="state = 'denied'"
+ @accepted="accepted"
+ />
+ <div class="denied" v-if="state == 'denied'">
+ <h1>{{ $ts._auth.denied }}</h1>
+ </div>
+ <div class="accepted" v-if="state == 'accepted'">
+ <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1>
+ <p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p>
+ <p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p>
+ </div>
+ <div class="error" v-if="state == 'fetch-session-error'">
+ <p>{{ $ts.somethingHappened }}</p>
+ </div>
+</div>
+<div class="signin" v-else>
+ <MkSignin @login="onLogin"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XForm from './auth.form.vue';
+import MkSignin from '@/components/signin.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ XForm,
+ MkSignin,
+ },
+ data() {
+ return {
+ state: null,
+ session: null,
+ fetching: true
+ };
+ },
+ computed: {
+ token(): string {
+ return this.$route.params.token;
+ }
+ },
+ mounted() {
+ if (!this.$i) return;
+
+ // Fetch session
+ os.api('auth/session/show', {
+ token: this.token
+ }).then(session => {
+ this.session = session;
+ this.fetching = false;
+
+ // 既に連携していた場合
+ if (this.session.app.isAuthorized) {
+ os.api('auth/accept', {
+ token: this.session.token
+ }).then(() => {
+ this.accepted();
+ });
+ } else {
+ this.state = 'waiting';
+ }
+ }).catch(error => {
+ this.state = 'fetch-session-error';
+ this.fetching = false;
+ });
+ },
+ methods: {
+ accepted() {
+ this.state = 'accepted';
+ if (this.session.app.callbackUrl) {
+ location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
+ }
+ }, onLogin(res) {
+ login(res.i);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue
new file mode 100644
index 0000000000..e2cf8b9f00
--- /dev/null
+++ b/packages/client/src/pages/channel-editor.vue
@@ -0,0 +1,129 @@
+<template>
+<div>
+ <div class="_section">
+ <div class="_content">
+ <MkInput v-model="name">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+
+ <MkTextarea v-model="description">
+ <template #label>{{ $ts.description }}</template>
+ </MkTextarea>
+
+ <div class="banner">
+ <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton>
+ <div v-else-if="bannerUrl">
+ <img :src="bannerUrl" style="width: 100%;"/>
+ <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton>
+ </div>
+ </div>
+ </div>
+ <div class="_footer">
+ <MkButton @click="save()" primary><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTextarea, MkButton, MkInput,
+ },
+
+ props: {
+ channelId: {
+ type: String,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.channelId ? {
+ title: this.$ts._channel.edit,
+ icon: 'fas fa-satellite-dish',
+ } : {
+ title: this.$ts._channel.create,
+ icon: 'fas fa-satellite-dish',
+ }),
+ channel: null,
+ name: null,
+ description: null,
+ bannerUrl: null,
+ bannerId: null,
+ };
+ },
+
+ watch: {
+ async bannerId() {
+ if (this.bannerId == null) {
+ this.bannerUrl = null;
+ } else {
+ this.bannerUrl = (await os.api('drive/files/show', {
+ fileId: this.bannerId,
+ })).url;
+ }
+ },
+ },
+
+ async created() {
+ if (this.channelId) {
+ this.channel = await os.api('channels/show', {
+ channelId: this.channelId,
+ });
+
+ this.name = this.channel.name;
+ this.description = this.channel.description;
+ this.bannerId = this.channel.bannerId;
+ this.bannerUrl = this.channel.bannerUrl;
+ }
+ },
+
+ methods: {
+ save() {
+ const params = {
+ name: this.name,
+ description: this.description,
+ bannerId: this.bannerId,
+ };
+
+ if (this.channelId) {
+ params.channelId = this.channelId;
+ os.api('channels/update', params)
+ .then(channel => {
+ os.success();
+ });
+ } else {
+ os.api('channels/create', params)
+ .then(channel => {
+ os.success();
+ this.$router.push(`/channels/${channel.id}`);
+ });
+ }
+ },
+
+ setBannerImage(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ this.bannerId = file.id;
+ });
+ },
+
+ removeBannerImage() {
+ this.bannerId = null;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue
new file mode 100644
index 0000000000..f9a9ca29e9
--- /dev/null
+++ b/packages/client/src/pages/channel.vue
@@ -0,0 +1,186 @@
+<template>
+<div v-if="channel" class="_section">
+ <div class="wpgynlbz _content _panel _gap" :class="{ hide: !showBanner }">
+ <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/>
+ <button class="_button toggle" @click="() => showBanner = !showBanner">
+ <template v-if="showBanner"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ <div class="hideOverlay" v-if="!showBanner">
+ </div>
+ <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
+ <div class="status">
+ <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
+ <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
+ </div>
+ <div class="fade"></div>
+ </div>
+ <div class="description" v-if="channel.description">
+ <Mfm :text="channel.description" :is-note="false" :i="$i"/>
+ </div>
+ </div>
+
+ <XPostForm :channel="channel" class="post-form _content _panel _gap" fixed v-if="$i"/>
+
+ <XTimeline class="_content _gap" src="channel" :key="channelId" :channel="channelId" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import XPostForm from '@/components/post-form.vue';
+import XTimeline from '@/components/timeline.vue';
+import XChannelFollowButton from '@/components/channel-follow-button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ XPostForm,
+ XTimeline,
+ XChannelFollowButton
+ },
+
+ props: {
+ channelId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.channel ? {
+ title: this.channel.name,
+ icon: 'fas fa-satellite-dish',
+ } : null),
+ channel: null,
+ showBanner: true,
+ pagination: {
+ endpoint: 'channels/timeline',
+ limit: 10,
+ params: () => ({
+ channelId: this.channelId,
+ })
+ },
+ };
+ },
+
+ watch: {
+ channelId: {
+ async handler() {
+ this.channel = await os.api('channels/show', {
+ channelId: this.channelId,
+ });
+ },
+ immediate: true
+ }
+ },
+
+ created() {
+
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.wpgynlbz {
+ position: relative;
+
+ > .subscribe {
+ position: absolute;
+ z-index: 1;
+ top: 16px;
+ left: 16px;
+ }
+
+ > .toggle {
+ position: absolute;
+ z-index: 2;
+ top: 8px;
+ right: 8px;
+ font-size: 1.2em;
+ width: 48px;
+ height: 48px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 100%;
+
+ > i {
+ vertical-align: middle;
+ }
+ }
+
+ > .banner {
+ position: relative;
+ height: 200px;
+ background-position: center;
+ background-size: cover;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+
+ > .status {
+ position: absolute;
+ z-index: 1;
+ bottom: 16px;
+ right: 16px;
+ padding: 8px 12px;
+ font-size: 80%;
+ background: rgba(0, 0, 0, 0.7);
+ border-radius: 6px;
+ color: #fff;
+ }
+ }
+
+ > .description {
+ padding: 16px;
+ }
+
+ > .hideOverlay {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ -webkit-backdrop-filter: var(--blur, blur(16px));
+ backdrop-filter: var(--blur, blur(16px));
+ background: rgba(0, 0, 0, 0.3);
+ }
+
+ &.hide {
+ > .subscribe {
+ display: none;
+ }
+
+ > .toggle {
+ top: 0;
+ right: 0;
+ height: 100%;
+ background: transparent;
+ }
+
+ > .banner {
+ height: 42px;
+ filter: blur(8px);
+
+ > * {
+ display: none;
+ }
+ }
+
+ > .description {
+ display: none;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue
new file mode 100644
index 0000000000..09e136ac00
--- /dev/null
+++ b/packages/client/src/pages/channels.vue
@@ -0,0 +1,77 @@
+<template>
+<div>
+ <div class="_section" style="padding: 0;" v-if="$i">
+ <MkTab class="_content" v-model="tab">
+ <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._channel.featured }}</option>
+ <option value="following"><i class="fas fa-heart"></i> {{ $ts._channel.following }}</option>
+ <option value="owned"><i class="fas fa-edit"></i> {{ $ts._channel.owned }}</option>
+ </MkTab>
+ </div>
+
+ <div class="_section">
+ <div class="_content grwlizim featured" v-if="tab === 'featured'">
+ <MkPagination :pagination="featuredPagination" #default="{items}">
+ <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="_content grwlizim following" v-if="tab === 'following'">
+ <MkPagination :pagination="followingPagination" #default="{items}">
+ <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="_content grwlizim owned" v-if="tab === 'owned'">
+ <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+ <MkPagination :pagination="ownedPagination" #default="{items}">
+ <MkChannelPreview v-for="channel in items" class="_gap" :channel="channel" :key="channel.id"/>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkChannelPreview from '@/components/channel-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTab from '@/components/tab.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkChannelPreview, MkPagination, MkButton, MkTab
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.channel,
+ icon: 'fas fa-satellite-dish',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ }
+ },
+ tab: 'featured',
+ featuredPagination: {
+ endpoint: 'channels/featured',
+ noPaging: true,
+ },
+ followingPagination: {
+ endpoint: 'channels/followed',
+ limit: 5,
+ },
+ ownedPagination: {
+ endpoint: 'channels/owned',
+ limit: 5,
+ },
+ };
+ },
+ methods: {
+ create() {
+ this.$router.push(`/channels/new`);
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue
new file mode 100644
index 0000000000..510a73ce68
--- /dev/null
+++ b/packages/client/src/pages/clip.vue
@@ -0,0 +1,154 @@
+<template>
+<div v-if="clip" class="_section">
+ <div class="okzinsic _content _panel _gap">
+ <div class="description" v-if="clip.description">
+ <Mfm :text="clip.description" :is-note="false" :i="$i"/>
+ </div>
+ <div class="user">
+ <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/>
+ </div>
+ </div>
+
+ <XNotes class="_content _gap" :pagination="pagination" :detail="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import XPostForm from '@/components/post-form.vue';
+import XNotes from '@/components/notes.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ XPostForm,
+ XNotes,
+ },
+
+ props: {
+ clipId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.clip ? {
+ title: this.clip.name,
+ icon: 'fas fa-paperclip',
+ action: {
+ icon: 'fas fa-ellipsis-h',
+ handler: this.menu
+ }
+ } : null),
+ clip: null,
+ pagination: {
+ endpoint: 'clips/notes',
+ limit: 10,
+ params: () => ({
+ clipId: this.clipId,
+ })
+ },
+ };
+ },
+
+ computed: {
+ isOwned(): boolean {
+ return this.$i && this.clip && (this.$i.id === this.clip.userId);
+ }
+ },
+
+ watch: {
+ clipId: {
+ async handler() {
+ this.clip = await os.api('clips/show', {
+ clipId: this.clipId,
+ });
+ },
+ immediate: true
+ }
+ },
+
+ created() {
+
+ },
+
+ methods: {
+ menu(ev) {
+ os.popupMenu([this.isOwned ? {
+ icon: 'fas fa-pencil-alt',
+ text: this.$ts.edit,
+ action: async () => {
+ const { canceled, result } = await os.form(this.clip.name, {
+ name: {
+ type: 'string',
+ label: this.$ts.name,
+ default: this.clip.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description,
+ default: this.clip.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: this.clip.isPublic
+ }
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('clips/update', {
+ clipId: this.clip.id,
+ ...result
+ });
+ }
+ } : undefined, this.isOwned ? {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: async () => {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('deleteAreYouSure', { x: this.clip.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('clips/delete', {
+ clipId: this.clip.id,
+ });
+ }
+ } : undefined], ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.okzinsic {
+ position: relative;
+
+ > .description {
+ padding: 16px;
+ }
+
+ > .user {
+ $height: 32px;
+ padding: 16px;
+ border-top: solid 0.5px var(--divider);
+ line-height: $height;
+
+ > .avatar {
+ width: $height;
+ height: $height;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue
new file mode 100644
index 0000000000..5d7d3b2f5a
--- /dev/null
+++ b/packages/client/src/pages/drive.vue
@@ -0,0 +1,28 @@
+<template>
+<div>
+ <XDrive ref="drive" @cd="x => folder = x"/>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XDrive from '@/components/drive.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XDrive
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: computed(() => this.folder ? this.folder.name : this.$ts.drive),
+ icon: 'fas fa-cloud',
+ },
+ folder: null,
+ };
+ },
+});
+</script>
diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/emojis.category.vue
new file mode 100644
index 0000000000..327cbce7e8
--- /dev/null
+++ b/packages/client/src/pages/emojis.category.vue
@@ -0,0 +1,135 @@
+<template>
+<div class="driuhtrh">
+ <div class="query">
+ <MkInput v-model="q" class="" :placeholder="$ts.search">
+ <template #prefix><i class="fas fa-search"></i></template>
+ </MkInput>
+
+ <!-- たくさんあると邪魔
+ <div class="tags">
+ <span class="tag _button" v-for="tag in tags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
+ </div>
+ -->
+ </div>
+
+ <MkFolder class="emojis" v-if="searchEmojis">
+ <template #header>{{ $ts.searchResult }}</template>
+ <div class="zuvgdzyt">
+ <XEmoji v-for="emoji in searchEmojis" :key="emoji.name" class="emoji" :emoji="emoji"/>
+ </div>
+ </MkFolder>
+
+ <MkFolder class="emojis" v-for="category in customEmojiCategories" :key="category">
+ <template #header>{{ category || $ts.other }}</template>
+ <div class="zuvgdzyt">
+ <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" class="emoji" :emoji="emoji"/>
+ </div>
+ </MkFolder>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, computed } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { emojiCategories, emojiTags } from '@/instance';
+import XEmoji from './emojis.emoji.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkFolder,
+ MkTab,
+ XEmoji,
+ },
+
+ data() {
+ return {
+ q: '',
+ customEmojiCategories: emojiCategories,
+ customEmojis: this.$instance.emojis,
+ tags: emojiTags,
+ selectedTags: new Set(),
+ searchEmojis: null,
+ }
+ },
+
+ watch: {
+ q() { this.search(); },
+ selectedTags: {
+ handler() {
+ this.search();
+ },
+ deep: true
+ },
+ },
+
+ methods: {
+ search() {
+ if ((this.q === '' || this.q == null) && this.selectedTags.size === 0) {
+ this.searchEmojis = null;
+ return;
+ }
+
+ if (this.selectedTags.size === 0) {
+ this.searchEmojis = this.customEmojis.filter(e => e.name.includes(this.q) || e.aliases.includes(this.q));
+ } else {
+ this.searchEmojis = this.customEmojis.filter(e => (e.name.includes(this.q) || e.aliases.includes(this.q)) && [...this.selectedTags].every(t => e.aliases.includes(t)));
+ }
+ },
+
+ toggleTag(tag) {
+ if (this.selectedTags.has(tag)) {
+ this.selectedTags.delete(tag);
+ } else {
+ this.selectedTags.add(tag);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.driuhtrh {
+ background: var(--bg);
+
+ > .query {
+ background: var(--bg);
+ padding: 16px;
+
+ > .tags {
+ > .tag {
+ display: inline-block;
+ margin: 8px 8px 0 0;
+ padding: 4px 8px;
+ font-size: 0.9em;
+ background: var(--accentedBg);
+ border-radius: 5px;
+
+ &.active {
+ background: var(--accent);
+ color: var(--fgOnAccent);
+ }
+ }
+ }
+ }
+
+ > .emojis {
+ --x-padding: 0 16px;
+
+ .zuvgdzyt {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ grid-gap: 12px;
+ margin: 0 var(--margin) var(--margin) var(--margin);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/emojis.emoji.vue b/packages/client/src/pages/emojis.emoji.vue
new file mode 100644
index 0000000000..4ca7c15742
--- /dev/null
+++ b/packages/client/src/pages/emojis.emoji.vue
@@ -0,0 +1,94 @@
+<template>
+<button class="zuvgdzyu _button" @click="menu">
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
+ <div class="body">
+ <div class="name _monospace">{{ emoji.name }}</div>
+ <div class="info">{{ emoji.aliases.join(' ') }}</div>
+ </div>
+</button>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import VanillaTilt from 'vanilla-tilt';
+
+export default defineComponent({
+ props: {
+ emoji: {
+ type: Object,
+ required: true,
+ }
+ },
+
+ mounted() {
+ if (this.$store.animation) {
+ VanillaTilt.init(this.$el, {
+ reverse: true,
+ gyroscope: false,
+ scale: 1.1,
+ speed: 500,
+ });
+ }
+ },
+
+ methods: {
+ menu(ev) {
+ os.popupMenu([{
+ type: 'label',
+ text: ':' + this.emoji.name + ':',
+ }, {
+ text: this.$ts.copy,
+ icon: 'fas fa-copy',
+ action: () => {
+ copyToClipboard(`:${this.emoji.name}:`);
+ os.success();
+ }
+ }], ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zuvgdzyu {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ text-align: left;
+ background: var(--panel);
+ border-radius: 8px;
+ transform-style: preserve-3d;
+ transform: perspective(1000px);
+
+ &:hover {
+ border-color: var(--accent);
+ }
+
+ > .img {
+ width: 42px;
+ height: 42px;
+ transform: translateZ(20px);
+ }
+
+ > .body {
+ padding: 0 0 0 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ transform: translateZ(10px);
+
+ > .name {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .info {
+ opacity: 0.5;
+ font-size: 0.9em;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue
new file mode 100644
index 0000000000..ae06fa7938
--- /dev/null
+++ b/packages/client/src/pages/emojis.vue
@@ -0,0 +1,36 @@
+<template>
+<div :class="$style.root">
+ <XCategory v-if="tab === 'category'"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, computed } from 'vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import XCategory from './emojis.category.vue';
+
+export default defineComponent({
+ components: {
+ XCategory,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.customEmojis,
+ icon: 'fas fa-laugh',
+ bg: 'var(--bg)',
+ })),
+ tab: 'category',
+ }
+ },
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+</style>
diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue
new file mode 100644
index 0000000000..7b1fcd0910
--- /dev/null
+++ b/packages/client/src/pages/explore.vue
@@ -0,0 +1,261 @@
+<template>
+<div>
+ <MkSpacer :content-max="1200">
+ <div class="lznhrdub">
+ <div v-if="tab === 'local'">
+ <div class="localfedi7 _block _isolated" v-if="meta && stats && tag == null" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }">
+ <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header>
+ <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div>
+ </div>
+
+ <template v-if="tag == null">
+ <MkFolder class="_gap" persist-key="explore-pinned-users">
+ <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template>
+ <XUserList :pagination="pinnedUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-popular-users">
+ <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
+ <XUserList :pagination="popularUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-recently-updated-users">
+ <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
+ <XUserList :pagination="recentlyUpdatedUsers"/>
+ </MkFolder>
+ <MkFolder class="_gap" persist-key="explore-recently-registered-users">
+ <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template>
+ <XUserList :pagination="recentlyRegisteredUsers"/>
+ </MkFolder>
+ </template>
+ </div>
+ <div v-else-if="tab === 'remote'">
+ <div class="localfedi7 _block _isolated" v-if="tag == null" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }">
+ <header><span>{{ $ts.exploreFediverse }}</span></header>
+ </div>
+
+ <MkFolder :foldable="true" :expanded="false" ref="tags" class="_gap">
+ <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template>
+
+ <div class="vxjfqztj">
+ <MkA v-for="tag in tagsLocal" :to="`/explore/tags/${tag.tag}`" :key="'local:' + tag.tag" class="local">{{ tag.tag }}</MkA>
+ <MkA v-for="tag in tagsRemote" :to="`/explore/tags/${tag.tag}`" :key="'remote:' + tag.tag">{{ tag.tag }}</MkA>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
+ <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
+ <XUserList :pagination="tagUsers"/>
+ </MkFolder>
+
+ <template v-if="tag == null">
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template>
+ <XUserList :pagination="popularUsersF"/>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template>
+ <XUserList :pagination="recentlyUpdatedUsersF"/>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template>
+ <XUserList :pagination="recentlyRegisteredUsersF"/>
+ </MkFolder>
+ </template>
+ </div>
+ <div v-else-if="tab === 'search'">
+ <div class="_isolated">
+ <MkInput v-model="searchQuery" :debounce="true" type="search">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.searchUser }}</template>
+ </MkInput>
+ <MkRadios v-model="searchOrigin">
+ <option value="local">{{ $ts.local }}</option>
+ <option value="remote">{{ $ts.remote }}</option>
+ <option value="both">{{ $ts.all }}</option>
+ </MkRadios>
+ </div>
+
+ <XUserList v-if="searchQuery" class="_gap" :pagination="searchPagination" ref="search"/>
+ </div>
+ </div>
+ </MkSpacer>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XUserList from '@/components/user-list.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkInput from '@/components/form/input.vue';
+import MkRadios from '@/components/form/radios.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XUserList,
+ MkFolder,
+ MkInput,
+ MkRadios,
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.explore,
+ icon: 'fas fa-hashtag',
+ bg: 'var(--bg)',
+ tabs: [{
+ active: this.tab === 'local',
+ title: this.$ts.local,
+ onClick: () => { this.tab = 'local'; },
+ }, {
+ active: this.tab === 'remote',
+ title: this.$ts.remote,
+ onClick: () => { this.tab = 'remote'; },
+ }, {
+ active: this.tab === 'search',
+ title: this.$ts.search,
+ onClick: () => { this.tab = 'search'; },
+ },]
+ })),
+ tab: 'local',
+ pinnedUsers: { endpoint: 'pinned-users' },
+ popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'local',
+ sort: '+follower',
+ } },
+ recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ sort: '+updatedAt',
+ } },
+ recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'local',
+ state: 'alive',
+ sort: '+createdAt',
+ } },
+ popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ state: 'alive',
+ origin: 'remote',
+ sort: '+follower',
+ } },
+ recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+updatedAt',
+ } },
+ recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: {
+ origin: 'combined',
+ sort: '+createdAt',
+ } },
+ searchPagination: {
+ endpoint: 'users/search',
+ limit: 10,
+ params: computed(() => (this.searchQuery && this.searchQuery !== '') ? {
+ query: this.searchQuery,
+ origin: this.searchOrigin,
+ } : null)
+ },
+ tagsLocal: [],
+ tagsRemote: [],
+ stats: null,
+ searchQuery: null,
+ searchOrigin: 'combined',
+ num: number,
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+ tagUsers(): any {
+ return {
+ endpoint: 'hashtags/users',
+ limit: 30,
+ params: {
+ tag: this.tag,
+ origin: 'combined',
+ sort: '+follower',
+ }
+ };
+ },
+ },
+
+ watch: {
+ tag() {
+ if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
+ },
+ },
+
+ created() {
+ os.api('hashtags/list', {
+ sort: '+attachedLocalUsers',
+ attachedToLocalUserOnly: true,
+ limit: 30
+ }).then(tags => {
+ this.tagsLocal = tags;
+ });
+ os.api('hashtags/list', {
+ sort: '+attachedRemoteUsers',
+ attachedToRemoteUserOnly: true,
+ limit: 30
+ }).then(tags => {
+ this.tagsRemote = tags;
+ });
+ os.api('stats').then(stats => {
+ this.stats = stats;
+ });
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.localfedi7 {
+ color: #fff;
+ padding: 16px;
+ height: 80px;
+ background-position: 50%;
+ background-size: cover;
+ margin-bottom: var(--margin);
+
+ > * {
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > span {
+ display: inline-block;
+ padding: 6px 8px;
+ background: rgba(0, 0, 0, 0.7);
+ }
+ }
+
+ > header {
+ font-size: 20px;
+ font-weight: bold;
+ }
+
+ > div {
+ font-size: 14px;
+ opacity: 0.8;
+ }
+}
+
+.vxjfqztj {
+ > * {
+ margin-right: 16px;
+
+ &.local {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue
new file mode 100644
index 0000000000..980d59835f
--- /dev/null
+++ b/packages/client/src/pages/favorites.vue
@@ -0,0 +1,60 @@
+<template>
+<div class="jmelgwjh">
+ <div class="body">
+ <XNotes class="notes" :pagination="pagination" :detail="true" :prop="'note'" @before="before()" @after="after()"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.favorites,
+ icon: 'fas fa-star',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'i/favorites',
+ limit: 10,
+ params: () => ({
+ })
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jmelgwjh {
+ background: var(--bg);
+
+ > .body {
+ box-sizing: border-box;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue
new file mode 100644
index 0000000000..f5edf25594
--- /dev/null
+++ b/packages/client/src/pages/featured.vue
@@ -0,0 +1,43 @@
+<template>
+<MkSpacer :content-max="800">
+ <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.featured,
+ icon: 'fas fa-fire-alt',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'notes/featured',
+ limit: 10,
+ offsetMode: true,
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue
new file mode 100644
index 0000000000..1bd5da58e3
--- /dev/null
+++ b/packages/client/src/pages/federation.vue
@@ -0,0 +1,265 @@
+<template>
+<div class="taeiyria">
+ <div class="query">
+ <MkInput v-model="host" :debounce="true" class="">
+ <template #prefix><i class="fas fa-search"></i></template>
+ <template #label>{{ $ts.host }}</template>
+ </MkInput>
+ <div class="_inputSplit">
+ <MkSelect v-model="state">
+ <template #label>{{ $ts.state }}</template>
+ <option value="all">{{ $ts.all }}</option>
+ <option value="federating">{{ $ts.federating }}</option>
+ <option value="subscribing">{{ $ts.subscribing }}</option>
+ <option value="publishing">{{ $ts.publishing }}</option>
+ <option value="suspended">{{ $ts.suspended }}</option>
+ <option value="blocked">{{ $ts.blocked }}</option>
+ <option value="notResponding">{{ $ts.notResponding }}</option>
+ </MkSelect>
+ <MkSelect v-model="sort">
+ <template #label>{{ $ts.sort }}</template>
+ <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option>
+ <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option>
+ <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option>
+ <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option>
+ <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option>
+ <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option>
+ <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
+ <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option>
+ <option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option>
+ <option value="+driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.descendingOrder }})</option>
+ <option value="-driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.ascendingOrder }})</option>
+ </MkSelect>
+ </div>
+ </div>
+
+ <MkPagination :pagination="pagination" #default="{items}" ref="instances" :key="host + state">
+ <div class="dqokceoi">
+ <MkA class="instance" v-for="instance in items" :key="instance.id" :to="`/instance-info/${instance.host}`">
+ <div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div>
+ <div class="table">
+ <div class="cell">
+ <div class="key">{{ $ts.registeredAt }}</div>
+ <div class="value"><MkTime :time="instance.caughtAt"/></div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.software }}</div>
+ <div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.version }}</div>
+ <div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.users }}</div>
+ <div class="value">{{ instance.usersCount }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.notes }}</div>
+ <div class="value">{{ instance.notesCount }}</div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.sent }}</div>
+ <div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div>
+ </div>
+ <div class="cell">
+ <div class="key">{{ $ts.received }}</div>
+ <div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div>
+ </div>
+ </div>
+ <div class="footer">
+ <span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span>
+ <span class="pubSub">
+ <span class="sub" v-if="instance.followersCount > 0"><i class="fas fa-caret-down icon"></i>Sub</span>
+ <span class="sub" v-else><i class="fas fa-caret-down icon"></i>-</span>
+ <span class="pub" v-if="instance.followingCount > 0"><i class="fas fa-caret-up icon"></i>Pub</span>
+ <span class="pub" v-else><i class="fas fa-caret-up icon"></i>-</span>
+ </span>
+ <span class="right">
+ <span class="latestStatus">{{ instance.latestStatus || '-' }}</span>
+ <span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span>
+ </span>
+ </div>
+ </MkA>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSelect,
+ MkPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.federation,
+ icon: 'fas fa-globe',
+ bg: 'var(--bg)',
+ },
+ host: '',
+ state: 'federating',
+ sort: '+pubSub',
+ pagination: {
+ endpoint: 'federation/instances',
+ limit: 10,
+ offsetMode: true,
+ params: () => ({
+ sort: this.sort,
+ host: this.host != '' ? this.host : null,
+ ...(
+ this.state === 'federating' ? { federating: true } :
+ this.state === 'subscribing' ? { subscribing: true } :
+ this.state === 'publishing' ? { publishing: true } :
+ this.state === 'suspended' ? { suspended: true } :
+ this.state === 'blocked' ? { blocked: true } :
+ this.state === 'notResponding' ? { notResponding: true } :
+ {})
+ })
+ },
+ }
+ },
+
+ watch: {
+ host() {
+ this.$refs.instances.reload();
+ },
+ state() {
+ this.$refs.instances.reload();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ getStatus(instance) {
+ if (instance.isSuspended) return 'suspended';
+ if (instance.isNotResponding) return 'error';
+ return 'alive';
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.taeiyria {
+ > .query {
+ background: var(--bg);
+ padding: 16px;
+ }
+}
+
+.dqokceoi {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
+ grid-gap: 12px;
+ padding: 16px;
+
+ > .instance {
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+
+ > .host {
+ font-weight: bold;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ > img {
+ width: 18px;
+ height: 18px;
+ margin-right: 6px;
+ vertical-align: middle;
+ }
+ }
+
+ > .table {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
+ grid-gap: 6px;
+ margin: 6px 0;
+ font-size: 70%;
+
+ > .cell {
+ > .key, > .value {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .key {
+ opacity: 0.7;
+ }
+
+ > .value {
+ }
+ }
+ }
+
+ > .footer {
+ display: flex;
+ align-items: center;
+ font-size: 0.9em;
+
+ > .status {
+ &.suspended {
+ opacity: 0.5;
+ }
+
+ &.error {
+ color: var(--error);
+ }
+
+ &.alive {
+ color: var(--success);
+ }
+ }
+
+ > .pubSub {
+ margin-left: 8px;
+ }
+
+ > .right {
+ margin-left: auto;
+
+ > .latestStatus {
+ border: solid 1px var(--divider);
+ border-radius: 4px;
+ margin: 0 8px;
+ padding: 0 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue
new file mode 100644
index 0000000000..d8967dc9d9
--- /dev/null
+++ b/packages/client/src/pages/follow-requests.vue
@@ -0,0 +1,153 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" class="mk-follow-requests" ref="list">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noFollowRequests }}</div>
+ </div>
+ </template>
+ <template #default="{items}">
+ <div class="user _panel" v-for="req in items" :key="req.id">
+ <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/>
+ <div class="body">
+ <div class="name">
+ <MkA class="name" :to="userPage(req.follower)" v-user-preview="req.follower.id"><MkUserName :user="req.follower"/></MkA>
+ <p class="acct">@{{ acct(req.follower) }}</p>
+ </div>
+ <div class="description" v-if="req.follower.description" :title="req.follower.description">
+ <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
+ </div>
+ <div class="actions">
+ <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button>
+ <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button>
+ </div>
+ </div>
+ </div>
+ </template>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import { userPage, acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.followRequests,
+ icon: 'fas fa-user-clock',
+ },
+ pagination: {
+ endpoint: 'following/requests/list',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ accept(user) {
+ os.api('following/requests/accept', { userId: user.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ },
+ reject(user) {
+ os.api('following/requests/reject', { userId: user.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ },
+ userPage,
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-follow-requests {
+ > .user {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 42px;
+ height: 42px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ display: flex;
+ width: calc(100% - 54px);
+ position: relative;
+
+ > .name {
+ width: 45%;
+
+ @media (max-width: 500px) {
+ width: 100%;
+ }
+
+ > .name,
+ > .acct {
+ display: block;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ margin: 0;
+ }
+
+ > .name {
+ font-size: 16px;
+ line-height: 24px;
+ }
+
+ > .acct {
+ font-size: 15px;
+ line-height: 16px;
+ opacity: 0.7;
+ }
+ }
+
+ > .description {
+ width: 55%;
+ line-height: 42px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ opacity: 0.7;
+ font-size: 14px;
+ padding-right: 40px;
+ padding-left: 8px;
+ box-sizing: border-box;
+
+ @media (max-width: 500px) {
+ display: none;
+ }
+ }
+
+ > .actions {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ margin: auto 0;
+
+ > button {
+ padding: 12px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue
new file mode 100644
index 0000000000..e8eaad73bf
--- /dev/null
+++ b/packages/client/src/pages/follow.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="mk-follow-page">
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import * as Acct from 'misskey-js/built/acct';
+
+export default defineComponent({
+ created() {
+ const acct = new URL(location.href).searchParams.get('acct');
+ if (acct == null) return;
+
+ let promise;
+
+ if (acct.startsWith('https://')) {
+ promise = os.api('ap/show', {
+ uri: acct
+ });
+ promise.then(res => {
+ if (res.type == 'User') {
+ this.follow(res.object);
+ } else if (res.type === 'Note') {
+ this.$router.push(`/notes/${res.object.id}`);
+ } else {
+ os.dialog({
+ type: 'error',
+ text: 'Not a user'
+ }).then(() => {
+ window.close();
+ });
+ }
+ });
+ } else {
+ promise = os.api('users/show', Acct.parse(acct));
+ promise.then(user => {
+ this.follow(user);
+ });
+ }
+
+ os.promiseDialog(promise, null, null, this.$ts.fetchingAsApObject);
+ },
+
+ methods: {
+ async follow(user) {
+ const { canceled } = await os.dialog({
+ type: 'question',
+ text: this.$t('followConfirm', { name: user.name || user.username }),
+ showCancelButton: true
+ });
+
+ if (canceled) {
+ window.close();
+ return;
+ }
+
+ os.apiWithDialog('following/create', {
+ userId: user.id
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue
new file mode 100644
index 0000000000..1ee3a9390b
--- /dev/null
+++ b/packages/client/src/pages/gallery/edit.vue
@@ -0,0 +1,168 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormInput v-model="title">
+ <span>{{ $ts.title }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description" :max="500">
+ <span>{{ $ts.description }}</span>
+ </FormTextarea>
+
+ <FormGroup>
+ <div v-for="file in files" :key="file.id" class="_debobigegoItem _debobigegoPanel wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }">
+ <div class="name">{{ file.name }}</div>
+ <button class="remove _button" @click="remove(file)" v-tooltip="$ts.remove"><i class="fas fa-times"></i></button>
+ </div>
+ <FormButton @click="selectFile" primary><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton>
+ </FormGroup>
+
+ <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch>
+
+ <FormButton v-if="postId" @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ <FormButton v-else @click="save" primary><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton>
+
+ <FormButton v-if="postId" @click="del" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormTuple from '@/components/debobigego/tuple.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormButton,
+ FormInput,
+ FormTextarea,
+ FormSwitch,
+ FormBase,
+ FormGroup,
+ FormSuspense,
+ },
+
+ props: {
+ postId: {
+ type: String,
+ required: false,
+ default: null,
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.postId ? {
+ title: this.$ts.edit,
+ icon: 'fas fa-pencil-alt'
+ } : {
+ title: this.$ts.postToGallery,
+ icon: 'fas fa-pencil-alt'
+ }),
+ init: null,
+ files: [],
+ description: null,
+ title: null,
+ isSensitive: false,
+ }
+ },
+
+ watch: {
+ postId: {
+ handler() {
+ this.init = () => this.postId ? os.api('gallery/posts/show', {
+ postId: this.postId
+ }).then(post => {
+ this.files = post.files;
+ this.title = post.title;
+ this.description = post.description;
+ this.isSensitive = post.isSensitive;
+ }) : Promise.resolve(null);
+ },
+ immediate: true,
+ }
+ },
+
+ methods: {
+ selectFile(e) {
+ selectFile(e.currentTarget || e.target, null, true).then(files => {
+ this.files = this.files.concat(files);
+ });
+ },
+
+ remove(file) {
+ this.files = this.files.filter(f => f.id !== file.id);
+ },
+
+ async save() {
+ if (this.postId) {
+ await os.apiWithDialog('gallery/posts/update', {
+ postId: this.postId,
+ title: this.title,
+ description: this.description,
+ fileIds: this.files.map(file => file.id),
+ isSensitive: this.isSensitive,
+ });
+ this.$router.push(`/gallery/${this.postId}`);
+ } else {
+ const post = await os.apiWithDialog('gallery/posts/create', {
+ title: this.title,
+ description: this.description,
+ fileIds: this.files.map(file => file.id),
+ isSensitive: this.isSensitive,
+ });
+ this.$router.push(`/gallery/${post.id}`);
+ }
+ },
+
+ async del() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteConfirm,
+ showCancelButton: true
+ });
+ if (canceled) return;
+ await os.apiWithDialog('gallery/posts/delete', {
+ postId: this.postId,
+ });
+ this.$router.push(`/gallery`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wqugxsfx {
+ height: 200px;
+ background-size: contain;
+ background-position: center;
+ background-repeat: no-repeat;
+ position: relative;
+
+ > .name {
+ position: absolute;
+ top: 8px;
+ left: 9px;
+ padding: 8px;
+ background: var(--panel);
+ }
+
+ > .remove {
+ position: absolute;
+ top: 8px;
+ right: 9px;
+ padding: 8px;
+ background: var(--panel);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue
new file mode 100644
index 0000000000..dfcd59349e
--- /dev/null
+++ b/packages/client/src/pages/gallery/index.vue
@@ -0,0 +1,152 @@
+<template>
+<div class="xprsixdl _root">
+ <MkTab v-model="tab" v-if="$i">
+ <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option>
+ <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option>
+ <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option>
+ </MkTab>
+
+ <div v-if="tab === 'explore'">
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template>
+ <MkPagination :pagination="recentPostsPagination" #default="{items}" :disable-auto-load="true">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </MkFolder>
+ <MkFolder class="_gap">
+ <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template>
+ <MkPagination :pagination="popularPostsPagination" #default="{items}" :disable-auto-load="true">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </MkFolder>
+ </div>
+ <div v-else-if="tab === 'liked'">
+ <MkPagination :pagination="likedPostsPagination" #default="{items}">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="like in items" :post="like.post" :key="like.id" class="post"/>
+ </div>
+ </MkPagination>
+ </div>
+ <div v-else-if="tab === 'my'">
+ <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA>
+ <MkPagination :pagination="myPostsPagination" #default="{items}">
+ <div class="vfpdbgtk">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XUserList from '@/components/user-list.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkInput from '@/components/form/input.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTab from '@/components/tab.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
+import number from '@/filters/number';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XUserList,
+ MkFolder,
+ MkInput,
+ MkButton,
+ MkTab,
+ MkPagination,
+ MkGalleryPostPreview,
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.gallery,
+ icon: 'fas fa-icons'
+ },
+ tab: 'explore',
+ recentPostsPagination: {
+ endpoint: 'gallery/posts',
+ limit: 6,
+ },
+ popularPostsPagination: {
+ endpoint: 'gallery/featured',
+ limit: 5,
+ },
+ myPostsPagination: {
+ endpoint: 'i/gallery/posts',
+ limit: 5,
+ },
+ likedPostsPagination: {
+ endpoint: 'i/gallery/likes',
+ limit: 5,
+ },
+ tags: [],
+ };
+ },
+
+ computed: {
+ meta() {
+ return this.$instance;
+ },
+ tagUsers(): any {
+ return {
+ endpoint: 'hashtags/users',
+ limit: 30,
+ params: {
+ tag: this.tag,
+ origin: 'combined',
+ sort: '+follower',
+ }
+ };
+ },
+ },
+
+ watch: {
+ tag() {
+ if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null);
+ },
+ },
+
+ created() {
+
+ },
+
+ methods: {
+
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.xprsixdl {
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.vfpdbgtk {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: 12px;
+ margin: 0 var(--margin);
+
+ > .post {
+
+ }
+}
+</style>
diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue
new file mode 100644
index 0000000000..255954def0
--- /dev/null
+++ b/packages/client/src/pages/gallery/post.vue
@@ -0,0 +1,282 @@
+<template>
+<div class="_root">
+ <transition name="fade" mode="out-in">
+ <div v-if="post" class="rkxwuolj">
+ <div class="files">
+ <div class="file" v-for="file in post.files" :key="file.id">
+ <img :src="file.url"/>
+ </div>
+ </div>
+ <div class="body _block">
+ <div class="title">{{ post.title }}</div>
+ <div class="description"><Mfm :text="post.description"/></div>
+ <div class="info">
+ <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/>
+ </div>
+ <div class="actions">
+ <div class="like">
+ <MkButton class="button" @click="unlike()" v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton>
+ <MkButton class="button" @click="like()" v-else v-tooltip="$ts._gallery.like"><i class="far fa-heart"></i><span class="count" v-if="post.likedCount > 0">{{ post.likedCount }}</span></MkButton>
+ </div>
+ <div class="other">
+ <button v-if="$i && $i.id === post.user.id" class="_button" @click="edit" v-tooltip="$ts.edit" v-click-anime><i class="fas fa-pencil-alt fa-fw"></i></button>
+ <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button>
+ <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button>
+ </div>
+ </div>
+ <div class="user">
+ <MkAvatar :user="post.user" class="avatar"/>
+ <div class="name">
+ <MkUserName :user="post.user" style="display: block;"/>
+ <MkAcct :user="post.user"/>
+ </div>
+ <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ </div>
+ </div>
+ <MkAd :prefer="['horizontal', 'horizontal-big']"/>
+ <MkContainer :max-height="300" :foldable="true" class="other">
+ <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
+ <MkPagination :pagination="otherPostsPagination" #default="{items}">
+ <div class="sdrarzaf">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+ </MkContainer>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import MkContainer from '@/components/ui/container.vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
+import MkFollowButton from '@/components/follow-button.vue';
+import { url } from '@/config';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ ImgWithBlurhash,
+ MkPagination,
+ MkGalleryPostPreview,
+ MkButton,
+ MkFollowButton,
+ },
+ props: {
+ postId: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.post ? {
+ title: this.post.title,
+ avatar: this.post.user,
+ path: `/gallery/${this.post.id}`,
+ share: {
+ title: this.post.title,
+ text: this.post.description,
+ },
+ actions: [{
+ icon: 'fas fa-pencil-alt',
+ text: this.$ts.edit,
+ handler: this.edit
+ }]
+ } : null),
+ otherPostsPagination: {
+ endpoint: 'users/gallery/posts',
+ limit: 6,
+ params: () => ({
+ userId: this.post.user.id
+ })
+ },
+ post: null,
+ error: null,
+ };
+ },
+
+ watch: {
+ postId: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ this.post = null;
+ os.api('gallery/posts/show', {
+ postId: this.postId
+ }).then(post => {
+ this.post = post;
+ }).catch(e => {
+ this.error = e;
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.post.title,
+ text: this.post.description,
+ url: `${url}/gallery/${this.post.id}`
+ });
+ },
+
+ shareWithNote() {
+ os.post({
+ initialText: `${this.post.title} ${url}/gallery/${this.post.id}`
+ });
+ },
+
+ like() {
+ os.apiWithDialog('gallery/posts/like', {
+ postId: this.postId,
+ }).then(() => {
+ this.post.isLiked = true;
+ this.post.likedCount++;
+ });
+ },
+
+ async unlike() {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$ts.unlikeConfirm,
+ });
+ if (confirm.canceled) return;
+ os.apiWithDialog('gallery/posts/unlike', {
+ postId: this.postId,
+ }).then(() => {
+ this.post.isLiked = false;
+ this.post.likedCount--;
+ });
+ },
+
+ edit() {
+ this.$router.push(`/gallery/${this.post.id}/edit`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.rkxwuolj {
+ > .files {
+ > .file {
+ > img {
+ display: block;
+ max-width: 100%;
+ max-height: 500px;
+ margin: 0 auto;
+ }
+
+ & + .file {
+ margin-top: 16px;
+ }
+ }
+ }
+
+ > .body {
+ padding: 32px;
+
+ > .title {
+ font-weight: bold;
+ font-size: 1.2em;
+ margin-bottom: 16px;
+ }
+
+ > .info {
+ margin-top: 16px;
+ font-size: 90%;
+ opacity: 0.7;
+ }
+
+ > .actions {
+ display: flex;
+ align-items: center;
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+
+ > .like {
+ > .button {
+ --accent: rgb(241 97 132);
+ --X8: rgb(241 92 128);
+ --buttonBg: rgb(216 71 106 / 5%);
+ --buttonHoverBg: rgb(216 71 106 / 10%);
+ color: #ff002f;
+
+ ::v-deep(.count) {
+ margin-left: 0.5em;
+ }
+ }
+ }
+
+ > .other {
+ margin-left: auto;
+
+ > button {
+ padding: 8px;
+ margin: 0 8px;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+ }
+ }
+
+ > .user {
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+ display: flex;
+ align-items: center;
+
+ > .avatar {
+ width: 52px;
+ height: 52px;
+ }
+
+ > .name {
+ margin: 0 0 0 12px;
+ font-size: 90%;
+ }
+
+ > .koudoku {
+ margin-left: auto;
+ }
+ }
+ }
+}
+
+.sdrarzaf {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+
+ > .post {
+
+ }
+}
+</style>
diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue
new file mode 100644
index 0000000000..586d9d7e52
--- /dev/null
+++ b/packages/client/src/pages/instance-info.vue
@@ -0,0 +1,238 @@
+<template>
+<FormBase>
+ <FormGroup v-if="instance">
+ <template #label>{{ instance.host }}</template>
+ <FormGroup>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel fnfelxur">
+ <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
+ </div>
+ </div>
+ <FormKeyValueView>
+ <template #key>Name</template>
+ <template #value><span class="_monospace">{{ instance.name || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormButton v-if="$i.isAdmin || $i.isModerator" @click="info" primary>{{ $ts.settings }}</FormButton>
+
+ <FormTextarea readonly :value="instance.description">
+ <span>{{ $ts.description }}</span>
+ </FormTextarea>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.software }}</template>
+ <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.version }}</template>
+ <template #value><span class="_monospace">{{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.administrator }}</template>
+ <template #value><span class="_monospace">{{ instance.maintainerName || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.contact }}</template>
+ <template #value><span class="_monospace">{{ instance.maintainerEmail || `(${$ts.unknown})` }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestRequestSentAt }}</template>
+ <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestStatus }}</template>
+ <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestRequestReceivedAt }}</template>
+ <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Open Registrations</template>
+ <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoLabel">{{ $ts.statistics }}</div>
+ <div class="_debobigegoPanel cmhjzshl">
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
+ <option value="instance-requests">{{ $ts._instanceCharts.requests }}</option>
+ <option value="instance-users">{{ $ts._instanceCharts.users }}</option>
+ <option value="instance-users-total">{{ $ts._instanceCharts.usersTotal }}</option>
+ <option value="instance-notes">{{ $ts._instanceCharts.notes }}</option>
+ <option value="instance-notes-total">{{ $ts._instanceCharts.notesTotal }}</option>
+ <option value="instance-ff">{{ $ts._instanceCharts.ff }}</option>
+ <option value="instance-ff-total">{{ $ts._instanceCharts.ffTotal }}</option>
+ <option value="instance-drive-usage">{{ $ts._instanceCharts.cacheSize }}</option>
+ <option value="instance-drive-usage-total">{{ $ts._instanceCharts.cacheSizeTotal }}</option>
+ <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option>
+ <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option>
+ </MkSelect>
+ <MkSelect v-model="chartSpan" style="margin: 0;">
+ <option value="hour">{{ $ts.perHour }}</option>
+ <option value="day">{{ $ts.perDay }}</option>
+ </MkSelect>
+ </div>
+ <div class="chart">
+ <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :detailed="true"></MkChart>
+ </div>
+ </div>
+ </div>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.registeredAt }}</template>
+ <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormObjectView tall :value="instance">
+ <span>Raw</span>
+ </FormObjectView>
+ <FormGroup>
+ <template #label>Well-known resources</template>
+ <FormLink :to="`https://${host}/.well-known/host-meta`" external>host-meta</FormLink>
+ <FormLink :to="`https://${host}/.well-known/host-meta.json`" external>host-meta.json</FormLink>
+ <FormLink :to="`https://${host}/.well-known/nodeinfo`" external>nodeinfo</FormLink>
+ <FormLink :to="`https://${host}/robots.txt`" external>robots.txt</FormLink>
+ <FormLink :to="`https://${host}/manifest.json`" external>manifest.json</FormLink>
+ </FormGroup>
+ <FormSuspense :p="dnsPromiseFactory" v-slot="{ result: dns }">
+ <FormGroup>
+ <template #label>DNS</template>
+ <FormKeyValueView v-for="record in dns.a" :key="record">
+ <template #key>A</template>
+ <template #value><span class="_monospace">{{ record }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView v-for="record in dns.aaaa" :key="record">
+ <template #key>AAAA</template>
+ <template #value><span class="_monospace">{{ record }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView v-for="record in dns.cname" :key="record">
+ <template #key>CNAME</template>
+ <template #value><span class="_monospace">{{ record }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView v-for="record in dns.txt">
+ <template #key>TXT</template>
+ <template #value><span class="_monospace">{{ record[0] }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import MkChart from '@/components/chart.vue';
+import FormObjectView from '@/components/debobigego/object-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import MkSelect from '@/components/form/select.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+import MkInstanceInfo from '@/pages/admin/instance.vue';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormTextarea,
+ FormObjectView,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormSuspense,
+ MkSelect,
+ MkChart,
+ },
+
+ props: {
+ host: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.instanceInfo,
+ icon: 'fas fa-info-circle',
+ actions: [{
+ text: `https://${this.host}`,
+ icon: 'fas fa-external-link-alt',
+ handler: () => {
+ window.open(`https://${this.host}`, '_blank');
+ }
+ }],
+ },
+ instance: null,
+ dnsPromiseFactory: () => os.api('federation/dns', {
+ host: this.host
+ }),
+ chartSrc: 'instance-requests',
+ chartSpan: 'hour',
+ }
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ methods: {
+ number,
+ bytes,
+
+ async fetch() {
+ this.instance = await os.api('federation/show-instance', {
+ host: this.host
+ });
+ },
+
+ info() {
+ os.popup(MkInstanceInfo, {
+ instance: this.instance
+ }, {}, 'closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fnfelxur {
+ padding: 16px;
+
+ > .icon {
+ display: block;
+ margin: auto;
+ height: 64px;
+ border-radius: 8px;
+ }
+}
+
+.cmhjzshl {
+ > .selects {
+ display: flex;
+ padding: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue
new file mode 100644
index 0000000000..cd9c6a8fdf
--- /dev/null
+++ b/packages/client/src/pages/mentions.vue
@@ -0,0 +1,42 @@
+<template>
+<MkSpacer :content-max="800">
+ <XNotes :pagination="pagination" @before="before()" @after="after()"/>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.mentions,
+ icon: 'fas fa-at',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue
new file mode 100644
index 0000000000..9fde0bc7d5
--- /dev/null
+++ b/packages/client/src/pages/messages.vue
@@ -0,0 +1,45 @@
+<template>
+<MkSpacer :content-max="800">
+ <XNotes :pagination="pagination" @before="before()" @after="after()"/>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.directNotes,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ params: () => ({
+ visibility: 'specified'
+ })
+ },
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue
new file mode 100644
index 0000000000..896c3927ce
--- /dev/null
+++ b/packages/client/src/pages/messaging/index.vue
@@ -0,0 +1,307 @@
+<template>
+<MkSpacer :content-max="800">
+ <div class="yweeujhr" v-size="{ max: [400] }">
+ <MkButton @click="start" primary class="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton>
+
+ <div class="history" v-if="messages.length > 0">
+ <MkA v-for="(message, i) in messages"
+ class="message _block"
+ :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }"
+ :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`"
+ :data-index="i"
+ :key="message.id"
+ v-anim="i"
+ >
+ <div>
+ <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/>
+ <header v-if="message.groupId">
+ <span class="name">{{ message.group.name }}</span>
+ <MkTime :time="message.createdAt" class="time"/>
+ </header>
+ <header v-else>
+ <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span>
+ <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span>
+ <MkTime :time="message.createdAt" class="time"/>
+ </header>
+ <div class="body">
+ <p class="text"><span class="me" v-if="isMe(message)">{{ $ts.you }}:</span>{{ message.text }}</p>
+ </div>
+ </div>
+ </MkA>
+ </div>
+ <div class="_fullinfo" v-if="!fetching && messages.length == 0">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noHistory }}</div>
+ </div>
+ <MkLoading v-if="fetching"/>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as Acct from 'misskey-js/built/acct';
+import MkButton from '@/components/ui/button.vue';
+import { acct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.messaging,
+ icon: 'fas fa-comments',
+ bg: 'var(--bg)',
+ },
+ fetching: true,
+ moreFetching: false,
+ messages: [],
+ connection: null,
+ };
+ },
+
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('messagingIndex'));
+
+ this.connection.on('message', this.onMessage);
+ this.connection.on('read', this.onRead);
+
+ os.api('messaging/history', { group: false }).then(userMessages => {
+ os.api('messaging/history', { group: true }).then(groupMessages => {
+ const messages = userMessages.concat(groupMessages);
+ messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+ this.messages = messages;
+ this.fetching = false;
+ });
+ });
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ getAcct: Acct.toString,
+
+ isMe(message) {
+ return message.userId == this.$i.id;
+ },
+
+ onMessage(message) {
+ if (message.recipientId) {
+ this.messages = this.messages.filter(m => !(
+ (m.recipientId == message.recipientId && m.userId == message.userId) ||
+ (m.recipientId == message.userId && m.userId == message.recipientId)));
+
+ this.messages.unshift(message);
+ } else if (message.groupId) {
+ this.messages = this.messages.filter(m => m.groupId !== message.groupId);
+ this.messages.unshift(message);
+ }
+ },
+
+ onRead(ids) {
+ for (const id of ids) {
+ const found = this.messages.find(m => m.id == id);
+ if (found) {
+ if (found.recipientId) {
+ found.isRead = true;
+ } else if (found.groupId) {
+ found.reads.push(this.$i.id);
+ }
+ }
+ }
+ },
+
+ start(ev) {
+ os.popupMenu([{
+ text: this.$ts.messagingWithUser,
+ icon: 'fas fa-user',
+ action: () => { this.startUser() }
+ }, {
+ text: this.$ts.messagingWithGroup,
+ icon: 'fas fa-users',
+ action: () => { this.startGroup() }
+ }], ev.currentTarget || ev.target);
+ },
+
+ async startUser() {
+ os.selectUser().then(user => {
+ this.$router.push(`/my/messaging/${Acct.toString(user)}`);
+ });
+ },
+
+ async startGroup() {
+ const groups1 = await os.api('users/groups/owned');
+ const groups2 = await os.api('users/groups/joined');
+ if (groups1.length === 0 && groups2.length === 0) {
+ os.dialog({
+ type: 'warning',
+ title: this.$ts.youHaveNoGroups,
+ text: this.$ts.joinOrCreateGroup,
+ });
+ return;
+ }
+ const { canceled, result: group } = await os.dialog({
+ type: null,
+ title: this.$ts.group,
+ select: {
+ items: groups1.concat(groups2).map(group => ({
+ value: group, text: group.name
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.$router.push(`/my/messaging/group/${group.id}`);
+ },
+
+ acct
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.yweeujhr {
+
+ > .start {
+ margin: 0 auto var(--margin) auto;
+ }
+
+ > .history {
+ > .message {
+ display: block;
+ text-decoration: none;
+ margin-bottom: var(--margin);
+
+ * {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ &:hover {
+ .avatar {
+ filter: saturate(200%);
+ }
+ }
+
+ &:active {
+ }
+
+ &.isRead,
+ &.isMe {
+ opacity: 0.8;
+ }
+
+ &:not(.isMe):not(.isRead) {
+ > div {
+ background-image: url("/client-assets/unread.svg");
+ background-repeat: no-repeat;
+ background-position: 0 center;
+ }
+ }
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > div {
+ padding: 20px 30px;
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+
+ > .name {
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 1em;
+ font-weight: bold;
+ transition: all 0.1s ease;
+ }
+
+ > .username {
+ margin: 0 8px;
+ }
+
+ > .time {
+ margin: 0 0 0 auto;
+ }
+ }
+
+ > .avatar {
+ float: left;
+ width: 54px;
+ height: 54px;
+ margin: 0 16px 0 0;
+ border-radius: 8px;
+ transition: all 0.1s ease;
+ }
+
+ > .body {
+
+ > .text {
+ display: block;
+ margin: 0 0 0 0;
+ padding: 0;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ font-size: 1.1em;
+ color: var(--faceText);
+
+ .me {
+ opacity: 0.7;
+ }
+ }
+
+ > .image {
+ display: block;
+ max-width: 100%;
+ max-height: 512px;
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_400px {
+ > .history {
+ > .message {
+ &:not(.isMe):not(.isRead) {
+ > div {
+ background-image: none;
+ border-left: solid 4px #3aa2dc;
+ }
+ }
+
+ > div {
+ padding: 16px;
+ font-size: 0.9em;
+
+ > .avatar {
+ margin: 0 12px 0 0;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue
new file mode 100644
index 0000000000..aafed2632d
--- /dev/null
+++ b/packages/client/src/pages/messaging/messaging-room.form.vue
@@ -0,0 +1,348 @@
+<template>
+<div class="pemppnzi _block"
+ @dragover.stop="onDragover"
+ @drop.stop="onDrop"
+>
+ <textarea
+ v-model="text"
+ ref="text"
+ @keypress="onKeypress"
+ @compositionupdate="onCompositionUpdate"
+ @paste="onPaste"
+ :placeholder="$ts.inputMessageHere"
+ ></textarea>
+ <div class="file" @click="file = null" v-if="file">{{ file.name }}</div>
+ <button class="send _button" @click="send" :disabled="!canSend || sending" :title="$ts.send">
+ <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>
+ </button>
+ <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button>
+ <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button>
+ <input ref="file" type="file" @change="onChangeFile"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import * as autosize from 'autosize';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { Autocomplete } from '@/scripts/autocomplete';
+import { throttle } from 'throttle-debounce';
+
+export default defineComponent({
+ props: {
+ user: {
+ type: Object,
+ requird: false,
+ },
+ group: {
+ type: Object,
+ requird: false,
+ },
+ },
+ data() {
+ return {
+ text: null,
+ file: null,
+ sending: false,
+ typing: throttle(3000, () => {
+ os.stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id });
+ }),
+ };
+ },
+ computed: {
+ draftKey(): string {
+ return this.user ? 'user:' + this.user.id : 'group:' + this.group.id;
+ },
+ canSend(): boolean {
+ return (this.text != null && this.text != '') || this.file != null;
+ },
+ room(): any {
+ return this.$parent;
+ }
+ },
+ watch: {
+ text() {
+ this.saveDraft();
+ },
+ file() {
+ this.saveDraft();
+ }
+ },
+ mounted() {
+ autosize(this.$refs.text);
+
+ // TODO: detach when unmount
+ new Autocomplete(this.$refs.text, this, { model: 'text' });
+
+ // 書きかけの投稿を復元
+ const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey];
+ if (draft) {
+ this.text = draft.data.text;
+ this.file = draft.data.file;
+ }
+ },
+ methods: {
+ async onPaste(e: ClipboardEvent) {
+ const data = e.clipboardData;
+ const items = data.items;
+
+ if (items.length == 1) {
+ if (items[0].kind == 'file') {
+ const file = items[0].getAsFile();
+ const lio = file.name.lastIndexOf('.');
+ const ext = lio >= 0 ? file.name.slice(lio) : '';
+ const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`;
+ const name = this.$store.state.pasteDialog
+ ? await os.dialog({
+ title: this.$ts.enterFileName,
+ input: {
+ default: formatted
+ },
+ allowEmpty: false
+ }).then(({ canceled, result }) => canceled ? false : result)
+ : formatted;
+ if (name) this.upload(file, name);
+ }
+ } else {
+ if (items[0].kind == 'file') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.onlyOneFileCanBeAttached
+ });
+ }
+ }
+ },
+
+ onDragover(e) {
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ }
+ },
+
+ onDrop(e): void {
+ // ファイルだったら
+ if (e.dataTransfer.files.length == 1) {
+ e.preventDefault();
+ this.upload(e.dataTransfer.files[0]);
+ return;
+ } else if (e.dataTransfer.files.length > 1) {
+ e.preventDefault();
+ os.dialog({
+ type: 'error',
+ text: this.$ts.onlyOneFileCanBeAttached
+ });
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ this.file = JSON.parse(driveFile);
+ e.preventDefault();
+ }
+ //#endregion
+ },
+
+ onKeypress(e) {
+ this.typing();
+ if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey) && this.canSend) {
+ this.send();
+ }
+ },
+
+ onCompositionUpdate() {
+ this.typing();
+ },
+
+ chooseFile(e) {
+ selectFile(e.currentTarget || e.target, this.$ts.selectFile, false).then(file => {
+ this.file = file;
+ });
+ },
+
+ onChangeFile() {
+ this.upload((this.$refs.file as any).files[0]);
+ },
+
+ upload(file: File, name?: string) {
+ os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+ this.file = res;
+ });
+ },
+
+ send() {
+ this.sending = true;
+ os.api('messaging/messages/create', {
+ userId: this.user ? this.user.id : undefined,
+ groupId: this.group ? this.group.id : undefined,
+ text: this.text ? this.text : undefined,
+ fileId: this.file ? this.file.id : undefined
+ }).then(message => {
+ this.clear();
+ }).catch(err => {
+ console.error(err);
+ }).then(() => {
+ this.sending = false;
+ });
+ },
+
+ clear() {
+ this.text = '';
+ this.file = null;
+ this.deleteDraft();
+ },
+
+ saveDraft() {
+ const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+ data[this.draftKey] = {
+ updatedAt: new Date(),
+ data: {
+ text: this.text,
+ file: this.file
+ }
+ }
+
+ localStorage.setItem('message_drafts', JSON.stringify(data));
+ },
+
+ deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('message_drafts') || '{}');
+
+ delete data[this.draftKey];
+
+ localStorage.setItem('message_drafts', JSON.stringify(data));
+ },
+
+ async insertEmoji(ev) {
+ os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.pemppnzi {
+ position: relative;
+
+ > textarea {
+ cursor: auto;
+ display: block;
+ width: 100%;
+ min-width: 100%;
+ max-width: 100%;
+ height: 80px;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ resize: none;
+ font-size: 1em;
+ font-family: inherit;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+ box-sizing: border-box;
+ color: var(--fg);
+ }
+
+ > .file {
+ padding: 8px;
+ color: #444;
+ background: #eee;
+ cursor: pointer;
+ }
+
+ > .send {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: 0;
+ padding: 16px;
+ font-size: 1em;
+ transition: color 0.1s ease;
+ color: var(--accent);
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
+ }
+
+ .files {
+ display: block;
+ margin: 0;
+ padding: 0 8px;
+ list-style: none;
+
+ &:after {
+ content: '';
+ display: block;
+ clear: both;
+ }
+
+ > li {
+ display: block;
+ float: left;
+ margin: 4px;
+ padding: 0;
+ width: 64px;
+ height: 64px;
+ background-color: #eee;
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: cover;
+ cursor: move;
+
+ &:hover {
+ > .remove {
+ display: block;
+ }
+ }
+
+ > .remove {
+ display: none;
+ position: absolute;
+ right: -6px;
+ top: -6px;
+ margin: 0;
+ padding: 0;
+ background: transparent;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ cursor: pointer;
+ }
+ }
+ }
+
+ ._button {
+ margin: 0;
+ padding: 16px;
+ font-size: 1em;
+ font-weight: normal;
+ text-decoration: none;
+ transition: color 0.1s ease;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ &:active {
+ color: var(--accentDarken);
+ transition: color 0s ease;
+ }
+ }
+
+ input[type=file] {
+ display: none;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue
new file mode 100644
index 0000000000..432d11add8
--- /dev/null
+++ b/packages/client/src/pages/messaging/messaging-room.message.vue
@@ -0,0 +1,350 @@
+<template>
+<div class="thvuemwp" :class="{ isMe }" v-size="{ max: [400, 500] }">
+ <MkAvatar class="avatar" :user="message.user" :show-indicator="true"/>
+ <div class="content">
+ <div class="balloon" :class="{ noText: message.text == null }" >
+ <button class="delete-button" v-if="isMe" :title="$ts.delete" @click="del">
+ <img src="/client-assets/remove.png" alt="Delete"/>
+ </button>
+ <div class="content" v-if="!message.isDeleted">
+ <Mfm class="text" v-if="message.text" ref="text" :text="message.text" :i="$i"/>
+ <div class="file" v-if="message.file">
+ <a :href="message.file.url" rel="noopener" target="_blank" :title="message.file.name">
+ <img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
+ <p v-else>{{ message.file.name }}</p>
+ </a>
+ </div>
+ </div>
+ <div class="content" v-else>
+ <p class="is-deleted">{{ $ts.deleted }}</p>
+ </div>
+ </div>
+ <div></div>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" style="margin: 8px 0;"/>
+ <footer>
+ <template v-if="isGroup">
+ <span class="read" v-if="message.reads.length > 0">{{ $ts.messageRead }} {{ message.reads.length }}</span>
+ </template>
+ <template v-else>
+ <span class="read" v-if="isMe && message.isRead">{{ $ts.messageRead }}</span>
+ </template>
+ <MkTime :time="message.createdAt"/>
+ <template v-if="message.is_edited"><i class="fas fa-pencil-alt"></i></template>
+ </footer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as mfm from 'mfm-js';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+import MkUrlPreview from '@/components/url-preview.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkUrlPreview
+ },
+ props: {
+ message: {
+ required: true
+ },
+ isGroup: {
+ required: false
+ }
+ },
+ computed: {
+ isMe(): boolean {
+ return this.message.userId === this.$i.id;
+ },
+ urls(): string[] {
+ if (this.message.text) {
+ return extractUrlFromMfm(mfm.parse(this.message.text));
+ } else {
+ return [];
+ }
+ }
+ },
+ methods: {
+ del() {
+ os.api('messaging/messages/delete', {
+ messageId: this.message.id
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.thvuemwp {
+ $me-balloon-color: var(--accent);
+
+ position: relative;
+ background-color: transparent;
+ display: flex;
+
+ > .avatar {
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 16px);
+ display: block;
+ width: 54px;
+ height: 54px;
+ transition: all 0.1s ease;
+ }
+
+ > .content {
+ min-width: 0;
+
+ > .balloon {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ padding: 0;
+ min-height: 38px;
+ border-radius: 16px;
+ max-width: 100%;
+
+ &:before {
+ content: "";
+ pointer-events: none;
+ display: block;
+ position: absolute;
+ top: 12px;
+ }
+
+ & + * {
+ clear: both;
+ }
+
+ &:hover {
+ > .delete-button {
+ display: block;
+ }
+ }
+
+ > .delete-button {
+ display: none;
+ position: absolute;
+ z-index: 1;
+ top: -4px;
+ right: -4px;
+ margin: 0;
+ padding: 0;
+ cursor: pointer;
+ outline: none;
+ border: none;
+ border-radius: 0;
+ box-shadow: none;
+ background: transparent;
+
+ > img {
+ vertical-align: bottom;
+ width: 16px;
+ height: 16px;
+ cursor: pointer;
+ }
+ }
+
+ > .content {
+ max-width: 100%;
+
+ > .is-deleted {
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ font-size: 1em;
+ color: rgba(#000, 0.5);
+ }
+
+ > .text {
+ display: block;
+ margin: 0;
+ padding: 12px 18px;
+ overflow: hidden;
+ overflow-wrap: break-word;
+ word-break: break-word;
+ font-size: 1em;
+ color: rgba(#000, 0.8);
+
+ & + .file {
+ > a {
+ border-radius: 0 0 16px 16px;
+ }
+ }
+ }
+
+ > .file {
+ > a {
+ display: block;
+ max-width: 100%;
+ border-radius: 16px;
+ overflow: hidden;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: none;
+
+ > p {
+ background: #ccc;
+ }
+ }
+
+ > * {
+ display: block;
+ margin: 0;
+ width: 100%;
+ max-height: 512px;
+ object-fit: contain;
+ box-sizing: border-box;
+ }
+
+ > p {
+ padding: 30px;
+ text-align: center;
+ color: #555;
+ background: #ddd;
+ }
+ }
+ }
+ }
+ }
+
+ > footer {
+ display: block;
+ margin: 2px 0 0 0;
+ font-size: 0.65em;
+
+ > .read {
+ margin: 0 8px;
+ }
+
+ > i {
+ margin-left: 4px;
+ }
+ }
+ }
+
+ &:not(.isMe) {
+ padding-left: var(--margin);
+
+ > .content {
+ padding-left: 16px;
+ padding-right: 32px;
+
+ > .balloon {
+ $color: var(--messageBg);
+ background: $color;
+
+ &.noText {
+ background: transparent;
+ }
+
+ &:not(.noText):before {
+ left: -14px;
+ border-top: solid 8px transparent;
+ border-right: solid 8px $color;
+ border-bottom: solid 8px transparent;
+ border-left: solid 8px transparent;
+ }
+
+ > .content {
+ > .text {
+ color: var(--fg);
+ }
+ }
+ }
+
+ > footer {
+ text-align: left;
+ }
+ }
+ }
+
+ &.isMe {
+ flex-direction: row-reverse;
+ padding-right: var(--margin);
+
+ > .content {
+ padding-right: 16px;
+ padding-left: 32px;
+ text-align: right;
+
+ > .balloon {
+ background: $me-balloon-color;
+ text-align: left;
+
+ ::selection {
+ color: var(--accent);
+ background-color: #fff;
+ }
+
+ &.noText {
+ background: transparent;
+ }
+
+ &:not(.noText):before {
+ right: -14px;
+ left: auto;
+ border-top: solid 8px transparent;
+ border-right: solid 8px transparent;
+ border-bottom: solid 8px transparent;
+ border-left: solid 8px $me-balloon-color;
+ }
+
+ > .content {
+
+ > p.is-deleted {
+ color: rgba(#fff, 0.5);
+ }
+
+ > .text {
+ &, ::v-deep(*) {
+ color: var(--fgOnAccent) !important;
+ }
+ }
+ }
+ }
+
+ > footer {
+ text-align: right;
+
+ > .read {
+ user-select: none;
+ }
+ }
+ }
+ }
+
+ &.max-width_400px {
+ > .avatar {
+ width: 48px;
+ height: 48px;
+ }
+
+ > .content {
+ > .balloon {
+ > .content {
+ > .text {
+ font-size: 0.9em;
+ }
+ }
+ }
+ }
+ }
+
+ &.max-width_500px {
+ > .content {
+ > .balloon {
+ > .content {
+ > .text {
+ padding: 8px 16px;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue
new file mode 100644
index 0000000000..3a19b12762
--- /dev/null
+++ b/packages/client/src/pages/messaging/messaging-room.vue
@@ -0,0 +1,470 @@
+<template>
+<div class="_section"
+ @dragover.prevent.stop="onDragover"
+ @drop.prevent.stop="onDrop"
+>
+ <div class="_content mk-messaging-room">
+ <div class="body">
+ <MkLoading v-if="fetching"/>
+ <p class="empty" v-if="!fetching && messages.length == 0"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p>
+ <p class="no-history" v-if="!fetching && messages.length > 0 && !existMoreMessages"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p>
+ <button class="more _button" ref="loadMore" :class="{ fetching: fetchingMoreMessages }" v-show="existMoreMessages" @click="fetchMoreMessages" :disabled="fetchingMoreMessages">
+ <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }}
+ </button>
+ <XList class="messages" :items="messages" v-slot="{ item: message }" direction="up" reversed>
+ <XMessage :message="message" :is-group="group != null" :key="message.id"/>
+ </XList>
+ </div>
+ <footer>
+ <div class="typers" v-if="typers.length > 0">
+ <I18n :src="$ts.typingUsers" text-tag="span" class="users">
+ <template #users>
+ <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
+ </template>
+ </I18n>
+ <MkEllipsis/>
+ </div>
+ <transition name="fade">
+ <div class="new-message" v-show="showIndicator">
+ <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button>
+ </div>
+ </transition>
+ <XForm v-if="!fetching" :user="user" :group="group" ref="form" class="form"/>
+ </footer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw } from 'vue';
+import XList from '@/components/date-separated-list.vue';
+import XMessage from './messaging-room.message.vue';
+import XForm from './messaging-room.form.vue';
+import * as Acct from 'misskey-js/built/acct';
+import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import { popout } from '@/scripts/popout';
+import * as sound from '@/scripts/sound';
+import * as symbols from '@/symbols';
+
+const Component = defineComponent({
+ components: {
+ XMessage,
+ XForm,
+ XList,
+ },
+
+ inject: ['inWindow'],
+
+ props: {
+ userAcct: {
+ type: String,
+ required: false,
+ },
+ groupId: {
+ type: String,
+ required: false,
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? {
+ userName: this.user,
+ avatar: this.user,
+ action: {
+ icon: 'fas fa-ellipsis-h',
+ handler: this.menu,
+ },
+ } : {
+ title: this.group.name,
+ icon: 'fas fa-users',
+ action: {
+ icon: 'fas fa-ellipsis-h',
+ handler: this.menu,
+ },
+ } : null),
+ fetching: true,
+ user: null,
+ group: null,
+ fetchingMoreMessages: false,
+ messages: [],
+ existMoreMessages: false,
+ connection: null,
+ showIndicator: false,
+ timer: null,
+ typers: [],
+ ilObserver: new IntersectionObserver(
+ (entries) => entries.some((entry) => entry.isIntersecting)
+ && !this.fetching
+ && !this.fetchingMoreMessages
+ && this.existMoreMessages
+ && this.fetchMoreMessages()
+ ),
+ };
+ },
+
+ computed: {
+ form(): any {
+ return this.$refs.form;
+ }
+ },
+
+ watch: {
+ userAcct: 'fetch',
+ groupId: 'fetch',
+ },
+
+ mounted() {
+ this.fetch();
+ if (this.$store.state.enableInfiniteScroll) {
+ this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element));
+ }
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+
+ document.removeEventListener('visibilitychange', this.onVisibilitychange);
+
+ this.ilObserver.disconnect();
+ },
+
+ methods: {
+ async fetch() {
+ this.fetching = true;
+ if (this.userAcct) {
+ const user = await os.api('users/show', Acct.parse(this.userAcct));
+ this.user = user;
+ } else {
+ const group = await os.api('users/groups/show', { groupId: this.groupId });
+ this.group = group;
+ }
+
+ this.connection = markRaw(os.stream.useChannel('messaging', {
+ otherparty: this.user ? this.user.id : undefined,
+ group: this.group ? this.group.id : undefined,
+ }));
+
+ this.connection.on('message', this.onMessage);
+ this.connection.on('read', this.onRead);
+ this.connection.on('deleted', this.onDeleted);
+ this.connection.on('typers', typers => {
+ this.typers = typers.filter(u => u.id !== this.$i.id);
+ });
+
+ document.addEventListener('visibilitychange', this.onVisibilitychange);
+
+ this.fetchMessages().then(() => {
+ this.scrollToBottom();
+
+ // もっと見るの交差検知を発火させないためにfetchは
+ // スクロールが終わるまでfalseにしておく
+ // scrollendのようなイベントはないのでsetTimeoutで
+ setTimeout(() => this.fetching = false, 300);
+ });
+ },
+
+ onDragover(e) {
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+
+ if (isFile || isDriveFile) {
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ } else {
+ e.dataTransfer.dropEffect = 'none';
+ }
+ },
+
+ onDrop(e): void {
+ // ファイルだったら
+ if (e.dataTransfer.files.length == 1) {
+ this.form.upload(e.dataTransfer.files[0]);
+ return;
+ } else if (e.dataTransfer.files.length > 1) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.onlyOneFileCanBeAttached
+ });
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.form.file = file;
+ }
+ //#endregion
+ },
+
+ fetchMessages() {
+ return new Promise((resolve, reject) => {
+ const max = this.existMoreMessages ? 20 : 10;
+
+ os.api('messaging/messages', {
+ userId: this.user ? this.user.id : undefined,
+ groupId: this.group ? this.group.id : undefined,
+ limit: max + 1,
+ untilId: this.existMoreMessages ? this.messages[0].id : undefined
+ }).then(messages => {
+ if (messages.length == max + 1) {
+ this.existMoreMessages = true;
+ messages.pop();
+ } else {
+ this.existMoreMessages = false;
+ }
+
+ this.messages.unshift.apply(this.messages, messages.reverse());
+ resolve();
+ });
+ });
+ },
+
+ fetchMoreMessages() {
+ this.fetchingMoreMessages = true;
+ this.fetchMessages().then(() => {
+ this.fetchingMoreMessages = false;
+ });
+ },
+
+ onMessage(message) {
+ sound.play('chat');
+
+ const _isBottom = isBottom(this.$el, 64);
+
+ this.messages.push(message);
+ if (message.userId != this.$i.id && !document.hidden) {
+ this.connection.send('read', {
+ id: message.id
+ });
+ }
+
+ if (_isBottom) {
+ // Scroll to bottom
+ this.$nextTick(() => {
+ this.scrollToBottom();
+ });
+ } else if (message.userId != this.$i.id) {
+ // Notify
+ this.notifyNewMessage();
+ }
+ },
+
+ onRead(x) {
+ if (this.user) {
+ if (!Array.isArray(x)) x = [x];
+ for (const id of x) {
+ if (this.messages.some(x => x.id == id)) {
+ const exist = this.messages.map(x => x.id).indexOf(id);
+ this.messages[exist] = {
+ ...this.messages[exist],
+ isRead: true,
+ };
+ }
+ }
+ } else if (this.group) {
+ for (const id of x.ids) {
+ if (this.messages.some(x => x.id == id)) {
+ const exist = this.messages.map(x => x.id).indexOf(id);
+ this.messages[exist] = {
+ ...this.messages[exist],
+ reads: [...this.messages[exist].reads, x.userId]
+ };
+ }
+ }
+ }
+ },
+
+ onDeleted(id) {
+ const msg = this.messages.find(m => m.id === id);
+ if (msg) {
+ this.messages = this.messages.filter(m => m.id !== msg.id);
+ }
+ },
+
+ scrollToBottom() {
+ scroll(this.$el, { top: this.$el.offsetHeight });
+ },
+
+ onIndicatorClick() {
+ this.showIndicator = false;
+ this.scrollToBottom();
+ },
+
+ notifyNewMessage() {
+ this.showIndicator = true;
+
+ onScrollBottom(this.$el, () => {
+ this.showIndicator = false;
+ });
+
+ if (this.timer) clearTimeout(this.timer);
+
+ this.timer = setTimeout(() => {
+ this.showIndicator = false;
+ }, 4000);
+ },
+
+ onVisibilitychange() {
+ if (document.hidden) return;
+ for (const message of this.messages) {
+ if (message.userId !== this.$i.id && !message.isRead) {
+ this.connection.send('read', {
+ id: message.id
+ });
+ }
+ }
+ },
+
+ menu(ev) {
+ const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`;
+
+ os.popupMenu([this.inWindow ? undefined : {
+ text: this.$ts.openInWindow,
+ icon: 'fas fa-window-maximize',
+ action: () => {
+ os.pageWindow(path);
+ this.$router.back();
+ },
+ }, this.inWindow ? undefined : {
+ text: this.$ts.popout,
+ icon: 'fas fa-external-link-alt',
+ action: () => {
+ popout(path);
+ this.$router.back();
+ },
+ }], ev.currentTarget || ev.target);
+ }
+ }
+});
+
+export default Component;
+</script>
+
+<style lang="scss" scoped>
+.mk-messaging-room {
+ > .body {
+ > .empty {
+ width: 100%;
+ margin: 0;
+ padding: 16px 8px 8px 8px;
+ text-align: center;
+ font-size: 0.8em;
+ opacity: 0.5;
+
+ i {
+ margin-right: 4px;
+ }
+ }
+
+ > .no-history {
+ display: block;
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+ font-size: 0.8em;
+ color: var(--messagingRoomInfo);
+ opacity: 0.5;
+
+ i {
+ margin-right: 4px;
+ }
+ }
+
+ > .more {
+ display: block;
+ margin: 16px auto;
+ padding: 0 12px;
+ line-height: 24px;
+ color: #fff;
+ background: rgba(#000, 0.3);
+ border-radius: 12px;
+
+ &:hover {
+ background: rgba(#000, 0.4);
+ }
+
+ &:active {
+ background: rgba(#000, 0.5);
+ }
+
+ &.fetching {
+ cursor: wait;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+
+ > .messages {
+ > ::v-deep(*) {
+ margin-bottom: 16px;
+ }
+ }
+ }
+
+ > footer {
+ width: 100%;
+ position: relative;
+
+ > .new-message {
+ position: absolute;
+ top: -48px;
+ width: 100%;
+ padding: 8px 0;
+ text-align: center;
+
+ > button {
+ display: inline-block;
+ margin: 0;
+ padding: 0 12px 0 30px;
+ line-height: 32px;
+ font-size: 12px;
+ border-radius: 16px;
+
+ > i {
+ position: absolute;
+ top: 0;
+ left: 10px;
+ line-height: 32px;
+ font-size: 16px;
+ }
+ }
+ }
+
+ > .typers {
+ position: absolute;
+ bottom: 100%;
+ padding: 0 8px 0 8px;
+ font-size: 0.9em;
+ color: var(--fgTransparentWeak);
+
+ > .users {
+ > .user + .user:before {
+ content: ", ";
+ font-weight: normal;
+ }
+
+ > .user:last-of-type:after {
+ content: " ";
+ }
+ }
+ }
+
+ > .form {
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+}
+
+.fade-enter-active, .fade-leave-active {
+ transition: opacity 0.1s;
+}
+
+.fade-enter-from, .fade-leave-to {
+ transition: opacity 0.5s;
+ opacity: 0;
+}
+</style>
diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue
new file mode 100644
index 0000000000..e9a3b6debc
--- /dev/null
+++ b/packages/client/src/pages/mfm-cheat-sheet.vue
@@ -0,0 +1,365 @@
+<template>
+<div class="mwysmxbg">
+ <div class="_isolated">{{ $ts._mfm.intro }}</div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.mention }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.mentionDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_mention"/>
+ <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.hashtag }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.hashtagDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_hashtag"/>
+ <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.url }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.urlDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_url"/>
+ <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.link }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.linkDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_link"/>
+ <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.emoji }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.emojiDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_emoji"/>
+ <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.bold }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.boldDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_bold"/>
+ <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.small }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.smallDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_small"/>
+ <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.quote }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.quoteDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_quote"/>
+ <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.center }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.centerDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_center"/>
+ <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.inlineCode }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.inlineCodeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_inlineCode"/>
+ <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.blockCode }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.blockCodeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_blockCode"/>
+ <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.inlineMath }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.inlineMathDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_inlineMath"/>
+ <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.search }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.searchDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_search"/>
+ <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.flip }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.flipDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_flip"/>
+ <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.font }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.fontDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_font"/>
+ <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.x2 }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.x2Description }}</p>
+ <div class="preview">
+ <Mfm :text="preview_x2"/>
+ <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.x3 }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.x3Description }}</p>
+ <div class="preview">
+ <Mfm :text="preview_x3"/>
+ <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.x4 }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.x4Description }}</p>
+ <div class="preview">
+ <Mfm :text="preview_x4"/>
+ <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.blur }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.blurDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_blur"/>
+ <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.jelly }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.jellyDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_jelly"/>
+ <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.tada }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.tadaDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_tada"/>
+ <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.jump }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.jumpDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_jump"/>
+ <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.bounce }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.bounceDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_bounce"/>
+ <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.spin }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.spinDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_spin"/>
+ <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.shake }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.shakeDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_shake"/>
+ <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.twitch }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.twitchDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_twitch"/>
+ <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.rainbow }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.rainbowDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_rainbow"/>
+ <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea>
+ </div>
+ </div>
+ </div>
+ <div class="section _block">
+ <div class="title">{{ $ts._mfm.sparkle }}</div>
+ <div class="content">
+ <p>{{ $ts._mfm.sparkleDescription }}</p>
+ <div class="preview">
+ <Mfm :text="preview_sparkle"/>
+ <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkTextarea
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._mfm.cheatSheet,
+ icon: 'fas fa-question-circle',
+ },
+ preview_mention: '@example',
+ preview_hashtag: '#test',
+ preview_url: `https://example.com`,
+ preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`,
+ preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`,
+ preview_bold: `**${this.$ts._mfm.dummy}**`,
+ preview_small: `<small>${this.$ts._mfm.dummy}</small>`,
+ preview_center: `<center>${this.$ts._mfm.dummy}</center>`,
+ preview_inlineCode: '`<: "Hello, world!"`',
+ preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```',
+ preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)',
+ preview_quote: `> ${this.$ts._mfm.dummy}`,
+ preview_search: `${this.$ts._mfm.dummy} 検索`,
+ preview_jelly: `$[jelly 🍮]`,
+ preview_tada: `$[tada 🍮]`,
+ preview_jump: `$[jump 🍮]`,
+ preview_bounce: `$[bounce 🍮]`,
+ preview_shake: `$[shake 🍮]`,
+ preview_twitch: `$[twitch 🍮]`,
+ preview_spin: `$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]`,
+ preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`,
+ preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`,
+ preview_x2: `$[x2 🍮]`,
+ preview_x3: `$[x3 🍮]`,
+ preview_x4: `$[x4 🍮]`,
+ preview_blur: `$[blur ${this.$ts._mfm.dummy}]`,
+ preview_rainbow: `$[rainbow 🍮]`,
+ preview_sparkle: `$[sparkle 🍮]`,
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mwysmxbg {
+ background: var(--bg);
+
+ > .section {
+ > .title {
+ position: sticky;
+ z-index: 1;
+ top: var(--stickyTop, 0px);
+ padding: 16px;
+ font-weight: bold;
+ -webkit-backdrop-filter: var(--blur, blur(10px));
+ backdrop-filter: var(--blur, blur(10px));
+ background-color: var(--X16);
+ }
+
+ > .content {
+ > p {
+ margin: 0;
+ padding: 16px;
+ }
+
+ > .preview {
+ border-top: solid 0.5px var(--divider);
+ padding: 16px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue
new file mode 100644
index 0000000000..6430588c46
--- /dev/null
+++ b/packages/client/src/pages/miauth.vue
@@ -0,0 +1,100 @@
+<template>
+<div v-if="$i">
+ <div class="waiting _section" v-if="state == 'waiting'">
+ <div class="_content">
+ <MkLoading/>
+ </div>
+ </div>
+ <div class="denied _section" v-if="state == 'denied'">
+ <div class="_content">
+ <p>{{ $ts._auth.denied }}</p>
+ </div>
+ </div>
+ <div class="accepted _section" v-else-if="state == 'accepted'">
+ <div class="_content">
+ <p v-if="callback">{{ $ts._auth.callback }}<MkEllipsis/></p>
+ <p v-else>{{ $ts._auth.pleaseGoBack }}</p>
+ </div>
+ </div>
+ <div class="_section" v-else>
+ <div class="_title" v-if="name">{{ $t('_auth.shareAccess', { name: name }) }}</div>
+ <div class="_title" v-else>{{ $ts._auth.shareAccessAsk }}</div>
+ <div class="_content">
+ <p>{{ $ts._auth.permissionAsk }}</p>
+ <ul>
+ <li v-for="p in permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </ul>
+ </div>
+ <div class="_footer">
+ <MkButton @click="deny" inline>{{ $ts.cancel }}</MkButton>
+ <MkButton @click="accept" inline primary>{{ $ts.accept }}</MkButton>
+ </div>
+ </div>
+</div>
+<div class="signin" v-else>
+ <MkSignin @login="onLogin"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkSignin from '@/components/signin.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkSignin,
+ MkButton,
+ },
+ data() {
+ return {
+ state: null
+ };
+ },
+ computed: {
+ session(): string {
+ return this.$route.params.session;
+ },
+ callback(): string {
+ return this.$route.query.callback;
+ },
+ name(): string {
+ return this.$route.query.name;
+ },
+ icon(): string {
+ return this.$route.query.icon;
+ },
+ permission(): string[] {
+ return this.$route.query.permission ? this.$route.query.permission.split(',') : [];
+ },
+ },
+ methods: {
+ async accept() {
+ this.state = 'waiting';
+ await os.api('miauth/gen-token', {
+ session: this.session,
+ name: this.name,
+ iconUrl: this.icon,
+ permission: this.permission,
+ });
+
+ this.state = 'accepted';
+ if (this.callback) {
+ location.href = `${this.callback}?session=${this.session}`;
+ }
+ },
+ deny() {
+ this.state = 'denied';
+ },
+ onLogin(res) {
+ login(res.i);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue
new file mode 100644
index 0000000000..173807475a
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/create.vue
@@ -0,0 +1,51 @@
+<template>
+<div class="geegznzt">
+ <XAntenna :antenna="draft" @created="onAntennaCreated"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XAntenna from './editor.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XAntenna,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageAntennas,
+ icon: 'fas fa-satellite',
+ },
+ draft: {
+ name: '',
+ src: 'all',
+ userListId: null,
+ userGroupId: null,
+ users: [],
+ keywords: [],
+ excludeKeywords: [],
+ withReplies: false,
+ caseSensitive: false,
+ withFile: false,
+ notify: false
+ },
+ };
+ },
+
+ methods: {
+ onAntennaCreated() {
+ this.$router.push('/my/antennas');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue
new file mode 100644
index 0000000000..04928c81a3
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/edit.vue
@@ -0,0 +1,56 @@
+<template>
+<div class="">
+ <XAntenna v-if="antenna" :antenna="antenna" @updated="onAntennaUpdated"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XAntenna from './editor.vue';
+import * as symbols from '@/symbols';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XAntenna,
+ },
+
+ props: {
+ antennaId: {
+ type: String,
+ required: true,
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageAntennas,
+ icon: 'fas fa-satellite',
+ },
+ antenna: null,
+ };
+ },
+
+ watch: {
+ antennaId: {
+ async handler() {
+ this.antenna = await os.api('antennas/show', { antennaId: this.antennaId });
+ },
+ immediate: true,
+ }
+ },
+
+ methods: {
+ onAntennaUpdated() {
+ this.$router.push('/my/antennas');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue
new file mode 100644
index 0000000000..5ad3d50486
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/editor.vue
@@ -0,0 +1,190 @@
+<template>
+<div class="shaynizk">
+ <div class="form">
+ <MkInput v-model="name" class="_formBlock">
+ <template #label>{{ $ts.name }}</template>
+ </MkInput>
+ <MkSelect v-model="src" class="_formBlock">
+ <template #label>{{ $ts.antennaSource }}</template>
+ <option value="all">{{ $ts._antennaSources.all }}</option>
+ <option value="home">{{ $ts._antennaSources.homeTimeline }}</option>
+ <option value="users">{{ $ts._antennaSources.users }}</option>
+ <option value="list">{{ $ts._antennaSources.userList }}</option>
+ <option value="group">{{ $ts._antennaSources.userGroup }}</option>
+ </MkSelect>
+ <MkSelect v-model="userListId" v-if="src === 'list'" class="_formBlock">
+ <template #label>{{ $ts.userList }}</template>
+ <option v-for="list in userLists" :value="list.id" :key="list.id">{{ list.name }}</option>
+ </MkSelect>
+ <MkSelect v-model="userGroupId" v-else-if="src === 'group'" class="_formBlock">
+ <template #label>{{ $ts.userGroup }}</template>
+ <option v-for="group in userGroups" :value="group.id" :key="group.id">{{ group.name }}</option>
+ </MkSelect>
+ <MkTextarea v-model="users" v-else-if="src === 'users'" class="_formBlock">
+ <template #label>{{ $ts.users }}</template>
+ <template #caption>{{ $ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ $ts.addUser }}</button></template>
+ </MkTextarea>
+ <MkSwitch v-model="withReplies" class="_formBlock">{{ $ts.withReplies }}</MkSwitch>
+ <MkTextarea v-model="keywords" class="_formBlock">
+ <template #label>{{ $ts.antennaKeywords }}</template>
+ <template #caption>{{ $ts.antennaKeywordsDescription }}</template>
+ </MkTextarea>
+ <MkTextarea v-model="excludeKeywords" class="_formBlock">
+ <template #label>{{ $ts.antennaExcludeKeywords }}</template>
+ <template #caption>{{ $ts.antennaKeywordsDescription }}</template>
+ </MkTextarea>
+ <MkSwitch v-model="caseSensitive" class="_formBlock">{{ $ts.caseSensitive }}</MkSwitch>
+ <MkSwitch v-model="withFile" class="_formBlock">{{ $ts.withFileAntenna }}</MkSwitch>
+ <MkSwitch v-model="notify" class="_formBlock">{{ $ts.notifyAntenna }}</MkSwitch>
+ </div>
+ <div class="actions">
+ <MkButton inline @click="saveAntenna()" primary><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton inline @click="deleteAntenna()" v-if="antenna.id != null" danger><i class="fas fa-trash"></i> {{ $ts.delete }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ MkButton, MkInput, MkTextarea, MkSelect, MkSwitch
+ },
+
+ props: {
+ antenna: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ name: '',
+ src: '',
+ userListId: null,
+ userGroupId: null,
+ users: '',
+ keywords: '',
+ excludeKeywords: '',
+ caseSensitive: false,
+ withReplies: false,
+ withFile: false,
+ notify: false,
+ userLists: null,
+ userGroups: null,
+ };
+ },
+
+ watch: {
+ async src() {
+ if (this.src === 'list' && this.userLists === null) {
+ this.userLists = await os.api('users/lists/list');
+ }
+
+ if (this.src === 'group' && this.userGroups === null) {
+ const groups1 = await os.api('users/groups/owned');
+ const groups2 = await os.api('users/groups/joined');
+
+ this.userGroups = [...groups1, ...groups2];
+ }
+ }
+ },
+
+ created() {
+ this.name = this.antenna.name;
+ this.src = this.antenna.src;
+ this.userListId = this.antenna.userListId;
+ this.userGroupId = this.antenna.userGroupId;
+ this.users = this.antenna.users.join('\n');
+ this.keywords = this.antenna.keywords.map(x => x.join(' ')).join('\n');
+ this.excludeKeywords = this.antenna.excludeKeywords.map(x => x.join(' ')).join('\n');
+ this.caseSensitive = this.antenna.caseSensitive;
+ this.withReplies = this.antenna.withReplies;
+ this.withFile = this.antenna.withFile;
+ this.notify = this.antenna.notify;
+ },
+
+ methods: {
+ async saveAntenna() {
+ if (this.antenna.id == null) {
+ await os.apiWithDialog('antennas/create', {
+ name: this.name,
+ src: this.src,
+ userListId: this.userListId,
+ userGroupId: this.userGroupId,
+ withReplies: this.withReplies,
+ withFile: this.withFile,
+ notify: this.notify,
+ caseSensitive: this.caseSensitive,
+ users: this.users.trim().split('\n').map(x => x.trim()),
+ keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')),
+ excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.$emit('created');
+ } else {
+ await os.apiWithDialog('antennas/update', {
+ antennaId: this.antenna.id,
+ name: this.name,
+ src: this.src,
+ userListId: this.userListId,
+ userGroupId: this.userGroupId,
+ withReplies: this.withReplies,
+ withFile: this.withFile,
+ notify: this.notify,
+ caseSensitive: this.caseSensitive,
+ users: this.users.trim().split('\n').map(x => x.trim()),
+ keywords: this.keywords.trim().split('\n').map(x => x.trim().split(' ')),
+ excludeKeywords: this.excludeKeywords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.$emit('updated');
+ }
+ },
+
+ async deleteAntenna() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.antenna.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.api('antennas/delete', {
+ antennaId: this.antenna.id,
+ });
+
+ os.success();
+ this.$emit('deleted');
+ },
+
+ addUser() {
+ os.selectUser().then(user => {
+ this.users = this.users.trim();
+ this.users += '\n@' + Acct.toString(user);
+ this.users = this.users.trim();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.shaynizk {
+ > .form {
+ padding: 32px;
+ }
+
+ > .actions {
+ padding: 24px 32px;
+ border-top: solid 0.5px var(--divider);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue
new file mode 100644
index 0000000000..029f1949d7
--- /dev/null
+++ b/packages/client/src/pages/my-antennas/index.vue
@@ -0,0 +1,71 @@
+<template>
+<div class="ieepwinx _section">
+ <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+
+ <div class="_content">
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <MkA class="ljoevbzj" v-for="antenna in items" :key="antenna.id" :to="`/my/antennas/${antenna.id}`">
+ <div class="name">{{ antenna.name }}</div>
+ </MkA>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageAntennas,
+ icon: 'fas fa-satellite',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ }
+ },
+ pagination: {
+ endpoint: 'antennas/list',
+ limit: 10,
+ },
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ieepwinx {
+ padding: 16px;
+
+ > .add {
+ margin: 0 auto 16px auto;
+ }
+
+ .ljoevbzj {
+ display: block;
+ padding: 16px;
+ margin-bottom: 8px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue
new file mode 100644
index 0000000000..cbcdb85fa5
--- /dev/null
+++ b/packages/client/src/pages/my-clips/index.vue
@@ -0,0 +1,104 @@
+<template>
+<div class="_section qtcaoidl">
+ <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton>
+
+ <div class="_content">
+ <MkPagination :pagination="pagination" #default="{items}" ref="list" class="list">
+ <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+ <b>{{ item.name }}</b>
+ <div v-if="item.description" class="description">{{ item.description }}</div>
+ </MkA>
+ </MkPagination>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.clip,
+ icon: 'fas fa-paperclip',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ }
+ },
+ pagination: {
+ endpoint: 'clips/list',
+ limit: 10,
+ },
+ draft: null,
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ os.apiWithDialog('clips/create', result);
+ },
+
+ onClipCreated() {
+ this.$refs.list.reload();
+ this.draft = null;
+ },
+
+ onClipDeleted() {
+ this.$refs.list.reload();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qtcaoidl {
+ > .add {
+ margin: 0 auto 16px auto;
+ }
+
+ > ._content {
+ > .list {
+ > .item {
+ display: block;
+ padding: 16px;
+
+ > .description {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: solid 0.5px var(--divider);
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue
new file mode 100644
index 0000000000..9548c374d2
--- /dev/null
+++ b/packages/client/src/pages/my-groups/group.vue
@@ -0,0 +1,184 @@
+<template>
+<div class="mk-group-page">
+ <transition name="zoom" mode="out-in">
+ <div v-if="group" class="_section">
+ <div class="_content">
+ <MkButton inline @click="invite()">{{ $ts.invite }}</MkButton>
+ <MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton>
+ <MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton>
+ <MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ </transition>
+
+ <transition name="zoom" mode="out-in">
+ <div v-if="group" class="_section members _gap">
+ <div class="_title">{{ $ts.members }}</div>
+ <div class="_content">
+ <div class="users">
+ <div class="user _panel" v-for="user in users" :key="user.id">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="user" class="name"/>
+ <MkAcct :user="user" class="acct"/>
+ </div>
+ <div class="action">
+ <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ groupId: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.group ? {
+ title: this.group.name,
+ icon: 'fas fa-users',
+ } : null),
+ group: null,
+ users: [],
+ };
+ },
+
+ watch: {
+ groupId: 'fetch',
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ Progress.start();
+ os.api('users/groups/show', {
+ groupId: this.groupId
+ }).then(group => {
+ this.group = group;
+ os.api('users/show', {
+ userIds: this.group.userIds
+ }).then(users => {
+ this.users = users;
+ Progress.done();
+ });
+ });
+ },
+
+ invite() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/groups/invite', {
+ groupId: this.group.id,
+ userId: user.id
+ });
+ });
+ },
+
+ removeUser(user) {
+ os.api('users/groups/pull', {
+ groupId: this.group.id,
+ userId: user.id
+ }).then(() => {
+ this.users = this.users.filter(x => x.id !== user.id);
+ });
+ },
+
+ async renameGroup() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.groupName,
+ input: {
+ default: this.group.name
+ }
+ });
+ if (canceled) return;
+
+ await os.api('users/groups/update', {
+ groupId: this.group.id,
+ name: name
+ });
+
+ this.group.name = name;
+ },
+
+ transfer() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/groups/transfer', {
+ groupId: this.group.id,
+ userId: user.id
+ });
+ });
+ },
+
+ async deleteGroup() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.group.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('users/groups/delete', {
+ groupId: this.group.id
+ });
+ this.$router.push('/my/groups');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-group-page {
+ > .members {
+ > ._content {
+ > .users {
+ > .user {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ flex: 1;
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue
new file mode 100644
index 0000000000..77e7d6088e
--- /dev/null
+++ b/packages/client/src/pages/my-groups/index.vue
@@ -0,0 +1,121 @@
+<template>
+<div class="">
+ <div class="_section" style="padding: 0;">
+ <MkTab v-model="tab">
+ <option value="owned">{{ $ts.ownedGroups }}</option>
+ <option value="joined">{{ $ts.joinedGroups }}</option>
+ <option value="invites"><i class="fas fa-envelope-open-text"></i> {{ $ts.invites }}</option>
+ </MkTab>
+ </div>
+
+ <div class="_section">
+ <div class="_content" v-if="tab === 'owned'">
+ <MkButton @click="create" primary style="margin: 0 auto var(--margin) auto;"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton>
+
+ <MkPagination :pagination="ownedPagination" #default="{items}" ref="owned">
+ <div class="_card" v-for="group in items" :key="group.id">
+ <div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div>
+ <div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
+ </div>
+ </MkPagination>
+ </div>
+
+ <div class="_content" v-else-if="tab === 'joined'">
+ <MkPagination :pagination="joinedPagination" #default="{items}" ref="joined">
+ <div class="_card" v-for="group in items" :key="group.id">
+ <div class="_title">{{ group.name }}</div>
+ <div class="_content"><MkAvatars :user-ids="group.userIds"/></div>
+ </div>
+ </MkPagination>
+ </div>
+
+ <div class="_content" v-else-if="tab === 'invites'">
+ <MkPagination :pagination="invitationPagination" #default="{items}" ref="invitations">
+ <div class="_card" v-for="invitation in items" :key="invitation.id">
+ <div class="_title">{{ invitation.group.name }}</div>
+ <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div>
+ <div class="_footer">
+ <MkButton @click="acceptInvite(invitation)" primary inline><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton>
+ <MkButton @click="rejectInvite(invitation)" primary inline><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton>
+ </div>
+ </div>
+ </MkPagination>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkAvatars from '@/components/avatars.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ MkContainer,
+ MkTab,
+ MkAvatars,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.groups,
+ icon: 'fas fa-users'
+ },
+ tab: 'owned',
+ ownedPagination: {
+ endpoint: 'users/groups/owned',
+ limit: 10,
+ },
+ joinedPagination: {
+ endpoint: 'users/groups/joined',
+ limit: 10,
+ },
+ invitationPagination: {
+ endpoint: 'i/user-group-invites',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.groupName,
+ input: true
+ });
+ if (canceled) return;
+ await os.api('users/groups/create', { name: name });
+ this.$refs.owned.reload();
+ os.success();
+ },
+ acceptInvite(invitation) {
+ os.api('users/groups/invitations/accept', {
+ invitationId: invitation.id
+ }).then(() => {
+ os.success();
+ this.$refs.invitations.reload();
+ this.$refs.joined.reload();
+ });
+ },
+ rejectInvite(invitation) {
+ os.api('users/groups/invitations/reject', {
+ invitationId: invitation.id
+ }).then(() => {
+ this.$refs.invitations.reload();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue
new file mode 100644
index 0000000000..adb59db665
--- /dev/null
+++ b/packages/client/src/pages/my-lists/index.vue
@@ -0,0 +1,88 @@
+<template>
+<div class="qkcjvfiv">
+ <MkButton @click="create" primary class="add"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton>
+
+ <MkPagination :pagination="pagination" #default="{items}" class="lists _content" ref="list">
+ <MkA v-for="list in items" :key="list.id" class="list _panel" :to="`/my/lists/${ list.id }`">
+ <div class="name">{{ list.name }}</div>
+ <MkAvatars :user-ids="list.userIds"/>
+ </MkA>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkAvatars from '@/components/avatars.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ MkAvatars,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.manageLists,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+ action: {
+ icon: 'fas fa-plus',
+ handler: this.create
+ },
+ },
+ pagination: {
+ endpoint: 'users/lists/list',
+ limit: 10,
+ },
+ };
+ },
+
+ methods: {
+ async create() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.enterListName,
+ input: true
+ });
+ if (canceled) return;
+ await os.api('users/lists/create', { name: name });
+ this.$refs.list.reload();
+ os.success();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qkcjvfiv {
+ padding: 16px;
+
+ > .add {
+ margin: 0 auto var(--margin) auto;
+ }
+
+ > .lists {
+ > .list {
+ display: block;
+ padding: 16px;
+ border: solid 1px var(--divider);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 1px var(--accent);
+ text-decoration: none;
+ }
+
+ > .name {
+ margin-bottom: 4px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue
new file mode 100644
index 0000000000..f2a02cadc9
--- /dev/null
+++ b/packages/client/src/pages/my-lists/list.vue
@@ -0,0 +1,170 @@
+<template>
+<div class="mk-list-page">
+ <transition name="zoom" mode="out-in">
+ <div v-if="list" class="_section">
+ <div class="_content">
+ <MkButton inline @click="addUser()">{{ $ts.addUser }}</MkButton>
+ <MkButton inline @click="renameList()">{{ $ts.rename }}</MkButton>
+ <MkButton inline @click="deleteList()">{{ $ts.delete }}</MkButton>
+ </div>
+ </div>
+ </transition>
+
+ <transition name="zoom" mode="out-in">
+ <div v-if="list" class="_section members _gap">
+ <div class="_title">{{ $ts.members }}</div>
+ <div class="_content">
+ <div class="users">
+ <div class="user _panel" v-for="user in users" :key="user.id">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ <div class="body">
+ <MkUserName :user="user" class="name"/>
+ <MkAcct :user="user" class="acct"/>
+ </div>
+ <div class="action">
+ <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.list ? {
+ title: this.list.name,
+ icon: 'fas fa-list-ul',
+ } : null),
+ list: null,
+ users: [],
+ };
+ },
+
+ watch: {
+ $route: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ Progress.start();
+ os.api('users/lists/show', {
+ listId: this.$route.params.list
+ }).then(list => {
+ this.list = list;
+ os.api('users/show', {
+ userIds: this.list.userIds
+ }).then(users => {
+ this.users = users;
+ Progress.done();
+ });
+ });
+ },
+
+ addUser() {
+ os.selectUser().then(user => {
+ os.apiWithDialog('users/lists/push', {
+ listId: this.list.id,
+ userId: user.id
+ }).then(() => {
+ this.users.push(user);
+ });
+ });
+ },
+
+ removeUser(user) {
+ os.api('users/lists/pull', {
+ listId: this.list.id,
+ userId: user.id
+ }).then(() => {
+ this.users = this.users.filter(x => x.id !== user.id);
+ });
+ },
+
+ async renameList() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.enterListName,
+ input: {
+ default: this.list.name
+ }
+ });
+ if (canceled) return;
+
+ await os.api('users/lists/update', {
+ listId: this.list.id,
+ name: name
+ });
+
+ this.list.name = name;
+ },
+
+ async deleteList() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.list.name }),
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ await os.api('users/lists/delete', {
+ listId: this.list.id
+ });
+ os.success();
+ this.$router.push('/my/lists');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-list-page {
+ > .members {
+ > ._content {
+ > .users {
+ > .user {
+ display: flex;
+ align-items: center;
+ padding: 16px;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+
+ > .body {
+ flex: 1;
+ padding: 8px;
+
+ > .name {
+ display: block;
+ font-weight: bold;
+ }
+
+ > .acct {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue
new file mode 100644
index 0000000000..92d3f399f7
--- /dev/null
+++ b/packages/client/src/pages/not-found.vue
@@ -0,0 +1,25 @@
+<template>
+<div class="ipledcug">
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/>
+ <div>{{ $ts.notFoundDescription }}</div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.notFound,
+ icon: 'fas fa-exclamation-triangle'
+ },
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue
new file mode 100644
index 0000000000..ecd391dfbf
--- /dev/null
+++ b/packages/client/src/pages/note.vue
@@ -0,0 +1,209 @@
+<template>
+<MkSpacer :content-max="800">
+ <div class="fcuexfpr">
+ <transition name="fade" mode="out-in">
+ <div v-if="note" class="note">
+ <div class="_gap" v-if="showNext">
+ <XNotes class="_content" :pagination="next" :no-gap="true"/>
+ </div>
+
+ <div class="main _gap">
+ <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton>
+ <div class="note _gap">
+ <MkRemoteCaution v-if="note.user.host != null" :href="note.url || note.uri" class="_isolated"/>
+ <XNoteDetailed v-model:note="note" :key="note.id" class="_isolated note"/>
+ </div>
+ <div class="_content clips _gap" v-if="clips && clips.length > 0">
+ <div class="title">{{ $ts.clip }}</div>
+ <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+ <b>{{ item.name }}</b>
+ <div v-if="item.description" class="description">{{ item.description }}</div>
+ <div class="user">
+ <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/>
+ </div>
+ </MkA>
+ </div>
+ <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton>
+ </div>
+
+ <div class="_gap" v-if="showPrev">
+ <XNotes class="_content" :pagination="prev" :no-gap="true"/>
+ </div>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XNote from '@/components/note.vue';
+import XNoteDetailed from '@/components/note-detailed.vue';
+import XNotes from '@/components/notes.vue';
+import MkRemoteCaution from '@/components/remote-caution.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNote,
+ XNoteDetailed,
+ XNotes,
+ MkRemoteCaution,
+ MkButton,
+ },
+ props: {
+ noteId: {
+ type: String,
+ required: true
+ }
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.note ? {
+ title: this.$ts.note,
+ subtitle: new Date(this.note.createdAt).toLocaleString(),
+ avatar: this.note.user,
+ path: `/notes/${this.note.id}`,
+ share: {
+ title: this.$t('noteOf', { user: this.note.user.name }),
+ text: this.note.text,
+ },
+ bg: 'var(--bg)',
+ } : null),
+ note: null,
+ clips: null,
+ hasPrev: false,
+ hasNext: false,
+ showPrev: false,
+ showNext: false,
+ error: null,
+ prev: {
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.note.userId,
+ untilId: this.note.id,
+ })
+ },
+ next: {
+ reversed: true,
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.note.userId,
+ sinceId: this.note.id,
+ })
+ },
+ };
+ },
+ watch: {
+ noteId: 'fetch'
+ },
+ created() {
+ this.fetch();
+ },
+ methods: {
+ fetch() {
+ this.note = null;
+ os.api('notes/show', {
+ noteId: this.noteId
+ }).then(note => {
+ this.note = note;
+ Promise.all([
+ os.api('notes/clips', {
+ noteId: note.id,
+ }),
+ os.api('users/notes', {
+ userId: note.userId,
+ untilId: note.id,
+ limit: 1,
+ }),
+ os.api('users/notes', {
+ userId: note.userId,
+ sinceId: note.id,
+ limit: 1,
+ }),
+ ]).then(([clips, prev, next]) => {
+ this.clips = clips;
+ this.hasPrev = prev.length !== 0;
+ this.hasNext = next.length !== 0;
+ });
+ }).catch(e => {
+ this.error = e;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.fcuexfpr {
+ background: var(--bg);
+
+ > .note {
+ > .main {
+ > .load {
+ min-width: 0;
+ margin: 0 auto;
+ border-radius: 999px;
+
+ &.next {
+ margin-bottom: var(--margin);
+ }
+
+ &.prev {
+ margin-top: var(--margin);
+ }
+ }
+
+ > .note {
+ > .note {
+ border-radius: var(--radius);
+ background: var(--panel);
+ }
+ }
+
+ > .clips {
+ > .title {
+ font-weight: bold;
+ padding: 12px;
+ }
+
+ > .item {
+ display: block;
+ padding: 16px;
+
+ > .description {
+ padding: 8px 0;
+ }
+
+ > .user {
+ $height: 32px;
+ padding-top: 16px;
+ border-top: solid 0.5px var(--divider);
+ line-height: $height;
+
+ > .avatar {
+ width: $height;
+ height: $height;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue
new file mode 100644
index 0000000000..f8e610a719
--- /dev/null
+++ b/packages/client/src/pages/notifications.vue
@@ -0,0 +1,88 @@
+<template>
+<MkSpacer :content-max="800">
+ <div class="clupoqwt">
+ <XNotifications class="notifications" @before="before" @after="after" :include-types="includeTypes" :unread-only="tab === 'unread'"/>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotifications from '@/components/notifications.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { notificationTypes } from 'misskey-js';
+
+export default defineComponent({
+ components: {
+ XNotifications
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.notifications,
+ icon: 'fas fa-bell',
+ bg: 'var(--bg)',
+ actions: [{
+ text: this.$ts.filter,
+ icon: 'fas fa-filter',
+ highlighted: this.includeTypes != null,
+ handler: this.setFilter,
+ }, {
+ text: this.$ts.markAllAsRead,
+ icon: 'fas fa-check',
+ handler: () => {
+ os.apiWithDialog('notifications/mark-all-as-read');
+ },
+ }],
+ tabs: [{
+ active: this.tab === 'all',
+ title: this.$ts.all,
+ onClick: () => { this.tab = 'all'; },
+ }, {
+ active: this.tab === 'unread',
+ title: this.$ts.unread,
+ onClick: () => { this.tab = 'unread'; },
+ },]
+ })),
+ tab: 'all',
+ includeTypes: null,
+ };
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ setFilter(ev) {
+ const typeItems = notificationTypes.map(t => ({
+ text: this.$t(`_notification._types.${t}`),
+ active: this.includeTypes && this.includeTypes.includes(t),
+ action: () => {
+ this.includeTypes = [t];
+ }
+ }));
+ const items = this.includeTypes != null ? [{
+ icon: 'fas fa-times',
+ text: this.$ts.clear,
+ action: () => {
+ this.includeTypes = null;
+ }
+ }, null, ...typeItems] : typeItems;
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.clupoqwt {
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
new file mode 100644
index 0000000000..a25a892eaa
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue
@@ -0,0 +1,84 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.button }}</template>
+
+ <section class="xfhsjczc">
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._button.text }}</template></MkInput>
+ <MkSwitch v-model="value.primary"><span>{{ $ts._pages.blocks._button.colored }}</span></MkSwitch>
+ <MkSelect v-model="value.action">
+ <template #label>{{ $ts._pages.blocks._button.action }}</template>
+ <option value="dialog">{{ $ts._pages.blocks._button._action.dialog }}</option>
+ <option value="resetRandom">{{ $ts._pages.blocks._button._action.resetRandom }}</option>
+ <option value="pushEvent">{{ $ts._pages.blocks._button._action.pushEvent }}</option>
+ <option value="callAiScript">{{ $ts._pages.blocks._button._action.callAiScript }}</option>
+ </MkSelect>
+ <template v-if="value.action === 'dialog'">
+ <MkInput v-model="value.content"><template #label>{{ $ts._pages.blocks._button._action._dialog.content }}</template></MkInput>
+ </template>
+ <template v-else-if="value.action === 'pushEvent'">
+ <MkInput v-model="value.event"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.event }}</template></MkInput>
+ <MkInput v-model="value.message"><template #label>{{ $ts._pages.blocks._button._action._pushEvent.message }}</template></MkInput>
+ <MkSelect v-model="value.var">
+ <template #label>{{ $ts._pages.blocks._button._action._pushEvent.variable }}</template>
+ <option :value="null">{{ $t('_pages.blocks._button._action._pushEvent.no-variable') }}</option>
+ <option v-for="v in hpml.getVarsByType()" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$ts._pages.script.pageVariables">
+ <option v-for="v in hpml.getPageVarsByType()" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.enviromentVariables">
+ <option v-for="v in hpml.getEnvVarsByType()" :value="v">{{ v }}</option>
+ </optgroup>
+ </MkSelect>
+ </template>
+ <template v-else-if="value.action === 'callAiScript'">
+ <MkInput v-model="value.fn"><template #label>{{ $ts._pages.blocks._button._action._callAiScript.functionName }}</template></MkInput>
+ </template>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkSelect, MkInput, MkSwitch
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ if (this.value.action == null) this.value.action = 'dialog';
+ if (this.value.content == null) this.value.content = null;
+ if (this.value.event == null) this.value.event = null;
+ if (this.value.message == null) this.value.message = null;
+ if (this.value.primary == null) this.value.primary = false;
+ if (this.value.var == null) this.value.var = null;
+ if (this.value.fn == null) this.value.fn = null;
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.xfhsjczc {
+ padding: 0 16px 0 16px;
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
new file mode 100644
index 0000000000..5d009561e2
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue
@@ -0,0 +1,50 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-paint-brush"></i> {{ $ts._pages.blocks.canvas }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name">
+ <template #prefix><i class="fas fa-magic"></i></template>
+ <template #label>{{ $ts._pages.blocks._canvas.id }}</template>
+ </MkInput>
+ <MkInput v-model="value.width" type="number">
+ <template #label>{{ $ts._pages.blocks._canvas.width }}</template>
+ <template #suffix>px</template>
+ </MkInput>
+ <MkInput v-model="value.height" type="number">
+ <template #label>{{ $ts._pages.blocks._canvas.height }}</template>
+ <template #suffix>px</template>
+ </MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ if (this.value.width == null) this.value.width = 300;
+ if (this.value.height == null) this.value.height = 200;
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
new file mode 100644
index 0000000000..3704c64250
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue
@@ -0,0 +1,46 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.counter }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name">
+ <template #prefix><i class="fas fa-magic"></i></template>
+ <template #label>{{ $ts._pages.blocks._counter.name }}</template>
+ </MkInput>
+ <MkInput v-model="value.text">
+ <template #label>{{ $ts._pages.blocks._counter.text }}</template>
+ </MkInput>
+ <MkInput v-model="value.inc" type="number">
+ <template #label>{{ $ts._pages.blocks._counter.inc }}</template>
+ </MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
new file mode 100644
index 0000000000..f76d59abe3
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue
@@ -0,0 +1,84 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-question"></i> {{ $ts._pages.blocks.if }}</template>
+ <template #func>
+ <button @click="add()" class="_button">
+ <i class="fas fa-plus"></i>
+ </button>
+ </template>
+
+ <section class="romcojzs">
+ <MkSelect v-model="value.var">
+ <template #label>{{ $ts._pages.blocks._if.variable }}</template>
+ <option v-for="v in hpml.getVarsByType('boolean')" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$ts._pages.script.pageVariables">
+ <option v-for="v in hpml.getPageVarsByType('boolean')" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.enviromentVariables">
+ <option v-for="v in hpml.getEnvVarsByType('boolean')" :value="v">{{ v }}</option>
+ </optgroup>
+ </MkSelect>
+
+ <XBlocks class="children" v-model="value.children" :hpml="hpml"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from '../page-editor.container.vue';
+import MkSelect from '@/components/form/select.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkSelect,
+ XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')),
+ },
+
+ inject: ['getPageBlockList'],
+
+ props: {
+ value: {
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.children == null) this.value.children = [];
+ if (this.value.var === undefined) this.value.var = null;
+ },
+
+ methods: {
+ async add() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.chooseBlock,
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.value.children.push({ id, type });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.romcojzs {
+ padding: 0 16px 16px 16px;
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
new file mode 100644
index 0000000000..396c83f512
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue
@@ -0,0 +1,72 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-image"></i> {{ $ts._pages.blocks.image }}</template>
+ <template #func>
+ <button @click="choose()">
+ <i class="fas fa-folder-open"></i>
+ </button>
+ </template>
+
+ <section class="oyyftmcf">
+ <MkDriveFileThumbnail class="preview" v-if="file" :file="file" fit="contain" @click="choose()"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkDriveFileThumbnail
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ file: null,
+ };
+ },
+
+ created() {
+ if (this.value.fileId === undefined) this.value.fileId = null;
+ },
+
+ mounted() {
+ if (this.value.fileId == null) {
+ this.choose();
+ } else {
+ os.api('drive/files/show', {
+ fileId: this.value.fileId
+ }).then(file => {
+ this.file = file;
+ });
+ }
+ },
+
+ methods: {
+ async choose() {
+ os.selectDriveFile(false).then(file => {
+ this.file = file;
+ this.value.fileId = file.id;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.oyyftmcf {
+ > .preview {
+ height: 150px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
new file mode 100644
index 0000000000..263b60d3e0
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue
@@ -0,0 +1,65 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-sticky-note"></i> {{ $ts._pages.blocks.note }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="id">
+ <template #label>{{ $ts._pages.blocks._note.id }}</template>
+ <template #caption>{{ $ts._pages.blocks._note.idDescription }}</template>
+ </MkInput>
+ <MkSwitch v-model="value.detailed"><span>{{ $ts._pages.blocks._note.detailed }}</span></MkSwitch>
+
+ <XNote v-if="note && !value.detailed" v-model:note="note" :key="note.id + ':normal'" style="margin-bottom: 16px;"/>
+ <XNoteDetailed v-if="note && value.detailed" v-model:note="note" :key="note.id + ':detail'" style="margin-bottom: 16px;"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import XNote from '@/components/note.vue';
+import XNoteDetailed from '@/components/note-detailed.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput, MkSwitch, XNote, XNoteDetailed,
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ id: this.value.note,
+ note: null,
+ };
+ },
+
+ watch: {
+ id: {
+ async handler() {
+ if (this.id && (this.id.startsWith('http://') || this.id.startsWith('https://'))) {
+ this.value.note = this.id.endsWith('/') ? this.id.substr(0, this.id.length - 1).split('/').pop() : this.id.split('/').pop();
+ } else {
+ this.value.note = this.id;
+ }
+
+ this.note = await os.api('notes/show', { noteId: this.value.note });
+ },
+ immediate: true
+ },
+ },
+
+ created() {
+ if (this.value.note == null) this.value.note = null;
+ if (this.value.detailed == null) this.value.detailed = false;
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
new file mode 100644
index 0000000000..3a2f4a762b
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue
@@ -0,0 +1,46 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.numberInput }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name">
+ <template #prefix><i class="fas fa-magic"></i></template>
+ <template #label>{{ $ts._pages.blocks._numberInput.name }}</template>
+ </MkInput>
+ <MkInput v-model="value.text">
+ <template #label>{{ $ts._pages.blocks._numberInput.text }}</template>
+ </MkInput>
+ <MkInput v-model="value.default" type="number">
+ <template #label>{{ $ts._pages.blocks._numberInput.default }}</template>
+ </MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
new file mode 100644
index 0000000000..780786144e
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue
@@ -0,0 +1,43 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-paper-plane"></i> {{ $ts._pages.blocks.post }}</template>
+
+ <section style="padding: 16px;">
+ <MkTextarea v-model="value.text"><template #label>{{ $ts._pages.blocks._post.text }}</template></MkTextarea>
+ <MkSwitch v-model="value.attachCanvasImage"><span>{{ $ts._pages.blocks._post.attachCanvasImage }}</span></MkSwitch>
+ <MkInput v-if="value.attachCanvasImage" v-model="value.canvasId"><template #label>{{ $ts._pages.blocks._post.canvasId }}</template></MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea, MkInput, MkSwitch
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false;
+ if (this.value.canvasId == null) this.value.canvasId = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
new file mode 100644
index 0000000000..f01a47c54a
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue
@@ -0,0 +1,50 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.radioButton }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._radioButton.name }}</template></MkInput>
+ <MkInput v-model="value.title"><template #label>{{ $ts._pages.blocks._radioButton.title }}</template></MkInput>
+ <MkTextarea v-model="values"><template #label>{{ $ts._pages.blocks._radioButton.values }}</template></MkTextarea>
+ <MkInput v-model="value.default"><template #label>{{ $ts._pages.blocks._radioButton.default }}</template></MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea, MkInput
+ },
+ props: {
+ value: {
+ required: true
+ },
+ },
+ data() {
+ return {
+ values: '',
+ };
+ },
+ watch: {
+ values: {
+ handler() {
+ this.value.values = this.values.split('\n');
+ },
+ deep: true
+ }
+ },
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ if (this.value.title == null) this.value.title = '';
+ if (this.value.values == null) this.value.values = [];
+ this.values = this.value.values.join('\n');
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
new file mode 100644
index 0000000000..16e32d8400
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue
@@ -0,0 +1,96 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-sticky-note"></i> {{ value.title }}</template>
+ <template #func>
+ <button @click="rename()" class="_button">
+ <i class="fas fa-pencil-alt"></i>
+ </button>
+ <button @click="add()" class="_button">
+ <i class="fas fa-plus"></i>
+ </button>
+ </template>
+
+ <section class="ilrvjyvi">
+ <XBlocks class="children" v-model="value.children" :hpml="hpml"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer,
+ XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')),
+ },
+
+ inject: ['getPageBlockList'],
+
+ props: {
+ value: {
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.title == null) this.value.title = null;
+ if (this.value.children == null) this.value.children = [];
+ },
+
+ mounted() {
+ if (this.value.title == null) {
+ this.rename();
+ }
+ },
+
+ methods: {
+ async rename() {
+ const { canceled, result: title } = await os.dialog({
+ title: 'Enter title',
+ input: {
+ type: 'text',
+ default: this.value.title
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.value.title = title;
+ },
+
+ async add() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.chooseBlock,
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.value.children.push({ id, type });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ilrvjyvi {
+ > .children {
+ padding: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
new file mode 100644
index 0000000000..e72f7b44d0
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue
@@ -0,0 +1,46 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.switch }}</template>
+
+ <section class="kjuadyyj">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._switch.name }}</template></MkInput>
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._switch.text }}</template></MkInput>
+ <MkSwitch v-model="value.default"><span>{{ $ts._pages.blocks._switch.default }}</span></MkSwitch>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkSwitch, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.kjuadyyj {
+ padding: 0 16px 16px 16px;
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
new file mode 100644
index 0000000000..908862cf07
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue
@@ -0,0 +1,39 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textInput }}</template>
+
+ <section style="padding: 0 16px 0 16px;">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textInput.name }}</template></MkInput>
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textInput.text }}</template></MkInput>
+ <MkInput v-model="value.default" type="text"><template #label>{{ $ts._pages.blocks._textInput.default }}</template></MkInput>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
new file mode 100644
index 0000000000..05b1a9c67d
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue
@@ -0,0 +1,57 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.text }}</template>
+
+ <section class="vckmsadr">
+ <textarea v-model="value.text"></textarea>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vckmsadr {
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ box-sizing: border-box;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
new file mode 100644
index 0000000000..bb37158ecb
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue
@@ -0,0 +1,40 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-bolt"></i> {{ $ts._pages.blocks.textareaInput }}</template>
+
+ <section style="padding: 0 16px 16px 16px;">
+ <MkInput v-model="value.name"><template #prefix><i class="fas fa-magic"></i></template><template #label>{{ $ts._pages.blocks._textareaInput.name }}</template></MkInput>
+ <MkInput v-model="value.text"><template #label>{{ $ts._pages.blocks._textareaInput.text }}</template></MkInput>
+ <MkTextarea v-model="value.default"><template #label>{{ $ts._pages.blocks._textareaInput.default }}</template></MkTextarea>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkInput from '@/components/form/input.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea, MkInput
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.name == null) this.value.name = '';
+ },
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
new file mode 100644
index 0000000000..4ca83da17c
--- /dev/null
+++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue
@@ -0,0 +1,57 @@
+<template>
+<XContainer @remove="() => $emit('remove')" :draggable="true">
+ <template #header><i class="fas fa-align-left"></i> {{ $ts._pages.blocks.textarea }}</template>
+
+ <section class="ihymsbbe">
+ <textarea v-model="value.text"></textarea>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XContainer from '../page-editor.container.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XContainer
+ },
+
+ props: {
+ value: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ created() {
+ if (this.value.text == null) this.value.text = '';
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ihymsbbe {
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ min-width: 100%;
+ min-height: 150px;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ background: transparent;
+ color: var(--fg);
+ font-size: 14px;
+ box-sizing: border-box;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.blocks.vue b/packages/client/src/pages/page-editor/page-editor.blocks.vue
new file mode 100644
index 0000000000..b91d9abae8
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.blocks.vue
@@ -0,0 +1,78 @@
+<template>
+<XDraggable tag="div" v-model="blocks" item-key="id" handle=".drag-handle" :group="{ name: 'blocks' }" animation="150" swap-threshold="0.5">
+ <template #item="{element}">
+ <component :is="'x-' + element.type" :value="element" @update:value="updateItem" @remove="() => removeItem(element)" :hpml="hpml"/>
+ </template>
+</XDraggable>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XSection from './els/page-editor.el.section.vue';
+import XText from './els/page-editor.el.text.vue';
+import XTextarea from './els/page-editor.el.textarea.vue';
+import XImage from './els/page-editor.el.image.vue';
+import XButton from './els/page-editor.el.button.vue';
+import XTextInput from './els/page-editor.el.text-input.vue';
+import XTextareaInput from './els/page-editor.el.textarea-input.vue';
+import XNumberInput from './els/page-editor.el.number-input.vue';
+import XSwitch from './els/page-editor.el.switch.vue';
+import XIf from './els/page-editor.el.if.vue';
+import XPost from './els/page-editor.el.post.vue';
+import XCounter from './els/page-editor.el.counter.vue';
+import XRadioButton from './els/page-editor.el.radio-button.vue';
+import XCanvas from './els/page-editor.el.canvas.vue';
+import XNote from './els/page-editor.el.note.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton, XCanvas, XNote
+ },
+
+ props: {
+ modelValue: {
+ type: Array,
+ required: true
+ },
+ hpml: {
+ required: true,
+ },
+ },
+
+ emits: ['update:modelValue'],
+
+ computed: {
+ blocks: {
+ get() {
+ return this.modelValue;
+ },
+ set(value) {
+ this.$emit('update:modelValue', value);
+ }
+ }
+ },
+
+ methods: {
+ updateItem(v) {
+ const i = this.blocks.findIndex(x => x.id === v.id);
+ const newValue = [
+ ...this.blocks.slice(0, i),
+ v,
+ ...this.blocks.slice(i + 1)
+ ];
+ this.$emit('update:modelValue', newValue);
+ },
+
+ removeItem(el) {
+ const i = this.blocks.findIndex(x => x.id === el.id);
+ const newValue = [
+ ...this.blocks.slice(0, i),
+ ...this.blocks.slice(i + 1)
+ ];
+ this.$emit('update:modelValue', newValue);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/page-editor/page-editor.container.vue b/packages/client/src/pages/page-editor/page-editor.container.vue
new file mode 100644
index 0000000000..afd261fac7
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.container.vue
@@ -0,0 +1,159 @@
+<template>
+<div class="cpjygsrt" :class="{ error: error != null, warn: warn != null }">
+ <header>
+ <div class="title"><slot name="header"></slot></div>
+ <div class="buttons">
+ <slot name="func"></slot>
+ <button v-if="removable" @click="remove()" class="_button">
+ <i class="fas fa-trash-alt"></i>
+ </button>
+ <button v-if="draggable" class="drag-handle _button">
+ <i class="fas fa-bars"></i>
+ </button>
+ <button @click="toggleContent(!showBody)" class="_button">
+ <template v-if="showBody"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ </div>
+ </header>
+ <p v-show="showBody" class="error" v-if="error != null">{{ $t('_pages.script.typeError', { slot: error.arg + 1, expect: $t(`script.types.${error.expect}`), actual: $t(`script.types.${error.actual}`) }) }}</p>
+ <p v-show="showBody" class="warn" v-if="warn != null">{{ $t('_pages.script.thereIsEmptySlot', { slot: warn.slot + 1 }) }}</p>
+ <div v-show="showBody" class="body">
+ <slot></slot>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ expanded: {
+ type: Boolean,
+ default: true
+ },
+ removable: {
+ type: Boolean,
+ default: true
+ },
+ draggable: {
+ type: Boolean,
+ default: false
+ },
+ error: {
+ required: false,
+ default: null
+ },
+ warn: {
+ required: false,
+ default: null
+ }
+ },
+ emits: ['toggle', 'remove'],
+ data() {
+ return {
+ showBody: this.expanded,
+ };
+ },
+ methods: {
+ toggleContent(show: boolean) {
+ this.showBody = show;
+ this.$emit('toggle', show);
+ },
+ remove() {
+ this.$emit('remove');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cpjygsrt {
+ position: relative;
+ overflow: hidden;
+ background: var(--panel);
+ border: solid 2px var(--X12);
+ border-radius: 6px;
+
+ &:hover {
+ border: solid 2px var(--X13);
+ }
+
+ &.warn {
+ border: solid 2px #dec44c;
+ }
+
+ &.error {
+ border: solid 2px #f00;
+ }
+
+ & + .cpjygsrt {
+ margin-top: 16px;
+ }
+
+ > header {
+ > .title {
+ z-index: 1;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 42px;
+ font-size: 0.9em;
+ font-weight: bold;
+ box-shadow: 0 1px rgba(#000, 0.07);
+
+ > i {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+
+ > button {
+ padding: 0;
+ width: 42px;
+ font-size: 0.9em;
+ line-height: 42px;
+ }
+
+ .drag-handle {
+ cursor: move;
+ }
+ }
+ }
+
+ > .warn {
+ color: #b19e49;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ font-size: 14px;
+ }
+
+ > .error {
+ color: #f00;
+ margin: 0;
+ padding: 16px 16px 0 16px;
+ font-size: 14px;
+ }
+
+ > .body {
+ ::v-deep(.juejbjww), ::v-deep(.eiipwacr) {
+ &:not(.inline):first-child {
+ margin-top: 28px;
+ }
+
+ &:not(.inline):last-child {
+ margin-bottom: 20px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.script-block.vue b/packages/client/src/pages/page-editor/page-editor.script-block.vue
new file mode 100644
index 0000000000..07958c902b
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.script-block.vue
@@ -0,0 +1,281 @@
+<template>
+<XContainer :removable="removable" @remove="() => $emit('remove')" :error="error" :warn="warn" :draggable="draggable">
+ <template #header><i v-if="icon" :class="icon"></i> <template v-if="title">{{ title }} <span class="turmquns" v-if="typeText">({{ typeText }})</span></template><template v-else-if="typeText">{{ typeText }}</template></template>
+ <template #func>
+ <button @click="changeType()" class="_button">
+ <i class="fas fa-pencil-alt"></i>
+ </button>
+ </template>
+
+ <section v-if="modelValue.type === null" class="pbglfege" @click="changeType()">
+ {{ $ts._pages.script.emptySlot }}
+ </section>
+ <section v-else-if="modelValue.type === 'text'" class="tbwccoaw">
+ <input v-model="modelValue.value"/>
+ </section>
+ <section v-else-if="modelValue.type === 'multiLineText'" class="tbwccoaw">
+ <textarea v-model="modelValue.value"></textarea>
+ </section>
+ <section v-else-if="modelValue.type === 'textList'" class="tbwccoaw">
+ <textarea v-model="modelValue.value" :placeholder="$ts._pages.script.blocks._textList.info"></textarea>
+ </section>
+ <section v-else-if="modelValue.type === 'number'" class="tbwccoaw">
+ <input v-model="modelValue.value" type="number"/>
+ </section>
+ <section v-else-if="modelValue.type === 'ref'" class="hpdwcrvs">
+ <select v-model="modelValue.value">
+ <option v-for="v in hpml.getVarsByType(getExpectedType ? getExpectedType() : null).filter(x => x.name !== name)" :value="v.name">{{ v.name }}</option>
+ <optgroup :label="$ts._pages.script.argVariables">
+ <option v-for="v in fnSlots" :value="v.name">{{ v.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.pageVariables">
+ <option v-for="v in hpml.getPageVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
+ </optgroup>
+ <optgroup :label="$ts._pages.script.enviromentVariables">
+ <option v-for="v in hpml.getEnvVarsByType(getExpectedType ? getExpectedType() : null)" :value="v">{{ v }}</option>
+ </optgroup>
+ </select>
+ </section>
+ <section v-else-if="modelValue.type === 'aiScriptVar'" class="tbwccoaw">
+ <input v-model="modelValue.value"/>
+ </section>
+ <section v-else-if="modelValue.type === 'fn'" class="" style="padding:0 16px 16px 16px;">
+ <MkTextarea v-model="slots">
+ <template #label>{{ $ts._pages.script.blocks._fn.slots }}</template>
+ <template #caption>{{ $t('_pages.script.blocks._fn.slots-info') }}</template>
+ </MkTextarea>
+ <XV v-if="modelValue.value.expression" v-model="modelValue.value.expression" :title="$t(`_pages.script.blocks._fn.arg1`)" :get-expected-type="() => null" :hpml="hpml" :fn-slots="value.value.slots" :name="name"/>
+ </section>
+ <section v-else-if="modelValue.type.startsWith('fn:')" class="" style="padding:16px;">
+ <XV v-for="(x, i) in modelValue.args" v-model="value.args[i]" :title="hpml.getVarByName(modelValue.type.split(':')[1]).value.slots[i].name" :get-expected-type="() => null" :hpml="hpml" :name="name" :key="i"/>
+ </section>
+ <section v-else class="" style="padding:16px;">
+ <XV v-for="(x, i) in modelValue.args" v-model="modelValue.args[i]" :title="$t(`_pages.script.blocks._${modelValue.type}.arg${i + 1}`)" :get-expected-type="() => _getExpectedType(i)" :hpml="hpml" :name="name" :fn-slots="fnSlots" :key="i"/>
+ </section>
+</XContainer>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import XContainer from './page-editor.container.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import { blockDefs } from '@/scripts/hpml/index';
+import * as os from '@/os';
+import { isLiteralValue } from '@/scripts/hpml/expr';
+import { funcDefs } from '@/scripts/hpml/lib';
+
+export default defineComponent({
+ components: {
+ XContainer, MkTextarea,
+ XV: defineAsyncComponent(() => import('./page-editor.script-block.vue')),
+ },
+
+ inject: ['getScriptBlockList'],
+
+ props: {
+ getExpectedType: {
+ required: false,
+ default: null
+ },
+ modelValue: {
+ required: true
+ },
+ title: {
+ required: false
+ },
+ removable: {
+ required: false,
+ default: false
+ },
+ hpml: {
+ required: true,
+ },
+ name: {
+ required: true,
+ },
+ fnSlots: {
+ required: false,
+ },
+ draggable: {
+ required: false,
+ default: false
+ }
+ },
+
+ data() {
+ return {
+ error: null,
+ warn: null,
+ slots: '',
+ };
+ },
+
+ computed: {
+ icon(): any {
+ if (this.modelValue.type === null) return null;
+ if (this.modelValue.type.startsWith('fn:')) return 'fas fa-plug';
+ return blockDefs.find(x => x.type === this.modelValue.type).icon;
+ },
+ typeText(): any {
+ if (this.modelValue.type === null) return null;
+ if (this.modelValue.type.startsWith('fn:')) return this.modelValue.type.split(':')[1];
+ return this.$t(`_pages.script.blocks.${this.modelValue.type}`);
+ },
+ },
+
+ watch: {
+ slots: {
+ handler() {
+ this.modelValue.value.slots = this.slots.split('\n').map(x => ({
+ name: x,
+ type: null
+ }));
+ },
+ deep: true
+ }
+ },
+
+ created() {
+ if (this.modelValue.value == null) this.modelValue.value = null;
+
+ if (this.modelValue.value && this.modelValue.value.slots) this.slots = this.modelValue.value.slots.map(x => x.name).join('\n');
+
+ this.$watch(() => this.modelValue.type, (t) => {
+ this.warn = null;
+
+ if (this.modelValue.type === 'fn') {
+ const id = uuid();
+ this.modelValue.value = {
+ slots: [],
+ expression: { id, type: null }
+ };
+ return;
+ }
+
+ if (this.modelValue.type && this.modelValue.type.startsWith('fn:')) {
+ const fnName = this.modelValue.type.split(':')[1];
+ const fn = this.hpml.getVarByName(fnName);
+
+ const empties = [];
+ for (let i = 0; i < fn.value.slots.length; i++) {
+ const id = uuid();
+ empties.push({ id, type: null });
+ }
+ this.modelValue.args = empties;
+ return;
+ }
+
+ if (isLiteralValue(this.modelValue)) return;
+
+ const empties = [];
+ for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) {
+ const id = uuid();
+ empties.push({ id, type: null });
+ }
+ this.modelValue.args = empties;
+
+ for (let i = 0; i < funcDefs[this.modelValue.type].in.length; i++) {
+ const inType = funcDefs[this.modelValue.type].in[i];
+ if (typeof inType !== 'number') {
+ if (inType === 'number') this.modelValue.args[i].type = 'number';
+ if (inType === 'string') this.modelValue.args[i].type = 'text';
+ }
+ }
+ });
+
+ this.$watch(() => this.modelValue.args, (args) => {
+ if (args == null) {
+ this.warn = null;
+ return;
+ }
+ const emptySlotIndex = args.findIndex(x => x.type === null);
+ if (emptySlotIndex !== -1 && emptySlotIndex < args.length) {
+ this.warn = {
+ slot: emptySlotIndex
+ };
+ } else {
+ this.warn = null;
+ }
+ }, {
+ deep: true
+ });
+
+ this.$watch(() => this.hpml.variables, () => {
+ if (this.type != null && this.modelValue) {
+ this.error = this.hpml.typeCheck(this.modelValue);
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ async changeType() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.selectType,
+ select: {
+ groupedItems: this.getScriptBlockList(this.getExpectedType ? this.getExpectedType() : null)
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.modelValue.type = type;
+ },
+
+ _getExpectedType(slot: number) {
+ return this.hpml.getExpectedType(this.modelValue, slot);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.turmquns {
+ opacity: 0.7;
+}
+
+.pbglfege {
+ opacity: 0.5;
+ padding: 16px;
+ text-align: center;
+ cursor: pointer;
+ color: var(--fg);
+}
+
+.tbwccoaw {
+ > input,
+ > textarea {
+ display: block;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ border: none;
+ box-shadow: none;
+ padding: 16px;
+ font-size: 16px;
+ background: transparent;
+ color: var(--fg);
+ box-sizing: border-box;
+ }
+
+ > textarea {
+ min-height: 100px;
+ }
+}
+
+.hpdwcrvs {
+ padding: 16px;
+
+ > select {
+ display: block;
+ padding: 4px;
+ font-size: 16px;
+ width: 100%;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue
new file mode 100644
index 0000000000..684b1f8c75
--- /dev/null
+++ b/packages/client/src/pages/page-editor/page-editor.vue
@@ -0,0 +1,561 @@
+<template>
+<div>
+ <div class="jqqmcavi" style="margin: 16px;">
+ <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton>
+ <MkButton inline @click="save" primary class="button" v-if="!readonly"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton inline @click="duplicate" class="button" v-if="pageId"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton>
+ <MkButton inline @click="del" class="button" v-if="pageId && !readonly" danger><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton>
+ </div>
+
+ <div v-if="tab === 'settings'">
+ <div style="padding: 16px;" class="_formRoot">
+ <MkInput v-model="title" class="_formBlock">
+ <template #label>{{ $ts._pages.title }}</template>
+ </MkInput>
+
+ <MkInput v-model="summary" class="_formBlock">
+ <template #label>{{ $ts._pages.summary }}</template>
+ </MkInput>
+
+ <MkInput v-model="name" class="_formBlock">
+ <template #prefix>{{ url }}/@{{ author.username }}/pages/</template>
+ <template #label>{{ $ts._pages.url }}</template>
+ </MkInput>
+
+ <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch>
+
+ <MkSelect v-model="font" class="_formBlock">
+ <template #label>{{ $ts._pages.font }}</template>
+ <option value="serif">{{ $ts._pages.fontSerif }}</option>
+ <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option>
+ </MkSelect>
+
+ <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch>
+
+ <div class="eyeCatch">
+ <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton>
+ <div v-else-if="eyeCatchingImage">
+ <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/>
+ <MkButton @click="removeEyeCatchingImage()" v-if="!readonly"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'contents'">
+ <div style="padding: 16px;">
+ <XBlocks class="content" v-model="content" :hpml="hpml"/>
+
+ <MkButton @click="add()" v-if="!readonly"><i class="fas fa-plus"></i></MkButton>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'variables'">
+ <div class="qmuvgica">
+ <XDraggable tag="div" class="variables" v-show="variables.length > 0" v-model="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5">
+ <template #item="{element}">
+ <XVariable
+ :modelValue="element"
+ :removable="true"
+ @remove="() => removeVariable(element)"
+ :hpml="hpml"
+ :name="element.name"
+ :title="element.name"
+ :draggable="true"
+ />
+ </template>
+ </XDraggable>
+
+ <MkButton @click="addVariable()" class="add" v-if="!readonly"><i class="fas fa-plus"></i></MkButton>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'script'">
+ <div>
+ <MkTextarea class="_code" v-model="script"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import 'prismjs';
+import { highlight, languages } from 'prismjs/components/prism-core';
+import 'prismjs/components/prism-clike';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/themes/prism-okaidia.css';
+import 'vue-prism-editor/dist/prismeditor.min.css';
+import { v4 as uuid } from 'uuid';
+import XVariable from './page-editor.script-block.vue';
+import XBlocks from './page-editor.blocks.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkInput from '@/components/form/input.vue';
+import { blockDefs } from '@/scripts/hpml/index';
+import { HpmlTypeChecker } from '@/scripts/hpml/type-checker';
+import { url } from '@/config';
+import { collectPageVars } from '@/scripts/collect-page-vars';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
+ XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput,
+ },
+
+ props: {
+ initPageId: {
+ type: String,
+ required: false
+ },
+ initPageName: {
+ type: String,
+ required: false
+ },
+ initUser: {
+ type: String,
+ required: false
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => {
+ let title = this.$ts._pages.newPage;
+ if (this.initPageId) {
+ title = this.$ts._pages.editPage;
+ }
+ else if (this.initPageName && this.initUser) {
+ title = this.$ts._pages.readPage;
+ }
+ return {
+ title: title,
+ icon: 'fas fa-pencil-alt',
+ bg: 'var(--bg)',
+ tabs: [{
+ active: this.tab === 'settings',
+ title: this.$ts._pages.pageSetting,
+ icon: 'fas fa-cog',
+ onClick: () => { this.tab = 'settings'; },
+ }, {
+ active: this.tab === 'contents',
+ title: this.$ts._pages.contents,
+ icon: 'fas fa-sticky-note',
+ onClick: () => { this.tab = 'contents'; },
+ }, {
+ active: this.tab === 'variables',
+ title: this.$ts._pages.variables,
+ icon: 'fas fa-magic',
+ onClick: () => { this.tab = 'variables'; },
+ }, {
+ active: this.tab === 'script',
+ title: this.$ts.script,
+ icon: 'fas fa-code',
+ onClick: () => { this.tab = 'script'; },
+ }],
+ };
+ }),
+ tab: 'settings',
+ author: this.$i,
+ readonly: false,
+ page: null,
+ pageId: null,
+ currentName: null,
+ title: '',
+ summary: null,
+ name: Date.now().toString(),
+ eyeCatchingImage: null,
+ eyeCatchingImageId: null,
+ font: 'sans-serif',
+ content: [],
+ alignCenter: false,
+ hideTitleWhenPinned: false,
+ variables: [],
+ hpml: null,
+ script: '',
+ url,
+ };
+ },
+
+ watch: {
+ async eyeCatchingImageId() {
+ if (this.eyeCatchingImageId == null) {
+ this.eyeCatchingImage = null;
+ } else {
+ this.eyeCatchingImage = await os.api('drive/files/show', {
+ fileId: this.eyeCatchingImageId,
+ });
+ }
+ },
+ },
+
+ async created() {
+ this.hpml = new HpmlTypeChecker();
+
+ this.$watch('variables', () => {
+ this.hpml.variables = this.variables;
+ }, { deep: true });
+
+ this.$watch('content', () => {
+ this.hpml.pageVars = collectPageVars(this.content);
+ }, { deep: true });
+
+ if (this.initPageId) {
+ this.page = await os.api('pages/show', {
+ pageId: this.initPageId,
+ });
+ } else if (this.initPageName && this.initUser) {
+ this.page = await os.api('pages/show', {
+ name: this.initPageName,
+ username: this.initUser,
+ });
+ this.readonly = true;
+ }
+
+ if (this.page) {
+ this.author = this.page.user;
+ this.pageId = this.page.id;
+ this.title = this.page.title;
+ this.name = this.page.name;
+ this.currentName = this.page.name;
+ this.summary = this.page.summary;
+ this.font = this.page.font;
+ this.script = this.page.script;
+ this.hideTitleWhenPinned = this.page.hideTitleWhenPinned;
+ this.alignCenter = this.page.alignCenter;
+ this.content = this.page.content;
+ this.variables = this.page.variables;
+ this.eyeCatchingImageId = this.page.eyeCatchingImageId;
+ } else {
+ const id = uuid();
+ this.content = [{
+ id,
+ type: 'text',
+ text: 'Hello World!'
+ }];
+ }
+ },
+
+ provide() {
+ return {
+ readonly: this.readonly,
+ getScriptBlockList: this.getScriptBlockList,
+ getPageBlockList: this.getPageBlockList
+ }
+ },
+
+ methods: {
+ getSaveOptions() {
+ return {
+ title: this.title.trim(),
+ name: this.name.trim(),
+ summary: this.summary,
+ font: this.font,
+ script: this.script,
+ hideTitleWhenPinned: this.hideTitleWhenPinned,
+ alignCenter: this.alignCenter,
+ content: this.content,
+ variables: this.variables,
+ eyeCatchingImageId: this.eyeCatchingImageId,
+ };
+ },
+
+ save() {
+ const options = this.getSaveOptions();
+
+ const onError = err => {
+ if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') {
+ if (err.info.param == 'name') {
+ os.dialog({
+ type: 'error',
+ title: this.$ts._pages.invalidNameTitle,
+ text: this.$ts._pages.invalidNameText
+ });
+ }
+ } else if (err.code == 'NAME_ALREADY_EXISTS') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._pages.nameAlreadyExists
+ });
+ }
+ };
+
+ if (this.pageId) {
+ options.pageId = this.pageId;
+ os.api('pages/update', options)
+ .then(page => {
+ this.currentName = this.name.trim();
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.updated
+ });
+ }).catch(onError);
+ } else {
+ os.api('pages/create', options)
+ .then(page => {
+ this.pageId = page.id;
+ this.currentName = this.name.trim();
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.created
+ });
+ this.$router.push(`/pages/edit/${this.pageId}`);
+ }).catch(onError);
+ }
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$t('removeAreYouSure', { x: this.title.trim() }),
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.api('pages/delete', {
+ pageId: this.pageId,
+ }).then(() => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.deleted
+ });
+ this.$router.push(`/pages`);
+ });
+ });
+ },
+
+ duplicate() {
+ this.title = this.title + ' - copy';
+ this.name = this.name + '-copy';
+ os.api('pages/create', this.getSaveOptions()).then(page => {
+ this.pageId = page.id;
+ this.currentName = this.name.trim();
+ os.dialog({
+ type: 'success',
+ text: this.$ts._pages.created
+ });
+ this.$router.push(`/pages/edit/${this.pageId}`);
+ });
+ },
+
+ async add() {
+ const { canceled, result: type } = await os.dialog({
+ type: null,
+ title: this.$ts._pages.chooseBlock,
+ select: {
+ groupedItems: this.getPageBlockList()
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ const id = uuid();
+ this.content.push({ id, type });
+ },
+
+ async addVariable() {
+ let { canceled, result: name } = await os.dialog({
+ title: this.$ts._pages.enterVariableName,
+ input: {
+ type: 'text',
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ name = name.trim();
+
+ if (this.hpml.isUsedName(name)) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._pages.variableNameIsAlreadyUsed
+ });
+ return;
+ }
+
+ const id = uuid();
+ this.variables.push({ id, name, type: null });
+ },
+
+ removeVariable(v) {
+ this.variables = this.variables.filter(x => x.name !== v.name);
+ },
+
+ getPageBlockList() {
+ return [{
+ label: this.$ts._pages.contentBlocks,
+ items: [
+ { value: 'section', text: this.$ts._pages.blocks.section },
+ { value: 'text', text: this.$ts._pages.blocks.text },
+ { value: 'image', text: this.$ts._pages.blocks.image },
+ { value: 'textarea', text: this.$ts._pages.blocks.textarea },
+ { value: 'note', text: this.$ts._pages.blocks.note },
+ { value: 'canvas', text: this.$ts._pages.blocks.canvas },
+ ]
+ }, {
+ label: this.$ts._pages.inputBlocks,
+ items: [
+ { value: 'button', text: this.$ts._pages.blocks.button },
+ { value: 'radioButton', text: this.$ts._pages.blocks.radioButton },
+ { value: 'textInput', text: this.$ts._pages.blocks.textInput },
+ { value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput },
+ { value: 'numberInput', text: this.$ts._pages.blocks.numberInput },
+ { value: 'switch', text: this.$ts._pages.blocks.switch },
+ { value: 'counter', text: this.$ts._pages.blocks.counter }
+ ]
+ }, {
+ label: this.$ts._pages.specialBlocks,
+ items: [
+ { value: 'if', text: this.$ts._pages.blocks.if },
+ { value: 'post', text: this.$ts._pages.blocks.post }
+ ]
+ }];
+ },
+
+ getScriptBlockList(type: string = null) {
+ const list = [];
+
+ const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number');
+
+ for (const block of blocks) {
+ const category = list.find(x => x.category === block.category);
+ if (category) {
+ category.items.push({
+ value: block.type,
+ text: this.$t(`_pages.script.blocks.${block.type}`)
+ });
+ } else {
+ list.push({
+ category: block.category,
+ label: this.$t(`_pages.script.categories.${block.category}`),
+ items: [{
+ value: block.type,
+ text: this.$t(`_pages.script.blocks.${block.type}`)
+ }]
+ });
+ }
+ }
+
+ const userFns = this.variables.filter(x => x.type === 'fn');
+ if (userFns.length > 0) {
+ list.unshift({
+ label: this.$t(`_pages.script.categories.fn`),
+ items: userFns.map(v => ({
+ value: 'fn:' + v.name,
+ text: v.name
+ }))
+ });
+ }
+
+ return list;
+ },
+
+ setEyeCatchingImage(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ this.eyeCatchingImageId = file.id;
+ });
+ },
+
+ removeEyeCatchingImage() {
+ this.eyeCatchingImageId = null;
+ },
+
+ highlighter(code) {
+ return highlight(code, languages.js, 'javascript');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jqqmcavi {
+ > .button {
+ & + .button {
+ margin-left: 8px;
+ }
+ }
+}
+
+.gwbmwxkm {
+ position: relative;
+
+ > header {
+ > .title {
+ z-index: 1;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 42px;
+ font-size: 0.9em;
+ font-weight: bold;
+ box-shadow: 0 1px rgba(#000, 0.07);
+
+ > i {
+ margin-right: 6px;
+ }
+
+ &:empty {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ right: 0;
+
+ > button {
+ padding: 0;
+ width: 42px;
+ font-size: 0.9em;
+ line-height: 42px;
+ }
+ }
+ }
+
+ > section {
+ padding: 0 32px 32px 32px;
+
+ @media (max-width: 500px) {
+ padding: 0 16px 16px 16px;
+ }
+
+ > .view {
+ display: inline-block;
+ margin: 16px 0 0 0;
+ font-size: 14px;
+ }
+
+ > .content {
+ margin-bottom: 16px;
+ }
+
+ > .eyeCatch {
+ margin-bottom: 16px;
+
+ > div {
+ > img {
+ max-width: 100%;
+ }
+ }
+ }
+ }
+}
+
+.qmuvgica {
+ padding: 16px;
+
+ > .variables {
+ margin-bottom: 16px;
+ }
+
+ > .add {
+ margin-bottom: 16px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue
new file mode 100644
index 0000000000..1eff1a98cb
--- /dev/null
+++ b/packages/client/src/pages/page.vue
@@ -0,0 +1,311 @@
+<template>
+<div>
+ <transition name="fade" mode="out-in">
+ <div v-if="page" class="xcukqgmh" :key="page.id" v-size="{ max: [450] }">
+ <div class="_block main">
+ <!--
+ <div class="header">
+ <h1>{{ page.title }}</h1>
+ </div>
+ -->
+ <div class="banner">
+ <img :src="page.eyeCatchingImage.url" v-if="page.eyeCatchingImageId"/>
+ </div>
+ <div class="content">
+ <XPage :page="page"/>
+ </div>
+ <div class="actions">
+ <div class="like">
+ <MkButton class="button" @click="unlike()" v-if="page.isLiked" v-tooltip="$ts._pages.unlike" primary><i class="fas fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton>
+ <MkButton class="button" @click="like()" v-else v-tooltip="$ts._pages.like"><i class="far fa-heart"></i><span class="count" v-if="page.likedCount > 0">{{ page.likedCount }}</span></MkButton>
+ </div>
+ <div class="other">
+ <button class="_button" @click="shareWithNote" v-tooltip="$ts.shareWithNote" v-click-anime><i class="fas fa-retweet fa-fw"></i></button>
+ <button class="_button" @click="share" v-tooltip="$ts.share" v-click-anime><i class="fas fa-share-alt fa-fw"></i></button>
+ </div>
+ </div>
+ <div class="user">
+ <MkAvatar :user="page.user" class="avatar"/>
+ <div class="name">
+ <MkUserName :user="page.user" style="display: block;"/>
+ <MkAcct :user="page.user"/>
+ </div>
+ <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ </div>
+ <div class="links">
+ <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA>
+ <template v-if="$i && $i.id === page.userId">
+ <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA>
+ <button v-if="$i.pinnedPageId === page.id" @click="pin(false)" class="link _textButton">{{ $ts.unpin }}</button>
+ <button v-else @click="pin(true)" class="link _textButton">{{ $ts.pin }}</button>
+ </template>
+ </div>
+ </div>
+ <div class="footer">
+ <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
+ <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
+ </div>
+ <MkAd :prefer="['horizontal', 'horizontal-big']"/>
+ <MkContainer :max-height="300" :foldable="true" class="other">
+ <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template>
+ <MkPagination :pagination="otherPostsPagination" #default="{items}">
+ <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/>
+ </MkPagination>
+ </MkContainer>
+ </div>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import XPage from '@/components/page/page.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { url } from '@/config';
+import MkFollowButton from '@/components/follow-button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkPagePreview from '@/components/page-preview.vue';
+
+export default defineComponent({
+ components: {
+ XPage,
+ MkButton,
+ MkFollowButton,
+ MkContainer,
+ MkPagination,
+ MkPagePreview,
+ },
+
+ props: {
+ pageName: {
+ type: String,
+ required: true
+ },
+ username: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.page ? {
+ title: computed(() => this.page.title || this.page.name),
+ avatar: this.page.user,
+ path: `/@${this.page.user.username}/pages/${this.page.name}`,
+ share: {
+ title: this.page.title || this.page.name,
+ text: this.page.summary,
+ },
+ } : null),
+ page: null,
+ error: null,
+ otherPostsPagination: {
+ endpoint: 'users/pages',
+ limit: 6,
+ params: () => ({
+ userId: this.page.user.id
+ })
+ },
+ };
+ },
+
+ computed: {
+ path(): string {
+ return this.username + '/' + this.pageName;
+ }
+ },
+
+ watch: {
+ path() {
+ this.fetch();
+ }
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ this.page = null;
+ os.api('pages/show', {
+ name: this.pageName,
+ username: this.username,
+ }).then(page => {
+ this.page = page;
+ }).catch(e => {
+ this.error = e;
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.page.title || this.page.name,
+ text: this.page.summary,
+ url: `${url}/@${this.page.user.username}/pages/${this.page.name}`
+ });
+ },
+
+ shareWithNote() {
+ os.post({
+ initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}`
+ });
+ },
+
+ like() {
+ os.apiWithDialog('pages/like', {
+ pageId: this.page.id,
+ }).then(() => {
+ this.page.isLiked = true;
+ this.page.likedCount++;
+ });
+ },
+
+ async unlike() {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$ts.unlikeConfirm,
+ });
+ if (confirm.canceled) return;
+ os.apiWithDialog('pages/unlike', {
+ pageId: this.page.id,
+ }).then(() => {
+ this.page.isLiked = false;
+ this.page.likedCount--;
+ });
+ },
+
+ pin(pin) {
+ os.apiWithDialog('i/update', {
+ pinnedPageId: pin ? this.page.id : null,
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.xcukqgmh {
+ --padding: 32px;
+
+ &.max-width_450px {
+ --padding: 16px;
+ }
+
+ > .main {
+ padding: var(--padding);
+
+ > .header {
+ padding: 16px;
+
+ > h1 {
+ margin: 0;
+ }
+ }
+
+ > .banner {
+ > img {
+ // TODO: 良い感じのアスペクト比で表示
+ display: block;
+ width: 100%;
+ height: 150px;
+ object-fit: cover;
+ }
+ }
+
+ > .content {
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ }
+
+ > .actions {
+ display: flex;
+ align-items: center;
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+
+ > .like {
+ > .button {
+ --accent: rgb(241 97 132);
+ --X8: rgb(241 92 128);
+ --buttonBg: rgb(216 71 106 / 5%);
+ --buttonHoverBg: rgb(216 71 106 / 10%);
+ color: #ff002f;
+
+ ::v-deep(.count) {
+ margin-left: 0.5em;
+ }
+ }
+ }
+
+ > .other {
+ margin-left: auto;
+
+ > button {
+ padding: 8px;
+ margin: 0 8px;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+ }
+ }
+
+ > .user {
+ margin-top: 16px;
+ padding: 16px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+ display: flex;
+ align-items: center;
+
+ > .avatar {
+ width: 52px;
+ height: 52px;
+ }
+
+ > .name {
+ margin: 0 0 0 12px;
+ font-size: 90%;
+ }
+
+ > .koudoku {
+ margin-left: auto;
+ }
+ }
+
+ > .links {
+ margin-top: 16px;
+ padding: 24px 0 0 0;
+ border-top: solid 0.5px var(--divider);
+
+ > .link {
+ margin-right: 0.75em;
+ }
+ }
+ }
+
+ > .footer {
+ margin: var(--padding);
+ font-size: 85%;
+ opacity: 0.75;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue
new file mode 100644
index 0000000000..d66fc2ad5b
--- /dev/null
+++ b/packages/client/src/pages/pages.vue
@@ -0,0 +1,96 @@
+<template>
+<MkSpacer>
+ <!-- TODO: MkHeaderに統合 -->
+ <MkTab v-model="tab" v-if="$i">
+ <option value="featured"><i class="fas fa-fire-alt"></i> {{ $ts._pages.featured }}</option>
+ <option value="my"><i class="fas fa-edit"></i> {{ $ts._pages.my }}</option>
+ <option value="liked"><i class="fas fa-heart"></i> {{ $ts._pages.liked }}</option>
+ </MkTab>
+
+ <div class="_section">
+ <div class="rknalgpo _content" v-if="tab === 'featured'">
+ <MkPagination :pagination="featuredPagesPagination" #default="{items}">
+ <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="rknalgpo _content my" v-if="tab === 'my'">
+ <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton>
+ <MkPagination :pagination="myPagesPagination" #default="{items}">
+ <MkPagePreview v-for="page in items" class="ckltabjg" :page="page" :key="page.id"/>
+ </MkPagination>
+ </div>
+
+ <div class="rknalgpo _content" v-if="tab === 'liked'">
+ <MkPagination :pagination="likedPagesPagination" #default="{items}">
+ <MkPagePreview v-for="like in items" class="ckltabjg" :page="like.page" :key="like.page.id"/>
+ </MkPagination>
+ </div>
+ </div>
+</MkSpacer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagePreview from '@/components/page-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTab from '@/components/tab.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagePreview, MkPagination, MkButton, MkTab
+ },
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.pages,
+ icon: 'fas fa-sticky-note',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-plus',
+ text: this.$ts.create,
+ handler: this.create,
+ }],
+ },
+ tab: 'featured',
+ featuredPagesPagination: {
+ endpoint: 'pages/featured',
+ noPaging: true,
+ },
+ myPagesPagination: {
+ endpoint: 'i/pages',
+ limit: 5,
+ },
+ likedPagesPagination: {
+ endpoint: 'i/page-likes',
+ limit: 5,
+ },
+ };
+ },
+ methods: {
+ create() {
+ this.$router.push(`/pages/new`);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rknalgpo {
+ &.my .ckltabjg:first-child {
+ margin-top: 16px;
+ }
+
+ .ckltabjg:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ @media (min-width: 500px) {
+ .ckltabjg:not(:last-child) {
+ margin-bottom: 16px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue
new file mode 100644
index 0000000000..9d1ebb74ed
--- /dev/null
+++ b/packages/client/src/pages/preview.vue
@@ -0,0 +1,32 @@
+<template>
+<div class="graojtoi">
+ <MkSample/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkSample from '@/components/sample.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkSample,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.preview,
+ icon: 'fas fa-eye',
+ },
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.graojtoi {
+ padding: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue
new file mode 100644
index 0000000000..f9a2500840
--- /dev/null
+++ b/packages/client/src/pages/reset-password.vue
@@ -0,0 +1,69 @@
+<template>
+<FormBase v-if="token">
+ <FormInput v-model="password" type="password">
+ <template #prefix><i class="fas fa-lock"></i></template>
+ <span>{{ $ts.newPassword }}</span>
+ </FormInput>
+
+ <FormButton primary @click="save">{{ $ts.save }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormInput,
+ FormButton,
+ },
+
+ props: {
+ token: {
+ type: String,
+ required: false
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.resetPassword,
+ icon: 'fas fa-lock'
+ },
+ password: '',
+ }
+ },
+
+ mounted() {
+ if (this.token == null) {
+ os.popup(import('@/components/forgot-password.vue'), {}, {}, 'closed');
+ this.$router.push('/');
+ }
+ },
+
+ methods: {
+ async save() {
+ await os.apiWithDialog('reset-password', {
+ token: this.token,
+ password: this.password,
+ });
+ this.$router.push('/');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/reversi/game.board.vue b/packages/client/src/pages/reversi/game.board.vue
new file mode 100644
index 0000000000..529e00d969
--- /dev/null
+++ b/packages/client/src/pages/reversi/game.board.vue
@@ -0,0 +1,528 @@
+<template>
+<div class="xqnhankfuuilcwvhgsopeqncafzsquya">
+ <header><b><MkA :to="userPage(blackUser)"><MkUserName :user="blackUser"/></MkA></b>({{ $ts._reversi.black }}) vs <b><MkA :to="userPage(whiteUser)"><MkUserName :user="whiteUser"/></MkA></b>({{ $ts._reversi.white }})</header>
+
+ <div style="overflow: hidden; line-height: 28px;">
+ <p class="turn" v-if="!iAmPlayer && !game.isEnded">
+ <Mfm :key="'turn:' + turnUser().name" :text="$t('_reversi.turnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/>
+ <MkEllipsis/>
+ </p>
+ <p class="turn" v-if="logPos != logs.length">
+ <Mfm :key="'past-turn-of:' + turnUser().name" :text="$t('_reversi.pastTurnOf', { name: turnUser().name })" :plain="true" :custom-emojis="turnUser().emojis"/>
+ </p>
+ <p class="turn1" v-if="iAmPlayer && !game.isEnded && !isMyTurn()">{{ $ts._reversi.opponentTurn }}<MkEllipsis/></p>
+ <p class="turn2" v-if="iAmPlayer && !game.isEnded && isMyTurn()" style="animation: tada 1s linear infinite both;">{{ $ts._reversi.myTurn }}</p>
+ <p class="result" v-if="game.isEnded && logPos == logs.length">
+ <template v-if="game.winner">
+ <Mfm :key="'won'" :text="$t('_reversi.won', { name: game.winner.name })" :plain="true" :custom-emojis="game.winner.emojis"/>
+ <span v-if="game.surrendered != null"> ({{ $ts._reversi.surrendered }})</span>
+ </template>
+ <template v-else>{{ $ts._reversi.drawn }}</template>
+ </p>
+ </div>
+
+ <div class="board">
+ <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels">
+ <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ <div class="flex">
+ <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels">
+ <div v-for="i in game.map.length">{{ i }}</div>
+ </div>
+ <div class="cells" :style="cellsStyle">
+ <div v-for="(stone, i) in o.board"
+ :class="{ empty: stone == null, none: o.map[i] == 'null', isEnded: game.isEnded, myTurn: !game.isEnded && isMyTurn(), can: turnUser() ? o.canPut(turnUser().id == blackUser.id, i) : null, prev: o.prevPos == i }"
+ @click="set(i)"
+ :title="`${String.fromCharCode(65 + o.transformPosToXy(i)[0])}${o.transformPosToXy(i)[1] + 1}`"
+ >
+ <template v-if="$store.state.gamesReversiUseAvatarStones || true">
+ <img v-if="stone === true" :src="blackUser.avatarUrl" alt="black">
+ <img v-if="stone === false" :src="whiteUser.avatarUrl" alt="white">
+ </template>
+ <template v-else>
+ <i v-if="stone === true" class="fas fa-circle"></i>
+ <i v-if="stone === false" class="far fa-circle"></i>
+ </template>
+ </div>
+ </div>
+ <div class="labels-y" v-if="$store.state.gamesReversiShowBoardLabels">
+ <div v-for="i in game.map.length">{{ i }}</div>
+ </div>
+ </div>
+ <div class="labels-x" v-if="$store.state.gamesReversiShowBoardLabels">
+ <span v-for="i in game.map[0].length">{{ String.fromCharCode(64 + i) }}</span>
+ </div>
+ </div>
+
+ <p class="status"><b>{{ $t('_reversi.turnCount', { count: logPos }) }}</b> {{ $ts._reversi.black }}:{{ o.blackCount }} {{ $ts._reversi.white }}:{{ o.whiteCount }} {{ $ts._reversi.total }}:{{ o.blackCount + o.whiteCount }}</p>
+
+ <div class="actions" v-if="!game.isEnded && iAmPlayer">
+ <MkButton @click="surrender" inline>{{ $ts._reversi.surrender }}</MkButton>
+ </div>
+
+ <div class="player" v-if="game.isEnded">
+ <span>{{ logPos }} / {{ logs.length }}</span>
+ <div class="buttons" v-if="!autoplaying">
+ <MkButton inline @click="logPos = 0" :disabled="logPos == 0"><i class="fas fa-angle-double-left"></i></MkButton>
+ <MkButton inline @click="logPos--" :disabled="logPos == 0"><i class="fas fa-angle-left"></i></MkButton>
+ <MkButton inline @click="logPos++" :disabled="logPos == logs.length"><i class="fas fa-angle-right"></i></MkButton>
+ <MkButton inline @click="logPos = logs.length" :disabled="logPos == logs.length"><i class="fas fa-angle-double-right"></i></MkButton>
+ </div>
+ <MkButton @click="autoplay()" :disabled="autoplaying" style="margin: var(--margin) auto 0 auto;"><i class="fas fa-play"></i></MkButton>
+ </div>
+
+ <div class="info">
+ <p v-if="game.isLlotheo">{{ $ts._reversi.isLlotheo }}</p>
+ <p v-if="game.loopedBoard">{{ $ts._reversi.loopedMap }}</p>
+ <p v-if="game.canPutEverywhere">{{ $ts._reversi.canPutEverywhere }}</p>
+ </div>
+
+ <div class="watchers">
+ <MkAvatar v-for="user in watchers" :key="user.id" :user="user" class="avatar"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as CRC32 from 'crc-32';
+import Reversi, { Color } from '@/scripts/games/reversi/core';
+import { url } from '@/config';
+import MkButton from '@/components/ui/button.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+
+ props: {
+ initGame: {
+ type: Object,
+ require: true
+ },
+ connection: {
+ type: Object,
+ require: true
+ },
+ },
+
+ data() {
+ return {
+ game: JSON.parse(JSON.stringify(this.initGame)),
+ o: null as Reversi,
+ logs: [],
+ logPos: 0,
+ watchers: [],
+ pollingClock: null,
+ };
+ },
+
+ computed: {
+ iAmPlayer(): boolean {
+ if (!this.$i) return false;
+ return this.game.user1Id == this.$i.id || this.game.user2Id == this.$i.id;
+ },
+
+ myColor(): Color {
+ if (!this.iAmPlayer) return null;
+ if (this.game.user1Id == this.$i.id && this.game.black == 1) return true;
+ if (this.game.user2Id == this.$i.id && this.game.black == 2) return true;
+ return false;
+ },
+
+ opColor(): Color {
+ if (!this.iAmPlayer) return null;
+ return this.myColor === true ? false : true;
+ },
+
+ blackUser(): any {
+ return this.game.black == 1 ? this.game.user1 : this.game.user2;
+ },
+
+ whiteUser(): any {
+ return this.game.black == 1 ? this.game.user2 : this.game.user1;
+ },
+
+ cellsStyle(): any {
+ return {
+ 'grid-template-rows': `repeat(${this.game.map.length}, 1fr)`,
+ 'grid-template-columns': `repeat(${this.game.map[0].length}, 1fr)`
+ };
+ }
+ },
+
+ watch: {
+ logPos(v) {
+ if (!this.game.isEnded) return;
+ const o = new Reversi(this.game.map, {
+ isLlotheo: this.game.isLlotheo,
+ canPutEverywhere: this.game.canPutEverywhere,
+ loopedBoard: this.game.loopedBoard
+ });
+ for (const log of this.logs.slice(0, v)) {
+ o.put(log.color, log.pos);
+ }
+ this.o = o;
+ //this.$forceUpdate();
+ }
+ },
+
+ created() {
+ this.o = new Reversi(this.game.map, {
+ isLlotheo: this.game.isLlotheo,
+ canPutEverywhere: this.game.canPutEverywhere,
+ loopedBoard: this.game.loopedBoard
+ });
+
+ for (const log of this.game.logs) {
+ this.o.put(log.color, log.pos);
+ }
+
+ this.logs = this.game.logs;
+ this.logPos = this.logs.length;
+
+ // 通信を取りこぼしてもいいように定期的にポーリングさせる
+ if (this.game.isStarted && !this.game.isEnded) {
+ this.pollingClock = setInterval(() => {
+ if (this.game.isEnded) return;
+ const crc32 = CRC32.str(this.logs.map(x => x.pos.toString()).join(''));
+ this.connection.send('check', {
+ crc32: crc32
+ });
+ }, 3000);
+ }
+ },
+
+ mounted() {
+ this.connection.on('set', this.onSet);
+ this.connection.on('rescue', this.onRescue);
+ this.connection.on('ended', this.onEnded);
+ this.connection.on('watchers', this.onWatchers);
+ },
+
+ beforeUnmount() {
+ this.connection.off('set', this.onSet);
+ this.connection.off('rescue', this.onRescue);
+ this.connection.off('ended', this.onEnded);
+ this.connection.off('watchers', this.onWatchers);
+
+ clearInterval(this.pollingClock);
+ },
+
+ methods: {
+ userPage,
+
+ // this.o がリアクティブになった折にはcomputedにできる
+ turnUser(): any {
+ if (this.o.turn === true) {
+ return this.game.black == 1 ? this.game.user1 : this.game.user2;
+ } else if (this.o.turn === false) {
+ return this.game.black == 1 ? this.game.user2 : this.game.user1;
+ } else {
+ return null;
+ }
+ },
+
+ // this.o がリアクティブになった折にはcomputedにできる
+ isMyTurn(): boolean {
+ if (!this.iAmPlayer) return false;
+ if (this.turnUser() == null) return false;
+ return this.turnUser().id == this.$i.id;
+ },
+
+ set(pos) {
+ if (this.game.isEnded) return;
+ if (!this.iAmPlayer) return;
+ if (!this.isMyTurn()) return;
+ if (!this.o.canPut(this.myColor, pos)) return;
+
+ this.o.put(this.myColor, pos);
+
+ // サウンドを再生する
+ sound.play(this.myColor ? 'reversiPutBlack' : 'reversiPutWhite');
+
+ this.connection.send('set', {
+ pos: pos
+ });
+
+ this.checkEnd();
+
+ this.$forceUpdate();
+ },
+
+ onSet(x) {
+ this.logs.push(x);
+ this.logPos++;
+ this.o.put(x.color, x.pos);
+ this.checkEnd();
+ this.$forceUpdate();
+
+ // サウンドを再生する
+ if (x.color !== this.myColor) {
+ sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite');
+ }
+ },
+
+ onEnded(x) {
+ this.game = JSON.parse(JSON.stringify(x.game));
+ },
+
+ checkEnd() {
+ this.game.isEnded = this.o.isEnded;
+ if (this.game.isEnded) {
+ if (this.o.winner === true) {
+ this.game.winnerId = this.game.black == 1 ? this.game.user1Id : this.game.user2Id;
+ this.game.winner = this.game.black == 1 ? this.game.user1 : this.game.user2;
+ } else if (this.o.winner === false) {
+ this.game.winnerId = this.game.black == 1 ? this.game.user2Id : this.game.user1Id;
+ this.game.winner = this.game.black == 1 ? this.game.user2 : this.game.user1;
+ } else {
+ this.game.winnerId = null;
+ this.game.winner = null;
+ }
+ }
+ },
+
+ // 正しいゲーム情報が送られてきたとき
+ onRescue(game) {
+ this.game = JSON.parse(JSON.stringify(game));
+
+ this.o = new Reversi(this.game.map, {
+ isLlotheo: this.game.isLlotheo,
+ canPutEverywhere: this.game.canPutEverywhere,
+ loopedBoard: this.game.loopedBoard
+ });
+
+ for (const log of this.game.logs) {
+ this.o.put(log.color, log.pos, true);
+ }
+
+ this.logs = this.game.logs;
+ this.logPos = this.logs.length;
+
+ this.checkEnd();
+ this.$forceUpdate();
+ },
+
+ onWatchers(users) {
+ this.watchers = users;
+ },
+
+ surrender() {
+ os.api('games/reversi/games/surrender', {
+ gameId: this.game.id
+ });
+ },
+
+ autoplay() {
+ this.autoplaying = true;
+ this.logPos = 0;
+
+ setTimeout(() => {
+ this.logPos = 1;
+
+ let i = 1;
+ let previousLog = this.game.logs[0];
+ const tick = () => {
+ const log = this.game.logs[i];
+ const time = new Date(log.at).getTime() - new Date(previousLog.at).getTime()
+ setTimeout(() => {
+ i++;
+ this.logPos++;
+ previousLog = log;
+
+ if (i < this.game.logs.length) {
+ tick();
+ } else {
+ this.autoplaying = false;
+ }
+ }, time);
+ };
+
+ tick();
+ }, 1000);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.xqnhankfuuilcwvhgsopeqncafzsquya {
+ text-align: center;
+
+ > .go-index {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 1;
+ width: 42px;
+ height :42px;
+ }
+
+ > header {
+ padding: 8px;
+ border-bottom: dashed 1px var(--divider);
+ }
+
+ > .board {
+ width: calc(100% - 16px);
+ max-width: 500px;
+ margin: 0 auto;
+
+ $label-size: 16px;
+ $gap: 4px;
+
+ > .labels-x {
+ height: $label-size;
+ padding: 0 $label-size;
+ display: flex;
+
+ > * {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8em;
+
+ &:first-child {
+ margin-left: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-right: -(math.div($gap, 2));
+ }
+ }
+ }
+
+ > .flex {
+ display: flex;
+
+ > .labels-y {
+ width: $label-size;
+ display: flex;
+ flex-direction: column;
+
+ > * {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 12px;
+
+ &:first-child {
+ margin-top: -(math.div($gap, 2));
+ }
+
+ &:last-child {
+ margin-bottom: -(math.div($gap, 2));
+ }
+ }
+ }
+
+ > .cells {
+ flex: 1;
+ display: grid;
+ grid-gap: $gap;
+
+ > div {
+ background: transparent;
+ border-radius: 6px;
+ overflow: hidden;
+
+ * {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ &.empty {
+ border: solid 2px var(--divider);
+ }
+
+ &.empty.can {
+ border-color: var(--accent);
+ }
+
+ &.empty.myTurn {
+ border-color: var(--divider);
+
+ &.can {
+ border-color: var(--accent);
+ cursor: pointer;
+
+ &:hover {
+ background: var(--accent);
+ }
+ }
+ }
+
+ &.prev {
+ box-shadow: 0 0 0 4px var(--accent);
+ }
+
+ &.isEnded {
+ border-color: var(--divider);
+ }
+
+ &.none {
+ border-color: transparent !important;
+ }
+
+ > svg, > img {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+ }
+ }
+ }
+ }
+
+ > .status {
+ margin: 0;
+ padding: 16px 0;
+ }
+
+ > .actions {
+ padding-bottom: 16px;
+ }
+
+ > .player {
+ padding: 0 16px 32px 16px;
+ margin: 0 auto;
+ max-width: 500px;
+
+ > span {
+ display: inline-block;
+ margin: 0 8px;
+ min-width: 70px;
+ }
+
+ > .buttons {
+ display: flex;
+
+ > * {
+ flex: 1;
+ }
+ }
+ }
+
+ > .watchers {
+ padding: 0 0 16px 0;
+
+ &:empty {
+ display: none;
+ }
+
+ > .avatar {
+ width: 32px;
+ height: 32px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/reversi/game.setting.vue b/packages/client/src/pages/reversi/game.setting.vue
new file mode 100644
index 0000000000..e6a6661f16
--- /dev/null
+++ b/packages/client/src/pages/reversi/game.setting.vue
@@ -0,0 +1,390 @@
+<template>
+<div class="urbixznjwwuukfsckrwzwsqzsxornqij">
+ <header><b><MkUserName :user="game.user1"/></b> vs <b><MkUserName :user="game.user2"/></b></header>
+
+ <div>
+ <p>{{ $ts._reversi.gameSettings }}</p>
+
+ <div class="card map _panel">
+ <header>
+ <select v-model="mapName" :placeholder="$ts._reversi.chooseBoard" @change="onMapChange">
+ <option label="-Custom-" :value="mapName" v-if="mapName == '-Custom-'"/>
+ <option :label="$ts.random" :value="null"/>
+ <optgroup v-for="c in mapCategories" :key="c" :label="c">
+ <option v-for="m in Object.values(maps).filter(m => m.category == c)" :key="m.name" :label="m.name" :value="m.name">{{ m.name }}</option>
+ </optgroup>
+ </select>
+ </header>
+
+ <div>
+ <div class="random" v-if="game.map == null"><i class="fas fa-dice"></i></div>
+ <div class="board" v-else :style="{ 'grid-template-rows': `repeat(${ game.map.length }, 1fr)`, 'grid-template-columns': `repeat(${ game.map[0].length }, 1fr)` }">
+ <div v-for="(x, i) in game.map.join('')" :class="{ none: x == ' ' }" @click="onPixelClick(i, x)">
+ <i v-if="x === 'b'" class="fas fa-circle"></i>
+ <i v-if="x === 'w'" class="far fa-circle"></i>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="card _panel">
+ <header>
+ <span>{{ $ts._reversi.blackOrWhite }}</span>
+ </header>
+
+ <div>
+ <MkRadio v-model="game.bw" value="random" @update:modelValue="updateSettings('bw')">{{ $ts.random }}</MkRadio>
+ <MkRadio v-model="game.bw" :value="'1'" @update:modelValue="updateSettings('bw')">
+ <I18n :src="$ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user1"/></b>
+ </template>
+ </I18n>
+ </MkRadio>
+ <MkRadio v-model="game.bw" :value="'2'" @update:modelValue="updateSettings('bw')">
+ <I18n :src="$ts._reversi.blackIs" tag="span">
+ <template #name>
+ <b><MkUserName :user="game.user2"/></b>
+ </template>
+ </I18n>
+ </MkRadio>
+ </div>
+ </div>
+
+ <div class="card _panel">
+ <header>
+ <span>{{ $ts._reversi.rules }}</span>
+ </header>
+
+ <div>
+ <MkSwitch v-model="game.isLlotheo" @update:modelValue="updateSettings('isLlotheo')">{{ $ts._reversi.isLlotheo }}</MkSwitch>
+ <MkSwitch v-model="game.loopedBoard" @update:modelValue="updateSettings('loopedBoard')">{{ $ts._reversi.loopedMap }}</MkSwitch>
+ <MkSwitch v-model="game.canPutEverywhere" @update:modelValue="updateSettings('canPutEverywhere')">{{ $ts._reversi.canPutEverywhere }}</MkSwitch>
+ </div>
+ </div>
+
+ <div class="card form _panel" v-if="form">
+ <header>
+ <span>{{ $ts._reversi.botSettings }}</span>
+ </header>
+
+ <div>
+ <template v-for="item in form">
+ <MkSwitch v-if="item.type == 'switch'" v-model="item.value" :key="item.id" @change="onChangeForm(item)">{{ item.label || item.desc || '' }}</MkSwitch>
+
+ <div class="card" v-if="item.type == 'radio'" :key="item.id">
+ <header>
+ <span>{{ item.label }}</span>
+ </header>
+
+ <div>
+ <MkRadio v-for="(r, i) in item.items" :key="item.id + ':' + i" v-model="item.value" :value="r.value" @update:modelValue="onChangeForm(item)">{{ r.label }}</MkRadio>
+ </div>
+ </div>
+
+ <div class="card" v-if="item.type == 'slider'" :key="item.id">
+ <header>
+ <span>{{ item.label }}</span>
+ </header>
+
+ <div>
+ <input type="range" :min="item.min" :max="item.max" :step="item.step || 1" v-model="item.value" @change="onChangeForm(item)"/>
+ </div>
+ </div>
+
+ <div class="card" v-if="item.type == 'textbox'" :key="item.id">
+ <header>
+ <span>{{ item.label }}</span>
+ </header>
+
+ <div>
+ <input v-model="item.value" @change="onChangeForm(item)"/>
+ </div>
+ </div>
+ </template>
+ </div>
+ </div>
+ </div>
+
+ <footer class="_acrylic">
+ <p class="status">
+ <template v-if="isAccepted && isOpAccepted">{{ $ts._reversi.thisGameIsStartedSoon }}<MkEllipsis/></template>
+ <template v-if="isAccepted && !isOpAccepted">{{ $ts._reversi.waitingForOther }}<MkEllipsis/></template>
+ <template v-if="!isAccepted && isOpAccepted">{{ $ts._reversi.waitingForMe }}</template>
+ <template v-if="!isAccepted && !isOpAccepted">{{ $ts._reversi.waitingBoth }}<MkEllipsis/></template>
+ </p>
+
+ <div class="actions">
+ <MkButton inline @click="exit">{{ $ts.cancel }}</MkButton>
+ <MkButton inline primary @click="accept" v-if="!isAccepted">{{ $ts._reversi.ready }}</MkButton>
+ <MkButton inline primary @click="cancel" v-if="isAccepted">{{ $ts._reversi.cancelReady }}</MkButton>
+ </div>
+ </footer>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as maps from '@/scripts/games/reversi/maps';
+import MkButton from '@/components/ui/button.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkRadio from '@/components/form/radio.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkSwitch,
+ MkRadio,
+ },
+
+ props: {
+ initGame: {
+ type: Object,
+ require: true
+ },
+ connection: {
+ type: Object,
+ require: true
+ },
+ },
+
+ data() {
+ return {
+ game: this.initGame,
+ o: null,
+ isLlotheo: false,
+ mapName: maps.eighteight.name,
+ maps: maps,
+ form: null,
+ messages: [],
+ };
+ },
+
+ computed: {
+ mapCategories(): string[] {
+ const categories = Object.values(maps).map(x => x.category);
+ return categories.filter((item, pos) => categories.indexOf(item) == pos);
+ },
+ isAccepted(): boolean {
+ if (this.game.user1Id == this.$i.id && this.game.user1Accepted) return true;
+ if (this.game.user2Id == this.$i.id && this.game.user2Accepted) return true;
+ return false;
+ },
+ isOpAccepted(): boolean {
+ if (this.game.user1Id != this.$i.id && this.game.user1Accepted) return true;
+ if (this.game.user2Id != this.$i.id && this.game.user2Accepted) return true;
+ return false;
+ }
+ },
+
+ created() {
+ this.connection.on('changeAccepts', this.onChangeAccepts);
+ this.connection.on('updateSettings', this.onUpdateSettings);
+ this.connection.on('initForm', this.onInitForm);
+ this.connection.on('message', this.onMessage);
+
+ if (this.game.user1Id != this.$i.id && this.game.form1) this.form = this.game.form1;
+ if (this.game.user2Id != this.$i.id && this.game.form2) this.form = this.game.form2;
+ },
+
+ beforeUnmount() {
+ this.connection.off('changeAccepts', this.onChangeAccepts);
+ this.connection.off('updateSettings', this.onUpdateSettings);
+ this.connection.off('initForm', this.onInitForm);
+ this.connection.off('message', this.onMessage);
+ },
+
+ methods: {
+ exit() {
+
+ },
+
+ accept() {
+ this.connection.send('accept', {});
+ },
+
+ cancel() {
+ this.connection.send('cancelAccept', {});
+ },
+
+ onChangeAccepts(accepts) {
+ this.game.user1Accepted = accepts.user1;
+ this.game.user2Accepted = accepts.user2;
+ },
+
+ updateSettings(key: string) {
+ this.connection.send('updateSettings', {
+ key: key,
+ value: this.game[key]
+ });
+ },
+
+ onUpdateSettings({ key, value }) {
+ this.game[key] = value;
+ if (this.game.map == null) {
+ this.mapName = null;
+ } else {
+ const found = Object.values(maps).find(x => x.data.join('') == this.game.map.join(''));
+ this.mapName = found ? found.name : '-Custom-';
+ }
+ },
+
+ onInitForm(x) {
+ if (x.userId == this.$i.id) return;
+ this.form = x.form;
+ },
+
+ onMessage(x) {
+ if (x.userId == this.$i.id) return;
+ this.messages.unshift(x.message);
+ },
+
+ onChangeForm(item) {
+ this.connection.send('updateForm', {
+ id: item.id,
+ value: item.value
+ });
+ },
+
+ onMapChange() {
+ if (this.mapName == null) {
+ this.game.map = null;
+ } else {
+ this.game.map = Object.values(maps).find(x => x.name == this.mapName).data;
+ }
+ this.updateSettings('map');
+ },
+
+ onPixelClick(pos, pixel) {
+ const x = pos % this.game.map[0].length;
+ const y = Math.floor(pos / this.game.map[0].length);
+ const newPixel =
+ pixel == ' ' ? '-' :
+ pixel == '-' ? 'b' :
+ pixel == 'b' ? 'w' :
+ ' ';
+ const line = this.game.map[y].split('');
+ line[x] = newPixel;
+ this.game.map[y] = line.join('');
+ this.updateSettings('map');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.urbixznjwwuukfsckrwzwsqzsxornqij {
+ text-align: center;
+ background: var(--bg);
+
+ > header {
+ padding: 8px;
+ border-bottom: dashed 1px #c4cdd4;
+ }
+
+ > div {
+ padding: 0 16px;
+
+ > .card {
+ margin: 0 auto 16px auto;
+
+ &.map {
+ > header {
+ > select {
+ width: 100%;
+ padding: 12px 14px;
+ background: var(--face);
+ border: 1px solid var(--inputBorder);
+ border-radius: 4px;
+ color: var(--fg);
+ cursor: pointer;
+ transition: border-color 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+
+ &:focus-visible,
+ &:active {
+ border-color: var(--accent);
+ }
+ }
+ }
+
+ > div {
+ > .random {
+ padding: 32px 0;
+ font-size: 64px;
+ color: var(--fg);
+ opacity: 0.7;
+ }
+
+ > .board {
+ display: grid;
+ grid-gap: 4px;
+ width: 300px;
+ height: 300px;
+ margin: 0 auto;
+ color: var(--fg);
+
+ > div {
+ background: transparent;
+ border: solid 2px var(--divider);
+ border-radius: 6px;
+ overflow: hidden;
+ cursor: pointer;
+
+ * {
+ pointer-events: none;
+ user-select: none;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.none {
+ border-color: transparent;
+ }
+ }
+ }
+ }
+ }
+
+ &.form {
+ > div {
+ > .card + .card {
+ margin-top: 16px;
+ }
+
+ input[type='range'] {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ .card {
+ max-width: 400px;
+
+ > header {
+ padding: 18px 20px;
+ border-bottom: 1px solid var(--divider);
+ }
+
+ > div {
+ padding: 20px;
+ color: var(--fg);
+ }
+ }
+ }
+
+ > footer {
+ position: sticky;
+ bottom: 0;
+ padding: 16px;
+ border-top: solid 1px var(--divider);
+
+ > .status {
+ margin: 0 0 16px 0;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/reversi/game.vue b/packages/client/src/pages/reversi/game.vue
new file mode 100644
index 0000000000..b1ed632904
--- /dev/null
+++ b/packages/client/src/pages/reversi/game.vue
@@ -0,0 +1,76 @@
+<template>
+<div v-if="game == null"><MkLoading/></div>
+<GameSetting v-else-if="!game.isStarted" :init-game="game" :connection="connection"/>
+<GameBoard v-else :init-game="game" :connection="connection"/>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import GameSetting from './game.setting.vue';
+import GameBoard from './game.board.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ GameSetting,
+ GameBoard,
+ },
+
+ props: {
+ gameId: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._reversi.reversi,
+ icon: 'fas fa-gamepad'
+ },
+ game: null,
+ connection: null,
+ };
+ },
+
+ watch: {
+ gameId() {
+ this.fetch();
+ }
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.dispose();
+ }
+ },
+
+ methods: {
+ fetch() {
+ os.api('games/reversi/games/show', {
+ gameId: this.gameId
+ }).then(game => {
+ this.game = game;
+
+ if (this.connection) {
+ this.connection.dispose();
+ }
+ this.connection = markRaw(os.stream.useChannel('gamesReversiGame', {
+ gameId: this.game.id
+ }));
+ this.connection.on('started', this.onStarted);
+ });
+ },
+
+ onStarted(game) {
+ Object.assign(this.game, game);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/reversi/index.vue b/packages/client/src/pages/reversi/index.vue
new file mode 100644
index 0000000000..1b8f1ffb71
--- /dev/null
+++ b/packages/client/src/pages/reversi/index.vue
@@ -0,0 +1,279 @@
+<template>
+<div class="bgvwxkhb" v-if="!matching">
+ <h1>Misskey {{ $ts._reversi.reversi }}</h1>
+
+ <div class="play">
+ <MkButton primary round @click="match" style="margin: var(--margin) auto 0 auto;">{{ $ts.invite }}</MkButton>
+ </div>
+
+ <div class="_section">
+ <MkFolder v-if="invitations.length > 0">
+ <template #header>{{ $ts.invitations }}</template>
+ <div class="nfcacttm">
+ <button class="invitation _panel _button" v-for="invitation in invitations" tabindex="-1" @click="accept(invitation)">
+ <MkAvatar class="avatar" :user="invitation.parent" :show-indicator="true"/>
+ <span class="name"><b><MkUserName :user="invitation.parent"/></b></span>
+ <span class="username">@{{ invitation.parent.username }}</span>
+ <MkTime :time="invitation.createdAt" class="time"/>
+ </button>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="myGames.length > 0">
+ <template #header>{{ $ts._reversi.myGames }}</template>
+ <div class="knextgwz">
+ <MkA class="game _panel" v-for="g in myGames" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id">
+ <div class="players">
+ <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/>
+ </div>
+ <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer>
+ </MkA>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="games.length > 0">
+ <template #header>{{ $ts._reversi.allGames }}</template>
+ <div class="knextgwz">
+ <MkA class="game _panel" v-for="g in games" tabindex="-1" :to="`/games/reversi/${g.id}`" :key="g.id">
+ <div class="players">
+ <MkAvatar class="avatar" :user="g.user1"/><b><MkUserName :user="g.user1"/></b> vs <b><MkUserName :user="g.user2"/></b><MkAvatar class="avatar" :user="g.user2"/>
+ </div>
+ <footer><span class="state" :class="{ playing: !g.isEnded }">{{ g.isEnded ? $ts._reversi.ended : $ts._reversi.playing }}</span><MkTime class="time" :time="g.createdAt"/></footer>
+ </MkA>
+ </div>
+ </MkFolder>
+ </div>
+</div>
+<div class="sazhgisb" v-else>
+ <h1>
+ <I18n :src="$ts.waitingFor" tag="span">
+ <template #x>
+ <b><MkUserName :user="matching"/></b>
+ </template>
+ </I18n>
+ <MkEllipsis/>
+ </h1>
+ <div class="cancel">
+ <MkButton inline round @click="cancel">{{ $ts.cancel }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import * as os from '@/os';
+import MkButton from '@/components/ui/button.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton, MkFolder,
+ },
+
+ inject: ['navHook'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._reversi.reversi,
+ icon: 'fas fa-gamepad'
+ },
+ games: [],
+ gamesFetching: true,
+ gamesMoreFetching: false,
+ myGames: [],
+ matching: null,
+ invitations: [],
+ connection: null,
+ pingClock: null,
+ };
+ },
+
+ mounted() {
+ if (this.$i) {
+ this.connection = markRaw(os.stream.useChannel('gamesReversi'));
+
+ this.connection.on('invited', this.onInvited);
+
+ this.connection.on('matched', this.onMatched);
+
+ this.pingClock = setInterval(() => {
+ if (this.matching) {
+ this.connection.send('ping', {
+ id: this.matching.id
+ });
+ }
+ }, 3000);
+
+ os.api('games/reversi/games', {
+ my: true
+ }).then(games => {
+ this.myGames = games;
+ });
+
+ os.api('games/reversi/invitations').then(invitations => {
+ this.invitations = this.invitations.concat(invitations);
+ });
+ }
+
+ os.api('games/reversi/games').then(games => {
+ this.games = games;
+ this.gamesFetching = false;
+ });
+ },
+
+ beforeUnmount() {
+ if (this.connection) {
+ this.connection.dispose();
+ clearInterval(this.pingClock);
+ }
+ },
+
+ methods: {
+ go(game) {
+ const url = '/games/reversi/' + game.id;
+ if (this.navHook) {
+ this.navHook(url);
+ } else {
+ this.$router.push(url);
+ }
+ },
+
+ async match() {
+ const user = await os.selectUser({ local: true });
+ if (user == null) return;
+ os.api('games/reversi/match', {
+ userId: user.id
+ }).then(res => {
+ if (res == null) {
+ this.matching = user;
+ } else {
+ this.go(res);
+ }
+ });
+ },
+
+ cancel() {
+ this.matching = null;
+ os.api('games/reversi/match/cancel');
+ },
+
+ accept(invitation) {
+ os.api('games/reversi/match', {
+ userId: invitation.parent.id
+ }).then(game => {
+ if (game) {
+ this.go(game);
+ }
+ });
+ },
+
+ onMatched(game) {
+ this.go(game);
+ },
+
+ onInvited(invite) {
+ this.invitations.unshift(invite);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bgvwxkhb {
+ > h1 {
+ margin: 0;
+ padding: 24px;
+ text-align: center;
+ font-size: 1.5em;
+ background: linear-gradient(0deg, #43c583, #438881);
+ color: #fff;
+ }
+
+ > .play {
+ text-align: center;
+ }
+}
+
+.sazhgisb {
+ text-align: center;
+}
+
+.nfcacttm {
+ > .invitation {
+ display: flex;
+ box-sizing: border-box;
+ width: 100%;
+ padding: 16px;
+ line-height: 32px;
+ text-align: left;
+
+ > .avatar {
+ width: 32px;
+ height: 32px;
+ margin-right: 8px;
+ }
+
+ > .name {
+ margin-right: 8px;
+ }
+
+ > .username {
+ margin-right: 8px;
+ opacity: 0.7;
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+}
+
+.knextgwz {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+
+ > .game {
+ > .players {
+ text-align: center;
+ padding: 16px;
+ line-height: 32px;
+
+ > .avatar {
+ width: 32px;
+ height: 32px;
+
+ &:first-child {
+ margin-right: 8px;
+ }
+
+ &:last-child {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > footer {
+ display: flex;
+ align-items: baseline;
+ border-top: solid 0.5px var(--divider);
+ padding: 6px 8px;
+ font-size: 0.9em;
+
+ > .state {
+ &.playing {
+ color: var(--accent);
+ }
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/room/preview.vue b/packages/client/src/pages/room/preview.vue
new file mode 100644
index 0000000000..b0e600d4fb
--- /dev/null
+++ b/packages/client/src/pages/room/preview.vue
@@ -0,0 +1,107 @@
+<template>
+<canvas width="224" height="128"></canvas>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as THREE from 'three';
+import * as os from '@/os';
+
+export default defineComponent({
+ data() {
+ return {
+ selected: null,
+ objectHeight: 0,
+ orbitRadius: 5
+ };
+ },
+
+ mounted() {
+ const canvas = this.$el;
+
+ const width = canvas.width;
+ const height = canvas.height;
+
+ const scene = new THREE.Scene();
+
+ const renderer = new THREE.WebGLRenderer({
+ canvas: canvas,
+ antialias: true,
+ alpha: false
+ });
+ renderer.setPixelRatio(window.devicePixelRatio);
+ renderer.setSize(width, height);
+ renderer.setClearColor(0x000000);
+ renderer.autoClear = false;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.cullFace = THREE.CullFaceBack;
+
+ const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100);
+ camera.zoom = 10;
+ camera.position.x = 0;
+ camera.position.y = 2;
+ camera.position.z = 0;
+ camera.updateProjectionMatrix();
+ scene.add(camera);
+
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1);
+ ambientLight.castShadow = false;
+ scene.add(ambientLight);
+
+ const light = new THREE.PointLight(0xffffff, 1, 100);
+ light.position.set(3, 3, 3);
+ scene.add(light);
+
+ const grid = new THREE.GridHelper(5, 16, 0x444444, 0x222222);
+ scene.add(grid);
+
+ const render = () => {
+ const timer = Date.now() * 0.0004;
+ requestAnimationFrame(render);
+
+ camera.position.y = Math.sin(Math.PI / 6) * this.orbitRadius; // Math.PI / 6 => 30deg
+ camera.position.z = Math.cos(timer) * this.orbitRadius;
+ camera.position.x = Math.sin(timer) * this.orbitRadius;
+ camera.lookAt(new THREE.Vector3(0, this.objectHeight / 2, 0));
+ renderer.render(scene, camera);
+ };
+
+ this.selected = selected => {
+ const obj = selected.clone();
+
+ // Remove current object
+ const current = scene.getObjectByName('obj');
+ if (current != null) {
+ scene.remove(current);
+ }
+
+ // Add new object
+ obj.name = 'obj';
+ obj.position.x = 0;
+ obj.position.y = 0;
+ obj.position.z = 0;
+ obj.rotation.x = 0;
+ obj.rotation.y = 0;
+ obj.rotation.z = 0;
+ obj.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ child.material = child.material.clone();
+ return child.material.emissive.setHex(0x000000);
+ }
+ });
+ const objectBoundingBox = new THREE.Box3().setFromObject(obj);
+ this.objectHeight = objectBoundingBox.max.y - objectBoundingBox.min.y;
+
+ const objectWidth = objectBoundingBox.max.x - objectBoundingBox.min.x;
+ const objectDepth = objectBoundingBox.max.z - objectBoundingBox.min.z;
+
+ const horizontal = Math.hypot(objectWidth, objectDepth) / camera.aspect;
+ this.orbitRadius = Math.max(horizontal, this.objectHeight) * camera.zoom * 0.625 / Math.tan(camera.fov * 0.5 * (Math.PI / 180));
+
+ scene.add(obj);
+ };
+
+ render();
+ },
+});
+</script>
diff --git a/packages/client/src/pages/room/room.vue b/packages/client/src/pages/room/room.vue
new file mode 100644
index 0000000000..1671bcd587
--- /dev/null
+++ b/packages/client/src/pages/room/room.vue
@@ -0,0 +1,285 @@
+<template>
+<div class="hveuntkp">
+ <div class="controller _section" v-if="objectSelected">
+ <div class="_content">
+ <p class="name">{{ selectedFurnitureName }}</p>
+ <XPreview ref="preview"/>
+ <template v-if="selectedFurnitureInfo.props">
+ <div v-for="k in Object.keys(selectedFurnitureInfo.props)" :key="k">
+ <p>{{ k }}</p>
+ <template v-if="selectedFurnitureInfo.props[k] === 'image'">
+ <MkButton @click="chooseImage(k, $event)">{{ $ts._rooms.chooseImage }}</MkButton>
+ </template>
+ <template v-else-if="selectedFurnitureInfo.props[k] === 'color'">
+ <input type="color" :value="selectedFurnitureProps ? selectedFurnitureProps[k] : null" @change="updateColor(k, $event)"/>
+ </template>
+ </div>
+ </template>
+ </div>
+ <div class="_content">
+ <MkButton inline @click="translate()" :primary="isTranslateMode"><i class="fas fa-arrows-alt"></i> {{ $ts._rooms.translate }}</MkButton>
+ <MkButton inline @click="rotate()" :primary="isRotateMode"><i class="fas fa-undo"></i> {{ $ts._rooms.rotate }}</MkButton>
+ <MkButton inline v-if="isTranslateMode || isRotateMode" @click="exit()"><i class="fas fa-ban"></i> {{ $ts._rooms.exit }}</MkButton>
+ </div>
+ <div class="_content">
+ <MkButton @click="remove()"><i class="fas fa-trash-alt"></i> {{ $ts._rooms.remove }}</MkButton>
+ </div>
+ </div>
+
+ <div class="menu _section" v-if="isMyRoom">
+ <div class="_content">
+ <MkButton @click="add()"><i class="fas fa-box-open"></i> {{ $ts._rooms.addFurniture }}</MkButton>
+ </div>
+ <div class="_content">
+ <MkSelect :model-value="roomType" @update:modelValue="updateRoomType($event)">
+ <template #label>{{ $ts._rooms.roomType }}</template>
+ <option value="default">{{ $ts._rooms._roomType.default }}</option>
+ <option value="washitsu">{{ $ts._rooms._roomType.washitsu }}</option>
+ </MkSelect>
+ <label v-if="roomType === 'default'">
+ <span>{{ $ts._rooms.carpetColor }}</span>
+ <input type="color" :value="carpetColor" @change="updateCarpetColor($event)"/>
+ </label>
+ </div>
+ <div class="_content">
+ <MkButton inline :disabled="!changed" primary @click="save()"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+ <MkButton inline @click="clear()"><i class="fas fa-broom"></i> {{ $ts._rooms.clear }}</MkButton>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import { Room } from '@/scripts/room/room';
+import * as Acct from 'misskey-js/built/acct';
+import XPreview from './preview.vue';
+const storeItems = require('@/scripts/room/furnitures.json5');
+import { query as urlQuery } from '@/scripts/url';
+import MkButton from '@/components/ui/button.vue';
+import MkSelect from '@/components/form/select.vue';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+let room: Room;
+
+export default defineComponent({
+ components: {
+ XPreview,
+ MkButton,
+ MkSelect,
+ },
+
+ props: {
+ acct: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.user ? {
+ title: this.$ts.room,
+ avatar: this.user,
+ } : null),
+ user: null,
+ objectSelected: false,
+ selectedFurnitureName: null,
+ selectedFurnitureInfo: null,
+ selectedFurnitureProps: null,
+ roomType: null,
+ carpetColor: null,
+ isTranslateMode: false,
+ isRotateMode: false,
+ isMyRoom: false,
+ changed: false,
+ };
+ },
+
+ async mounted() {
+ window.addEventListener('beforeunload', this.beforeunload);
+
+ this.user = await os.api('users/show', {
+ ...Acct.parse(this.acct)
+ });
+
+ this.isMyRoom = this.$i && (this.$i.id === this.user.id);
+
+ const roomInfo = await os.api('room/show', {
+ userId: this.user.id
+ });
+
+ this.roomType = roomInfo.roomType;
+ this.carpetColor = roomInfo.carpetColor;
+
+ room = new Room(this.user, this.isMyRoom, roomInfo, this.$el, {
+ graphicsQuality: ColdDeviceStorage.get('roomGraphicsQuality'),
+ onChangeSelect: obj => {
+ this.objectSelected = obj != null;
+ if (obj) {
+ const f = room.findFurnitureById(obj.name);
+ this.selectedFurnitureName = this.$t('_rooms._furnitures.' + f.type);
+ this.selectedFurnitureInfo = storeItems.find(x => x.id === f.type);
+ this.selectedFurnitureProps = f.props
+ ? JSON.parse(JSON.stringify(f.props)) // Disable reactivity
+ : null;
+ this.$nextTick(() => {
+ this.$refs.preview.selected(obj);
+ });
+ }
+ },
+ useOrthographicCamera: ColdDeviceStorage.get('roomUseOrthographicCamera'),
+ });
+ },
+
+ beforeRouteLeave(to, from, next) {
+ if (this.changed) {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.leaveConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) {
+ next(false);
+ } else {
+ next();
+ }
+ });
+ } else {
+ next();
+ }
+ },
+
+ beforeUnmount() {
+ room.destroy();
+ window.removeEventListener('beforeunload', this.beforeunload);
+ },
+
+ methods: {
+ beforeunload(e: BeforeUnloadEvent) {
+ if (this.changed) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ },
+
+ async add() {
+ const { canceled, result: id } = await os.dialog({
+ type: null,
+ title: this.$ts._rooms.addFurniture,
+ select: {
+ items: storeItems.map(item => ({
+ value: item.id, text: this.$t('_rooms._furnitures.' + item.id)
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ room.addFurniture(id);
+ this.changed = true;
+ },
+
+ remove() {
+ this.isTranslateMode = false;
+ this.isRotateMode = false;
+ room.removeFurniture();
+ this.changed = true;
+ },
+
+ save() {
+ os.api('room/update', {
+ room: room.getRoomInfo()
+ }).then(() => {
+ this.changed = false;
+ os.success();
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+
+ clear() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts._rooms.clearConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ room.removeAllFurnitures();
+ this.changed = true;
+ });
+ },
+
+ chooseImage(key, e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ room.updateProp(key, `/proxy/?${urlQuery({ url: file.thumbnailUrl })}`);
+ this.$refs.preview.selected(room.getSelectedObject());
+ this.changed = true;
+ });
+ },
+
+ updateColor(key, ev) {
+ room.updateProp(key, ev.target.value);
+ this.$refs.preview.selected(room.getSelectedObject());
+ this.changed = true;
+ },
+
+ updateCarpetColor(ev) {
+ room.updateCarpetColor(ev.target.value);
+ this.carpetColor = ev.target.value;
+ this.changed = true;
+ },
+
+ updateRoomType(type) {
+ room.changeRoomType(type);
+ this.roomType = type;
+ this.changed = true;
+ },
+
+ translate() {
+ if (this.isTranslateMode) {
+ this.exit();
+ } else {
+ this.isRotateMode = false;
+ this.isTranslateMode = true;
+ room.enterTransformMode('translate');
+ }
+ this.changed = true;
+ },
+
+ rotate() {
+ if (this.isRotateMode) {
+ this.exit();
+ } else {
+ this.isTranslateMode = false;
+ this.isRotateMode = true;
+ room.enterTransformMode('rotate');
+ }
+ this.changed = true;
+ },
+
+ exit() {
+ this.isTranslateMode = false;
+ this.isRotateMode = false;
+ room.exitTransformMode();
+ this.changed = true;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hveuntkp {
+ position: relative;
+ min-height: 500px;
+
+ > ::v-deep(canvas) {
+ display: block;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
new file mode 100644
index 0000000000..c26658cbc4
--- /dev/null
+++ b/packages/client/src/pages/scratchpad.vue
@@ -0,0 +1,149 @@
+<template>
+<div class="iltifgqe">
+ <div class="editor _panel _gap">
+ <PrismEditor class="_code code" v-model="code" :highlight="highlighter" :line-numbers="false"/>
+ <MkButton style="position: absolute; top: 8px; right: 8px;" @click="run()" primary><i class="fas fa-play"></i></MkButton>
+ </div>
+
+ <MkContainer :foldable="true" class="_gap">
+ <template #header>{{ $ts.output }}</template>
+ <div class="bepmlvbi">
+ <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div>
+ </div>
+ </MkContainer>
+
+ <div class="_gap">
+ {{ $ts.scratchpadDescription }}
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import 'prismjs';
+import { highlight, languages } from 'prismjs/components/prism-core';
+import 'prismjs/components/prism-clike';
+import 'prismjs/components/prism-javascript';
+import 'prismjs/themes/prism-okaidia.css';
+import { PrismEditor } from 'vue-prism-editor';
+import 'vue-prism-editor/dist/prismeditor.min.css';
+import { AiScript, parse, utils, values } from '@syuilo/aiscript';
+import MkContainer from '@/components/ui/container.vue';
+import MkButton from '@/components/ui/button.vue';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ MkButton,
+ PrismEditor,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.scratchpad,
+ icon: 'fas fa-terminal',
+ },
+ code: '',
+ logs: [],
+ }
+ },
+
+ watch: {
+ code() {
+ localStorage.setItem('scratchpad', this.code);
+ }
+ },
+
+ created() {
+ const saved = localStorage.getItem('scratchpad');
+ if (saved) {
+ this.code = saved;
+ }
+ },
+
+ methods: {
+ async run() {
+ this.logs = [];
+ const aiscript = new AiScript(createAiScriptEnv({
+ storageKey: 'scratchpad',
+ token: this.$i?.token,
+ }), {
+ in: (q) => {
+ return new Promise(ok => {
+ os.dialog({
+ title: q,
+ input: {}
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ this.logs.push({
+ id: Math.random(),
+ text: value.type === 'str' ? value.value : utils.valToString(value),
+ print: true
+ });
+ },
+ log: (type, params) => {
+ switch (type) {
+ case 'end': this.logs.push({
+ id: Math.random(),
+ text: utils.valToString(params.val, true),
+ print: false
+ }); break;
+ default: break;
+ }
+ }
+ });
+
+ let ast;
+ try {
+ ast = parse(this.code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ try {
+ await aiscript.exec(ast);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ }
+ },
+
+ highlighter(code) {
+ return highlight(code, languages.js, 'javascript');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.iltifgqe {
+ padding: 16px;
+
+ > .editor {
+ position: relative;
+ }
+}
+
+.bepmlvbi {
+ padding: 16px;
+
+ > .log {
+ &:not(.print) {
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue
new file mode 100644
index 0000000000..c7da3fe1c1
--- /dev/null
+++ b/packages/client/src/pages/search.vue
@@ -0,0 +1,53 @@
+<template>
+<div class="_section">
+ <div class="_content">
+ <XNotes ref="notes" :pagination="pagination" @before="before" @after="after"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: computed(() => this.$t('searchWith', { q: this.$route.query.q })),
+ icon: 'fas fa-search',
+ },
+ pagination: {
+ endpoint: 'notes/search',
+ limit: 10,
+ params: () => ({
+ query: this.$route.query.q,
+ channelId: this.$route.query.channel,
+ })
+ },
+ };
+ },
+
+ watch: {
+ $route() {
+ (this.$refs.notes as any).reload();
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue
new file mode 100644
index 0000000000..dce217559a
--- /dev/null
+++ b/packages/client/src/pages/settings/2fa.vue
@@ -0,0 +1,247 @@
+<template>
+<section class="_card">
+ <div class="_title"><i class="fas fa-lock"></i> {{ $ts.twoStepAuthentication }}</div>
+ <div class="_content">
+ <MkButton v-if="!data && !$i.twoFactorEnabled" @click="register">{{ $ts._2fa.registerDevice }}</MkButton>
+ <template v-if="$i.twoFactorEnabled">
+ <p>{{ $ts._2fa.alreadyRegistered }}</p>
+ <MkButton @click="unregister">{{ $ts.unregister }}</MkButton>
+
+ <template v-if="supportsCredentials">
+ <hr class="totp-method-sep">
+
+ <h2 class="heading">{{ $ts.securityKey }}</h2>
+ <p>{{ $ts._2fa.securityKeyInfo }}</p>
+ <div class="key-list">
+ <div class="key" v-for="key in $i.securityKeysList">
+ <h3>{{ key.name }}</h3>
+ <div class="last-used">{{ $ts.lastUsed }}<MkTime :time="key.lastUsed"/></div>
+ <MkButton @click="unregisterKey(key)">{{ $ts.unregister }}</MkButton>
+ </div>
+ </div>
+
+ <MkSwitch v-model="usePasswordLessLogin" @update:modelValue="updatePasswordLessLogin" v-if="$i.securityKeysList.length > 0">{{ $ts.passwordLessLogin }}</MkSwitch>
+
+ <MkInfo warn v-if="registration && registration.error">{{ $ts.error }} {{ registration.error }}</MkInfo>
+ <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $ts._2fa.registerKey }}</MkButton>
+
+ <ol v-if="registration && !registration.error">
+ <li v-if="registration.stage >= 0">
+ {{ $ts.tapSecurityKey }}
+ <i v-if="registration.saving && registration.stage == 0" class="fas fa-spinner fa-pulse fa-fw"></i>
+ </li>
+ <li v-if="registration.stage >= 1">
+ <MkForm :disabled="registration.stage != 1 || registration.saving">
+ <MkInput v-model="keyName" :max="30">
+ <template #label>{{ $ts.securityKeyName }}</template>
+ </MkInput>
+ <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $ts.registerSecurityKey }}</MkButton>
+ <i v-if="registration.saving && registration.stage == 1" class="fas fa-spinner fa-pulse fa-fw"></i>
+ </MkForm>
+ </li>
+ </ol>
+ </template>
+ </template>
+ <div v-if="data && !$i.twoFactorEnabled">
+ <ol style="margin: 0; padding: 0 0 0 1em;">
+ <li>
+ <I18n :src="$ts._2fa.step1" tag="span">
+ <template #a>
+ <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
+ </template>
+ <template #b>
+ <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
+ </template>
+ </I18n>
+ </li>
+ <li>{{ $ts._2fa.step2 }}<br><img :src="data.qr"></li>
+ <li>{{ $ts._2fa.step3 }}<br>
+ <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ $ts.token }}</template></MkInput>
+ <MkButton primary @click="submit">{{ $ts.done }}</MkButton>
+ </li>
+ </ol>
+ <MkInfo>{{ $ts._2fa.step4 }}</MkInfo>
+ </div>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { hostname } from '@/config';
+import { byteify, hexify, stringify } from '@/scripts/2fa';
+import MkButton from '@/components/ui/button.vue';
+import MkInfo from '@/components/ui/info.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkButton, MkInfo, MkInput, MkSwitch
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.twoStepAuthentication,
+ icon: 'fas fa-lock'
+ },
+ data: null,
+ supportsCredentials: !!navigator.credentials,
+ usePasswordLessLogin: this.$i.usePasswordLessLogin,
+ registration: null,
+ keyName: '',
+ token: null,
+ };
+ },
+
+ methods: {
+ register() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register', {
+ password: password
+ }).then(data => {
+ this.data = data;
+ });
+ });
+ },
+
+ unregister() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/unregister', {
+ password: password
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ this.$i.twoFactorEnabled = false;
+ });
+ });
+ },
+
+ submit() {
+ os.api('i/2fa/done', {
+ token: this.token
+ }).then(() => {
+ os.success();
+ this.$i.twoFactorEnabled = true;
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+ },
+
+ registerKey() {
+ this.registration.saving = true;
+ os.api('i/2fa/key-done', {
+ password: this.registration.password,
+ name: this.keyName,
+ challengeId: this.registration.challengeId,
+ // we convert each 16 bits to a string to serialise
+ clientDataJSON: stringify(this.registration.credential.response.clientDataJSON),
+ attestationObject: hexify(this.registration.credential.response.attestationObject)
+ }).then(key => {
+ this.registration = null;
+ key.lastUsed = new Date();
+ os.success();
+ })
+ },
+
+ unregisterKey(key) {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ return os.api('i/2fa/remove-key', {
+ password,
+ credentialId: key.id
+ }).then(() => {
+ this.usePasswordLessLogin = false;
+ this.updatePasswordLessLogin();
+ }).then(() => {
+ os.success();
+ });
+ });
+ },
+
+ addSecurityKey() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/2fa/register-key', {
+ password
+ }).then(registration => {
+ this.registration = {
+ password,
+ challengeId: registration.challengeId,
+ stage: 0,
+ publicKeyOptions: {
+ challenge: byteify(registration.challenge, 'base64'),
+ rp: {
+ id: hostname,
+ name: 'Misskey'
+ },
+ user: {
+ id: byteify(this.$i.id, 'ascii'),
+ name: this.$i.username,
+ displayName: this.$i.name,
+ },
+ pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
+ timeout: 60000,
+ attestation: 'direct'
+ },
+ saving: true
+ };
+ return navigator.credentials.create({
+ publicKey: this.registration.publicKeyOptions
+ });
+ }).then(credential => {
+ this.registration.credential = credential;
+ this.registration.saving = false;
+ this.registration.stage = 1;
+ }).catch(err => {
+ console.warn('Error while registering?', err);
+ this.registration.error = err.message;
+ this.registration.stage = -1;
+ });
+ });
+ },
+
+ updatePasswordLessLogin() {
+ os.api('i/2fa/password-less', {
+ value: !!this.usePasswordLessLogin
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue
new file mode 100644
index 0000000000..f3d5e2f2c3
--- /dev/null
+++ b/packages/client/src/pages/settings/account-info.vue
@@ -0,0 +1,185 @@
+<template>
+<FormBase>
+ <FormKeyValueView>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ $i.id }}</span></template>
+ </FormKeyValueView>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.registeredDate }}</template>
+ <template #value><MkTime :time="$i.createdAt" mode="detail"/></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="stats">
+ <template #label>{{ $ts.statistics }}</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.notesCount }}</template>
+ <template #value>{{ number(stats.notesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.repliesCount }}</template>
+ <template #value>{{ number(stats.repliesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.renotesCount }}</template>
+ <template #value>{{ number(stats.renotesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.repliedCount }}</template>
+ <template #value>{{ number(stats.repliedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.renotedCount }}</template>
+ <template #value>{{ number(stats.renotedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pollVotesCount }}</template>
+ <template #value>{{ number(stats.pollVotesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pollVotedCount }}</template>
+ <template #value>{{ number(stats.pollVotedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.sentReactionsCount }}</template>
+ <template #value>{{ number(stats.sentReactionsCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.receivedReactionsCount }}</template>
+ <template #value>{{ number(stats.receivedReactionsCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.noteFavoritesCount }}</template>
+ <template #value>{{ number(stats.noteFavoritesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followingCount }}</template>
+ <template #value>{{ number(stats.followingCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followingCount }} ({{ $ts.local }})</template>
+ <template #value>{{ number(stats.localFollowingCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followingCount }} ({{ $ts.remote }})</template>
+ <template #value>{{ number(stats.remoteFollowingCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followersCount }}</template>
+ <template #value>{{ number(stats.followersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followersCount }} ({{ $ts.local }})</template>
+ <template #value>{{ number(stats.localFollowersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.followersCount }} ({{ $ts.remote }})</template>
+ <template #value>{{ number(stats.remoteFollowersCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pageLikesCount }}</template>
+ <template #value>{{ number(stats.pageLikesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.pageLikedCount }}</template>
+ <template #value>{{ number(stats.pageLikedCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.driveFilesCount }}</template>
+ <template #value>{{ number(stats.driveFilesCount) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.driveUsage }}</template>
+ <template #value>{{ bytes(stats.driveUsage) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.reversiCount }}</template>
+ <template #value>{{ number(stats.reversiCount) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <template #label>{{ $ts.other }}</template>
+ <FormKeyValueView>
+ <template #key>emailVerified</template>
+ <template #value>{{ $i.emailVerified ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>twoFactorEnabled</template>
+ <template #value>{{ $i.twoFactorEnabled ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>securityKeys</template>
+ <template #value>{{ $i.securityKeys ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>usePasswordLessLogin</template>
+ <template #value>{{ $i.usePasswordLessLogin ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>isModerator</template>
+ <template #value>{{ $i.isModerator ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>isAdmin</template>
+ <template #value>{{ $i.isAdmin ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.accountInfo,
+ icon: 'fas fa-info-circle'
+ },
+ stats: null
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('users/stats', {
+ userId: this.$i.id
+ }).then(stats => {
+ this.stats = stats;
+ });
+ },
+
+ methods: {
+ number,
+ bytes,
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue
new file mode 100644
index 0000000000..94a3c9483d
--- /dev/null
+++ b/packages/client/src/pages/settings/accounts.vue
@@ -0,0 +1,149 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <FormButton @click="addAccount" primary><i class="fas fa-plus"></i> {{ $ts.addAccount }}</FormButton>
+
+ <div class="_debobigegoItem _button" v-for="account in accounts" :key="account.id" @click="menu(account, $event)">
+ <div class="_debobigegoPanel lcjjdxlm">
+ <div class="avatar">
+ <MkAvatar :user="account" class="avatar"/>
+ </div>
+ <div class="body">
+ <div class="name">
+ <MkUserName :user="account"/>
+ </div>
+ <div class="acct">
+ <MkAcct :user="account"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { getAccounts, addAccount, login } from '@/account';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSuspense,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.accounts,
+ icon: 'fas fa-users',
+ bg: 'var(--bg)',
+ },
+ storedAccounts: getAccounts().then(accounts => accounts.filter(x => x.id !== this.$i.id)),
+ accounts: null,
+ init: async () => os.api('users/show', {
+ userIds: (await this.storedAccounts).map(x => x.id)
+ }).then(accounts => {
+ this.accounts = accounts;
+ }),
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ menu(account, ev) {
+ os.popupMenu([{
+ text: this.$ts.switch,
+ icon: 'fas fa-exchange-alt',
+ action: () => this.switchAccount(account),
+ }, {
+ text: this.$ts.remove,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => this.removeAccount(account),
+ }], ev.currentTarget || ev.target);
+ },
+
+ addAccount(ev) {
+ os.popupMenu([{
+ text: this.$ts.existingAccount,
+ action: () => { this.addExistingAccount(); },
+ }, {
+ text: this.$ts.createAccount,
+ action: () => { this.createAccount(); },
+ }], ev.currentTarget || ev.target);
+ },
+
+ addExistingAccount() {
+ os.popup(import('@/components/signin-dialog.vue'), {}, {
+ done: res => {
+ addAccount(res.id, res.i);
+ os.success();
+ },
+ }, 'closed');
+ },
+
+ createAccount() {
+ os.popup(import('@/components/signup-dialog.vue'), {}, {
+ done: res => {
+ addAccount(res.id, res.i);
+ this.switchAccountWithToken(res.i);
+ },
+ }, 'closed');
+ },
+
+ async switchAccount(account: any) {
+ const storedAccounts = await getAccounts();
+ const token = storedAccounts.find(x => x.id === account.id).token;
+ this.switchAccountWithToken(token);
+ },
+
+ switchAccountWithToken(token: string) {
+ login(token);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lcjjdxlm {
+ display: flex;
+ padding: 16px;
+
+ > .avatar {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+
+ > .avatar {
+ width: 50px;
+ height: 50px;
+ }
+ }
+
+ > .body {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: calc(100% - 62px);
+ position: relative;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue
new file mode 100644
index 0000000000..1def0189ec
--- /dev/null
+++ b/packages/client/src/pages/settings/api.vue
@@ -0,0 +1,65 @@
+<template>
+<FormBase>
+ <FormButton @click="generateToken" primary>{{ $ts.generateAccessToken }}</FormButton>
+ <FormLink to="/settings/apps">{{ $ts.manageAccessTokens }}</FormLink>
+ <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'API',
+ icon: 'fas fa-key',
+ bg: 'var(--bg)',
+ },
+ isDesktop: window.innerWidth >= 1100,
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ generateToken() {
+ os.popup(import('@/components/token-generate-window.vue'), {}, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+
+ os.dialog({
+ type: 'success',
+ title: this.$ts.token,
+ text: token
+ });
+ },
+ }, 'closed');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue
new file mode 100644
index 0000000000..6eec80d805
--- /dev/null
+++ b/packages/client/src/pages/settings/apps.vue
@@ -0,0 +1,113 @@
+<template>
+<FormBase>
+ <FormPagination :pagination="pagination" ref="list">
+ <template #empty>
+ <div class="_fullinfo">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.nothing }}</div>
+ </div>
+ </template>
+ <template #default="{items}">
+ <div class="_debobigegoPanel bfomjevm" v-for="token in items" :key="token.id">
+ <img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/>
+ <div class="body">
+ <div class="name">{{ token.name }}</div>
+ <div class="description">{{ token.description }}</div>
+ <div class="_keyValue">
+ <div>{{ $ts.installedDate }}:</div>
+ <div><MkTime :time="token.createdAt"/></div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $ts.lastUsedDate }}:</div>
+ <div><MkTime :time="token.lastUsedAt"/></div>
+ </div>
+ <div class="actions">
+ <button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button>
+ </div>
+ <details>
+ <summary>{{ $ts.details }}</summary>
+ <ul>
+ <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li>
+ </ul>
+ </details>
+ </div>
+ </div>
+ </template>
+ </FormPagination>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormPagination from '@/components/debobigego/pagination.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormPagination,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.installedApps,
+ icon: 'fas fa-plug',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'i/apps',
+ limit: 100,
+ params: {
+ sort: '+lastUsedAt'
+ }
+ },
+ };
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ revoke(token) {
+ os.api('i/revoke-token', { tokenId: token.id }).then(() => {
+ this.$refs.list.reload();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.bfomjevm {
+ display: flex;
+ padding: 16px;
+
+ > .icon {
+ display: block;
+ flex-shrink: 0;
+ margin: 0 12px 0 0;
+ width: 50px;
+ height: 50px;
+ border-radius: 8px;
+ }
+
+ > .body {
+ width: calc(100% - 62px);
+ position: relative;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue
new file mode 100644
index 0000000000..8c878fb084
--- /dev/null
+++ b/packages/client/src/pages/settings/custom-css.vue
@@ -0,0 +1,73 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts.customCssWarn }}</FormInfo>
+
+ <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;">
+ <span>{{ $ts.local }}</span>
+ </FormTextarea>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+import { defaultStore } from '@/store';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.customCss,
+ icon: 'fas fa-code',
+ bg: 'var(--bg)',
+ },
+ localCustomCss: localStorage.getItem('customCss')
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ this.$watch('localCustomCss', this.apply);
+ },
+
+ methods: {
+ async apply() {
+ localStorage.setItem('customCss', this.localCustomCss);
+
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue
new file mode 100644
index 0000000000..a96c6cd685
--- /dev/null
+++ b/packages/client/src/pages/settings/deck.vue
@@ -0,0 +1,107 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <template #label>{{ $ts.defaultNavigationBehaviour }}</template>
+ <FormSwitch v-model="navWindow">{{ $ts.openInWindow }}</FormSwitch>
+ </FormGroup>
+
+ <FormSwitch v-model="alwaysShowMainColumn">{{ $ts._deck.alwaysShowMainColumn }}</FormSwitch>
+
+ <FormRadios v-model="columnAlign">
+ <template #desc>{{ $ts._deck.columnAlign }}</template>
+ <option value="left">{{ $ts.left }}</option>
+ <option value="center">{{ $ts.center }}</option>
+ </FormRadios>
+
+ <FormRadios v-model="columnHeaderHeight">
+ <template #desc>{{ $ts._deck.columnHeaderHeight }}</template>
+ <option :value="42">{{ $ts.narrow }}</option>
+ <option :value="45">{{ $ts.medium }}</option>
+ <option :value="48">{{ $ts.wide }}</option>
+ </FormRadios>
+
+ <FormInput v-model="columnMargin" type="number">
+ <span>{{ $ts._deck.columnMargin }}</span>
+ <template #suffix>px</template>
+ </FormInput>
+
+ <FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { deckStore } from '@/ui/deck/deck-store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormLink,
+ FormInput,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.deck,
+ icon: 'fas fa-columns',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ computed: {
+ navWindow: deckStore.makeGetterSetter('navWindow'),
+ alwaysShowMainColumn: deckStore.makeGetterSetter('alwaysShowMainColumn'),
+ columnAlign: deckStore.makeGetterSetter('columnAlign'),
+ columnMargin: deckStore.makeGetterSetter('columnMargin'),
+ columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'),
+ profile: deckStore.makeGetterSetter('profile'),
+ },
+
+ watch: {
+ async navWindow() {
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async setProfile() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts._deck.profile,
+ input: {
+ allowEmpty: false
+ }
+ });
+ if (canceled) return;
+ this.profile = name;
+ unisonReload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue
new file mode 100644
index 0000000000..018f7c795e
--- /dev/null
+++ b/packages/client/src/pages/settings/delete-account.vue
@@ -0,0 +1,68 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts._accountDelete.mayTakeTime }}</FormInfo>
+ <FormInfo>{{ $ts._accountDelete.sendEmail }}</FormInfo>
+ <FormButton @click="deleteAccount" danger v-if="!$i.isDeleted">{{ $ts._accountDelete.requestAccountDelete }}</FormButton>
+ <FormButton disabled v-else>{{ $ts._accountDelete.inProgress }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { debug } from '@/config';
+import { signout } from '@/account';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormGroup,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._accountDelete.accountDelete,
+ icon: 'fas fa-exclamation-triangle',
+ bg: 'var(--bg)',
+ },
+ debug,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async deleteAccount() {
+ const { canceled, result: password } = await os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled) return;
+
+ await os.apiWithDialog('i/delete-account', {
+ password: password
+ });
+
+ await os.dialog({
+ title: this.$ts._accountDelete.started,
+ });
+
+ signout();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue
new file mode 100644
index 0000000000..ed5282e23d
--- /dev/null
+++ b/packages/client/src/pages/settings/drive.vue
@@ -0,0 +1,147 @@
+<template>
+<FormBase class="">
+ <FormGroup v-if="!fetching">
+ <template #label>{{ $ts.usageAmount }}</template>
+ <div class="_debobigegoItem uawsfosz">
+ <div class="_debobigegoPanel">
+ <div class="meter"><div :style="meterStyle"></div></div>
+ </div>
+ </div>
+ <FormKeyValueView>
+ <template #key>{{ $ts.capacity }}</template>
+ <template #value>{{ bytes(capacity, 1) }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.inUse }}</template>
+ <template #value>{{ bytes(usage, 1) }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <div class="_debobigegoItem">
+ <div class="_debobigegoLabel">{{ $ts.statistics }}</div>
+ <div class="_debobigegoPanel">
+ <div ref="chart"></div>
+ </div>
+ </div>
+
+ <FormButton :center="false" @click="chooseUploadFolder()" primary>
+ {{ $ts.uploadFolder }}
+ <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
+ <template #suffixIcon><i class="fas fa-folder-open"></i></template>
+ </FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as tinycolor from 'tinycolor2';
+import FormButton from '@/components/debobigego/button.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import * as os from '@/os';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+
+// TODO: render chart
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.drive,
+ icon: 'fas fa-cloud',
+ bg: 'var(--bg)',
+ },
+ fetching: true,
+ usage: null,
+ capacity: null,
+ uploadFolder: null,
+ }
+ },
+
+ computed: {
+ meterStyle(): any {
+ return {
+ width: `${this.usage / this.capacity * 100}%`,
+ background: tinycolor({
+ h: 180 - (this.usage / this.capacity * 180),
+ s: 0.7,
+ l: 0.5
+ })
+ };
+ }
+ },
+
+ async created() {
+ os.api('drive').then(info => {
+ this.capacity = info.capacity;
+ this.usage = info.usage;
+ this.fetching = false;
+ this.$nextTick(() => {
+ this.renderChart();
+ });
+ });
+
+ if (this.$store.state.uploadFolder) {
+ this.uploadFolder = await os.api('drive/folders/show', {
+ folderId: this.$store.state.uploadFolder
+ });
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ chooseUploadFolder() {
+ os.selectDriveFolder(false).then(async folder => {
+ this.$store.set('uploadFolder', folder ? folder.id : null);
+ os.success();
+ if (this.$store.state.uploadFolder) {
+ this.uploadFolder = await os.api('drive/folders/show', {
+ folderId: this.$store.state.uploadFolder
+ });
+ } else {
+ this.uploadFolder = null;
+ }
+ });
+ },
+
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.uawsfosz {
+ > div {
+ padding: 24px;
+
+ > .meter {
+ $size: 12px;
+ background: rgba(0, 0, 0, 0.1);
+ border-radius: math.div($size, 2);
+ overflow: hidden;
+
+ > div {
+ height: $size;
+ border-radius: math.div($size, 2);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/email-address.vue b/packages/client/src/pages/settings/email-address.vue
new file mode 100644
index 0000000000..476d0c0e17
--- /dev/null
+++ b/packages/client/src/pages/settings/email-address.vue
@@ -0,0 +1,70 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormInput v-model="emailAddress" type="email">
+ {{ $ts.emailAddress }}
+ <template #desc v-if="$i.email && !$i.emailVerified">{{ $ts.verificationEmailSent }}</template>
+ <template #desc v-else-if="emailAddress === $i.email && $i.emailVerified">{{ $ts.emailVerified }}</template>
+ </FormInput>
+ </FormGroup>
+ <FormButton @click="save" primary>{{ $ts.save }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInput from '@/components/form/input.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormInput,
+ FormButton,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.emailAddress,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ emailAddress: null,
+ code: null,
+ }
+ },
+
+ created() {
+ this.emailAddress = this.$i.email;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/update-email', {
+ password: password,
+ email: this.emailAddress,
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/email-notification.vue b/packages/client/src/pages/settings/email-notification.vue
new file mode 100644
index 0000000000..c1735a0728
--- /dev/null
+++ b/packages/client/src/pages/settings/email-notification.vue
@@ -0,0 +1,91 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormSwitch v-model="mention">
+ {{ $ts._notification._types.mention }}
+ </FormSwitch>
+ <FormSwitch v-model="reply">
+ {{ $ts._notification._types.reply }}
+ </FormSwitch>
+ <FormSwitch v-model="quote">
+ {{ $ts._notification._types.quote }}
+ </FormSwitch>
+ <FormSwitch v-model="follow">
+ {{ $ts._notification._types.follow }}
+ </FormSwitch>
+ <FormSwitch v-model="receiveFollowRequest">
+ {{ $ts._notification._types.receiveFollowRequest }}
+ </FormSwitch>
+ <FormSwitch v-model="groupInvited">
+ {{ $ts._notification._types.groupInvited }}
+ </FormSwitch>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSwitch,
+ FormButton,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.emailNotification,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+
+ mention: this.$i.emailNotificationTypes.includes('mention'),
+ reply: this.$i.emailNotificationTypes.includes('reply'),
+ quote: this.$i.emailNotificationTypes.includes('quote'),
+ follow: this.$i.emailNotificationTypes.includes('follow'),
+ receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'),
+ groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'),
+ }
+ },
+
+ created() {
+ this.$watch('mention', this.save);
+ this.$watch('reply', this.save);
+ this.$watch('quote', this.save);
+ this.$watch('follow', this.save);
+ this.$watch('receiveFollowRequest', this.save);
+ this.$watch('groupInvited', this.save);
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ os.api('i/update', {
+ emailNotificationTypes: [
+ ...[this.mention ? 'mention' : null],
+ ...[this.reply ? 'reply' : null],
+ ...[this.quote ? 'quote' : null],
+ ...[this.follow ? 'follow' : null],
+ ...[this.receiveFollowRequest ? 'receiveFollowRequest' : null],
+ ...[this.groupInvited ? 'groupInvited' : null],
+ ].filter(x => x != null)
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue
new file mode 100644
index 0000000000..d1dda20f00
--- /dev/null
+++ b/packages/client/src/pages/settings/email.vue
@@ -0,0 +1,66 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <template #label>{{ $ts.emailAddress }}</template>
+ <FormLink to="/settings/email/address">
+ <template v-if="$i.email && !$i.emailVerified" #icon><i class="fas fa-exclamation-triangle" style="color: var(--warn);"></i></template>
+ <template v-else-if="$i.email && $i.emailVerified" #icon><i class="fas fa-check" style="color: var(--success);"></i></template>
+ {{ $i.email || $ts.notSet }}
+ </FormLink>
+ </FormGroup>
+
+ <FormLink to="/settings/email/notification">
+ <template #icon><i class="fas fa-bell"></i></template>
+ {{ $ts.emailNotification }}
+ </FormLink>
+
+ <FormSwitch :value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail">
+ {{ $ts.receiveAnnouncementFromInstance }}
+ </FormSwitch>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ FormButton,
+ FormSwitch,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.email,
+ icon: 'fas fa-envelope',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ onChangeReceiveAnnouncementEmail(v) {
+ os.api('i/update', {
+ receiveAnnouncementEmail: v
+ });
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/experimental-features.vue b/packages/client/src/pages/settings/experimental-features.vue
new file mode 100644
index 0000000000..5a7bcb3b41
--- /dev/null
+++ b/packages/client/src/pages/settings/experimental-features.vue
@@ -0,0 +1,52 @@
+<template>
+<FormBase>
+ <FormButton @click="error()">error test</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.experimentalFeatures,
+ icon: 'fas fa-flask'
+ },
+ stats: null
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ error() {
+ throw new Error('Test error');
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue
new file mode 100644
index 0000000000..8e3dcc3e41
--- /dev/null
+++ b/packages/client/src/pages/settings/general.vue
@@ -0,0 +1,223 @@
+<template>
+<FormBase>
+ <FormSwitch v-model="showFixedPostForm">{{ $ts.showFixedPostForm }}</FormSwitch>
+
+ <FormSelect v-model="lang">
+ <template #label>{{ $ts.uiLanguage }}</template>
+ <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+ <template #caption>
+ <I18n :src="$ts.i18nInfo" tag="span">
+ <template #link>
+ <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
+ </template>
+ </I18n>
+ </template>
+ </FormSelect>
+
+ <FormGroup>
+ <template #label>{{ $ts.behavior }}</template>
+ <FormSwitch v-model="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch>
+ <FormSwitch v-model="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch>
+ <FormSwitch v-model="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch>
+ <FormSwitch v-model="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch>
+ </FormGroup>
+
+ <FormSelect v-model="serverDisconnectedBehavior">
+ <template #label>{{ $ts.whenServerDisconnected }}</template>
+ <option value="reload">{{ $ts._serverDisconnectedBehavior.reload }}</option>
+ <option value="dialog">{{ $ts._serverDisconnectedBehavior.dialog }}</option>
+ <option value="quiet">{{ $ts._serverDisconnectedBehavior.quiet }}</option>
+ </FormSelect>
+
+ <FormGroup>
+ <template #label>{{ $ts.appearance }}</template>
+ <FormSwitch v-model="disableAnimatedMfm">{{ $ts.disableAnimatedMfm }}</FormSwitch>
+ <FormSwitch v-model="reduceAnimation">{{ $ts.reduceUiAnimation }}</FormSwitch>
+ <FormSwitch v-model="useBlurEffect">{{ $ts.useBlurEffect }}</FormSwitch>
+ <FormSwitch v-model="useBlurEffectForModal">{{ $ts.useBlurEffectForModal }}</FormSwitch>
+ <FormSwitch v-model="showGapBetweenNotesInTimeline">{{ $ts.showGapBetweenNotesInTimeline }}</FormSwitch>
+ <FormSwitch v-model="loadRawImages">{{ $ts.loadRawImages }}</FormSwitch>
+ <FormSwitch v-model="disableShowingAnimatedImages">{{ $ts.disableShowingAnimatedImages }}</FormSwitch>
+ <FormSwitch v-model="squareAvatars">{{ $ts.squareAvatars }}</FormSwitch>
+ <FormSwitch v-model="useSystemFont">{{ $ts.useSystemFont }}</FormSwitch>
+ <FormSwitch v-model="useOsNativeEmojis">{{ $ts.useOsNativeEmojis }}
+ <div><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪" :key="useOsNativeEmojis"/></div>
+ </FormSwitch>
+ </FormGroup>
+
+ <FormGroup>
+ <FormSwitch v-model="aiChanMode">{{ $ts.aiChanMode }}</FormSwitch>
+ </FormGroup>
+
+ <FormRadios v-model="fontSize">
+ <template #desc>{{ $ts.fontSize }}</template>
+ <option value="small"><span style="font-size: 14px;">Aa</span></option>
+ <option :value="null"><span style="font-size: 16px;">Aa</span></option>
+ <option value="large"><span style="font-size: 18px;">Aa</span></option>
+ <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option>
+ </FormRadios>
+
+ <FormSelect v-model="instanceTicker">
+ <template #label>{{ $ts.instanceTicker }}</template>
+ <option value="none">{{ $ts._instanceTicker.none }}</option>
+ <option value="remote">{{ $ts._instanceTicker.remote }}</option>
+ <option value="always">{{ $ts._instanceTicker.always }}</option>
+ </FormSelect>
+
+ <FormSelect v-model="nsfw">
+ <template #label>{{ $ts.nsfw }}</template>
+ <option value="respect">{{ $ts._nsfw.respect }}</option>
+ <option value="ignore">{{ $ts._nsfw.ignore }}</option>
+ <option value="force">{{ $ts._nsfw.force }}</option>
+ </FormSelect>
+
+ <FormGroup>
+ <template #label>{{ $ts.defaultNavigationBehaviour }}</template>
+ <FormSwitch v-model="defaultSideView">{{ $ts.openInSideView }}</FormSwitch>
+ </FormGroup>
+
+ <FormSelect v-model="chatOpenBehavior">
+ <template #label>{{ $ts.chatOpenBehavior }}</template>
+ <option value="page">{{ $ts.showInPage }}</option>
+ <option value="window">{{ $ts.openInWindow }}</option>
+ <option value="popout">{{ $ts.popout }}</option>
+ </FormSelect>
+
+ <FormLink to="/settings/deck">{{ $ts.deck }}</FormLink>
+
+ <FormLink to="/settings/custom-css"><template #icon><i class="fas fa-code"></i></template>{{ $ts.customCss }}</FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import MkLink from '@/components/link.vue';
+import { langs } from '@/config';
+import { defaultStore } from '@/store';
+import { ColdDeviceStorage } from '@/store';
+import * as os from '@/os';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkLink,
+ FormSwitch,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.general,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)'
+ },
+ langs,
+ lang: localStorage.getItem('lang'),
+ fontSize: localStorage.getItem('fontSize'),
+ useSystemFont: localStorage.getItem('useSystemFont') != null,
+ }
+ },
+
+ computed: {
+ serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'),
+ reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v),
+ useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'),
+ useBlurEffect: defaultStore.makeGetterSetter('useBlurEffect'),
+ showGapBetweenNotesInTimeline: defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'),
+ disableAnimatedMfm: defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v),
+ useOsNativeEmojis: defaultStore.makeGetterSetter('useOsNativeEmojis'),
+ disableShowingAnimatedImages: defaultStore.makeGetterSetter('disableShowingAnimatedImages'),
+ loadRawImages: defaultStore.makeGetterSetter('loadRawImages'),
+ imageNewTab: defaultStore.makeGetterSetter('imageNewTab'),
+ nsfw: defaultStore.makeGetterSetter('nsfw'),
+ disablePagesScript: defaultStore.makeGetterSetter('disablePagesScript'),
+ showFixedPostForm: defaultStore.makeGetterSetter('showFixedPostForm'),
+ defaultSideView: defaultStore.makeGetterSetter('defaultSideView'),
+ chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'),
+ instanceTicker: defaultStore.makeGetterSetter('instanceTicker'),
+ enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'),
+ useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'),
+ squareAvatars: defaultStore.makeGetterSetter('squareAvatars'),
+ aiChanMode: defaultStore.makeGetterSetter('aiChanMode'),
+ },
+
+ watch: {
+ lang() {
+ localStorage.setItem('lang', this.lang);
+ localStorage.removeItem('locale');
+ this.reloadAsk();
+ },
+
+ fontSize() {
+ if (this.fontSize == null) {
+ localStorage.removeItem('fontSize');
+ } else {
+ localStorage.setItem('fontSize', this.fontSize);
+ }
+ this.reloadAsk();
+ },
+
+ useSystemFont() {
+ if (this.useSystemFont) {
+ localStorage.setItem('useSystemFont', 't');
+ } else {
+ localStorage.removeItem('useSystemFont');
+ }
+ this.reloadAsk();
+ },
+
+ enableInfiniteScroll() {
+ this.reloadAsk();
+ },
+
+ squareAvatars() {
+ this.reloadAsk();
+ },
+
+ aiChanMode() {
+ this.reloadAsk();
+ },
+
+ showGapBetweenNotesInTimeline() {
+ this.reloadAsk();
+ },
+
+ instanceTicker() {
+ this.reloadAsk();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async reloadAsk() {
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
new file mode 100644
index 0000000000..8923483b98
--- /dev/null
+++ b/packages/client/src/pages/settings/import-export.vue
@@ -0,0 +1,112 @@
+<template>
+<div style="margin: 16px;">
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.allNotes }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('notes')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.followingList }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('following')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('following', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.userLists }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('user-lists')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('user-lists', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.muteList }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+ <FormSection>
+ <template #label>{{ $ts._exportOrImport.blockingList }}</template>
+ <MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton>
+ <MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton>
+ </FormSection>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import FormSection from '@/components/form/section.vue';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormSection,
+ MkButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.importAndExport,
+ icon: 'fas fa-boxes',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ doExport(target) {
+ os.api(
+ target === 'notes' ? 'i/export-notes' :
+ target === 'following' ? 'i/export-following' :
+ target === 'blocking' ? 'i/export-blocking' :
+ target === 'user-lists' ? 'i/export-user-lists' :
+ target === 'muting' ? 'i/export-mute' :
+ null, {})
+ .then(() => {
+ os.dialog({
+ type: 'info',
+ text: this.$ts.exportRequested
+ });
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+
+ async doImport(target, e) {
+ const file = await selectFile(e.currentTarget || e.target);
+
+ os.api(
+ target === 'following' ? 'i/import-following' :
+ target === 'user-lists' ? 'i/import-user-lists' :
+ target === 'muting' ? 'i/import-muting' :
+ target === 'blocking' ? 'i/import-blocking' :
+ null, {
+ fileId: file.id
+ }).then(() => {
+ os.dialog({
+ type: 'info',
+ text: this.$ts.importRequested
+ });
+ }).catch((e: any) => {
+ os.dialog({
+ type: 'error',
+ text: e.message
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style module>
+.button {
+ margin-right: 16px;
+}
+</style>
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
new file mode 100644
index 0000000000..b9d3903269
--- /dev/null
+++ b/packages/client/src/pages/settings/index.vue
@@ -0,0 +1,326 @@
+<template>
+<div class="vvcocwet" :class="{ wide: !narrow }" ref="el">
+ <div class="nav" v-if="!narrow || page == null">
+ <MkSpacer :content-max="700">
+ <div class="baaadecd">
+ <div class="title">{{ $ts.settings }}</div>
+ <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
+ <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu>
+ </div>
+ </MkSpacer>
+ </div>
+ <div class="main">
+ <component :is="component" :key="page" v-bind="pageProps"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
+import { i18n } from '@/i18n';
+import MkInfo from '@/components/ui/info.vue';
+import MkSuperMenu from '@/components/ui/super-menu.vue';
+import { scroll } from '@/scripts/scroll';
+import { signout } from '@/account';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+import { instance } from '@/instance';
+import { $i } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkInfo,
+ MkSuperMenu,
+ },
+
+ props: {
+ initialPage: {
+ type: String,
+ required: false
+ }
+ },
+
+ setup(props, context) {
+ const indexInfo = {
+ title: i18n.locale.settings,
+ icon: 'fas fa-cog',
+ bg: 'var(--bg)',
+ hideHeader: true,
+ };
+ const INFO = ref(indexInfo);
+ const page = ref(props.initialPage);
+ const narrow = ref(false);
+ const view = ref(null);
+ const el = ref(null);
+ const menuDef = computed(() => [{
+ title: i18n.locale.basicSettings,
+ items: [{
+ icon: 'fas fa-user',
+ text: i18n.locale.profile,
+ to: '/settings/profile',
+ active: page.value === 'profile',
+ }, {
+ icon: 'fas fa-lock-open',
+ text: i18n.locale.privacy,
+ to: '/settings/privacy',
+ active: page.value === 'privacy',
+ }, {
+ icon: 'fas fa-laugh',
+ text: i18n.locale.reaction,
+ to: '/settings/reaction',
+ active: page.value === 'reaction',
+ }, {
+ icon: 'fas fa-cloud',
+ text: i18n.locale.drive,
+ to: '/settings/drive',
+ active: page.value === 'drive',
+ }, {
+ icon: 'fas fa-bell',
+ text: i18n.locale.notifications,
+ to: '/settings/notifications',
+ active: page.value === 'notifications',
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.email,
+ to: '/settings/email',
+ active: page.value === 'email',
+ }, {
+ icon: 'fas fa-share-alt',
+ text: i18n.locale.integration,
+ to: '/settings/integration',
+ active: page.value === 'integration',
+ }, {
+ icon: 'fas fa-lock',
+ text: i18n.locale.security,
+ to: '/settings/security',
+ active: page.value === 'security',
+ }],
+ }, {
+ title: i18n.locale.clientSettings,
+ items: [{
+ icon: 'fas fa-cogs',
+ text: i18n.locale.general,
+ to: '/settings/general',
+ active: page.value === 'general',
+ }, {
+ icon: 'fas fa-palette',
+ text: i18n.locale.theme,
+ to: '/settings/theme',
+ active: page.value === 'theme',
+ }, {
+ icon: 'fas fa-list-ul',
+ text: i18n.locale.menu,
+ to: '/settings/menu',
+ active: page.value === 'menu',
+ }, {
+ icon: 'fas fa-music',
+ text: i18n.locale.sounds,
+ to: '/settings/sounds',
+ active: page.value === 'sounds',
+ }, {
+ icon: 'fas fa-plug',
+ text: i18n.locale.plugins,
+ to: '/settings/plugin',
+ active: page.value === 'plugin',
+ }],
+ }, {
+ title: i18n.locale.otherSettings,
+ items: [{
+ icon: 'fas fa-boxes',
+ text: i18n.locale.importAndExport,
+ to: '/settings/import-export',
+ active: page.value === 'import-export',
+ }, {
+ icon: 'fas fa-ban',
+ text: i18n.locale.muteAndBlock,
+ to: '/settings/mute-block',
+ active: page.value === 'mute-block',
+ }, {
+ icon: 'fas fa-comment-slash',
+ text: i18n.locale.wordMute,
+ to: '/settings/word-mute',
+ active: page.value === 'word-mute',
+ }, {
+ icon: 'fas fa-key',
+ text: 'API',
+ to: '/settings/api',
+ active: page.value === 'api',
+ }, {
+ icon: 'fas fa-ellipsis-h',
+ text: i18n.locale.other,
+ to: '/settings/other',
+ active: page.value === 'other',
+ }],
+ }, {
+ items: [{
+ type: 'button',
+ icon: 'fas fa-trash',
+ text: i18n.locale.clearCache,
+ action: () => {
+ localStorage.removeItem('locale');
+ localStorage.removeItem('theme');
+ unisonReload();
+ },
+ }, {
+ type: 'button',
+ icon: 'fas fa-sign-in-alt fa-flip-horizontal',
+ text: i18n.locale.logout,
+ action: () => {
+ signout();
+ },
+ danger: true,
+ },],
+ }]);
+
+ const pageProps = ref({});
+ const component = computed(() => {
+ if (page.value == null) return null;
+ switch (page.value) {
+ case 'accounts': return defineAsyncComponent(() => import('./accounts.vue'));
+ case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
+ case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
+ case 'reaction': return defineAsyncComponent(() => import('./reaction.vue'));
+ case 'drive': return defineAsyncComponent(() => import('./drive.vue'));
+ case 'notifications': return defineAsyncComponent(() => import('./notifications.vue'));
+ case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue'));
+ case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
+ case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
+ case 'security': return defineAsyncComponent(() => import('./security.vue'));
+ case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
+ case 'api': return defineAsyncComponent(() => import('./api.vue'));
+ case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
+ case 'other': return defineAsyncComponent(() => import('./other.vue'));
+ case 'general': return defineAsyncComponent(() => import('./general.vue'));
+ case 'email': return defineAsyncComponent(() => import('./email.vue'));
+ case 'email/address': return defineAsyncComponent(() => import('./email-address.vue'));
+ case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue'));
+ case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
+ case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
+ case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
+ case 'menu': return defineAsyncComponent(() => import('./menu.vue'));
+ case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
+ case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
+ case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
+ case 'plugin': return defineAsyncComponent(() => import('./plugin.vue'));
+ case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue'));
+ case 'plugin/manage': return defineAsyncComponent(() => import('./plugin.manage.vue'));
+ case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
+ case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
+ case 'update': return defineAsyncComponent(() => import('./update.vue'));
+ case 'registry': return defineAsyncComponent(() => import('./registry.vue'));
+ case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue'));
+ case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue'));
+ }
+ if (page.value.startsWith('registry/keys/system/')) {
+ return defineAsyncComponent(() => import('./registry.keys.vue'));
+ }
+ if (page.value.startsWith('registry/value/system/')) {
+ return defineAsyncComponent(() => import('./registry.value.vue'));
+ }
+ });
+
+ watch(component, () => {
+ pageProps.value = {};
+
+ if (page.value) {
+ if (page.value.startsWith('registry/keys/system/')) {
+ pageProps.value.scope = page.value.replace('registry/keys/system/', '').split('/');
+ }
+ if (page.value.startsWith('registry/value/system/')) {
+ const path = page.value.replace('registry/value/system/', '').split('/');
+ pageProps.value.xKey = path.pop();
+ pageProps.value.scope = path;
+ }
+ }
+
+ nextTick(() => {
+ scroll(el.value, { top: 0 });
+ });
+ }, { immediate: true });
+
+ watch(() => props.initialPage, () => {
+ if (props.initialPage == null && !narrow.value) {
+ page.value = 'profile';
+ } else {
+ page.value = props.initialPage;
+ if (props.initialPage == null) {
+ INFO.value = indexInfo;
+ }
+ }
+ });
+
+ onMounted(() => {
+ narrow.value = el.value.offsetWidth < 800;
+ if (!narrow.value) {
+ page.value = 'profile';
+ }
+ });
+
+ const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ page,
+ menuDef,
+ narrow,
+ view,
+ el,
+ pageProps,
+ component,
+ emailNotConfigured,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vvcocwet {
+ > .nav {
+ .baaadecd {
+ > .title {
+ margin: 16px;
+ font-size: 1.5em;
+ font-weight: bold;
+ }
+
+ > .info {
+ margin: 0 16px;
+ }
+
+ > .accounts {
+ > .avatar {
+ display: block;
+ width: 50px;
+ height: 50px;
+ margin: 8px auto 16px auto;
+ }
+ }
+ }
+ }
+
+ &.wide {
+ display: flex;
+ max-width: 1000px;
+ margin: 0 auto;
+ height: 100%;
+
+ > .nav {
+ width: 32%;
+ box-sizing: border-box;
+ overflow: auto;
+
+ .baaadecd {
+ > .title {
+ margin: 24px 0;
+ }
+ }
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
new file mode 100644
index 0000000000..405f93b779
--- /dev/null
+++ b/packages/client/src/pages/settings/integration.vue
@@ -0,0 +1,141 @@
+<template>
+<FormBase>
+ <div class="_debobigegoItem" v-if="enableTwitterIntegration">
+ <div class="_debobigegoLabel"><i class="fab fa-twitter"></i> Twitter</div>
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <p v-if="integrations.twitter">{{ $ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
+ <MkButton v-if="integrations.twitter" @click="disconnectTwitter" danger>{{ $ts.disconnectService }}</MkButton>
+ <MkButton v-else @click="connectTwitter" primary>{{ $ts.connectService }}</MkButton>
+ </div>
+ </div>
+
+ <div class="_debobigegoItem" v-if="enableDiscordIntegration">
+ <div class="_debobigegoLabel"><i class="fab fa-discord"></i> Discord</div>
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <p v-if="integrations.discord">{{ $ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
+ <MkButton v-if="integrations.discord" @click="disconnectDiscord" danger>{{ $ts.disconnectService }}</MkButton>
+ <MkButton v-else @click="connectDiscord" primary>{{ $ts.connectService }}</MkButton>
+ </div>
+ </div>
+
+ <div class="_debobigegoItem" v-if="enableGithubIntegration">
+ <div class="_debobigegoLabel"><i class="fab fa-github"></i> GitHub</div>
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <p v-if="integrations.github">{{ $ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
+ <MkButton v-if="integrations.github" @click="disconnectGithub" danger>{{ $ts.disconnectService }}</MkButton>
+ <MkButton v-else @click="connectGithub" primary>{{ $ts.connectService }}</MkButton>
+ </div>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { apiUrl } from '@/config';
+import FormBase from '@/components/debobigego/base.vue';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ MkButton
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.integration,
+ icon: 'fas fa-share-alt',
+ bg: 'var(--bg)',
+ },
+ apiUrl,
+ twitterForm: null,
+ discordForm: null,
+ githubForm: null,
+ enableTwitterIntegration: false,
+ enableDiscordIntegration: false,
+ enableGithubIntegration: false,
+ };
+ },
+
+ computed: {
+ integrations() {
+ return this.$i.integrations;
+ },
+
+ meta() {
+ return this.$instance;
+ },
+ },
+
+ created() {
+ this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
+ this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
+ this.enableGithubIntegration = this.meta.enableGithubIntegration;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ document.cookie = `igi=${this.$i.token}; path=/;` +
+ ` max-age=31536000;` +
+ (document.location.protocol.startsWith('https') ? ' secure' : '');
+
+ this.$watch('integrations', () => {
+ if (this.integrations.twitter) {
+ if (this.twitterForm) this.twitterForm.close();
+ }
+ if (this.integrations.discord) {
+ if (this.discordForm) this.discordForm.close();
+ }
+ if (this.integrations.github) {
+ if (this.githubForm) this.githubForm.close();
+ }
+ }, {
+ deep: true
+ });
+ },
+
+ methods: {
+ connectTwitter() {
+ this.twitterForm = window.open(apiUrl + '/connect/twitter',
+ 'twitter_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectTwitter() {
+ window.open(apiUrl + '/disconnect/twitter',
+ 'twitter_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectDiscord() {
+ this.discordForm = window.open(apiUrl + '/connect/discord',
+ 'discord_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectDiscord() {
+ window.open(apiUrl + '/disconnect/discord',
+ 'discord_disconnect_window',
+ 'height=570, width=520');
+ },
+
+ connectGithub() {
+ this.githubForm = window.open(apiUrl + '/connect/github',
+ 'github_connect_window',
+ 'height=570, width=520');
+ },
+
+ disconnectGithub() {
+ window.open(apiUrl + '/disconnect/github',
+ 'github_disconnect_window',
+ 'height=570, width=520');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue
new file mode 100644
index 0000000000..e40740a3a4
--- /dev/null
+++ b/packages/client/src/pages/settings/menu.vue
@@ -0,0 +1,117 @@
+<template>
+<FormBase>
+ <FormTextarea v-model="items" tall manual-save>
+ <span>{{ $ts.menu }}</span>
+ <template #desc><button class="_textButton" @click="addItem">{{ $ts.addItem }}</button></template>
+ </FormTextarea>
+
+ <FormRadios v-model="menuDisplay">
+ <template #desc>{{ $ts.display }}</template>
+ <option value="sideFull">{{ $ts._menuDisplay.sideFull }}</option>
+ <option value="sideIcon">{{ $ts._menuDisplay.sideIcon }}</option>
+ <option value="top">{{ $ts._menuDisplay.top }}</option>
+ <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ $ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 -->
+ </FormRadios>
+
+ <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { defaultStore } from '@/store';
+import * as symbols from '@/symbols';
+import { unisonReload } from '@/scripts/unison-reload';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormTextarea,
+ FormRadios,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.menu,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+ },
+ menuDef: menuDef,
+ items: defaultStore.state.menu.join('\n'),
+ }
+ },
+
+ computed: {
+ splited(): string[] {
+ return this.items.trim().split('\n').filter(x => x.trim() !== '');
+ },
+
+ menuDisplay: defaultStore.makeGetterSetter('menuDisplay')
+ },
+
+ watch: {
+ menuDisplay() {
+ this.reloadAsk();
+ },
+
+ items() {
+ this.save();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async addItem() {
+ const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.menu.includes(k));
+ const { canceled, result: item } = await os.dialog({
+ type: null,
+ title: this.$ts.addItem,
+ select: {
+ items: [...menu.map(k => ({
+ value: k, text: this.$ts[this.menuDef[k].title]
+ })), ...[{
+ value: '-', text: this.$ts.divider
+ }]]
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ this.items = [...this.splited, item].join('\n');
+ },
+
+ save() {
+ this.$store.set('menu', this.splited);
+ this.reloadAsk();
+ },
+
+ reset() {
+ this.$store.reset('menu');
+ this.items = this.$store.state.menu.join('\n');
+ },
+
+ async reloadAsk() {
+ const { canceled } = await os.dialog({
+ type: 'info',
+ text: this.$ts.reloadToApplySetting,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ unisonReload();
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue
new file mode 100644
index 0000000000..4a9633a20d
--- /dev/null
+++ b/packages/client/src/pages/settings/mute-block.vue
@@ -0,0 +1,85 @@
+<template>
+<FormBase>
+ <MkTab v-model="tab" style="margin-bottom: var(--margin);">
+ <option value="mute">{{ $ts.mutedUsers }}</option>
+ <option value="block">{{ $ts.blockedUsers }}</option>
+ </MkTab>
+ <div v-if="tab === 'mute'">
+ <MkPagination :pagination="mutingPagination" class="muting">
+ <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
+ <template #default="{items}">
+ <FormGroup>
+ <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)">
+ <MkAcct :user="mute.mutee"/>
+ </FormLink>
+ </FormGroup>
+ </template>
+ </MkPagination>
+ </div>
+ <div v-if="tab === 'block'">
+ <MkPagination :pagination="blockingPagination" class="blocking">
+ <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template>
+ <template #default="{items}">
+ <FormGroup>
+ <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)">
+ <MkAcct :user="block.blockee"/>
+ </FormLink>
+ </FormGroup>
+ </template>
+ </MkPagination>
+ </div>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkTab from '@/components/tab.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkTab,
+ FormInfo,
+ FormBase,
+ FormGroup,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.muteAndBlock,
+ icon: 'fas fa-ban',
+ bg: 'var(--bg)',
+ },
+ tab: 'mute',
+ mutingPagination: {
+ endpoint: 'mute/list',
+ limit: 10,
+ },
+ blockingPagination: {
+ endpoint: 'blocking/list',
+ limit: 10,
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ userPage
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue
new file mode 100644
index 0000000000..7de10a182c
--- /dev/null
+++ b/packages/client/src/pages/settings/notifications.vue
@@ -0,0 +1,77 @@
+<template>
+<FormBase>
+ <FormLink @click="configure">{{ $ts.notificationSetting }}</FormLink>
+ <FormGroup>
+ <FormButton @click="readAllNotifications">{{ $ts.markAsReadAllNotifications }}</FormButton>
+ <FormButton @click="readAllUnreadNotes">{{ $ts.markAsReadAllUnreadNotes }}</FormButton>
+ <FormButton @click="readAllMessagingMessages">{{ $ts.markAsReadAllTalkMessages }}</FormButton>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { notificationTypes } from 'misskey-js';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ FormButton,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.notifications,
+ icon: 'fas fa-bell',
+ bg: 'var(--bg)',
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ readAllUnreadNotes() {
+ os.api('i/read-all-unread-notes');
+ },
+
+ readAllMessagingMessages() {
+ os.api('i/read-all-messaging-messages');
+ },
+
+ readAllNotifications() {
+ os.api('notifications/mark-all-as-read');
+ },
+
+ configure() {
+ const includingTypes = notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
+ os.popup(import('@/components/notification-setting-window.vue'), {
+ includingTypes,
+ showGlobalToggle: false,
+ }, {
+ done: async (res) => {
+ const { includingTypes: value } = res;
+ await os.apiWithDialog('i/update', {
+ mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
+ }).then(i => {
+ this.$i.mutingNotificationTypes = i.mutingNotificationTypes;
+ });
+ }
+ }, 'closed');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
new file mode 100644
index 0000000000..fbc895a07d
--- /dev/null
+++ b/packages/client/src/pages/settings/other.vue
@@ -0,0 +1,97 @@
+<template>
+<FormBase>
+ <FormLink to="/settings/update">Misskey Update</FormLink>
+
+ <FormSwitch :value="$i.injectFeaturedNote" @update:modelValue="onChangeInjectFeaturedNote">
+ {{ $ts.showFeaturedNotesInTimeline }}
+ </FormSwitch>
+
+ <FormSwitch v-model="reportError">{{ $ts.sendErrorReports }}<template #desc>{{ $ts.sendErrorReportsDescription }}</template></FormSwitch>
+
+ <FormLink to="/settings/account-info">{{ $ts.accountInfo }}</FormLink>
+ <FormLink to="/settings/experimental-features">{{ $ts.experimentalFeatures }}</FormLink>
+
+ <FormGroup>
+ <template #label>{{ $ts.developer }}</template>
+ <FormSwitch v-model="debug" @update:modelValue="changeDebug">
+ DEBUG MODE
+ </FormSwitch>
+ <template v-if="debug">
+ <FormButton @click="taskmanager">Task Manager</FormButton>
+ </template>
+ </FormGroup>
+
+ <FormLink to="/settings/registry"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.registry }}</FormLink>
+
+ <FormLink to="/bios" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>BIOS</FormLink>
+ <FormLink to="/cli" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>CLI</FormLink>
+
+ <FormLink to="/settings/delete-account"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ $ts.closeAccount }}</FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { debug } from '@/config';
+import { defaultStore } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.other,
+ icon: 'fas fa-ellipsis-h',
+ bg: 'var(--bg)',
+ },
+ debug,
+ }
+ },
+
+ computed: {
+ reportError: defaultStore.makeGetterSetter('reportError'),
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ changeDebug(v) {
+ console.log(v);
+ localStorage.setItem('debug', v.toString());
+ unisonReload();
+ },
+
+ onChangeInjectFeaturedNote(v) {
+ os.api('i/update', {
+ injectFeaturedNote: v
+ });
+ },
+
+ taskmanager() {
+ os.popup(import('@/components/taskmanager.vue'), {
+ }, {}, 'closed');
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue
new file mode 100644
index 0000000000..9958f98f58
--- /dev/null
+++ b/packages/client/src/pages/settings/plugin.install.vue
@@ -0,0 +1,147 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts._plugin.installWarn }}</FormInfo>
+
+ <FormGroup>
+ <FormTextarea v-model="code" tall>
+ <span>{{ $ts.code }}</span>
+ </FormTextarea>
+ </FormGroup>
+
+ <FormButton @click="install" :disabled="code == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { AiScript, parse } from '@syuilo/aiscript';
+import { serialize } from '@syuilo/aiscript/built/serializer';
+import { v4 as uuid } from 'uuid';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { unisonReload } from '@/scripts/unison-reload';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._plugin.install,
+ icon: 'fas fa-download',
+ bg: 'var(--bg)',
+ },
+ code: null,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ installPlugin({ id, meta, ast, token }) {
+ ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({
+ ...meta,
+ id,
+ active: true,
+ configData: {},
+ token: token,
+ ast: ast
+ }));
+ },
+
+ async install() {
+ let ast;
+ try {
+ ast = parse(this.code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ const meta = AiScript.collectMetadata(ast);
+ if (meta == null) {
+ os.dialog({
+ type: 'error',
+ text: 'No metadata found :('
+ });
+ return;
+ }
+ const data = meta.get(null);
+ if (data == null) {
+ os.dialog({
+ type: 'error',
+ text: 'No metadata found :('
+ });
+ return;
+ }
+ const { name, version, author, description, permissions, config } = data;
+ if (name == null || version == null || author == null) {
+ os.dialog({
+ type: 'error',
+ text: 'Required property not found :('
+ });
+ return;
+ }
+
+ const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => {
+ os.popup(import('@/components/token-generate-window.vue'), {
+ title: this.$ts.tokenRequested,
+ information: this.$ts.pluginTokenRequestedDescription,
+ initialName: name,
+ initialPermissions: permissions
+ }, {
+ done: async result => {
+ const { name, permissions } = result;
+ const { token } = await os.api('miauth/gen-token', {
+ session: null,
+ name: name,
+ permission: permissions,
+ });
+
+ res(token);
+ }
+ }, 'closed');
+ });
+
+ this.installPlugin({
+ id: uuid(),
+ meta: {
+ name, version, author, description, permissions, config
+ },
+ token,
+ ast: serialize(ast)
+ });
+
+ os.success();
+
+ this.$nextTick(() => {
+ unisonReload();
+ });
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/plugin.manage.vue b/packages/client/src/pages/settings/plugin.manage.vue
new file mode 100644
index 0000000000..3a0168d13d
--- /dev/null
+++ b/packages/client/src/pages/settings/plugin.manage.vue
@@ -0,0 +1,115 @@
+<template>
+<FormBase>
+ <FormGroup v-for="plugin in plugins" :key="plugin.id">
+ <template #label><span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span></template>
+
+ <FormSwitch :value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ $ts.makeActive }}</FormSwitch>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <div class="_keyValue">
+ <div>{{ $ts.author }}:</div>
+ <div>{{ plugin.author }}</div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $ts.description }}:</div>
+ <div>{{ plugin.description }}</div>
+ </div>
+ <div class="_keyValue">
+ <div>{{ $ts.permission }}:</div>
+ <div>{{ plugin.permissions }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoPanel" style="padding: 16px;">
+ <MkButton @click="config(plugin)" inline v-if="plugin.config"><i class="fas fa-cog"></i> {{ $ts.settings }}</MkButton>
+ <MkButton @click="uninstall(plugin)" inline danger><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</MkButton>
+ </div>
+ </div>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkSelect from '@/components/form/select.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkTextarea,
+ MkSelect,
+ FormSwitch,
+ FormBase,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._plugin.manage,
+ icon: 'fas fa-plug',
+ bg: 'var(--bg)',
+ },
+ plugins: ColdDeviceStorage.get('plugins'),
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ uninstall(plugin) {
+ ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id));
+ os.success();
+ this.$nextTick(() => {
+ unisonReload();
+ });
+ },
+
+ // TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする
+ async config(plugin) {
+ const config = plugin.config;
+ for (const key in plugin.configData) {
+ config[key].default = plugin.configData[key];
+ }
+
+ const { canceled, result } = await os.form(plugin.name, config);
+ if (canceled) return;
+
+ const plugins = ColdDeviceStorage.get('plugins');
+ plugins.find(p => p.id === plugin.id).configData = result;
+ ColdDeviceStorage.set('plugins', plugins);
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ },
+
+ changeActive(plugin, active) {
+ const plugins = ColdDeviceStorage.get('plugins');
+ plugins.find(p => p.id === plugin.id).active = active;
+ ColdDeviceStorage.set('plugins', plugins);
+
+ this.$nextTick(() => {
+ location.reload();
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue
new file mode 100644
index 0000000000..50e53f459f
--- /dev/null
+++ b/packages/client/src/pages/settings/plugin.vue
@@ -0,0 +1,44 @@
+<template>
+<FormBase>
+ <FormLink to="/settings/plugin/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._plugin.install }}</FormLink>
+ <FormLink to="/settings/plugin/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._plugin.manage }}<template #suffix>{{ plugins }}</template></FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.plugins,
+ icon: 'fas fa-plug',
+ bg: 'var(--bg)',
+ },
+ plugins: ColdDeviceStorage.get('plugins').length,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue
new file mode 100644
index 0000000000..94afba9aa4
--- /dev/null
+++ b/packages/client/src/pages/settings/privacy.vue
@@ -0,0 +1,120 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormSwitch v-model="isLocked" @update:modelValue="save()">{{ $ts.makeFollowManuallyApprove }}</FormSwitch>
+ <FormSwitch v-model="autoAcceptFollowed" :disabled="!isLocked" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch>
+ <template #caption>{{ $ts.lockedAccountInfo }}</template>
+ </FormGroup>
+ <FormSwitch v-model="publicReactions" @update:modelValue="save()">
+ {{ $ts.makeReactionsPublic }}
+ <template #desc>{{ $ts.makeReactionsPublicDescription }}</template>
+ </FormSwitch>
+ <FormGroup>
+ <template #label>{{ $ts.ffVisibility }}</template>
+ <FormSelect v-model="ffVisibility">
+ <option value="public">{{ $ts._ffVisibility.public }}</option>
+ <option value="followers">{{ $ts._ffVisibility.followers }}</option>
+ <option value="private">{{ $ts._ffVisibility.private }}</option>
+ </FormSelect>
+ <template #caption>{{ $ts.ffVisibilityDescription }}</template>
+ </FormGroup>
+ <FormSwitch v-model="hideOnlineStatus" @update:modelValue="save()">
+ {{ $ts.hideOnlineStatus }}
+ <template #desc>{{ $ts.hideOnlineStatusDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="noCrawle" @update:modelValue="save()">
+ {{ $ts.noCrawle }}
+ <template #desc>{{ $ts.noCrawleDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="isExplorable" @update:modelValue="save()">
+ {{ $ts.makeExplorable }}
+ <template #desc>{{ $ts.makeExplorableDescription }}</template>
+ </FormSwitch>
+ <FormSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch>
+ <FormGroup v-if="!rememberNoteVisibility">
+ <template #label>{{ $ts.defaultNoteVisibility }}</template>
+ <FormSelect v-model="defaultNoteVisibility">
+ <option value="public">{{ $ts._visibility.public }}</option>
+ <option value="home">{{ $ts._visibility.home }}</option>
+ <option value="followers">{{ $ts._visibility.followers }}</option>
+ <option value="specified">{{ $ts._visibility.specified }}</option>
+ </FormSelect>
+ <FormSwitch v-model="defaultNoteLocalOnly">{{ $ts._visibility.localOnly }}</FormSwitch>
+ </FormGroup>
+ <FormSwitch v-model="keepCw" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormGroup,
+ FormSwitch,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.privacy,
+ icon: 'fas fa-lock-open',
+ bg: 'var(--bg)',
+ },
+ isLocked: false,
+ autoAcceptFollowed: false,
+ noCrawle: false,
+ isExplorable: false,
+ hideOnlineStatus: false,
+ publicReactions: false,
+ ffVisibility: 'public',
+ }
+ },
+
+ computed: {
+ defaultNoteVisibility: defaultStore.makeGetterSetter('defaultNoteVisibility'),
+ defaultNoteLocalOnly: defaultStore.makeGetterSetter('defaultNoteLocalOnly'),
+ rememberNoteVisibility: defaultStore.makeGetterSetter('rememberNoteVisibility'),
+ keepCw: defaultStore.makeGetterSetter('keepCw'),
+ },
+
+ created() {
+ this.isLocked = this.$i.isLocked;
+ this.autoAcceptFollowed = this.$i.autoAcceptFollowed;
+ this.noCrawle = this.$i.noCrawle;
+ this.isExplorable = this.$i.isExplorable;
+ this.hideOnlineStatus = this.$i.hideOnlineStatus;
+ this.publicReactions = this.$i.publicReactions;
+ this.ffVisibility = this.$i.ffVisibility;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ os.api('i/update', {
+ isLocked: !!this.isLocked,
+ autoAcceptFollowed: !!this.autoAcceptFollowed,
+ noCrawle: !!this.noCrawle,
+ isExplorable: !!this.isExplorable,
+ hideOnlineStatus: !!this.hideOnlineStatus,
+ publicReactions: !!this.publicReactions,
+ ffVisibility: this.ffVisibility,
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue
new file mode 100644
index 0000000000..a7ddc6d178
--- /dev/null
+++ b/packages/client/src/pages/settings/profile.vue
@@ -0,0 +1,281 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <div class="_debobigegoItem _debobigegoPanel llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
+ <MkAvatar class="avatar" :user="$i"/>
+ </div>
+ <FormButton @click="changeAvatar" primary>{{ $ts._profile.changeAvatar }}</FormButton>
+ <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton>
+ </FormGroup>
+
+ <FormInput v-model="name" :max="30" manual-save>
+ <span>{{ $ts._profile.name }}</span>
+ </FormInput>
+
+ <FormTextarea v-model="description" :max="500" tall manual-save>
+ <span>{{ $ts._profile.description }}</span>
+ <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template>
+ </FormTextarea>
+
+ <FormInput v-model="location" manual-save>
+ <span>{{ $ts.location }}</span>
+ <template #prefix><i class="fas fa-map-marker-alt"></i></template>
+ </FormInput>
+
+ <FormInput v-model="birthday" type="date" manual-save>
+ <span>{{ $ts.birthday }}</span>
+ <template #prefix><i class="fas fa-birthday-cake"></i></template>
+ </FormInput>
+
+ <FormSelect v-model="lang">
+ <template #label>{{ $ts.language }}</template>
+ <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
+ </FormSelect>
+
+ <FormGroup>
+ <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton>
+ <template #caption>{{ $ts._profile.metadataDescription }}</template>
+ </FormGroup>
+
+ <FormSwitch v-model="isCat">{{ $ts.flagAsCat }}<template #desc>{{ $ts.flagAsCatDescription }}</template></FormSwitch>
+
+ <FormSwitch v-model="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch>
+
+ <FormSwitch v-model="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import { host, langs } from '@/config';
+import { selectFile } from '@/scripts/select-file';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormButton,
+ FormInput,
+ FormTextarea,
+ FormSwitch,
+ FormSelect,
+ FormBase,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.profile,
+ icon: 'fas fa-user',
+ bg: 'var(--bg)',
+ },
+ host,
+ langs,
+ name: null,
+ description: null,
+ birthday: null,
+ lang: null,
+ location: null,
+ fieldName0: null,
+ fieldValue0: null,
+ fieldName1: null,
+ fieldValue1: null,
+ fieldName2: null,
+ fieldValue2: null,
+ fieldName3: null,
+ fieldValue3: null,
+ avatarId: null,
+ bannerId: null,
+ isBot: false,
+ isCat: false,
+ alwaysMarkNsfw: false,
+ saving: false,
+ }
+ },
+
+ created() {
+ this.name = this.$i.name;
+ this.description = this.$i.description;
+ this.location = this.$i.location;
+ this.birthday = this.$i.birthday;
+ this.lang = this.$i.lang;
+ this.avatarId = this.$i.avatarId;
+ this.bannerId = this.$i.bannerId;
+ this.isBot = this.$i.isBot;
+ this.isCat = this.$i.isCat;
+ this.alwaysMarkNsfw = this.$i.alwaysMarkNsfw;
+
+ this.fieldName0 = this.$i.fields[0] ? this.$i.fields[0].name : null;
+ this.fieldValue0 = this.$i.fields[0] ? this.$i.fields[0].value : null;
+ this.fieldName1 = this.$i.fields[1] ? this.$i.fields[1].name : null;
+ this.fieldValue1 = this.$i.fields[1] ? this.$i.fields[1].value : null;
+ this.fieldName2 = this.$i.fields[2] ? this.$i.fields[2].name : null;
+ this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null;
+ this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null;
+ this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null;
+
+ this.$watch('name', this.save);
+ this.$watch('description', this.save);
+ this.$watch('location', this.save);
+ this.$watch('birthday', this.save);
+ this.$watch('lang', this.save);
+ this.$watch('isBot', this.save);
+ this.$watch('isCat', this.save);
+ this.$watch('alwaysMarkNsfw', this.save);
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ changeAvatar(e) {
+ selectFile(e.currentTarget || e.target, this.$ts.avatar).then(file => {
+ os.api('i/update', {
+ avatarId: file.id,
+ });
+ });
+ },
+
+ changeBanner(e) {
+ selectFile(e.currentTarget || e.target, this.$ts.banner).then(file => {
+ os.api('i/update', {
+ bannerId: file.id,
+ });
+ });
+ },
+
+ async editMetadata() {
+ const { canceled, result } = await os.form(this.$ts._profile.metadata, {
+ fieldName0: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 1',
+ default: this.fieldName0,
+ },
+ fieldValue0: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 1',
+ default: this.fieldValue0,
+ },
+ fieldName1: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 2',
+ default: this.fieldName1,
+ },
+ fieldValue1: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 2',
+ default: this.fieldValue1,
+ },
+ fieldName2: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 3',
+ default: this.fieldName2,
+ },
+ fieldValue2: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 3',
+ default: this.fieldValue2,
+ },
+ fieldName3: {
+ type: 'string',
+ label: this.$ts._profile.metadataLabel + ' 4',
+ default: this.fieldName3,
+ },
+ fieldValue3: {
+ type: 'string',
+ label: this.$ts._profile.metadataContent + ' 4',
+ default: this.fieldValue3,
+ },
+ });
+ if (canceled) return;
+
+ this.fieldName0 = result.fieldName0;
+ this.fieldValue0 = result.fieldValue0;
+ this.fieldName1 = result.fieldName1;
+ this.fieldValue1 = result.fieldValue1;
+ this.fieldName2 = result.fieldName2;
+ this.fieldValue2 = result.fieldValue2;
+ this.fieldName3 = result.fieldName3;
+ this.fieldValue3 = result.fieldValue3;
+
+ const fields = [
+ { name: this.fieldName0, value: this.fieldValue0 },
+ { name: this.fieldName1, value: this.fieldValue1 },
+ { name: this.fieldName2, value: this.fieldValue2 },
+ { name: this.fieldName3, value: this.fieldValue3 },
+ ];
+
+ os.api('i/update', {
+ fields,
+ }).then(i => {
+ os.success();
+ }).catch(err => {
+ os.dialog({
+ type: 'error',
+ text: err.id
+ });
+ });
+ },
+
+ save() {
+ this.saving = true;
+
+ os.apiWithDialog('i/update', {
+ name: this.name || null,
+ description: this.description || null,
+ location: this.location || null,
+ birthday: this.birthday || null,
+ lang: this.lang || null,
+ isBot: !!this.isBot,
+ isCat: !!this.isCat,
+ alwaysMarkNsfw: !!this.alwaysMarkNsfw,
+ }).then(i => {
+ this.saving = false;
+ this.$i.avatarId = i.avatarId;
+ this.$i.avatarUrl = i.avatarUrl;
+ this.$i.bannerId = i.bannerId;
+ this.$i.bannerUrl = i.bannerUrl;
+ }).catch(err => {
+ this.saving = false;
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.llvierxe {
+ position: relative;
+ height: 150px;
+ background-size: cover;
+ background-position: center;
+
+ > * {
+ pointer-events: none;
+ }
+
+ > .avatar {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ display: block;
+ width: 72px;
+ height: 72px;
+ margin: auto;
+ box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue
new file mode 100644
index 0000000000..905a3e4957
--- /dev/null
+++ b/packages/client/src/pages/settings/reaction.vue
@@ -0,0 +1,152 @@
+<template>
+<FormBase>
+ <div class="_debobigegoItem">
+ <div class="_debobigegoLabel">{{ $ts.reactionSettingDescription }}</div>
+ <div class="_debobigegoPanel">
+ <XDraggable class="zoaiodol" v-model="reactions" :item-key="item => item" animation="150" delay="100" delay-on-touch-only="true">
+ <template #item="{element}">
+ <button class="_button item" @click="remove(element, $event)">
+ <MkEmoji :emoji="element" :normal="true"/>
+ </button>
+ </template>
+ <template #footer>
+ <button class="_button add" @click="chooseEmoji"><i class="fas fa-plus"></i></button>
+ </template>
+ </XDraggable>
+ </div>
+ <div class="_debobigegoCaption">{{ $ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ $ts.preview }}</button></div>
+ </div>
+
+ <FormRadios v-model="reactionPickerWidth">
+ <template #desc>{{ $ts.width }}</template>
+ <option :value="1">{{ $ts.small }}</option>
+ <option :value="2">{{ $ts.medium }}</option>
+ <option :value="3">{{ $ts.large }}</option>
+ </FormRadios>
+ <FormRadios v-model="reactionPickerHeight">
+ <template #desc>{{ $ts.height }}</template>
+ <option :value="1">{{ $ts.small }}</option>
+ <option :value="2">{{ $ts.medium }}</option>
+ <option :value="3">{{ $ts.large }}</option>
+ </FormRadios>
+ <FormButton @click="preview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton>
+ <FormButton danger @click="setDefault"><i class="fas fa-undo"></i> {{ $ts.default }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XDraggable from 'vuedraggable';
+import FormInput from '@/components/debobigego/input.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import * as os from '@/os';
+import { defaultStore } from '@/store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormInput,
+ FormButton,
+ FormBase,
+ FormRadios,
+ XDraggable,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.reaction,
+ icon: 'fas fa-laugh',
+ action: {
+ icon: 'fas fa-eye',
+ handler: this.preview
+ },
+ bg: 'var(--bg)',
+ },
+ reactions: JSON.parse(JSON.stringify(this.$store.state.reactions)),
+ }
+ },
+
+ computed: {
+ reactionPickerWidth: defaultStore.makeGetterSetter('reactionPickerWidth'),
+ reactionPickerHeight: defaultStore.makeGetterSetter('reactionPickerHeight'),
+ },
+
+ watch: {
+ reactions: {
+ handler() {
+ this.save();
+ },
+ deep: true
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ save() {
+ this.$store.set('reactions', this.reactions);
+ },
+
+ remove(reaction, ev) {
+ os.popupMenu([{
+ text: this.$ts.remove,
+ action: () => {
+ this.reactions = this.reactions.filter(x => x !== reaction)
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ preview(ev) {
+ os.popup(import('@/components/emoji-picker-dialog.vue'), {
+ asReactionPicker: true,
+ src: ev.currentTarget || ev.target,
+ }, {}, 'closed');
+ },
+
+ async setDefault() {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.resetAreYouSure,
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ this.reactions = JSON.parse(JSON.stringify(this.$store.def.reactions.default));
+ },
+
+ chooseEmoji(ev) {
+ os.pickEmoji(ev.currentTarget || ev.target, {
+ showPinned: false
+ }).then(emoji => {
+ if (!this.reactions.includes(emoji)) {
+ this.reactions.push(emoji);
+ }
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zoaiodol {
+ padding: 16px;
+
+ > .item {
+ display: inline-block;
+ padding: 8px;
+ cursor: move;
+ }
+
+ > .add {
+ display: inline-block;
+ padding: 8px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/registry.keys.vue b/packages/client/src/pages/settings/registry.keys.vue
new file mode 100644
index 0000000000..ca4d01cc94
--- /dev/null
+++ b/packages/client/src/pages/settings/registry.keys.vue
@@ -0,0 +1,114 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.domain }}</template>
+ <template #value>{{ $ts.system }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.scope }}</template>
+ <template #value>{{ scope.join('/') }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="keys">
+ <template #label>{{ $ts._registry.keys }}</template>
+ <FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
+ </FormGroup>
+
+ <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ props: {
+ scope: {
+ required: true
+ }
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.registry,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ keys: null,
+ }
+ },
+
+ watch: {
+ scope() {
+ this.fetch();
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/keys-with-type', {
+ scope: this.scope
+ }).then(keys => {
+ this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0]));
+ });
+ },
+
+ async createKey() {
+ const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+ key: {
+ type: 'string',
+ label: this.$ts._registry.key,
+ },
+ value: {
+ type: 'string',
+ multiline: true,
+ label: this.$ts.value,
+ },
+ scope: {
+ type: 'string',
+ label: this.$ts._registry.scope,
+ default: this.scope.join('/')
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: result.scope.split('/'),
+ key: result.key,
+ value: JSON5.parse(result.value),
+ }).then(() => {
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/registry.value.vue b/packages/client/src/pages/settings/registry.value.vue
new file mode 100644
index 0000000000..36f989dbc5
--- /dev/null
+++ b/packages/client/src/pages/settings/registry.value.vue
@@ -0,0 +1,149 @@
+<template>
+<FormBase>
+ <FormInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</FormInfo>
+
+ <template v-if="value">
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.domain }}</template>
+ <template #value>{{ $ts.system }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.scope }}</template>
+ <template #value>{{ scope.join('/') }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts._registry.key }}</template>
+ <template #value>{{ xKey }}</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <FormTextarea tall v-model="valueForEditor" class="_monospace" style="tab-size: 2;">
+ <span>{{ $ts.value }} (JSON)</span>
+ </FormTextarea>
+ <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormGroup>
+
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
+ </FormKeyValueView>
+
+ <FormButton danger @click="del"><i class="fas fa-trash"></i> {{ $ts.delete }}</FormButton>
+ </template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormInfo from '@/components/debobigego/info.vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormInfo,
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormTextarea,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ props: {
+ scope: {
+ required: true
+ },
+ xKey: {
+ required: true
+ },
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.registry,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ value: null,
+ valueForEditor: null,
+ }
+ },
+
+ watch: {
+ key() {
+ this.fetch();
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ this.fetch();
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/get-detail', {
+ scope: this.scope,
+ key: this.xKey
+ }).then(value => {
+ this.value = value;
+ this.valueForEditor = JSON5.stringify(this.value.value, null, '\t');
+ });
+ },
+
+ save() {
+ try {
+ JSON5.parse(this.valueForEditor);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.invalidValue
+ });
+ return;
+ }
+
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.saveConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: this.scope,
+ key: this.xKey,
+ value: JSON5.parse(this.valueForEditor)
+ });
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+ os.apiWithDialog('i/registry/remove', {
+ scope: this.scope,
+ key: this.xKey
+ });
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/registry.vue b/packages/client/src/pages/settings/registry.vue
new file mode 100644
index 0000000000..0bfed0ddb7
--- /dev/null
+++ b/packages/client/src/pages/settings/registry.vue
@@ -0,0 +1,90 @@
+<template>
+<FormBase>
+ <FormGroup v-if="scopes">
+ <template #label>{{ $ts.system }}</template>
+ <FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
+ </FormGroup>
+ <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.registry,
+ icon: 'fas fa-cogs',
+ bg: 'var(--bg)',
+ },
+ scopes: null,
+ }
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ fetch() {
+ os.api('i/registry/scopes').then(scopes => {
+ this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
+ });
+ },
+
+ async createKey() {
+ const { canceled, result } = await os.form(this.$ts._registry.createKey, {
+ key: {
+ type: 'string',
+ label: this.$ts._registry.key,
+ },
+ value: {
+ type: 'string',
+ multiline: true,
+ label: this.$ts.value,
+ },
+ scope: {
+ type: 'string',
+ label: this.$ts._registry.scope,
+ }
+ });
+ if (canceled) return;
+ os.apiWithDialog('i/registry/set', {
+ scope: result.scope.split('/'),
+ key: result.key,
+ value: JSON5.parse(result.value),
+ }).then(() => {
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue
new file mode 100644
index 0000000000..4d81bf1b9e
--- /dev/null
+++ b/packages/client/src/pages/settings/security.vue
@@ -0,0 +1,158 @@
+<template>
+<FormBase>
+ <X2fa/>
+ <FormLink to="/settings/2fa"><template #icon><i class="fas fa-mobile-alt"></i></template>{{ $ts.twoStepAuthentication }}</FormLink>
+ <FormButton primary @click="change()">{{ $ts.changePassword }}</FormButton>
+ <FormPagination :pagination="pagination">
+ <template #label>{{ $ts.signinHistory }}</template>
+ <template #default="{items}">
+ <div class="_debobigegoPanel timnmucd" v-for="item in items" :key="item.id">
+ <header>
+ <i v-if="item.success" class="fas fa-check icon succ"></i>
+ <i v-else class="fas fa-times-circle icon fail"></i>
+ <code class="ip _monospace">{{ item.ip }}</code>
+ <MkTime :time="item.createdAt" class="time"/>
+ </header>
+ </div>
+ </template>
+ </FormPagination>
+ <FormGroup>
+ <FormButton danger @click="regenerateToken"><i class="fas fa-sync-alt"></i> {{ $ts.regenerateLoginToken }}</FormButton>
+ <template #caption>{{ $ts.regenerateLoginTokenDescription }}</template>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormPagination from '@/components/debobigego/pagination.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormLink,
+ FormButton,
+ FormPagination,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.security,
+ icon: 'fas fa-lock',
+ bg: 'var(--bg)',
+ },
+ pagination: {
+ endpoint: 'i/signin-history',
+ limit: 5,
+ },
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async change() {
+ const { canceled: canceled1, result: currentPassword } = await os.dialog({
+ title: this.$ts.currentPassword,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled1) return;
+
+ const { canceled: canceled2, result: newPassword } = await os.dialog({
+ title: this.$ts.newPassword,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled2) return;
+
+ const { canceled: canceled3, result: newPassword2 } = await os.dialog({
+ title: this.$ts.newPasswordRetype,
+ input: {
+ type: 'password'
+ }
+ });
+ if (canceled3) return;
+
+ if (newPassword !== newPassword2) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.retypedNotMatch
+ });
+ return;
+ }
+
+ os.apiWithDialog('i/change-password', {
+ currentPassword,
+ newPassword
+ });
+ },
+
+ regenerateToken() {
+ os.dialog({
+ title: this.$ts.password,
+ input: {
+ type: 'password'
+ }
+ }).then(({ canceled, result: password }) => {
+ if (canceled) return;
+ os.api('i/regenerate_token', {
+ password: password
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.timnmucd {
+ padding: 16px;
+
+ > header {
+ display: flex;
+ align-items: center;
+
+ > .icon {
+ width: 1em;
+ margin-right: 0.75em;
+
+ &.succ {
+ color: var(--success);
+ }
+
+ &.fail {
+ color: var(--error);
+ }
+ }
+
+ > .ip {
+ flex: 1;
+ min-width: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-right: 12px;
+ }
+
+ > .time {
+ margin-left: auto;
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue
new file mode 100644
index 0000000000..ea3daced9d
--- /dev/null
+++ b/packages/client/src/pages/settings/sounds.vue
@@ -0,0 +1,155 @@
+<template>
+<FormBase>
+ <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05">
+ <template #label><i class="fas fa-volume-icon"></i> {{ $ts.masterVolume }}</template>
+ </FormRange>
+
+ <FormGroup>
+ <template #label>{{ $ts.sounds }}</template>
+ <FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)">
+ {{ $t('_sfx.' + type) }}
+ <template #suffix>{{ sounds[type].type || $ts.none }}</template>
+ <template #suffixIcon><i class="fas fa-chevron-down"></i></template>
+ </FormButton>
+ </FormGroup>
+
+ <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormRange from '@/components/debobigego/range.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { playFile } from '@/scripts/sound';
+import * as symbols from '@/symbols';
+
+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_gaba',
+ 'noizenecio/kick_gaba2',
+];
+
+export default defineComponent({
+ components: {
+ FormSelect,
+ FormButton,
+ FormBase,
+ FormRange,
+ FormGroup,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.sounds,
+ icon: 'fas fa-music',
+ bg: 'var(--bg)',
+ },
+ sounds: {},
+ }
+ },
+
+ computed: {
+ masterVolume: { // TODO: (外部)関数にcomputedを使うのはアレなので直す
+ get() { return ColdDeviceStorage.get('sound_masterVolume'); },
+ set(value) { ColdDeviceStorage.set('sound_masterVolume', value); }
+ },
+ volumeIcon() {
+ return this.masterVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up';
+ }
+ },
+
+ created() {
+ this.sounds.note = ColdDeviceStorage.get('sound_note');
+ this.sounds.noteMy = ColdDeviceStorage.get('sound_noteMy');
+ this.sounds.notification = ColdDeviceStorage.get('sound_notification');
+ this.sounds.chat = ColdDeviceStorage.get('sound_chat');
+ this.sounds.chatBg = ColdDeviceStorage.get('sound_chatBg');
+ this.sounds.antenna = ColdDeviceStorage.get('sound_antenna');
+ this.sounds.channel = ColdDeviceStorage.get('sound_channel');
+ this.sounds.reversiPutBlack = ColdDeviceStorage.get('sound_reversiPutBlack');
+ this.sounds.reversiPutWhite = ColdDeviceStorage.get('sound_reversiPutWhite');
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async edit(type) {
+ const { canceled, result } = await os.form(this.$t('_sfx.' + type), {
+ type: {
+ type: 'enum',
+ enum: soundsTypes.map(x => ({
+ value: x,
+ label: x == null ? this.$ts.none : x,
+ })),
+ label: this.$ts.sound,
+ default: this.sounds[type].type,
+ },
+ volume: {
+ type: 'range',
+ mim: 0,
+ max: 1,
+ step: 0.05,
+ label: this.$ts.volume,
+ default: this.sounds[type].volume
+ },
+ listen: {
+ type: 'button',
+ content: this.$ts.listen,
+ action: (_, values) => {
+ playFile(values.type, values.volume);
+ }
+ }
+ });
+ if (canceled) return;
+
+ const v = {
+ type: result.type,
+ volume: result.volume,
+ };
+
+ ColdDeviceStorage.set('sound_' + type, v);
+ this.sounds[type] = v;
+ },
+
+ reset() {
+ for (const sound of Object.keys(this.sounds)) {
+ const v = ColdDeviceStorage.default['sound_' + sound];
+ ColdDeviceStorage.set('sound_' + sound, v);
+ this.sounds[sound] = v;
+ }
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue
new file mode 100644
index 0000000000..59ad3ad9b7
--- /dev/null
+++ b/packages/client/src/pages/settings/theme.install.vue
@@ -0,0 +1,105 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <FormTextarea v-model="installThemeCode">
+ <span>{{ $ts._theme.code }}</span>
+ </FormTextarea>
+ <FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton>
+ </FormGroup>
+
+ <FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormRadios from '@/components/form/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import { applyTheme, validateTheme } from '@/scripts/theme';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { addTheme, getThemes } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._theme.install,
+ icon: 'fas fa-download',
+ bg: 'var(--bg)',
+ },
+ installThemeCode: null,
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ parseThemeCode(code) {
+ let theme;
+
+ try {
+ theme = JSON5.parse(code);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._theme.invalid
+ });
+ return false;
+ }
+ if (!validateTheme(theme)) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._theme.invalid
+ });
+ return false;
+ }
+ if (getThemes().some(t => t.id === theme.id)) {
+ os.dialog({
+ type: 'info',
+ text: this.$ts._theme.alreadyInstalled
+ });
+ return false;
+ }
+
+ return theme;
+ },
+
+ preview(code) {
+ const theme = this.parseThemeCode(code);
+ if (theme) applyTheme(theme, false);
+ },
+
+ async install(code) {
+ const theme = this.parseThemeCode(code);
+ if (!theme) return;
+ await addTheme(theme);
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: theme.name })
+ });
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue
new file mode 100644
index 0000000000..8a24481ae2
--- /dev/null
+++ b/packages/client/src/pages/settings/theme.manage.vue
@@ -0,0 +1,105 @@
+<template>
+<FormBase>
+ <FormSelect v-model="selectedThemeId">
+ <template #label>{{ $ts.theme }}</template>
+ <optgroup :label="$ts._theme.installedThemes">
+ <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts._theme.builtinThemes">
+ <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <template v-if="selectedTheme">
+ <FormInput readonly :modelValue="selectedTheme.author">
+ <span>{{ $ts.author }}</span>
+ </FormInput>
+ <FormTextarea readonly :modelValue="selectedTheme.desc" v-if="selectedTheme.desc">
+ <span>{{ $ts._theme.description }}</span>
+ </FormTextarea>
+ <FormTextarea readonly tall :modelValue="selectedThemeCode">
+ <span>{{ $ts._theme.code }}</span>
+ <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $ts.copy }}</button></template>
+ </FormTextarea>
+ <FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</FormButton>
+ </template>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as JSON5 from 'json5';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormRadios from '@/components/debobigego/radios.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormInput from '@/components/debobigego/input.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import { Theme, builtinThemes } from '@/scripts/theme';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { getThemes, removeTheme } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormTextarea,
+ FormSelect,
+ FormRadios,
+ FormBase,
+ FormGroup,
+ FormInput,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts._theme.manage,
+ icon: 'fas fa-folder-open',
+ bg: 'var(--bg)',
+ },
+ installedThemes: getThemes(),
+ builtinThemes,
+ selectedThemeId: null,
+ }
+ },
+
+ computed: {
+ themes(): Theme[] {
+ return this.builtinThemes.concat(this.installedThemes);
+ },
+
+ selectedTheme() {
+ if (this.selectedThemeId == null) return null;
+ return this.themes.find(x => x.id === this.selectedThemeId);
+ },
+
+ selectedThemeCode() {
+ if (this.selectedTheme == null) return null;
+ return JSON5.stringify(this.selectedTheme, null, '\t');
+ },
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ copyThemeCode() {
+ copyToClipboard(this.selectedThemeCode);
+ os.success();
+ },
+
+ uninstall() {
+ removeTheme(this.selectedTheme);
+ this.installedThemes = this.installedThemes.filter(t => t.id !== this.selectedThemeId);
+ this.selectedThemeId = null;
+ os.success();
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue
new file mode 100644
index 0000000000..a9cca40f3c
--- /dev/null
+++ b/packages/client/src/pages/settings/theme.vue
@@ -0,0 +1,424 @@
+<template>
+<FormBase>
+ <FormGroup>
+ <div class="rfqxtzch _debobigegoItem _debobigegoPanel">
+ <div class="darkMode">
+ <div class="toggleWrapper">
+ <input type="checkbox" class="dn" id="dn" v-model="darkMode"/>
+ <label for="dn" class="toggle">
+ <span class="before">{{ $ts.light }}</span>
+ <span class="after">{{ $ts.dark }}</span>
+ <span class="toggle__handler">
+ <span class="crater crater--1"></span>
+ <span class="crater crater--2"></span>
+ <span class="crater crater--3"></span>
+ </span>
+ <span class="star star--1"></span>
+ <span class="star star--2"></span>
+ <span class="star star--3"></span>
+ <span class="star star--4"></span>
+ <span class="star star--5"></span>
+ <span class="star star--6"></span>
+ </label>
+ </div>
+ </div>
+ </div>
+ <FormSwitch v-model="syncDeviceDarkMode">{{ $ts.syncDeviceDarkMode }}</FormSwitch>
+ </FormGroup>
+
+ <template v-if="darkMode">
+ <FormSelect v-model="darkThemeId">
+ <template #label>{{ $ts.themeForDarkMode }}</template>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <FormSelect v-model="lightThemeId">
+ <template #label>{{ $ts.themeForLightMode }}</template>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ </template>
+ <template v-else>
+ <FormSelect v-model="lightThemeId">
+ <template #label>{{ $ts.themeForLightMode }}</template>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ <FormSelect v-model="darkThemeId">
+ <template #label>{{ $ts.themeForDarkMode }}</template>
+ <optgroup :label="$ts.darkThemes">
+ <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ <optgroup :label="$ts.lightThemes">
+ <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+ </optgroup>
+ </FormSelect>
+ </template>
+
+ <FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $ts.setWallpaper }}</FormButton>
+ <FormButton primary v-else @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton>
+
+ <FormGroup>
+ <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ $ts._theme.explore }}</FormLink>
+ <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._theme.install }}</FormLink>
+ </FormGroup>
+
+ <FormGroup>
+ <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }}</FormLink>
+ <!--<FormLink to="/advanced-theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }} ({{ $ts.advanced }})</FormLink>-->
+ </FormGroup>
+
+ <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, onActivated, onMounted, ref, watch } from 'vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormSelect from '@/components/debobigego/select.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import { builtinThemes } from '@/scripts/theme';
+import { selectFile } from '@/scripts/select-file';
+import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
+import { ColdDeviceStorage } from '@/store';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+import { fetchThemes, getThemes } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormSwitch,
+ FormSelect,
+ FormBase,
+ FormGroup,
+ FormLink,
+ FormButton,
+ },
+
+ emits: ['info'],
+
+ setup(props, { emit }) {
+ const INFO = {
+ title: i18n.locale.theme,
+ icon: 'fas fa-palette',
+ bg: 'var(--bg)',
+ };
+
+ const installedThemes = ref(getThemes());
+ const themes = computed(() => builtinThemes.concat(installedThemes.value));
+ const darkThemes = computed(() => themes.value.filter(t => t.base == 'dark' || t.kind == 'dark'));
+ const lightThemes = computed(() => themes.value.filter(t => t.base == 'light' || t.kind == 'light'));
+ const darkTheme = ColdDeviceStorage.ref('darkTheme');
+ const darkThemeId = computed({
+ get() {
+ return darkTheme.value.id;
+ },
+ set(id) {
+ ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id))
+ }
+ });
+ const lightTheme = ColdDeviceStorage.ref('lightTheme');
+ const lightThemeId = computed({
+ get() {
+ return lightTheme.value.id;
+ },
+ set(id) {
+ ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id))
+ }
+ });
+ const darkMode = computed(defaultStore.makeGetterSetter('darkMode'));
+ const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode'));
+ const wallpaper = ref(localStorage.getItem('wallpaper'));
+ const themesCount = installedThemes.value.length;
+
+ watch(syncDeviceDarkMode, () => {
+ if (syncDeviceDarkMode) {
+ defaultStore.set('darkMode', isDeviceDarkmode());
+ }
+ });
+
+ watch(wallpaper, () => {
+ if (wallpaper.value == null) {
+ localStorage.removeItem('wallpaper');
+ } else {
+ localStorage.setItem('wallpaper', wallpaper.value);
+ }
+ location.reload();
+ });
+
+ onMounted(() => {
+ emit('info', INFO);
+ });
+
+ onActivated(() => {
+ fetchThemes().then(() => {
+ installedThemes.value = getThemes();
+ });
+ });
+
+ fetchThemes().then(() => {
+ installedThemes.value = getThemes();
+ });
+
+ return {
+ [symbols.PAGE_INFO]: INFO,
+ darkThemes,
+ lightThemes,
+ darkThemeId,
+ lightThemeId,
+ darkMode,
+ syncDeviceDarkMode,
+ themesCount,
+ wallpaper,
+ setWallpaper(e) {
+ selectFile(e.currentTarget || e.target, null, false).then(file => {
+ wallpaper.value = file.url;
+ });
+ },
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rfqxtzch {
+ padding: 16px;
+
+ > .darkMode {
+ position: relative;
+ padding: 32px 0;
+
+ &.disabled {
+ opacity: 0.7;
+
+ &, * {
+ cursor: not-allowed !important;
+ }
+ }
+
+ .toggleWrapper {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ overflow: hidden;
+ padding: 0 100px;
+ transform: translate3d(-50%, -50%, 0);
+
+ input {
+ position: absolute;
+ left: -99em;
+ }
+ }
+
+ .toggle {
+ cursor: pointer;
+ display: inline-block;
+ position: relative;
+ width: 90px;
+ height: 50px;
+ background-color: #83D8FF;
+ border-radius: 90px - 6;
+ transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+
+ > .before, > .after {
+ position: absolute;
+ top: 15px;
+ font-size: 18px;
+ transition: color 1s ease;
+ }
+
+ > .before {
+ left: -70px;
+ color: var(--accent);
+ }
+
+ > .after {
+ right: -68px;
+ color: var(--fg);
+ }
+ }
+
+ .toggle__handler {
+ display: inline-block;
+ position: relative;
+ z-index: 1;
+ top: 3px;
+ left: 3px;
+ width: 50px - 6;
+ height: 50px - 6;
+ background-color: #FFCF96;
+ border-radius: 50px;
+ box-shadow: 0 2px 6px rgba(0,0,0,.3);
+ transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important;
+ transform: rotate(-45deg);
+
+ .crater {
+ position: absolute;
+ background-color: #E8CDA5;
+ opacity: 0;
+ transition: opacity 200ms ease-in-out !important;
+ border-radius: 100%;
+ }
+
+ .crater--1 {
+ top: 18px;
+ left: 10px;
+ width: 4px;
+ height: 4px;
+ }
+
+ .crater--2 {
+ top: 28px;
+ left: 22px;
+ width: 6px;
+ height: 6px;
+ }
+
+ .crater--3 {
+ top: 10px;
+ left: 25px;
+ width: 8px;
+ height: 8px;
+ }
+ }
+
+ .star {
+ position: absolute;
+ background-color: #ffffff;
+ transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ border-radius: 50%;
+ }
+
+ .star--1 {
+ top: 10px;
+ left: 35px;
+ z-index: 0;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--2 {
+ top: 18px;
+ left: 28px;
+ z-index: 1;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--3 {
+ top: 27px;
+ left: 40px;
+ z-index: 0;
+ width: 30px;
+ height: 3px;
+ }
+
+ .star--4,
+ .star--5,
+ .star--6 {
+ opacity: 0;
+ transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--4 {
+ top: 16px;
+ left: 11px;
+ z-index: 0;
+ width: 2px;
+ height: 2px;
+ transform: translate3d(3px,0,0);
+ }
+
+ .star--5 {
+ top: 32px;
+ left: 17px;
+ z-index: 0;
+ width: 3px;
+ height: 3px;
+ transform: translate3d(3px,0,0);
+ }
+
+ .star--6 {
+ top: 36px;
+ left: 28px;
+ z-index: 0;
+ width: 2px;
+ height: 2px;
+ transform: translate3d(3px,0,0);
+ }
+
+ input:checked {
+ + .toggle {
+ background-color: #749DD6;
+
+ > .before {
+ color: var(--fg);
+ }
+
+ > .after {
+ color: var(--accent);
+ }
+
+ .toggle__handler {
+ background-color: #FFE5B5;
+ transform: translate3d(40px, 0, 0) rotate(0);
+
+ .crater { opacity: 1; }
+ }
+
+ .star--1 {
+ width: 2px;
+ height: 2px;
+ }
+
+ .star--2 {
+ width: 4px;
+ height: 4px;
+ transform: translate3d(-5px, 0, 0);
+ }
+
+ .star--3 {
+ width: 2px;
+ height: 2px;
+ transform: translate3d(-7px, 0, 0);
+ }
+
+ .star--4,
+ .star--5,
+ .star--6 {
+ opacity: 1;
+ transform: translate3d(0,0,0);
+ }
+
+ .star--4 {
+ transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--5 {
+ transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+
+ .star--6 {
+ transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/settings/update.vue b/packages/client/src/pages/settings/update.vue
new file mode 100644
index 0000000000..aa4050fe9f
--- /dev/null
+++ b/packages/client/src/pages/settings/update.vue
@@ -0,0 +1,95 @@
+<template>
+<FormBase>
+ <template v-if="meta">
+ <FormInfo v-if="version === meta.version">{{ $ts.youAreRunningUpToDateClient }}</FormInfo>
+ <FormInfo v-else warn>{{ $ts.newVersionOfClientAvailable }}</FormInfo>
+ </template>
+ <FormGroup>
+ <template #label>{{ instanceName }}</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.currentVersion }}</template>
+ <template #value>{{ version }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestVersion }}</template>
+ <template #value v-if="meta">{{ meta.version }}</template>
+ <template #value v-else><MkEllipsis/></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormGroup>
+ <template #label>Misskey</template>
+ <FormKeyValueView>
+ <template #key>{{ $ts.latestVersion }}</template>
+ <template #value v-if="releases">{{ releases[0].tag_name }}</template>
+ <template #value v-else><MkEllipsis/></template>
+ </FormKeyValueView>
+ <template #caption v-if="releases"><MkTime :time="releases[0].published_at" mode="detail"/></template>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormSwitch from '@/components/form/switch.vue';
+import FormSelect from '@/components/form/select.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import * as os from '@/os';
+import { version, instanceName } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormSelect,
+ FormSwitch,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Misskey Update',
+ icon: 'fas fa-sync-alt',
+ bg: 'var(--bg)',
+ },
+ version,
+ instanceName,
+ releases: null,
+ meta: null
+ }
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+
+ os.api('meta', {
+ detail: false
+ }).then(meta => {
+ this.meta = meta;
+ localStorage.setItem('v', meta.version);
+ });
+
+ fetch('https://api.github.com/repos/misskey-dev/misskey/releases', {
+ method: 'GET',
+ })
+ .then(res => res.json())
+ .then(res => {
+ this.releases = res;
+ });
+ },
+
+ methods: {
+ }
+});
+</script>
diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue
new file mode 100644
index 0000000000..c2162bb1f3
--- /dev/null
+++ b/packages/client/src/pages/settings/word-mute.vue
@@ -0,0 +1,110 @@
+<template>
+<div>
+ <MkTab v-model="tab">
+ <option value="soft">{{ $ts._wordMute.soft }}</option>
+ <option value="hard">{{ $ts._wordMute.hard }}</option>
+ </MkTab>
+ <FormBase>
+ <div class="_debobigegoItem">
+ <div v-show="tab === 'soft'">
+ <FormInfo>{{ $ts._wordMute.softDescription }}</FormInfo>
+ <FormTextarea v-model="softMutedWords">
+ <span>{{ $ts._wordMute.muteWords }}</span>
+ <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template>
+ </FormTextarea>
+ </div>
+ <div v-show="tab === 'hard'">
+ <FormInfo>{{ $ts._wordMute.hardDescription }}</FormInfo>
+ <FormTextarea v-model="hardMutedWords">
+ <span>{{ $ts._wordMute.muteWords }}</span>
+ <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template>
+ </FormTextarea>
+ <FormKeyValueView v-if="hardWordMutedNotesCount != null">
+ <template #key>{{ $ts._wordMute.mutedNotes }}</template>
+ <template #value>{{ number(hardWordMutedNotesCount) }}</template>
+ </FormKeyValueView>
+ </div>
+ </div>
+ <FormButton @click="save()" primary inline :disabled="!changed"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
+ </FormBase>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormInfo from '@/components/debobigego/info.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormTextarea,
+ FormKeyValueView,
+ MkTab,
+ FormInfo,
+ },
+
+ emits: ['info'],
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.wordMute,
+ icon: 'fas fa-comment-slash',
+ bg: 'var(--bg)',
+ },
+ tab: 'soft',
+ softMutedWords: '',
+ hardMutedWords: '',
+ hardWordMutedNotesCount: null,
+ changed: false,
+ }
+ },
+
+ watch: {
+ softMutedWords: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ },
+ hardMutedWords: {
+ handler() {
+ this.changed = true;
+ },
+ deep: true
+ },
+ },
+
+ async created() {
+ this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n');
+ this.hardMutedWords = this.$i.mutedWords.map(x => x.join(' ')).join('\n');
+
+ this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count;
+ },
+
+ mounted() {
+ this.$emit('info', this[symbols.PAGE_INFO]);
+ },
+
+ methods: {
+ async save() {
+ this.$store.set('mutedWords', this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')));
+ await os.api('i/update', {
+ mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
+ });
+ this.changed = false;
+ },
+
+ number
+ }
+});
+</script>
diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue
new file mode 100644
index 0000000000..c0af44fdd1
--- /dev/null
+++ b/packages/client/src/pages/share.vue
@@ -0,0 +1,184 @@
+<template>
+<div class="">
+ <section class="_section">
+ <div class="_content">
+ <XPostForm
+ v-if="state === 'writing'"
+ fixed
+ :share="true"
+ :initial-text="initialText"
+ :initial-visibility="visibility"
+ :initial-files="files"
+ :initial-local-only="localOnly"
+ :reply="reply"
+ :renote="renote"
+ :visible-users="visibleUsers"
+ @posted="state = 'posted'"
+ class="_panel"
+ />
+ <MkButton v-else-if="state === 'posted'" primary @click="close()" class="close">{{ $ts.close }}</MkButton>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
+
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import XPostForm from '@/components/post-form.vue';
+import * as os from '@/os';
+import { noteVisibilities } from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+import * as symbols from '@/symbols';
+import * as Misskey from 'misskey-js';
+
+export default defineComponent({
+ components: {
+ XPostForm,
+ MkButton,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.share,
+ icon: 'fas fa-share-alt'
+ },
+ state: 'fetching' as 'fetching' | 'writing' | 'posted',
+
+ title: null as string | null,
+ initialText: null as string | null,
+ reply: null as Misskey.entities.Note | null,
+ renote: null as Misskey.entities.Note | null,
+ visibility: null as string | null,
+ localOnly: null as boolean | null,
+ files: [] as Misskey.entities.DriveFile[],
+ visibleUsers: [] as Misskey.entities.User[],
+ }
+ },
+
+ async created() {
+ const urlParams = new URLSearchParams(window.location.search);
+
+ this.title = urlParams.get('title');
+ const text = urlParams.get('text');
+ const url = urlParams.get('url');
+
+ let noteText = '';
+ if (this.title) noteText += `[ ${this.title} ]\n`;
+ // Googleニュース対策
+ if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, '');
+ else if (text && this.title !== text) noteText += `${text}\n`;
+ if (url) noteText += `${url}`;
+ this.initialText = noteText.trim();
+
+ const visibility = urlParams.get('visibility');
+ if (noteVisibilities.includes(visibility)) {
+ this.visibility = visibility;
+ }
+
+ if (this.visibility === 'specified') {
+ const visibleUserIds = urlParams.get('visibleUserIds');
+ const visibleAccts = urlParams.get('visibleAccts');
+ await Promise.all(
+ [
+ ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []),
+ ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : [])
+ ]
+ // TypeScriptの指示通りに変換する
+ .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q)
+ .map(q => os.api('users/show', q)
+ .then(user => {
+ this.visibleUsers.push(user);
+ }, () => {
+ console.error(`Invalid user query: ${JSON.stringify(q)}`);
+ })
+ )
+ );
+ }
+
+ const localOnly = urlParams.get('localOnly');
+ if (localOnly === '0') this.localOnly = false;
+ else if (localOnly === '1') this.localOnly = true;
+
+ try {
+ //#region Reply
+ const replyId = urlParams.get('replyId');
+ const replyUri = urlParams.get('replyUri');
+ if (replyId) {
+ this.reply = await os.api('notes/show', {
+ noteId: replyId
+ });
+ } else if (replyUri) {
+ const obj = await os.api('ap/show', {
+ uri: replyUri
+ });
+ if (obj.type === 'Note') {
+ this.reply = obj.object;
+ }
+ }
+ //#endregion
+
+ //#region Renote
+ const renoteId = urlParams.get('renoteId');
+ const renoteUri = urlParams.get('renoteUri');
+ if (renoteId) {
+ this.renote = await os.api('notes/show', {
+ noteId: renoteId
+ });
+ } else if (renoteUri) {
+ const obj = await os.api('ap/show', {
+ uri: renoteUri
+ });
+ if (obj.type === 'Note') {
+ this.renote = obj.object;
+ }
+ }
+ //#endregion
+
+ //#region Drive files
+ const fileIds = urlParams.get('fileIds');
+ if (fileIds) {
+ await Promise.all(
+ fileIds.split(',')
+ .map(fileId => os.api('drive/files/show', { fileId })
+ .then(file => {
+ this.files.push(file);
+ }, () => {
+ console.error(`Failed to fetch a file ${fileId}`);
+ })
+ )
+ );
+ }
+ //#endregion
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ title: e.message,
+ text: e.name
+ });
+ }
+
+ this.state = 'writing';
+ },
+
+ methods: {
+ close() {
+ window.close();
+
+ // 閉じなければ100ms後タイムラインに
+ setTimeout(() => {
+ this.$router.push('/');
+ }, 100);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.close {
+ margin: 16px auto;
+}
+</style>
diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue
new file mode 100644
index 0000000000..3bbc9938dd
--- /dev/null
+++ b/packages/client/src/pages/signup-complete.vue
@@ -0,0 +1,50 @@
+<template>
+<div>
+ {{ $ts.processing }}
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+
+ },
+
+ props: {
+ code: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.signup,
+ icon: 'fas fa-user'
+ },
+ }
+ },
+
+ mounted() {
+ os.apiWithDialog('signup-pending', {
+ code: this.code,
+ }).then(res => {
+ login(res.i, '/');
+ });
+ },
+
+ methods: {
+
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue
new file mode 100644
index 0000000000..f4709659e3
--- /dev/null
+++ b/packages/client/src/pages/tag.vue
@@ -0,0 +1,57 @@
+<template>
+<div class="_section">
+ <XNotes ref="notes" class="_content" :pagination="pagination" @before="before" @after="after"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XNotes from '@/components/notes.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes
+ },
+
+ props: {
+ tag: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.tag,
+ icon: 'fas fa-hashtag'
+ },
+ pagination: {
+ endpoint: 'notes/search-by-tag',
+ limit: 10,
+ params: () => ({
+ tag: this.tag,
+ })
+ },
+ };
+ },
+
+ watch: {
+ tag() {
+ (this.$refs.notes as any).reload();
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/test.vue b/packages/client/src/pages/test.vue
new file mode 100644
index 0000000000..9dd9ae5e0c
--- /dev/null
+++ b/packages/client/src/pages/test.vue
@@ -0,0 +1,259 @@
+<template>
+<div class="_section">
+ <div class="_content">
+ <div class="_card _gap">
+ <div class="_title">Dialog</div>
+ <div class="_content">
+ <MkInput v-model="dialogTitle">
+ <template #label>Title</template>
+ </MkInput>
+ <MkInput v-model="dialogBody">
+ <template #label>Body</template>
+ </MkInput>
+ <MkRadio v-model="dialogType" value="info">Info</MkRadio>
+ <MkRadio v-model="dialogType" value="success">Success</MkRadio>
+ <MkRadio v-model="dialogType" value="warning">Warn</MkRadio>
+ <MkRadio v-model="dialogType" value="error">Error</MkRadio>
+ <MkSwitch v-model="dialogCancel">
+ <span>With cancel button</span>
+ </MkSwitch>
+ <MkSwitch v-model="dialogCancelByBgClick">
+ <span>Can cancel by modal bg click</span>
+ </MkSwitch>
+ <MkSwitch v-model="dialogInput">
+ <span>With input field</span>
+ </MkSwitch>
+ <MkButton @click="showDialog()">Show</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ dialogResult }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Form</div>
+ <div class="_content">
+ <MkInput v-model="formTitle">
+ <template #label>Title</template>
+ </MkInput>
+ <MkTextarea v-model="formForm">
+ <template #label>Form</template>
+ </MkTextarea>
+ <MkButton @click="form()">Show</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ formResult }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">MFM</div>
+ <div class="_content">
+ <MkTextarea v-model="mfm">
+ <template #label>MFM</template>
+ </MkTextarea>
+ </div>
+ <div class="_content">
+ <Mfm :text="mfm"/>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">selectDriveFile</div>
+ <div class="_content">
+ <MkSwitch v-model="selectDriveFileMultiple">
+ <span>Multiple</span>
+ </MkSwitch>
+ <MkButton @click="selectDriveFile()">selectDriveFile</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ JSON.stringify(selectDriveFileResult) }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">selectDriveFolder</div>
+ <div class="_content">
+ <MkSwitch v-model="selectDriveFolderMultiple">
+ <span>Multiple</span>
+ </MkSwitch>
+ <MkButton @click="selectDriveFolder()">selectDriveFolder</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ JSON.stringify(selectDriveFolderResult) }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">selectUser</div>
+ <div class="_content">
+ <MkButton @click="selectUser()">selectUser</MkButton>
+ </div>
+ <div class="_content">
+ <code>Result: {{ user }}</code>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Notification</div>
+ <div class="_content">
+ <MkInput v-model="notificationIconUrl">
+ <template #label>Icon URL</template>
+ </MkInput>
+ <MkInput v-model="notificationHeader">
+ <template #label>Header</template>
+ </MkInput>
+ <MkTextarea v-model="notificationBody">
+ <template #label>Body</template>
+ </MkTextarea>
+ <MkButton @click="createNotification()">createNotification</MkButton>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Waiting dialog</div>
+ <div class="_content">
+ <MkButton inline @click="openWaitingDialog()">icon only</MkButton>
+ <MkButton inline @click="openWaitingDialog('Doing')">with text</MkButton>
+ </div>
+ </div>
+
+ <div class="_card _gap">
+ <div class="_title">Messaging window</div>
+ <div class="_content">
+ <MkButton @click="messagingWindowOpen()">open</MkButton>
+ </div>
+ </div>
+
+ <MkButton @click="resetTutorial()">Reset tutorial</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import MkSwitch from '@/components/form/switch.vue';
+import MkTextarea from '@/components/form/textarea.vue';
+import MkRadio from '@/components/form/radio.vue';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ MkSwitch,
+ MkTextarea,
+ MkRadio,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'TEST',
+ icon: 'fas fa-exclamation-triangle'
+ },
+ dialogTitle: 'Hello',
+ dialogBody: 'World!',
+ dialogType: 'info',
+ dialogCancel: false,
+ dialogCancelByBgClick: true,
+ dialogInput: false,
+ dialogResult: null,
+ formTitle: 'Test form',
+ formForm: JSON.stringify({
+ foo: {
+ type: 'boolean',
+ default: true,
+ label: 'This is a boolean property'
+ },
+ bar: {
+ type: 'number',
+ default: 300,
+ label: 'This is a number property'
+ },
+ baz: {
+ type: 'string',
+ default: 'Misskey makes you happy.',
+ label: 'This is a string property'
+ },
+ qux: {
+ type: 'string',
+ multiline: true,
+ default: 'Misskey makes\nyou happy.',
+ label: 'Multiline string'
+ },
+ }, null, '\t'),
+ formResult: null,
+ mfm: '',
+ selectDriveFileMultiple: false,
+ selectDriveFolderMultiple: false,
+ selectDriveFileResult: null,
+ selectDriveFolderResult: null,
+ user: null,
+ notificationIconUrl: null,
+ notificationHeader: '',
+ notificationBody: '',
+ }
+ },
+
+ methods: {
+ async showDialog() {
+ this.dialogResult = null;
+ this.dialogResult = await os.dialog({
+ type: this.dialogType,
+ title: this.dialogTitle,
+ text: this.dialogBody,
+ showCancelButton: this.dialogCancel,
+ cancelableByBgClick: this.dialogCancelByBgClick,
+ input: this.dialogInput ? {} : null
+ });
+ },
+
+ async form() {
+ this.formResult = null;
+ this.formResult = await os.form(this.formTitle, JSON.parse(this.formForm));
+ },
+
+ async selectDriveFile() {
+ this.selectDriveFileResult = null;
+ this.selectDriveFileResult = await os.selectDriveFile(this.selectDriveFileMultiple);
+ },
+
+ async selectDriveFolder() {
+ this.selectDriveFolderResult = null;
+ this.selectDriveFolderResult = await os.selectDriveFolder(this.selectDriveFolderMultiple);
+ },
+
+ async selectUser() {
+ this.user = null;
+ this.user = await os.selectUser();
+ },
+
+ async createNotification() {
+ os.api('notifications/create', {
+ header: this.notificationHeader,
+ body: this.notificationBody,
+ icon: this.notificationIconUrl,
+ });
+ },
+
+ messagingWindowOpen() {
+ os.pageWindow('/my/messaging');
+ },
+
+ openWaitingDialog(text?) {
+ const promise = new Promise((resolve, reject) => {
+ setTimeout(resolve, 2000);
+ });
+ os.promiseDialog(promise, null, null, text);
+ },
+
+ resetTutorial() {
+ this.$store.set('tutorial', 0);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue
new file mode 100644
index 0000000000..d1a892629b
--- /dev/null
+++ b/packages/client/src/pages/theme-editor.vue
@@ -0,0 +1,306 @@
+<template>
+<FormBase class="cwepdizn">
+ <div class="_debobigegoItem colorPicker">
+ <div class="_debobigegoLabel">{{ $ts.backgroundColor }}</div>
+ <div class="_debobigegoPanel colors">
+ <div class="row">
+ <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }">
+ <div class="preview" :style="{ background: color.forPreview }"></div>
+ </button>
+ </div>
+ <div class="row">
+ <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" @click="setBgColor(color)" class="color _button" :class="{ active: theme.props.bg === color.color }">
+ <div class="preview" :style="{ background: color.forPreview }"></div>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="_debobigegoItem colorPicker">
+ <div class="_debobigegoLabel">{{ $ts.accentColor }}</div>
+ <div class="_debobigegoPanel colors">
+ <div class="row">
+ <button v-for="color in accentColors" :key="color" @click="setAccentColor(color)" class="color rounded _button" :class="{ active: theme.props.accent === color }">
+ <div class="preview" :style="{ background: color }"></div>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="_debobigegoItem colorPicker">
+ <div class="_debobigegoLabel">{{ $ts.textColor }}</div>
+ <div class="_debobigegoPanel colors">
+ <div class="row">
+ <button v-for="color in fgColors" :key="color" @click="setFgColor(color)" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }">
+ <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div>
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <FormGroup v-if="codeEnabled">
+ <FormTextarea v-model="themeCode" tall>
+ <span>{{ $ts._theme.code }}</span>
+ </FormTextarea>
+ <FormButton @click="applyThemeCode" primary>{{ $ts.apply }}</FormButton>
+ </FormGroup>
+ <FormButton v-else @click="codeEnabled = true"><i class="fas fa-code"></i> {{ $ts.editCode }}</FormButton>
+
+ <FormGroup v-if="descriptionEnabled">
+ <FormTextarea v-model="description">
+ <span>{{ $ts._theme.description }}</span>
+ </FormTextarea>
+ </FormGroup>
+ <FormButton v-else @click="descriptionEnabled = true">{{ $ts.addDescription }}</FormButton>
+
+ <FormGroup>
+ <FormButton @click="showPreview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton>
+ <FormButton @click="saveAs" primary><i class="fas fa-save"></i> {{ $ts.saveAs }}</FormButton>
+ </FormGroup>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import * as tinycolor from 'tinycolor2';
+import { v4 as uuid} from 'uuid';
+import * as JSON5 from 'json5';
+
+import FormBase from '@/components/debobigego/base.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+
+import { Theme, applyTheme, validateTheme, darkTheme, lightTheme } from '@/scripts/theme';
+import { host } from '@/config';
+import * as os from '@/os';
+import { ColdDeviceStorage } from '@/store';
+import { addTheme } from '@/theme-store';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormButton,
+ FormTextarea,
+ FormGroup,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.themeEditor,
+ icon: 'fas fa-palette',
+ },
+ theme: {
+ base: 'light',
+ props: lightTheme.props
+ } as Theme,
+ codeEnabled: false,
+ descriptionEnabled: false,
+ description: null,
+ themeCode: null,
+ bgColors: [
+ { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' },
+ { color: '#f0eee9', kind: 'light', forPreview: '#f3e2b9' },
+ { color: '#e9eff0', kind: 'light', forPreview: '#bfe3e8' },
+ { color: '#f0e9ee', kind: 'light', forPreview: '#f1d1e8' },
+ { color: '#dce2e0', kind: 'light', forPreview: '#a4dccc' },
+ { color: '#e2e0dc', kind: 'light', forPreview: '#d8c7a5' },
+ { color: '#d5dbe0', kind: 'light', forPreview: '#b0cae0' },
+ { color: '#dad5d5', kind: 'light', forPreview: '#d6afaf' },
+ { color: '#2b2b2b', kind: 'dark', forPreview: '#444444' },
+ { color: '#362e29', kind: 'dark', forPreview: '#735c4d' },
+ { color: '#303629', kind: 'dark', forPreview: '#506d2f' },
+ { color: '#293436', kind: 'dark', forPreview: '#258192' },
+ { color: '#2e2936', kind: 'dark', forPreview: '#504069' },
+ { color: '#252722', kind: 'dark', forPreview: '#3c462f' },
+ { color: '#212525', kind: 'dark', forPreview: '#303e3e' },
+ { color: '#191919', kind: 'dark', forPreview: '#272727' },
+ ],
+ accentColors: ['#e36749', '#f29924', '#98c934', '#34c9a9', '#34a1c9', '#606df7', '#8d34c9', '#e84d83'],
+ fgColors: [
+ { color: 'none', forLight: '#5f5f5f', forDark: '#dadada', forPreview: null },
+ { color: 'red', forLight: '#7f6666', forDark: '#e4d1d1', forPreview: '#ca4343' },
+ { color: 'yellow', forLight: '#736955', forDark: '#e0d5c0', forPreview: '#d49923' },
+ { color: 'green', forLight: '#586d5b', forDark: '#d1e4d4', forPreview: '#4cbd5c' },
+ { color: 'cyan', forLight: '#5d7475', forDark: '#d1e3e4', forPreview: '#2abdc3' },
+ { color: 'blue', forLight: '#676880', forDark: '#d1d2e4', forPreview: '#7275d8' },
+ { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' },
+ ],
+ changed: false,
+ }
+ },
+
+ created() {
+ this.$watch('theme', this.apply, { deep: true });
+ window.addEventListener('beforeunload', this.beforeunload);
+ },
+
+ beforeUnmount() {
+ window.removeEventListener('beforeunload', this.beforeunload);
+ },
+
+ async beforeRouteLeave(to, from) {
+ if (this.changed && !(await this.leaveConfirm())) {
+ return false;
+ }
+ },
+
+ methods: {
+ beforeunload(e: BeforeUnloadEvent) {
+ if (this.changed) {
+ e.preventDefault();
+ e.returnValue = '';
+ }
+ },
+
+ async leaveConfirm(): Promise<boolean> {
+ const { canceled } = await os.dialog({
+ type: 'warning',
+ text: this.$ts.leaveConfirm,
+ showCancelButton: true
+ });
+ return !canceled;
+ },
+
+ showPreview() {
+ os.pageWindow('preview');
+ },
+
+ setBgColor(color) {
+ if (this.theme.base != color.kind) {
+ const base = color.kind === 'dark' ? darkTheme : lightTheme;
+ for (const prop of Object.keys(base.props)) {
+ if (prop === 'accent') continue;
+ if (prop === 'fg') continue;
+ this.theme.props[prop] = base.props[prop];
+ }
+ }
+ this.theme.base = color.kind;
+ this.theme.props.bg = color.color;
+
+ if (this.theme.props.fg) {
+ const matchedFgColor = this.fgColors.find(x => [tinycolor(x.forLight).toRgbString(), tinycolor(x.forDark).toRgbString()].includes(tinycolor(this.theme.props.fg).toRgbString()));
+ if (matchedFgColor) this.setFgColor(matchedFgColor);
+ }
+ },
+
+ setAccentColor(color) {
+ this.theme.props.accent = color;
+ },
+
+ setFgColor(color) {
+ this.theme.props.fg = this.theme.base === 'light' ? color.forLight : color.forDark;
+ },
+
+ apply() {
+ this.themeCode = JSON5.stringify(this.theme, null, '\t');
+ applyTheme(this.theme, false);
+ this.changed = true;
+ },
+
+ applyThemeCode() {
+ let parsed;
+
+ try {
+ parsed = JSON5.parse(this.themeCode);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: this.$ts._theme.invalid
+ });
+ return;
+ }
+
+ this.theme = parsed;
+ },
+
+ async saveAs() {
+ const { canceled, result: name } = await os.dialog({
+ title: this.$ts.name,
+ input: {
+ allowEmpty: false
+ }
+ });
+ if (canceled) return;
+
+ this.theme.id = uuid();
+ this.theme.name = name;
+ this.theme.author = `@${this.$i.username}@${toUnicode(host)}`;
+ if (this.description) this.theme.desc = this.description;
+ addTheme(this.theme);
+ applyTheme(this.theme);
+ if (this.$store.state.darkMode) {
+ ColdDeviceStorage.set('darkTheme', this.theme);
+ } else {
+ ColdDeviceStorage.set('lightTheme', this.theme);
+ }
+ this.changed = false;
+ os.dialog({
+ type: 'success',
+ text: this.$t('_theme.installed', { name: this.theme.name })
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cwepdizn {
+ max-width: 800px;
+ margin: 0 auto;
+
+ > .colorPicker {
+ > .colors {
+ padding: 32px;
+ text-align: center;
+
+ > .row {
+ > .color {
+ display: inline-block;
+ position: relative;
+ width: 64px;
+ height: 64px;
+ border-radius: 8px;
+
+ > .preview {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ margin: auto;
+ width: 42px;
+ height: 42px;
+ border-radius: 4px;
+ box-shadow: 0 2px 4px rgb(0 0 0 / 30%);
+ transition: transform 0.15s ease;
+ }
+
+ &:hover {
+ > .preview {
+ transform: scale(1.1);
+ }
+ }
+
+ &.active {
+ box-shadow: 0 0 0 2px var(--divider) inset;
+ }
+
+ &.rounded {
+ border-radius: 999px;
+
+ > .preview {
+ border-radius: 999px;
+ }
+ }
+
+ &.char {
+ line-height: 42px;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/timeline.tutorial.vue b/packages/client/src/pages/timeline.tutorial.vue
new file mode 100644
index 0000000000..4d6dd0af41
--- /dev/null
+++ b/packages/client/src/pages/timeline.tutorial.vue
@@ -0,0 +1,131 @@
+<template>
+<div class="_card tbkwesmv">
+ <div class="_title"><i class="fas fa-info-circle"></i> {{ $ts._tutorial.title }}</div>
+ <div class="_content" v-if="tutorial === 0">
+ <div>{{ $ts._tutorial.step1_1 }}</div>
+ <div>{{ $ts._tutorial.step1_2 }}</div>
+ <div>{{ $ts._tutorial.step1_3 }}</div>
+ </div>
+ <div class="_content" v-else-if="tutorial === 1">
+ <div>{{ $ts._tutorial.step2_1 }}</div>
+ <div>{{ $ts._tutorial.step2_2 }}</div>
+ <MkA class="_link" to="/settings/profile">{{ $ts.editProfile }}</MkA>
+ </div>
+ <div class="_content" v-else-if="tutorial === 2">
+ <div>{{ $ts._tutorial.step3_1 }}</div>
+ <div>{{ $ts._tutorial.step3_2 }}</div>
+ <div>{{ $ts._tutorial.step3_3 }}</div>
+ <small>{{ $ts._tutorial.step3_4 }}</small>
+ </div>
+ <div class="_content" v-else-if="tutorial === 3">
+ <div>{{ $ts._tutorial.step4_1 }}</div>
+ <div>{{ $ts._tutorial.step4_2 }}</div>
+ </div>
+ <div class="_content" v-else-if="tutorial === 4">
+ <div>{{ $ts._tutorial.step5_1 }}</div>
+ <I18n :src="$ts._tutorial.step5_2" tag="div">
+ <template #featured>
+ <MkA class="_link" to="/featured">{{ $ts.featured }}</MkA>
+ </template>
+ <template #explore>
+ <MkA class="_link" to="/explore">{{ $ts.explore }}</MkA>
+ </template>
+ </I18n>
+ <div>{{ $ts._tutorial.step5_3 }}</div>
+ <small>{{ $ts._tutorial.step5_4 }}</small>
+ </div>
+ <div class="_content" v-else-if="tutorial === 5">
+ <div>{{ $ts._tutorial.step6_1 }}</div>
+ <div>{{ $ts._tutorial.step6_2 }}</div>
+ <div>{{ $ts._tutorial.step6_3 }}</div>
+ </div>
+ <div class="_content" v-else-if="tutorial === 6">
+ <div>{{ $ts._tutorial.step7_1 }}</div>
+ <I18n :src="$ts._tutorial.step7_2" tag="div">
+ <template #help>
+ <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ $ts.help }}</a>
+ </template>
+ </I18n>
+ <div>{{ $ts._tutorial.step7_3 }}</div>
+ </div>
+
+ <div class="_footer navigation">
+ <div class="step">
+ <button class="arrow _button" @click="tutorial--" :disabled="tutorial === 0">
+ <i class="fas fa-chevron-left"></i>
+ </button>
+ <span>{{ tutorial + 1 }} / 7</span>
+ <button class="arrow _button" @click="tutorial++" :disabled="tutorial === 6">
+ <i class="fas fa-chevron-right"></i>
+ </button>
+ </div>
+ <MkButton class="ok" @click="tutorial = -1" primary v-if="tutorial === 6"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton>
+ <MkButton class="ok" @click="tutorial++" primary v-else><i class="fas fa-check"></i> {{ $ts.next }}</MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ data() {
+ return {
+ }
+ },
+
+ computed: {
+ tutorial: {
+ get() { return this.$store.reactiveState.tutorial.value || 0; },
+ set(value) { this.$store.set('tutorial', value); }
+ },
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.tbkwesmv {
+ > ._content {
+ > small {
+ opacity: 0.7;
+ }
+ }
+
+ > .navigation {
+ display: flex;
+ flex-direction: row;
+ align-items: baseline;
+
+ > .step {
+ > .arrow {
+ padding: 4px;
+
+ &:disabled {
+ opacity: 0.5;
+ }
+
+ &:first-child {
+ padding-right: 8px;
+ }
+
+ &:last-child {
+ padding-left: 8px;
+ }
+ }
+
+ > span {
+ margin: 0 4px;
+ }
+ }
+
+ > .ok {
+ margin-left: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue
new file mode 100644
index 0000000000..911d6f5c6a
--- /dev/null
+++ b/packages/client/src/pages/timeline.vue
@@ -0,0 +1,225 @@
+<template>
+<div class="cmuxhskf" v-size="{ min: [800] }" v-hotkey.global="keymap">
+ <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/>
+ <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/>
+
+ <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline ref="tl" class="tl"
+ :key="src"
+ :src="src"
+ :sound="true"
+ @before="before()"
+ @after="after()"
+ @queue="queueUpdated"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import Progress from '@/scripts/loading';
+import XTimeline from '@/components/timeline.vue';
+import XPostForm from '@/components/post-form.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ name: 'timeline',
+
+ components: {
+ XTimeline,
+ XTutorial: defineAsyncComponent(() => import('./timeline.tutorial.vue')),
+ XPostForm,
+ },
+
+ data() {
+ return {
+ src: 'home',
+ queue: 0,
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.timeline,
+ icon: this.src === 'local' ? 'fas fa-comments' : this.src === 'social' ? 'fas fa-share-alt' : this.src === 'global' ? 'fas fa-globe' : 'fas fa-home',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-list-ul',
+ text: this.$ts.lists,
+ handler: this.chooseList
+ }, {
+ icon: 'fas fa-satellite',
+ text: this.$ts.antennas,
+ handler: this.chooseAntenna
+ }, {
+ icon: 'fas fa-satellite-dish',
+ text: this.$ts.channel,
+ handler: this.chooseChannel
+ }, {
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }],
+ tabs: [{
+ active: this.src === 'home',
+ title: this.$ts._timelines.home,
+ icon: 'fas fa-home',
+ iconOnly: true,
+ onClick: () => { this.src = 'home'; this.saveSrc(); },
+ }, {
+ active: this.src === 'local',
+ title: this.$ts._timelines.local,
+ icon: 'fas fa-comments',
+ iconOnly: true,
+ onClick: () => { this.src = 'local'; this.saveSrc(); },
+ }, {
+ active: this.src === 'social',
+ title: this.$ts._timelines.social,
+ icon: 'fas fa-share-alt',
+ iconOnly: true,
+ onClick: () => { this.src = 'social'; this.saveSrc(); },
+ }, {
+ active: this.src === 'global',
+ title: this.$ts._timelines.global,
+ icon: 'fas fa-globe',
+ iconOnly: true,
+ onClick: () => { this.src = 'global'; this.saveSrc(); },
+ }],
+ })),
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ },
+
+ isLocalTimelineAvailable(): boolean {
+ return !this.$instance.disableLocalTimeline || this.$i.isModerator || this.$i.isAdmin;
+ },
+
+ isGlobalTimelineAvailable(): boolean {
+ return !this.$instance.disableGlobalTimeline || this.$i.isModerator || this.$i.isAdmin;
+ },
+ },
+
+ watch: {
+ src() {
+ this.showNav = false;
+ },
+ },
+
+ created() {
+ this.src = this.$store.state.tl.src;
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ queueUpdated(q) {
+ this.queue = q;
+ },
+
+ top() {
+ scroll(this.$el, { top: 0 });
+ },
+
+ async chooseList(ev) {
+ const lists = await os.api('users/lists/list');
+ const items = lists.map(list => ({
+ type: 'link',
+ text: list.name,
+ to: `/timeline/list/${list.id}`
+ }));
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+
+ async chooseAntenna(ev) {
+ const antennas = await os.api('antennas/list');
+ const items = antennas.map(antenna => ({
+ type: 'link',
+ text: antenna.name,
+ indicate: antenna.hasUnreadNote,
+ to: `/timeline/antenna/${antenna.id}`
+ }));
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+
+ async chooseChannel(ev) {
+ const channels = await os.api('channels/followed');
+ const items = channels.map(channel => ({
+ type: 'link',
+ text: channel.name,
+ indicate: channel.hasUnreadNote,
+ to: `/channels/${channel.id}`
+ }));
+ os.popupMenu(items, ev.currentTarget || ev.target);
+ },
+
+ saveSrc() {
+ this.$store.set('tl', {
+ src: this.src,
+ });
+ },
+
+ async timetravel() {
+ const { canceled, result: date } = await os.dialog({
+ title: this.$ts.date,
+ input: {
+ type: 'date'
+ }
+ });
+ if (canceled) return;
+
+ this.$refs.tl.timetravel(new Date(date));
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.cmuxhskf {
+ padding: var(--margin);
+
+ > .new {
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 16px);
+ z-index: 1000;
+ width: 100%;
+
+ > button {
+ display: block;
+ margin: var(--margin) auto 0 auto;
+ padding: 8px 16px;
+ border-radius: 32px;
+ }
+ }
+
+ > .post-form {
+ border-radius: var(--radius);
+ }
+
+ > .tl {
+ background: var(--bg);
+ border-radius: var(--radius);
+ overflow: clip;
+ }
+
+ &.min-width_800px {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user-ap-info.vue b/packages/client/src/pages/user-ap-info.vue
new file mode 100644
index 0000000000..6253faa242
--- /dev/null
+++ b/packages/client/src/pages/user-ap-info.vue
@@ -0,0 +1,124 @@
+<template>
+<FormBase>
+ <FormSuspense :p="apPromiseFactory" v-slot="{ result: ap }">
+ <FormGroup>
+ <template #label>ActivityPub</template>
+ <FormKeyValueView>
+ <template #key>Type</template>
+ <template #value><span class="_monospace">{{ ap.type }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>URI</template>
+ <template #value><span class="_monospace">{{ ap.id }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>URL</template>
+ <template #value><span class="_monospace">{{ ap.url }}</span></template>
+ </FormKeyValueView>
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Inbox</template>
+ <template #value><span class="_monospace">{{ ap.inbox }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>Shared Inbox</template>
+ <template #value><span class="_monospace">{{ ap.sharedInbox || ap.endpoints.sharedInbox }}</span></template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>Outbox</template>
+ <template #value><span class="_monospace">{{ ap.outbox }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+ <FormTextarea readonly tall code pre :value="ap.publicKey.publicKeyPem">
+ <span>Public Key</span>
+ </FormTextarea>
+ <FormKeyValueView>
+ <template #key>Discoverable</template>
+ <template #value>{{ ap.discoverable ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormKeyValueView>
+ <template #key>ManuallyApprovesFollowers</template>
+ <template #value>{{ ap.manuallyApprovesFollowers ? $ts.yes : $ts.no }}</template>
+ </FormKeyValueView>
+ <FormObjectView tall :value="ap">
+ <span>Raw</span>
+ </FormObjectView>
+ <FormGroup>
+ <FormLink :to="`https://${user.host}/.well-known/webfinger?resource=acct:${user.username}`" external>WebFinger</FormLink>
+ </FormGroup>
+ <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
+ <FormKeyValueView v-else>
+ <template #key>{{ $ts.instanceInfo }}</template>
+ <template #value>(Local user)</template>
+ </FormKeyValueView>
+ </FormGroup>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import FormObjectView from '@/components/debobigego/object-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+import { url } from '@/config';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormTextarea,
+ FormObjectView,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormSuspense,
+ },
+
+ props: {
+ userId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: this.$ts.userInfo,
+ icon: 'fas fa-info-circle'
+ },
+ user: null,
+ apPromiseFactory: null,
+ }
+ },
+
+ mounted() {
+ this.fetch();
+ },
+
+ methods: {
+ number,
+ bytes,
+
+ async fetch() {
+ this.user = await os.api('users/show', {
+ userId: this.userId
+ });
+
+ this.apPromiseFactory = () => os.api('ap/get', {
+ uri: this.user.uri || `${url}/users/${this.user.id}`
+ });
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
new file mode 100644
index 0000000000..b77d879a7e
--- /dev/null
+++ b/packages/client/src/pages/user-info.vue
@@ -0,0 +1,245 @@
+<template>
+<FormBase>
+ <FormSuspense :p="init">
+ <div class="_debobigegoItem aeakzknw">
+ <MkAvatar class="avatar" :user="user" :show-indicator="true"/>
+ </div>
+
+ <FormLink :to="userPage(user)">Profile</FormLink>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>Acct</template>
+ <template #value><span class="_monospace">{{ acct(user) }}</span></template>
+ </FormKeyValueView>
+
+ <FormKeyValueView>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ user.id }}</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup v-if="iAmModerator">
+ <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" @update:modelValue="toggleModerator" v-model="moderator">{{ $ts.moderator }}</FormSwitch>
+ <FormSwitch @update:modelValue="toggleSilence" v-model="silenced">{{ $ts.silence }}</FormSwitch>
+ <FormSwitch @update:modelValue="toggleSuspend" v-model="suspended">{{ $ts.suspend }}</FormSwitch>
+ </FormGroup>
+
+ <FormGroup>
+ <FormButton v-if="user.host != null" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton>
+ <FormButton v-if="user.host == null && iAmModerator" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+ </FormGroup>
+
+ <FormGroup>
+ <FormLink :to="`/user-ap-info/${user.id}`">ActivityPub</FormLink>
+
+ <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ $ts.instanceInfo }}<template #suffix>{{ user.host }}</template></FormLink>
+ <FormKeyValueView v-else>
+ <template #key>{{ $ts.instanceInfo }}</template>
+ <template #value>(Local user)</template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormGroup>
+ <FormKeyValueView>
+ <template #key>{{ $ts.updatedAt }}</template>
+ <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+ </FormKeyValueView>
+ </FormGroup>
+
+ <FormObjectView tall :value="user">
+ <span>Raw</span>
+ </FormObjectView>
+ </FormSuspense>
+</FormBase>
+</template>
+
+<script lang="ts">
+import { computed, defineAsyncComponent, defineComponent } from 'vue';
+import FormObjectView from '@/components/debobigego/object-view.vue';
+import FormTextarea from '@/components/debobigego/textarea.vue';
+import FormSwitch from '@/components/debobigego/switch.vue';
+import FormLink from '@/components/debobigego/link.vue';
+import FormBase from '@/components/debobigego/base.vue';
+import FormGroup from '@/components/debobigego/group.vue';
+import FormButton from '@/components/debobigego/button.vue';
+import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
+import FormSuspense from '@/components/debobigego/suspense.vue';
+import * as os from '@/os';
+import number from '@/filters/number';
+import bytes from '@/filters/bytes';
+import * as symbols from '@/symbols';
+import { url } from '@/config';
+import { userPage, acct } from '@/filters/user';
+
+export default defineComponent({
+ components: {
+ FormBase,
+ FormTextarea,
+ FormSwitch,
+ FormObjectView,
+ FormButton,
+ FormLink,
+ FormGroup,
+ FormKeyValueView,
+ FormSuspense,
+ },
+
+ props: {
+ userId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.user ? acct(this.user) : this.$ts.userInfo,
+ icon: 'fas fa-info-circle',
+ actions: this.user ? [this.user.url ? {
+ text: this.user.url,
+ icon: 'fas fa-external-link-alt',
+ handler: () => {
+ window.open(this.user.url, '_blank');
+ }
+ } : undefined].filter(x => x !== undefined) : [],
+ })),
+ init: null,
+ user: null,
+ info: null,
+ moderator: false,
+ silenced: false,
+ suspended: false,
+ }
+ },
+
+ computed: {
+ iAmModerator(): boolean {
+ return this.$i && (this.$i.isAdmin || this.$i.isModerator);
+ }
+ },
+
+ watch: {
+ userId: {
+ handler() {
+ this.init = this.createFetcher();
+ },
+ immediate: true
+ }
+ },
+
+ methods: {
+ number,
+ bytes,
+ userPage,
+ acct,
+
+ createFetcher() {
+ if (this.iAmModerator) {
+ return () => Promise.all([os.api('users/show', {
+ userId: this.userId
+ }), os.api('admin/show-user', {
+ userId: this.userId
+ })]).then(([user, info]) => {
+ this.user = user;
+ this.info = info;
+ this.moderator = this.info.isModerator;
+ this.silenced = this.info.isSilenced;
+ this.suspended = this.info.isSuspended;
+ });
+ } else {
+ return () => os.api('users/show', {
+ userId: this.userId
+ }).then((user) => {
+ this.user = user;
+ });
+ }
+ },
+
+ refreshUser() {
+ this.init = this.createFetcher();
+ },
+
+ async updateRemoteUser() {
+ await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id });
+ this.refreshUser();
+ },
+
+ async resetPassword() {
+ const { password } = await os.api('admin/reset-password', {
+ userId: this.user.id,
+ });
+
+ os.dialog({
+ type: 'success',
+ text: this.$t('newPasswordIs', { password })
+ });
+ },
+
+ async toggleSilence(v) {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm,
+ });
+ if (confirm.canceled) {
+ this.silenced = !v;
+ } else {
+ await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
+ await this.refreshUser();
+ }
+ },
+
+ async toggleSuspend(v) {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm,
+ });
+ if (confirm.canceled) {
+ this.suspended = !v;
+ } else {
+ await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
+ await this.refreshUser();
+ }
+ },
+
+ async toggleModerator(v) {
+ await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
+ await this.refreshUser();
+ },
+
+ async deleteAllFiles() {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ text: this.$ts.deleteAllFilesConfirm,
+ });
+ if (confirm.canceled) return;
+ const process = async () => {
+ await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
+ os.success();
+ };
+ await process().catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e.toString()
+ });
+ });
+ await this.refreshUser();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.aeakzknw {
+ > .avatar {
+ display: block;
+ margin: 0 auto;
+ width: 64px;
+ height: 64px;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue
new file mode 100644
index 0000000000..2fc2476fba
--- /dev/null
+++ b/packages/client/src/pages/user-list-timeline.vue
@@ -0,0 +1,147 @@
+<template>
+<div class="eqqrhokj" v-hotkey.global="keymap" v-size="{ min: [800] }">
+ <div class="new" v-if="queue > 0"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
+ <div class="tl _block">
+ <XTimeline ref="tl" class="tl"
+ :key="listId"
+ src="list"
+ :list="listId"
+ :sound="true"
+ @before="before()"
+ @after="after()"
+ @queue="queueUpdated"
+ />
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import Progress from '@/scripts/loading';
+import XTimeline from '@/components/timeline.vue';
+import { scroll } from '@/scripts/scroll';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XTimeline,
+ },
+
+ props: {
+ listId: {
+ type: String,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ list: null,
+ queue: 0,
+ [symbols.PAGE_INFO]: computed(() => this.list ? {
+ title: this.list.name,
+ icon: 'fas fa-list-ul',
+ bg: 'var(--bg)',
+ actions: [{
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }, {
+ icon: 'fas fa-cog',
+ text: this.$ts.settings,
+ handler: this.settings
+ }],
+ } : null),
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 't': this.focus
+ };
+ },
+ },
+
+ watch: {
+ listId: {
+ async handler() {
+ this.list = await os.api('users/lists/show', {
+ listId: this.listId
+ });
+ },
+ immediate: true
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ },
+
+ queueUpdated(q) {
+ this.queue = q;
+ },
+
+ top() {
+ scroll(this.$el, { top: 0 });
+ },
+
+ settings() {
+ this.$router.push(`/my/lists/${this.listId}`);
+ },
+
+ async timetravel() {
+ const { canceled, result: date } = await os.dialog({
+ title: this.$ts.date,
+ input: {
+ type: 'date'
+ }
+ });
+ if (canceled) return;
+
+ this.$refs.tl.timetravel(new Date(date));
+ },
+
+ focus() {
+ (this.$refs.tl as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.eqqrhokj {
+ padding: var(--margin);
+
+ > .new {
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 16px);
+ z-index: 1000;
+ width: 100%;
+
+ > button {
+ display: block;
+ margin: var(--margin) auto 0 auto;
+ padding: 8px 16px;
+ border-radius: 32px;
+ }
+ }
+
+ > .tl {
+ background: var(--bg);
+ border-radius: var(--radius);
+ overflow: clip;
+ }
+
+ &.min-width_800px {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/clips.vue b/packages/client/src/pages/user/clips.vue
new file mode 100644
index 0000000000..2ec96d2286
--- /dev/null
+++ b/packages/client/src/pages/user/clips.vue
@@ -0,0 +1,50 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <MkA v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap">
+ <b>{{ item.name }}</b>
+ <div v-if="item.description" class="description">{{ item.description }}</div>
+ </MkA>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/clips',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue
new file mode 100644
index 0000000000..fec4431419
--- /dev/null
+++ b/packages/client/src/pages/user/follow-list.vue
@@ -0,0 +1,65 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers" ref="list">
+ <div class="users _isolated">
+ <MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkUserInfo from '@/components/user-info.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkUserInfo,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ type: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: () => this.type === 'following' ? 'users/following' : 'users/followers',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ type() {
+ this.$refs.list.reload();
+ },
+
+ user() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-following-or-followers {
+ > .users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: var(--margin);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue
new file mode 100644
index 0000000000..fb99cdff19
--- /dev/null
+++ b/packages/client/src/pages/user/gallery.vue
@@ -0,0 +1,56 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}">
+ <div class="jrnovfpt">
+ <MkGalleryPostPreview v-for="post in items" :post="post" :key="post.id" class="post"/>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkGalleryPostPreview from '@/components/gallery-post-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkGalleryPostPreview,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/gallery/posts',
+ limit: 6,
+ params: () => ({
+ userId: this.user.id
+ })
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.jrnovfpt {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ grid-gap: 12px;
+ margin: var(--margin);
+}
+</style>
diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue
new file mode 100644
index 0000000000..e51d6c6090
--- /dev/null
+++ b/packages/client/src/pages/user/index.activity.vue
@@ -0,0 +1,34 @@
+<template>
+<MkContainer>
+ <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
+
+ <div style="padding: 8px;">
+ <MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :stacked="true" :detailed="false" :aspect-ratio="6"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
+import MkChart from '@/components/chart.vue';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ MkChart,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ limit: {
+ type: Number,
+ required: false,
+ default: 40
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue
new file mode 100644
index 0000000000..4c52dceae6
--- /dev/null
+++ b/packages/client/src/pages/user/index.photos.vue
@@ -0,0 +1,107 @@
+<template>
+<MkContainer :max-height="300" :foldable="true">
+ <template #header><i class="fas fa-image" style="margin-right: 0.5em;"></i>{{ $ts.images }}</template>
+ <div class="ujigsodd">
+ <MkLoading v-if="fetching"/>
+ <div class="stream" v-if="!fetching && images.length > 0">
+ <MkA v-for="image in images"
+ class="img"
+ :to="notePage(image.note)"
+ :key="image.id"
+ >
+ <ImgWithBlurhash :hash="image.blurhash" :src="thumbnail(image.file)" :alt="image.name" :title="image.name"/>
+ </MkA>
+ </div>
+ <p class="empty" v-if="!fetching && images.length == 0">{{ $ts.nothing }}</p>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import notePage from '@/filters/note';
+import * as os from '@/os';
+import MkContainer from '@/components/ui/container.vue';
+import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
+
+export default defineComponent({
+ components: {
+ MkContainer,
+ ImgWithBlurhash,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+ data() {
+ return {
+ fetching: true,
+ images: [],
+ };
+ },
+ mounted() {
+ const image = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/gif',
+ 'image/apng',
+ 'image/vnd.mozilla.apng',
+ ];
+ os.api('users/notes', {
+ userId: this.user.id,
+ fileType: image,
+ excludeNsfw: this.$store.state.nsfw !== 'ignore',
+ limit: 10,
+ }).then(notes => {
+ for (const note of notes) {
+ for (const file of note.files) {
+ this.images.push({
+ note,
+ file
+ });
+ }
+ }
+ this.fetching = false;
+ });
+ },
+ methods: {
+ thumbnail(image: any): string {
+ return this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(image.thumbnailUrl)
+ : image.thumbnailUrl;
+ },
+ notePage
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.ujigsodd {
+ padding: 8px;
+
+ > .stream {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ grid-gap: 6px;
+
+ > .img {
+ height: 128px;
+ border-radius: 6px;
+ overflow: clip;
+ }
+ }
+
+ > .empty {
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/index.timeline.vue b/packages/client/src/pages/user/index.timeline.vue
new file mode 100644
index 0000000000..eff38ec3c8
--- /dev/null
+++ b/packages/client/src/pages/user/index.timeline.vue
@@ -0,0 +1,68 @@
+<template>
+<div class="yrzkoczt" v-sticky-container>
+ <MkTab v-model="with_" class="tab">
+ <option :value="null">{{ $ts.notes }}</option>
+ <option value="replies">{{ $ts.notesAndReplies }}</option>
+ <option value="files">{{ $ts.withFiles }}</option>
+ </MkTab>
+ <XNotes ref="timeline" :no-gap="true" :pagination="pagination" @before="$emit('before')" @after="e => $emit('after', e)"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNotes from '@/components/notes.vue';
+import MkTab from '@/components/tab.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNotes,
+ MkTab,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ watch: {
+ user() {
+ this.$refs.timeline.reload();
+ },
+
+ with_() {
+ this.$refs.timeline.reload();
+ },
+ },
+
+ data() {
+ return {
+ date: null,
+ with_: null,
+ pagination: {
+ endpoint: 'users/notes',
+ limit: 10,
+ params: init => ({
+ userId: this.user.id,
+ includeReplies: this.with_ === 'replies',
+ withFiles: this.with_ === 'files',
+ untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
+ })
+ }
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.yrzkoczt {
+ > .tab {
+ margin: calc(var(--margin) / 2) 0;
+ padding: calc(var(--margin) / 2) 0;
+ background: var(--bg);
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
new file mode 100644
index 0000000000..d2531c0d1b
--- /dev/null
+++ b/packages/client/src/pages/user/index.vue
@@ -0,0 +1,829 @@
+<template>
+<div>
+<transition name="fade" mode="out-in">
+ <div class="ftskorzw wide" v-if="user && narrow === false">
+ <MkRemoteCaution v-if="user.host != null" :href="user.url"/>
+
+ <div class="banner-container" :style="style">
+ <div class="banner" ref="banner" :style="style"></div>
+ </div>
+ <div class="contents">
+ <div class="side _forceContainerFull_">
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="name">
+ <MkUserName :user="user" :nowrap="false" class="name"/>
+ <MkAcct :user="user" :detail="true" class="acct"/>
+ </div>
+ <div class="followed" v-if="$i && $i.id != user.id && user.isFollowed"><span>{{ $ts.followsYou }}</span></div>
+ <div class="status">
+ <MkA :to="userPage(user)" :class="{ active: page === 'index' }">
+ <b>{{ number(user.notesCount) }}</b>
+ <span>{{ $ts.notes }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }">
+ <b>{{ number(user.followingCount) }}</b>
+ <span>{{ $ts.following }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }">
+ <b>{{ number(user.followersCount) }}</b>
+ <span>{{ $ts.followers }}</span>
+ </MkA>
+ </div>
+ <div class="description">
+ <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ <p v-else class="empty">{{ $ts.noAccountDescription }}</p>
+ </div>
+ <div class="fields system">
+ <dl class="field" v-if="user.location">
+ <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl class="field" v-if="user.birthday">
+ <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+ <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+ </dl>
+ </div>
+ <div class="fields" v-if="user.fields.length > 0">
+ <dl class="field" v-for="(field, i) in user.fields" :key="i">
+ <dt class="name">
+ <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+ </dt>
+ <dd class="value">
+ <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
+ </dd>
+ </dl>
+ </div>
+ <XActivity :user="user" :key="user.id" class="_gap"/>
+ <XPhotos :user="user" :key="user.id" class="_gap"/>
+ </div>
+ <div class="main">
+ <div class="actions">
+ <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
+ <MkFollowButton v-if="!$i || $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
+ </div>
+ <template v-if="page === 'index'">
+ <div v-if="user.pinnedNotes.length > 0" class="_gap">
+ <XNote v-for="note in user.pinnedNotes" class="note _gap" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/>
+ </div>
+ <div class="_gap">
+ <XUserTimeline :user="user"/>
+ </div>
+ </template>
+ <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_gap"/>
+ <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_gap"/>
+ <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
+ <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
+ </div>
+ </div>
+ </div>
+ <MkSpacer v-else-if="user && narrow === true" :content-max="800">
+ <div class="ftskorzw narrow" v-size="{ max: [500] }">
+ <!-- TODO -->
+ <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> -->
+ <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> -->
+
+ <div class="profile">
+ <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/>
+
+ <div class="_block main" :key="user.id">
+ <div class="banner-container" :style="style">
+ <div class="banner" ref="banner" :style="style"></div>
+ <div class="fade"></div>
+ <div class="title">
+ <MkUserName class="name" :user="user" :nowrap="true"/>
+ <div class="bottom">
+ <span class="username"><MkAcct :user="user" :detail="true" /></span>
+ <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+ </div>
+ </div>
+ <span class="followed" v-if="$i && $i.id != user.id && user.isFollowed">{{ $ts.followsYou }}</span>
+ <div class="actions" v-if="$i">
+ <button @click="menu" class="menu _button"><i class="fas fa-ellipsis-h"></i></button>
+ <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
+ </div>
+ </div>
+ <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>
+ <div class="title">
+ <MkUserName :user="user" :nowrap="false" class="name"/>
+ <div class="bottom">
+ <span class="username"><MkAcct :user="user" :detail="true" /></span>
+ <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span>
+ <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span>
+ <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span>
+ <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span>
+ </div>
+ </div>
+ <div class="description">
+ <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/>
+ <p v-else class="empty">{{ $ts.noAccountDescription }}</p>
+ </div>
+ <div class="fields system">
+ <dl class="field" v-if="user.location">
+ <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt>
+ <dd class="value">{{ user.location }}</dd>
+ </dl>
+ <dl class="field" v-if="user.birthday">
+ <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt>
+ <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd>
+ </dl>
+ <dl class="field">
+ <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt>
+ <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd>
+ </dl>
+ </div>
+ <div class="fields" v-if="user.fields.length > 0">
+ <dl class="field" v-for="(field, i) in user.fields" :key="i">
+ <dt class="name">
+ <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/>
+ </dt>
+ <dd class="value">
+ <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/>
+ </dd>
+ </dl>
+ </div>
+ <div class="status">
+ <MkA :to="userPage(user)" :class="{ active: page === 'index' }" v-click-anime>
+ <b>{{ number(user.notesCount) }}</b>
+ <span>{{ $ts.notes }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }" v-click-anime>
+ <b>{{ number(user.followingCount) }}</b>
+ <span>{{ $ts.following }}</span>
+ </MkA>
+ <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }" v-click-anime>
+ <b>{{ number(user.followersCount) }}</b>
+ <span>{{ $ts.followers }}</span>
+ </MkA>
+ </div>
+ </div>
+ </div>
+
+ <div class="contents">
+ <template v-if="page === 'index'">
+ <div>
+ <div v-if="user.pinnedNotes.length > 0" class="_gap">
+ <XNote v-for="note in user.pinnedNotes" class="note _block" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :pinned="true"/>
+ </div>
+ <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo>
+ <XPhotos :user="user" :key="user.id"/>
+ <XActivity :user="user" :key="user.id" style="margin-top: var(--margin);"/>
+ </div>
+ <div>
+ <XUserTimeline :user="user"/>
+ </div>
+ </template>
+ <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/>
+ <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/>
+ <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/>
+ <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/>
+ <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/>
+ <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/>
+ </div>
+ </div>
+ </MkSpacer>
+ <MkError v-else-if="error" @retry="fetch()"/>
+ <MkLoading v-else/>
+</transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, computed } from 'vue';
+import * as age from 's-age';
+import XUserTimeline from './index.timeline.vue';
+import XNote from '@/components/note.vue';
+import MkFollowButton from '@/components/follow-button.vue';
+import MkContainer from '@/components/ui/container.vue';
+import MkFolder from '@/components/ui/folder.vue';
+import MkRemoteCaution from '@/components/remote-caution.vue';
+import MkTab from '@/components/tab.vue';
+import MkInfo from '@/components/ui/info.vue';
+import Progress from '@/scripts/loading';
+import * as Acct from 'misskey-js/built/acct';
+import { getScrollPosition } from '@/scripts/scroll';
+import { getUserMenu } from '@/scripts/get-user-menu';
+import number from '@/filters/number';
+import { userPage, acct as getAcct } from '@/filters/user';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XUserTimeline,
+ XNote,
+ MkFollowButton,
+ MkContainer,
+ MkRemoteCaution,
+ MkFolder,
+ MkTab,
+ MkInfo,
+ XFollowList: defineAsyncComponent(() => import('./follow-list.vue')),
+ XReactions: defineAsyncComponent(() => import('./reactions.vue')),
+ XClips: defineAsyncComponent(() => import('./clips.vue')),
+ XPages: defineAsyncComponent(() => import('./pages.vue')),
+ XGallery: defineAsyncComponent(() => import('./gallery.vue')),
+ XPhotos: defineAsyncComponent(() => import('./index.photos.vue')),
+ XActivity: defineAsyncComponent(() => import('./index.activity.vue')),
+ },
+
+ props: {
+ acct: {
+ type: String,
+ required: true
+ },
+ page: {
+ type: String,
+ required: false,
+ default: 'index'
+ }
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: computed(() => this.user ? {
+ icon: 'fas fa-user',
+ title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`,
+ subtitle: `@${getAcct(this.user)}`,
+ userName: this.user,
+ avatar: this.user,
+ path: `/@${this.user.username}`,
+ share: {
+ title: this.user.name,
+ },
+ bg: 'var(--bg)',
+ tabs: [{
+ active: this.page === 'index',
+ title: this.$ts.overview,
+ icon: 'fas fa-home',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user)); },
+ }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{
+ active: this.page === 'reactions',
+ title: this.$ts.reaction,
+ icon: 'fas fa-laugh',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/reactions'); },
+ }] : [], {
+ active: this.page === 'clips',
+ title: this.$ts.clips,
+ icon: 'fas fa-paperclip',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/clips'); },
+ }, {
+ active: this.page === 'pages',
+ title: this.$ts.pages,
+ icon: 'fas fa-file-alt',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/pages'); },
+ }, {
+ active: this.page === 'gallery',
+ title: this.$ts.gallery,
+ icon: 'fas fa-icons',
+ onClick: () => { this.$router.push('/@' + getAcct(this.user) + '/gallery'); },
+ }],
+ } : null),
+ user: null,
+ error: null,
+ parallaxAnimationId: null,
+ narrow: null,
+ };
+ },
+
+ computed: {
+ style(): any {
+ if (this.user.bannerUrl == null) return {};
+ return {
+ backgroundImage: `url(${ this.user.bannerUrl })`
+ };
+ },
+
+ age(): number {
+ return age(this.user.birthday);
+ }
+ },
+
+ watch: {
+ acct: 'fetch'
+ },
+
+ created() {
+ this.fetch();
+ },
+
+ mounted() {
+ window.requestAnimationFrame(this.parallaxLoop);
+ this.narrow = true//this.$el.clientWidth < 1000;
+ },
+
+ beforeUnmount() {
+ window.cancelAnimationFrame(this.parallaxAnimationId);
+ },
+
+ methods: {
+ getAcct,
+
+ fetch() {
+ if (this.acct == null) return;
+ this.user = null;
+ Progress.start();
+ os.api('users/show', Acct.parse(this.acct)).then(user => {
+ this.user = user;
+ }).catch(e => {
+ this.error = e;
+ }).finally(() => {
+ Progress.done();
+ });
+ },
+
+ menu(ev) {
+ os.popupMenu(getUserMenu(this.user), ev.currentTarget || ev.target);
+ },
+
+ parallaxLoop() {
+ this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop);
+ this.parallax();
+ },
+
+ parallax() {
+ const banner = this.$refs.banner as any;
+ if (banner == null) return;
+
+ const top = getScrollPosition(this.$el);
+
+ if (top < 0) return;
+
+ const z = 1.75; // 奥行き(小さいほど奥)
+ const pos = -(top / z);
+ banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
+ },
+
+ pinnedNoteUpdated(oldValue, newValue) {
+ const i = this.user.pinnedNotes.findIndex(n => n === oldValue);
+ this.user.pinnedNotes[i] = newValue;
+ },
+
+ number,
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.125s ease;
+}
+.fade-enter-from,
+.fade-leave-to {
+ opacity: 0;
+}
+
+.ftskorzw.wide {
+
+ > .banner-container {
+ position: relative;
+ height: 300px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+
+ > .banner {
+ height: 100%;
+ background-color: #4c5e6d;
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
+ will-change: background-position;
+ }
+ }
+
+ > .contents {
+ display: flex;
+ padding: 16px;
+
+ > .side {
+ width: 360px;
+
+ > .avatar {
+ display: block;
+ width: 180px;
+ height: 180px;
+ margin: -130px auto 0 auto;
+ }
+
+ > .name {
+ padding: 16px 0px 20px 0;
+ text-align: center;
+
+ > .name {
+ display: block;
+ font-size: 1.75em;
+ font-weight: bold;
+ }
+ }
+
+ > .followed {
+ text-align: center;
+
+ > span {
+ display: inline-block;
+ font-size: 80%;
+ padding: 8px 12px;
+ margin-bottom: 20px;
+ border: solid 0.5px var(--divider);
+ border-radius: 999px;
+ }
+ }
+
+ > .status {
+ display: flex;
+ padding: 20px 16px;
+ border-top: solid 0.5px var(--divider);
+ font-size: 90%;
+
+ > a {
+ flex: 1;
+ text-align: center;
+
+ &.active {
+ color: var(--accent);
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > b {
+ display: block;
+ line-height: 16px;
+ }
+
+ > span {
+ font-size: 75%;
+ }
+ }
+ }
+
+ > .description {
+ padding: 20px 16px;
+ border-top: solid 0.5px var(--divider);
+ font-size: 90%;
+ }
+
+ > .fields {
+ padding: 20px 16px;
+ border-top: solid 0.5px var(--divider);
+ font-size: 90%;
+
+ > .field {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ align-items: center;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > .name {
+ width: 30%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-weight: bold;
+ }
+
+ > .value {
+ width: 70%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0;
+ }
+ }
+ }
+ }
+
+ > .main {
+ flex: 1;
+ margin-left: var(--margin);
+ min-width: 0;
+
+ > .nav {
+ display: flex;
+ align-items: center;
+ margin-top: var(--margin);
+ //font-size: 120%;
+ font-weight: bold;
+
+ > .link {
+ display: inline-block;
+ padding: 15px 24px 12px 24px;
+ text-align: center;
+ border-bottom: solid 3px transparent;
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ &.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+ }
+
+ &:not(.active):hover {
+ color: var(--fgHighlighted);
+ }
+
+ > .icon {
+ margin-right: 6px;
+ }
+ }
+
+ > .actions {
+ display: flex;
+ align-items: center;
+ margin-left: auto;
+
+ > .menu {
+ padding: 12px 16px;
+ }
+ }
+ }
+ }
+ }
+}
+
+.ftskorzw.narrow {
+ box-sizing: border-box;
+ overflow: clip;
+ background: var(--bg);
+
+ > .punished {
+ font-size: 0.8em;
+ padding: 16px;
+ }
+
+ > .profile {
+
+ > .main {
+ position: relative;
+ overflow: hidden;
+
+ > .banner-container {
+ position: relative;
+ height: 250px;
+ overflow: hidden;
+ background-size: cover;
+ background-position: center;
+
+ > .banner {
+ height: 100%;
+ background-color: #4c5e6d;
+ background-size: cover;
+ background-position: center;
+ box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
+ will-change: background-position;
+ }
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 78px;
+ background: linear-gradient(transparent, rgba(#000, 0.7));
+ }
+
+ > .followed {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ padding: 4px 8px;
+ color: #fff;
+ background: rgba(0, 0, 0, 0.7);
+ font-size: 0.7em;
+ border-radius: 6px;
+ }
+
+ > .actions {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ background: rgba(0, 0, 0, 0.2);
+ padding: 8px;
+ border-radius: 24px;
+
+ > .menu {
+ vertical-align: bottom;
+ height: 31px;
+ width: 31px;
+ color: #fff;
+ text-shadow: 0 0 8px #000;
+ font-size: 16px;
+ }
+
+ > .koudoku {
+ margin-left: 4px;
+ vertical-align: bottom;
+ }
+ }
+
+ > .title {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ padding: 0 0 8px 154px;
+ box-sizing: border-box;
+ color: #fff;
+
+ > .name {
+ display: block;
+ margin: 0;
+ line-height: 32px;
+ font-weight: bold;
+ font-size: 1.8em;
+ text-shadow: 0 0 8px #000;
+ }
+
+ > .bottom {
+ > * {
+ display: inline-block;
+ margin-right: 16px;
+ line-height: 20px;
+ opacity: 0.8;
+
+ &.username {
+ font-weight: bold;
+ }
+ }
+ }
+ }
+ }
+
+ > .title {
+ display: none;
+ text-align: center;
+ padding: 50px 8px 16px 8px;
+ font-weight: bold;
+ border-bottom: solid 0.5px var(--divider);
+
+ > .bottom {
+ > * {
+ display: inline-block;
+ margin-right: 8px;
+ opacity: 0.8;
+ }
+ }
+ }
+
+ > .avatar {
+ display: block;
+ position: absolute;
+ top: 170px;
+ left: 16px;
+ z-index: 2;
+ width: 120px;
+ height: 120px;
+ box-shadow: 1px 1px 3px rgba(#000, 0.2);
+ }
+
+ > .description {
+ padding: 24px 24px 24px 154px;
+ font-size: 0.95em;
+
+ > .empty {
+ margin: 0;
+ opacity: 0.5;
+ }
+ }
+
+ > .fields {
+ padding: 24px;
+ font-size: 0.9em;
+ border-top: solid 0.5px var(--divider);
+
+ > .field {
+ display: flex;
+ padding: 0;
+ margin: 0;
+ align-items: center;
+
+ &:not(:last-child) {
+ margin-bottom: 8px;
+ }
+
+ > .name {
+ width: 30%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ font-weight: bold;
+ text-align: center;
+ }
+
+ > .value {
+ width: 70%;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ margin: 0;
+ }
+ }
+
+ &.system > .field > .name {
+ }
+ }
+
+ > .status {
+ display: flex;
+ padding: 24px;
+ border-top: solid 0.5px var(--divider);
+
+ > a {
+ flex: 1;
+ text-align: center;
+
+ &.active {
+ color: var(--accent);
+ }
+
+ &:hover {
+ text-decoration: none;
+ }
+
+ > b {
+ display: block;
+ line-height: 16px;
+ }
+
+ > span {
+ font-size: 70%;
+ }
+ }
+ }
+ }
+ }
+
+ > .contents {
+ > .content {
+ margin-bottom: var(--margin);
+ }
+ }
+
+ &.max-width_500px {
+ > .profile > .main {
+ > .banner-container {
+ height: 140px;
+
+ > .fade {
+ display: none;
+ }
+
+ > .title {
+ display: none;
+ }
+ }
+
+ > .title {
+ display: block;
+ }
+
+ > .avatar {
+ top: 90px;
+ left: 0;
+ right: 0;
+ width: 92px;
+ height: 92px;
+ margin: auto;
+ }
+
+ > .description {
+ padding: 16px;
+ text-align: center;
+ }
+
+ > .fields {
+ padding: 16px;
+ }
+
+ > .status {
+ padding: 16px;
+ }
+ }
+
+ > .contents {
+ > .nav {
+ font-size: 80%;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/user/pages.vue b/packages/client/src/pages/user/pages.vue
new file mode 100644
index 0000000000..0bf925d7d5
--- /dev/null
+++ b/packages/client/src/pages/user/pages.vue
@@ -0,0 +1,49 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <MkPagePreview v-for="page in items" :page="page" :key="page.id" class="_gap"/>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagePreview from '@/components/page-preview.vue';
+import MkPagination from '@/components/ui/pagination.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkPagePreview,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/pages',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/client/src/pages/user/reactions.vue b/packages/client/src/pages/user/reactions.vue
new file mode 100644
index 0000000000..3ca3b2aac8
--- /dev/null
+++ b/packages/client/src/pages/user/reactions.vue
@@ -0,0 +1,81 @@
+<template>
+<div>
+ <MkPagination :pagination="pagination" #default="{items}" ref="list">
+ <div v-for="item in items" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap afdcfbfb">
+ <div class="header">
+ <MkAvatar class="avatar" :user="user"/>
+ <MkReactionIcon class="reaction" :reaction="item.type" :custom-emojis="item.note.emojis" :no-style="true"/>
+ <MkTime :time="item.createdAt" class="createdAt"/>
+ </div>
+ <MkNote :note="item.note" @update:note="updated(note, $event)" :key="item.id"/>
+ </div>
+ </MkPagination>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkNote from '@/components/note.vue';
+import MkReactionIcon from '@/components/reaction-icon.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkNote,
+ MkReactionIcon,
+ },
+
+ props: {
+ user: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'users/reactions',
+ limit: 20,
+ params: {
+ userId: this.user.id,
+ }
+ },
+ };
+ },
+
+ watch: {
+ user() {
+ this.$refs.list.reload();
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.afdcfbfb {
+ > .header {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ margin-bottom: 8px;
+ border-bottom: solid 2px var(--divider);
+
+ > .avatar {
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+ }
+
+ > .reaction {
+ width: 32px;
+ height: 32px;
+ }
+
+ > .createdAt {
+ margin-left: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/v.vue b/packages/client/src/pages/v.vue
new file mode 100644
index 0000000000..3b1bb20861
--- /dev/null
+++ b/packages/client/src/pages/v.vue
@@ -0,0 +1,29 @@
+<template>
+<div>
+ <section class="_section">
+ <div class="_content" style="text-align: center;">
+ <img src="/static-assets/icons/512.png" alt="" style="display: block; width: 100px; margin: 0 auto; border-radius: 16px;"/>
+ <div style="margin-top: 0.75em;">Misskey</div>
+ <div style="opacity: 0.5;">v{{ version }}</div>
+ </div>
+ </section>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { version } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: 'Misskey',
+ icon: null
+ },
+ version,
+ }
+ },
+});
+</script>
diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue
new file mode 100644
index 0000000000..2e0c520bc6
--- /dev/null
+++ b/packages/client/src/pages/welcome.entrance.a.vue
@@ -0,0 +1,320 @@
+<template>
+<div class="rsqzvsbo" v-if="meta">
+ <div class="top">
+ <MkFeaturedPhotos class="bg"/>
+ <XTimeline class="tl"/>
+ <div class="shape1"></div>
+ <div class="shape2"></div>
+ <img src="/client-assets/misskey.svg" class="misskey"/>
+ <div class="emojis">
+ <MkEmoji :normal="true" :no-style="true" emoji="👍"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="❤"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="😆"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
+ </div>
+ <div class="main _panel">
+ <div class="bg">
+ <div class="fade"></div>
+ </div>
+ <div class="fg">
+ <h1>
+ <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に -->
+ <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> -->
+ <span class="text">{{ instanceName }}</span>
+ </h1>
+ <div class="about">
+ <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ </div>
+ <div class="action">
+ <MkButton @click="signup()" inline gradate data-cy-signup style="margin-right: 12px;">{{ $ts.signup }}</MkButton>
+ <MkButton @click="signin()" inline data-cy-signin>{{ $ts.login }}</MkButton>
+ </div>
+ <div class="status" v-if="onlineUsersCount && stats">
+ <div>
+ <I18n :src="$ts.nUsers" text-tag="span" class="users">
+ <template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
+ </I18n>
+ <I18n :src="$ts.nNotes" text-tag="span" class="notes">
+ <template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
+ </I18n>
+ </div>
+ <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
+ <template #n><b>{{ onlineUsersCount }}</b></template>
+ </I18n>
+ </div>
+ <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XNote from '@/components/note.vue';
+import MkFeaturedPhotos from '@/components/featured-photos.vue';
+import XTimeline from './welcome.timeline.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XNote,
+ MkFeaturedPhotos,
+ XTimeline,
+ },
+
+ data() {
+ return {
+ host: toUnicode(host),
+ instanceName,
+ meta: null,
+ stats: null,
+ tags: [],
+ onlineUsersCount: null,
+ };
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+
+ os.api('stats').then(stats => {
+ this.stats = stats;
+ });
+
+ os.api('get-online-users-count').then(res => {
+ this.onlineUsersCount = res.count;
+ });
+
+ os.api('hashtags/list', {
+ sort: '+mentionedLocalUsers',
+ limit: 8
+ }).then(tags => {
+ this.tags = tags;
+ });
+ },
+
+ methods: {
+ signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ showMenu(ev) {
+ os.popupMenu([{
+ text: this.$t('aboutX', { x: instanceName }),
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ }
+ }, {
+ text: this.$ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ }
+ }, null, {
+ text: this.$ts.help,
+ icon: 'fas fa-question-circle',
+ action: () => {
+ window.open(`https://misskey-hub.net/help.md`, '_blank');
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+ > .top {
+ display: flex;
+ text-align: center;
+ min-height: 100vh;
+ box-sizing: border-box;
+ padding: 16px;
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 80%; // 100%からshapeの幅を引いている
+ height: 100%;
+ }
+
+ > .tl {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 64px;
+ margin: auto;
+ width: 500px;
+ height: calc(100% - 128px);
+ overflow: hidden;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+
+ @media (max-width: 1200px) {
+ display: none;
+ }
+ }
+
+ > .shape1 {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--accent);
+ clip-path: polygon(0% 0%, 45% 0%, 20% 100%, 0% 100%);
+ }
+ > .shape2 {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--accent);
+ clip-path: polygon(0% 0%, 25% 0%, 35% 100%, 0% 100%);
+ opacity: 0.5;
+ }
+
+ > .misskey {
+ position: absolute;
+ top: 42px;
+ left: 42px;
+ width: 160px;
+
+ @media (max-width: 450px) {
+ width: 130px;
+ }
+ }
+
+ > .emojis {
+ position: absolute;
+ bottom: 32px;
+ left: 35px;
+
+ > * {
+ margin-right: 8px;
+ }
+
+ @media (max-width: 1200px) {
+ display: none;
+ }
+ }
+
+ > .main {
+ position: relative;
+ width: min(480px, 100%);
+ margin: auto auto auto 128px;
+ box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+
+ @media (max-width: 1200px) {
+ margin: auto;
+ }
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 128px;
+ background-position: center;
+ background-size: cover;
+ opacity: 0.75;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 128px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+ }
+
+ > .fg {
+ position: relative;
+ z-index: 1;
+
+ > h1 {
+ display: block;
+ margin: 0;
+ padding: 32px 32px 24px 32px;
+ font-size: 1.5em;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 120px;
+ max-width: min(100%, 300px);
+ }
+ }
+
+ > .about {
+ padding: 0 32px;
+ }
+
+ > .action {
+ padding: 32px;
+
+ > * {
+ line-height: 28px;
+ }
+ }
+
+ > .status {
+ border-top: solid 0.5px var(--divider);
+ padding: 32px;
+ font-size: 90%;
+
+ > div {
+ > span:not(:last-child) {
+ padding-right: 1em;
+ margin-right: 1em;
+ border-right: solid 0.5px var(--divider);
+ }
+ }
+
+ > .online {
+ ::v-deep(b) {
+ color: #41b781;
+ }
+
+ ::v-deep(span) {
+ opacity: 0.7;
+ }
+ }
+ }
+
+ > .menu {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue
new file mode 100644
index 0000000000..efb8b09360
--- /dev/null
+++ b/packages/client/src/pages/welcome.entrance.b.vue
@@ -0,0 +1,236 @@
+<template>
+<div class="rsqzvsbo" v-if="meta">
+ <div class="top">
+ <MkFeaturedPhotos class="bg"/>
+ <XTimeline class="tl"/>
+ <div class="shape"></div>
+ <div class="main">
+ <h1>
+ <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
+ </h1>
+ <div class="about">
+ <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ </div>
+ <div class="action">
+ <MkButton class="signup" @click="signup()" inline gradate>{{ $ts.signup }}</MkButton>
+ <MkButton class="signin" @click="signin()" inline>{{ $ts.login }}</MkButton>
+ </div>
+ <div class="status" v-if="onlineUsersCount && stats">
+ <div>
+ <I18n :src="$ts.nUsers" text-tag="span" class="users">
+ <template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
+ </I18n>
+ <I18n :src="$ts.nNotes" text-tag="span" class="notes">
+ <template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
+ </I18n>
+ </div>
+ <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
+ <template #n><b>{{ onlineUsersCount }}</b></template>
+ </I18n>
+ </div>
+ </div>
+ <img src="/client-assets/misskey.svg" class="misskey"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XNote from '@/components/note.vue';
+import MkFeaturedPhotos from '@/components/featured-photos.vue';
+import XTimeline from './welcome.timeline.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XNote,
+ XTimeline,
+ MkFeaturedPhotos,
+ },
+
+ data() {
+ return {
+ host: toUnicode(host),
+ instanceName,
+ meta: null,
+ stats: null,
+ tags: [],
+ onlineUsersCount: null,
+ };
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+
+ os.api('stats').then(stats => {
+ this.stats = stats;
+ });
+
+ os.api('get-online-users-count').then(res => {
+ this.onlineUsersCount = res.count;
+ });
+
+ os.api('hashtags/list', {
+ sort: '+mentionedLocalUsers',
+ limit: 8
+ }).then(tags => {
+ this.tags = tags;
+ });
+ },
+
+ methods: {
+ signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ showMenu(ev) {
+ os.popupMenu([{
+ text: this.$t('aboutX', { x: instanceName }),
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ }
+ }, {
+ text: this.$ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ }
+ }, null, {
+ text: this.$ts.help,
+ icon: 'fas fa-question-circle',
+ action: () => {
+ window.open(`https://misskey-hub.net/help.md`, '_blank');
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+ > .top {
+ min-height: 100vh;
+ box-sizing: border-box;
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .tl {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 64px;
+ margin: auto;
+ width: 500px;
+ height: calc(100% - 128px);
+ overflow: hidden;
+ -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+ mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%);
+ }
+
+ > .shape {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--accent);
+ clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%);
+ }
+
+ > .misskey {
+ position: absolute;
+ bottom: 64px;
+ left: 64px;
+ width: 160px;
+ }
+
+ > .main {
+ position: relative;
+ width: min(450px, 100%);
+ padding: 64px;
+ color: #fff;
+ font-size: 1.1em;
+
+ @media (max-width: 1200px) {
+ margin: auto;
+ }
+
+ > h1 {
+ display: block;
+ margin: 0 0 32px 0;
+ padding: 0;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 100px;
+ }
+ }
+
+ > .about {
+ padding: 0;
+ }
+
+ > .action {
+ margin: 32px 0;
+
+ > * {
+ line-height: 32px;
+ }
+
+ > .signup {
+ background: var(--panel);
+ color: var(--fg);
+ }
+
+ > .signin {
+ background: var(--accent);
+ color: inherit;
+ }
+ }
+
+ > .status {
+ margin: 32px 0;
+ border-top: solid 1px rgba(255, 255, 255, 0.5);
+ font-size: 90%;
+
+ > div {
+ padding: 16px 0;
+
+ > span:not(:last-child) {
+ padding-right: 1em;
+ margin-right: 1em;
+ border-right: solid 1px rgba(255, 255, 255, 0.5);
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue
new file mode 100644
index 0000000000..2b0ff7a31c
--- /dev/null
+++ b/packages/client/src/pages/welcome.entrance.c.vue
@@ -0,0 +1,305 @@
+<template>
+<div class="rsqzvsbo" v-if="meta">
+ <div class="top">
+ <MkFeaturedPhotos class="bg"/>
+ <div class="fade"></div>
+ <div class="emojis">
+ <MkEmoji :normal="true" :no-style="true" emoji="👍"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="❤"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="😆"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="🎉"/>
+ <MkEmoji :normal="true" :no-style="true" emoji="🍮"/>
+ </div>
+ <div class="main">
+ <img src="/client-assets/misskey.svg" class="misskey"/>
+ <div class="form _panel">
+ <div class="bg">
+ <div class="fade"></div>
+ </div>
+ <div class="fg">
+ <h1>
+ <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span>
+ </h1>
+ <div class="about">
+ <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div>
+ </div>
+ <div class="action">
+ <MkButton @click="signup()" inline gradate>{{ $ts.signup }}</MkButton>
+ <MkButton @click="signin()" inline>{{ $ts.login }}</MkButton>
+ </div>
+ <div class="status" v-if="onlineUsersCount && stats">
+ <div>
+ <I18n :src="$ts.nUsers" text-tag="span" class="users">
+ <template #n><b>{{ number(stats.originalUsersCount) }}</b></template>
+ </I18n>
+ <I18n :src="$ts.nNotes" text-tag="span" class="notes">
+ <template #n><b>{{ number(stats.originalNotesCount) }}</b></template>
+ </I18n>
+ </div>
+ <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online">
+ <template #n><b>{{ onlineUsersCount }}</b></template>
+ </I18n>
+ </div>
+ <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button>
+ </div>
+ </div>
+ <nav class="nav">
+ <MkA to="/announcements">{{ $ts.announcements }}</MkA>
+ <MkA to="/explore">{{ $ts.explore }}</MkA>
+ <MkA to="/channels">{{ $ts.channel }}</MkA>
+ <MkA to="/featured">{{ $ts.featured }}</MkA>
+ </nav>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { toUnicode } from 'punycode/';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XNote from '@/components/note.vue';
+import MkFeaturedPhotos from '@/components/featured-photos.vue';
+import XTimeline from './welcome.timeline.vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import number from '@/filters/number';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ XNote,
+ MkFeaturedPhotos,
+ XTimeline,
+ },
+
+ data() {
+ return {
+ host: toUnicode(host),
+ instanceName,
+ meta: null,
+ stats: null,
+ tags: [],
+ onlineUsersCount: null,
+ };
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+
+ os.api('stats').then(stats => {
+ this.stats = stats;
+ });
+
+ os.api('get-online-users-count').then(res => {
+ this.onlineUsersCount = res.count;
+ });
+
+ os.api('hashtags/list', {
+ sort: '+mentionedLocalUsers',
+ limit: 8
+ }).then(tags => {
+ this.tags = tags;
+ });
+ },
+
+ methods: {
+ signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ showMenu(ev) {
+ os.popupMenu([{
+ text: this.$t('aboutX', { x: instanceName }),
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about');
+ }
+ }, {
+ text: this.$ts.aboutMisskey,
+ icon: 'fas fa-info-circle',
+ action: () => {
+ os.pageWindow('/about-misskey');
+ }
+ }, null, {
+ text: this.$ts.help,
+ icon: 'fas fa-question-circle',
+ action: () => {
+ window.open(`https://misskey-hub.net/help.md`, '_blank');
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rsqzvsbo {
+ > .top {
+ display: flex;
+ text-align: center;
+ min-height: 100vh;
+ box-sizing: border-box;
+ padding: 16px;
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ > .fade {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.25);
+ }
+
+ > .emojis {
+ position: absolute;
+ bottom: 32px;
+ left: 35px;
+
+ > * {
+ margin-right: 8px;
+ }
+
+ @media (max-width: 1200px) {
+ display: none;
+ }
+ }
+
+ > .main {
+ position: relative;
+ width: min(460px, 100%);
+ margin: auto;
+
+ > .misskey {
+ width: 150px;
+ margin-bottom: 16px;
+
+ @media (max-width: 450px) {
+ width: 130px;
+ }
+ }
+
+ > .form {
+ position: relative;
+ box-shadow: 0 12px 32px rgb(0 0 0 / 25%);
+
+ > .bg {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 128px;
+ background-position: center;
+ background-size: cover;
+ opacity: 0.75;
+
+ > .fade {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 128px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+ }
+ }
+
+ > .fg {
+ position: relative;
+ z-index: 1;
+
+ > h1 {
+ display: block;
+ margin: 0;
+ padding: 32px 32px 24px 32px;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 120px;
+ }
+ }
+
+ > .about {
+ padding: 0 32px;
+ }
+
+ > .action {
+ padding: 32px;
+
+ > * {
+ line-height: 28px;
+ }
+ }
+
+ > .status {
+ border-top: solid 0.5px var(--divider);
+ padding: 32px;
+ font-size: 90%;
+
+ > div {
+ > span:not(:last-child) {
+ padding-right: 1em;
+ margin-right: 1em;
+ border-right: solid 0.5px var(--divider);
+ }
+ }
+
+ > .online {
+ ::v-deep(b) {
+ color: #41b781;
+ }
+
+ ::v-deep(span) {
+ opacity: 0.7;
+ }
+ }
+ }
+
+ > .menu {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ width: 32px;
+ height: 32px;
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .nav {
+ position: relative;
+ z-index: 2;
+ margin-top: 20px;
+ color: #fff;
+ text-shadow: 0 0 8px black;
+ font-size: 0.9em;
+
+ > *:not(:last-child) {
+ margin-right: 1.5em;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue
new file mode 100644
index 0000000000..8c88720cf3
--- /dev/null
+++ b/packages/client/src/pages/welcome.setup.vue
@@ -0,0 +1,102 @@
+<template>
+<form class="mk-setup" @submit.prevent="submit()">
+ <h1>Welcome to Misskey!</h1>
+ <div>
+ <p>{{ $ts.intro }}</p>
+ <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username>
+ <template #label>{{ $ts.username }}</template>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ </MkInput>
+ <MkInput v-model="password" type="password" data-cy-admin-password>
+ <template #label>{{ $ts.password }}</template>
+ <template #prefix><i class="fas fa-lock"></i></template>
+ </MkInput>
+ <footer>
+ <MkButton primary type="submit" :disabled="submitting" data-cy-admin-ok>
+ {{ submitting ? $ts.processing : $ts.done }}<MkEllipsis v-if="submitting"/>
+ </MkButton>
+ </footer>
+ </div>
+</form>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkButton from '@/components/ui/button.vue';
+import MkInput from '@/components/form/input.vue';
+import { host } from '@/config';
+import * as os from '@/os';
+import { login } from '@/account';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ MkInput,
+ },
+
+ data() {
+ return {
+ username: '',
+ password: '',
+ submitting: false,
+ host,
+ }
+ },
+
+ methods: {
+ submit() {
+ if (this.submitting) return;
+ this.submitting = true;
+
+ os.api('admin/accounts/create', {
+ username: this.username,
+ password: this.password,
+ }).then(res => {
+ return login(res.token);
+ }).catch(() => {
+ this.submitting = false;
+
+ os.dialog({
+ type: 'error',
+ text: this.$ts.somethingHappened
+ });
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-setup {
+ border-radius: var(--radius);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+ max-width: 500px;
+ margin: 32px auto;
+
+ > h1 {
+ margin: 0;
+ font-size: 1.5em;
+ text-align: center;
+ padding: 32px;
+ background: var(--accent);
+ color: #fff;
+ }
+
+ > div {
+ padding: 32px;
+ background: var(--panel);
+
+ > p {
+ margin-top: 0;
+ }
+
+ > footer {
+ > * {
+ margin: 0 auto;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.timeline.vue b/packages/client/src/pages/welcome.timeline.vue
new file mode 100644
index 0000000000..46e3dbb5ed
--- /dev/null
+++ b/packages/client/src/pages/welcome.timeline.vue
@@ -0,0 +1,99 @@
+<template>
+<div class="civpbkhh">
+ <div class="scrollbox" ref="scroll" v-bind:class="{ scroll: isScrolling }">
+ <div v-for="note in notes" class="note">
+ <div class="content _panel">
+ <div class="body">
+ <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+ <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+ </div>
+ <div v-if="note.files.length > 0" class="richcontent">
+ <XMediaList :media-list="note.files"/>
+ </div>
+ <div v-if="note.poll">
+ <XPoll :note="note" :readOnly="true" />
+ </div>
+ </div>
+ <XReactionsViewer :note="note" ref="reactionsViewer"/>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XReactionsViewer from '@/components/reactions-viewer.vue';
+import XMediaList from '@/components/media-list.vue';
+import XPoll from '@/components/poll.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XReactionsViewer,
+ XMediaList,
+ XPoll
+ },
+
+ data() {
+ return {
+ notes: [],
+ isScrolling: false,
+ }
+ },
+
+ created() {
+ os.api('notes/featured').then(notes => {
+ this.notes = notes;
+ });
+ },
+
+ updated() {
+ if (this.$refs.scroll.clientHeight > window.innerHeight) {
+ this.isScrolling = true;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes scroll {
+ 0% {
+ transform: translate3d(0, 0, 0);
+ }
+ 5% {
+ transform: translate3d(0, 0, 0);
+ }
+ 75% {
+ transform: translate3d(0, calc(-100% + 90vh), 0);
+ }
+ 90% {
+ transform: translate3d(0, calc(-100% + 90vh), 0);
+ }
+}
+
+.civpbkhh {
+ text-align: right;
+
+ > .scrollbox {
+ &.scroll {
+ animation: scroll 45s linear infinite;
+ }
+
+ > .note {
+ margin: 16px 0 16px auto;
+
+ > .content {
+ padding: 16px;
+ margin: 0 0 0 auto;
+ max-width: max-content;
+ border-radius: 16px;
+
+ > .richcontent {
+ min-width: 250px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue
new file mode 100644
index 0000000000..4c038b5113
--- /dev/null
+++ b/packages/client/src/pages/welcome.vue
@@ -0,0 +1,38 @@
+<template>
+<div v-if="meta">
+ <XSetup v-if="meta.requireSetup"/>
+ <XEntrance v-else/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XSetup from './welcome.setup.vue';
+import XEntrance from './welcome.entrance.a.vue';
+import { instanceName } from '@/config';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XSetup,
+ XEntrance,
+ },
+
+ data() {
+ return {
+ [symbols.PAGE_INFO]: {
+ title: instanceName,
+ icon: null
+ },
+ meta: null
+ }
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ }
+});
+</script>
diff --git a/packages/client/src/pizzax.ts b/packages/client/src/pizzax.ts
new file mode 100644
index 0000000000..396abc2418
--- /dev/null
+++ b/packages/client/src/pizzax.ts
@@ -0,0 +1,153 @@
+import { onUnmounted, Ref, ref, watch } from 'vue';
+import { $i } from './account';
+import { api } from './os';
+
+type StateDef = Record<string, {
+ where: 'account' | 'device' | 'deviceAccount';
+ default: any;
+}>;
+
+type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
+
+export class Storage<T extends StateDef> {
+ 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<T[K]['default']> };
+
+ 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関数内でエラーになる(おそらく循環参照してることに原因がありそう)
+ 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);
+
+ // TODO: streamingのuser storage updateイベントを監視して更新
+ }
+ }
+
+ public set<K extends keyof T>(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<K extends keyof T>(key: K, value: ArrayElement<T[K]['default']>): 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<K extends keyof T>(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/client/src/plugin.ts b/packages/client/src/plugin.ts
new file mode 100644
index 0000000000..c56ee1eb25
--- /dev/null
+++ b/packages/client/src/plugin.ts
@@ -0,0 +1,124 @@
+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 { dialog } from '@/os';
+import { noteActions, notePostInterruptors, noteViewInterruptors, postFormActions, userActions } from '@/store';
+
+const pluginContexts = new Map<string, AiScript>();
+
+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 => {
+ dialog({
+ title: q,
+ input: {}
+ }).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(opts.plugin.configData.hasOwnProperty(k) ? 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/client/src/router.ts b/packages/client/src/router.ts
new file mode 100644
index 0000000000..9b4dd162f3
--- /dev/null
+++ b/packages/client/src/router.ts
@@ -0,0 +1,149 @@
+import { defineAsyncComponent, markRaw } from 'vue';
+import { createRouter, createWebHistory } from 'vue-router';
+import MkLoading from '@/pages/_loading_.vue';
+import MkError from '@/pages/_error_.vue';
+import MkTimeline from '@/pages/timeline.vue';
+import { $i } from './account';
+import { ui } from '@/config';
+
+const page = (path: string, ui?: string) => defineAsyncComponent({
+ loader: ui ? () => import(`./ui/${ui}/pages/${path}.vue`) : () => import(`./pages/${path}.vue`),
+ loadingComponent: MkLoading,
+ errorComponent: MkError,
+});
+
+let indexScrollPos = 0;
+
+const defaultRoutes = [
+ // NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる
+ { path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') },
+ { path: '/@:acct/:page?', name: 'user', component: page('user/index'), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) },
+ { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) },
+ { path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) },
+ { path: '/@:acct/room', props: true, component: page('room/room') },
+ { path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ initialPage: route.params.page || null }) },
+ { path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) },
+ { path: '/signup-complete/:code', component: page('signup-complete'), props: route => ({ code: route.params.code }) },
+ { path: '/announcements', component: page('announcements') },
+ { path: '/about', component: page('about') },
+ { path: '/about-misskey', component: page('about-misskey') },
+ { path: '/featured', component: page('featured') },
+ { path: '/theme-editor', component: page('theme-editor') },
+ { path: '/advanced-theme-editor', component: page('advanced-theme-editor') },
+ { path: '/explore', component: page('explore') },
+ { path: '/explore/tags/:tag', props: true, component: page('explore') },
+ { path: '/federation', component: page('federation') },
+ { path: '/emojis', component: page('emojis') },
+ { path: '/search', component: page('search') },
+ { path: '/pages', name: 'pages', component: page('pages') },
+ { path: '/pages/new', component: page('page-editor/page-editor') },
+ { path: '/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) },
+ { path: '/gallery', component: page('gallery/index') },
+ { path: '/gallery/new', component: page('gallery/edit') },
+ { path: '/gallery/:postId/edit', component: page('gallery/edit'), props: route => ({ postId: route.params.postId }) },
+ { path: '/gallery/:postId', component: page('gallery/post'), props: route => ({ postId: route.params.postId }) },
+ { path: '/channels', component: page('channels') },
+ { path: '/channels/new', component: page('channel-editor') },
+ { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true },
+ { path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) },
+ { path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) },
+ { path: '/timeline/list/:listId', component: page('user-list-timeline'), props: route => ({ listId: route.params.listId }) },
+ { path: '/timeline/antenna/:antennaId', component: page('antenna-timeline'), props: route => ({ antennaId: route.params.antennaId }) },
+ { path: '/my/notifications', component: page('notifications') },
+ { path: '/my/favorites', component: page('favorites') },
+ { path: '/my/messages', component: page('messages') },
+ { path: '/my/mentions', component: page('mentions') },
+ { path: '/my/messaging', name: 'messaging', component: page('messaging/index') },
+ { path: '/my/messaging/:user', component: page('messaging/messaging-room'), props: route => ({ userAcct: route.params.user }) },
+ { path: '/my/messaging/group/:group', component: page('messaging/messaging-room'), props: route => ({ groupId: route.params.group }) },
+ { path: '/my/drive', name: 'drive', component: page('drive') },
+ { path: '/my/drive/folder/:folder', component: page('drive') },
+ { path: '/my/follow-requests', component: page('follow-requests') },
+ { path: '/my/lists', component: page('my-lists/index') },
+ { path: '/my/lists/:list', component: page('my-lists/list') },
+ { path: '/my/groups', component: page('my-groups/index') },
+ { path: '/my/groups/:group', component: page('my-groups/group'), props: route => ({ groupId: route.params.group }) },
+ { path: '/my/antennas', component: page('my-antennas/index') },
+ { path: '/my/antennas/create', component: page('my-antennas/create') },
+ { path: '/my/antennas/:antennaId', component: page('my-antennas/edit'), props: true },
+ { path: '/my/clips', component: page('my-clips/index') },
+ { path: '/scratchpad', component: page('scratchpad') },
+ { path: '/admin/:page(.*)?', component: page('admin/index'), props: route => ({ initialPage: route.params.page || null }) },
+ { path: '/admin', component: page('admin/index') },
+ { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) },
+ { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) },
+ { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) },
+ { path: '/user-ap-info/:user', component: page('user-ap-info'), props: route => ({ userId: route.params.user }) },
+ { path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) },
+ { path: '/games/reversi', component: page('reversi/index') },
+ { path: '/games/reversi/:gameId', component: page('reversi/game'), props: route => ({ gameId: route.params.gameId }) },
+ { path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') },
+ { path: '/api-console', component: page('api-console') },
+ { path: '/preview', component: page('preview') },
+ { path: '/test', component: page('test') },
+ { path: '/auth/:token', component: page('auth') },
+ { path: '/miauth/:session', component: page('miauth') },
+ { path: '/authorize-follow', component: page('follow') },
+ { path: '/share', component: page('share') },
+ { path: '/:catchAll(.*)', component: page('not-found') }
+];
+
+const chatRoutes = [
+ { path: '/timeline', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) },
+ { path: '/timeline/home', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) },
+ { path: '/timeline/local', component: page('timeline', 'chat'), props: route => ({ src: 'local' }) },
+ { path: '/timeline/social', component: page('timeline', 'chat'), props: route => ({ src: 'social' }) },
+ { path: '/timeline/global', component: page('timeline', 'chat'), props: route => ({ src: 'global' }) },
+ { path: '/channels/:channelId', component: page('channel', 'chat'), props: route => ({ channelId: route.params.channelId }) },
+];
+
+function margeRoutes(routes: any[]) {
+ const result = defaultRoutes;
+ for (const route of routes) {
+ const found = result.findIndex(x => x.path === route.path);
+ if (found > -1) {
+ result[found] = route;
+ } else {
+ result.unshift(route);
+ }
+ }
+ return result;
+}
+
+export const router = createRouter({
+ history: createWebHistory(),
+ routes: margeRoutes(ui === 'chat' ? chatRoutes : []),
+ // なんかHacky
+ // 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする
+ scrollBehavior(to) {
+ window._scroll = () => { // さらにHacky
+ if (to.name === 'index') {
+ window.scroll({ top: indexScrollPos, behavior: 'instant' });
+ const i = setInterval(() => {
+ window.scroll({ top: indexScrollPos, behavior: 'instant' });
+ }, 10);
+ setTimeout(() => {
+ clearInterval(i);
+ }, 500);
+ } else {
+ window.scroll({ top: 0, behavior: 'instant' });
+ }
+ };
+ }
+});
+
+router.afterEach((to, from) => {
+ if (from.name === 'index') {
+ indexScrollPos = window.scrollY;
+ }
+});
+
+export function resolve(path: string) {
+ const resolved = router.resolve(path);
+ const route = resolved.matched[0];
+ return {
+ component: markRaw(route.components.default),
+ // TODO: route.propsには関数以外も入る可能性があるのでよしなにハンドリングする
+ props: route.props?.default ? route.props.default(resolved) : resolved.params
+ };
+}
diff --git a/packages/client/src/scripts/2fa.ts b/packages/client/src/scripts/2fa.ts
new file mode 100644
index 0000000000..00363cffa6
--- /dev/null
+++ b/packages/client/src/scripts/2fa.ts
@@ -0,0 +1,33 @@
+export function byteify(data: string, encoding: 'ascii' | 'base64' | 'hex') {
+ switch (encoding) {
+ case 'ascii':
+ return Uint8Array.from(data, c => c.charCodeAt(0));
+ case 'base64':
+ return Uint8Array.from(
+ atob(
+ data
+ .replace(/-/g, '+')
+ .replace(/_/g, '/')
+ ),
+ c => c.charCodeAt(0)
+ );
+ case 'hex':
+ return new Uint8Array(
+ data
+ .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/client/src/scripts/aiscript/api.ts b/packages/client/src/scripts/aiscript/api.ts
new file mode 100644
index 0000000000..20c15d809e
--- /dev/null
+++ b/packages/client/src/scripts/aiscript/api.ts
@@ -0,0 +1,44 @@
+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.dialog({
+ type: type ? type.value : 'info',
+ title: title.value,
+ text: text.value,
+ });
+ }),
+ 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
+ const confirm = await os.dialog({
+ type: type ? type.value : 'question',
+ showCancelButton: true,
+ 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/client/src/scripts/array.ts b/packages/client/src/scripts/array.ts
new file mode 100644
index 0000000000..d63f0475d0
--- /dev/null
+++ b/packages/client/src/scripts/array.ts
@@ -0,0 +1,138 @@
+import { EndoRelation, Predicate } from './relation';
+
+/**
+ * Count the number of elements that satisfy the predicate
+ */
+
+export function countIf<T>(f: Predicate<T>, xs: T[]): number {
+ return xs.filter(f).length;
+}
+
+/**
+ * Count the number of elements that is equal to the element
+ */
+export function count<T>(a: T, xs: T[]): number {
+ return countIf(x => x === a, xs);
+}
+
+/**
+ * Concatenate an array of arrays
+ */
+export function concat<T>(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<T>(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<T>(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<T>(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<T>(xs: T[]): T[] {
+ return [...new Set(xs)];
+}
+
+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<T>(f: EndoRelation<T>, 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<T, S>(f: (x: T) => S, xs: T[]): T[][] {
+ return groupBy((a, b) => f(a) === f(b), xs);
+}
+
+export function groupByX<T>(collections: T[], keySelector: (x: T) => string) {
+ return collections.reduce((obj: Record<string, T[]>, item: T) => {
+ const key = keySelector(item);
+ if (!obj.hasOwnProperty(key)) {
+ 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<T>(f: Predicate<T>, xs: T[]): T[] {
+ const ys = [];
+ 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<T>(x: T | T[] | undefined): T[] {
+ return Array.isArray(x) ? x : x != null ? [x] : [];
+}
+
+export function toSingle<T>(x: T | T[] | undefined): T | undefined {
+ return Array.isArray(x) ? x[0] : x;
+}
diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts
new file mode 100644
index 0000000000..f2d5806484
--- /dev/null
+++ b/packages/client/src/scripts/autocomplete.ts
@@ -0,0 +1,276 @@
+import { Ref, ref } from 'vue';
+import * as getCaretCoordinates from 'textarea-caret';
+import { toASCII } from 'punycode/';
+import { popup } from '@/os';
+
+export class Autocomplete {
+ private suggestion: {
+ x: Ref<number>;
+ y: Ref<number>;
+ q: Ref<string | null>;
+ close: Function;
+ } | null;
+ private textarea: any;
+ private vm: any;
+ private currentType: string;
+ private opts: {
+ model: string;
+ };
+ private opening: boolean;
+
+ private get text(): string {
+ return this.vm[this.opts.model];
+ }
+
+ private set text(text: string) {
+ this.vm[this.opts.model] = text;
+ }
+
+ /**
+ * 対象のテキストエリアを与えてインスタンスを初期化します。
+ */
+ constructor(textarea, vm, opts) {
+ //#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.vm = vm;
+ this.opts = opts;
+ 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(import('@/components/autocomplete.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}`;
+
+ // キャレットを戻す
+ this.vm.$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}`;
+
+ // キャレットを戻す
+ this.vm.$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;
+
+ // キャレットを戻す
+ this.vm.$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}`;
+
+ // キャレットを戻す
+ this.vm.$nextTick(() => {
+ this.textarea.focus();
+ const pos = trimmedBefore.length + (value.length + 3);
+ this.textarea.setSelectionRange(pos, pos);
+ });
+ }
+ }
+}
diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts
new file mode 100644
index 0000000000..3b1fa75b1e
--- /dev/null
+++ b/packages/client/src/scripts/check-word-mute.ts
@@ -0,0 +1,26 @@
+export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
+ // 自分自身
+ if (me && (note.userId === me.id)) return false;
+
+ const words = mutedWords
+ // Clean up
+ .map(xs => xs.filter(x => x !== ''))
+ .filter(xs => xs.length > 0);
+
+ if (words.length > 0) {
+ if (note.text == null) return false;
+
+ const matched = words.some(and =>
+ and.every(keyword => {
+ const regexp = keyword.match(/^\/(.+)\/(.*)$/);
+ if (regexp) {
+ return new RegExp(regexp[1], regexp[2]).test(note.text!);
+ }
+ return note.text!.includes(keyword);
+ }));
+
+ if (matched) return true;
+ }
+
+ return false;
+}
diff --git a/packages/client/src/scripts/collect-page-vars.ts b/packages/client/src/scripts/collect-page-vars.ts
new file mode 100644
index 0000000000..a4096fb2c2
--- /dev/null
+++ b/packages/client/src/scripts/collect-page-vars.ts
@@ -0,0 +1,48 @@
+export function collectPageVars(content) {
+ const pageVars = [];
+ const collect = (xs: any[]) => {
+ 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/client/src/scripts/contains.ts b/packages/client/src/scripts/contains.ts
new file mode 100644
index 0000000000..770bda63bb
--- /dev/null
+++ b/packages/client/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/client/src/scripts/copy-to-clipboard.ts b/packages/client/src/scripts/copy-to-clipboard.ts
new file mode 100644
index 0000000000..ab13cab970
--- /dev/null
+++ b/packages/client/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/client/src/scripts/emojilist.ts b/packages/client/src/scripts/emojilist.ts
new file mode 100644
index 0000000000..de7591f5a0
--- /dev/null
+++ b/packages/client/src/scripts/emojilist.ts
@@ -0,0 +1,7 @@
+// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb
+export const emojilist = require('../emojilist.json') as {
+ name: string;
+ keywords: string[];
+ char: string;
+ category: 'people' | 'animals_and_nature' | 'food_and_drink' | 'activity' | 'travel_and_places' | 'objects' | 'symbols' | 'flags';
+}[];
diff --git a/packages/client/src/scripts/extract-avg-color-from-blurhash.ts b/packages/client/src/scripts/extract-avg-color-from-blurhash.ts
new file mode 100644
index 0000000000..123ab7a06d
--- /dev/null
+++ b/packages/client/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/client/src/scripts/extract-mentions.ts b/packages/client/src/scripts/extract-mentions.ts
new file mode 100644
index 0000000000..cc19b161a8
--- /dev/null
+++ b/packages/client/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/client/src/scripts/extract-url-from-mfm.ts b/packages/client/src/scripts/extract-url-from-mfm.ts
new file mode 100644
index 0000000000..34e3eb6c19
--- /dev/null
+++ b/packages/client/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/client/src/scripts/focus.ts b/packages/client/src/scripts/focus.ts
new file mode 100644
index 0000000000..0894877820
--- /dev/null
+++ b/packages/client/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/client/src/scripts/form.ts b/packages/client/src/scripts/form.ts
new file mode 100644
index 0000000000..7bf6cec452
--- /dev/null
+++ b/packages/client/src/scripts/form.ts
@@ -0,0 +1,31 @@
+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: 'array';
+ default: unknown[] | null;
+ hidden?: boolean;
+};
+
+export type Form = Record<string, FormItem>;
diff --git a/packages/client/src/scripts/format-time-string.ts b/packages/client/src/scripts/format-time-string.ts
new file mode 100644
index 0000000000..bfb2c397ae
--- /dev/null
+++ b/packages/client/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/client/src/scripts/games/reversi/core.ts b/packages/client/src/scripts/games/reversi/core.ts
new file mode 100644
index 0000000000..0cb8922e19
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/core.ts
@@ -0,0 +1,263 @@
+import { count, concat } from '@/scripts/array';
+
+// MISSKEY REVERSI ENGINE
+
+/**
+ * true ... 黒
+ * false ... 白
+ */
+export type Color = boolean;
+const BLACK = true;
+const WHITE = false;
+
+export type MapPixel = 'null' | 'empty';
+
+export type Options = {
+ isLlotheo: boolean;
+ canPutEverywhere: boolean;
+ loopedBoard: boolean;
+};
+
+export type Undo = {
+ /**
+ * 色
+ */
+ color: Color;
+
+ /**
+ * どこに打ったか
+ */
+ pos: number;
+
+ /**
+ * 反転した石の位置の配列
+ */
+ effects: number[];
+
+ /**
+ * ターン
+ */
+ turn: Color | null;
+};
+
+/**
+ * リバーシエンジン
+ */
+export default class Reversi {
+ public map: MapPixel[];
+ public mapWidth: number;
+ public mapHeight: number;
+ public board: (Color | null | undefined)[];
+ public turn: Color | null = BLACK;
+ public opts: Options;
+
+ public prevPos = -1;
+ public prevColor: Color | null = null;
+
+ private logs: Undo[] = [];
+
+ /**
+ * ゲームを初期化します
+ */
+ constructor(map: string[], opts: Options) {
+ //#region binds
+ this.put = this.put.bind(this);
+ //#endregion
+
+ //#region Options
+ this.opts = opts;
+ if (this.opts.isLlotheo == null) this.opts.isLlotheo = false;
+ if (this.opts.canPutEverywhere == null) this.opts.canPutEverywhere = false;
+ if (this.opts.loopedBoard == null) this.opts.loopedBoard = false;
+ //#endregion
+
+ //#region Parse map data
+ this.mapWidth = map[0].length;
+ this.mapHeight = map.length;
+ const mapData = map.join('');
+
+ this.board = mapData.split('').map(d => d === '-' ? null : d === 'b' ? BLACK : d === 'w' ? WHITE : undefined);
+
+ this.map = mapData.split('').map(d => d === '-' || d === 'b' || d === 'w' ? 'empty' : 'null');
+ //#endregion
+
+ // ゲームが始まった時点で片方の色の石しかないか、始まった時点で勝敗が決定するようなマップの場合がある
+ if (!this.canPutSomewhere(BLACK))
+ this.turn = this.canPutSomewhere(WHITE) ? WHITE : null;
+ }
+
+ /**
+ * 黒石の数
+ */
+ public get blackCount() {
+ return count(BLACK, this.board);
+ }
+
+ /**
+ * 白石の数
+ */
+ public get whiteCount() {
+ return count(WHITE, this.board);
+ }
+
+ public transformPosToXy(pos: number): number[] {
+ const x = pos % this.mapWidth;
+ const y = Math.floor(pos / this.mapWidth);
+ return [x, y];
+ }
+
+ public transformXyToPos(x: number, y: number): number {
+ return x + (y * this.mapWidth);
+ }
+
+ /**
+ * 指定のマスに石を打ちます
+ * @param color 石の色
+ * @param pos 位置
+ */
+ public put(color: Color, pos: number) {
+ this.prevPos = pos;
+ this.prevColor = color;
+
+ this.board[pos] = color;
+
+ // 反転させられる石を取得
+ const effects = this.effects(color, pos);
+
+ // 反転させる
+ for (const pos of effects) {
+ this.board[pos] = color;
+ }
+
+ const turn = this.turn;
+
+ this.logs.push({
+ color,
+ pos,
+ effects,
+ turn
+ });
+
+ this.calcTurn();
+ }
+
+ private calcTurn() {
+ // ターン計算
+ this.turn =
+ this.canPutSomewhere(!this.prevColor) ? !this.prevColor :
+ this.canPutSomewhere(this.prevColor!) ? this.prevColor :
+ null;
+ }
+
+ public undo() {
+ const undo = this.logs.pop()!;
+ this.prevColor = undo.color;
+ this.prevPos = undo.pos;
+ this.board[undo.pos] = null;
+ for (const pos of undo.effects) {
+ const color = this.board[pos];
+ this.board[pos] = !color;
+ }
+ this.turn = undo.turn;
+ }
+
+ /**
+ * 指定した位置のマップデータのマスを取得します
+ * @param pos 位置
+ */
+ public mapDataGet(pos: number): MapPixel {
+ const [x, y] = this.transformPosToXy(pos);
+ return x < 0 || y < 0 || x >= this.mapWidth || y >= this.mapHeight ? 'null' : this.map[pos];
+ }
+
+ /**
+ * 打つことができる場所を取得します
+ */
+ public puttablePlaces(color: Color): number[] {
+ return Array.from(this.board.keys()).filter(i => this.canPut(color, i));
+ }
+
+ /**
+ * 打つことができる場所があるかどうかを取得します
+ */
+ public canPutSomewhere(color: Color): boolean {
+ return this.puttablePlaces(color).length > 0;
+ }
+
+ /**
+ * 指定のマスに石を打つことができるかどうかを取得します
+ * @param color 自分の色
+ * @param pos 位置
+ */
+ public canPut(color: Color, pos: number): boolean {
+ return (
+ this.board[pos] !== null ? false : // 既に石が置いてある場所には打てない
+ this.opts.canPutEverywhere ? this.mapDataGet(pos) == 'empty' : // 挟んでなくても置けるモード
+ this.effects(color, pos).length !== 0); // 相手の石を1つでも反転させられるか
+ }
+
+ /**
+ * 指定のマスに石を置いた時の、反転させられる石を取得します
+ * @param color 自分の色
+ * @param initPos 位置
+ */
+ public effects(color: Color, initPos: number): number[] {
+ const enemyColor = !color;
+
+ const diffVectors: [number, number][] = [
+ [ 0, -1], // 上
+ [ +1, -1], // 右上
+ [ +1, 0], // 右
+ [ +1, +1], // 右下
+ [ 0, +1], // 下
+ [ -1, +1], // 左下
+ [ -1, 0], // 左
+ [ -1, -1] // 左上
+ ];
+
+ const effectsInLine = ([dx, dy]: [number, number]): number[] => {
+ const nextPos = (x: number, y: number): [number, number] => [x + dx, y + dy];
+
+ const found: number[] = []; // 挟めるかもしれない相手の石を入れておく配列
+ let [x, y] = this.transformPosToXy(initPos);
+ while (true) {
+ [x, y] = nextPos(x, y);
+
+ // 座標が指し示す位置がボード外に出たとき
+ if (this.opts.loopedBoard && this.transformXyToPos(
+ (x = ((x % this.mapWidth) + this.mapWidth) % this.mapWidth),
+ (y = ((y % this.mapHeight) + this.mapHeight) % this.mapHeight)) === initPos)
+ // 盤面の境界でループし、自分が石を置く位置に戻ってきたとき、挟めるようにしている (ref: Test4のマップ)
+ return found;
+ else if (x === -1 || y === -1 || x === this.mapWidth || y === this.mapHeight)
+ return []; // 挟めないことが確定 (盤面外に到達)
+
+ const pos = this.transformXyToPos(x, y);
+ if (this.mapDataGet(pos) === 'null') return []; // 挟めないことが確定 (配置不可能なマスに到達)
+ const stone = this.board[pos];
+ if (stone === null) return []; // 挟めないことが確定 (石が置かれていないマスに到達)
+ if (stone === enemyColor) found.push(pos); // 挟めるかもしれない (相手の石を発見)
+ if (stone === color) return found; // 挟めることが確定 (対となる自分の石を発見)
+ }
+ };
+
+ return concat(diffVectors.map(effectsInLine));
+ }
+
+ /**
+ * ゲームが終了したか否か
+ */
+ public get isEnded(): boolean {
+ return this.turn === null;
+ }
+
+ /**
+ * ゲームの勝者 (null = 引き分け)
+ */
+ public get winner(): Color | null {
+ return this.isEnded ?
+ this.blackCount == this.whiteCount ? null :
+ this.opts.isLlotheo === this.blackCount > this.whiteCount ? WHITE : BLACK :
+ undefined as never;
+ }
+}
diff --git a/packages/client/src/scripts/games/reversi/maps.ts b/packages/client/src/scripts/games/reversi/maps.ts
new file mode 100644
index 0000000000..dc0d1bf9d0
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/maps.ts
@@ -0,0 +1,896 @@
+/**
+ * 組み込みマップ定義
+ *
+ * データ値:
+ * (スペース) ... マス無し
+ * - ... マス
+ * b ... 初期配置される黒石
+ * w ... 初期配置される白石
+ */
+
+export type Map = {
+ name?: string;
+ category?: string;
+ author?: string;
+ data: string[];
+};
+
+export const fourfour: Map = {
+ name: '4x4',
+ category: '4x4',
+ data: [
+ '----',
+ '-wb-',
+ '-bw-',
+ '----'
+ ]
+};
+
+export const sixsix: Map = {
+ name: '6x6',
+ category: '6x6',
+ data: [
+ '------',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ '------'
+ ]
+};
+
+export const roundedSixsix: Map = {
+ name: '6x6 rounded',
+ category: '6x6',
+ author: 'syuilo',
+ data: [
+ ' ---- ',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ ' ---- '
+ ]
+};
+
+export const roundedSixsix2: Map = {
+ name: '6x6 rounded 2',
+ category: '6x6',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ---- ',
+ '--wb--',
+ '--bw--',
+ ' ---- ',
+ ' -- '
+ ]
+};
+
+export const eighteight: Map = {
+ name: '8x8',
+ category: '8x8',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const eighteightH1: Map = {
+ name: '8x8 handicap 1',
+ category: '8x8',
+ data: [
+ 'b-------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const eighteightH2: Map = {
+ name: '8x8 handicap 2',
+ category: '8x8',
+ data: [
+ 'b-------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '-------b'
+ ]
+};
+
+export const eighteightH3: Map = {
+ name: '8x8 handicap 3',
+ category: '8x8',
+ data: [
+ 'b------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '-------b'
+ ]
+};
+
+export const eighteightH4: Map = {
+ name: '8x8 handicap 4',
+ category: '8x8',
+ data: [
+ 'b------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ 'b------b'
+ ]
+};
+
+export const eighteightH28: Map = {
+ name: '8x8 handicap 28',
+ category: '8x8',
+ data: [
+ 'bbbbbbbb',
+ 'b------b',
+ 'b------b',
+ 'b--wb--b',
+ 'b--bw--b',
+ 'b------b',
+ 'b------b',
+ 'bbbbbbbb'
+ ]
+};
+
+export const roundedEighteight: Map = {
+ name: '8x8 rounded',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const roundedEighteight2: Map = {
+ name: '8x8 rounded 2',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ---- ',
+ ' ------ ',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ ' ------ ',
+ ' ---- '
+ ]
+};
+
+export const roundedEighteight3: Map = {
+ name: '8x8 rounded 3',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ---- ',
+ ' ------ ',
+ '---wb---',
+ '---bw---',
+ ' ------ ',
+ ' ---- ',
+ ' -- '
+ ]
+};
+
+export const eighteightWithNotch: Map = {
+ name: '8x8 with notch',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '--------',
+ '--------',
+ ' --wb-- ',
+ ' --bw-- ',
+ '--------',
+ '--------',
+ '--- ---'
+ ]
+};
+
+export const eighteightWithSomeHoles: Map = {
+ name: '8x8 with some holes',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--- ----',
+ '----- --',
+ '-- -----',
+ '---wb---',
+ '---bw- -',
+ ' -------',
+ '--- ----',
+ '--------'
+ ]
+};
+
+export const circle: Map = {
+ name: 'Circle',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' ------ ',
+ ' ------ ',
+ '---wb---',
+ '---bw---',
+ ' ------ ',
+ ' ------ ',
+ ' -- '
+ ]
+};
+
+export const smile: Map = {
+ name: 'Smile',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ '--------',
+ '-- -- --',
+ '---wb---',
+ '-- bw --',
+ '--- ---',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const window: Map = {
+ name: 'Window',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--------',
+ '- -- -',
+ '- -- -',
+ '---wb---',
+ '---bw---',
+ '- -- -',
+ '- -- -',
+ '--------'
+ ]
+};
+
+export const reserved: Map = {
+ name: 'Reserved',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ 'w------b',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ 'b------w'
+ ]
+};
+
+export const x: Map = {
+ name: 'X',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ 'w------b',
+ '-w----b-',
+ '--w--b--',
+ '---wb---',
+ '---bw---',
+ '--b--w--',
+ '-b----w-',
+ 'b------w'
+ ]
+};
+
+export const parallel: Map = {
+ name: 'Parallel',
+ category: '8x8',
+ author: 'Aya',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---bb---',
+ '---ww---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const lackOfBlack: Map = {
+ name: 'Lack of Black',
+ category: '8x8',
+ data: [
+ '--------',
+ '--------',
+ '--------',
+ '---w----',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------'
+ ]
+};
+
+export const squareParty: Map = {
+ name: 'Square Party',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ '--------',
+ '-wwwbbb-',
+ '-w-wb-b-',
+ '-wwwbbb-',
+ '-bbbwww-',
+ '-b-bw-w-',
+ '-bbbwww-',
+ '--------'
+ ]
+};
+
+export const minesweeper: Map = {
+ name: 'Minesweeper',
+ category: '8x8',
+ author: 'syuilo',
+ data: [
+ 'b-b--w-w',
+ '-w-wb-b-',
+ 'w-b--w-b',
+ '-b-wb-w-',
+ '-w-bw-b-',
+ 'b-w--b-w',
+ '-b-bw-w-',
+ 'w-w--b-b'
+ ]
+};
+
+export const tenthtenth: Map = {
+ name: '10x10',
+ category: '10x10',
+ data: [
+ '----------',
+ '----------',
+ '----------',
+ '----------',
+ '----wb----',
+ '----bw----',
+ '----------',
+ '----------',
+ '----------',
+ '----------'
+ ]
+};
+
+export const hole: Map = {
+ name: 'The Hole',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '----------',
+ '----------',
+ '--wb--wb--',
+ '--bw--bw--',
+ '---- ----',
+ '---- ----',
+ '--wb--wb--',
+ '--bw--bw--',
+ '----------',
+ '----------'
+ ]
+};
+
+export const grid: Map = {
+ name: 'Grid',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '----------',
+ '- - -- - -',
+ '----------',
+ '- - -- - -',
+ '----wb----',
+ '----bw----',
+ '- - -- - -',
+ '----------',
+ '- - -- - -',
+ '----------'
+ ]
+};
+
+export const cross: Map = {
+ name: 'Cross',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ ' ---- ',
+ ' ---- ',
+ ' ---- ',
+ '----------',
+ '----wb----',
+ '----bw----',
+ '----------',
+ ' ---- ',
+ ' ---- ',
+ ' ---- '
+ ]
+};
+
+export const charX: Map = {
+ name: 'Char X',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '---- ----',
+ '----------',
+ ' -------- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' -------- ',
+ '----------',
+ '---- ----',
+ '--- ---'
+ ]
+};
+
+export const charY: Map = {
+ name: 'Char Y',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '--- ---',
+ '---- ----',
+ '----------',
+ ' -------- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' ------ ',
+ ' ------ ',
+ ' ------ ',
+ ' ------ '
+ ]
+};
+
+export const walls: Map = {
+ name: 'Walls',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ ' bbbbbbbb ',
+ 'w--------w',
+ 'w--------w',
+ 'w--------w',
+ 'w---wb---w',
+ 'w---bw---w',
+ 'w--------w',
+ 'w--------w',
+ 'w--------w',
+ ' bbbbbbbb '
+ ]
+};
+
+export const cpu: Map = {
+ name: 'CPU',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ ' b b b b ',
+ 'w--------w',
+ ' -------- ',
+ 'w--------w',
+ ' ---wb--- ',
+ ' ---bw--- ',
+ 'w--------w',
+ ' -------- ',
+ 'w--------w',
+ ' b b b b '
+ ]
+};
+
+export const checker: Map = {
+ name: 'Checker',
+ category: '10x10',
+ author: 'Aya',
+ data: [
+ '----------',
+ '----------',
+ '----------',
+ '---wbwb---',
+ '---bwbw---',
+ '---wbwb---',
+ '---bwbw---',
+ '----------',
+ '----------',
+ '----------'
+ ]
+};
+
+export const japaneseCurry: Map = {
+ name: 'Japanese curry',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ 'w-b-b-b-b-',
+ '-w-b-b-b-b',
+ 'w-w-b-b-b-',
+ '-w-w-b-b-b',
+ 'w-w-wwb-b-',
+ '-w-wbb-b-b',
+ 'w-w-w-b-b-',
+ '-w-w-w-b-b',
+ 'w-w-w-w-b-',
+ '-w-w-w-w-b'
+ ]
+};
+
+export const mosaic: Map = {
+ name: 'Mosaic',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '- - - - - ',
+ ' - - - - -',
+ '- - - - - ',
+ ' - w w - -',
+ '- - b b - ',
+ ' - w w - -',
+ '- - b b - ',
+ ' - - - - -',
+ '- - - - - ',
+ ' - - - - -',
+ ]
+};
+
+export const arena: Map = {
+ name: 'Arena',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '- - -- - -',
+ ' - - - - ',
+ '- ------ -',
+ ' -------- ',
+ '- --wb-- -',
+ '- --bw-- -',
+ ' -------- ',
+ '- ------ -',
+ ' - - - - ',
+ '- - -- - -'
+ ]
+};
+
+export const reactor: Map = {
+ name: 'Reactor',
+ category: '10x10',
+ author: 'syuilo',
+ data: [
+ '-w------b-',
+ 'b- - - -w',
+ '- --wb-- -',
+ '---b w---',
+ '- b wb w -',
+ '- w bw b -',
+ '---w b---',
+ '- --bw-- -',
+ 'w- - - -b',
+ '-b------w-'
+ ]
+};
+
+export const sixeight: Map = {
+ name: '6x8',
+ category: 'Special',
+ data: [
+ '------',
+ '------',
+ '------',
+ '--wb--',
+ '--bw--',
+ '------',
+ '------',
+ '------'
+ ]
+};
+
+export const spark: Map = {
+ name: 'Spark',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' - - ',
+ '----------',
+ ' -------- ',
+ ' -------- ',
+ ' ---wb--- ',
+ ' ---bw--- ',
+ ' -------- ',
+ ' -------- ',
+ '----------',
+ ' - - '
+ ]
+};
+
+export const islands: Map = {
+ name: 'Islands',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ '-------- ',
+ '---wb--- ',
+ '---bw--- ',
+ '-------- ',
+ ' - - ',
+ ' - - ',
+ ' --------',
+ ' --------',
+ ' --------',
+ ' --------'
+ ]
+};
+
+export const galaxy: Map = {
+ name: 'Galaxy',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' ------ ',
+ ' --www--- ',
+ ' ------w--- ',
+ '---bbb--w---',
+ '--b---b-w-b-',
+ '-b--wwb-w-b-',
+ '-b-w-bww--b-',
+ '-b-w-b---b--',
+ '---w--bbb---',
+ ' ---w------ ',
+ ' ---www-- ',
+ ' ------ '
+ ]
+};
+
+export const triangle: Map = {
+ name: 'Triangle',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' -- ',
+ ' -- ',
+ ' ---- ',
+ ' ---- ',
+ ' --wb-- ',
+ ' --bw-- ',
+ ' -------- ',
+ ' -------- ',
+ '----------',
+ '----------'
+ ]
+};
+
+export const iphonex: Map = {
+ name: 'iPhone X',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' -- -- ',
+ '--------',
+ '--------',
+ '--------',
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------',
+ '--------',
+ '--------',
+ '--------',
+ ' ------ '
+ ]
+};
+
+export const dealWithIt: Map = {
+ name: 'Deal with it!',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ '------------',
+ '--w-b-------',
+ ' --b-w------',
+ ' --w-b---- ',
+ ' ------- '
+ ]
+};
+
+export const experiment: Map = {
+ name: 'Let\'s experiment',
+ category: 'Special',
+ author: 'syuilo',
+ data: [
+ ' ------------ ',
+ '------wb------',
+ '------bw------',
+ '--------------',
+ ' - - ',
+ '------ ------',
+ 'bbbbbb wwwwww',
+ 'bbbbbb wwwwww',
+ 'bbbbbb wwwwww',
+ 'bbbbbb wwwwww',
+ 'wwwwww bbbbbb'
+ ]
+};
+
+export const bigBoard: Map = {
+ name: 'Big board',
+ category: 'Special',
+ data: [
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '-------wb-------',
+ '-------bw-------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------',
+ '----------------'
+ ]
+};
+
+export const twoBoard: Map = {
+ name: 'Two board',
+ category: 'Special',
+ author: 'Aya',
+ data: [
+ '-------- --------',
+ '-------- --------',
+ '-------- --------',
+ '---wb--- ---wb---',
+ '---bw--- ---bw---',
+ '-------- --------',
+ '-------- --------',
+ '-------- --------'
+ ]
+};
+
+export const test1: Map = {
+ name: 'Test1',
+ category: 'Test',
+ data: [
+ '--------',
+ '---wb---',
+ '---bw---',
+ '--------'
+ ]
+};
+
+export const test2: Map = {
+ name: 'Test2',
+ category: 'Test',
+ data: [
+ '------',
+ '------',
+ '-b--w-',
+ '-w--b-',
+ '-w--b-'
+ ]
+};
+
+export const test3: Map = {
+ name: 'Test3',
+ category: 'Test',
+ data: [
+ '-w-',
+ '--w',
+ 'w--',
+ '-w-',
+ '--w',
+ 'w--',
+ '-w-',
+ '--w',
+ 'w--',
+ '-w-',
+ '---',
+ 'b--',
+ ]
+};
+
+export const test4: Map = {
+ name: 'Test4',
+ category: 'Test',
+ data: [
+ '-w--b-',
+ '-w--b-',
+ '------',
+ '-w--b-',
+ '-w--b-'
+ ]
+};
+
+// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)A1に打ってしまう
+export const test6: Map = {
+ name: 'Test6',
+ category: 'Test',
+ data: [
+ '--wwwww-',
+ 'wwwwwwww',
+ 'wbbbwbwb',
+ 'wbbbbwbb',
+ 'wbwbbwbb',
+ 'wwbwbbbb',
+ '--wbbbbb',
+ '-wwwww--',
+ ]
+};
+
+// 検証用: この盤面で藍(lv3)が黒で始めると何故か(?)G7に打ってしまう
+export const test7: Map = {
+ name: 'Test7',
+ category: 'Test',
+ data: [
+ 'b--w----',
+ 'b-wwww--',
+ 'bwbwwwbb',
+ 'wbwwwwb-',
+ 'wwwwwww-',
+ '-wwbbwwb',
+ '--wwww--',
+ '--wwww--',
+ ]
+};
+
+// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう
+export const test8: Map = {
+ name: 'Test8',
+ category: 'Test',
+ data: [
+ '--------',
+ '-----w--',
+ 'w--www--',
+ 'wwwwww--',
+ 'bbbbwww-',
+ 'wwwwww--',
+ '--www---',
+ '--ww----',
+ ]
+};
diff --git a/packages/client/src/scripts/games/reversi/package.json b/packages/client/src/scripts/games/reversi/package.json
new file mode 100644
index 0000000000..a4415ad141
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "misskey-reversi",
+ "version": "0.0.5",
+ "description": "Misskey reversi engine",
+ "keywords": [
+ "misskey"
+ ],
+ "author": "syuilo <i@syuilo.com>",
+ "license": "MIT",
+ "repository": "https://github.com/misskey-dev/misskey.git",
+ "bugs": "https://github.com/misskey-dev/misskey/issues",
+ "main": "./built/core.js",
+ "types": "./built/core.d.ts",
+ "scripts": {
+ "build": "tsc"
+ },
+ "dependencies": {}
+}
diff --git a/packages/client/src/scripts/games/reversi/tsconfig.json b/packages/client/src/scripts/games/reversi/tsconfig.json
new file mode 100644
index 0000000000..851fb6b7e4
--- /dev/null
+++ b/packages/client/src/scripts/games/reversi/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "noEmitOnError": false,
+ "noImplicitAny": false,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "experimentalDecorators": true,
+ "declaration": true,
+ "sourceMap": false,
+ "target": "es2017",
+ "module": "commonjs",
+ "removeComments": false,
+ "noLib": false,
+ "outDir": "./built",
+ "rootDir": "./"
+ },
+ "compileOnSave": false,
+ "include": [
+ "./core.ts"
+ ]
+}
diff --git a/packages/client/src/scripts/gen-search-query.ts b/packages/client/src/scripts/gen-search-query.ts
new file mode 100644
index 0000000000..57a06c280c
--- /dev/null
+++ b/packages/client/src/scripts/gen-search-query.ts
@@ -0,0 +1,31 @@
+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/client/src/scripts/get-account-from-id.ts b/packages/client/src/scripts/get-account-from-id.ts
new file mode 100644
index 0000000000..ba3adceecc
--- /dev/null
+++ b/packages/client/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(e => e.id === id);
+}
diff --git a/packages/client/src/scripts/get-md5.ts b/packages/client/src/scripts/get-md5.ts
new file mode 100644
index 0000000000..b002d762b1
--- /dev/null
+++ b/packages/client/src/scripts/get-md5.ts
@@ -0,0 +1,10 @@
+// スクリプトサイズがデカい
+//import * as crypto from 'crypto';
+
+export default (data: ArrayBuffer) => {
+ //const buf = new Buffer(data);
+ //const hash = crypto.createHash('md5');
+ //hash.update(buf);
+ //return hash.digest('hex');
+ return '';
+};
diff --git a/packages/client/src/scripts/get-note-summary.ts b/packages/client/src/scripts/get-note-summary.ts
new file mode 100644
index 0000000000..bd394279cb
--- /dev/null
+++ b/packages/client/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.locale.deletedNote})`;
+ }
+
+ if (note.isHidden) {
+ return `(${i18n.locale.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.locale.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/client/src/scripts/get-static-image-url.ts b/packages/client/src/scripts/get-static-image-url.ts
new file mode 100644
index 0000000000..e9a3e87cc8
--- /dev/null
+++ b/packages/client/src/scripts/get-static-image-url.ts
@@ -0,0 +1,16 @@
+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;
+ }
+ const dummy = `${u.host}${u.pathname}`; // 拡張子がないとキャッシュしてくれないCDNがあるので
+ return `${instanceUrl}/proxy/${dummy}?${url.query({
+ url: u.href,
+ static: '1'
+ })}`;
+}
diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts
new file mode 100644
index 0000000000..8d767afa25
--- /dev/null
+++ b/packages/client/src/scripts/get-user-menu.ts
@@ -0,0 +1,205 @@
+import { i18n } from '@/i18n';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { host } from '@/config';
+import * as Acct from 'misskey-js/built/acct';
+import * as os from '@/os';
+import { userActions } from '@/store';
+import { router } from '@/router';
+import { $i } from '@/account';
+
+export function getUserMenu(user) {
+ const meId = $i ? $i.id : null;
+
+ async function pushList() {
+ const t = i18n.locale.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく
+ const lists = await os.api('users/lists/list');
+ if (lists.length === 0) {
+ os.dialog({
+ type: 'error',
+ text: i18n.locale.youHaveNoLists
+ });
+ return;
+ }
+ const { canceled, result: listId } = await os.dialog({
+ type: null,
+ title: t,
+ select: {
+ items: lists.map(list => ({
+ value: list.id, text: list.name
+ }))
+ },
+ showCancelButton: true
+ });
+ 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.dialog({
+ type: 'error',
+ text: i18n.locale.youHaveNoGroups
+ });
+ return;
+ }
+ const { canceled, result: groupId } = await os.dialog({
+ type: null,
+ title: i18n.locale.group,
+ select: {
+ items: groups.map(group => ({
+ value: group.id, text: group.name
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ os.apiWithDialog('users/groups/invite', {
+ groupId: groupId,
+ userId: user.id
+ });
+ }
+
+ async function toggleMute() {
+ os.apiWithDialog(user.isMuted ? 'mute/delete' : 'mute/create', {
+ userId: user.id
+ }).then(() => {
+ user.isMuted = !user.isMuted;
+ });
+ }
+
+ async function toggleBlock() {
+ if (!await getConfirmed(user.isBlocking ? i18n.locale.unblockConfirm : i18n.locale.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(import('@/components/abuse-report-window.vue'), {
+ user: user,
+ }, {}, 'closed');
+ }
+
+ async function getConfirmed(text: string): Promise<boolean> {
+ const confirm = await os.dialog({
+ type: 'warning',
+ showCancelButton: true,
+ title: 'confirm',
+ text,
+ });
+
+ return !confirm.canceled;
+ }
+
+ let menu = [{
+ icon: 'fas fa-at',
+ text: i18n.locale.copyUsername,
+ action: () => {
+ copyToClipboard(`@${user.username}@${user.host || host}`);
+ }
+ }, {
+ icon: 'fas fa-info-circle',
+ text: i18n.locale.info,
+ action: () => {
+ os.pageWindow(`/user-info/${user.id}`);
+ }
+ }, {
+ icon: 'fas fa-envelope',
+ text: i18n.locale.sendMessage,
+ action: () => {
+ os.post({ specified: user });
+ }
+ }, meId != user.id ? {
+ type: 'link',
+ icon: 'fas fa-comments',
+ text: i18n.locale.startMessaging,
+ to: '/my/messaging/' + Acct.toString(user),
+ } : undefined, null, {
+ icon: 'fas fa-list-ul',
+ text: i18n.locale.addToList,
+ action: pushList
+ }, meId != user.id ? {
+ icon: 'fas fa-users',
+ text: i18n.locale.inviteToGroup,
+ action: inviteGroup
+ } : undefined] as any;
+
+ if ($i && meId != user.id) {
+ menu = menu.concat([null, {
+ icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash',
+ text: user.isMuted ? i18n.locale.unmute : i18n.locale.mute,
+ action: toggleMute
+ }, {
+ icon: 'fas fa-ban',
+ text: user.isBlocking ? i18n.locale.unblock : i18n.locale.block,
+ action: toggleBlock
+ }]);
+
+ menu = menu.concat([null, {
+ icon: 'fas fa-exclamation-circle',
+ text: i18n.locale.reportAbuse,
+ action: reportAbuse
+ }]);
+
+ if ($i && ($i.isAdmin || $i.isModerator)) {
+ menu = menu.concat([null, {
+ icon: 'fas fa-microphone-slash',
+ text: user.isSilenced ? i18n.locale.unsilence : i18n.locale.silence,
+ action: toggleSilence
+ }, {
+ icon: 'fas fa-snowflake',
+ text: user.isSuspended ? i18n.locale.unsuspend : i18n.locale.suspend,
+ action: toggleSuspend
+ }]);
+ }
+ }
+
+ if ($i && meId === user.id) {
+ menu = menu.concat([null, {
+ icon: 'fas fa-pencil-alt',
+ text: i18n.locale.editProfile,
+ action: () => {
+ router.push('/settings/profile');
+ }
+ }]);
+ }
+
+ if (userActions.length > 0) {
+ menu = menu.concat([null, ...userActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(user);
+ }
+ }))]);
+ }
+
+ return menu;
+}
diff --git a/packages/client/src/scripts/hotkey.ts b/packages/client/src/scripts/hotkey.ts
new file mode 100644
index 0000000000..2b3f491fd8
--- /dev/null
+++ b/packages/client/src/scripts/hotkey.ts
@@ -0,0 +1,88 @@
+import keyCode from './keycode';
+
+type Keymap = Record<string, Function>;
+
+type Pattern = {
+ which: string[];
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+};
+
+type Action = {
+ patterns: Pattern[];
+ callback: Function;
+ allowRepeat: boolean;
+};
+
+const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => {
+ const result = {
+ patterns: [],
+ callback: 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(e: KeyboardEvent, patterns: Action['patterns']): boolean {
+ const key = e.code.toLowerCase();
+ return patterns.some(pattern => pattern.which.includes(key) &&
+ pattern.ctrl === e.ctrlKey &&
+ pattern.shift === e.shiftKey &&
+ pattern.alt === e.altKey &&
+ !e.metaKey
+ );
+}
+
+export const makeHotkey = (keymap: Keymap) => {
+ const actions = parseKeymap(keymap);
+
+ return (e: 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(e, action.patterns);
+
+ if (matched) {
+ if (!action.allowRepeat && e.repeat) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+ action.callback(e);
+ break;
+ }
+ }
+ };
+};
diff --git a/packages/client/src/scripts/hpml/block.ts b/packages/client/src/scripts/hpml/block.ts
new file mode 100644
index 0000000000..804c5c1124
--- /dev/null
+++ b/packages/client/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/client/src/scripts/hpml/evaluator.ts b/packages/client/src/scripts/hpml/evaluator.ts
new file mode 100644
index 0000000000..20261d333d
--- /dev/null
+++ b/packages/client/src/scripts/hpml/evaluator.ts
@@ -0,0 +1,234 @@
+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<keyof typeof envVarsDef, any>;
+ public aiscript?: AiScript;
+ public pageVarUpdatedCallback?: values.VFn;
+ public canvases: Record<string, HTMLCanvasElement> = {};
+ public vars: Ref<Record<string, any>> = ref({});
+ public page: Record<string, any>;
+
+ 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.dialog({
+ title: q,
+ input: {}
+ }).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 (e) {
+ //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 (e) {}
+ }
+
+ @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<string, any> {
+ const values: Record<string, any> = {};
+
+ 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 (e) {
+ return null;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ // Define user function
+ if (expr.type == 'fn') {
+ return {
+ slots: expr.value.slots.map(x => x.name),
+ exec: (slotArg: Record<string, any>) => {
+ 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<string, any>;
+ 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/client/src/scripts/hpml/expr.ts b/packages/client/src/scripts/hpml/expr.ts
new file mode 100644
index 0000000000..00e3ed118b
--- /dev/null
+++ b/packages/client/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/client/src/scripts/hpml/index.ts b/packages/client/src/scripts/hpml/index.ts
new file mode 100644
index 0000000000..ac81eac2d9
--- /dev/null
+++ b/packages/client/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<string, any>) => ReturnType<Hpml['evaluate']>;
+};
+
+export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null;
+
+export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = {
+ text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', },
+ 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<string, Type> = {
+ 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<string, any>[];
+ public name: string;
+
+ constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) {
+ this.layerdStates = layerdStates;
+ this.name = name || 'anonymous';
+ }
+
+ @autobind
+ public createChildScope(states: Record<string, any>, 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/client/src/scripts/hpml/lib.ts b/packages/client/src/scripts/hpml/lib.ts
new file mode 100644
index 0000000000..2a1ac73a40
--- /dev/null
+++ b/packages/client/src/scripts/hpml/lib.ts
@@ -0,0 +1,246 @@
+import * as tinycolor from 'tinycolor2';
+import { Hpml } from './evaluator';
+import { values, utils } from '@syuilo/aiscript';
+import { Fn, HpmlScope } from '.';
+import { Expr } from './expr';
+import * as 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<string, { in: any[]; out: any; category: string; icon: any; }> = {
+ if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt', },
+ 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: 'fas fa-plus', },
+ subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus', },
+ multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times', },
+ 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: 'fas fa-quote-right', },
+ strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', },
+ 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()}`;
+
+ const funcs: Record<string, Function> = {
+ 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/client/src/scripts/hpml/type-checker.ts b/packages/client/src/scripts/hpml/type-checker.ts
new file mode 100644
index 0000000000..9633b3cd01
--- /dev/null
+++ b/packages/client/src/scripts/hpml/type-checker.ts
@@ -0,0 +1,189 @@
+import autobind from 'autobind-decorator';
+import { Type, envVarsDef, PageVar } from '.';
+import { Expr, isLiteralValue, Variable } from './expr';
+import { funcDefs } from './lib';
+
+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/client/src/scripts/i18n.ts b/packages/client/src/scripts/i18n.ts
new file mode 100644
index 0000000000..4fa398763a
--- /dev/null
+++ b/packages/client/src/scripts/i18n.ts
@@ -0,0 +1,29 @@
+export class I18n<T extends Record<string, any>> {
+ public locale: T;
+
+ constructor(locale: T) {
+ this.locale = locale;
+
+ //#region BIND
+ this.t = this.t.bind(this);
+ //#endregion
+ }
+
+ // string にしているのは、ドット区切りでのパス指定を許可するため
+ // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
+ public t(key: string, args?: Record<string, any>): string {
+ try {
+ let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
+
+ if (args) {
+ for (const [k, v] of Object.entries(args)) {
+ str = str.replace(`{${k}}`, v);
+ }
+ }
+ return str;
+ } catch (e) {
+ console.warn(`missing localization '${key}'`);
+ return key;
+ }
+ }
+}
diff --git a/packages/client/src/scripts/idb-proxy.ts b/packages/client/src/scripts/idb-proxy.ts
new file mode 100644
index 0000000000..5f76ae30bb
--- /dev/null
+++ b/packages/client/src/scripts/idb-proxy.ts
@@ -0,0 +1,37 @@
+// 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) {
+ try {
+ await iset('idb-test', 'test');
+ } catch (e) {
+ console.error('idb error', e);
+ idbAvailable = false;
+ }
+}
+
+if (!idbAvailable) 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/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts
new file mode 100644
index 0000000000..d6dbd5dbd4
--- /dev/null
+++ b/packages/client/src/scripts/initialize-sw.ts
@@ -0,0 +1,68 @@
+import { instance } from '@/instance';
+import { $i } from '@/account';
+import { api } from '@/os';
+import { lang } from '@/config';
+
+export async function initializeSw() {
+ if (instance.swPublickey &&
+ ('serviceWorker' in navigator) &&
+ ('PushManager' in window) &&
+ $i && $i.token) {
+ navigator.serviceWorker.register(`/sw.js`);
+
+ navigator.serviceWorker.ready.then(registration => {
+ registration.active?.postMessage({
+ msg: 'initialize',
+ lang,
+ });
+ // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
+ registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(instance.swPublickey)
+ }).then(subscription => {
+ function encode(buffer: ArrayBuffer | null) {
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
+ }
+
+ // Register
+ api('sw/register', {
+ endpoint: subscription.endpoint,
+ auth: encode(subscription.getKey('auth')),
+ publickey: encode(subscription.getKey('p256dh'))
+ });
+ })
+ // When subscribe failed
+ .catch(async (err: Error) => {
+ // 通知が許可されていなかったとき
+ if (err.name === 'NotAllowedError') {
+ return;
+ }
+
+ // 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
+ // 既に存在していることが原因でエラーになった可能性があるので、
+ // そのサブスクリプションを解除しておく
+ const subscription = await registration.pushManager.getSubscription();
+ if (subscription) subscription.unsubscribe();
+ });
+ });
+ }
+}
+
+/**
+ * Convert the URL safe base64 string to a Uint8Array
+ * @param base64String base64 string
+ */
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding)
+ .replace(/-/g, '+')
+ .replace(/_/g, '/');
+
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
diff --git a/packages/client/src/scripts/is-device-darkmode.ts b/packages/client/src/scripts/is-device-darkmode.ts
new file mode 100644
index 0000000000..854f38e517
--- /dev/null
+++ b/packages/client/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/client/src/scripts/is-device-touch.ts b/packages/client/src/scripts/is-device-touch.ts
new file mode 100644
index 0000000000..3f0bfefed2
--- /dev/null
+++ b/packages/client/src/scripts/is-device-touch.ts
@@ -0,0 +1 @@
+export const isDeviceTouch = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0;
diff --git a/packages/client/src/scripts/is-mobile.ts b/packages/client/src/scripts/is-mobile.ts
new file mode 100644
index 0000000000..60cb59f91e
--- /dev/null
+++ b/packages/client/src/scripts/is-mobile.ts
@@ -0,0 +1,2 @@
+const ua = navigator.userAgent.toLowerCase();
+export const isMobile = /mobile|iphone|ipad|android/.test(ua);
diff --git a/packages/client/src/scripts/keycode.ts b/packages/client/src/scripts/keycode.ts
new file mode 100644
index 0000000000..c127d54bb2
--- /dev/null
+++ b/packages/client/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'],
+};
+
+/*!
+* Programatically 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/client/src/scripts/loading.ts b/packages/client/src/scripts/loading.ts
new file mode 100644
index 0000000000..4b0a560e34
--- /dev/null
+++ b/packages/client/src/scripts/loading.ts
@@ -0,0 +1,11 @@
+export default {
+ start: () => {
+ // TODO
+ },
+ done: () => {
+ // TODO
+ },
+ set: val => {
+ // TODO
+ }
+};
diff --git a/packages/client/src/scripts/login-id.ts b/packages/client/src/scripts/login-id.ts
new file mode 100644
index 0000000000..0f9c6be4a9
--- /dev/null
+++ b/packages/client/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/client/src/scripts/lookup-user.ts b/packages/client/src/scripts/lookup-user.ts
new file mode 100644
index 0000000000..174fa9f879
--- /dev/null
+++ b/packages/client/src/scripts/lookup-user.ts
@@ -0,0 +1,37 @@
+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.dialog({
+ title: i18n.locale.usernameOrUserId,
+ input: true
+ });
+ 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.dialog({
+ type: 'error',
+ text: i18n.locale.noSuchUser
+ });
+ } else {
+ _notFound = true;
+ }
+ };
+ usernamePromise.then(show).catch(e => {
+ if (e.code === 'NO_SUCH_USER') {
+ notFound();
+ }
+ });
+ idPromise.then(show).catch(e => {
+ notFound();
+ });
+}
diff --git a/packages/client/src/scripts/mfm-tags.ts b/packages/client/src/scripts/mfm-tags.ts
new file mode 100644
index 0000000000..1b18210aa9
--- /dev/null
+++ b/packages/client/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'];
diff --git a/packages/client/src/scripts/paging.ts b/packages/client/src/scripts/paging.ts
new file mode 100644
index 0000000000..ef63ecc450
--- /dev/null
+++ b/packages/client/src/scripts/paging.ts
@@ -0,0 +1,246 @@
+import { markRaw } from 'vue';
+import * as os from '@/os';
+import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
+
+const SECOND_FETCH_LIMIT = 30;
+
+// reversed: items 配列の中身を逆順にする(新しい方が最後)
+
+export default (opts) => ({
+ emits: ['queue'],
+
+ data() {
+ return {
+ items: [],
+ queue: [],
+ offset: 0,
+ fetching: true,
+ moreFetching: false,
+ inited: false,
+ more: false,
+ backed: false, // 遡り中か否か
+ isBackTop: false,
+ };
+ },
+
+ computed: {
+ empty(): boolean {
+ return this.items.length === 0 && !this.fetching && this.inited;
+ },
+
+ error(): boolean {
+ return !this.fetching && !this.inited;
+ },
+ },
+
+ watch: {
+ pagination: {
+ handler() {
+ this.init();
+ },
+ deep: true
+ },
+
+ queue: {
+ handler(a, b) {
+ if (a.length === 0 && b.length === 0) return;
+ this.$emit('queue', this.queue.length);
+ },
+ deep: true
+ }
+ },
+
+ created() {
+ opts.displayLimit = opts.displayLimit || 30;
+ this.init();
+ },
+
+ activated() {
+ this.isBackTop = false;
+ },
+
+ deactivated() {
+ this.isBackTop = window.scrollY === 0;
+ },
+
+ methods: {
+ reload() {
+ this.items = [];
+ this.init();
+ },
+
+ replaceItem(finder, data) {
+ const i = this.items.findIndex(finder);
+ this.items[i] = data;
+ },
+
+ removeItem(finder) {
+ const i = this.items.findIndex(finder);
+ this.items.splice(i, 1);
+ },
+
+ async init() {
+ this.queue = [];
+ this.fetching = true;
+ if (opts.before) opts.before(this);
+ let params = typeof this.pagination.params === 'function' ? this.pagination.params(true) : this.pagination.params;
+ if (params && params.then) params = await params;
+ if (params === null) return;
+ const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+ await os.api(endpoint, {
+ ...params,
+ limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
+ }).then(items => {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ markRaw(item);
+ if (this.pagination.reversed) {
+ if (i === items.length - 2) item._shouldInsertAd_ = true;
+ } else {
+ if (i === 3) item._shouldInsertAd_ = true;
+ }
+ }
+ if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
+ items.pop();
+ this.items = this.pagination.reversed ? [...items].reverse() : items;
+ this.more = true;
+ } else {
+ this.items = this.pagination.reversed ? [...items].reverse() : items;
+ this.more = false;
+ }
+ this.offset = items.length;
+ this.inited = true;
+ this.fetching = false;
+ if (opts.after) opts.after(this, null);
+ }, e => {
+ this.fetching = false;
+ if (opts.after) opts.after(this, e);
+ });
+ },
+
+ async fetchMore() {
+ if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
+ this.moreFetching = true;
+ this.backed = true;
+ let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
+ if (params && params.then) params = await params;
+ const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+ await os.api(endpoint, {
+ ...params,
+ limit: SECOND_FETCH_LIMIT + 1,
+ ...(this.pagination.offsetMode ? {
+ offset: this.offset,
+ } : {
+ untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
+ }),
+ }).then(items => {
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ markRaw(item);
+ if (this.pagination.reversed) {
+ if (i === items.length - 9) item._shouldInsertAd_ = true;
+ } else {
+ if (i === 10) item._shouldInsertAd_ = true;
+ }
+ }
+ if (items.length > SECOND_FETCH_LIMIT) {
+ items.pop();
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = true;
+ } else {
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = false;
+ }
+ this.offset += items.length;
+ this.moreFetching = false;
+ }, e => {
+ this.moreFetching = false;
+ });
+ },
+
+ async fetchMoreFeature() {
+ if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
+ this.moreFetching = true;
+ let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
+ if (params && params.then) params = await params;
+ const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
+ await os.api(endpoint, {
+ ...params,
+ limit: SECOND_FETCH_LIMIT + 1,
+ ...(this.pagination.offsetMode ? {
+ offset: this.offset,
+ } : {
+ sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
+ }),
+ }).then(items => {
+ for (const item of items) {
+ markRaw(item);
+ }
+ if (items.length > SECOND_FETCH_LIMIT) {
+ items.pop();
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = true;
+ } else {
+ this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
+ this.more = false;
+ }
+ this.offset += items.length;
+ this.moreFetching = false;
+ }, e => {
+ this.moreFetching = false;
+ });
+ },
+
+ prepend(item) {
+ if (this.pagination.reversed) {
+ const container = getScrollContainer(this.$el);
+ const pos = getScrollPosition(this.$el);
+ const viewHeight = container.clientHeight;
+ const height = container.scrollHeight;
+ const isBottom = (pos + viewHeight > height - 32);
+ if (isBottom) {
+ // オーバーフローしたら古いアイテムは捨てる
+ if (this.items.length >= opts.displayLimit) {
+ // このやり方だとVue 3.2以降アニメーションが動かなくなる
+ //this.items = this.items.slice(-opts.displayLimit);
+ while (this.items.length >= opts.displayLimit) {
+ this.items.shift();
+ }
+ this.more = true;
+ }
+ }
+ this.items.push(item);
+ // TODO
+ } else {
+ const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
+
+ if (isTop) {
+ // Prepend the item
+ this.items.unshift(item);
+
+ // オーバーフローしたら古いアイテムは捨てる
+ if (this.items.length >= opts.displayLimit) {
+ // このやり方だとVue 3.2以降アニメーションが動かなくなる
+ //this.items = this.items.slice(0, opts.displayLimit);
+ while (this.items.length >= opts.displayLimit) {
+ this.items.pop();
+ }
+ this.more = true;
+ }
+ } else {
+ this.queue.push(item);
+ onScrollTop(this.$el, () => {
+ for (const item of this.queue) {
+ this.prepend(item);
+ }
+ this.queue = [];
+ });
+ }
+ }
+ },
+
+ append(item) {
+ this.items.push(item);
+ },
+ }
+});
diff --git a/packages/client/src/scripts/physics.ts b/packages/client/src/scripts/physics.ts
new file mode 100644
index 0000000000..445b6296eb
--- /dev/null
+++ b/packages/client/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);
+ const objs = [];
+ 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;
+ 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;
+ 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 = 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);
+ clearInterval(intervalId);
+ }
+ };
+}
diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts
new file mode 100644
index 0000000000..928f6ec0f4
--- /dev/null
+++ b/packages/client/src/scripts/please-login.ts
@@ -0,0 +1,14 @@
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+import { dialog } from '@/os';
+
+export function pleaseLogin() {
+ if ($i) return;
+
+ dialog({
+ title: i18n.locale.signinRequired,
+ text: null
+ });
+
+ throw new Error('signin required');
+}
diff --git a/packages/client/src/scripts/popout.ts b/packages/client/src/scripts/popout.ts
new file mode 100644
index 0000000000..51b8d72868
--- /dev/null
+++ b/packages/client/src/scripts/popout.ts
@@ -0,0 +1,22 @@
+import * as config from '@/config';
+
+export function popout(path: string, w?: HTMLElement) {
+ let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path;
+ url += '?zen'; // TODO: ちゃんとURLパースしてクエリ付ける
+ 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/client/src/scripts/reaction-picker.ts b/packages/client/src/scripts/reaction-picker.ts
new file mode 100644
index 0000000000..e923326ece
--- /dev/null
+++ b/packages/client/src/scripts/reaction-picker.ts
@@ -0,0 +1,41 @@
+import { Ref, ref } from 'vue';
+import { popup } from '@/os';
+
+class ReactionPicker {
+ private src: Ref<HTMLElement | null> = ref(null);
+ private manualShowing = ref(false);
+ private onChosen?: Function;
+ private onClosed?: Function;
+
+ constructor() {
+ // nop
+ }
+
+ public async init() {
+ await popup(import('@/components/emoji-picker-dialog.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: Function, onClosed: Function) {
+ this.src.value = src;
+ this.manualShowing.value = true;
+ this.onChosen = onChosen;
+ this.onClosed = onClosed;
+ }
+}
+
+export const reactionPicker = new ReactionPicker();
diff --git a/packages/client/src/scripts/room/furniture.ts b/packages/client/src/scripts/room/furniture.ts
new file mode 100644
index 0000000000..7734e32668
--- /dev/null
+++ b/packages/client/src/scripts/room/furniture.ts
@@ -0,0 +1,21 @@
+export type RoomInfo = {
+ roomType: string;
+ carpetColor: string;
+ furnitures: Furniture[];
+};
+
+export type Furniture = {
+ id: string; // 同じ家具が複数ある場合にそれぞれを識別するためのIDであり、家具IDではない
+ type: string; // こっちが家具ID(chairとか)
+ position: {
+ x: number;
+ y: number;
+ z: number;
+ };
+ rotation: {
+ x: number;
+ y: number;
+ z: number;
+ };
+ props?: Record<string, any>;
+};
diff --git a/packages/client/src/scripts/room/furnitures.json5 b/packages/client/src/scripts/room/furnitures.json5
new file mode 100644
index 0000000000..4a40994107
--- /dev/null
+++ b/packages/client/src/scripts/room/furnitures.json5
@@ -0,0 +1,407 @@
+// 家具メタデータ
+
+// 家具IDはglbファイル及びそのディレクトリ名と一致する必要があります
+
+// 家具にはユーザーが設定できるプロパティを設定可能です:
+//
+// props: {
+// <propname>: <proptype>
+// }
+//
+// proptype一覧:
+// * image ... 画像選択ダイアログを出し、その画像のURLが格納されます
+// * color ... 色選択コントロールを出し、選択された色が格納されます
+
+// 家具にカスタムテクスチャを適用できるようにするには、textureプロパティに以下の追加の情報を含めます:
+// 便宜上そのUVのどの部分にカスタムテクスチャを貼り合わせるかのエリアをテクスチャエリアと呼びます。
+// UVは1024*1024だと仮定します。
+//
+// <key>: {
+// prop: <プロパティ名>,
+// uv: {
+// x: <テクスチャエリアX座標>,
+// y: <テクスチャエリアY座標>,
+// width: <テクスチャエリアの幅>,
+// height: <テクスチャエリアの高さ>,
+// },
+// }
+//
+// <key>には、カスタムテクスチャを適用したいメッシュ名を指定します
+// <プロパティ名>には、カスタムテクスチャとして使用する画像を格納するプロパティ(前述)名を指定します
+
+// 家具にカスタムカラーを適用できるようにするには、colorプロパティに以下の追加の情報を含めます:
+//
+// <key>: <プロパティ名>
+//
+// <key>には、カスタムカラーを適用したいマテリアル名を指定します
+// <プロパティ名>には、カスタムカラーとして使用する色を格納するプロパティ(前述)名を指定します
+
+[
+ {
+ id: "milk",
+ place: "floor"
+ },
+ {
+ id: "bed",
+ place: "floor"
+ },
+ {
+ id: "low-table",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Table: 'color'
+ }
+ },
+ {
+ id: "desk",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Board: 'color'
+ }
+ },
+ {
+ id: "chair",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Chair: 'color'
+ }
+ },
+ {
+ id: "chair2",
+ place: "floor",
+ props: {
+ color1: 'color',
+ color2: 'color'
+ },
+ color: {
+ Cushion: 'color1',
+ Leg: 'color2'
+ }
+ },
+ {
+ id: "fan",
+ place: "wall"
+ },
+ {
+ id: "pc",
+ place: "floor"
+ },
+ {
+ id: "plant",
+ place: "floor"
+ },
+ {
+ id: "plant2",
+ place: "floor"
+ },
+ {
+ id: "eraser",
+ place: "floor"
+ },
+ {
+ id: "pencil",
+ place: "floor"
+ },
+ {
+ id: "pudding",
+ place: "floor"
+ },
+ {
+ id: "cardboard-box",
+ place: "floor"
+ },
+ {
+ id: "cardboard-box2",
+ place: "floor"
+ },
+ {
+ id: "cardboard-box3",
+ place: "floor"
+ },
+ {
+ id: "book",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Cover: 'color'
+ }
+ },
+ {
+ id: "book2",
+ place: "floor"
+ },
+ {
+ id: "piano",
+ place: "floor"
+ },
+ {
+ id: "facial-tissue",
+ place: "floor"
+ },
+ {
+ id: "server",
+ place: "floor"
+ },
+ {
+ id: "moon",
+ place: "floor"
+ },
+ {
+ id: "corkboard",
+ place: "wall"
+ },
+ {
+ id: "mousepad",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Pad: 'color'
+ }
+ },
+ {
+ id: "monitor",
+ place: "floor",
+ props: {
+ screen: 'image'
+ },
+ texture: {
+ Screen: {
+ prop: 'screen',
+ uv: {
+ x: 0,
+ y: 434,
+ width: 1024,
+ height: 588,
+ },
+ },
+ },
+ },
+ {
+ id: "tv",
+ place: "floor",
+ props: {
+ screen: 'image'
+ },
+ texture: {
+ Screen: {
+ prop: 'screen',
+ uv: {
+ x: 0,
+ y: 434,
+ width: 1024,
+ height: 588,
+ },
+ },
+ },
+ },
+ {
+ id: "keyboard",
+ place: "floor"
+ },
+ {
+ id: "carpet-stripe",
+ place: "floor",
+ props: {
+ color1: 'color',
+ color2: 'color'
+ },
+ color: {
+ CarpetAreaA: 'color1',
+ CarpetAreaB: 'color2'
+ },
+ },
+ {
+ id: "mat",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Mat: 'color'
+ }
+ },
+ {
+ id: "color-box",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ main: 'color'
+ }
+ },
+ {
+ id: "wall-clock",
+ place: "wall"
+ },
+ {
+ id: "cube",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Cube: 'color'
+ }
+ },
+ {
+ id: "photoframe",
+ place: "wall",
+ props: {
+ photo: 'image',
+ color: 'color'
+ },
+ texture: {
+ Photo: {
+ prop: 'photo',
+ uv: {
+ x: 0,
+ y: 342,
+ width: 1024,
+ height: 683,
+ },
+ },
+ },
+ color: {
+ Frame: 'color'
+ }
+ },
+ {
+ id: "pinguin",
+ place: "floor",
+ props: {
+ body: 'color',
+ belly: 'color'
+ },
+ color: {
+ Body: 'body',
+ Belly: 'belly',
+ }
+ },
+ {
+ id: "rubik-cube",
+ place: "floor",
+ },
+ {
+ id: "poster-h",
+ place: "wall",
+ props: {
+ picture: 'image'
+ },
+ texture: {
+ Poster: {
+ prop: 'picture',
+ uv: {
+ x: 0,
+ y: 277,
+ width: 1024,
+ height: 745,
+ },
+ },
+ },
+ },
+ {
+ id: "poster-v",
+ place: "wall",
+ props: {
+ picture: 'image'
+ },
+ texture: {
+ Poster: {
+ prop: 'picture',
+ uv: {
+ x: 0,
+ y: 0,
+ width: 745,
+ height: 1024,
+ },
+ },
+ },
+ },
+ {
+ id: "sofa",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Sofa: 'color'
+ }
+ },
+ {
+ id: "spiral",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Step: 'color'
+ }
+ },
+ {
+ id: "bin",
+ place: "floor",
+ props: {
+ color: 'color'
+ },
+ color: {
+ Bin: 'color'
+ }
+ },
+ {
+ id: "cup-noodle",
+ place: "floor"
+ },
+ {
+ id: "holo-display",
+ place: "floor",
+ props: {
+ image: 'image'
+ },
+ texture: {
+ Image_Front: {
+ prop: 'image',
+ uv: {
+ x: 0,
+ y: 0,
+ width: 1024,
+ height: 1024,
+ },
+ },
+ Image_Back: {
+ prop: 'image',
+ uv: {
+ x: 0,
+ y: 0,
+ width: 1024,
+ height: 1024,
+ },
+ },
+ },
+ },
+ {
+ id: 'energy-drink',
+ place: "floor",
+ },
+ {
+ id: 'doll-ai',
+ place: "floor",
+ },
+ {
+ id: 'banknote',
+ place: "floor",
+ },
+]
diff --git a/packages/client/src/scripts/room/room.ts b/packages/client/src/scripts/room/room.ts
new file mode 100644
index 0000000000..7e04bec646
--- /dev/null
+++ b/packages/client/src/scripts/room/room.ts
@@ -0,0 +1,775 @@
+import autobind from 'autobind-decorator';
+import { v4 as uuid } from 'uuid';
+import * as THREE from 'three';
+import { GLTFLoader, GLTF } from 'three/examples/jsm/loaders/GLTFLoader';
+import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
+import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
+import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
+import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
+import { BloomPass } from 'three/examples/jsm/postprocessing/BloomPass.js';
+import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';
+import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
+import { Furniture, RoomInfo } from './furniture';
+import { query as urlQuery } from '@/scripts/url';
+const furnitureDefs = require('./furnitures.json5');
+
+THREE.ImageUtils.crossOrigin = '';
+
+type Options = {
+ graphicsQuality: Room['graphicsQuality'];
+ onChangeSelect: Room['onChangeSelect'];
+ useOrthographicCamera: boolean;
+};
+
+/**
+ * MisskeyRoom Core Engine
+ */
+export class Room {
+ private clock: THREE.Clock;
+ private scene: THREE.Scene;
+ private renderer: THREE.WebGLRenderer;
+ private camera: THREE.PerspectiveCamera | THREE.OrthographicCamera;
+ private controls: OrbitControls;
+ private composer: EffectComposer;
+ private mixers: THREE.AnimationMixer[] = [];
+ private furnitureControl: TransformControls;
+ private roomInfo: RoomInfo;
+ private graphicsQuality: 'cheep' | 'low' | 'medium' | 'high' | 'ultra';
+ private roomObj: THREE.Object3D;
+ private objects: THREE.Object3D[] = [];
+ private selectedObject: THREE.Object3D = null;
+ private onChangeSelect: Function;
+ private isTransformMode = false;
+ private renderFrameRequestId: number;
+
+ private get canvas(): HTMLCanvasElement {
+ return this.renderer.domElement;
+ }
+
+ private get furnitures(): Furniture[] {
+ return this.roomInfo.furnitures;
+ }
+
+ private set furnitures(furnitures: Furniture[]) {
+ this.roomInfo.furnitures = furnitures;
+ }
+
+ private get enableShadow() {
+ return this.graphicsQuality != 'cheep';
+ }
+
+ private get usePostFXs() {
+ return this.graphicsQuality !== 'cheep' && this.graphicsQuality !== 'low';
+ }
+
+ private get shadowQuality() {
+ return (
+ this.graphicsQuality === 'ultra' ? 16384 :
+ this.graphicsQuality === 'high' ? 8192 :
+ this.graphicsQuality === 'medium' ? 4096 :
+ this.graphicsQuality === 'low' ? 1024 :
+ 0); // cheep
+ }
+
+ constructor(user, isMyRoom, roomInfo: RoomInfo, container: Element, options: Options) {
+ this.roomInfo = roomInfo;
+ this.graphicsQuality = options.graphicsQuality;
+ this.onChangeSelect = options.onChangeSelect;
+
+ this.clock = new THREE.Clock(true);
+
+ //#region Init a scene
+ this.scene = new THREE.Scene();
+
+ const width = container.clientWidth;
+ const height = container.clientHeight;
+
+ //#region Init a renderer
+ this.renderer = new THREE.WebGLRenderer({
+ antialias: false,
+ stencil: false,
+ alpha: false,
+ powerPreference:
+ this.graphicsQuality === 'ultra' ? 'high-performance' :
+ this.graphicsQuality === 'high' ? 'high-performance' :
+ this.graphicsQuality === 'medium' ? 'default' :
+ this.graphicsQuality === 'low' ? 'low-power' :
+ 'low-power' // cheep
+ });
+
+ this.renderer.setPixelRatio(window.devicePixelRatio);
+ this.renderer.setSize(width, height);
+ this.renderer.autoClear = false;
+ this.renderer.setClearColor(new THREE.Color(0x051f2d));
+ this.renderer.shadowMap.enabled = this.enableShadow;
+ this.renderer.shadowMap.type =
+ this.graphicsQuality === 'ultra' ? THREE.PCFSoftShadowMap :
+ this.graphicsQuality === 'high' ? THREE.PCFSoftShadowMap :
+ this.graphicsQuality === 'medium' ? THREE.PCFShadowMap :
+ this.graphicsQuality === 'low' ? THREE.BasicShadowMap :
+ THREE.BasicShadowMap; // cheep
+
+ container.insertBefore(this.canvas, container.firstChild);
+ //#endregion
+
+ //#region Init a camera
+ this.camera = options.useOrthographicCamera
+ ? new THREE.OrthographicCamera(
+ width / - 2, width / 2, height / 2, height / - 2, -10, 10)
+ : new THREE.PerspectiveCamera(45, width / height);
+
+ if (options.useOrthographicCamera) {
+ this.camera.position.x = 2;
+ this.camera.position.y = 2;
+ this.camera.position.z = 2;
+ this.camera.zoom = 100;
+ this.camera.updateProjectionMatrix();
+ } else {
+ this.camera.position.x = 5;
+ this.camera.position.y = 2;
+ this.camera.position.z = 5;
+ }
+
+ this.scene.add(this.camera);
+ //#endregion
+
+ //#region AmbientLight
+ const ambientLight = new THREE.AmbientLight(0xffffff, 1);
+ this.scene.add(ambientLight);
+ //#endregion
+
+ if (this.graphicsQuality !== 'cheep') {
+ //#region Room light
+ const roomLight = new THREE.SpotLight(0xffffff, 0.1);
+
+ roomLight.position.set(0, 8, 0);
+ roomLight.castShadow = this.enableShadow;
+ roomLight.shadow.bias = -0.0001;
+ roomLight.shadow.mapSize.width = this.shadowQuality;
+ roomLight.shadow.mapSize.height = this.shadowQuality;
+ roomLight.shadow.camera.near = 0.1;
+ roomLight.shadow.camera.far = 9;
+ roomLight.shadow.camera.fov = 45;
+
+ this.scene.add(roomLight);
+ //#endregion
+ }
+
+ //#region Out light
+ const outLight1 = new THREE.SpotLight(0xffffff, 0.4);
+ outLight1.position.set(9, 3, -2);
+ outLight1.castShadow = this.enableShadow;
+ outLight1.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
+ outLight1.shadow.mapSize.width = this.shadowQuality;
+ outLight1.shadow.mapSize.height = this.shadowQuality;
+ outLight1.shadow.camera.near = 6;
+ outLight1.shadow.camera.far = 15;
+ outLight1.shadow.camera.fov = 45;
+ this.scene.add(outLight1);
+
+ const outLight2 = new THREE.SpotLight(0xffffff, 0.2);
+ outLight2.position.set(-2, 3, 9);
+ outLight2.castShadow = false;
+ outLight2.shadow.bias = -0.001; // アクネ、アーチファクト対策 その代わりピーターパンが発生する可能性がある
+ outLight2.shadow.camera.near = 6;
+ outLight2.shadow.camera.far = 15;
+ outLight2.shadow.camera.fov = 45;
+ this.scene.add(outLight2);
+ //#endregion
+
+ //#region Init a controller
+ this.controls = new OrbitControls(this.camera, this.canvas);
+
+ this.controls.target.set(0, 1, 0);
+ this.controls.enableZoom = true;
+ this.controls.enablePan = isMyRoom;
+ this.controls.minPolarAngle = 0;
+ this.controls.maxPolarAngle = Math.PI / 2;
+ this.controls.minAzimuthAngle = 0;
+ this.controls.maxAzimuthAngle = Math.PI / 2;
+ this.controls.enableDamping = true;
+ this.controls.dampingFactor = 0.2;
+ //#endregion
+
+ //#region POST FXs
+ if (!this.usePostFXs) {
+ this.composer = null;
+ } else {
+ const renderTarget = new THREE.WebGLRenderTarget(width, height, {
+ minFilter: THREE.LinearFilter,
+ magFilter: THREE.LinearFilter,
+ format: THREE.RGBFormat,
+ stencilBuffer: false,
+ });
+
+ const fxaa = new ShaderPass(FXAAShader);
+ fxaa.uniforms['resolution'].value = new THREE.Vector2(1 / width, 1 / height);
+ fxaa.renderToScreen = true;
+
+ this.composer = new EffectComposer(this.renderer, renderTarget);
+ this.composer.addPass(new RenderPass(this.scene, this.camera));
+ if (this.graphicsQuality === 'ultra') {
+ this.composer.addPass(new BloomPass(0.25, 30, 128.0, 512));
+ }
+ this.composer.addPass(fxaa);
+ }
+ //#endregion
+ //#endregion
+
+ //#region Label
+ //#region Avatar
+ const avatarUrl = `/proxy/?${urlQuery({ url: user.avatarUrl })}`;
+
+ const textureLoader = new THREE.TextureLoader();
+ textureLoader.crossOrigin = 'anonymous';
+
+ const iconTexture = textureLoader.load(avatarUrl);
+ iconTexture.wrapS = THREE.RepeatWrapping;
+ iconTexture.wrapT = THREE.RepeatWrapping;
+ iconTexture.anisotropy = 16;
+
+ const avatarMaterial = new THREE.MeshBasicMaterial({
+ map: iconTexture,
+ side: THREE.DoubleSide,
+ alphaTest: 0.5
+ });
+
+ const iconGeometry = new THREE.PlaneGeometry(1, 1);
+
+ const avatarObject = new THREE.Mesh(iconGeometry, avatarMaterial);
+ avatarObject.position.set(-3, 2.5, 2);
+ avatarObject.rotation.y = Math.PI / 2;
+ avatarObject.castShadow = false;
+
+ this.scene.add(avatarObject);
+ //#endregion
+
+ //#region Username
+ const name = user.username;
+
+ new THREE.FontLoader().load('/assets/fonts/helvetiker_regular.typeface.json', font => {
+ const nameGeometry = new THREE.TextGeometry(name, {
+ size: 0.5,
+ height: 0,
+ curveSegments: 8,
+ font: font,
+ bevelThickness: 0,
+ bevelSize: 0,
+ bevelEnabled: false
+ });
+
+ const nameMaterial = new THREE.MeshLambertMaterial({
+ color: 0xffffff
+ });
+
+ const nameObject = new THREE.Mesh(nameGeometry, nameMaterial);
+ nameObject.position.set(-3, 2.25, 1.25);
+ nameObject.rotation.y = Math.PI / 2;
+ nameObject.castShadow = false;
+
+ this.scene.add(nameObject);
+ });
+ //#endregion
+ //#endregion
+
+ //#region Interaction
+ if (isMyRoom) {
+ this.furnitureControl = new TransformControls(this.camera, this.canvas);
+ this.scene.add(this.furnitureControl);
+
+ // Hover highlight
+ this.canvas.onmousemove = this.onmousemove;
+
+ // Click
+ this.canvas.onmousedown = this.onmousedown;
+ }
+ //#endregion
+
+ //#region Init room
+ this.loadRoom();
+ //#endregion
+
+ //#region Load furnitures
+ for (const furniture of this.furnitures) {
+ this.loadFurniture(furniture).then(obj => {
+ this.scene.add(obj.scene);
+ this.objects.push(obj.scene);
+ });
+ }
+ //#endregion
+
+ // Start render
+ if (this.usePostFXs) {
+ this.renderWithPostFXs();
+ } else {
+ this.renderWithoutPostFXs();
+ }
+ }
+
+ @autobind
+ private renderWithoutPostFXs() {
+ this.renderFrameRequestId =
+ window.requestAnimationFrame(this.renderWithoutPostFXs);
+
+ // Update animations
+ const clock = this.clock.getDelta();
+ for (const mixer of this.mixers) {
+ mixer.update(clock);
+ }
+
+ this.controls.update();
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ @autobind
+ private renderWithPostFXs() {
+ this.renderFrameRequestId =
+ window.requestAnimationFrame(this.renderWithPostFXs);
+
+ // Update animations
+ const clock = this.clock.getDelta();
+ for (const mixer of this.mixers) {
+ mixer.update(clock);
+ }
+
+ this.controls.update();
+ this.renderer.clear();
+ this.composer.render();
+ }
+
+ @autobind
+ private loadRoom() {
+ const type = this.roomInfo.roomType;
+ new GLTFLoader().load(`/client-assets/room/rooms/${type}/${type}.glb`, gltf => {
+ gltf.scene.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+
+ child.receiveShadow = this.enableShadow;
+
+ child.material = new THREE.MeshLambertMaterial({
+ color: (child.material as THREE.MeshStandardMaterial).color,
+ map: (child.material as THREE.MeshStandardMaterial).map,
+ name: (child.material as THREE.MeshStandardMaterial).name,
+ });
+
+ // 異方性フィルタリング
+ if ((child.material as THREE.MeshLambertMaterial).map && this.graphicsQuality !== 'cheep') {
+ (child.material as THREE.MeshLambertMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshLambertMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshLambertMaterial).map.anisotropy = 8;
+ }
+ });
+
+ gltf.scene.position.set(0, 0, 0);
+
+ this.scene.add(gltf.scene);
+ this.roomObj = gltf.scene;
+ if (this.roomInfo.roomType === 'default') {
+ this.applyCarpetColor();
+ }
+ });
+ }
+
+ @autobind
+ private loadFurniture(furniture: Furniture) {
+ const def = furnitureDefs.find(d => d.id === furniture.type);
+ return new Promise<GLTF>((res, rej) => {
+ const loader = new GLTFLoader();
+ loader.load(`/client-assets/room/furnitures/${furniture.type}/${furniture.type}.glb`, gltf => {
+ const model = gltf.scene;
+
+ // Load animation
+ if (gltf.animations.length > 0) {
+ const mixer = new THREE.AnimationMixer(model);
+ this.mixers.push(mixer);
+ for (const clip of gltf.animations) {
+ mixer.clipAction(clip).play();
+ }
+ }
+
+ model.name = furniture.id;
+ model.position.x = furniture.position.x;
+ model.position.y = furniture.position.y;
+ model.position.z = furniture.position.z;
+ model.rotation.x = furniture.rotation.x;
+ model.rotation.y = furniture.rotation.y;
+ model.rotation.z = furniture.rotation.z;
+
+ model.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ child.castShadow = this.enableShadow;
+ child.receiveShadow = this.enableShadow;
+ (child.material as THREE.MeshStandardMaterial).metalness = 0;
+
+ // 異方性フィルタリング
+ if ((child.material as THREE.MeshStandardMaterial).map && this.graphicsQuality !== 'cheep') {
+ (child.material as THREE.MeshStandardMaterial).map.minFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshStandardMaterial).map.magFilter = THREE.LinearMipMapLinearFilter;
+ (child.material as THREE.MeshStandardMaterial).map.anisotropy = 8;
+ }
+ });
+
+ if (def.color) { // カスタムカラー
+ this.applyCustomColor(model);
+ }
+
+ if (def.texture) { // カスタムテクスチャ
+ this.applyCustomTexture(model);
+ }
+
+ res(gltf);
+ }, null, rej);
+ });
+ }
+
+ @autobind
+ private applyCarpetColor() {
+ this.roomObj.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ if (child.material &&
+ (child.material as THREE.MeshStandardMaterial).name &&
+ (child.material as THREE.MeshStandardMaterial).name === 'Carpet'
+ ) {
+ const colorHex = parseInt(this.roomInfo.carpetColor.substr(1), 16);
+ (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
+ }
+ });
+ }
+
+ @autobind
+ private applyCustomColor(model: THREE.Object3D) {
+ const furniture = this.furnitures.find(furniture => furniture.id === model.name);
+ const def = furnitureDefs.find(d => d.id === furniture.type);
+ if (def.color == null) return;
+ model.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ for (const t of Object.keys(def.color)) {
+ if (!child.material ||
+ !(child.material as THREE.MeshStandardMaterial).name ||
+ (child.material as THREE.MeshStandardMaterial).name !== t
+ ) continue;
+
+ const prop = def.color[t];
+ const val = furniture.props ? furniture.props[prop] : undefined;
+
+ if (val == null) continue;
+
+ const colorHex = parseInt(val.substr(1), 16);
+ (child.material as THREE.MeshStandardMaterial).color.setHex(colorHex);
+ }
+ });
+ }
+
+ @autobind
+ private applyCustomTexture(model: THREE.Object3D) {
+ const furniture = this.furnitures.find(furniture => furniture.id === model.name);
+ const def = furnitureDefs.find(d => d.id === furniture.type);
+ if (def.texture == null) return;
+
+ model.traverse(child => {
+ if (!(child instanceof THREE.Mesh)) return;
+ for (const t of Object.keys(def.texture)) {
+ if (child.name !== t) continue;
+
+ const prop = def.texture[t].prop;
+ const val = furniture.props ? furniture.props[prop] : undefined;
+
+ if (val == null) continue;
+
+ const canvas = document.createElement('canvas');
+ canvas.height = 1024;
+ canvas.width = 1024;
+
+ child.material = new THREE.MeshLambertMaterial({
+ emissive: 0x111111,
+ side: THREE.DoubleSide,
+ alphaTest: 0.5,
+ });
+
+ const img = new Image();
+ img.crossOrigin = 'anonymous';
+ img.onload = () => {
+ const uvInfo = def.texture[t].uv;
+
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(img,
+ 0, 0, img.width, img.height,
+ uvInfo.x, uvInfo.y, uvInfo.width, uvInfo.height);
+
+ const texture = new THREE.Texture(canvas);
+ texture.wrapS = THREE.RepeatWrapping;
+ texture.wrapT = THREE.RepeatWrapping;
+ texture.anisotropy = 16;
+ texture.flipY = false;
+
+ (child.material as THREE.MeshLambertMaterial).map = texture;
+ (child.material as THREE.MeshLambertMaterial).needsUpdate = true;
+ (child.material as THREE.MeshLambertMaterial).map.needsUpdate = true;
+ };
+ img.src = val;
+ }
+ });
+ }
+
+ @autobind
+ private onmousemove(ev: MouseEvent) {
+ if (this.isTransformMode) return;
+
+ const rect = (ev.target as HTMLElement).getBoundingClientRect();
+ const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
+ const pos = new THREE.Vector2(x, y);
+
+ this.camera.updateMatrixWorld();
+
+ const raycaster = new THREE.Raycaster();
+ raycaster.setFromCamera(pos, this.camera);
+
+ const intersects = raycaster.intersectObjects(this.objects, true);
+
+ for (const object of this.objects) {
+ if (this.isSelectedObject(object)) continue;
+ object.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
+ }
+ });
+ }
+
+ if (intersects.length > 0) {
+ const intersected = this.getRoot(intersects[0].object);
+ if (this.isSelectedObject(intersected)) return;
+ intersected.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x191919);
+ }
+ });
+ }
+ }
+
+ @autobind
+ private onmousedown(ev: MouseEvent) {
+ if (this.isTransformMode) return;
+ if (ev.target !== this.canvas || ev.button !== 0) return;
+
+ const rect = (ev.target as HTMLElement).getBoundingClientRect();
+ const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
+ const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
+ const pos = new THREE.Vector2(x, y);
+
+ this.camera.updateMatrixWorld();
+
+ const raycaster = new THREE.Raycaster();
+ raycaster.setFromCamera(pos, this.camera);
+
+ const intersects = raycaster.intersectObjects(this.objects, true);
+
+ for (const object of this.objects) {
+ object.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0x000000);
+ }
+ });
+ }
+
+ if (intersects.length > 0) {
+ const selectedObj = this.getRoot(intersects[0].object);
+ this.selectFurniture(selectedObj);
+ } else {
+ this.selectedObject = null;
+ this.onChangeSelect(null);
+ }
+ }
+
+ @autobind
+ private getRoot(obj: THREE.Object3D): THREE.Object3D {
+ let found = false;
+ let x = obj.parent;
+ while (!found) {
+ if (x.parent.parent == null) {
+ found = true;
+ } else {
+ x = x.parent;
+ }
+ }
+ return x;
+ }
+
+ @autobind
+ private isSelectedObject(obj: THREE.Object3D): boolean {
+ if (this.selectedObject == null) {
+ return false;
+ } else {
+ return obj.name === this.selectedObject.name;
+ }
+ }
+
+ @autobind
+ private selectFurniture(obj: THREE.Object3D) {
+ this.selectedObject = obj;
+ this.onChangeSelect(obj);
+ obj.traverse(child => {
+ if (child instanceof THREE.Mesh) {
+ (child.material as THREE.MeshStandardMaterial).emissive.setHex(0xff0000);
+ }
+ });
+ }
+
+ /**
+ * 家具の移動/回転モードにします
+ * @param type 移動か回転か
+ */
+ @autobind
+ public enterTransformMode(type: 'translate' | 'rotate') {
+ this.isTransformMode = true;
+ this.furnitureControl.setMode(type);
+ this.furnitureControl.attach(this.selectedObject);
+ this.controls.enableRotate = false;
+ }
+
+ /**
+ * 家具の移動/回転モードを終了します
+ */
+ @autobind
+ public exitTransformMode() {
+ this.isTransformMode = false;
+ this.furnitureControl.detach();
+ this.controls.enableRotate = true;
+ }
+
+ /**
+ * 家具プロパティを更新します
+ * @param key プロパティ名
+ * @param value 値
+ */
+ @autobind
+ public updateProp(key: string, value: any) {
+ const furniture = this.furnitures.find(furniture => furniture.id === this.selectedObject.name);
+ if (furniture.props == null) furniture.props = {};
+ furniture.props[key] = value;
+ this.applyCustomColor(this.selectedObject);
+ this.applyCustomTexture(this.selectedObject);
+ }
+
+ /**
+ * 部屋に家具を追加します
+ * @param type 家具の種類
+ */
+ @autobind
+ public addFurniture(type: string) {
+ const furniture = {
+ id: uuid(),
+ type: type,
+ position: {
+ x: 0,
+ y: 0,
+ z: 0,
+ },
+ rotation: {
+ x: 0,
+ y: 0,
+ z: 0,
+ },
+ };
+
+ this.furnitures.push(furniture);
+
+ this.loadFurniture(furniture).then(obj => {
+ this.scene.add(obj.scene);
+ this.objects.push(obj.scene);
+ });
+ }
+
+ /**
+ * 現在選択されている家具を部屋から削除します
+ */
+ @autobind
+ public removeFurniture() {
+ this.exitTransformMode();
+ const obj = this.selectedObject;
+ this.scene.remove(obj);
+ this.objects = this.objects.filter(object => object.name !== obj.name);
+ this.furnitures = this.furnitures.filter(furniture => furniture.id !== obj.name);
+ this.selectedObject = null;
+ this.onChangeSelect(null);
+ }
+
+ /**
+ * 全ての家具を部屋から削除します
+ */
+ @autobind
+ public removeAllFurnitures() {
+ this.exitTransformMode();
+ for (const obj of this.objects) {
+ this.scene.remove(obj);
+ }
+ this.objects = [];
+ this.furnitures = [];
+ this.selectedObject = null;
+ this.onChangeSelect(null);
+ }
+
+ /**
+ * 部屋の床の色を変更します
+ * @param color 色
+ */
+ @autobind
+ public updateCarpetColor(color: string) {
+ this.roomInfo.carpetColor = color;
+ this.applyCarpetColor();
+ }
+
+ /**
+ * 部屋の種類を変更します
+ * @param type 種類
+ */
+ @autobind
+ public changeRoomType(type: string) {
+ this.roomInfo.roomType = type;
+ this.scene.remove(this.roomObj);
+ this.loadRoom();
+ }
+
+ /**
+ * 部屋データを取得します
+ */
+ @autobind
+ public getRoomInfo() {
+ for (const obj of this.objects) {
+ const furniture = this.furnitures.find(f => f.id === obj.name);
+ furniture.position.x = obj.position.x;
+ furniture.position.y = obj.position.y;
+ furniture.position.z = obj.position.z;
+ furniture.rotation.x = obj.rotation.x;
+ furniture.rotation.y = obj.rotation.y;
+ furniture.rotation.z = obj.rotation.z;
+ }
+
+ return this.roomInfo;
+ }
+
+ /**
+ * 選択されている家具を取得します
+ */
+ @autobind
+ public getSelectedObject() {
+ return this.selectedObject;
+ }
+
+ @autobind
+ public findFurnitureById(id: string) {
+ return this.furnitures.find(furniture => furniture.id === id);
+ }
+
+ /**
+ * レンダリングを終了します
+ */
+ @autobind
+ public destroy() {
+ // Stop render loop
+ window.cancelAnimationFrame(this.renderFrameRequestId);
+
+ this.controls.dispose();
+ this.scene.dispose();
+ }
+}
diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts
new file mode 100644
index 0000000000..621fe88105
--- /dev/null
+++ b/packages/client/src/scripts/scroll.ts
@@ -0,0 +1,80 @@
+type ScrollBehavior = 'auto' | 'smooth' | 'instant';
+
+export function getScrollContainer(el: Element | null): Element | null {
+ if (el == null || el.tagName === 'BODY') return null;
+ const overflow = window.getComputedStyle(el).getPropertyValue('overflow');
+ if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden 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 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/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts
new file mode 100644
index 0000000000..b28cccfab7
--- /dev/null
+++ b/packages/client/src/scripts/search.ts
@@ -0,0 +1,64 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { router } from '@/router';
+
+export async function search() {
+ const { canceled, result: query } = await os.dialog({
+ title: i18n.locale.search,
+ input: true
+ });
+ if (canceled || query == null || query === '') return;
+
+ const q = query.trim();
+
+ if (q.startsWith('@') && !q.includes(' ')) {
+ router.push(`/${q}`);
+ return;
+ }
+
+ if (q.startsWith('#')) {
+ router.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.dialog({
+ 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.locale.fetchingAsApObject);
+
+ const res = await promise;
+
+ if (res.type === 'User') {
+ router.push(`/@${res.object.username}@${res.object.host}`);
+ } else if (res.type === 'Note') {
+ router.push(`/notes/${res.object.id}`);
+ }
+
+ return;
+ }
+
+ router.push(`/search?q=${encodeURIComponent(q)}`);
+}
diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts
new file mode 100644
index 0000000000..5fbc545b26
--- /dev/null
+++ b/packages/client/src/scripts/select-file.ts
@@ -0,0 +1,89 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { defaultStore } from '@/store';
+
+export function selectFile(src: any, label: string | null, multiple = false) {
+ return new Promise((res, rej) => {
+ const chooseFileFromPc = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.multiple = multiple;
+ input.onchange = () => {
+ const promises = Array.from(input.files).map(file => os.upload(file, defaultStore.state.uploadFolder));
+
+ Promise.all(promises).then(driveFiles => {
+ res(multiple ? driveFiles : driveFiles[0]);
+ }).catch(e => {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ });
+
+ // 一応廃棄
+ (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.dialog({
+ title: i18n.locale.uploadFromUrl,
+ input: {
+ placeholder: i18n.locale.uploadFromUrlDescription
+ }
+ }).then(({ canceled, result: url }) => {
+ if (canceled) return;
+
+ const marker = Math.random().toString(); // TODO: UUIDとか使う
+
+ const connection = os.stream.useChannel('main');
+ connection.on('urlUploadFinished', data => {
+ if (data.marker === marker) {
+ res(multiple ? [data.file] : data.file);
+ connection.dispose();
+ }
+ });
+
+ os.api('drive/files/upload-from-url', {
+ url: url,
+ folderId: defaultStore.state.uploadFolder,
+ marker
+ });
+
+ os.dialog({
+ title: i18n.locale.uploadFromUrlRequested,
+ text: i18n.locale.uploadFromUrlMayTakeTime
+ });
+ });
+ };
+
+ os.popupMenu([label ? {
+ text: label,
+ type: 'label'
+ } : undefined, {
+ text: i18n.locale.upload,
+ icon: 'fas fa-upload',
+ action: chooseFileFromPc
+ }, {
+ text: i18n.locale.fromDrive,
+ icon: 'fas fa-cloud',
+ action: chooseFileFromDrive
+ }, {
+ text: i18n.locale.fromUrl,
+ icon: 'fas fa-link',
+ action: chooseFileFromUrl
+ }], src);
+ });
+}
diff --git a/packages/client/src/scripts/show-suspended-dialog.ts b/packages/client/src/scripts/show-suspended-dialog.ts
new file mode 100644
index 0000000000..3bc4800030
--- /dev/null
+++ b/packages/client/src/scripts/show-suspended-dialog.ts
@@ -0,0 +1,10 @@
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+export function showSuspendedDialog() {
+ return os.dialog({
+ type: 'error',
+ title: i18n.locale.yourAccountSuspendedTitle,
+ text: i18n.locale.yourAccountSuspendedDescription
+ });
+}
diff --git a/packages/client/src/scripts/sound.ts b/packages/client/src/scripts/sound.ts
new file mode 100644
index 0000000000..2b8279b3df
--- /dev/null
+++ b/packages/client/src/scripts/sound.ts
@@ -0,0 +1,34 @@
+import { ColdDeviceStorage } from '@/store';
+
+const cache = new Map<string, HTMLAudioElement>();
+
+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/client/src/scripts/sticky-sidebar.ts b/packages/client/src/scripts/sticky-sidebar.ts
new file mode 100644
index 0000000000..c67b8f37ac
--- /dev/null
+++ b/packages/client/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/client/src/scripts/theme-editor.ts b/packages/client/src/scripts/theme-editor.ts
new file mode 100644
index 0000000000..3d69d2836a
--- /dev/null
+++ b/packages/client/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/client/src/scripts/theme.ts b/packages/client/src/scripts/theme.ts
new file mode 100644
index 0000000000..3b7f003d0f
--- /dev/null
+++ b/packages/client/src/scripts/theme.ts
@@ -0,0 +1,127 @@
+import { globalEvents } from '@/events';
+import * as tinycolor from 'tinycolor2';
+
+export type Theme = {
+ id: string;
+ name: string;
+ author: string;
+ desc?: string;
+ base?: 'dark' | 'light';
+ props: Record<string, string>;
+};
+
+export const lightTheme: Theme = require('@/themes/_light.json5');
+export const darkTheme: Theme = require('@/themes/_dark.json5');
+
+export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
+
+export const builtinThemes = [
+ require('@/themes/l-light.json5'),
+ require('@/themes/l-apricot.json5'),
+ require('@/themes/l-rainy.json5'),
+ require('@/themes/l-vivid.json5'),
+ require('@/themes/l-sushi.json5'),
+
+ require('@/themes/d-dark.json5'),
+ require('@/themes/d-persimmon.json5'),
+ require('@/themes/d-astro.json5'),
+ require('@/themes/d-future.json5'),
+ require('@/themes/d-botanical.json5'),
+ require('@/themes/d-pumpkin.json5'),
+ require('@/themes/d-black.json5'),
+] as Theme[];
+
+let timeout = null;
+
+export function applyTheme(theme: Theme, persist = true) {
+ if (timeout) clearTimeout(timeout);
+
+ document.documentElement.classList.add('_themeChanging_');
+
+ timeout = setTimeout(() => {
+ document.documentElement.classList.remove('_themeChanging_');
+ }, 1000);
+
+ // Deep copy
+ const _theme = JSON.parse(JSON.stringify(theme));
+
+ if (_theme.base) {
+ const base = [lightTheme, darkTheme].find(x => x.id === _theme.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['html']);
+ break;
+ }
+ }
+
+ for (const [k, v] of Object.entries(props)) {
+ document.documentElement.style.setProperty(`--${k}`, v.toString());
+ }
+
+ if (persist) {
+ localStorage.setItem('theme', JSON.stringify(props));
+ }
+
+ // 色計算など再度行えるようにクライアント全体に通知
+ globalEvents.emit('themeChanged');
+}
+
+function compile(theme: Theme): Record<string, string> {
+ 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<string, any>): 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/client/src/scripts/time.ts b/packages/client/src/scripts/time.ts
new file mode 100644
index 0000000000..34e8b6b17c
--- /dev/null
+++ b/packages/client/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/client/src/scripts/twemoji-base.ts b/packages/client/src/scripts/twemoji-base.ts
new file mode 100644
index 0000000000..cd50311b15
--- /dev/null
+++ b/packages/client/src/scripts/twemoji-base.ts
@@ -0,0 +1 @@
+export const twemojiSvgBase = '/twemoji';
diff --git a/packages/client/src/scripts/unison-reload.ts b/packages/client/src/scripts/unison-reload.ts
new file mode 100644
index 0000000000..59af584c1b
--- /dev/null
+++ b/packages/client/src/scripts/unison-reload.ts
@@ -0,0 +1,15 @@
+// SafariがBroadcastChannel未実装なのでライブラリを使う
+import { BroadcastChannel } from 'broadcast-channel';
+
+export const reloadChannel = new BroadcastChannel<string | null>('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/client/src/scripts/url.ts b/packages/client/src/scripts/url.ts
new file mode 100644
index 0000000000..c7f2b7c1e7
--- /dev/null
+++ b/packages/client/src/scripts/url.ts
@@ -0,0 +1,13 @@
+export function query(obj: {}): 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<string, any>);
+
+ return Object.entries(params)
+ .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`)
+ .join('&');
+}
+
+export function appendQuery(url: string, query: string): string {
+ return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`;
+}
diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts
new file mode 100644
index 0000000000..955d94a074
--- /dev/null
+++ b/packages/client/src/store.ts
@@ -0,0 +1,318 @@
+import { markRaw, ref } from 'vue';
+import { Storage } from './pizzax';
+import { Theme } from './scripts/theme';
+
+export const postFormActions = [];
+export const userActions = [];
+export const noteActions = [];
+export const noteViewInterruptors = [];
+export const notePostInterruptors = [];
+
+// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
+// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
+export const defaultStore = markRaw(new Storage('base', {
+ tutorial: {
+ where: 'account',
+ default: 0
+ },
+ keepCw: {
+ where: 'account',
+ default: false
+ },
+ 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}}]'
+ },
+ memo: {
+ where: 'account',
+ default: null
+ },
+ reactions: {
+ where: 'account',
+ default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮']
+ },
+ mutedWords: {
+ where: 'account',
+ default: []
+ },
+ mutedAds: {
+ where: 'account',
+ default: [] as string[]
+ },
+
+ menu: {
+ where: 'deviceAccount',
+ default: [
+ 'notifications',
+ 'messaging',
+ 'drive',
+ 'followRequests',
+ '-',
+ 'gallery',
+ 'featured',
+ 'explore',
+ 'announcements',
+ 'search',
+ '-',
+ 'ui',
+ ]
+ },
+ visibility: {
+ where: 'deviceAccount',
+ default: 'public' as 'public' | 'home' | 'followers' | 'specified'
+ },
+ localOnly: {
+ where: 'deviceAccount',
+ default: false
+ },
+ widgets: {
+ where: 'deviceAccount',
+ default: [] as {
+ name: string;
+ id: string;
+ place: string | null;
+ data: Record<string, any>;
+ }[]
+ },
+ tl: {
+ where: 'deviceAccount',
+ default: {
+ src: 'home',
+ arg: null
+ }
+ },
+
+ 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: true
+ },
+ loadRawImages: {
+ where: 'device',
+ default: false
+ },
+ imageNewTab: {
+ where: 'device',
+ default: false
+ },
+ disableShowingAnimatedImages: {
+ where: 'device',
+ default: false
+ },
+ disablePagesScript: {
+ where: 'device',
+ default: false
+ },
+ useOsNativeEmojis: {
+ 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: true
+ },
+ showGapBetweenNotesInTimeline: {
+ where: 'device',
+ default: false
+ },
+ darkMode: {
+ where: 'device',
+ default: false
+ },
+ instanceTicker: {
+ where: 'device',
+ default: 'remote' as 'none' | 'remote' | 'always'
+ },
+ reactionPickerWidth: {
+ where: 'device',
+ default: 1
+ },
+ reactionPickerHeight: {
+ where: 'device',
+ default: 1
+ },
+ 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: ''
+ },
+ aiChanMode: {
+ where: 'device',
+ default: false
+ },
+}));
+
+// TODO: 他のタブと永続化されたstateを同期
+
+const PREFIX = 'miux:';
+
+type Plugin = {
+ id: string;
+ name: string;
+ active: boolean;
+ configData: Record<string, any>;
+ token: string;
+ ast: any[];
+};
+
+/**
+ * 常にメモリにロードしておく必要がないような設定情報を保管するストレージ(非リアクティブ)
+ */
+export class ColdDeviceStorage {
+ public static default = {
+ lightTheme: require('@/themes/l-light.json5') as Theme,
+ darkTheme: require('@/themes/d-dark.json5') as Theme,
+ syncDeviceDarkMode: true,
+ chatOpenBehavior: 'page' as 'page' | 'window' | 'popout',
+ 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 },
+ sound_reversiPutBlack: { type: 'syuilo/kick', volume: 0.3 },
+ sound_reversiPutWhite: { type: 'syuilo/snare', volume: 0.3 },
+ roomGraphicsQuality: 'medium' as 'cheep' | 'low' | 'medium' | 'high' | 'ultra',
+ roomUseOrthographicCamera: true,
+ };
+
+ public static watchers = [];
+
+ public static get<T extends keyof typeof ColdDeviceStorage.default>(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<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void {
+ 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<T extends keyof typeof ColdDeviceStorage.default>(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<K extends keyof typeof ColdDeviceStorage.default>(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);
+ }
+ };
+ }
+}
+
+// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない
+declare module '@vue/runtime-core' {
+ interface ComponentCustomProperties {
+ $store: typeof defaultStore;
+ }
+}
diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss
new file mode 100644
index 0000000000..951d5a14f3
--- /dev/null
+++ b/packages/client/src/style.scss
@@ -0,0 +1,562 @@
+@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);
+ overflow: auto;
+ overflow-wrap: break-word;
+ font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
+ line-height: 1.35;
+ text-size-adjust: 100%;
+ tab-size: 2;
+
+ &, * {
+ scrollbar-color: var(--scrollbarHandle) inherit;
+ scrollbar-width: thin;
+
+ &:hover {
+ scrollbar-color: var(--scrollbarHandleHover) inherit;
+ }
+
+ &:active {
+ scrollbar-color: var(--accent) inherit;
+ }
+
+ &::-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-small {
+ font-size: 0.9em;
+ }
+
+ &.f-large {
+ font-size: 1.1em;
+ }
+
+ &.f-veryLarge {
+ font-size: 1.2em;
+ }
+
+ &.useSystemFont {
+ font-family: 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);
+}
+
+._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;
+
+ &, * {
+ @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);
+ }
+}
+
+._window {
+ background: var(--panel);
+ border-radius: var(--radius);
+ contain: content;
+}
+
+._popup {
+ background: var(--popup);
+ border-radius: var(--radius);
+ contain: layout; // ふき出しがボックスから飛び出て表示されるようなデザインをする場合もあるので paint は contain することができない
+}
+
+// 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));
+}
+
+._inputSplit {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ grid-gap: 8px;
+ margin: 1em 0;
+
+ > * {
+ margin: 0 !important;
+ }
+}
+
+._formBlock {
+ margin: 20px 0;
+}
+
+._formRoot {
+ > ._formBlock:first-child {
+ margin-top: 0;
+ }
+
+ > ._formBlock:last-child {
+ margin-bottom: 0;
+ }
+}
+
+._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/client/src/sw/compose-notification.ts b/packages/client/src/sw/compose-notification.ts
new file mode 100644
index 0000000000..0aed9610ea
--- /dev/null
+++ b/packages/client/src/sw/compose-notification.ts
@@ -0,0 +1,103 @@
+/**
+ * Notification composer of Service Worker
+ */
+declare var self: ServiceWorkerGlobalScope;
+
+import { getNoteSummary } from '@/scripts/get-note-summary';
+import * as misskey from 'misskey-js';
+
+function getUserName(user: misskey.entities.User): string {
+ return user.name || user.username;
+}
+
+export default async function(type, data, i18n): Promise<[string, NotificationOptions] | null | undefined> {
+ if (!i18n) {
+ console.log('no i18n');
+ return;
+ }
+
+ switch (type) {
+ case 'driveFileCreated': // TODO (Server Side)
+ return [i18n.t('_notification.fileUploaded'), {
+ body: data.name,
+ icon: data.url
+ }];
+ case 'notification':
+ switch (data.type) {
+ case 'mention':
+ return [i18n.t('_notification.youGotMention', { name: getUserName(data.user) }), {
+ body: getNoteSummary(data.note, i18n.locale),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'reply':
+ return [i18n.t('_notification.youGotReply', { name: getUserName(data.user) }), {
+ body: getNoteSummary(data.note, i18n.locale),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'renote':
+ return [i18n.t('_notification.youRenoted', { name: getUserName(data.user) }), {
+ body: getNoteSummary(data.note, i18n.locale),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'quote':
+ return [i18n.t('_notification.youGotQuote', { name: getUserName(data.user) }), {
+ body: getNoteSummary(data.note, i18n.locale),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'reaction':
+ return [`${data.reaction} ${getUserName(data.user)}`, {
+ body: getNoteSummary(data.note, i18n.locale),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'pollVote':
+ return [i18n.t('_notification.youGotPoll', { name: getUserName(data.user) }), {
+ body: getNoteSummary(data.note, i18n.locale),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'follow':
+ return [i18n.t('_notification.youWereFollowed'), {
+ body: getUserName(data.user),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'receiveFollowRequest':
+ return [i18n.t('_notification.youReceivedFollowRequest'), {
+ body: getUserName(data.user),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'followRequestAccepted':
+ return [i18n.t('_notification.yourFollowRequestAccepted'), {
+ body: getUserName(data.user),
+ icon: data.user.avatarUrl
+ }];
+
+ case 'groupInvited':
+ return [i18n.t('_notification.youWereInvitedToGroup'), {
+ body: data.group.name
+ }];
+
+ default:
+ return null;
+ }
+ case 'unreadMessagingMessage':
+ if (data.groupId === null) {
+ return [i18n.t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.user) }), {
+ icon: data.user.avatarUrl,
+ tag: `messaging:user:${data.user.id}`
+ }];
+ }
+ return [i18n.t('_notification.youGotMessagingMessageFromGroup', { name: data.group.name }), {
+ icon: data.user.avatarUrl,
+ tag: `messaging:group:${data.group.id}`
+ }];
+ default:
+ return null;
+ }
+}
diff --git a/packages/client/src/sw/sw.ts b/packages/client/src/sw/sw.ts
new file mode 100644
index 0000000000..68c650c771
--- /dev/null
+++ b/packages/client/src/sw/sw.ts
@@ -0,0 +1,123 @@
+/**
+ * Service Worker
+ */
+declare var self: ServiceWorkerGlobalScope;
+
+import { get, set } from 'idb-keyval';
+import composeNotification from '@/sw/compose-notification';
+import { I18n } from '@/scripts/i18n';
+
+//#region Variables
+const version = _VERSION_;
+const cacheName = `mk-cache-${version}`;
+
+let lang: string;
+let i18n: I18n<any>;
+let pushesPool: any[] = [];
+//#endregion
+
+//#region Startup
+get('lang').then(async prelang => {
+ if (!prelang) return;
+ lang = prelang;
+ return fetchLocale();
+});
+//#endregion
+
+//#region Lifecycle: Install
+self.addEventListener('install', ev => {
+ self.skipWaiting();
+});
+//#endregion
+
+//#region Lifecycle: Activate
+self.addEventListener('activate', ev => {
+ ev.waitUntil(
+ caches.keys()
+ .then(cacheNames => Promise.all(
+ cacheNames
+ .filter((v) => v !== cacheName)
+ .map(name => caches.delete(name))
+ ))
+ .then(() => self.clients.claim())
+ );
+});
+//#endregion
+
+//#region When: Fetching
+self.addEventListener('fetch', ev => {
+ // Nothing to do
+});
+//#endregion
+
+//#region When: Caught Notification
+self.addEventListener('push', ev => {
+ // クライアント取得
+ ev.waitUntil(self.clients.matchAll({
+ includeUncontrolled: true
+ }).then(async clients => {
+ // クライアントがあったらストリームに接続しているということなので通知しない
+ if (clients.length != 0) return;
+
+ const { type, body } = ev.data?.json();
+
+ // localeを読み込めておらずi18nがundefinedだった場合はpushesPoolにためておく
+ if (!i18n) return pushesPool.push({ type, body });
+
+ const n = await composeNotification(type, body, i18n);
+ if (n) return self.registration.showNotification(...n);
+ }));
+});
+//#endregion
+
+//#region When: Caught a message from the client
+self.addEventListener('message', ev => {
+ switch(ev.data) {
+ case 'clear':
+ return; // TODO
+ default:
+ break;
+ }
+
+ if (typeof ev.data === 'object') {
+ // E.g. '[object Array]' → 'array'
+ const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase();
+
+ if (otype === 'object') {
+ if (ev.data.msg === 'initialize') {
+ lang = ev.data.lang;
+ set('lang', lang);
+ fetchLocale();
+ }
+ }
+ }
+});
+//#endregion
+
+//#region Function: (Re)Load i18n instance
+async function fetchLocale() {
+ //#region localeファイルの読み込み
+ // Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う
+ const localeUrl = `/assets/locales/${lang}.${version}.json`;
+ let localeRes = await caches.match(localeUrl);
+
+ if (!localeRes) {
+ localeRes = await fetch(localeUrl);
+ const clone = localeRes?.clone();
+ if (!clone?.clone().ok) return;
+
+ caches.open(cacheName).then(cache => cache.put(localeUrl, clone));
+ }
+
+ i18n = new I18n(await localeRes.json());
+ //#endregion
+
+ //#region i18nをきちんと読み込んだ後にやりたい処理
+ for (const { type, body } of pushesPool) {
+ const n = await composeNotification(type, body, i18n);
+ if (n) self.registration.showNotification(...n);
+ }
+ pushesPool = [];
+ //#endregion
+}
+//#endregion
diff --git a/packages/client/src/symbols.ts b/packages/client/src/symbols.ts
new file mode 100644
index 0000000000..6913f29c28
--- /dev/null
+++ b/packages/client/src/symbols.ts
@@ -0,0 +1 @@
+export const PAGE_INFO = Symbol('Page info');
diff --git a/packages/client/src/theme-store.ts b/packages/client/src/theme-store.ts
new file mode 100644
index 0000000000..e7962e7e8e
--- /dev/null
+++ b/packages/client/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<void> {
+ if ($i == null) return;
+
+ try {
+ const themes = await api('i/registry/get', { scope: ['client'], key: 'themes' });
+ localStorage.setItem(lsCacheKey, JSON.stringify(themes));
+ } catch (e) {
+ if (e.code === 'NO_SUCH_KEY') return;
+ throw e;
+ }
+}
+
+export async function addTheme(theme: Theme): Promise<void> {
+ 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<void> {
+ 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/client/src/themes/_dark.json5 b/packages/client/src/themes/_dark.json5
new file mode 100644
index 0000000000..d8be16f60a
--- /dev/null
+++ b/packages/client/src/themes/_dark.json5
@@ -0,0 +1,90 @@
+// ダークテーマのベーステーマ
+// このテーマが直接使われることは無い
+{
+ 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',
+ 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',
+ 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',
+ 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/client/src/themes/_light.json5 b/packages/client/src/themes/_light.json5
new file mode 100644
index 0000000000..251aa36c7a
--- /dev/null
+++ b/packages/client/src/themes/_light.json5
@@ -0,0 +1,90 @@
+// ライトテーマのベーステーマ
+// このテーマが直接使われることは無い
+{
+ 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',
+ 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',
+ 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',
+ 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/client/src/themes/d-astro.json5 b/packages/client/src/themes/d-astro.json5
new file mode 100644
index 0000000000..c6a927ec3a
--- /dev/null
+++ b/packages/client/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/client/src/themes/d-black.json5 b/packages/client/src/themes/d-black.json5
new file mode 100644
index 0000000000..3c18ebdaf1
--- /dev/null
+++ b/packages/client/src/themes/d-black.json5
@@ -0,0 +1,17 @@
+{
+ id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1',
+
+ name: 'Mi Black',
+ author: 'syuilo',
+
+ base: 'dark',
+
+ props: {
+ divider: '#2d2d2d',
+ panel: '#131313',
+ panelHeaderBg: '@panel',
+ panelHeaderDivider: '@divider',
+ shadow: 'rgba(255, 255, 255, 0.05)',
+ modalBg: 'rgba(255, 255, 255, 0.1)',
+ },
+}
diff --git a/packages/client/src/themes/d-botanical.json5 b/packages/client/src/themes/d-botanical.json5
new file mode 100644
index 0000000000..c03b95e2d7
--- /dev/null
+++ b/packages/client/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/client/src/themes/d-dark.json5 b/packages/client/src/themes/d-dark.json5
new file mode 100644
index 0000000000..d24ce4df69
--- /dev/null
+++ b/packages/client/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/client/src/themes/d-future.json5 b/packages/client/src/themes/d-future.json5
new file mode 100644
index 0000000000..b6fa1ab0c1
--- /dev/null
+++ b/packages/client/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/client/src/themes/d-persimmon.json5 b/packages/client/src/themes/d-persimmon.json5
new file mode 100644
index 0000000000..e36265ff10
--- /dev/null
+++ b/packages/client/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/client/src/themes/d-pumpkin.json5 b/packages/client/src/themes/d-pumpkin.json5
new file mode 100644
index 0000000000..064ca4577b
--- /dev/null
+++ b/packages/client/src/themes/d-pumpkin.json5
@@ -0,0 +1,88 @@
+{
+ id: '0b64fef3-02c7-20b5-dd87-b3f77e2b4301',
+
+ name: 'Mi Pumpkin Dark',
+ author: 'syuilo',
+
+ base: '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: 'rgb(37, 32, 47)',
+ fg: '#e0d5c0',
+ 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: 'rgb(172, 193, 68)',
+ warn: '#ecb637',
+ badge: '#31b1ce',
+ error: '#ec4137',
+ focus: ':alpha<0.3<@accent',
+ navBg: '@panel',
+ navFg: '@fg',
+ panel: ':lighten<3<@bg',
+ popup: ':lighten<3<@panel',
+ accent: 'rgb(242, 133, 36)',
+ header: ':alpha<0.7<@panel',
+ infoBg: '#253142',
+ infoFg: '#fff',
+ renote: 'rgb(110, 179, 72)',
+ shadow: 'rgba(0, 0, 0, 0.3)',
+ divider: 'rgba(255, 255, 255, 0.1)',
+ hashtag: 'rgb(188, 90, 255)',
+ mention: 'rgb(72, 179, 139)',
+ 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: '@accent',
+ messageBg: '@bg',
+ navActive: '@accent',
+ accentedBg: ':alpha<0.15<@accent',
+ fgOnAccent: '#000',
+ infoWarnBg: '#42321c',
+ infoWarnFg: '#ffbd3e',
+ navHoverFg: ':lighten<17<@fg',
+ 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)',
+ },
+}
diff --git a/packages/client/src/themes/l-apricot.json5 b/packages/client/src/themes/l-apricot.json5
new file mode 100644
index 0000000000..1ed5525575
--- /dev/null
+++ b/packages/client/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/client/src/themes/l-light.json5 b/packages/client/src/themes/l-light.json5
new file mode 100644
index 0000000000..248355c945
--- /dev/null
+++ b/packages/client/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/client/src/themes/l-rainy.json5 b/packages/client/src/themes/l-rainy.json5
new file mode 100644
index 0000000000..283dd74c6c
--- /dev/null
+++ b/packages/client/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/client/src/themes/l-sushi.json5 b/packages/client/src/themes/l-sushi.json5
new file mode 100644
index 0000000000..5846927d65
--- /dev/null
+++ b/packages/client/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/client/src/themes/l-vivid.json5 b/packages/client/src/themes/l-vivid.json5
new file mode 100644
index 0000000000..b3c08f38ae
--- /dev/null
+++ b/packages/client/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/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue
new file mode 100644
index 0000000000..59e26c837e
--- /dev/null
+++ b/packages/client/src/ui/_common_/common.vue
@@ -0,0 +1,89 @@
+<template>
+<component v-for="popup in popups"
+ :key="popup.id"
+ :is="popup.component"
+ v-bind="popup.props"
+ v-on="popup.events"
+/>
+
+<XUpload v-if="uploads.length > 0"/>
+
+<XStreamIndicator/>
+
+<div id="wait" v-if="pendingApiRequestsCount > 0"></div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent } from 'vue';
+import { stream, popup, popups, uploads, pendingApiRequestsCount } from '@/os';
+import * as sound from '@/scripts/sound';
+import { $i } from '@/account';
+
+export default defineComponent({
+ components: {
+ XStreamIndicator: defineAsyncComponent(() => import('./stream-indicator.vue')),
+ XUpload: defineAsyncComponent(() => import('./upload.vue')),
+ },
+
+ setup() {
+ const onNotification = notification => {
+ if ($i.mutingNotificationTypes.includes(notification.type)) return;
+
+ if (document.visibilityState === 'visible') {
+ stream.send('readNotification', {
+ id: notification.id
+ });
+
+ popup(import('@/components/toast.vue'), {
+ notification
+ }, {}, 'closed');
+ }
+
+ sound.play('notification');
+ };
+
+ if ($i) {
+ const connection = stream.useChannel('main', null, 'UI');
+ connection.on('notification', onNotification);
+ }
+
+ return {
+ uploads,
+ popups,
+ pendingApiRequestsCount,
+ };
+ },
+});
+</script>
+
+<style lang="scss">
+#wait {
+ display: block;
+ position: fixed;
+ z-index: 10000;
+ top: 15px;
+ right: 15px;
+
+ &:before {
+ content: "";
+ display: block;
+ width: 18px;
+ height: 18px;
+ box-sizing: border-box;
+ border: solid 2px transparent;
+ border-top-color: var(--accent);
+ border-left-color: var(--accent);
+ border-radius: 50%;
+ animation: progress-spinner 400ms linear infinite;
+ }
+}
+
+@keyframes progress-spinner {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+</style>
diff --git a/packages/client/src/ui/_common_/sidebar.vue b/packages/client/src/ui/_common_/sidebar.vue
new file mode 100644
index 0000000000..9bbf1b3e3d
--- /dev/null
+++ b/packages/client/src/ui/_common_/sidebar.vue
@@ -0,0 +1,388 @@
+<template>
+<div class="mvcprjjd">
+ <transition name="nav-back">
+ <div class="nav-back _modalBg"
+ v-if="showing"
+ @click="showing = false"
+ @touchstart.passive="showing = false"
+ ></div>
+ </transition>
+
+ <transition name="nav">
+ <nav class="nav" :class="{ iconOnly, hidden }" v-show="showing">
+ <div>
+ <button class="item _button account" @click="openAccountMenu" v-click-anime>
+ <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
+ </button>
+ <MkA class="item index" active-class="active" to="/" exact v-click-anime>
+ <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span>
+ </MkA>
+ <template v-for="item in menu">
+ <div v-if="item === '-'" class="divider"></div>
+ <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime>
+ <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span>
+ <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span>
+ </component>
+ </template>
+ <div class="divider"></div>
+ <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" v-click-anime>
+ <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
+ </MkA>
+ <button class="item _button" @click="more" v-click-anime>
+ <i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
+ <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ <MkA class="item" active-class="active" to="/settings" v-click-anime>
+ <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span>
+ </MkA>
+ <button class="item _button post" @click="post" data-cy-open-post-form>
+ <i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span>
+ </button>
+ </div>
+ </nav>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { openAccountMenu } from '@/account';
+
+export default defineComponent({
+ props: {
+ defaultHidden: {
+ type: Boolean,
+ required: false,
+ default: false,
+ }
+ },
+
+ data() {
+ return {
+ host: host,
+ showing: false,
+ accounts: [],
+ connection: null,
+ menuDef: menuDef,
+ iconOnly: false,
+ hidden: this.defaultHidden,
+ };
+ },
+
+ computed: {
+ menu(): string[] {
+ return this.$store.state.menu;
+ },
+
+ otherNavItemIndicated(): boolean {
+ for (const def in this.menuDef) {
+ if (this.menu.includes(def)) continue;
+ if (this.menuDef[def].indicated) return true;
+ }
+ return false;
+ },
+ },
+
+ watch: {
+ $route(to, from) {
+ this.showing = false;
+ },
+
+ '$store.reactiveState.menuDisplay.value'() {
+ this.calcViewState();
+ },
+
+ iconOnly() {
+ this.$nextTick(() => {
+ this.$emit('change-view-mode');
+ });
+ },
+
+ hidden() {
+ this.$nextTick(() => {
+ this.$emit('change-view-mode');
+ });
+ }
+ },
+
+ created() {
+ window.addEventListener('resize', this.calcViewState);
+ this.calcViewState();
+ },
+
+ methods: {
+ calcViewState() {
+ this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.menuDisplay === 'sideIcon');
+ if (!this.defaultHidden) {
+ this.hidden = (window.innerWidth <= 650);
+ }
+ },
+
+ show() {
+ this.showing = true;
+ },
+
+ post() {
+ os.post();
+ },
+
+ search() {
+ search();
+ },
+
+ more(ev) {
+ os.popup(import('@/components/launch-pad.vue'), {}, {
+ }, 'closed');
+ },
+
+ openAccountMenu,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.nav-enter-active,
+.nav-leave-active {
+ opacity: 1;
+ transform: translateX(0);
+ transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.nav-enter-from,
+.nav-leave-active {
+ opacity: 0;
+ transform: translateX(-240px);
+}
+
+.nav-back-enter-active,
+.nav-back-leave-active {
+ opacity: 1;
+ transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.nav-back-enter-from,
+.nav-back-leave-active {
+ opacity: 0;
+}
+
+.mvcprjjd {
+ $ui-font-size: 1em; // TODO: どこかに集約したい
+ $nav-width: 250px;
+ $nav-icon-only-width: 86px;
+
+ > .nav-back {
+ z-index: 1001;
+ }
+
+ > .nav {
+ $avatar-size: 32px;
+ $avatar-margin: 8px;
+
+ flex: 0 0 $nav-width;
+ width: $nav-width;
+ box-sizing: border-box;
+
+ &.iconOnly {
+ flex: 0 0 $nav-icon-only-width;
+ width: $nav-icon-only-width;
+
+ &:not(.hidden) {
+ > div {
+ width: $nav-icon-only-width;
+
+ > .divider {
+ margin: 8px auto;
+ width: calc(100% - 32px);
+ }
+
+ > .item {
+ padding-left: 0;
+ padding: 18px 0;
+ width: 100%;
+ text-align: center;
+ font-size: $ui-font-size * 1.1;
+ line-height: initial;
+
+ > i,
+ > .avatar {
+ display: block;
+ margin: 0 auto;
+ }
+
+ > i {
+ opacity: 0.7;
+ }
+
+ > .text {
+ display: none;
+ }
+
+ &:hover, &.active {
+ > i, > .text {
+ opacity: 1;
+ }
+ }
+
+ &:first-child {
+ margin-bottom: 8px;
+ }
+
+ &:last-child {
+ margin-top: 8px;
+ }
+ }
+ }
+ }
+ }
+
+ &.hidden {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1001;
+ }
+
+ &:not(.hidden) {
+ display: block !important;
+ }
+
+ > div {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 1001;
+ width: $nav-width;
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ height: calc(var(--vh, 1vh) * 100);
+ box-sizing: border-box;
+ overflow: auto;
+ overflow-x: clip;
+ background: var(--navBg);
+
+ > .divider {
+ margin: 16px 16px;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .item {
+ position: relative;
+ display: block;
+ padding-left: 24px;
+ font-size: $ui-font-size;
+ line-height: 2.85rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ color: var(--navFg);
+
+ > i {
+ position: relative;
+ width: 32px;
+ }
+
+ > i,
+ > .avatar {
+ margin-right: $avatar-margin;
+ }
+
+ > .avatar {
+ width: $avatar-size;
+ height: $avatar-size;
+ vertical-align: middle;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 0;
+ left: 20px;
+ color: var(--navIndicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+ }
+
+ > .text {
+ position: relative;
+ font-size: 0.9em;
+ }
+
+ &:hover {
+ text-decoration: none;
+ color: var(--navHoverFg);
+ }
+
+ &.active {
+ color: var(--navActive);
+ }
+
+ &:hover, &.active {
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 24px);
+ height: 100%;
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: var(--accentedBg);
+ }
+ }
+
+ &:first-child, &:last-child {
+ position: sticky;
+ z-index: 1;
+ padding-top: 8px;
+ padding-bottom: 8px;
+ background: var(--X14);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ }
+
+ &:first-child {
+ top: 0;
+
+ &:hover, &.active {
+ &:before {
+ content: none;
+ }
+ }
+ }
+
+ &:last-child {
+ bottom: 0;
+ color: var(--fgOnAccent);
+
+ &:before {
+ content: "";
+ display: block;
+ width: calc(100% - 20px);
+ height: calc(100% - 20px);
+ margin: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
+ }
+
+ &:hover, &.active {
+ &:before {
+ background: var(--accentLighten);
+ }
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/_common_/stream-indicator.vue b/packages/client/src/ui/_common_/stream-indicator.vue
new file mode 100644
index 0000000000..8b1b4b567c
--- /dev/null
+++ b/packages/client/src/ui/_common_/stream-indicator.vue
@@ -0,0 +1,70 @@
+<template>
+<div class="nsbbhtug" v-if="hasDisconnected && $store.state.serverDisconnectedBehavior === 'quiet'" @click="resetDisconnected">
+ <div>{{ $ts.disconnectedFromServer }}</div>
+ <div class="command">
+ <button class="_textButton" @click="reload">{{ $ts.reload }}</button>
+ <button class="_textButton">{{ $ts.doNothing }}</button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ data() {
+ return {
+ hasDisconnected: false,
+ }
+ },
+ computed: {
+ stream() {
+ return os.stream;
+ },
+ },
+ created() {
+ os.stream.on('_disconnected_', this.onDisconnected);
+ },
+ beforeUnmount() {
+ os.stream.off('_disconnected_', this.onDisconnected);
+ },
+ methods: {
+ onDisconnected() {
+ this.hasDisconnected = true;
+ },
+ resetDisconnected() {
+ this.hasDisconnected = false;
+ },
+ reload() {
+ location.reload();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.nsbbhtug {
+ position: fixed;
+ z-index: 16385;
+ bottom: 8px;
+ right: 8px;
+ margin: 0;
+ padding: 6px 12px;
+ font-size: 0.9em;
+ color: #fff;
+ background: #000;
+ opacity: 0.8;
+ border-radius: 4px;
+ max-width: 320px;
+
+ > .command {
+ display: flex;
+ justify-content: space-around;
+
+ > button {
+ padding: 0.7em;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/_common_/upload.vue b/packages/client/src/ui/_common_/upload.vue
new file mode 100644
index 0000000000..0ca353e4e1
--- /dev/null
+++ b/packages/client/src/ui/_common_/upload.vue
@@ -0,0 +1,134 @@
+<template>
+<div class="mk-uploader _acrylic">
+ <ol v-if="uploads.length > 0">
+ <li v-for="ctx in uploads" :key="ctx.id">
+ <div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
+ <div class="top">
+ <p class="name"><i class="fas fa-spinner fa-pulse"></i>{{ ctx.name }}</p>
+ <p class="status">
+ <span class="initing" v-if="ctx.progressValue === undefined">{{ $ts.waiting }}<MkEllipsis/></span>
+ <span class="kb" v-if="ctx.progressValue !== undefined">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
+ <span class="percentage" v-if="ctx.progressValue !== undefined">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
+ </p>
+ </div>
+ <progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress>
+ </li>
+ </ol>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ data() {
+ return {
+ uploads: os.uploads,
+ };
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-uploader {
+ position: fixed;
+ z-index: 10000;
+ right: 16px;
+ width: 260px;
+ top: 32px;
+ padding: 16px 20px;
+ pointer-events: none;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+}
+.mk-uploader:empty {
+ display: none;
+}
+.mk-uploader > ol {
+ display: block;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+.mk-uploader > ol > li {
+ display: grid;
+ margin: 8px 0 0 0;
+ padding: 0;
+ height: 36px;
+ width: 100%;
+ border-top: solid 8px transparent;
+ grid-template-columns: 36px calc(100% - 44px);
+ grid-template-rows: 1fr 8px;
+ column-gap: 8px;
+ box-sizing: content-box;
+}
+.mk-uploader > ol > li:first-child {
+ margin: 0;
+ box-shadow: none;
+ border-top: none;
+}
+.mk-uploader > ol > li > .img {
+ display: block;
+ background-size: cover;
+ background-position: center center;
+ grid-column: 1/2;
+ grid-row: 1/3;
+}
+.mk-uploader > ol > li > .top {
+ display: flex;
+ grid-column: 2/3;
+ grid-row: 1/2;
+}
+.mk-uploader > ol > li > .top > .name {
+ display: block;
+ padding: 0 8px 0 0;
+ margin: 0;
+ font-size: 0.8em;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ flex-shrink: 1;
+}
+.mk-uploader > ol > li > .top > .name > i {
+ margin-right: 4px;
+}
+.mk-uploader > ol > li > .top > .status {
+ display: block;
+ margin: 0 0 0 auto;
+ padding: 0;
+ font-size: 0.8em;
+ flex-shrink: 0;
+}
+.mk-uploader > ol > li > .top > .status > .initing {
+}
+.mk-uploader > ol > li > .top > .status > .kb {
+}
+.mk-uploader > ol > li > .top > .status > .percentage {
+ display: inline-block;
+ width: 48px;
+ text-align: right;
+}
+.mk-uploader > ol > li > .top > .status > .percentage:after {
+ content: '%';
+}
+.mk-uploader > ol > li > progress {
+ display: block;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ overflow: hidden;
+ grid-column: 2/3;
+ grid-row: 2/3;
+ z-index: 2;
+ width: 100%;
+ height: 8px;
+}
+.mk-uploader > ol > li > progress::-webkit-progress-value {
+ background: var(--accent);
+}
+.mk-uploader > ol > li > progress::-webkit-progress-bar {
+ //background: var(--accentAlpha01);
+ background: transparent;
+}
+</style>
diff --git a/packages/client/src/ui/chat/date-separated-list.vue b/packages/client/src/ui/chat/date-separated-list.vue
new file mode 100644
index 0000000000..b21e425aba
--- /dev/null
+++ b/packages/client/src/ui/chat/date-separated-list.vue
@@ -0,0 +1,163 @@
+<script lang="ts">
+import { defineComponent, h, PropType, TransitionGroup } from 'vue';
+import MkAd from '@/components/global/ad.vue';
+
+export default defineComponent({
+ props: {
+ items: {
+ type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
+ required: true,
+ },
+ reversed: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ ad: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ methods: {
+ focus() {
+ this.$slots.default[0].elm.focus();
+ }
+ },
+
+ render() {
+ const getDateText = (time: string) => {
+ const date = new Date(time).getDate();
+ const month = new Date(time).getMonth() + 1;
+ return this.$t('monthAndDay', {
+ month: month.toString(),
+ day: date.toString()
+ });
+ }
+
+ return h(this.reversed ? 'div' : TransitionGroup, {
+ class: 'hmjzthxl',
+ name: this.reversed ? 'list-reversed' : 'list',
+ tag: 'div',
+ }, this.items.map((item, i) => {
+ const el = this.$slots.default({
+ item: item
+ })[0];
+ if (el.key == null && item.id) el.key = item.id;
+
+ if (
+ i != this.items.length - 1 &&
+ new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
+ ) {
+ const separator = h('div', {
+ class: 'separator',
+ key: item.id + ':separator',
+ }, h('p', {
+ class: 'date'
+ }, [
+ h('span', [
+ h('i', {
+ class: 'fas fa-angle-up icon',
+ }),
+ getDateText(item.createdAt)
+ ]),
+ h('span', [
+ getDateText(this.items[i + 1].createdAt),
+ h('i', {
+ class: 'fas fa-angle-down icon',
+ })
+ ])
+ ]));
+
+ return [el, separator];
+ } else {
+ if (this.ad && item._shouldInsertAd_) {
+ return [h(MkAd, {
+ class: 'a', // advertiseの意(ブロッカー対策)
+ key: item.id + ':ad',
+ prefer: ['horizontal', 'horizontal-big'],
+ }), el];
+ } else {
+ return el;
+ }
+ }
+ }));
+ },
+});
+</script>
+
+<style lang="scss">
+.hmjzthxl {
+ > .list-move {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+ > .list-enter-active {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+ > .list-enter-from {
+ opacity: 0;
+ transform: translateY(-64px);
+ }
+
+ > .list-reversed-enter-active, > .list-reversed-leave-active {
+ transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+ > .list-reversed-enter-from {
+ opacity: 0;
+ transform: translateY(64px);
+ }
+}
+</style>
+
+<style lang="scss">
+.hmjzthxl {
+ > .separator {
+ text-align: center;
+ position: relative;
+
+ &:before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 50%;
+ left: 0;
+ right: 0;
+ margin: auto;
+ width: calc(100% - 32px);
+ height: 1px;
+ background: var(--divider);
+ }
+
+ > .date {
+ display: inline-block;
+ position: relative;
+ margin: 0;
+ padding: 0 16px;
+ line-height: 32px;
+ text-align: center;
+ font-size: 12px;
+ color: var(--dateLabelFg);
+ background: var(--panel);
+
+ > span {
+ &:first-child {
+ margin-right: 8px;
+
+ > .icon {
+ margin-right: 8px;
+ }
+ }
+
+ &:last-child {
+ margin-left: 8px;
+
+ > .icon {
+ margin-left: 8px;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/header-clock.vue b/packages/client/src/ui/chat/header-clock.vue
new file mode 100644
index 0000000000..3488289c21
--- /dev/null
+++ b/packages/client/src/ui/chat/header-clock.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="acemodlh _monospace">
+ <div>
+ <span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span>
+ </div>
+ <div>
+ <span v-text="hh"></span>
+ <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+ <span v-text="mm"></span>
+ <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+ <span v-text="ss"></span>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ data() {
+ return {
+ clock: null,
+ y: null,
+ m: null,
+ d: null,
+ hh: null,
+ mm: null,
+ ss: null,
+ showColon: true,
+ };
+ },
+ created() {
+ this.tick();
+ this.clock = setInterval(this.tick, 1000);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ tick() {
+ const now = new Date();
+ this.y = now.getFullYear().toString();
+ this.m = (now.getMonth() + 1).toString().padStart(2, '0');
+ this.d = now.getDate().toString().padStart(2, '0');
+ this.hh = now.getHours().toString().padStart(2, '0');
+ this.mm = now.getMinutes().toString().padStart(2, '0');
+ this.ss = now.getSeconds().toString().padStart(2, '0');
+ this.showColon = now.getSeconds() % 2 === 0;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.acemodlh {
+ opacity: 0.7;
+ font-size: 0.85em;
+ line-height: 1em;
+ text-align: center;
+}
+</style>
diff --git a/packages/client/src/ui/chat/index.vue b/packages/client/src/ui/chat/index.vue
new file mode 100644
index 0000000000..e8d15b2cfc
--- /dev/null
+++ b/packages/client/src/ui/chat/index.vue
@@ -0,0 +1,467 @@
+<template>
+<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
+ <XSidebar ref="menu" class="menu" :default-hidden="true"/>
+
+ <div class="nav">
+ <header class="header">
+ <div class="left">
+ <button class="_button account" @click="openAccountMenu">
+ <MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>-->
+ </button>
+ </div>
+ <div class="right">
+ <MkA class="item" to="/my/messaging" v-tooltip="$ts.messaging"><i class="fas fa-comments icon"></i><span v-if="$i.hasUnreadMessagingMessage" class="indicator"><i class="fas fa-circle"></i></span></MkA>
+ <MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><i class="fas fa-envelope icon"></i><span v-if="$i.hasUnreadSpecifiedNotes" class="indicator"><i class="fas fa-circle"></i></span></MkA>
+ <MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><i class="fas fa-at icon"></i><span v-if="$i.hasUnreadMentions" class="indicator"><i class="fas fa-circle"></i></span></MkA>
+ <MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><i class="fas fa-bell icon"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></MkA>
+ </div>
+ </header>
+ <div class="body">
+ <div class="container">
+ <div class="header">{{ $ts.timeline }}</div>
+ <div class="body">
+ <MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><i class="fas fa-home icon"></i>{{ $ts._timelines.home }}</MkA>
+ <MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><i class="fas fa-comments icon"></i>{{ $ts._timelines.local }}</MkA>
+ <MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><i class="fas fa-share-alt icon"></i>{{ $ts._timelines.social }}</MkA>
+ <MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><i class="fas fa-globe icon"></i>{{ $ts._timelines.global }}</MkA>
+ </div>
+ </div>
+ <div class="container" v-if="followedChannels">
+ <div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div>
+ <div class="body">
+ <MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA>
+ </div>
+ </div>
+ <div class="container" v-if="featuredChannels">
+ <div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><i class="fas fa-plus"></i></button></div>
+ <div class="body">
+ <MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><i class="fas fa-satellite-dish icon"></i>{{ channel.name }}</MkA>
+ </div>
+ </div>
+ <div class="container" v-if="lists">
+ <div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><i class="fas fa-plus"></i></button></div>
+ <div class="body">
+ <MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><i class="fas fa-list-ul icon"></i>{{ list.name }}</MkA>
+ </div>
+ </div>
+ <div class="container" v-if="antennas">
+ <div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><i class="fas fa-plus"></i></button></div>
+ <div class="body">
+ <MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><i class="fas fa-satellite icon"></i>{{ antenna.name }}</MkA>
+ </div>
+ </div>
+ <div class="container">
+ <div class="body">
+ <MkA to="/my/favorites" class="item"><i class="fas fa-star icon"></i>{{ $ts.favorites }}</MkA>
+ </div>
+ </div>
+ <MkAd class="a" :prefer="['square']"/>
+ </div>
+ <footer class="footer">
+ <div class="left">
+ <button class="_button menu" @click="showMenu">
+ <i class="fas fa-bars icon"></i>
+ </button>
+ </div>
+ <div class="right">
+ <button class="_button item search" @click="search" v-tooltip="$ts.search">
+ <i class="fas fa-search icon"></i>
+ </button>
+ <MkA class="item" to="/settings" v-tooltip="$ts.settings"><i class="fas fa-cog icon"></i></MkA>
+ </div>
+ </footer>
+ </div>
+
+ <main class="main" @contextmenu.stop="onContextmenu">
+ <header class="header">
+ <MkHeader class="header" :info="pageInfo" :menu="menu" :center="false" @click="onHeaderClick"/>
+ </header>
+ <router-view v-slot="{ Component }">
+ <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+ <keep-alive :include="['timeline']">
+ <component :is="Component" :ref="changePage" class="body"/>
+ </keep-alive>
+ </transition>
+ </router-view>
+ </main>
+
+ <XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/>
+ <div class="side widgets" :class="{ sideViewOpening }">
+ <XWidgets/>
+ </div>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { instanceName, url } from '@/config';
+import XSidebar from '@/ui/_common_/sidebar.vue';
+import XWidgets from './widgets.vue';
+import XCommon from '../_common_/common.vue';
+import XSide from './side.vue';
+import XHeaderClock from './header-clock.vue';
+import * as os from '@/os';
+import { router } from '@/router';
+import { menuDef } from '@/menu';
+import { search } from '@/scripts/search';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { store } from './store';
+import * as symbols from '@/symbols';
+import { openAccountMenu } from '@/account';
+
+export default defineComponent({
+ components: {
+ XCommon,
+ XSidebar,
+ XWidgets,
+ XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
+ XHeaderClock,
+ },
+
+ provide() {
+ return {
+ sideViewHook: (path) => {
+ this.$refs.side.navigate(path);
+ }
+ };
+ },
+
+ data() {
+ return {
+ pageInfo: null,
+ lists: null,
+ antennas: null,
+ followedChannels: null,
+ featuredChannels: null,
+ currentChannel: null,
+ menuDef: menuDef,
+ sideViewOpening: false,
+ instanceName,
+ };
+ },
+
+ computed: {
+ menu() {
+ return [{
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.$refs.side.navigate(this.$route.path);
+ }
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(this.$route.path);
+ }
+ }];
+ }
+ },
+
+ created() {
+ if (window.innerWidth < 1024) {
+ localStorage.setItem('ui', 'default');
+ location.reload();
+ }
+
+ os.api('users/lists/list').then(lists => {
+ this.lists = lists;
+ });
+
+ os.api('antennas/list').then(antennas => {
+ this.antennas = antennas;
+ });
+
+ os.api('channels/followed', { limit: 20 }).then(channels => {
+ this.followedChannels = channels;
+ });
+
+ // TODO: pagination
+ os.api('channels/featured', { limit: 20 }).then(channels => {
+ this.featuredChannels = channels;
+ });
+ },
+
+ methods: {
+ changePage(page) {
+ console.log(page);
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ document.title = `${this.pageInfo.title} | ${instanceName}`;
+ }
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+
+ showMenu() {
+ this.$refs.menu.show();
+ },
+
+ post() {
+ os.post();
+ },
+
+ search() {
+ search();
+ },
+
+ back() {
+ history.back();
+ },
+
+ top() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+
+ onHeaderClick() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+ if (window.getSelection().toString() !== '') return;
+ const path = this.$route.path;
+ os.contextMenu([{
+ type: 'label',
+ text: path,
+ }, {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.$refs.side.navigate(path);
+ }
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(path);
+ }
+ }], e);
+ },
+
+ openAccountMenu,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+ $header-height: 54px; // TODO: どこかに集約したい
+ $ui-font-size: 1em; // TODO: どこかに集約したい
+
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ height: calc(var(--vh, 1vh) * 100);
+ display: flex;
+
+ > .nav {
+ display: flex;
+ flex-direction: column;
+ width: 250px;
+ height: 100vh;
+ border-right: solid 4px var(--divider);
+
+ > .header, > .footer {
+ $padding: 8px;
+ display: flex;
+ align-items: center;
+ z-index: 1000;
+ height: $header-height;
+ padding: $padding;
+ box-sizing: border-box;
+ user-select: none;
+
+ &.header {
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ &.footer {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .left, > .right {
+ > .item, > .menu {
+ display: inline-flex;
+ vertical-align: middle;
+ height: ($header-height - ($padding * 2));
+ width: ($header-height - ($padding * 2));
+ box-sizing: border-box;
+ //opacity: 0.6;
+ position: relative;
+ border-radius: 5px;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ > .icon {
+ margin: auto;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ color: var(--indicator);
+ font-size: 8px;
+ line-height: 8px;
+ animation: blink 1s infinite;
+ }
+ }
+ }
+
+ > .left {
+ flex: 1;
+ min-width: 0;
+
+ > .account {
+ display: flex;
+ align-items: center;
+ padding: 0 8px;
+
+ > .avatar {
+ width: 26px;
+ height: 26px;
+ margin-right: 8px;
+ }
+
+ > .text {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ font-size: 0.9em;
+ }
+ }
+ }
+
+ > .right {
+ margin-left: auto;
+ }
+ }
+
+ > .body {
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+
+ > .container {
+ margin-top: 8px;
+ margin-bottom: 8px;
+
+ & + .container {
+ margin-top: 16px;
+ }
+
+ > .header {
+ display: flex;
+ font-size: 0.9em;
+ padding: 8px 16px;
+ position: sticky;
+ top: 0;
+ background: var(--X17);
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ z-index: 1;
+ color: var(--fgTransparentWeak);
+
+ > .add {
+ margin-left: auto;
+ color: var(--fgTransparentWeak);
+
+ &:hover {
+ color: var(--fg);
+ }
+ }
+ }
+
+ > .body {
+ padding: 0 8px;
+
+ > .item {
+ display: block;
+ padding: 6px 8px;
+ border-radius: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover {
+ text-decoration: none;
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &.active, &.active:hover {
+ background: var(--accent);
+ color: #fff !important;
+ }
+
+ &.read {
+ color: var(--fgTransparent);
+ }
+
+ > .icon {
+ margin-right: 8px;
+ opacity: 0.6;
+ }
+ }
+ }
+ }
+
+ > .a {
+ margin: 12px;
+ }
+ }
+ }
+
+ > .main {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ min-width: 0;
+ height: 100vh;
+ position: relative;
+ background: var(--panel);
+
+ > .header {
+ z-index: 1000;
+ height: $header-height;
+ background-color: var(--panel);
+ border-bottom: solid 0.5px var(--divider);
+ user-select: none;
+ }
+
+ > .body {
+ width: 100%;
+ box-sizing: border-box;
+ overflow: auto;
+ }
+ }
+
+ > .side {
+ width: 350px;
+ border-left: solid 4px var(--divider);
+ background: var(--panel);
+
+ &.widgets.sideViewOpening {
+ @media (max-width: 1400px) {
+ display: none;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/note-header.vue b/packages/client/src/ui/chat/note-header.vue
new file mode 100644
index 0000000000..8ab03501b2
--- /dev/null
+++ b/packages/client/src/ui/chat/note-header.vue
@@ -0,0 +1,112 @@
+<template>
+<header class="dehvdgxo">
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ <span class="is-bot" v-if="note.user.isBot">bot</span>
+ <span class="username"><MkAcct :user="note.user"/></span>
+ <span class="admin" v-if="note.user.isAdmin"><i class="fas fa-bookmark"></i></span>
+ <span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><i class="far fa-bookmark"></i></span>
+ <div class="info">
+ <span class="mobile" v-if="note.viaMobile"><i class="fas fa-mobile-alt"></i></span>
+ <MkA class="created-at" :to="notePage(note)">
+ <MkTime :time="note.createdAt"/>
+ </MkA>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+</header>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import notePage from '@/filters/note';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ notePage,
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.dehvdgxo {
+ display: flex;
+ align-items: baseline;
+ white-space: nowrap;
+ font-size: 0.9em;
+
+ > .name {
+ display: block;
+ margin: 0 .5em 0 0;
+ padding: 0;
+ overflow: hidden;
+ font-size: 1em;
+ font-weight: bold;
+ text-decoration: none;
+ text-overflow: ellipsis;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ > .is-bot {
+ flex-shrink: 0;
+ align-self: center;
+ margin: 0 .5em 0 0;
+ padding: 1px 6px;
+ font-size: 80%;
+ border: solid 0.5px var(--divider);
+ border-radius: 3px;
+ }
+
+ > .admin,
+ > .moderator {
+ margin-right: 0.5em;
+ color: var(--badge);
+ }
+
+ > .username {
+ margin: 0 .5em 0 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .info {
+ font-size: 0.9em;
+ opacity: 0.7;
+
+ > .mobile {
+ margin-right: 8px;
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/note-preview.vue b/packages/client/src/ui/chat/note-preview.vue
new file mode 100644
index 0000000000..2a08a3d7f5
--- /dev/null
+++ b/packages/client/src/ui/chat/note-preview.vue
@@ -0,0 +1,112 @@
+<template>
+<div class="hduudsxk">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <div class="main">
+ <XNoteHeader class="header" :note="note" :mini="true"/>
+ <div class="body">
+ <p v-if="note.cw != null" class="cw">
+ <span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
+ <XCwButton v-model="showContent" :note="note"/>
+ </p>
+ <div class="content" v-show="note.cw == null || showContent">
+ <XSubNote-content class="text" :note="note"/>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from '@/components/cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XNoteHeader,
+ XSubNoteContent,
+ XCwButton,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ showContent: false
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hduudsxk {
+ display: flex;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ font-size: 0.95em;
+
+ > .avatar {
+
+ @media (min-width: 350px) {
+ margin: 0 10px 0 0;
+ width: 44px;
+ height: 44px;
+ }
+
+ @media (min-width: 500px) {
+ margin: 0 12px 0 0;
+ width: 48px;
+ height: 48px;
+ }
+ }
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 10px 0 0;
+ width: 40px;
+ height: 40px;
+ border-radius: 8px;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .header {
+ margin-bottom: 2px;
+ }
+
+ > .body {
+
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ cursor: default;
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/note.sub.vue b/packages/client/src/ui/chat/note.sub.vue
new file mode 100644
index 0000000000..75d9d98088
--- /dev/null
+++ b/packages/client/src/ui/chat/note.sub.vue
@@ -0,0 +1,137 @@
+<template>
+<div class="wrpstxzv" :class="{ children }">
+ <div class="main">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <div class="body">
+ <XNoteHeader class="header" :note="note" :mini="true"/>
+ <div class="body">
+ <p v-if="note.cw != null" class="cw">
+ <Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis" />
+ <XCwButton v-model="showContent" :note="note"/>
+ </p>
+ <div class="content" v-show="note.cw == null || showContent">
+ <XSubNote-content class="text" :note="note"/>
+ </div>
+ </div>
+ </div>
+ </div>
+ <XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XNoteHeader from './note-header.vue';
+import XSubNoteContent from './sub-note-content.vue';
+import XCwButton from '@/components/cw-button.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ name: 'XSub',
+
+ components: {
+ XNoteHeader,
+ XSubNoteContent,
+ XCwButton,
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ detail: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ children: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ // TODO
+ truncate: {
+ type: Boolean,
+ default: true
+ }
+ },
+
+ data() {
+ return {
+ showContent: false,
+ replies: [],
+ };
+ },
+
+ created() {
+ if (this.detail) {
+ os.api('notes/children', {
+ noteId: this.note.id,
+ limit: 5
+ }).then(replies => {
+ this.replies = replies;
+ });
+ }
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.wrpstxzv {
+ padding: 16px 16px;
+ font-size: 0.8em;
+
+ &.children {
+ padding: 10px 0 0 16px;
+ font-size: 1em;
+ }
+
+ > .main {
+ display: flex;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ margin: 0 8px 0 0;
+ width: 36px;
+ height: 36px;
+ }
+
+ > .body {
+ flex: 1;
+ min-width: 0;
+
+ > .header {
+ margin-bottom: 2px;
+ }
+
+ > .body {
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ > .text {
+ margin: 0;
+ padding: 0;
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-left: solid 0.5px var(--divider);
+ margin-top: 10px;
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/note.vue b/packages/client/src/ui/chat/note.vue
new file mode 100644
index 0000000000..c0b5cebd94
--- /dev/null
+++ b/packages/client/src/ui/chat/note.vue
@@ -0,0 +1,1144 @@
+<template>
+<div
+ class="vfzoeqcg"
+ v-if="!muted"
+ v-show="!isDeleted"
+ :tabindex="!isDeleted ? '-1' : null"
+ :class="{ renote: isRenote, highlighted: appearNote._prId_ || appearNote._featuredId_, operating }"
+ v-hotkey="keymap"
+>
+ <XSub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
+ <div class="info" v-if="pinned"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div>
+ <div class="info" v-if="appearNote._prId_"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div>
+ <div class="info" v-if="appearNote._featuredId_"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
+ <div class="renote" v-if="isRenote">
+ <MkAvatar class="avatar" :user="note.user"/>
+ <i class="fas fa-retweet"></i>
+ <I18n :src="$ts.renotedBy" tag="span">
+ <template #user>
+ <MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
+ <MkUserName :user="note.user"/>
+ </MkA>
+ </template>
+ </I18n>
+ <div class="info">
+ <button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
+ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
+ <MkTime :time="note.createdAt"/>
+ </button>
+ <span class="visibility" v-if="note.visibility !== 'public'">
+ <i v-if="note.visibility === 'home'" class="fas fa-home"></i>
+ <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
+ <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
+ </span>
+ <span class="localOnly" v-if="note.localOnly"><i class="fas fa-biohazard"></i></span>
+ </div>
+ </div>
+ <article class="article" @contextmenu.stop="onContextmenu">
+ <MkAvatar class="avatar" :user="appearNote.user"/>
+ <div class="main">
+ <XNoteHeader class="header" :note="appearNote" :mini="true"/>
+ <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
+ <div class="body">
+ <p v-if="appearNote.cw != null" class="cw">
+ <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <XCwButton v-model="showContent" :note="appearNote"/>
+ </p>
+ <div class="content" :class="{ collapsed }" v-show="appearNote.cw == null || showContent">
+ <div class="text">
+ <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <MkA class="reply" v-if="appearNote.replyId" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
+ <a class="rp" v-if="appearNote.renote != null">RN:</a>
+ </div>
+ <div class="files" v-if="appearNote.files.length > 0">
+ <XMediaList :media-list="appearNote.files"/>
+ </div>
+ <XPoll v-if="appearNote.poll" :note="appearNote" ref="pollViewer" class="poll"/>
+ <MkUrlPreview v-for="url in urls" :url="url" :key="url" :compact="true" :detail="false" class="url-preview"/>
+ <div class="renote" v-if="appearNote.renote"><XNoteSimple :note="appearNote.renote"/></div>
+ <button v-if="collapsed" class="fade _button" @click="collapsed = false">
+ <span>{{ $ts.showMore }}</span>
+ </button>
+ </div>
+ <MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
+ </div>
+ <XReactionsViewer :note="appearNote" ref="reactionsViewer"/>
+ <footer class="footer _panel">
+ <button @click="reply()" class="button _button" v-tooltip="$ts.reply">
+ <template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template>
+ <template v-else><i class="fas fa-reply"></i></template>
+ <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
+ </button>
+ <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton" v-tooltip="$ts.renote">
+ <i class="fas fa-retweet"></i><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
+ </button>
+ <button v-else class="button _button">
+ <i class="fas fa-ban"></i>
+ </button>
+ <button v-if="appearNote.myReaction == null" class="button _button" @click="react()" ref="reactButton" v-tooltip="$ts.reaction">
+ <i class="fas fa-plus"></i>
+ </button>
+ <button v-if="appearNote.myReaction != null" class="button _button reacted" @click="undoReact(appearNote)" ref="reactButton" v-tooltip="$ts.reaction">
+ <i class="fas fa-minus"></i>
+ </button>
+ <button class="button _button" @click="menu()" ref="menuButton">
+ <i class="fas fa-ellipsis-h"></i>
+ </button>
+ </footer>
+ </div>
+ </article>
+</div>
+<div v-else class="muted" @click="muted = false">
+ <I18n :src="$ts.userSaysSomething" tag="small">
+ <template #name>
+ <MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
+ <MkUserName :user="appearNote.user"/>
+ </MkA>
+ </template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
+import * as mfm from 'mfm-js';
+import { sum } from '@/scripts/array';
+import XSub from './note.sub.vue';
+import XNoteHeader from './note-header.vue';
+import XNoteSimple from './note-preview.vue';
+import XReactionsViewer from '@/components/reactions-viewer.vue';
+import XMediaList from '@/components/media-list.vue';
+import XCwButton from '@/components/cw-button.vue';
+import XPoll from '@/components/poll.vue';
+import { pleaseLogin } from '@/scripts/please-login';
+import { focusPrev, focusNext } from '@/scripts/focus';
+import { url } from '@/config';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { checkWordMute } from '@/scripts/check-word-mute';
+import { userPage } from '@/filters/user';
+import * as os from '@/os';
+import { noteActions, noteViewInterruptors } from '@/store';
+import { reactionPicker } from '@/scripts/reaction-picker';
+import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
+
+export default defineComponent({
+ components: {
+ XSub,
+ XNoteHeader,
+ XNoteSimple,
+ XReactionsViewer,
+ XMediaList,
+ XCwButton,
+ XPoll,
+ MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
+ MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
+ },
+
+ inject: {
+ inChannel: {
+ default: null
+ },
+ },
+
+ props: {
+ note: {
+ type: Object,
+ required: true
+ },
+ pinned: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['update:note'],
+
+ data() {
+ return {
+ connection: null,
+ replies: [],
+ showContent: false,
+ collapsed: false,
+ isDeleted: false,
+ muted: false,
+ operating: false,
+ };
+ },
+
+ computed: {
+ rs() {
+ return this.$store.state.reactions;
+ },
+ keymap(): any {
+ return {
+ 'r': () => this.reply(true),
+ 'e|a|plus': () => this.react(true),
+ 'q': () => this.renote(true),
+ 'f|b': this.favorite,
+ 'delete|ctrl+d': this.del,
+ 'ctrl+q': this.renoteDirectly,
+ 'up|k|shift+tab': this.focusBefore,
+ 'down|j|tab': this.focusAfter,
+ 'esc': this.blur,
+ 'm|o': () => this.menu(true),
+ 's': this.toggleShowContent,
+ '1': () => this.reactDirectly(this.rs[0]),
+ '2': () => this.reactDirectly(this.rs[1]),
+ '3': () => this.reactDirectly(this.rs[2]),
+ '4': () => this.reactDirectly(this.rs[3]),
+ '5': () => this.reactDirectly(this.rs[4]),
+ '6': () => this.reactDirectly(this.rs[5]),
+ '7': () => this.reactDirectly(this.rs[6]),
+ '8': () => this.reactDirectly(this.rs[7]),
+ '9': () => this.reactDirectly(this.rs[8]),
+ '0': () => this.reactDirectly(this.rs[9]),
+ };
+ },
+
+ isRenote(): boolean {
+ return (this.note.renote &&
+ this.note.text == null &&
+ this.note.fileIds.length == 0 &&
+ this.note.poll == null);
+ },
+
+ appearNote(): any {
+ return this.isRenote ? this.note.renote : this.note;
+ },
+
+ isMyNote(): boolean {
+ return this.$i && (this.$i.id === this.appearNote.userId);
+ },
+
+ isMyRenote(): boolean {
+ return this.$i && (this.$i.id === this.note.userId);
+ },
+
+ canRenote(): boolean {
+ return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
+ },
+
+ reactionsCount(): number {
+ return this.appearNote.reactions
+ ? sum(Object.values(this.appearNote.reactions))
+ : 0;
+ },
+
+ urls(): string[] {
+ if (this.appearNote.text) {
+ return extractUrlFromMfm(mfm.parse(this.appearNote.text));
+ } else {
+ return null;
+ }
+ },
+
+ showTicker() {
+ if (this.$store.state.instanceTicker === 'always') return true;
+ if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
+ return false;
+ }
+ },
+
+ async created() {
+ if (this.$i) {
+ this.connection = os.stream;
+ }
+
+ this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
+ (this.appearNote.text.split('\n').length > 9) ||
+ (this.appearNote.text.length > 500)
+ );
+ this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
+
+ // plugin
+ if (noteViewInterruptors.length > 0) {
+ let result = this.note;
+ for (const interruptor of noteViewInterruptors) {
+ result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
+ }
+ this.$emit('update:note', Object.freeze(result));
+ }
+ },
+
+ mounted() {
+ this.capture(true);
+
+ if (this.$i) {
+ this.connection.on('_connected_', this.onStreamConnected);
+ }
+ },
+
+ beforeUnmount() {
+ this.decapture(true);
+
+ if (this.$i) {
+ this.connection.off('_connected_', this.onStreamConnected);
+ }
+ },
+
+ methods: {
+ updateAppearNote(v) {
+ this.$emit('update:note', Object.freeze(this.isRenote ? {
+ ...this.note,
+ renote: {
+ ...this.note.renote,
+ ...v
+ }
+ } : {
+ ...this.note,
+ ...v
+ }));
+ },
+
+ readPromo() {
+ os.api('promo/read', {
+ noteId: this.appearNote.id
+ });
+ this.isDeleted = true;
+ },
+
+ capture(withHandler = false) {
+ if (this.$i) {
+ // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
+ this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
+ if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ decapture(withHandler = false) {
+ if (this.$i) {
+ this.connection.send('un', {
+ id: this.appearNote.id
+ });
+ if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
+ }
+ },
+
+ onStreamConnected() {
+ this.capture();
+ },
+
+ onStreamNoteUpdated(data) {
+ const { type, id, body } = data;
+
+ if (id !== this.appearNote.id) return;
+
+ switch (type) {
+ case 'reacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ if (body.emoji) {
+ const emojis = this.appearNote.emojis || [];
+ if (!emojis.includes(body.emoji)) {
+ n.emojis = [...emojis, body.emoji];
+ }
+ }
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Increment the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: currentCount + 1
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = reaction;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'unreacted': {
+ const reaction = body.reaction;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる
+ const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
+
+ // Decrement the count
+ n.reactions = {
+ ...this.appearNote.reactions,
+ [reaction]: Math.max(0, currentCount - 1)
+ };
+
+ if (body.userId === this.$i.id) {
+ n.myReaction = null;
+ }
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'pollVoted': {
+ const choice = body.choice;
+
+ // DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
+ let n = {
+ ...this.appearNote,
+ };
+
+ const choices = [...this.appearNote.poll.choices];
+ choices[choice] = {
+ ...choices[choice],
+ votes: choices[choice].votes + 1,
+ ...(body.userId === this.$i.id ? {
+ isVoted: true
+ } : {})
+ };
+
+ n.poll = {
+ ...this.appearNote.poll,
+ choices: choices
+ };
+
+ this.updateAppearNote(n);
+ break;
+ }
+
+ case 'deleted': {
+ this.isDeleted = true;
+ break;
+ }
+ }
+ },
+
+ reply(viaKeyboard = false) {
+ pleaseLogin();
+ this.operating = true;
+ os.post({
+ reply: this.appearNote,
+ animation: !viaKeyboard,
+ }, () => {
+ this.operating = false;
+ this.focus();
+ });
+ },
+
+ renote(viaKeyboard = false) {
+ pleaseLogin();
+ this.operating = true;
+ this.blur();
+ os.popupMenu([{
+ text: this.$ts.renote,
+ icon: 'fas fa-retweet',
+ action: () => {
+ os.api('notes/create', {
+ renoteId: this.appearNote.id
+ });
+ }
+ }, {
+ text: this.$ts.quote,
+ icon: 'fas fa-quote-right',
+ action: () => {
+ os.post({
+ renote: this.appearNote,
+ });
+ }
+ }], this.$refs.renoteButton, {
+ viaKeyboard
+ }).then(() => {
+ this.operating = false;
+ });
+ },
+
+ renoteDirectly() {
+ os.apiWithDialog('notes/create', {
+ renoteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.renoted,
+ });
+ }, (e: Error) => {
+ if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantRenote,
+ });
+ } else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantReRenote,
+ });
+ }
+ });
+ },
+
+ async react(viaKeyboard = false) {
+ pleaseLogin();
+ this.operating = true;
+ this.blur();
+ reactionPicker.show(this.$refs.reactButton, reaction => {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ }, () => {
+ this.operating = false;
+ this.focus();
+ });
+ },
+
+ reactDirectly(reaction) {
+ os.api('notes/reactions/create', {
+ noteId: this.appearNote.id,
+ reaction: reaction
+ });
+ },
+
+ undoReact(note) {
+ const oldReaction = note.myReaction;
+ if (!oldReaction) return;
+ os.api('notes/reactions/delete', {
+ noteId: note.id
+ });
+ },
+
+ favorite() {
+ pleaseLogin();
+ os.apiWithDialog('notes/favorites/create', {
+ noteId: this.appearNote.id
+ }, undefined, (res: any) => {
+ os.dialog({
+ type: 'success',
+ text: this.$ts.favorited,
+ });
+ }, (e: Error) => {
+ if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.alreadyFavorited,
+ });
+ } else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.cantFavorite,
+ });
+ }
+ });
+ },
+
+ del() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.noteDeleteConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+ });
+ },
+
+ delEdit() {
+ os.dialog({
+ type: 'warning',
+ text: this.$ts.deleteAndEditConfirm,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) return;
+
+ os.api('notes/delete', {
+ noteId: this.appearNote.id
+ });
+
+ os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
+ });
+ },
+
+ toggleFavorite(favorite: boolean) {
+ os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ toggleWatch(watch: boolean) {
+ os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
+ noteId: this.appearNote.id
+ });
+ },
+
+ getMenu() {
+ let menu;
+ if (this.$i) {
+ const statePromise = os.api('notes/state', {
+ noteId: this.appearNote.id
+ });
+
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined,
+ {
+ icon: 'fas fa-share-alt',
+ text: this.$ts.share,
+ action: this.share
+ },
+ null,
+ statePromise.then(state => state.isFavorited ? {
+ icon: 'fas fa-star',
+ text: this.$ts.unfavorite,
+ action: () => this.toggleFavorite(false)
+ } : {
+ icon: 'fas fa-star',
+ text: this.$ts.favorite,
+ action: () => this.toggleFavorite(true)
+ }),
+ {
+ icon: 'fas fa-paperclip',
+ text: this.$ts.clip,
+ action: () => this.clip()
+ },
+ (this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
+ icon: 'fas fa-eye-slash',
+ text: this.$ts.unwatch,
+ action: () => this.toggleWatch(false)
+ } : {
+ icon: 'fas fa-eye',
+ text: this.$ts.watch,
+ action: () => this.toggleWatch(true)
+ }) : undefined,
+ this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.unpin,
+ action: () => this.togglePin(false)
+ } : {
+ icon: 'fas fa-thumbtack',
+ text: this.$ts.pin,
+ action: () => this.togglePin(true)
+ } : undefined,
+ ...(this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ {
+ icon: 'fas fa-bullhorn',
+ text: this.$ts.promote,
+ action: this.promote
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId != this.$i.id ? [
+ null,
+ {
+ icon: 'fas fa-exclamation-circle',
+ text: this.$ts.reportAbuse,
+ action: () => {
+ const u = `${url}/notes/${this.appearNote.id}`;
+ os.popup(import('@/components/abuse-report-window.vue'), {
+ user: this.appearNote.user,
+ initialComment: `Note: ${u}\n-----\n`
+ }, {}, 'closed');
+ }
+ }]
+ : []
+ ),
+ ...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
+ null,
+ this.appearNote.userId == this.$i.id ? {
+ icon: 'fas fa-edit',
+ text: this.$ts.deleteAndEdit,
+ action: this.delEdit
+ } : undefined,
+ {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.delete,
+ danger: true,
+ action: this.del
+ }]
+ : []
+ )]
+ .filter(x => x !== undefined);
+ } else {
+ menu = [{
+ icon: 'fas fa-copy',
+ text: this.$ts.copyContent,
+ action: this.copyContent
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: this.copyLink
+ }, (this.appearNote.url || this.appearNote.uri) ? {
+ icon: 'fas fa-external-link-square-alt',
+ text: this.$ts.showOnRemote,
+ action: () => {
+ window.open(this.appearNote.url || this.appearNote.uri, '_blank');
+ }
+ } : undefined]
+ .filter(x => x !== undefined);
+ }
+
+ if (noteActions.length > 0) {
+ menu = menu.concat([null, ...noteActions.map(action => ({
+ icon: 'fas fa-plug',
+ text: action.title,
+ action: () => {
+ action.handler(this.appearNote);
+ }
+ }))]);
+ }
+
+ return menu;
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (window.getSelection().toString() !== '') return;
+
+ if (this.$store.state.useReactionPickerForContextMenu) {
+ e.preventDefault();
+ this.react();
+ } else {
+ os.contextMenu(this.getMenu(), e).then(this.focus);
+ }
+ },
+
+ menu(viaKeyboard = false) {
+ this.operating = true;
+ os.popupMenu(this.getMenu(), this.$refs.menuButton, {
+ viaKeyboard
+ }).then(() => {
+ this.operating = false;
+ this.focus();
+ });
+ },
+
+ showRenoteMenu(viaKeyboard = false) {
+ if (!this.isMyRenote) return;
+ os.popupMenu([{
+ text: this.$ts.unrenote,
+ icon: 'fas fa-trash-alt',
+ danger: true,
+ action: () => {
+ os.api('notes/delete', {
+ noteId: this.note.id
+ });
+ this.isDeleted = true;
+ }
+ }], this.$refs.renoteTime, {
+ viaKeyboard: viaKeyboard
+ });
+ },
+
+ toggleShowContent() {
+ this.showContent = !this.showContent;
+ },
+
+ copyContent() {
+ copyToClipboard(this.appearNote.text);
+ os.success();
+ },
+
+ copyLink() {
+ copyToClipboard(`${url}/notes/${this.appearNote.id}`);
+ os.success();
+ },
+
+ togglePin(pin: boolean) {
+ os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
+ noteId: this.appearNote.id
+ }, undefined, null, e => {
+ if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
+ os.dialog({
+ type: 'error',
+ text: this.$ts.pinLimitExceeded
+ });
+ }
+ });
+ },
+
+ async clip() {
+ const clips = await os.api('clips/list');
+ os.popupMenu([{
+ icon: 'fas fa-plus',
+ text: this.$ts.createNew,
+ action: async () => {
+ const { canceled, result } = await os.form(this.$ts.createNewClip, {
+ name: {
+ type: 'string',
+ label: this.$ts.name
+ },
+ description: {
+ type: 'string',
+ required: false,
+ multiline: true,
+ label: this.$ts.description
+ },
+ isPublic: {
+ type: 'boolean',
+ label: this.$ts.public,
+ default: false
+ }
+ });
+ if (canceled) return;
+
+ const clip = await os.apiWithDialog('clips/create', result);
+
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }, null, ...clips.map(clip => ({
+ text: clip.name,
+ action: () => {
+ os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
+ }
+ }))], this.$refs.menuButton, {
+ }).then(this.focus);
+ },
+
+ async promote() {
+ const { canceled, result: days } = await os.dialog({
+ title: this.$ts.numberOfDays,
+ input: { type: 'number' }
+ });
+
+ if (canceled) return;
+
+ os.apiWithDialog('admin/promo/create', {
+ noteId: this.appearNote.id,
+ expiresAt: Date.now() + (86400000 * days)
+ });
+ },
+
+ share() {
+ navigator.share({
+ title: this.$t('noteOf', { user: this.appearNote.user.name }),
+ text: this.appearNote.text,
+ url: `${url}/notes/${this.appearNote.id}`
+ });
+ },
+
+ focus() {
+ this.$el.focus();
+ },
+
+ blur() {
+ this.$el.blur();
+ },
+
+ focusBefore() {
+ focusPrev(this.$el);
+ },
+
+ focusAfter() {
+ focusNext(this.$el);
+ },
+
+ userPage
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vfzoeqcg {
+ position: relative;
+ contain: content;
+
+ // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、
+ // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう
+ // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、
+ // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
+ // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
+ //content-visibility: auto;
+ //contain-intrinsic-size: 0 128px;
+
+ &:focus-visible {
+ outline: none;
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ &:hover, &.operating {
+ > .article > .main > .footer {
+ display: block;
+ }
+ }
+
+ &.renote {
+ background: rgba(128, 255, 0, 0.05);
+ }
+
+ &.highlighted {
+ background: rgba(255, 128, 0, 0.05);
+ }
+
+ > .info {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px 4px 16px;
+ line-height: 24px;
+ font-size: 85%;
+ white-space: pre;
+ color: #d28a3f;
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > .hide {
+ margin-left: 16px;
+ color: inherit;
+ opacity: 0.7;
+ }
+ }
+
+ > .info + .article {
+ padding-top: 8px;
+ }
+
+ > .reply-to {
+ opacity: 0.7;
+ padding-bottom: 0;
+ }
+
+ > .renote {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px 4px 16px;
+ line-height: 28px;
+ white-space: pre;
+ color: var(--renote);
+ font-size: 0.9em;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: inline-block;
+ width: 28px;
+ height: 28px;
+ margin: 0 8px 0 0;
+ border-radius: 6px;
+ }
+
+ > i {
+ margin-right: 4px;
+ }
+
+ > span {
+ overflow: hidden;
+ flex-shrink: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ > .name {
+ font-weight: bold;
+ }
+ }
+
+ > .info {
+ margin-left: 8px;
+ font-size: 0.9em;
+ opacity: 0.7;
+
+ > .time {
+ flex-shrink: 0;
+ color: inherit;
+
+ > .dropdownIcon {
+ margin-right: 4px;
+ }
+ }
+
+ > .visibility {
+ margin-left: 8px;
+ }
+
+ > .localOnly {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > .renote + .article {
+ padding-top: 8px;
+ }
+
+ > .article {
+ display: flex;
+ padding: 12px 16px;
+
+ > .avatar {
+ flex-shrink: 0;
+ display: block;
+ position: sticky;
+ top: 0;
+ margin: 0 14px 0 0;
+ width: 46px;
+ height: 46px;
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .body {
+ > .cw {
+ cursor: default;
+ display: block;
+ margin: 0;
+ padding: 0;
+ overflow-wrap: break-word;
+
+ > .text {
+ margin-right: 8px;
+ }
+ }
+
+ > .content {
+ &.collapsed {
+ position: relative;
+ max-height: 9em;
+ overflow: hidden;
+
+ > .fade {
+ display: block;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 64px;
+ background: linear-gradient(0deg, var(--panel), var(--X15));
+
+ > span {
+ display: inline-block;
+ background: var(--panel);
+ padding: 6px 10px;
+ font-size: 0.8em;
+ border-radius: 999px;
+ box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
+ }
+
+ &:hover {
+ > span {
+ background: var(--panelHighlight);
+ }
+ }
+ }
+ }
+
+ > .text {
+ overflow-wrap: break-word;
+
+ > .reply {
+ color: var(--accent);
+ margin-right: 0.5em;
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+ }
+
+ > .files {
+ max-width: 500px;
+ }
+
+ > .url-preview {
+ margin-top: 8px;
+ max-width: 500px;
+ }
+
+ > .poll {
+ font-size: 80%;
+ max-width: 500px;
+ }
+
+ > .renote {
+ padding: 8px 0;
+
+ > * {
+ padding: 16px;
+ border: dashed 1px var(--renote);
+ border-radius: 8px;
+ }
+ }
+ }
+
+ > .channel {
+ opacity: 0.7;
+ font-size: 80%;
+ }
+ }
+
+ > .footer {
+ display: none;
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ padding: 0 6px;
+ opacity: 0.7;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ > .button {
+ margin: 0;
+ padding: 8px;
+ opacity: 0.7;
+
+ &:hover {
+ color: var(--accent);
+ }
+
+ > .count {
+ display: inline;
+ margin: 0 0 0 8px;
+ opacity: 0.7;
+ }
+
+ &.reacted {
+ color: var(--accent);
+ }
+ }
+ }
+ }
+ }
+
+ > .reply {
+ border-top: solid 0.5px var(--divider);
+ }
+}
+
+.muted {
+ padding: 8px 16px;
+ opacity: 0.7;
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/notes.vue b/packages/client/src/ui/chat/notes.vue
new file mode 100644
index 0000000000..9103f717e6
--- /dev/null
+++ b/packages/client/src/ui/chat/notes.vue
@@ -0,0 +1,94 @@
+<template>
+<div class="">
+ <div class="_fullinfo" v-if="empty">
+ <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
+ <div>{{ $ts.noNotes }}</div>
+ </div>
+
+ <MkLoading v-if="fetching"/>
+
+ <MkError v-if="error" @retry="init()"/>
+
+ <div v-show="more && reversed" style="margin-bottom: var(--margin);">
+ <MkButton style="margin: 0 auto;" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+
+ <XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :ad="true">
+ <XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
+ </XList>
+
+ <div v-show="more && !reversed" style="margin-top: var(--margin);">
+ <MkButton style="margin: 0 auto;" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
+ <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
+ <template v-if="moreFetching"><MkLoading inline/></template>
+ </MkButton>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import paging from '@/scripts/paging';
+import XNote from './note.vue';
+import XList from './date-separated-list.vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ XNote, XList, MkButton,
+ },
+
+ mixins: [
+ paging({
+ before: (self) => {
+ self.$emit('before');
+ },
+
+ after: (self, e) => {
+ self.$emit('after', e);
+ }
+ }),
+ ],
+
+ props: {
+ pagination: {
+ required: true
+ },
+
+ prop: {
+ type: String,
+ required: false
+ }
+ },
+
+ emits: ['before', 'after'],
+
+ computed: {
+ notes(): any[] {
+ return this.prop ? this.items.map(item => item[this.prop]) : this.items;
+ },
+
+ reversed(): boolean {
+ return this.pagination.reversed;
+ }
+ },
+
+ methods: {
+ updated(oldValue, newValue) {
+ const i = this.notes.findIndex(n => n === oldValue);
+ if (this.prop) {
+ this.items[i][this.prop] = newValue;
+ } else {
+ this.items[i] = newValue;
+ }
+ },
+
+ focus() {
+ this.$refs.notes.focus();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/ui/chat/pages/channel.vue b/packages/client/src/ui/chat/pages/channel.vue
new file mode 100644
index 0000000000..5152af20f9
--- /dev/null
+++ b/packages/client/src/ui/chat/pages/channel.vue
@@ -0,0 +1,259 @@
+<template>
+<div v-if="channel" class="hhizbblb">
+ <div class="info" v-if="date">
+ <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
+ </div>
+ <div class="tl" ref="body">
+ <div class="new" v-if="queue > 0" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
+ <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="true"/>
+ </div>
+ <div class="bottom">
+ <div class="typers" v-if="typers.length > 0">
+ <I18n :src="$ts.typingUsers" text-tag="span" class="users">
+ <template #users>
+ <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
+ </template>
+ </I18n>
+ <MkEllipsis/>
+ </div>
+ <XPostForm :channel="channel"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw } from 'vue';
+import * as Misskey from 'misskey-js';
+import XNotes from '../notes.vue';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
+import follow from '@/directives/follow-append';
+import XPostForm from '../post-form.vue';
+import MkInfo from '@/components/ui/info.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes,
+ XPostForm,
+ MkInfo,
+ },
+
+ directives: {
+ follow
+ },
+
+ provide() {
+ return {
+ inChannel: true
+ };
+ },
+
+ props: {
+ channelId: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ channel: null as Misskey.entities.Channel | null,
+ connection: null,
+ pagination: null,
+ baseQuery: {
+ includeMyRenotes: this.$store.state.showMyRenotes,
+ includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
+ includeLocalRenotes: this.$store.state.showLocalRenotes
+ },
+ queue: 0,
+ width: 0,
+ top: 0,
+ bottom: 0,
+ typers: [],
+ date: null,
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.channel ? this.channel.name : '-',
+ subtitle: this.channel ? this.channel.description : '-',
+ icon: 'fas fa-satellite-dish',
+ actions: [{
+ icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star',
+ text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow,
+ highlighted: this.channel?.isFollowing,
+ handler: this.toggleChannelFollow
+ }, {
+ icon: 'fas fa-search',
+ text: this.$ts.inChannelSearch,
+ handler: this.inChannelSearch
+ }, {
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }]
+ })),
+ };
+ },
+
+ async created() {
+ this.channel = await os.api('channels/show', { channelId: this.channelId });
+
+ const prepend = note => {
+ (this.$refs.tl as any).prepend(note);
+
+ this.$emit('note');
+
+ sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
+ };
+
+ this.connection = markRaw(os.stream.useChannel('channel', {
+ channelId: this.channelId
+ }));
+ this.connection.on('note', prepend);
+ this.connection.on('typers', typers => {
+ this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers;
+ });
+
+ this.pagination = {
+ endpoint: 'channels/timeline',
+ reversed: true,
+ limit: 10,
+ params: init => ({
+ channelId: this.channelId,
+ untilDate: this.date?.getTime(),
+ ...this.baseQuery
+ })
+ };
+ },
+
+ mounted() {
+
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+
+ methods: {
+ focus() {
+ this.$refs.body.focus();
+ },
+
+ goTop() {
+ const container = getScrollContainer(this.$refs.body);
+ container.scrollTop = 0;
+ },
+
+ queueUpdated(q) {
+ if (this.$refs.body.offsetWidth !== 0) {
+ const rect = this.$refs.body.getBoundingClientRect();
+ this.width = this.$refs.body.offsetWidth;
+ this.top = rect.top;
+ this.bottom = this.$refs.body.offsetHeight;
+ }
+ this.queue = q;
+ },
+
+ async inChannelSearch() {
+ const { canceled, result: query } = await os.dialog({
+ title: this.$ts.inChannelSearch,
+ input: true
+ });
+ if (canceled || query == null || query === '') return;
+ router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`);
+ },
+
+ async toggleChannelFollow() {
+ if (this.channel.isFollowing) {
+ await os.apiWithDialog('channels/unfollow', {
+ channelId: this.channel.id
+ });
+ this.channel.isFollowing = false;
+ } else {
+ await os.apiWithDialog('channels/follow', {
+ channelId: this.channel.id
+ });
+ this.channel.isFollowing = true;
+ }
+ },
+
+ openChannelMenu(ev) {
+ os.popupMenu([{
+ text: this.$ts.copyUrl,
+ icon: 'fas fa-link',
+ action: () => {
+ copyToClipboard(`${url}/channels/${this.currentChannel.id}`);
+ }
+ }], ev.currentTarget || ev.target);
+ },
+
+ timetravel(date?: Date) {
+ this.date = date;
+ this.$refs.tl.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hhizbblb {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: auto;
+
+ > .info {
+ padding: 16px 16px 0 16px;
+ }
+
+ > .top {
+ padding: 16px 16px 0 16px;
+ }
+
+ > .bottom {
+ padding: 0 16px 16px 16px;
+ position: relative;
+
+ > .typers {
+ position: absolute;
+ bottom: 100%;
+ padding: 0 8px 0 8px;
+ font-size: 0.9em;
+ background: var(--panel);
+ border-radius: 0 8px 0 0;
+ color: var(--fgTransparentWeak);
+
+ > .users {
+ > .user + .user:before {
+ content: ", ";
+ font-weight: normal;
+ }
+
+ > .user:last-of-type:after {
+ content: " ";
+ }
+ }
+ }
+ }
+
+ > .tl {
+ position: relative;
+ padding: 16px 0;
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+
+ > .new {
+ position: fixed;
+ z-index: 1000;
+
+ > button {
+ display: block;
+ margin: 16px auto;
+ padding: 8px 16px;
+ border-radius: 32px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/pages/timeline.vue b/packages/client/src/ui/chat/pages/timeline.vue
new file mode 100644
index 0000000000..f4dfdf891e
--- /dev/null
+++ b/packages/client/src/ui/chat/pages/timeline.vue
@@ -0,0 +1,221 @@
+<template>
+<div class="dbiokgaf">
+ <div class="info" v-if="date">
+ <MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
+ </div>
+ <div class="top">
+ <XPostForm/>
+ </div>
+ <div class="tl" ref="body">
+ <div class="new" v-if="queue > 0" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
+ <XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, markRaw } from 'vue';
+import XNotes from '../notes.vue';
+import * as os from '@/os';
+import * as sound from '@/scripts/sound';
+import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
+import follow from '@/directives/follow-append';
+import XPostForm from '../post-form.vue';
+import MkInfo from '@/components/ui/info.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XNotes,
+ XPostForm,
+ MkInfo,
+ },
+
+ directives: {
+ follow
+ },
+
+ props: {
+ src: {
+ type: String,
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ connection: null,
+ connection2: null,
+ pagination: null,
+ baseQuery: {
+ includeMyRenotes: this.$store.state.showMyRenotes,
+ includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
+ includeLocalRenotes: this.$store.state.showLocalRenotes
+ },
+ query: {},
+ queue: 0,
+ width: 0,
+ top: 0,
+ bottom: 0,
+ typers: [],
+ date: null,
+ [symbols.PAGE_INFO]: computed(() => ({
+ title: this.$ts.timeline,
+ icon: 'fas fa-home',
+ actions: [{
+ icon: 'fas fa-calendar-alt',
+ text: this.$ts.jumpToSpecifiedDate,
+ handler: this.timetravel
+ }]
+ })),
+ };
+ },
+
+ created() {
+ const prepend = note => {
+ (this.$refs.tl as any).prepend(note);
+
+ this.$emit('note');
+
+ sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
+ };
+
+ const onChangeFollowing = () => {
+ if (!this.$refs.tl.backed) {
+ this.$refs.tl.reload();
+ }
+ };
+
+ let endpoint;
+
+ if (this.src == 'home') {
+ endpoint = 'notes/timeline';
+ this.connection = markRaw(os.stream.useChannel('homeTimeline'));
+ this.connection.on('note', prepend);
+
+ this.connection2 = markRaw(os.stream.useChannel('main'));
+ this.connection2.on('follow', onChangeFollowing);
+ this.connection2.on('unfollow', onChangeFollowing);
+ } else if (this.src == 'local') {
+ endpoint = 'notes/local-timeline';
+ this.connection = markRaw(os.stream.useChannel('localTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'social') {
+ endpoint = 'notes/hybrid-timeline';
+ this.connection = markRaw(os.stream.useChannel('hybridTimeline'));
+ this.connection.on('note', prepend);
+ } else if (this.src == 'global') {
+ endpoint = 'notes/global-timeline';
+ this.connection = markRaw(os.stream.useChannel('globalTimeline'));
+ this.connection.on('note', prepend);
+ }
+
+ this.pagination = {
+ endpoint: endpoint,
+ limit: 10,
+ params: init => ({
+ untilDate: this.date?.getTime(),
+ ...this.baseQuery, ...this.query
+ })
+ };
+ },
+
+ mounted() {
+
+ },
+
+ beforeUnmount() {
+ this.connection.dispose();
+ if (this.connection2) this.connection2.dispose();
+ },
+
+ methods: {
+ focus() {
+ this.$refs.body.focus();
+ },
+
+ goTop() {
+ const container = getScrollContainer(this.$refs.body);
+ container.scrollTop = 0;
+ },
+
+ queueUpdated(q) {
+ if (this.$refs.body.offsetWidth !== 0) {
+ const rect = this.$refs.body.getBoundingClientRect();
+ this.width = this.$refs.body.offsetWidth;
+ this.top = rect.top;
+ this.bottom = this.$refs.body.offsetHeight;
+ }
+ this.queue = q;
+ },
+
+ timetravel(date?: Date) {
+ this.date = date;
+ this.$refs.tl.reload();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.dbiokgaf {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow: auto;
+
+ > .info {
+ padding: 16px 16px 0 16px;
+ }
+
+ > .top {
+ padding: 16px 16px 0 16px;
+ }
+
+ > .bottom {
+ padding: 0 16px 16px 16px;
+ position: relative;
+
+ > .typers {
+ position: absolute;
+ bottom: 100%;
+ padding: 0 8px 0 8px;
+ font-size: 0.9em;
+ background: var(--panel);
+ border-radius: 0 8px 0 0;
+ color: var(--fgTransparentWeak);
+
+ > .users {
+ > .user + .user:before {
+ content: ", ";
+ font-weight: normal;
+ }
+
+ > .user:last-of-type:after {
+ content: " ";
+ }
+ }
+ }
+ }
+
+ > .tl {
+ position: relative;
+ padding: 16px 0;
+ flex: 1;
+ min-width: 0;
+ overflow: auto;
+
+ > .new {
+ position: fixed;
+ z-index: 1000;
+
+ > button {
+ display: block;
+ margin: 16px auto;
+ padding: 8px 16px;
+ border-radius: 32px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/post-form.vue b/packages/client/src/ui/chat/post-form.vue
new file mode 100644
index 0000000000..44461c4a58
--- /dev/null
+++ b/packages/client/src/ui/chat/post-form.vue
@@ -0,0 +1,772 @@
+<template>
+<div class="pxiwixjf"
+ @dragover.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.stop="onDrop"
+>
+ <div class="form">
+ <div class="with-quote" v-if="quoteId"><i class="fas fa-quote-left"></i> {{ $ts.quoteAttached }}<button @click="quoteId = null"><i class="fas fa-times"></i></button></div>
+ <div v-if="visibility === 'specified'" class="to-specified">
+ <span style="margin-right: 8px;">{{ $ts.recipient }}</span>
+ <div class="visibleUsers">
+ <span v-for="u in visibleUsers" :key="u.id">
+ <MkAcct :user="u"/>
+ <button class="_button" @click="removeVisibleUser(u)"><i class="fas fa-times"></i></button>
+ </span>
+ <button @click="addVisibleUser" class="_buttonPrimary"><i class="fas fa-plus fa-fw"></i></button>
+ </div>
+ </div>
+ <input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
+ <textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" />
+ <XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
+ <XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
+ <footer>
+ <div class="left">
+ <button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><i class="fas fa-photo-video"></i></button>
+ <button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><i class="fas fa-poll-h"></i></button>
+ <button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><i class="fas fa-eye-slash"></i></button>
+ <button class="_button" @click="insertMention" v-tooltip="$ts.mention"><i class="fas fa-at"></i></button>
+ <button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><i class="fas fa-laugh-squint"></i></button>
+ <button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><i class="fas fa-plug"></i></button>
+ </div>
+ <div class="right">
+ <span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
+ <span class="local-only" v-if="localOnly"><i class="fas fa-biohazard"></i></span>
+ <button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
+ <span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
+ <span v-if="visibility === 'home'"><i class="fas fa-home"></i></span>
+ <span v-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
+ <span v-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
+ </button>
+ <button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<i :class="reply ? 'fas fa-reply' : renote ? 'fas fa-quote-right' : 'fas fa-paper-plane'"></i></button>
+ </div>
+ </footer>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import insertTextAtCursor from 'insert-text-at-cursor';
+import { length } from 'stringz';
+import { toASCII } from 'punycode/';
+import * as mfm from 'mfm-js';
+import { host, url } from '@/config';
+import { erase, unique } from '@/scripts/array';
+import { extractMentions } from '@/scripts/extract-mentions';
+import * as Acct from 'misskey-js/built/acct';
+import { formatTimeString } from '@/scripts/format-time-string';
+import { Autocomplete } from '@/scripts/autocomplete';
+import * as os from '@/os';
+import { selectFile } from '@/scripts/select-file';
+import { notePostInterruptors, postFormActions } from '@/store';
+import { isMobile } from '@/scripts/is-mobile';
+import { throttle } from 'throttle-debounce';
+
+export default defineComponent({
+ components: {
+ XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')),
+ XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue'))
+ },
+
+ props: {
+ reply: {
+ type: Object,
+ required: false
+ },
+ renote: {
+ type: Object,
+ required: false
+ },
+ channel: {
+ type: String,
+ required: false
+ },
+ mention: {
+ type: Object,
+ required: false
+ },
+ specified: {
+ type: Object,
+ required: false
+ },
+ initialText: {
+ type: String,
+ required: false
+ },
+ initialNote: {
+ type: Object,
+ required: false
+ },
+ share: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ autofocus: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ emits: ['posted', 'cancel', 'esc'],
+
+ data() {
+ return {
+ posting: false,
+ text: '',
+ files: [],
+ poll: null,
+ useCw: false,
+ cw: null,
+ localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
+ visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility,
+ visibleUsers: [],
+ autocomplete: null,
+ draghover: false,
+ quoteId: null,
+ recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
+ imeText: '',
+ typing: throttle(3000, () => {
+ if (this.channel) {
+ os.stream.send('typingOnChannel', { channel: this.channel });
+ }
+ }),
+ postFormActions,
+ };
+ },
+
+ computed: {
+ draftKey(): string {
+ let key = this.channel ? `channel:${this.channel}` : '';
+
+ if (this.renote) {
+ key += `renote:${this.renote.id}`;
+ } else if (this.reply) {
+ key += `reply:${this.reply.id}`;
+ } else {
+ key += 'note';
+ }
+
+ return key;
+ },
+
+ placeholder(): string {
+ if (this.renote) {
+ return this.$ts._postForm.quotePlaceholder;
+ } else if (this.reply) {
+ return this.$ts._postForm.replyPlaceholder;
+ } else if (this.channel) {
+ return this.$ts._postForm.channelPlaceholder;
+ } else {
+ const xs = [
+ this.$ts._postForm._placeholders.a,
+ this.$ts._postForm._placeholders.b,
+ this.$ts._postForm._placeholders.c,
+ this.$ts._postForm._placeholders.d,
+ this.$ts._postForm._placeholders.e,
+ this.$ts._postForm._placeholders.f
+ ];
+ return xs[Math.floor(Math.random() * xs.length)];
+ }
+ },
+
+ submitText(): string {
+ return this.renote
+ ? this.$ts.quote
+ : this.reply
+ ? this.$ts.reply
+ : this.$ts.note;
+ },
+
+ textLength(): number {
+ return length((this.text + this.imeText).trim());
+ },
+
+ canPost(): boolean {
+ return !this.posting &&
+ (1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
+ (this.textLength <= this.max) &&
+ (!this.poll || this.poll.choices.length >= 2);
+ },
+
+ max(): number {
+ return this.$instance ? this.$instance.maxNoteTextLength : 1000;
+ }
+ },
+
+ mounted() {
+ if (this.initialText) {
+ this.text = this.initialText;
+ }
+
+ if (this.mention) {
+ this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
+ this.text += ' ';
+ }
+
+ if (this.reply && this.reply.user.host != null) {
+ this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
+ }
+
+ if (this.reply && this.reply.text != null) {
+ const ast = mfm.parse(this.reply.text);
+
+ for (const x of extractMentions(ast)) {
+ const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
+
+ // 自分は除外
+ if (this.$i.username == x.username && x.host == null) continue;
+ if (this.$i.username == x.username && x.host == host) continue;
+
+ // 重複は除外
+ if (this.text.indexOf(`${mention} `) != -1) continue;
+
+ this.text += `${mention} `;
+ }
+ }
+
+ if (this.channel) {
+ this.visibility = 'public';
+ this.localOnly = true; // TODO: チャンネルが連合するようになった折には消す
+ }
+
+ // 公開以外へのリプライ時は元の公開範囲を引き継ぐ
+ if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
+ this.visibility = this.reply.visibility;
+ if (this.reply.visibility === 'specified') {
+ os.api('users/show', {
+ userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
+ }).then(users => {
+ this.visibleUsers.push(...users);
+ });
+
+ if (this.reply.userId !== this.$i.id) {
+ os.api('users/show', { userId: this.reply.userId }).then(user => {
+ this.visibleUsers.push(user);
+ });
+ }
+ }
+ }
+
+ if (this.specified) {
+ this.visibility = 'specified';
+ this.visibleUsers.push(this.specified);
+ }
+
+ // keep cw when reply
+ if (this.$store.state.keepCw && this.reply && this.reply.cw) {
+ this.useCw = true;
+ this.cw = this.reply.cw;
+ }
+
+ if (this.autofocus) {
+ this.focus();
+
+ this.$nextTick(() => {
+ this.focus();
+ });
+ }
+
+ // TODO: detach when unmount
+ new Autocomplete(this.$refs.text, this, { model: 'text' });
+ new Autocomplete(this.$refs.cw, this, { model: 'cw' });
+
+ this.$nextTick(() => {
+ // 書きかけの投稿を復元
+ if (!this.share && !this.mention && !this.specified) {
+ const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
+ if (draft) {
+ this.text = draft.data.text;
+ this.useCw = draft.data.useCw;
+ this.cw = draft.data.cw;
+ this.visibility = draft.data.visibility;
+ this.localOnly = draft.data.localOnly;
+ this.files = (draft.data.files || []).filter(e => e);
+ if (draft.data.poll) {
+ this.poll = draft.data.poll;
+ }
+ }
+ }
+
+ // 削除して編集
+ if (this.initialNote) {
+ const init = this.initialNote;
+ this.text = init.text ? init.text : '';
+ this.files = init.files;
+ this.cw = init.cw;
+ this.useCw = init.cw != null;
+ if (init.poll) {
+ this.poll = init.poll;
+ }
+ this.visibility = init.visibility;
+ this.localOnly = init.localOnly;
+ this.quoteId = init.renote ? init.renote.id : null;
+ }
+
+ this.$nextTick(() => this.watch());
+ });
+ },
+
+ methods: {
+ watch() {
+ this.$watch('text', () => this.saveDraft());
+ this.$watch('useCw', () => this.saveDraft());
+ this.$watch('cw', () => this.saveDraft());
+ this.$watch('poll', () => this.saveDraft());
+ this.$watch('files', () => this.saveDraft(), { deep: true });
+ this.$watch('visibility', () => this.saveDraft());
+ this.$watch('localOnly', () => this.saveDraft());
+ },
+
+ togglePoll() {
+ if (this.poll) {
+ this.poll = null;
+ } else {
+ this.poll = {
+ choices: ['', ''],
+ multiple: false,
+ expiresAt: null,
+ expiredAfter: null,
+ };
+ }
+ },
+
+ addTag(tag: string) {
+ insertTextAtCursor(this.$refs.text, ` #${tag} `);
+ },
+
+ focus() {
+ (this.$refs.text as any).focus();
+ },
+
+ chooseFileFrom(ev) {
+ selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
+ for (const file of files) {
+ this.files.push(file);
+ }
+ });
+ },
+
+ detachFile(id) {
+ this.files = this.files.filter(x => x.id != id);
+ },
+
+ updateFiles(files) {
+ this.files = files;
+ },
+
+ updateFileSensitive(file, sensitive) {
+ this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
+ },
+
+ updateFileName(file, name) {
+ this.files[this.files.findIndex(x => x.id === file.id)].name = name;
+ },
+
+ upload(file: File, name?: string) {
+ os.upload(file, this.$store.state.uploadFolder, name).then(res => {
+ this.files.push(res);
+ });
+ },
+
+ onPollUpdate(poll) {
+ this.poll = poll;
+ this.saveDraft();
+ },
+
+ setVisibility() {
+ if (this.channel) {
+ // TODO: information dialog
+ return;
+ }
+
+ os.popup(import('@/components/visibility-picker.vue'), {
+ currentVisibility: this.visibility,
+ currentLocalOnly: this.localOnly,
+ src: this.$refs.visibilityButton
+ }, {
+ changeVisibility: visibility => {
+ this.visibility = visibility;
+ if (this.$store.state.rememberNoteVisibility) {
+ this.$store.set('visibility', visibility);
+ }
+ },
+ changeLocalOnly: localOnly => {
+ this.localOnly = localOnly;
+ if (this.$store.state.rememberNoteVisibility) {
+ this.$store.set('localOnly', localOnly);
+ }
+ }
+ }, 'closed');
+ },
+
+ addVisibleUser() {
+ os.selectUser().then(user => {
+ this.visibleUsers.push(user);
+ });
+ },
+
+ removeVisibleUser(user) {
+ this.visibleUsers = erase(user, this.visibleUsers);
+ },
+
+ clear() {
+ this.text = '';
+ this.files = [];
+ this.poll = null;
+ this.quoteId = null;
+ },
+
+ onKeydown(e: KeyboardEvent) {
+ if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
+ if (e.which === 27) this.$emit('esc');
+ this.typing();
+ },
+
+ onCompositionUpdate(e: CompositionEvent) {
+ this.imeText = e.data;
+ this.typing();
+ },
+
+ onCompositionEnd(e: CompositionEvent) {
+ this.imeText = '';
+ },
+
+ async onPaste(e: ClipboardEvent) {
+ for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
+ if (item.kind == 'file') {
+ const file = item.getAsFile();
+ const lio = file.name.lastIndexOf('.');
+ const ext = lio >= 0 ? file.name.slice(lio) : '';
+ const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
+ this.upload(file, formatted);
+ }
+ }
+
+ const paste = e.clipboardData.getData('text');
+
+ if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
+ e.preventDefault();
+
+ os.dialog({
+ type: 'info',
+ text: this.$ts.quoteQuestion,
+ showCancelButton: true
+ }).then(({ canceled }) => {
+ if (canceled) {
+ insertTextAtCursor(this.$refs.text, paste);
+ return;
+ }
+
+ this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
+ });
+ }
+ },
+
+ onDragover(e) {
+ if (!e.dataTransfer.items[0]) return;
+ const isFile = e.dataTransfer.items[0].kind == 'file';
+ const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
+ if (isFile || isDriveFile) {
+ e.preventDefault();
+ this.draghover = true;
+ e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
+ }
+ },
+
+ onDragenter(e) {
+ this.draghover = true;
+ },
+
+ onDragleave(e) {
+ this.draghover = false;
+ },
+
+ onDrop(e): void {
+ this.draghover = false;
+
+ // ファイルだったら
+ if (e.dataTransfer.files.length > 0) {
+ e.preventDefault();
+ for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
+ return;
+ }
+
+ //#region ドライブのファイル
+ const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
+ if (driveFile != null && driveFile != '') {
+ const file = JSON.parse(driveFile);
+ this.files.push(file);
+ e.preventDefault();
+ }
+ //#endregion
+ },
+
+ saveDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+ data[this.draftKey] = {
+ updatedAt: new Date(),
+ data: {
+ text: this.text,
+ useCw: this.useCw,
+ cw: this.cw,
+ visibility: this.visibility,
+ localOnly: this.localOnly,
+ files: this.files,
+ poll: this.poll
+ }
+ };
+
+ localStorage.setItem('drafts', JSON.stringify(data));
+ },
+
+ deleteDraft() {
+ const data = JSON.parse(localStorage.getItem('drafts') || '{}');
+
+ delete data[this.draftKey];
+
+ localStorage.setItem('drafts', JSON.stringify(data));
+ },
+
+ async post() {
+ let data = {
+ text: this.text == '' ? undefined : this.text,
+ fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
+ replyId: this.reply ? this.reply.id : undefined,
+ renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
+ channelId: this.channel ? this.channel : undefined,
+ poll: this.poll,
+ cw: this.useCw ? this.cw || '' : undefined,
+ localOnly: this.localOnly,
+ visibility: this.visibility,
+ visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
+ viaMobile: isMobile
+ };
+
+ // plugin
+ if (notePostInterruptors.length > 0) {
+ for (const interruptor of notePostInterruptors) {
+ data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
+ }
+ }
+
+ this.posting = true;
+ os.api('notes/create', data).then(() => {
+ this.clear();
+ this.$nextTick(() => {
+ this.deleteDraft();
+ this.$emit('posted');
+ if (this.text && this.text != '') {
+ const hashtags = mfm.parse(this.text).filter(x => x.type === 'hashtag').map(x => x.props.hashtag);
+ const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
+ localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
+ }
+ this.posting = false;
+ });
+ }).catch(err => {
+ this.posting = false;
+ os.dialog({
+ type: 'error',
+ text: err.message + '\n' + (err as any).id,
+ });
+ });
+ },
+
+ cancel() {
+ this.$emit('cancel');
+ },
+
+ insertMention() {
+ os.selectUser().then(user => {
+ insertTextAtCursor(this.$refs.text, '@' + Acct.toString(user) + ' ');
+ });
+ },
+
+ async insertEmoji(ev) {
+ os.openEmojiPicker(ev.currentTarget || ev.target, {}, this.$refs.text);
+ },
+
+ showActions(ev) {
+ os.popupMenu(postFormActions.map(action => ({
+ text: action.title,
+ action: () => {
+ action.handler({
+ text: this.text
+ }, (key, value) => {
+ if (key === 'text') { this.text = value; }
+ });
+ }
+ })), ev.currentTarget || ev.target);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.pxiwixjf {
+ position: relative;
+ border: solid 0.5px var(--divider);
+ border-radius: 8px;
+
+ > .form {
+ > .preview {
+ padding: 16px;
+ }
+
+ > .with-quote {
+ margin: 0 0 8px 0;
+ color: var(--accent);
+
+ > button {
+ padding: 4px 8px;
+ color: var(--accentAlpha04);
+
+ &:hover {
+ color: var(--accentAlpha06);
+ }
+
+ &:active {
+ color: var(--accentDarken30);
+ }
+ }
+ }
+
+ > .to-specified {
+ padding: 6px 24px;
+ margin-bottom: 8px;
+ overflow: auto;
+ white-space: nowrap;
+
+ > .visibleUsers {
+ display: inline;
+ top: -1px;
+ font-size: 14px;
+
+ > button {
+ padding: 4px;
+ border-radius: 8px;
+ }
+
+ > span {
+ margin-right: 14px;
+ padding: 8px 0 8px 8px;
+ border-radius: 8px;
+ background: var(--X4);
+
+ > button {
+ padding: 4px 8px;
+ }
+ }
+ }
+ }
+
+ > .cw,
+ > .text {
+ display: block;
+ box-sizing: border-box;
+ padding: 16px;
+ margin: 0;
+ width: 100%;
+ font-size: 16px;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--fg);
+ font-family: inherit;
+
+ &:focus {
+ outline: none;
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ }
+ }
+
+ > .cw {
+ z-index: 1;
+ padding-bottom: 8px;
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > .text {
+ max-width: 100%;
+ min-width: 100%;
+ min-height: 60px;
+
+ &.withCw {
+ padding-top: 8px;
+ }
+ }
+
+ > footer {
+ $height: 44px;
+ display: flex;
+ padding: 0 8px 8px 8px;
+ line-height: $height;
+
+ > .left {
+ > button {
+ display: inline-block;
+ padding: 0;
+ margin: 0;
+ font-size: 16px;
+ width: $height;
+ height: $height;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+
+ &.active {
+ color: var(--accent);
+ }
+ }
+ }
+
+ > .right {
+ margin-left: auto;
+
+ > .text-count {
+ opacity: 0.7;
+ }
+
+ > .visibility {
+ width: $height;
+ margin: 0 8px;
+
+ & + .localOnly {
+ margin-left: 0 !important;
+ }
+ }
+
+ > .local-only {
+ margin: 0 0 0 12px;
+ opacity: 0.7;
+ }
+
+ > .submit {
+ margin: 0;
+ padding: 0 12px;
+ line-height: 34px;
+ font-weight: bold;
+ border-radius: 4px;
+
+ &:disabled {
+ opacity: 0.7;
+ }
+
+ > i {
+ margin-left: 6px;
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/side.vue b/packages/client/src/ui/chat/side.vue
new file mode 100644
index 0000000000..73881b23c0
--- /dev/null
+++ b/packages/client/src/ui/chat/side.vue
@@ -0,0 +1,157 @@
+<template>
+<div class="mrajymqm _narrow_" v-if="component">
+ <header class="header" @contextmenu.prevent.stop="onContextmenu">
+ <MkHeader class="title" :info="pageInfo" :center="false"/>
+ </header>
+ <component :is="component" v-bind="props" :ref="changePage" class="body"/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ },
+
+ provide() {
+ return {
+ navHook: (path) => {
+ this.navigate(path);
+ }
+ };
+ },
+
+ data() {
+ return {
+ path: null,
+ component: null,
+ props: {},
+ pageInfo: null,
+ history: [],
+ };
+ },
+
+ computed: {
+ url(): string {
+ return url + this.path;
+ }
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ navigate(path, record = true) {
+ if (record && this.path) this.history.push(this.path);
+ this.path = path;
+ const { component, props } = resolve(path);
+ this.component = component;
+ this.props = props;
+ this.$emit('open');
+ },
+
+ back() {
+ this.navigate(this.history.pop(), false);
+ },
+
+ close() {
+ this.path = null;
+ this.component = null;
+ this.props = {};
+ this.$emit('close');
+ },
+
+ onContextmenu(e) {
+ os.contextMenu([{
+ type: 'label',
+ text: this.path,
+ }, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: () => {
+ this.$router.push(this.path);
+ this.close();
+ }
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(this.path);
+ this.close();
+ }
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.url, '_blank');
+ this.close();
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(this.url);
+ }
+ }], e);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mrajymqm {
+ $header-height: 54px; // TODO: どこかに集約したい
+
+ --root-margin: 16px;
+ --margin: var(--marginHalf);
+
+ height: 100%;
+ overflow: auto;
+ box-sizing: border-box;
+
+ > .header {
+ display: flex;
+ position: sticky;
+ z-index: 1000;
+ top: 0;
+ height: $header-height;
+ width: 100%;
+ font-weight: bold;
+ //background-color: var(--panel);
+ -webkit-backdrop-filter: var(--blur, blur(32px));
+ backdrop-filter: var(--blur, blur(32px));
+ background-color: var(--header);
+ border-bottom: solid 0.5px var(--divider);
+ box-sizing: border-box;
+
+ > ._button {
+ height: $header-height;
+ width: $header-height;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+
+ > .title {
+ flex: 1;
+ position: relative;
+ }
+ }
+
+ > .body {
+
+ }
+}
+</style>
+
diff --git a/packages/client/src/ui/chat/store.ts b/packages/client/src/ui/chat/store.ts
new file mode 100644
index 0000000000..389d56afb6
--- /dev/null
+++ b/packages/client/src/ui/chat/store.ts
@@ -0,0 +1,17 @@
+import { markRaw } from 'vue';
+import { Storage } from '../../pizzax';
+
+export const store = markRaw(new Storage('chatUi', {
+ widgets: {
+ where: 'account',
+ default: [] as {
+ name: string;
+ id: string;
+ data: Record<string, any>;
+ }[]
+ },
+ tl: {
+ where: 'deviceAccount',
+ default: 'home'
+ },
+}));
diff --git a/packages/client/src/ui/chat/sub-note-content.vue b/packages/client/src/ui/chat/sub-note-content.vue
new file mode 100644
index 0000000000..9c169ea546
--- /dev/null
+++ b/packages/client/src/ui/chat/sub-note-content.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="wrmlmaau">
+ <div class="body">
+ <span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
+ <span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
+ <MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
+ <Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
+ <MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
+ </div>
+ <details v-if="note.files.length > 0">
+ <summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
+ <XMediaList :media-list="note.files"/>
+ </details>
+ <details v-if="note.poll">
+ <summary>{{ $ts.poll }}</summary>
+ <XPoll :note="note"/>
+ </details>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPoll from '@/components/poll.vue';
+import XMediaList from '@/components/media-list.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XPoll,
+ XMediaList,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true
+ }
+ },
+ data() {
+ return {
+ };
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wrmlmaau {
+ overflow-wrap: break-word;
+
+ > .body {
+ > .reply {
+ margin-right: 6px;
+ color: var(--accent);
+ }
+
+ > .rp {
+ margin-left: 4px;
+ font-style: oblique;
+ color: var(--renote);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/chat/widgets.vue b/packages/client/src/ui/chat/widgets.vue
new file mode 100644
index 0000000000..6b12f9dac9
--- /dev/null
+++ b/packages/client/src/ui/chat/widgets.vue
@@ -0,0 +1,62 @@
+<template>
+<div class="qydbhufi">
+ <XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
+
+ <button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button>
+ <button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XWidgets from '@/components/widgets.vue';
+import { store } from './store';
+
+export default defineComponent({
+ components: {
+ XWidgets,
+ },
+
+ data() {
+ return {
+ edit: false,
+ widgets: store.reactiveState.widgets
+ };
+ },
+
+ methods: {
+ addWidget(widget) {
+ store.set('widgets', [widget, ...store.state.widgets]);
+ },
+
+ removeWidget(widget) {
+ store.set('widgets', store.state.widgets.filter(w => w.id != widget.id));
+ },
+
+ updateWidget({ id, data }) {
+ // TODO: throttleしたい
+ store.set('widgets', store.state.widgets.map(w => w.id === id ? {
+ ...w,
+ data: data
+ } : w));
+ },
+
+ updateWidgets(widgets) {
+ store.set('widgets', widgets);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qydbhufi {
+ height: 100%;
+ box-sizing: border-box;
+ overflow: auto;
+ padding: var(--margin);
+
+ ::v-deep(._panel) {
+ box-shadow: none;
+ }
+}
+</style>
diff --git a/packages/client/src/ui/classic.header.vue b/packages/client/src/ui/classic.header.vue
new file mode 100644
index 0000000000..c2e3bbd69b
--- /dev/null
+++ b/packages/client/src/ui/classic.header.vue
@@ -0,0 +1,210 @@
+<template>
+<div class="azykntjl">
+ <div class="body">
+ <div class="left">
+ <MkA class="item index" active-class="active" to="/" exact v-click-anime v-tooltip="$ts.timeline">
+ <i class="fas fa-home fa-fw"></i>
+ </MkA>
+ <template v-for="item in menu">
+ <div v-if="item === '-'" class="divider"></div>
+ <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime v-tooltip="$ts[menuDef[item].title]">
+ <i class="fa-fw" :class="menuDef[item].icon"></i>
+ <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span>
+ </component>
+ </template>
+ <div class="divider"></div>
+ <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.controlPanel">
+ <i class="fas fa-door-open fa-fw"></i>
+ </MkA>
+ <button class="item _button" @click="more" v-click-anime>
+ <i class="fas fa-ellipsis-h fa-fw"></i>
+ <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ </div>
+ <div class="right">
+ <MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime v-tooltip="$ts.settings">
+ <i class="fas fa-cog fa-fw"></i>
+ </MkA>
+ <button class="item _button account" @click="openAccountMenu" v-click-anime>
+ <MkAvatar :user="$i" class="avatar"/><MkAcct class="acct" :user="$i"/>
+ </button>
+ <div class="post" @click="post">
+ <MkButton class="button" gradate full rounded>
+ <i class="fas fa-pencil-alt fa-fw"></i>
+ </MkButton>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { openAccountMenu } from '@/account';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ },
+
+ data() {
+ return {
+ host: host,
+ accounts: [],
+ connection: null,
+ menuDef: menuDef,
+ settingsWindowed: false,
+ };
+ },
+
+ computed: {
+ menu(): string[] {
+ return this.$store.state.menu;
+ },
+
+ otherNavItemIndicated(): boolean {
+ for (const def in this.menuDef) {
+ if (this.menu.includes(def)) continue;
+ if (this.menuDef[def].indicated) return true;
+ }
+ return false;
+ },
+ },
+
+ watch: {
+ '$store.reactiveState.menuDisplay.value'() {
+ this.calcViewState();
+ },
+ },
+
+ created() {
+ window.addEventListener('resize', this.calcViewState);
+ this.calcViewState();
+ },
+
+ methods: {
+ calcViewState() {
+ this.settingsWindowed = (window.innerWidth > 1400);
+ },
+
+ post() {
+ os.post();
+ },
+
+ search() {
+ search();
+ },
+
+ more(ev) {
+ os.popup(import('@/components/launch-pad.vue'), {}, {
+ }, 'closed');
+ },
+
+ openAccountMenu,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.azykntjl {
+ $height: 60px;
+ $avatar-size: 32px;
+ $avatar-margin: 8px;
+
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+ width: 100%;
+ height: $height;
+ background-color: var(--bg);
+
+ > .body {
+ max-width: 1380px;
+ margin: 0 auto;
+ display: flex;
+
+ > .right,
+ > .left {
+
+ > .item {
+ position: relative;
+ font-size: 0.9em;
+ display: inline-block;
+ padding: 0 12px;
+ line-height: $height;
+
+ > i,
+ > .avatar {
+ margin-right: 0;
+ }
+
+ > i {
+ left: 10px;
+ }
+
+ > .avatar {
+ width: $avatar-size;
+ height: $avatar-size;
+ vertical-align: middle;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: var(--navIndicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+ }
+
+ &:hover {
+ text-decoration: none;
+ color: var(--navHoverFg);
+ }
+
+ &.active {
+ color: var(--navActive);
+ }
+ }
+
+ > .divider {
+ display: inline-block;
+ height: 16px;
+ margin: 0 10px;
+ border-right: solid 0.5px var(--divider);
+ }
+
+ > .post {
+ display: inline-block;
+
+ > .button {
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ min-width: 0;
+ }
+ }
+
+ > .account {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: top;
+ margin-right: 8px;
+
+ > .acct {
+ margin-left: 8px;
+ }
+ }
+ }
+
+ > .right {
+ margin-left: auto;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue
new file mode 100644
index 0000000000..38087cebb8
--- /dev/null
+++ b/packages/client/src/ui/classic.side.vue
@@ -0,0 +1,158 @@
+<template>
+<div class="qvzfzxam _narrow_" v-if="component">
+ <div class="container">
+ <header class="header" @contextmenu.prevent.stop="onContextmenu">
+ <button class="_button" @click="back()" v-if="history.length > 0"><i class="fas fa-chevron-left"></i></button>
+ <button class="_button" style="pointer-events: none;" v-else><!-- マージンのバランスを取るためのダミー --></button>
+ <span class="title">{{ pageInfo.title }}</span>
+ <button class="_button" @click="close()"><i class="fas fa-times"></i></button>
+ </header>
+ <MkHeader class="pageHeader" :info="pageInfo"/>
+ <component :is="component" v-bind="props" :ref="changePage"/>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { resolve } from '@/router';
+import { url } from '@/config';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ provide() {
+ return {
+ navHook: (path) => {
+ this.navigate(path);
+ }
+ };
+ },
+
+ data() {
+ return {
+ path: null,
+ component: null,
+ props: {},
+ pageInfo: null,
+ history: [],
+ };
+ },
+
+ computed: {
+ url(): string {
+ return url + this.path;
+ }
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ navigate(path, record = true) {
+ if (record && this.path) this.history.push(this.path);
+ this.path = path;
+ const { component, props } = resolve(path);
+ this.component = component;
+ this.props = props;
+ },
+
+ back() {
+ this.navigate(this.history.pop(), false);
+ },
+
+ close() {
+ this.path = null;
+ this.component = null;
+ this.props = {};
+ },
+
+ onContextmenu(e) {
+ os.contextMenu([{
+ type: 'label',
+ text: this.path,
+ }, {
+ icon: 'fas fa-expand-alt',
+ text: this.$ts.showInPage,
+ action: () => {
+ this.$router.push(this.path);
+ this.close();
+ }
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(this.path);
+ this.close();
+ }
+ }, null, {
+ icon: 'fas fa-external-link-alt',
+ text: this.$ts.openInNewTab,
+ action: () => {
+ window.open(this.url, '_blank');
+ this.close();
+ }
+ }, {
+ icon: 'fas fa-link',
+ text: this.$ts.copyLink,
+ action: () => {
+ copyToClipboard(this.url);
+ }
+ }], e);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.qvzfzxam {
+ $header-height: 58px; // TODO: どこかに集約したい
+
+ --root-margin: 16px;
+ --margin: var(--marginHalf);
+
+ > .container {
+ position: fixed;
+ width: 370px;
+ height: 100vh;
+ overflow: auto;
+ box-sizing: border-box;
+
+ > .header {
+ display: flex;
+ position: sticky;
+ z-index: 1000;
+ top: 0;
+ height: $header-height;
+ width: 100%;
+ line-height: $header-height;
+ text-align: center;
+ font-weight: bold;
+ //background-color: var(--panel);
+ -webkit-backdrop-filter: var(--blur, blur(32px));
+ backdrop-filter: var(--blur, blur(32px));
+ background-color: var(--header);
+
+ > ._button {
+ height: $header-height;
+ width: $header-height;
+
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+
+ > .title {
+ flex: 1;
+ position: relative;
+ }
+ }
+ }
+}
+</style>
+
diff --git a/packages/client/src/ui/classic.sidebar.vue b/packages/client/src/ui/classic.sidebar.vue
new file mode 100644
index 0000000000..5e4b6ae28f
--- /dev/null
+++ b/packages/client/src/ui/classic.sidebar.vue
@@ -0,0 +1,263 @@
+<template>
+<div class="npcljfve" :class="{ iconOnly }">
+ <button class="item _button account" @click="openAccountMenu" v-click-anime>
+ <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
+ </button>
+ <div class="post" @click="post" data-cy-open-post-form>
+ <MkButton class="button" gradate full rounded>
+ <i class="fas fa-pencil-alt fa-fw"></i><span class="text" v-if="!iconOnly">{{ $ts.note }}</span>
+ </MkButton>
+ </div>
+ <div class="divider"></div>
+ <MkA class="item index" active-class="active" to="/" exact v-click-anime>
+ <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span>
+ </MkA>
+ <template v-for="item in menu">
+ <div v-if="item === '-'" class="divider"></div>
+ <component v-else-if="menuDef[item] && (menuDef[item].show !== false)" :is="menuDef[item].to ? 'MkA' : 'button'" class="item _button" :class="item" active-class="active" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}" :to="menuDef[item].to" v-click-anime>
+ <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span>
+ <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span>
+ </component>
+ </template>
+ <div class="divider"></div>
+ <MkA v-if="$i.isAdmin || $i.isModerator" class="item" active-class="active" to="/admin" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime>
+ <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span>
+ </MkA>
+ <button class="item _button" @click="more" v-click-anime>
+ <i class="fas fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span>
+ <span v-if="otherNavItemIndicated" class="indicator"><i class="fas fa-circle"></i></span>
+ </button>
+ <MkA class="item" active-class="active" to="/settings" :behavior="settingsWindowed ? 'modalWindow' : null" v-click-anime>
+ <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span>
+ </MkA>
+ <div class="divider"></div>
+ <div class="about">
+ <MkA class="link" to="/about" v-click-anime>
+ <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" class="_ghost"/>
+ </MkA>
+ </div>
+ <!--<MisskeyLogo class="misskey"/>-->
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import { openAccountMenu } from '@/account';
+import MkButton from '@/components/ui/button.vue';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
+//import MisskeyLogo from '@assets/client/misskey.svg';
+
+export default defineComponent({
+ components: {
+ MkButton,
+ //MisskeyLogo,
+ },
+
+ data() {
+ return {
+ host: host,
+ accounts: [],
+ connection: null,
+ menuDef: menuDef,
+ iconOnly: false,
+ settingsWindowed: false,
+ };
+ },
+
+ computed: {
+ menu(): string[] {
+ return this.$store.state.menu;
+ },
+
+ otherNavItemIndicated(): boolean {
+ for (const def in this.menuDef) {
+ if (this.menu.includes(def)) continue;
+ if (this.menuDef[def].indicated) return true;
+ }
+ return false;
+ },
+ },
+
+ watch: {
+ '$store.reactiveState.menuDisplay.value'() {
+ this.calcViewState();
+ },
+
+ iconOnly() {
+ this.$nextTick(() => {
+ this.$emit('change-view-mode');
+ });
+ },
+ },
+
+ created() {
+ window.addEventListener('resize', this.calcViewState);
+ this.calcViewState();
+ },
+
+ mounted() {
+ const sticky = new StickySidebar(this.$el.parentElement, 16);
+ window.addEventListener('scroll', () => {
+ sticky.calc(window.scrollY);
+ }, { passive: true });
+ },
+
+ methods: {
+ calcViewState() {
+ this.iconOnly = (window.innerWidth <= 1400) || (this.$store.state.menuDisplay === 'sideIcon');
+ this.settingsWindowed = (window.innerWidth > 1400);
+ },
+
+ post() {
+ os.post();
+ },
+
+ search() {
+ search();
+ },
+
+ more(ev) {
+ os.popup(import('@/components/launch-pad.vue'), {}, {
+ }, 'closed');
+ },
+
+ openAccountMenu,
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.npcljfve {
+ $ui-font-size: 1em; // TODO: どこかに集約したい
+ $nav-icon-only-width: 78px; // TODO: どこかに集約したい
+ $avatar-size: 32px;
+ $avatar-margin: 8px;
+
+ padding: 0 16px;
+ box-sizing: border-box;
+ width: 260px;
+
+ &.iconOnly {
+ flex: 0 0 $nav-icon-only-width;
+ width: $nav-icon-only-width !important;
+
+ > .divider {
+ margin: 8px auto;
+ width: calc(100% - 32px);
+ }
+
+ > .post {
+ > .button {
+ width: 46px;
+ height: 46px;
+ padding: 0;
+ }
+ }
+
+ > .item {
+ padding-left: 0;
+ width: 100%;
+ text-align: center;
+ font-size: $ui-font-size * 1.1;
+ line-height: 3.7rem;
+
+ > i,
+ > .avatar {
+ margin-right: 0;
+ }
+
+ > i {
+ left: 10px;
+ }
+
+ > .text {
+ display: none;
+ }
+ }
+ }
+
+ > .divider {
+ margin: 10px 0;
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .post {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: 16px 0;
+ background: var(--bg);
+
+ > .button {
+ min-width: 0;
+ }
+ }
+
+ > .about {
+ fill: currentColor;
+ padding: 8px 0 16px 0;
+ text-align: center;
+
+ > .link {
+ display: block;
+ width: 32px;
+ margin: 0 auto;
+
+ img {
+ display: block;
+ width: 100%;
+ }
+ }
+ }
+
+ > .item {
+ position: relative;
+ display: block;
+ font-size: $ui-font-size;
+ line-height: 2.6rem;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+
+ > i {
+ width: 32px;
+ }
+
+ > i,
+ > .avatar {
+ margin-right: $avatar-margin;
+ }
+
+ > .avatar {
+ width: $avatar-size;
+ height: $avatar-size;
+ vertical-align: middle;
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: var(--navIndicator);
+ font-size: 8px;
+ animation: blink 1s infinite;
+ }
+
+ &:hover {
+ text-decoration: none;
+ color: var(--navHoverFg);
+ }
+
+ &.active {
+ color: var(--navActive);
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue
new file mode 100644
index 0000000000..5d7c79a0e2
--- /dev/null
+++ b/packages/client/src/ui/classic.vue
@@ -0,0 +1,471 @@
+<template>
+<div class="mk-app" :class="{ wallpaper, isMobile }" :style="`--globalHeaderHeight:${globalHeaderHeight}px`">
+ <XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/>
+
+ <div class="columns" :class="{ fullView, withGlobalHeader: showMenuOnTop }">
+ <template v-if="!isMobile">
+ <div class="sidebar" v-if="!showMenuOnTop">
+ <XSidebar/>
+ </div>
+ <div class="widgets left" ref="widgetsLeft" v-else>
+ <XWidgets @mounted="attachSticky('widgetsLeft')" :place="'left'"/>
+ </div>
+ </template>
+
+ <main class="main" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }">
+ <div class="content">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <router-view v-slot="{ Component }">
+ <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+ <keep-alive :include="['timeline']">
+ <component :is="Component" :ref="changePage"/>
+ </keep-alive>
+ </transition>
+ </router-view>
+ </MkStickyContainer>
+ </div>
+ </main>
+
+ <div v-if="isDesktop" class="widgets right" ref="widgetsRight">
+ <XWidgets @mounted="attachSticky('widgetsRight')" :place="null"/>
+ </div>
+ </div>
+
+ <div class="buttons" v-if="isMobile">
+ <button class="button nav _button" @click="showDrawerNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
+ <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button>
+ <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
+ <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
+ <button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button>
+ </div>
+
+ <XDrawerSidebar ref="drawerNav" class="sidebar" v-if="isMobile"/>
+
+ <transition name="tray-back">
+ <div class="tray-back _modalBg"
+ v-if="widgetsShowing"
+ @click="widgetsShowing = false"
+ @touchstart.passive="widgetsShowing = false"
+ ></div>
+ </transition>
+
+ <transition name="tray">
+ <XWidgets v-if="widgetsShowing" class="tray"/>
+ </transition>
+
+ <iframe v-if="$store.state.aiChanMode" class="ivnzpscs" ref="live2d" src="https://misskey-dev.github.io/mascot-web/?scale=2&y=1.4"></iframe>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent, markRaw } from 'vue';
+import { instanceName } from '@/config';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
+import XSidebar from './classic.sidebar.vue';
+import XDrawerSidebar from '@/ui/_common_/sidebar.vue';
+import XCommon from './_common_/common.vue';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import * as symbols from '@/symbols';
+
+const DESKTOP_THRESHOLD = 1100;
+const MOBILE_THRESHOLD = 600;
+
+export default defineComponent({
+ components: {
+ XCommon,
+ XSidebar,
+ XDrawerSidebar,
+ XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')),
+ XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')),
+ },
+
+ provide() {
+ return {
+ shouldHeaderThin: this.showMenuOnTop,
+ };
+ },
+
+ data() {
+ return {
+ pageInfo: null,
+ menuDef: menuDef,
+ globalHeaderHeight: 0,
+ isMobile: window.innerWidth <= MOBILE_THRESHOLD,
+ isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+ widgetsShowing: false,
+ fullView: false,
+ wallpaper: localStorage.getItem('wallpaper') != null,
+ };
+ },
+
+ computed: {
+ navIndicated(): boolean {
+ for (const def in this.menuDef) {
+ if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
+ if (this.menuDef[def].indicated) return true;
+ }
+ return false;
+ },
+
+ showMenuOnTop(): boolean {
+ return !this.isMobile && this.$store.state.menuDisplay === 'top';
+ }
+ },
+
+ created() {
+ document.documentElement.style.overflowY = 'scroll';
+
+ if (this.$store.state.widgets.length === 0) {
+ this.$store.set('widgets', [{
+ name: 'calendar',
+ id: 'a', place: null, data: {}
+ }, {
+ name: 'notifications',
+ id: 'b', place: null, data: {}
+ }, {
+ name: 'trends',
+ id: 'c', place: null, data: {}
+ }]);
+ }
+ },
+
+ mounted() {
+ window.addEventListener('resize', () => {
+ this.isMobile = (window.innerWidth <= MOBILE_THRESHOLD);
+ this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD);
+ }, { passive: true });
+
+ if (this.$store.state.aiChanMode) {
+ const iframeRect = this.$refs.live2d.getBoundingClientRect();
+ window.addEventListener('mousemove', ev => {
+ this.$refs.live2d.contentWindow.postMessage({
+ type: 'moveCursor',
+ body: {
+ x: ev.clientX - iframeRect.left,
+ y: ev.clientY - iframeRect.top,
+ }
+ }, '*');
+ }, { passive: true });
+ window.addEventListener('touchmove', ev => {
+ this.$refs.live2d.contentWindow.postMessage({
+ type: 'moveCursor',
+ body: {
+ x: ev.touches[0].clientX - iframeRect.left,
+ y: ev.touches[0].clientY - iframeRect.top,
+ }
+ }, '*');
+ }, { passive: true });
+ }
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ document.title = `${this.pageInfo.title} | ${instanceName}`;
+ }
+ },
+
+ attachSticky(ref) {
+ const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す
+ window.addEventListener('scroll', () => {
+ sticky.calc(window.scrollY);
+ }, { passive: true });
+ },
+
+ post() {
+ os.post();
+ },
+
+ top() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ back() {
+ history.back();
+ },
+
+ showDrawerNav() {
+ this.$refs.drawerNav.show();
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+ if (window.getSelection().toString() !== '') return;
+ const path = this.$route.path;
+ os.contextMenu([{
+ type: 'label',
+ text: path,
+ }, {
+ icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand',
+ text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView,
+ action: () => {
+ this.fullView = !this.fullView;
+ }
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(path);
+ }
+ }], e);
+ },
+
+ onAiClick(ev) {
+ //if (this.live2d) this.live2d.click(ev);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tray-enter-active,
+.tray-leave-active {
+ opacity: 1;
+ transform: translateX(0);
+ transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-enter-from,
+.tray-leave-active {
+ opacity: 0;
+ transform: translateX(240px);
+}
+
+.tray-back-enter-active,
+.tray-back-leave-active {
+ opacity: 1;
+ transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-back-enter-from,
+.tray-back-leave-active {
+ opacity: 0;
+}
+
+.mk-app {
+ $ui-font-size: 1em;
+ $widgets-hide-threshold: 1200px;
+ $nav-icon-only-width: 78px; // TODO: どこかに集約したい
+
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ min-height: calc(var(--vh, 1vh) * 100);
+ box-sizing: border-box;
+
+ &.wallpaper {
+ background: var(--wallpaperOverlay);
+ //backdrop-filter: var(--blur, blur(4px));
+ }
+
+ &.isMobile {
+ > .columns {
+ display: block;
+ margin: 0;
+
+ > .main {
+ margin: 0;
+ padding-bottom: 92px;
+ border: none;
+ width: 100%;
+ border-radius: 0;
+ }
+ }
+ }
+
+ > .columns {
+ display: flex;
+ justify-content: center;
+ max-width: 100%;
+ //margin: 32px 0;
+
+ &.fullView {
+ margin: 0;
+
+ > .sidebar {
+ display: none;
+ }
+
+ > .widgets {
+ display: none;
+ }
+
+ > .main {
+ margin: 0;
+ border-radius: 0;
+ box-shadow: none;
+ width: 100%;
+ }
+ }
+
+ > .main {
+ min-width: 0;
+ width: 750px;
+ margin: 0 16px 0 0;
+ background: var(--panel);
+ border-left: solid 1px var(--divider);
+ border-right: solid 1px var(--divider);
+ border-radius: 0;
+ overflow: clip;
+ --margin: 12px;
+ }
+
+ > .widgets {
+ //--panelBorder: none;
+ width: 300px;
+ margin-top: 16px;
+
+ @media (max-width: $widgets-hide-threshold) {
+ display: none;
+ }
+
+ &.left {
+ margin-right: 16px;
+ }
+ }
+
+ > .sidebar {
+ margin-top: 16px;
+ }
+
+ &.withGlobalHeader {
+ > .main {
+ margin-top: 0;
+ border: solid 1px var(--divider);
+ border-radius: var(--radius);
+ --stickyTop: var(--globalHeaderHeight);
+ }
+
+ > .widgets {
+ --stickyTop: var(--globalHeaderHeight);
+ margin-top: 0;
+ }
+ }
+
+ @media (max-width: 850px) {
+ margin: 0;
+
+ > .sidebar {
+ border-right: solid 0.5px var(--divider);
+ }
+
+ > .main {
+ margin: 0;
+ border-radius: 0;
+ box-shadow: none;
+ width: 100%;
+ }
+ }
+ }
+
+ > .buttons {
+ position: fixed;
+ z-index: 1000;
+ bottom: 0;
+ padding: 16px;
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ -webkit-backdrop-filter: var(--blur, blur(32px));
+ backdrop-filter: var(--blur, blur(32px));
+ background-color: var(--header);
+ border-top: solid 0.5px var(--divider);
+
+ > .button {
+ position: relative;
+ flex: 1;
+ padding: 0;
+ margin: auto;
+ height: 64px;
+ border-radius: 8px;
+ background: var(--panel);
+ color: var(--fg);
+
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+
+ @media (max-width: 400px) {
+ height: 60px;
+
+ &:not(:last-child) {
+ margin-right: 8px;
+ }
+ }
+
+ &:hover {
+ background: var(--X2);
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: var(--indicator);
+ font-size: 16px;
+ animation: blink 1s infinite;
+ }
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ > * {
+ font-size: 22px;
+ }
+
+ &:disabled {
+ cursor: default;
+
+ > * {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ > .tray-back {
+ z-index: 1001;
+ }
+
+ > .tray {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 1001;
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ height: calc(var(--vh, 1vh) * 100);
+ padding: var(--margin);
+ box-sizing: border-box;
+ overflow: auto;
+ background: var(--bg);
+ }
+
+ > .ivnzpscs {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ width: 300px;
+ height: 600px;
+ border: none;
+ pointer-events: none;
+ }
+}
+</style>
diff --git a/packages/client/src/ui/classic.widgets.vue b/packages/client/src/ui/classic.widgets.vue
new file mode 100644
index 0000000000..562c2eeb2c
--- /dev/null
+++ b/packages/client/src/ui/classic.widgets.vue
@@ -0,0 +1,84 @@
+<template>
+<div class="ddiqwdnk">
+ <XWidgets class="widgets" :edit="editMode" :widgets="$store.reactiveState.widgets.value.filter(w => w.place === place)" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+ <MkAd class="a" :prefer="['square']"/>
+
+ <button v-if="editMode" @click="editMode = false" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button>
+ <button v-else @click="editMode = true" class="_textButton edit" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XWidgets from '@/components/widgets.vue';
+
+export default defineComponent({
+ components: {
+ XWidgets
+ },
+
+ props: {
+ place: {
+ type: String,
+ }
+ },
+
+ emits: ['mounted'],
+
+ data() {
+ return {
+ editMode: false,
+ };
+ },
+
+ mounted() {
+ this.$emit('mounted', this.$el);
+ },
+
+ methods: {
+ addWidget(widget) {
+ this.$store.set('widgets', [{
+ ...widget,
+ place: this.place,
+ }, ...this.$store.state.widgets]);
+ },
+
+ removeWidget(widget) {
+ this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id));
+ },
+
+ updateWidget({ id, data }) {
+ this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
+ ...w,
+ data: data
+ } : w));
+ },
+
+ updateWidgets(widgets) {
+ this.$store.set('widgets', [
+ ...this.$store.state.widgets.filter(w => w.place !== this.place),
+ ...widgets
+ ]);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ddiqwdnk {
+ position: sticky;
+ height: min-content;
+ box-sizing: border-box;
+ padding-bottom: 8px;
+
+ > .widgets,
+ > .a {
+ width: 300px;
+ }
+
+ > .edit {
+ display: block;
+ margin: 16px auto;
+ }
+}
+</style>
diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue
new file mode 100644
index 0000000000..cc8bf5a511
--- /dev/null
+++ b/packages/client/src/ui/deck.vue
@@ -0,0 +1,229 @@
+<template>
+<div class="mk-deck" :class="`${deckStore.reactiveState.columnAlign.value}`" @contextmenu.self.prevent="onContextmenu"
+ :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }"
+>
+ <XSidebar ref="nav"/>
+
+ <template v-for="ids in layout">
+ <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+ <section v-if="ids.length > 1"
+ class="folder column"
+ :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
+ >
+ <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/>
+ </section>
+ <DeckColumnCore v-else
+ class="column"
+ :ref="ids[0]"
+ :key="ids[0]"
+ :column="columns.find(c => c.id === ids[0])"
+ @parent-focus="moveFocus(ids[0], $event)"
+ :style="columns.find(c => c.id === ids[0]).flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0]).width + 'px' }"
+ />
+ </template>
+
+ <button v-if="$i" class="nav _button" @click="showNav()"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
+ <button v-if="$i" class="post _buttonPrimary" @click="post()"><i class="fas fa-pencil-alt"></i></button>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+import { host } from '@/config';
+import DeckColumnCore from '@/ui/deck/column-core.vue';
+import XSidebar from '@/ui/_common_/sidebar.vue';
+import { getScrollContainer } from '@/scripts/scroll';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import XCommon from './_common_/common.vue';
+import { deckStore, addColumn, loadDeck } from './deck/deck-store';
+
+export default defineComponent({
+ components: {
+ XCommon,
+ XSidebar,
+ DeckColumnCore,
+ },
+
+ provide() {
+ return deckStore.state.navWindow ? {
+ navHook: (url) => {
+ os.pageWindow(url);
+ }
+ } : {};
+ },
+
+ data() {
+ return {
+ deckStore,
+ host: host,
+ menuDef: menuDef,
+ wallpaper: localStorage.getItem('wallpaper') != null,
+ };
+ },
+
+ computed: {
+ columns() {
+ return deckStore.reactiveState.columns.value;
+ },
+ layout() {
+ return deckStore.reactiveState.layout.value;
+ },
+ navIndicated(): boolean {
+ if (!this.$i) return false;
+ for (const def in this.menuDef) {
+ if (this.menuDef[def].indicated) return true;
+ }
+ return false;
+ },
+ },
+
+ created() {
+ document.documentElement.style.overflowY = 'hidden';
+ document.documentElement.style.scrollBehavior = 'auto';
+ window.addEventListener('wheel', this.onWheel);
+ loadDeck();
+ },
+
+ mounted() {
+ },
+
+ methods: {
+ onWheel(e) {
+ if (getScrollContainer(e.target) == null) {
+ document.documentElement.scrollLeft += e.deltaY > 0 ? 96 : -96;
+ }
+ },
+
+ showNav() {
+ this.$refs.nav.show();
+ },
+
+ post() {
+ os.post();
+ },
+
+ async addColumn(ev) {
+ const columns = [
+ 'main',
+ 'widgets',
+ 'notifications',
+ 'tl',
+ 'antenna',
+ 'list',
+ 'mentions',
+ 'direct',
+ ];
+
+ const { canceled, result: column } = await os.dialog({
+ title: this.$ts._deck.addColumn,
+ type: null,
+ select: {
+ items: columns.map(column => ({
+ value: column, text: this.$t('_deck._columns.' + column)
+ }))
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+
+ addColumn({
+ type: column,
+ id: uuid(),
+ name: this.$t('_deck._columns.' + column),
+ width: 330,
+ });
+ },
+
+ onContextmenu(e) {
+ os.contextMenu([{
+ text: this.$ts._deck.addColumn,
+ icon: null,
+ action: this.addColumn
+ }], e);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-deck {
+ $nav-hide-threshold: 650px; // TODO: どこかに集約したい
+
+ // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい
+ --margin: var(--marginHalf);
+
+ display: flex;
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ height: calc(var(--vh, 1vh) * 100);
+ box-sizing: border-box;
+ flex: 1;
+ padding: var(--deckMargin);
+
+ &.center {
+ > .column:first-of-type {
+ margin-left: auto;
+ }
+
+ > .column:last-of-type {
+ margin-right: auto;
+ }
+ }
+
+ > .column {
+ flex-shrink: 0;
+ margin-right: var(--deckMargin);
+
+ &.folder {
+ display: flex;
+ flex-direction: column;
+
+ > *:not(:last-child) {
+ margin-bottom: var(--deckMargin);
+ }
+ }
+ }
+
+ > .post,
+ > .nav {
+ position: fixed;
+ z-index: 1000;
+ bottom: 32px;
+ width: 64px;
+ height: 64px;
+ border-radius: 100%;
+ box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12);
+ font-size: 22px;
+
+ @media (min-width: ($nav-hide-threshold + 1px)) {
+ display: none;
+ }
+ }
+
+ > .post {
+ right: 32px;
+ }
+
+ > .nav {
+ left: 32px;
+ background: var(--panel);
+ color: var(--fg);
+
+ &:hover {
+ background: var(--X2);
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: var(--indicator);
+ font-size: 16px;
+ animation: blink 1s infinite;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/deck/antenna-column.vue b/packages/client/src/ui/deck/antenna-column.vue
new file mode 100644
index 0000000000..d42b8a5a10
--- /dev/null
+++ b/packages/client/src/ui/deck/antenna-column.vue
@@ -0,0 +1,80 @@
+<template>
+<XColumn :func="{ handler: setAntenna, title: $ts.selectAntenna }" :column="column" :is-stacked="isStacked">
+ <template #header>
+ <i class="fas fa-satellite"></i><span style="margin-left: 8px;">{{ column.name }}</span>
+ </template>
+
+ <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => $emit('loaded')"/>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XColumn from './column.vue';
+import XTimeline from '@/components/timeline.vue';
+import * as os from '@/os';
+import { updateColumn } from './deck-store';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XTimeline,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ watch: {
+ mediaOnly() {
+ (this.$refs.timeline as any).reload();
+ }
+ },
+
+ mounted() {
+ if (this.column.antennaId == null) {
+ this.setAntenna();
+ }
+ },
+
+ methods: {
+ async setAntenna() {
+ const antennas = await os.api('antennas/list');
+ const { canceled, result: antenna } = await os.dialog({
+ title: this.$ts.selectAntenna,
+ type: null,
+ select: {
+ items: antennas.map(x => ({
+ value: x, text: x.name
+ })),
+ default: this.column.antennaId
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ updateColumn(this.column.id, {
+ antennaId: antenna.id
+ });
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/ui/deck/column-core.vue b/packages/client/src/ui/deck/column-core.vue
new file mode 100644
index 0000000000..5393bac736
--- /dev/null
+++ b/packages/client/src/ui/deck/column-core.vue
@@ -0,0 +1,52 @@
+<template>
+<!-- TODO: リファクタの余地がありそう -->
+<XMainColumn v-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="$emit('parent-focus', $event)"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XMainColumn from './main-column.vue';
+import XTlColumn from './tl-column.vue';
+import XAntennaColumn from './antenna-column.vue';
+import XListColumn from './list-column.vue';
+import XNotificationsColumn from './notifications-column.vue';
+import XWidgetsColumn from './widgets-column.vue';
+import XMentionsColumn from './mentions-column.vue';
+import XDirectColumn from './direct-column.vue';
+
+export default defineComponent({
+ components: {
+ XMainColumn,
+ XTlColumn,
+ XAntennaColumn,
+ XListColumn,
+ XNotificationsColumn,
+ XWidgetsColumn,
+ XMentionsColumn,
+ XDirectColumn
+ },
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: false,
+ default: false
+ }
+ },
+ methods: {
+ focus() {
+ this.$children[0].focus();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue
new file mode 100644
index 0000000000..fe112e3039
--- /dev/null
+++ b/packages/client/src/ui/deck/column.vue
@@ -0,0 +1,408 @@
+<template>
+<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため -->
+<section class="dnpfarvg _panel _narrow_" :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }"
+ @dragover.prevent.stop="onDragover"
+ @dragleave="onDragleave"
+ @drop.prevent.stop="onDrop"
+ v-hotkey="keymap"
+ :style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + 'px' }"
+>
+ <header :class="{ indicated }"
+ draggable="true"
+ @click="goTop"
+ @dragstart="onDragstart"
+ @dragend="onDragend"
+ @contextmenu.prevent.stop="onContextmenu"
+ >
+ <button class="toggleActive _button" @click="toggleActive" v-if="isStacked && !isMainColumn">
+ <template v-if="active"><i class="fas fa-angle-up"></i></template>
+ <template v-else><i class="fas fa-angle-down"></i></template>
+ </button>
+ <div class="action">
+ <slot name="action"></slot>
+ </div>
+ <span class="header"><slot name="header"></slot></span>
+ <button v-if="func" class="menu _button" v-tooltip="func.title" @click.stop="func.handler"><i :class="func.icon || 'fas fa-cog'"></i></button>
+ </header>
+ <div ref="body" v-show="active">
+ <slot></slot>
+ </div>
+</section>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn } from './deck-store';
+import { deckStore } from './deck-store';
+
+export default defineComponent({
+ provide: {
+ shouldHeaderThin: true,
+ shouldOmitHeaderTitle: true,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: false,
+ default: null
+ },
+ isStacked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ func: {
+ type: Object,
+ required: false,
+ default: null
+ },
+ naked: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ indicated: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+
+ data() {
+ return {
+ deckStore,
+ dragging: false,
+ draghover: false,
+ dropready: false,
+ };
+ },
+
+ computed: {
+ isMainColumn(): boolean {
+ return this.column.type === 'main';
+ },
+
+ active(): boolean {
+ return this.column.active !== false;
+ },
+
+ keymap(): any {
+ return {
+ 'shift+up': () => this.$parent.$emit('parent-focus', 'up'),
+ 'shift+down': () => this.$parent.$emit('parent-focus', 'down'),
+ 'shift+left': () => this.$parent.$emit('parent-focus', 'left'),
+ 'shift+right': () => this.$parent.$emit('parent-focus', 'right'),
+ };
+ }
+ },
+
+ watch: {
+ active(v) {
+ this.$emit('change-active-state', v);
+ },
+
+ dragging(v) {
+ os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd');
+ }
+ },
+
+ mounted() {
+ os.deckGlobalEvents.on('column.dragStart', this.onOtherDragStart);
+ os.deckGlobalEvents.on('column.dragEnd', this.onOtherDragEnd);
+ },
+
+ beforeUnmount() {
+ os.deckGlobalEvents.off('column.dragStart', this.onOtherDragStart);
+ os.deckGlobalEvents.off('column.dragEnd', this.onOtherDragEnd);
+ },
+
+ methods: {
+ onOtherDragStart() {
+ this.dropready = true;
+ },
+
+ onOtherDragEnd() {
+ this.dropready = false;
+ },
+
+ toggleActive() {
+ if (!this.isStacked) return;
+ updateColumn(this.column.id, {
+ active: !this.column.active
+ });
+ },
+
+ getMenu() {
+ const items = [{
+ icon: 'fas fa-pencil-alt',
+ text: this.$ts.edit,
+ action: async () => {
+ const { canceled, result } = await os.form(this.column.name, {
+ name: {
+ type: 'string',
+ label: this.$ts.name,
+ default: this.column.name
+ },
+ width: {
+ type: 'number',
+ label: this.$ts.width,
+ default: this.column.width
+ },
+ flexible: {
+ type: 'boolean',
+ label: this.$ts.flexible,
+ default: this.column.flexible
+ }
+ });
+ if (canceled) return;
+ updateColumn(this.column.id, result);
+ }
+ }, null, {
+ icon: 'fas fa-arrow-left',
+ text: this.$ts._deck.swapLeft,
+ action: () => {
+ swapLeftColumn(this.column.id);
+ }
+ }, {
+ icon: 'fas fa-arrow-right',
+ text: this.$ts._deck.swapRight,
+ action: () => {
+ swapRightColumn(this.column.id);
+ }
+ }, this.isStacked ? {
+ icon: 'fas fa-arrow-up',
+ text: this.$ts._deck.swapUp,
+ action: () => {
+ swapUpColumn(this.column.id);
+ }
+ } : undefined, this.isStacked ? {
+ icon: 'fas fa-arrow-down',
+ text: this.$ts._deck.swapDown,
+ action: () => {
+ swapDownColumn(this.column.id);
+ }
+ } : undefined, null, {
+ icon: 'fas fa-window-restore',
+ text: this.$ts._deck.stackLeft,
+ action: () => {
+ stackLeftColumn(this.column.id);
+ }
+ }, this.isStacked ? {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts._deck.popRight,
+ action: () => {
+ popRightColumn(this.column.id);
+ }
+ } : undefined, null, {
+ icon: 'fas fa-trash-alt',
+ text: this.$ts.remove,
+ danger: true,
+ action: () => {
+ removeColumn(this.column.id);
+ }
+ }];
+
+ return items;
+ },
+
+ onContextmenu(e) {
+ os.contextMenu(this.getMenu(), e);
+ },
+
+ goTop() {
+ this.$refs.body.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ },
+
+ onDragstart(e) {
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, this.column.id);
+
+ // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう
+ // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
+ setTimeout(() => {
+ this.dragging = true;
+ }, 10);
+ },
+
+ onDragend(e) {
+ this.dragging = false;
+ },
+
+ onDragover(e) {
+ // 自分自身がドラッグされている場合
+ if (this.dragging) {
+ // 自分自身にはドロップさせない
+ e.dataTransfer.dropEffect = 'none';
+ return;
+ }
+
+ const isDeckColumn = e.dataTransfer.types[0] == _DATA_TRANSFER_DECK_COLUMN_;
+
+ e.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none';
+
+ if (!this.dragging && isDeckColumn) this.draghover = true;
+ },
+
+ onDragleave() {
+ this.draghover = false;
+ },
+
+ onDrop(e) {
+ this.draghover = false;
+ os.deckGlobalEvents.emit('column.dragEnd');
+
+ const id = e.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_);
+ if (id != null && id != '') {
+ swapColumn(this.column.id, id);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.dnpfarvg {
+ --root-margin: 10px;
+
+ height: 100%;
+ overflow: hidden;
+ contain: content;
+ box-shadow: 0 0 8px 0 var(--shadow);
+
+ &.draghover {
+ box-shadow: 0 0 0 2px var(--focus);
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ z-index: 1000;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: var(--focus);
+ }
+ }
+
+ &.dragging {
+ box-shadow: 0 0 0 2px var(--focus);
+ }
+
+ &.dropready {
+ * {
+ pointer-events: none;
+ }
+ }
+
+ &:not(.active) {
+ flex-basis: var(--deckColumnHeaderHeight);
+ min-height: var(--deckColumnHeaderHeight);
+
+ > header.indicated {
+ box-shadow: 4px 0px var(--accent) inset;
+ }
+ }
+
+ &.naked {
+ background: var(--acrylicBg) !important;
+ -webkit-backdrop-filter: var(--blur, blur(10px));
+ backdrop-filter: var(--blur, blur(10px));
+
+ > header {
+ background: transparent;
+ box-shadow: none;
+
+ > button {
+ color: var(--fg);
+ }
+ }
+ }
+
+ &.paged {
+ background: var(--bg) !important;
+ }
+
+ > header {
+ position: relative;
+ display: flex;
+ z-index: 2;
+ line-height: var(--deckColumnHeaderHeight);
+ height: var(--deckColumnHeaderHeight);
+ padding: 0 16px;
+ font-size: 0.9em;
+ color: var(--panelHeaderFg);
+ background: var(--panelHeaderBg);
+ box-shadow: 0 1px 0 0 var(--panelHeaderDivider);
+ cursor: pointer;
+
+ &, * {
+ user-select: none;
+ }
+
+ &.indicated {
+ box-shadow: 0 3px 0 0 var(--accent);
+ }
+
+ > .header {
+ display: inline-block;
+ align-items: center;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ > span:only-of-type {
+ width: 100%;
+ }
+
+ > .toggleActive,
+ > .action > ::v-deep(*),
+ > .menu {
+ z-index: 1;
+ width: var(--deckColumnHeaderHeight);
+ line-height: var(--deckColumnHeaderHeight);
+ font-size: 16px;
+ color: var(--faceTextButton);
+
+ &:hover {
+ color: var(--faceTextButtonHover);
+ }
+
+ &:active {
+ color: var(--faceTextButtonActive);
+ }
+ }
+
+ > .toggleActive, > .action {
+ margin-left: -16px;
+ }
+
+ > .action {
+ z-index: 1;
+ }
+
+ > .action:empty {
+ display: none;
+ }
+
+ > .menu {
+ margin-left: auto;
+ margin-right: -16px;
+ }
+ }
+
+ > div {
+ height: calc(100% - var(--deckColumnHeaderHeight));
+ overflow: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ box-sizing: border-box;
+ }
+}
+</style>
diff --git a/packages/client/src/ui/deck/deck-store.ts b/packages/client/src/ui/deck/deck-store.ts
new file mode 100644
index 0000000000..6b6b02f3f9
--- /dev/null
+++ b/packages/client/src/ui/deck/deck-store.ts
@@ -0,0 +1,298 @@
+import { throttle } from 'throttle-debounce';
+import { i18n } from '@/i18n';
+import { api } from '@/os';
+import { markRaw, watch } from 'vue';
+import { Storage } from '../../pizzax';
+
+type ColumnWidget = {
+ name: string;
+ id: string;
+ data: Record<string, any>;
+};
+
+type Column = {
+ id: string;
+ type: string;
+ name: string | null;
+ width: number;
+ widgets?: ColumnWidget[];
+ active?: boolean;
+};
+
+function copy<T>(x: T): T {
+ return JSON.parse(JSON.stringify(x));
+}
+
+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
+ },
+ columnMargin: {
+ where: 'deviceAccount',
+ default: 16
+ },
+ columnHeaderHeight: {
+ where: 'deviceAccount',
+ default: 42
+ },
+}));
+
+export const loadDeck = async () => {
+ let deck;
+
+ try {
+ deck = await api('i/registry/get', {
+ scope: ['client', 'deck', 'profiles'],
+ key: deckStore.state.profile,
+ });
+ } catch (e) {
+ if (e.code === 'NO_SUCH_KEY') {
+ // 後方互換性のため
+ if (deckStore.state.profile === 'default') {
+ saveDeck();
+ return;
+ }
+
+ deckStore.set('columns', [{
+ id: 'a',
+ type: 'main',
+ name: i18n.locale._deck._columns.main,
+ width: 350,
+ }, {
+ id: 'b',
+ type: 'notifications',
+ name: i18n.locale._deck._columns.notifications,
+ width: 330,
+ }]);
+ deckStore.set('layout', [['a'], ['b']]);
+ return;
+ }
+ throw e;
+ }
+
+ 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 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 = copy(deckStore.state.layout);
+ layout[aX][aY] = b;
+ layout[bX][bY] = a;
+ deckStore.set('layout', layout);
+ saveDeck();
+}
+
+export function swapLeftColumn(id: Column['id']) {
+ const layout = copy(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 = copy(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 = copy(deckStore.state.layout);
+ const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
+ const ids = copy(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 = copy(deckStore.state.layout);
+ const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
+ const ids = copy(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 = copy(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 = copy(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 = copy(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 = copy(deckStore.state.columns);
+ const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+ const column = copy(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 = copy(deckStore.state.columns);
+ const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+ const column = copy(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 = copy(deckStore.state.columns);
+ const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+ const column = copy(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, data: any) {
+ const columns = copy(deckStore.state.columns);
+ const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+ const column = copy(deckStore.state.columns[columnIndex]);
+ if (column == null) return;
+ column.widgets = column.widgets.map(w => w.id === widgetId ? {
+ ...w,
+ data: data
+ } : w);
+ columns[columnIndex] = column;
+ deckStore.set('columns', columns);
+ saveDeck();
+}
+
+export function updateColumn(id: Column['id'], column: Partial<Column>) {
+ const columns = copy(deckStore.state.columns);
+ const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
+ const currentColumn = copy(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/client/src/ui/deck/direct-column.vue b/packages/client/src/ui/deck/direct-column.vue
new file mode 100644
index 0000000000..a11b2e82ed
--- /dev/null
+++ b/packages/client/src/ui/deck/direct-column.vue
@@ -0,0 +1,55 @@
+<template>
+<XColumn :column="column" :is-stacked="isStacked">
+ <template #header><i class="fas fa-envelope" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+ <XNotes :pagination="pagination" @before="before()" @after="after()"/>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XColumn from './column.vue';
+import XNotes from '@/components/notes.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XNotes
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ params: () => ({
+ visibility: 'specified'
+ })
+ },
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/ui/deck/list-column.vue b/packages/client/src/ui/deck/list-column.vue
new file mode 100644
index 0000000000..3ebba8032f
--- /dev/null
+++ b/packages/client/src/ui/deck/list-column.vue
@@ -0,0 +1,80 @@
+<template>
+<XColumn :func="{ handler: setList, title: $ts.selectList }" :column="column" :is-stacked="isStacked">
+ <template #header>
+ <i class="fas fa-list-ul"></i><span style="margin-left: 8px;">{{ column.name }}</span>
+ </template>
+
+ <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => $emit('loaded')"/>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XColumn from './column.vue';
+import XTimeline from '@/components/timeline.vue';
+import * as os from '@/os';
+import { updateColumn } from './deck-store';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XTimeline,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ watch: {
+ mediaOnly() {
+ (this.$refs.timeline as any).reload();
+ }
+ },
+
+ mounted() {
+ if (this.column.listId == null) {
+ this.setList();
+ }
+ },
+
+ methods: {
+ async setList() {
+ const lists = await os.api('users/lists/list');
+ const { canceled, result: list } = await os.dialog({
+ title: this.$ts.selectList,
+ type: null,
+ select: {
+ items: lists.map(x => ({
+ value: x, text: x.name
+ })),
+ default: this.column.listId
+ },
+ showCancelButton: true
+ });
+ if (canceled) return;
+ updateColumn(this.column.id, {
+ listId: list.id
+ });
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue
new file mode 100644
index 0000000000..744056881c
--- /dev/null
+++ b/packages/client/src/ui/deck/main-column.vue
@@ -0,0 +1,91 @@
+<template>
+<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked">
+ <template #header>
+ <template v-if="pageInfo">
+ <i :class="pageInfo.icon"></i>
+ {{ pageInfo.title }}
+ </template>
+ </template>
+
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <router-view v-slot="{ Component }">
+ <transition>
+ <keep-alive :include="['timeline']">
+ <component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/>
+ </keep-alive>
+ </transition>
+ </router-view>
+ </MkStickyContainer>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XColumn from './column.vue';
+import XNotes from '@/components/notes.vue';
+import { deckStore } from '@/ui/deck/deck-store';
+import * as os from '@/os';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XNotes
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ deckStore,
+ pageInfo: null,
+ }
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ back() {
+ history.back();
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+ if (window.getSelection().toString() !== '') return;
+ const path = this.$route.path;
+ os.contextMenu([{
+ type: 'label',
+ text: path,
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(path);
+ }
+ }], e);
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/ui/deck/mentions-column.vue b/packages/client/src/ui/deck/mentions-column.vue
new file mode 100644
index 0000000000..7dd06989cb
--- /dev/null
+++ b/packages/client/src/ui/deck/mentions-column.vue
@@ -0,0 +1,52 @@
+<template>
+<XColumn :column="column" :is-stacked="isStacked">
+ <template #header><i class="fas fa-at" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+ <XNotes :pagination="pagination" @before="before()" @after="after()"/>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import Progress from '@/scripts/loading';
+import XColumn from './column.vue';
+import XNotes from '@/components/notes.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XNotes
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ pagination: {
+ endpoint: 'notes/mentions',
+ limit: 10,
+ },
+ }
+ },
+
+ methods: {
+ before() {
+ Progress.start();
+ },
+
+ after() {
+ Progress.done();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/ui/deck/notifications-column.vue b/packages/client/src/ui/deck/notifications-column.vue
new file mode 100644
index 0000000000..f8f406cdd1
--- /dev/null
+++ b/packages/client/src/ui/deck/notifications-column.vue
@@ -0,0 +1,53 @@
+<template>
+<XColumn :column="column" :is-stacked="isStacked" :func="{ handler: func, title: $ts.notificationSetting }">
+ <template #header><i class="fas fa-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+ <XNotifications :include-types="column.includingTypes"/>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XColumn from './column.vue';
+import XNotifications from '@/components/notifications.vue';
+import * as os from '@/os';
+import { updateColumn } from './deck-store';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XNotifications
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ }
+ },
+
+ methods: {
+ func() {
+ os.popup(import('@/components/notification-setting-window.vue'), {
+ includingTypes: this.column.includingTypes,
+ }, {
+ done: async (res) => {
+ const { includingTypes } = res;
+ updateColumn(this.column.id, {
+ includingTypes: includingTypes
+ });
+ },
+ }, 'closed');
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/ui/deck/tl-column.vue b/packages/client/src/ui/deck/tl-column.vue
new file mode 100644
index 0000000000..faf692c447
--- /dev/null
+++ b/packages/client/src/ui/deck/tl-column.vue
@@ -0,0 +1,137 @@
+<template>
+<XColumn :func="{ handler: setType, title: $ts.timeline }" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState">
+ <template #header>
+ <i v-if="column.tl === 'home'" class="fas fa-home"></i>
+ <i v-else-if="column.tl === 'local'" class="fas fa-comments"></i>
+ <i v-else-if="column.tl === 'social'" class="fas fa-share-alt"></i>
+ <i v-else-if="column.tl === 'global'" class="fas fa-globe"></i>
+ <span style="margin-left: 8px;">{{ column.name }}</span>
+ </template>
+
+ <div class="iwaalbte" v-if="disabled">
+ <p>
+ <i class="fas fa-minus-circle"></i>
+ {{ $t('disabled-timeline.title') }}
+ </p>
+ <p class="desc">{{ $t('disabled-timeline.description') }}</p>
+ </div>
+ <XTimeline v-else-if="column.tl" ref="timeline" :src="column.tl" @after="() => $emit('loaded')" @queue="queueUpdated" @note="onNote" :key="column.tl"/>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XColumn from './column.vue';
+import XTimeline from '@/components/timeline.vue';
+import * as os from '@/os';
+import { removeColumn, updateColumn } from './deck-store';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XTimeline,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true
+ },
+ isStacked: {
+ type: Boolean,
+ required: true
+ }
+ },
+
+ data() {
+ return {
+ disabled: false,
+ indicated: false,
+ columnActive: true,
+ };
+ },
+
+ watch: {
+ mediaOnly() {
+ (this.$refs.timeline as any).reload();
+ }
+ },
+
+ mounted() {
+ if (this.column.tl == null) {
+ this.setType();
+ } else {
+ this.disabled = !this.$i.isModerator && !this.$i.isAdmin && (
+ this.$instance.disableLocalTimeline && ['local', 'social'].includes(this.column.tl) ||
+ this.$instance.disableGlobalTimeline && ['global'].includes(this.column.tl));
+ }
+ },
+
+ methods: {
+ async setType() {
+ const { canceled, result: src } = await os.dialog({
+ title: this.$ts.timeline,
+ type: null,
+ select: {
+ items: [{
+ value: 'home', text: this.$ts._timelines.home
+ }, {
+ value: 'local', text: this.$ts._timelines.local
+ }, {
+ value: 'social', text: this.$ts._timelines.social
+ }, {
+ value: 'global', text: this.$ts._timelines.global
+ }]
+ },
+ });
+ if (canceled) {
+ if (this.column.tl == null) {
+ removeColumn(this.column.id);
+ }
+ return;
+ }
+ updateColumn(this.column.id, {
+ tl: src
+ });
+ },
+
+ queueUpdated(q) {
+ if (this.columnActive) {
+ this.indicated = q !== 0;
+ }
+ },
+
+ onNote() {
+ if (!this.columnActive) {
+ this.indicated = true;
+ }
+ },
+
+ onChangeActiveState(state) {
+ this.columnActive = state;
+
+ if (this.columnActive) {
+ this.indicated = false;
+ }
+ },
+
+ focus() {
+ (this.$refs.timeline as any).focus();
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.iwaalbte {
+ text-align: center;
+
+ > p {
+ margin: 16px;
+
+ &.desc {
+ font-size: 14px;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/deck/widgets-column.vue b/packages/client/src/ui/deck/widgets-column.vue
new file mode 100644
index 0000000000..8c3a95ac2b
--- /dev/null
+++ b/packages/client/src/ui/deck/widgets-column.vue
@@ -0,0 +1,71 @@
+<template>
+<XColumn :func="{ handler: func, title: $ts.editWidgets }" :naked="true" :column="column" :is-stacked="isStacked">
+ <template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template>
+
+ <div class="wtdtxvec">
+ <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
+ </div>
+</XColumn>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XWidgets from '@/components/widgets.vue';
+import XColumn from './column.vue';
+import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store';
+
+export default defineComponent({
+ components: {
+ XColumn,
+ XWidgets,
+ },
+
+ props: {
+ column: {
+ type: Object,
+ required: true,
+ },
+ isStacked: {
+ type: Boolean,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ edit: false,
+ };
+ },
+
+ methods: {
+ addWidget(widget) {
+ addColumnWidget(this.column.id, widget);
+ },
+
+ removeWidget(widget) {
+ removeColumnWidget(this.column.id, widget);
+ },
+
+ updateWidget({ id, data }) {
+ updateColumnWidget(this.column.id, id, data);
+ },
+
+ updateWidgets(widgets) {
+ setColumnWidgets(this.column.id, widgets);
+ },
+
+ func() {
+ this.edit = !this.edit;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wtdtxvec {
+ --margin: 8px;
+ --panelBorder: none;
+
+ padding: 0 var(--margin);
+}
+</style>
diff --git a/packages/client/src/ui/desktop.vue b/packages/client/src/ui/desktop.vue
new file mode 100644
index 0000000000..17783c58e3
--- /dev/null
+++ b/packages/client/src/ui/desktop.vue
@@ -0,0 +1,70 @@
+<template>
+<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}">
+ <XSidebar ref="nav" class="sidebar"/>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { host } from '@/config';
+import { search } from '@/scripts/search';
+import XCommon from './_common_/common.vue';
+import * as os from '@/os';
+import XSidebar from '@/ui/_common_/sidebar.vue';
+import { menuDef } from '@/menu';
+import { ColdDeviceStorage } from '@/store';
+
+export default defineComponent({
+ components: {
+ XCommon,
+ XSidebar
+ },
+
+ provide() {
+ return {
+ navHook: (url) => {
+ os.pageWindow(url);
+ }
+ };
+ },
+
+ data() {
+ return {
+ host: host,
+ menuDef: menuDef,
+ wallpaper: localStorage.getItem('wallpaper') != null,
+ };
+ },
+
+ computed: {
+ menu(): string[] {
+ return this.$store.state.menu;
+ },
+ },
+
+ created() {
+ if (window.innerWidth < 1024) {
+ localStorage.setItem('ui', 'default');
+ location.reload();
+ }
+ },
+
+ methods: {
+ help() {
+ window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+ height: 100vh;
+ width: 100vw;
+}
+</style>
+
+<style lang="scss">
+</style>
diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue
new file mode 100644
index 0000000000..6d00bb6048
--- /dev/null
+++ b/packages/client/src/ui/universal.vue
@@ -0,0 +1,402 @@
+<template>
+<div class="mk-app" :class="{ wallpaper }">
+ <XSidebar ref="nav" class="sidebar"/>
+
+ <div class="contents" ref="contents" @contextmenu.stop="onContextmenu" :style="{ background: pageInfo?.bg }">
+ <main ref="main">
+ <div class="content">
+ <MkStickyContainer>
+ <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template>
+ <router-view v-slot="{ Component }">
+ <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+ <keep-alive :include="['timeline']">
+ <component :is="Component" :ref="changePage"/>
+ </keep-alive>
+ </transition>
+ </router-view>
+ </MkStickyContainer>
+ </div>
+ <div class="spacer"></div>
+ </main>
+ </div>
+
+ <XSide v-if="isDesktop" class="side" ref="side"/>
+
+ <div v-if="isDesktop" class="widgets" ref="widgets">
+ <XWidgets @mounted="attachSticky"/>
+ </div>
+
+ <div class="buttons" :class="{ navHidden }">
+ <button class="button nav _button" @click="showNav" ref="navButton"><i class="fas fa-bars"></i><span v-if="navIndicated" class="indicator"><i class="fas fa-circle"></i></span></button>
+ <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button>
+ <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button>
+ <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
+ <button class="button post _button" @click="post"><i class="fas fa-pencil-alt"></i></button>
+ </div>
+
+ <button class="widgetButton _button" :class="{ navHidden }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button>
+
+ <transition name="tray-back">
+ <div class="tray-back _modalBg"
+ v-if="widgetsShowing"
+ @click="widgetsShowing = false"
+ @touchstart.passive="widgetsShowing = false"
+ ></div>
+ </transition>
+
+ <transition name="tray">
+ <XWidgets v-if="widgetsShowing" class="tray"/>
+ </transition>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { instanceName } from '@/config';
+import { StickySidebar } from '@/scripts/sticky-sidebar';
+import XSidebar from '@/ui/_common_/sidebar.vue';
+import XCommon from './_common_/common.vue';
+import XSide from './classic.side.vue';
+import * as os from '@/os';
+import { menuDef } from '@/menu';
+import * as symbols from '@/symbols';
+
+const DESKTOP_THRESHOLD = 1100;
+
+export default defineComponent({
+ components: {
+ XCommon,
+ XSidebar,
+ XWidgets: defineAsyncComponent(() => import('./universal.widgets.vue')),
+ XSide, // NOTE: dynamic importするとAsyncComponentWrapperが間に入るせいでref取得できなくて面倒になる
+ },
+
+ provide() {
+ return {
+ sideViewHook: this.isDesktop ? (url) => {
+ this.$refs.side.navigate(url);
+ } : null
+ };
+ },
+
+ data() {
+ return {
+ pageInfo: null,
+ isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+ menuDef: menuDef,
+ navHidden: false,
+ widgetsShowing: false,
+ wallpaper: localStorage.getItem('wallpaper') != null,
+ };
+ },
+
+ computed: {
+ navIndicated(): boolean {
+ for (const def in this.menuDef) {
+ if (def === 'notifications') continue; // 通知は下にボタンとして表示されてるから
+ if (this.menuDef[def].indicated) return true;
+ }
+ return false;
+ }
+ },
+
+ created() {
+ document.documentElement.style.overflowY = 'scroll';
+
+ if (this.$store.state.widgets.length === 0) {
+ this.$store.set('widgets', [{
+ name: 'calendar',
+ id: 'a', place: 'right', data: {}
+ }, {
+ name: 'notifications',
+ id: 'b', place: 'right', data: {}
+ }, {
+ name: 'trends',
+ id: 'c', place: 'right', data: {}
+ }]);
+ }
+ },
+
+ mounted() {
+ this.adjustUI();
+
+ const ro = new ResizeObserver((entries, observer) => {
+ this.adjustUI();
+ });
+
+ ro.observe(this.$refs.contents);
+
+ window.addEventListener('resize', this.adjustUI, { passive: true });
+
+ if (!this.isDesktop) {
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
+ }, { passive: true });
+ }
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ document.title = `${this.pageInfo.title} | ${instanceName}`;
+ }
+ },
+
+ adjustUI() {
+ const navWidth = this.$refs.nav.$el.offsetWidth;
+ this.navHidden = navWidth === 0;
+ },
+
+ showNav() {
+ this.$refs.nav.show();
+ },
+
+ attachSticky(el) {
+ const sticky = new StickySidebar(this.$refs.widgets);
+ window.addEventListener('scroll', () => {
+ sticky.calc(window.scrollY);
+ }, { passive: true });
+ },
+
+ post() {
+ os.post();
+ },
+
+ top() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ back() {
+ history.back();
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+
+ onContextmenu(e) {
+ const isLink = (el: HTMLElement) => {
+ if (el.tagName === 'A') return true;
+ if (el.parentElement) {
+ return isLink(el.parentElement);
+ }
+ };
+ if (isLink(e.target)) return;
+ if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
+ if (window.getSelection().toString() !== '') return;
+ const path = this.$route.path;
+ os.contextMenu([{
+ type: 'label',
+ text: path,
+ }, {
+ icon: 'fas fa-columns',
+ text: this.$ts.openInSideView,
+ action: () => {
+ this.$refs.side.navigate(path);
+ }
+ }, {
+ icon: 'fas fa-window-maximize',
+ text: this.$ts.openInWindow,
+ action: () => {
+ os.pageWindow(path);
+ }
+ }], e);
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.tray-enter-active,
+.tray-leave-active {
+ opacity: 1;
+ transform: translateX(0);
+ transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-enter-from,
+.tray-leave-active {
+ opacity: 0;
+ transform: translateX(240px);
+}
+
+.tray-back-enter-active,
+.tray-back-leave-active {
+ opacity: 1;
+ transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-back-enter-from,
+.tray-back-leave-active {
+ opacity: 0;
+}
+
+.mk-app {
+ $ui-font-size: 1em; // TODO: どこかに集約したい
+ $widgets-hide-threshold: 1090px;
+
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ min-height: calc(var(--vh, 1vh) * 100);
+ box-sizing: border-box;
+ display: flex;
+
+ &.wallpaper {
+ background: var(--wallpaperOverlay);
+ //backdrop-filter: var(--blur, blur(4px));
+ }
+
+ > .sidebar {
+ }
+
+ > .contents {
+ width: 100%;
+ min-width: 0;
+ background: var(--panel);
+
+ > main {
+ min-width: 0;
+
+ > .spacer {
+ height: 82px;
+
+ @media (min-width: ($widgets-hide-threshold + 1px)) {
+ display: none;
+ }
+ }
+ }
+ }
+
+ > .side {
+ min-width: 370px;
+ max-width: 370px;
+ border-left: solid 0.5px var(--divider);
+ }
+
+ > .widgets {
+ padding: 0 var(--margin);
+ border-left: solid 0.5px var(--divider);
+ background: var(--bg);
+
+ @media (max-width: $widgets-hide-threshold) {
+ display: none;
+ }
+ }
+
+ > .widgetButton {
+ display: block;
+ position: fixed;
+ z-index: 1000;
+ bottom: 32px;
+ right: 32px;
+ width: 64px;
+ height: 64px;
+ border-radius: 100%;
+ box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12);
+ font-size: 22px;
+ background: var(--panel);
+
+ &.navHidden {
+ display: none;
+ }
+
+ @media (min-width: ($widgets-hide-threshold + 1px)) {
+ display: none;
+ }
+ }
+
+ > .buttons {
+ position: fixed;
+ z-index: 1000;
+ bottom: 0;
+ padding: 16px;
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ -webkit-backdrop-filter: var(--blur, blur(32px));
+ backdrop-filter: var(--blur, blur(32px));
+ background-color: var(--header);
+
+ &:not(.navHidden) {
+ display: none;
+ }
+
+ > .button {
+ position: relative;
+ flex: 1;
+ padding: 0;
+ margin: auto;
+ height: 64px;
+ border-radius: 8px;
+ background: var(--panel);
+ color: var(--fg);
+
+ &:not(:last-child) {
+ margin-right: 12px;
+ }
+
+ @media (max-width: 400px) {
+ height: 60px;
+
+ &:not(:last-child) {
+ margin-right: 8px;
+ }
+ }
+
+ &:hover {
+ background: var(--X2);
+ }
+
+ > .indicator {
+ position: absolute;
+ top: 0;
+ left: 0;
+ color: var(--indicator);
+ font-size: 16px;
+ animation: blink 1s infinite;
+ }
+
+ &:first-child {
+ margin-left: 0;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+
+ > * {
+ font-size: 22px;
+ }
+
+ &:disabled {
+ cursor: default;
+
+ > * {
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ > .tray-back {
+ z-index: 1001;
+ }
+
+ > .tray {
+ position: fixed;
+ top: 0;
+ right: 0;
+ z-index: 1001;
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ height: calc(var(--vh, 1vh) * 100);
+ padding: var(--margin);
+ box-sizing: border-box;
+ overflow: auto;
+ background: var(--bg);
+ }
+}
+</style>
+
+<style lang="scss">
+</style>
diff --git a/packages/client/src/ui/universal.widgets.vue b/packages/client/src/ui/universal.widgets.vue
new file mode 100644
index 0000000000..37911d6624
--- /dev/null
+++ b/packages/client/src/ui/universal.widgets.vue
@@ -0,0 +1,79 @@
+<template>
+<div class="efzpzdvf">
+ <XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
+
+ <button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-check"></i> {{ $ts.editWidgetsExit }}</button>
+ <button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><i class="fas fa-pencil-alt"></i> {{ $ts.editWidgets }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import XWidgets from '@/components/widgets.vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ components: {
+ XWidgets
+ },
+
+ emits: ['mounted'],
+
+ data() {
+ return {
+ editMode: false,
+ };
+ },
+
+ mounted() {
+ this.$emit('mounted', this.$el);
+ },
+
+ methods: {
+ addWidget(widget) {
+ this.$store.set('widgets', [{
+ ...widget,
+ place: null,
+ }, ...this.$store.state.widgets]);
+ },
+
+ removeWidget(widget) {
+ this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id));
+ },
+
+ updateWidget({ id, data }) {
+ this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
+ ...w,
+ data: data
+ } : w));
+ },
+
+ updateWidgets(widgets) {
+ this.$store.set('widgets', widgets);
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.efzpzdvf {
+ position: sticky;
+ height: min-content;
+ min-height: 100vh;
+ padding: var(--margin) 0;
+ box-sizing: border-box;
+
+ > * {
+ margin: var(--margin) 0;
+ width: 300px;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ > .add {
+ margin: 0 auto;
+ }
+}
+</style>
diff --git a/packages/client/src/ui/visitor.vue b/packages/client/src/ui/visitor.vue
new file mode 100644
index 0000000000..ec9150d346
--- /dev/null
+++ b/packages/client/src/ui/visitor.vue
@@ -0,0 +1,19 @@
+<template>
+<DesignB/>
+<XCommon/>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import DesignA from './visitor/a.vue';
+import DesignB from './visitor/b.vue';
+import XCommon from './_common_/common.vue';
+
+export default defineComponent({
+ components: {
+ XCommon,
+ DesignA,
+ DesignB,
+ },
+});
+</script>
diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue
new file mode 100644
index 0000000000..d7098f94b3
--- /dev/null
+++ b/packages/client/src/ui/visitor/a.vue
@@ -0,0 +1,260 @@
+<template>
+<div class="mk-app">
+ <div class="banner" v-if="$route.path === '/'" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+ <div>
+ <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+ <div class="about" v-if="meta">
+ <div class="desc" v-html="meta.description || $ts.introMisskey"></div>
+ </div>
+ <div class="action">
+ <button class="_button primary" @click="signup()">{{ $ts.signup }}</button>
+ <button class="_button" @click="signin()">{{ $ts.login }}</button>
+ </div>
+ </div>
+ </div>
+ <div class="banner-mini" v-else :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
+ <div>
+ <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1>
+ </div>
+ </div>
+
+ <div class="main">
+ <div class="contents" ref="contents" :class="{ wallpaper }">
+ <header class="header" ref="header" v-show="$route.path !== '/'">
+ <XHeader :info="pageInfo"/>
+ </header>
+ <main ref="main">
+ <router-view v-slot="{ Component }">
+ <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+ <component :is="Component" :ref="changePage"/>
+ </transition>
+ </router-view>
+ </main>
+ <div class="powered-by">
+ <b><MkA to="/">{{ host }}</MkA></b>
+ <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { host, instanceName } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import MkPagination from '@/components/ui/pagination.vue';
+import MkButton from '@/components/ui/button.vue';
+import XHeader from './header.vue';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+const DESKTOP_THRESHOLD = 1100;
+
+export default defineComponent({
+ components: {
+ XHeader,
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ host,
+ instanceName,
+ pageInfo: null,
+ meta: null,
+ narrow: window.innerWidth < 1280,
+ announcements: {
+ endpoint: 'announcements',
+ limit: 10,
+ },
+ isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 'd': () => {
+ if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+ this.$store.set('darkMode', !this.$store.state.darkMode);
+ },
+ 's': search,
+ 'h|/': this.help
+ };
+ },
+ },
+
+ created() {
+ document.documentElement.style.overflowY = 'scroll';
+
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ },
+
+ mounted() {
+ if (!this.isDesktop) {
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
+ }, { passive: true });
+ }
+ },
+
+ methods: {
+ setParallax(el) {
+ //new simpleParallax(el);
+ },
+
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ top() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ help() {
+ window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+ min-height: 100vh;
+
+ > .banner {
+ position: relative;
+ width: 100%;
+ text-align: center;
+ background-position: center;
+ background-size: cover;
+
+ > div {
+ height: 100%;
+ background: rgba(0, 0, 0, 0.3);
+
+ * {
+ color: #fff;
+ }
+
+ > h1 {
+ margin: 0;
+ padding: 96px 32px 0 32px;
+ text-shadow: 0 0 8px black;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 150px;
+ }
+ }
+
+ > .about {
+ padding: 32px;
+ max-width: 580px;
+ margin: 0 auto;
+ box-sizing: border-box;
+ text-shadow: 0 0 8px black;
+ }
+
+ > .action {
+ padding-bottom: 64px;
+
+ > button {
+ display: inline-block;
+ padding: 10px 20px;
+ box-sizing: border-box;
+ text-align: center;
+ border-radius: 999px;
+ background: var(--panel);
+ color: var(--fg);
+
+ &.primary {
+ background: var(--accent);
+ color: #fff;
+ }
+
+ &:first-child {
+ margin-right: 16px;
+ }
+ }
+ }
+ }
+ }
+
+ > .banner-mini {
+ position: relative;
+ width: 100%;
+ text-align: center;
+ background-position: center;
+ background-size: cover;
+
+ > div {
+ position: relative;
+ z-index: 1;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.3);
+
+ * {
+ color: #fff !important;
+ }
+
+ > header {
+
+ }
+
+ > h1 {
+ margin: 0;
+ padding: 32px;
+ text-shadow: 0 0 8px black;
+
+ > .logo {
+ vertical-align: bottom;
+ max-height: 100px;
+ }
+ }
+ }
+ }
+
+ > .main {
+ > .contents {
+ position: relative;
+ z-index: 1;
+
+ > .header {
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ }
+
+ > .powered-by {
+ padding: 28px;
+ font-size: 14px;
+ text-align: center;
+ border-top: 1px solid var(--divider);
+
+ > small {
+ display: block;
+ margin-top: 8px;
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+}
+</style>
+
+<style lang="scss">
+</style>
diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue
new file mode 100644
index 0000000000..d662187aae
--- /dev/null
+++ b/packages/client/src/ui/visitor/b.vue
@@ -0,0 +1,282 @@
+<template>
+<div class="mk-app">
+ <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a>
+
+ <div class="side" v-if="!narrow && !root">
+ <XKanban class="kanban" full/>
+ </div>
+
+ <div class="main">
+ <XKanban class="banner" :powered-by="root" v-if="narrow && !root"/>
+
+ <div class="contents">
+ <XHeader class="header" :info="pageInfo" v-if="!root"/>
+ <main>
+ <router-view v-slot="{ Component }">
+ <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+ <component :is="Component" :ref="changePage"/>
+ </transition>
+ </router-view>
+ </main>
+ <div class="powered-by" v-if="!root">
+ <b><MkA to="/">{{ host }}</MkA></b>
+ <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
+ </div>
+ </div>
+ </div>
+
+ <transition name="tray-back">
+ <div class="menu-back _modalBg"
+ v-if="showMenu"
+ @click="showMenu = false"
+ @touchstart.passive="showMenu = false"
+ ></div>
+ </transition>
+
+ <transition name="tray">
+ <div v-if="showMenu" class="menu">
+ <MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA>
+ <MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA>
+ <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
+ <MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA>
+ <div class="action">
+ <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
+ <button class="_button" @click="signin()">{{ $ts.login }}</button>
+ </div>
+ </div>
+ </transition>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { host, instanceName } from '@/config';
+import { search } from '@/scripts/search';
+import * as os from '@/os';
+import MkPagination from '@/components/ui/pagination.vue';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+import XHeader from './header.vue';
+import XKanban from './kanban.vue';
+import { ColdDeviceStorage } from '@/store';
+import * as symbols from '@/symbols';
+
+const DESKTOP_THRESHOLD = 1100;
+
+export default defineComponent({
+ components: {
+ XHeader,
+ XKanban,
+ MkPagination,
+ MkButton,
+ },
+
+ data() {
+ return {
+ host,
+ instanceName,
+ pageInfo: null,
+ meta: null,
+ showMenu: false,
+ narrow: window.innerWidth < 1280,
+ announcements: {
+ endpoint: 'announcements',
+ limit: 10,
+ },
+ isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
+ };
+ },
+
+ computed: {
+ keymap(): any {
+ return {
+ 'd': () => {
+ if (ColdDeviceStorage.get('syncDeviceDarkMode')) return;
+ this.$store.set('darkMode', !this.$store.state.darkMode);
+ },
+ 's': search,
+ 'h|/': this.help
+ };
+ },
+
+ root(): boolean {
+ return this.$route.path === '/';
+ },
+ },
+
+ created() {
+ //document.documentElement.style.overflowY = 'scroll';
+
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ },
+
+ mounted() {
+ if (!this.isDesktop) {
+ window.addEventListener('resize', () => {
+ if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
+ }, { passive: true });
+ }
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ top() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ help() {
+ window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+
+ signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ }
+ }
+});
+</script>
+
+<style>
+.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
+</style>
+
+<style lang="scss" scoped>
+.tray-enter-active,
+.tray-leave-active {
+ opacity: 1;
+ transform: translateX(0);
+ transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-enter-from,
+.tray-leave-active {
+ opacity: 0;
+ transform: translateX(-240px);
+}
+
+.tray-back-enter-active,
+.tray-back-leave-active {
+ opacity: 1;
+ transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
+}
+.tray-back-enter-from,
+.tray-back-leave-active {
+ opacity: 0;
+}
+
+.mk-app {
+ display: flex;
+ min-height: 100vh;
+ background-position: center;
+ background-size: cover;
+ background-attachment: fixed;
+
+ > .side {
+ width: 500px;
+ height: 100vh;
+
+ > .kanban {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 500px;
+ height: 100vh;
+ overflow: auto;
+ }
+ }
+
+ > .main {
+ flex: 1;
+ min-width: 0;
+
+ > .banner {
+ }
+
+ > .contents {
+ position: relative;
+ z-index: 1;
+
+ > .powered-by {
+ padding: 28px;
+ font-size: 14px;
+ text-align: center;
+ border-top: 1px solid var(--divider);
+
+ > small {
+ display: block;
+ margin-top: 8px;
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+
+ > .menu-back {
+ position: fixed;
+ z-index: 1001;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ }
+
+ > .menu {
+ position: fixed;
+ z-index: 1001;
+ top: 0;
+ left: 0;
+ width: 240px;
+ height: 100vh;
+ background: var(--panel);
+
+ > .link {
+ display: block;
+ padding: 16px;
+
+ > .icon {
+ margin-right: 1em;
+ }
+ }
+
+ > .action {
+ padding: 16px;
+
+ > button {
+ display: block;
+ width: 100%;
+ padding: 10px;
+ box-sizing: border-box;
+ text-align: center;
+ border-radius: 999px;
+
+ &._button {
+ background: var(--panel);
+ }
+
+ &:first-child {
+ margin-bottom: 16px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/visitor/header.vue b/packages/client/src/ui/visitor/header.vue
new file mode 100644
index 0000000000..5caef1cdd6
--- /dev/null
+++ b/packages/client/src/ui/visitor/header.vue
@@ -0,0 +1,228 @@
+<template>
+<div class="sqxihjet">
+ <div class="wide" v-if="narrow === false">
+ <div class="content">
+ <MkA to="/" class="link" active-class="active"><i class="fas fa-home icon"></i>{{ $ts.home }}</MkA>
+ <MkA to="/explore" class="link" active-class="active"><i class="fas fa-hashtag icon"></i>{{ $ts.explore }}</MkA>
+ <MkA to="/featured" class="link" active-class="active"><i class="fas fa-fire-alt icon"></i>{{ $ts.featured }}</MkA>
+ <MkA to="/channels" class="link" active-class="active"><i class="fas fa-satellite-dish icon"></i>{{ $ts.channel }}</MkA>
+ <div class="page active link" v-if="info">
+ <div class="title">
+ <i v-if="info.icon" class="icon" :class="info.icon"></i>
+ <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
+ <span v-if="info.title" class="text">{{ info.title }}</span>
+ <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
+ </div>
+ <button class="_button action" v-if="info.action" @click.stop="info.action.handler"><!-- TODO --></button>
+ </div>
+ <div class="right">
+ <button class="_button search" @click="search()"><i class="fas fa-search icon"></i><span>{{ $ts.search }}</span></button>
+ <button class="_buttonPrimary signup" @click="signup()">{{ $ts.signup }}</button>
+ <button class="_button login" @click="signin()">{{ $ts.login }}</button>
+ </div>
+ </div>
+ </div>
+ <div class="narrow" v-else-if="narrow === true">
+ <button class="menu _button" @click="$parent.showMenu = true">
+ <i class="fas fa-bars icon"></i>
+ </button>
+ <div class="title" v-if="info">
+ <i v-if="info.icon" class="icon" :class="info.icon"></i>
+ <MkAvatar v-else-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/>
+ <span v-if="info.title" class="text">{{ info.title }}</span>
+ <MkUserName v-else-if="info.userName" :user="info.userName" :nowrap="false" class="text"/>
+ </div>
+ <button class="action _button" v-if="info && info.action" @click.stop="info.action.handler">
+ <!-- TODO -->
+ </button>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import * as os from '@/os';
+import { search } from '@/scripts/search';
+
+export default defineComponent({
+ props: {
+ info: {
+ required: true
+ },
+ },
+
+ data() {
+ return {
+ narrow: null,
+ showMenu: false,
+ };
+ },
+
+ mounted() {
+ this.narrow = this.$el.clientWidth < 1300;
+ },
+
+ methods: {
+ signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ search
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.sqxihjet {
+ $height: 60px;
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 1000;
+ line-height: $height;
+ -webkit-backdrop-filter: var(--blur, blur(32px));
+ backdrop-filter: var(--blur, blur(32px));
+ background-color: var(--X16);
+
+ > .wide {
+ > .content {
+ max-width: 1400px;
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+
+ > .link {
+ $line: 3px;
+ display: inline-block;
+ padding: 0 16px;
+ line-height: $height - ($line * 2);
+ border-top: solid $line transparent;
+ border-bottom: solid $line transparent;
+
+ > .icon {
+ margin-right: 0.5em;
+ }
+
+ &.page {
+ border-bottom-color: var(--accent);
+ }
+ }
+
+ > .page {
+ > .title {
+ display: inline-block;
+ vertical-align: bottom;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ position: relative;
+
+ > .icon + .text {
+ margin-left: 8px;
+ }
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: middle;
+ margin-right: 8px;
+ pointer-events: none;
+ }
+
+ &._button {
+ &:hover {
+ color: var(--fgHighlighted);
+ }
+ }
+
+ &.selected {
+ box-shadow: 0 -2px 0 0 var(--accent) inset;
+ color: var(--fgHighlighted);
+ }
+ }
+
+ > .action {
+ padding: 0 0 0 16px;
+ }
+ }
+
+ > .right {
+ margin-left: auto;
+
+ > .search {
+ background: var(--bg);
+ border-radius: 999px;
+ width: 230px;
+ line-height: $height - 20px;
+ margin-right: 16px;
+ text-align: left;
+
+ > * {
+ opacity: 0.7;
+ }
+
+ > .icon {
+ padding: 0 16px;
+ }
+ }
+
+ > .signup {
+ border-radius: 999px;
+ padding: 0 24px;
+ line-height: $height - 20px;
+ }
+
+ > .login {
+ padding: 0 16px;
+ }
+ }
+ }
+ }
+
+ > .narrow {
+ display: flex;
+
+ > .menu,
+ > .action {
+ width: $height;
+ height: $height;
+ font-size: 20px;
+ }
+
+ > .title {
+ flex: 1;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ position: relative;
+ text-align: center;
+
+ > .icon + .text {
+ margin-left: 8px;
+ }
+
+ > .avatar {
+ $size: 32px;
+ display: inline-block;
+ width: $size;
+ height: $size;
+ vertical-align: middle;
+ margin-right: 8px;
+ pointer-events: none;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/visitor/kanban.vue b/packages/client/src/ui/visitor/kanban.vue
new file mode 100644
index 0000000000..97d210d7e0
--- /dev/null
+++ b/packages/client/src/ui/visitor/kanban.vue
@@ -0,0 +1,256 @@
+<template>
+<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }">
+ <div class="back" :class="{ transparent }"></div>
+ <div class="contents">
+ <div class="wrapper">
+ <h1 v-if="meta" :class="{ full }">
+ <MkA to="/" class="link"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></MkA>
+ </h1>
+ <template v-if="full">
+ <div class="about" v-if="meta">
+ <div class="desc" v-html="meta.description || $ts.introMisskey"></div>
+ </div>
+ <div class="action">
+ <button class="_buttonPrimary" @click="signup()">{{ $ts.signup }}</button>
+ <button class="_button" @click="signin()">{{ $ts.login }}</button>
+ </div>
+ <div class="announcements panel">
+ <header>{{ $ts.announcements }}</header>
+ <MkPagination :pagination="announcements" #default="{items}" class="list">
+ <section class="item" v-for="(announcement, i) in items" :key="announcement.id">
+ <div class="title">{{ announcement.title }}</div>
+ <div class="content">
+ <Mfm :text="announcement.text"/>
+ <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
+ </div>
+ </section>
+ </MkPagination>
+ </div>
+ <div class="powered-by" v-if="poweredBy">
+ <b><MkA to="/">{{ host }}</MkA></b>
+ <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small>
+ </div>
+ </template>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { host, instanceName } from '@/config';
+import * as os from '@/os';
+import MkPagination from '@/components/ui/pagination.vue';
+import XSigninDialog from '@/components/signin-dialog.vue';
+import XSignupDialog from '@/components/signup-dialog.vue';
+import MkButton from '@/components/ui/button.vue';
+
+export default defineComponent({
+ components: {
+ MkPagination,
+ MkButton,
+ },
+
+ props: {
+ full: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ transparent: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ poweredBy: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+
+ data() {
+ return {
+ host,
+ instanceName,
+ pageInfo: null,
+ meta: null,
+ narrow: window.innerWidth < 1280,
+ announcements: {
+ endpoint: 'announcements',
+ limit: 10,
+ },
+ };
+ },
+
+ created() {
+ os.api('meta', { detail: true }).then(meta => {
+ this.meta = meta;
+ });
+ },
+
+ methods: {
+ signin() {
+ os.popup(XSigninDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ },
+
+ signup() {
+ os.popup(XSignupDialog, {
+ autoSet: true
+ }, {}, 'closed');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.rwqkcmrc {
+ position: relative;
+ text-align: center;
+ background-position: center;
+ background-size: cover;
+ // TODO: パララックスにしたい
+
+ > .back {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.3);
+
+ &.transparent {
+ -webkit-backdrop-filter: var(--blur, blur(12px));
+ backdrop-filter: var(--blur, blur(12px));
+ }
+ }
+
+ > .contents {
+ position: relative;
+ z-index: 1;
+ height: inherit;
+ overflow: auto;
+
+ > .wrapper {
+ max-width: 380px;
+ padding: 0 16px;
+ box-sizing: border-box;
+ margin: 0 auto;
+
+ > .panel {
+ -webkit-backdrop-filter: var(--blur, blur(8px));
+ backdrop-filter: var(--blur, blur(8px));
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: var(--radius);
+
+ &, * {
+ color: #fff !important;
+ }
+ }
+
+ > h1 {
+ display: block;
+ margin: 0;
+ padding: 32px 0 32px 0;
+ color: #fff;
+
+ &.full {
+ padding: 64px 0 0 0;
+
+ > .link {
+ > ::v-deep(.logo) {
+ max-height: 130px;
+ }
+ }
+ }
+
+ > .link {
+ display: block;
+
+ > ::v-deep(.logo) {
+ vertical-align: bottom;
+ max-height: 100px;
+ }
+ }
+ }
+
+ > .about {
+ display: block;
+ margin: 24px 0;
+ text-align: center;
+ box-sizing: border-box;
+ text-shadow: 0 0 8px black;
+ color: #fff;
+ }
+
+ > .action {
+ > button {
+ display: block;
+ width: 100%;
+ padding: 10px;
+ box-sizing: border-box;
+ text-align: center;
+ border-radius: 999px;
+
+ &._button {
+ background: var(--panel);
+ }
+
+ &:first-child {
+ margin-bottom: 16px;
+ }
+ }
+ }
+
+ > .announcements {
+ margin: 32px 0;
+ text-align: left;
+
+ > header {
+ padding: 12px 16px;
+ border-bottom: solid 1px rgba(255, 255, 255, 0.5);
+ }
+
+ > .list {
+ max-height: 300px;
+ overflow: auto;
+
+ > .item {
+ padding: 12px 16px;
+
+ & + .item {
+ border-top: solid 1px rgba(255, 255, 255, 0.5);
+ }
+
+ > .title {
+ font-weight: bold;
+ }
+
+ > .content {
+ > img {
+ max-width: 100%;
+ }
+ }
+ }
+ }
+ }
+
+ > .powered-by {
+ padding: 28px;
+ font-size: 14px;
+ text-align: center;
+ border-top: 1px solid rgba(255, 255, 255, 0.5);
+ color: #fff;
+
+ > small {
+ display: block;
+ margin-top: 8px;
+ opacity: 0.5;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue
new file mode 100644
index 0000000000..7c72232cfd
--- /dev/null
+++ b/packages/client/src/ui/zen.vue
@@ -0,0 +1,106 @@
+<template>
+<div class="mk-app">
+ <div class="contents">
+ <header class="header">
+ <MkHeader :info="pageInfo"/>
+ </header>
+ <main ref="main">
+ <div class="content">
+ <router-view v-slot="{ Component }">
+ <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
+ <keep-alive :include="['timeline']">
+ <component :is="Component" :ref="changePage"/>
+ </keep-alive>
+ </transition>
+ </router-view>
+ </div>
+ </main>
+ </div>
+
+ <XCommon/>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, defineAsyncComponent } from 'vue';
+import { host } from '@/config';
+import XCommon from './_common_/common.vue';
+import * as symbols from '@/symbols';
+
+export default defineComponent({
+ components: {
+ XCommon,
+ },
+
+ data() {
+ return {
+ host: host,
+ pageInfo: null,
+ };
+ },
+
+ created() {
+ document.documentElement.style.overflowY = 'scroll';
+ },
+
+ methods: {
+ changePage(page) {
+ if (page == null) return;
+ if (page[symbols.PAGE_INFO]) {
+ this.pageInfo = page[symbols.PAGE_INFO];
+ }
+ },
+
+ top() {
+ window.scroll({ top: 0, behavior: 'smooth' });
+ },
+
+ help() {
+ window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank');
+ },
+
+ onTransition() {
+ if (window._scroll) window._scroll();
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mk-app {
+ $header-height: 52px;
+ $ui-font-size: 1em; // TODO: どこかに集約したい
+
+ // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ min-height: calc(var(--vh, 1vh) * 100);
+ box-sizing: border-box;
+
+ > .contents {
+ padding-top: $header-height;
+
+ > .header {
+ position: fixed;
+ z-index: 1000;
+ top: 0;
+ height: $header-height;
+ width: 100%;
+ line-height: $header-height;
+ text-align: center;
+ //background-color: var(--panel);
+ -webkit-backdrop-filter: var(--blur, blur(32px));
+ backdrop-filter: var(--blur, blur(32px));
+ background-color: var(--header);
+ border-bottom: solid 0.5px var(--divider);
+ }
+
+ > main {
+ > .content {
+ > * {
+ // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
+ min-height: calc((var(--vh, 1vh) * 100) - #{$header-height});
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/activity.calendar.vue b/packages/client/src/widgets/activity.calendar.vue
new file mode 100644
index 0000000000..b833bd65ca
--- /dev/null
+++ b/packages/client/src/widgets/activity.calendar.vue
@@ -0,0 +1,85 @@
+<template>
+<svg viewBox="0 0 21 7">
+ <rect v-for="record in data" class="day"
+ width="1" height="1"
+ :x="record.x" :y="record.date.weekday"
+ rx="1" ry="1"
+ fill="transparent">
+ <title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
+ </rect>
+ <rect v-for="record in data" class="day"
+ :width="record.v" :height="record.v"
+ :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
+ rx="1" ry="1"
+ :fill="record.color"
+ style="pointer-events: none;"/>
+ <rect class="today"
+ width="1" height="1"
+ :x="data[0].x" :y="data[0].date.weekday"
+ rx="1" ry="1"
+ fill="none"
+ stroke-width="0.1"
+ stroke="#f73520"/>
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+export default defineComponent({
+ props: ['data'],
+ created() {
+ for (const d of this.data) {
+ d.total = d.notes + d.replies + d.renotes;
+ }
+ const peak = Math.max.apply(null, this.data.map(d => d.total));
+
+ const now = new Date();
+ const year = now.getFullYear();
+ const month = now.getMonth();
+ const day = now.getDate();
+
+ let x = 20;
+ this.data.slice().forEach((d, i) => {
+ d.x = x;
+
+ const date = new Date(year, month, day - i);
+ d.date = {
+ year: date.getFullYear(),
+ month: date.getMonth(),
+ day: date.getDate(),
+ weekday: date.getDay()
+ };
+
+ d.v = peak === 0 ? 0 : d.total / (peak / 2);
+ if (d.v > 1) d.v = 1;
+ const ch = d.date.weekday === 0 || d.date.weekday === 6 ? 275 : 170;
+ const cs = d.v * 100;
+ const cl = 15 + ((1 - d.v) * 80);
+ d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
+
+ if (d.date.weekday === 0) x--;
+ });
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+svg {
+ display: block;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+
+ > rect {
+ transform-origin: center;
+
+ &.day {
+ &:hover {
+ fill: rgba(#000, 0.05);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/activity.chart.vue b/packages/client/src/widgets/activity.chart.vue
new file mode 100644
index 0000000000..9702d66663
--- /dev/null
+++ b/packages/client/src/widgets/activity.chart.vue
@@ -0,0 +1,107 @@
+<template>
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
+ <polyline
+ :points="pointsNote"
+ fill="none"
+ stroke-width="1"
+ stroke="#41ddde"/>
+ <polyline
+ :points="pointsReply"
+ fill="none"
+ stroke-width="1"
+ stroke="#f7796c"/>
+ <polyline
+ :points="pointsRenote"
+ fill="none"
+ stroke-width="1"
+ stroke="#a1de41"/>
+ <polyline
+ :points="pointsTotal"
+ fill="none"
+ stroke-width="1"
+ stroke="#555"
+ stroke-dasharray="2 2"/>
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import * as os from '@/os';
+
+function dragListen(fn) {
+ window.addEventListener('mousemove', fn);
+ window.addEventListener('mouseleave', dragClear.bind(null, fn));
+ window.addEventListener('mouseup', dragClear.bind(null, fn));
+}
+
+function dragClear(fn) {
+ window.removeEventListener('mousemove', fn);
+ window.removeEventListener('mouseleave', dragClear);
+ window.removeEventListener('mouseup', dragClear);
+}
+
+export default defineComponent({
+ props: ['data'],
+ data() {
+ return {
+ viewBoxX: 147,
+ viewBoxY: 60,
+ zoom: 1,
+ pos: 0,
+ pointsNote: null,
+ pointsReply: null,
+ pointsRenote: null,
+ pointsTotal: null
+ };
+ },
+ created() {
+ for (const d of this.data) {
+ d.total = d.notes + d.replies + d.renotes;
+ }
+
+ this.render();
+ },
+ methods: {
+ render() {
+ const peak = Math.max.apply(null, this.data.map(d => d.total));
+ if (peak != 0) {
+ const data = this.data.slice().reverse();
+ this.pointsNote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' ');
+ this.pointsReply = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' ');
+ this.pointsRenote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' ');
+ this.pointsTotal = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ');
+ }
+ },
+ onMousedown(e) {
+ const clickX = e.clientX;
+ const clickY = e.clientY;
+ const baseZoom = this.zoom;
+ const basePos = this.pos;
+
+ // 動かした時
+ dragListen(me => {
+ let moveLeft = me.clientX - clickX;
+ let moveTop = me.clientY - clickY;
+
+ this.zoom = baseZoom + (-moveTop / 20);
+ this.pos = basePos + moveLeft;
+ if (this.zoom < 1) this.zoom = 1;
+ if (this.pos > 0) this.pos = 0;
+ if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX);
+
+ this.render();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+svg {
+ display: block;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+ cursor: all-scroll;
+}
+</style>
diff --git a/packages/client/src/widgets/activity.vue b/packages/client/src/widgets/activity.vue
new file mode 100644
index 0000000000..eaac1455ad
--- /dev/null
+++ b/packages/client/src/widgets/activity.vue
@@ -0,0 +1,82 @@
+<template>
+<MkContainer :show-header="props.showHeader" :naked="props.transparent">
+ <template #header><i class="fas fa-chart-bar"></i>{{ $ts._widgets.activity }}</template>
+ <template #func><button @click="toggleView()" class="_button"><i class="fas fa-sort"></i></button></template>
+
+ <div>
+ <MkLoading v-if="fetching"/>
+ <template v-else>
+ <XCalendar v-show="props.view === 0" :data="[].concat(activity)"/>
+ <XChart v-show="props.view === 1" :data="[].concat(activity)"/>
+ </template>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import XCalendar from './activity.calendar.vue';
+import XChart from './activity.chart.vue';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'activity',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ view: {
+ type: 'number',
+ default: 0,
+ hidden: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer,
+ XCalendar,
+ XChart,
+ },
+ data() {
+ return {
+ fetching: true,
+ activity: null,
+ };
+ },
+ mounted() {
+ os.api('charts/user/notes', {
+ userId: this.$i.id,
+ span: 'day',
+ limit: 7 * 21
+ }).then(activity => {
+ this.activity = activity.diffs.normal.map((_, i) => ({
+ total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i],
+ notes: activity.diffs.normal[i],
+ replies: activity.diffs.reply[i],
+ renotes: activity.diffs.renote[i]
+ }));
+ this.fetching = false;
+ });
+ },
+ methods: {
+ toggleView() {
+ if (this.props.view === 1) {
+ this.props.view = 0;
+ } else {
+ this.props.view++;
+ }
+ this.save();
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue
new file mode 100644
index 0000000000..8196bff8fa
--- /dev/null
+++ b/packages/client/src/widgets/aichan.vue
@@ -0,0 +1,59 @@
+<template>
+<MkContainer :naked="props.transparent" :show-header="false">
+ <iframe class="dedjhjmo" ref="live2d" @click="touched" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100"></iframe>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import define from './define';
+import MkContainer from '@/components/ui/container.vue';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'ai',
+ props: () => ({
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer,
+ },
+ data() {
+ return {
+ };
+ },
+ mounted() {
+ window.addEventListener('mousemove', ev => {
+ const iframeRect = this.$refs.live2d.getBoundingClientRect();
+ this.$refs.live2d.contentWindow.postMessage({
+ type: 'moveCursor',
+ body: {
+ x: ev.clientX - iframeRect.left,
+ y: ev.clientY - iframeRect.top,
+ }
+ }, '*');
+ }, { passive: true });
+ },
+ methods: {
+ touched() {
+ //if (this.live2d) this.live2d.changeExpression('gurugurume');
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.dedjhjmo {
+ width: 100%;
+ height: 350px;
+ border: none;
+ pointer-events: none;
+}
+</style>
diff --git a/packages/client/src/widgets/aiscript.vue b/packages/client/src/widgets/aiscript.vue
new file mode 100644
index 0000000000..992ec2f8a1
--- /dev/null
+++ b/packages/client/src/widgets/aiscript.vue
@@ -0,0 +1,163 @@
+<template>
+<MkContainer :show-header="props.showHeader">
+ <template #header><i class="fas fa-terminal"></i>{{ $ts._widgets.aiscript }}</template>
+
+ <div class="uylguesu _monospace">
+ <textarea v-model="props.script" placeholder="(1 + 1)"></textarea>
+ <button @click="run" class="_buttonPrimary">RUN</button>
+ <div class="logs">
+ <div v-for="log in logs" class="log" :key="log.id" :class="{ print: log.print }">{{ log.text }}</div>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import * as os from '@/os';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+
+const widget = define({
+ name: 'aiscript',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ script: {
+ type: 'string',
+ multiline: true,
+ default: '(1 + 1)',
+ hidden: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer
+ },
+
+ data() {
+ return {
+ logs: [],
+ };
+ },
+
+ methods: {
+ async run() {
+ this.logs = [];
+ const aiscript = new AiScript(createAiScriptEnv({
+ storageKey: 'widget',
+ token: this.$i?.token,
+ }), {
+ in: (q) => {
+ return new Promise(ok => {
+ os.dialog({
+ title: q,
+ input: {}
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ this.logs.push({
+ id: Math.random(),
+ text: value.type === 'str' ? value.value : utils.valToString(value),
+ print: true
+ });
+ },
+ log: (type, params) => {
+ switch (type) {
+ case 'end': this.logs.push({
+ id: Math.random(),
+ text: utils.valToString(params.val, true),
+ print: false
+ }); break;
+ default: break;
+ }
+ }
+ });
+
+ let ast;
+ try {
+ ast = parse(this.props.script);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ try {
+ await aiscript.exec(ast);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ }
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.uylguesu {
+ text-align: right;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ padding: 16px;
+ color: var(--fg);
+ background: transparent;
+ border: none;
+ border-bottom: solid 0.5px var(--divider);
+ border-radius: 0;
+ box-sizing: border-box;
+ font: inherit;
+
+ &:focus-visible {
+ outline: none;
+ }
+ }
+
+ > button {
+ display: inline-block;
+ margin: 8px;
+ padding: 0 10px;
+ height: 28px;
+ outline: none;
+ border-radius: 4px;
+
+ &:disabled {
+ opacity: 0.7;
+ cursor: default;
+ }
+ }
+
+ > .logs {
+ border-top: solid 0.5px var(--divider);
+ text-align: left;
+ padding: 16px;
+
+ &:empty {
+ display: none;
+ }
+
+ > .log {
+ &:not(.print) {
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/button.vue b/packages/client/src/widgets/button.vue
new file mode 100644
index 0000000000..3417181d0c
--- /dev/null
+++ b/packages/client/src/widgets/button.vue
@@ -0,0 +1,95 @@
+<template>
+<div class="mkw-button">
+ <MkButton :primary="props.colored" full @click="run">
+ {{ props.label }}
+ </MkButton>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import define from './define';
+import MkButton from '@/components/ui/button.vue';
+import * as os from '@/os';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+
+const widget = define({
+ name: 'button',
+ props: () => ({
+ label: {
+ type: 'string',
+ default: 'BUTTON',
+ },
+ colored: {
+ type: 'boolean',
+ default: true,
+ },
+ script: {
+ type: 'string',
+ multiline: true,
+ default: 'Mk:dialog("hello" "world")',
+ },
+ })
+});
+
+export default defineComponent({
+ components: {
+ MkButton
+ },
+ extends: widget,
+ data() {
+ return {
+ };
+ },
+ methods: {
+ async run() {
+ const aiscript = new AiScript(createAiScriptEnv({
+ storageKey: 'widget',
+ token: this.$i?.token,
+ }), {
+ in: (q) => {
+ return new Promise(ok => {
+ os.dialog({
+ title: q,
+ input: {}
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ // nop
+ },
+ log: (type, params) => {
+ // nop
+ }
+ });
+
+ let ast;
+ try {
+ ast = parse(this.props.script);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: 'Syntax error :('
+ });
+ return;
+ }
+ try {
+ await aiscript.exec(ast);
+ } catch (e) {
+ os.dialog({
+ type: 'error',
+ text: e
+ });
+ }
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-button {
+}
+</style>
diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue
new file mode 100644
index 0000000000..545072e87b
--- /dev/null
+++ b/packages/client/src/widgets/calendar.vue
@@ -0,0 +1,204 @@
+<template>
+<div class="mkw-calendar" :class="{ _panel: !props.transparent }">
+ <div class="calendar" :class="{ isHoliday }">
+ <p class="month-and-year">
+ <span class="year">{{ $t('yearX', { year }) }}</span>
+ <span class="month">{{ $t('monthX', { month }) }}</span>
+ </p>
+ <p class="day">{{ $t('dayX', { day }) }}</p>
+ <p class="week-day">{{ weekDay }}</p>
+ </div>
+ <div class="info">
+ <div>
+ <p>{{ $ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p>
+ <div class="meter">
+ <div class="val" :style="{ width: `${dayP}%` }"></div>
+ </div>
+ </div>
+ <div>
+ <p>{{ $ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p>
+ <div class="meter">
+ <div class="val" :style="{ width: `${monthP}%` }"></div>
+ </div>
+ </div>
+ <div>
+ <p>{{ $ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p>
+ <div class="meter">
+ <div class="val" :style="{ width: `${yearP}%` }"></div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'calendar',
+ props: () => ({
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ data() {
+ return {
+ now: new Date(),
+ year: null,
+ month: null,
+ day: null,
+ weekDay: null,
+ yearP: null,
+ dayP: null,
+ monthP: null,
+ isHoliday: null,
+ clock: null
+ };
+ },
+ created() {
+ this.tick();
+ this.clock = setInterval(this.tick, 1000);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ tick() {
+ const now = new Date();
+ const nd = now.getDate();
+ const nm = now.getMonth();
+ const ny = now.getFullYear();
+
+ this.year = ny;
+ this.month = nm + 1;
+ this.day = nd;
+ this.weekDay = [
+ this.$ts._weekday.sunday,
+ this.$ts._weekday.monday,
+ this.$ts._weekday.tuesday,
+ this.$ts._weekday.wednesday,
+ this.$ts._weekday.thursday,
+ this.$ts._weekday.friday,
+ this.$ts._weekday.saturday
+ ][now.getDay()];
+
+ const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime();
+ const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
+ const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
+ const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
+ const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime();
+ const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
+
+ this.dayP = dayNumer / dayDenom * 100;
+ this.monthP = monthNumer / monthDenom * 100;
+ this.yearP = yearNumer / yearDenom * 100;
+
+ this.isHoliday = now.getDay() === 0 || now.getDay() === 6;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-calendar {
+ padding: 16px 0;
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > .calendar {
+ float: left;
+ width: 60%;
+ text-align: center;
+
+ &.isHoliday {
+ > .day {
+ color: #ef95a0;
+ }
+ }
+
+ > p {
+ margin: 0;
+ line-height: 18px;
+ font-size: 0.9em;
+
+ > span {
+ margin: 0 4px;
+ }
+ }
+
+ > .day {
+ margin: 10px 0;
+ line-height: 32px;
+ font-size: 1.75em;
+ }
+ }
+
+ > .info {
+ display: block;
+ float: left;
+ width: 40%;
+ padding: 0 16px 0 0;
+ box-sizing: border-box;
+
+ > div {
+ margin-bottom: 8px;
+
+ &:last-child {
+ margin-bottom: 4px;
+ }
+
+ > p {
+ margin: 0 0 2px 0;
+ font-size: 0.75em;
+ line-height: 18px;
+ opacity: 0.8;
+
+ > b {
+ margin-left: 2px;
+ }
+ }
+
+ > .meter {
+ width: 100%;
+ overflow: hidden;
+ background: var(--X11);
+ border-radius: 8px;
+
+ > .val {
+ height: 4px;
+ transition: width .3s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+ }
+
+ &:nth-child(1) {
+ > .meter > .val {
+ background: #f7796c;
+ }
+ }
+
+ &:nth-child(2) {
+ > .meter > .val {
+ background: #a1de41;
+ }
+ }
+
+ &:nth-child(3) {
+ > .meter > .val {
+ background: #41ddde;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/clock.vue b/packages/client/src/widgets/clock.vue
new file mode 100644
index 0000000000..da0cd65c96
--- /dev/null
+++ b/packages/client/src/widgets/clock.vue
@@ -0,0 +1,55 @@
+<template>
+<MkContainer :naked="props.transparent" :show-header="false">
+ <div class="vubelbmv">
+ <MkAnalogClock class="clock" :thickness="props.thickness"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import define from './define';
+import MkContainer from '@/components/ui/container.vue';
+import MkAnalogClock from '@/components/analog-clock.vue';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'clock',
+ props: () => ({
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ thickness: {
+ type: 'radio',
+ default: 0.1,
+ options: [{
+ value: 0.1, label: 'thin'
+ }, {
+ value: 0.2, label: 'medium'
+ }, {
+ value: 0.3, label: 'thick'
+ }]
+ }
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer,
+ MkAnalogClock
+ },
+});
+</script>
+
+<style lang="scss" scoped>
+.vubelbmv {
+ padding: 8px;
+
+ > .clock {
+ height: 150px;
+ margin: auto;
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/define.ts b/packages/client/src/widgets/define.ts
new file mode 100644
index 0000000000..08a346d97c
--- /dev/null
+++ b/packages/client/src/widgets/define.ts
@@ -0,0 +1,75 @@
+import { defineComponent } from 'vue';
+import { throttle } from 'throttle-debounce';
+import { Form } from '@/scripts/form';
+import * as os from '@/os';
+
+export default function <T extends Form>(data: {
+ name: string;
+ props?: () => T;
+}) {
+ return defineComponent({
+ props: {
+ widget: {
+ type: Object,
+ required: false
+ },
+ settingCallback: {
+ required: false
+ }
+ },
+
+ emits: ['updateProps'],
+
+ data() {
+ return {
+ props: this.widget ? JSON.parse(JSON.stringify(this.widget.data)) : {},
+ save: throttle(3000, () => {
+ this.$emit('updateProps', this.props);
+ }),
+ };
+ },
+
+ computed: {
+ id(): string {
+ return this.widget ? this.widget.id : null;
+ },
+ },
+
+ created() {
+ this.mergeProps();
+
+ this.$watch('props', () => {
+ this.mergeProps();
+ }, { deep: true });
+
+ if (this.settingCallback) this.settingCallback(this.setting);
+ },
+
+ methods: {
+ mergeProps() {
+ if (data.props) {
+ const defaultProps = data.props();
+ for (const prop of Object.keys(defaultProps)) {
+ if (this.props.hasOwnProperty(prop)) continue;
+ this.props[prop] = defaultProps[prop].default;
+ }
+ }
+ },
+
+ async setting() {
+ const form = data.props();
+ for (const item of Object.keys(form)) {
+ form[item].default = this.props[item];
+ }
+ const { canceled, result } = await os.form(data.name, form);
+ if (canceled) return;
+
+ for (const key of Object.keys(result)) {
+ this.props[key] = result[key];
+ }
+
+ this.save();
+ },
+ }
+ });
+}
diff --git a/packages/client/src/widgets/digital-clock.vue b/packages/client/src/widgets/digital-clock.vue
new file mode 100644
index 0000000000..9d32e8b9fe
--- /dev/null
+++ b/packages/client/src/widgets/digital-clock.vue
@@ -0,0 +1,79 @@
+<template>
+<div class="mkw-digitalClock _monospace" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }">
+ <span>
+ <span v-text="hh"></span>
+ <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+ <span v-text="mm"></span>
+ <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
+ <span v-text="ss"></span>
+ <span :style="{ visibility: showColon ? 'visible' : 'hidden' }" v-if="props.showMs">:</span>
+ <span v-text="ms" v-if="props.showMs"></span>
+ </span>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'digitalClock',
+ props: () => ({
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ fontSize: {
+ type: 'number',
+ default: 1.5,
+ step: 0.1,
+ },
+ showMs: {
+ type: 'boolean',
+ default: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ data() {
+ return {
+ clock: null,
+ hh: null,
+ mm: null,
+ ss: null,
+ ms: null,
+ showColon: true,
+ };
+ },
+ created() {
+ this.tick();
+ this.$watch(() => this.props.showMs, () => {
+ if (this.clock) clearInterval(this.clock);
+ this.clock = setInterval(this.tick, this.props.showMs ? 10 : 1000);
+ }, { immediate: true });
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ tick() {
+ const now = new Date();
+ this.hh = now.getHours().toString().padStart(2, '0');
+ this.mm = now.getMinutes().toString().padStart(2, '0');
+ this.ss = now.getSeconds().toString().padStart(2, '0');
+ this.ms = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
+ this.showColon = now.getSeconds() % 2 === 0;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-digitalClock {
+ padding: 16px 0;
+ text-align: center;
+}
+</style>
diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue
new file mode 100644
index 0000000000..85cfb8b845
--- /dev/null
+++ b/packages/client/src/widgets/federation.vue
@@ -0,0 +1,145 @@
+<template>
+<MkContainer :show-header="props.showHeader" :foldable="foldable" :scrollable="scrollable">
+ <template #header><i class="fas fa-globe"></i>{{ $ts._widgets.federation }}</template>
+
+ <div class="wbrkwalb">
+ <MkLoading v-if="fetching"/>
+ <transition-group tag="div" name="chart" class="instances" v-else>
+ <div v-for="(instance, i) in instances" :key="instance.id" class="instance">
+ <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/>
+ <div class="body">
+ <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a>
+ <p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p>
+ </div>
+ <MkMiniChart class="chart" :src="charts[i].requests.received"/>
+ </div>
+ </transition-group>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'federation',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer, MkMiniChart
+ },
+ props: {
+ foldable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ scrollable: {
+ type: Boolean,
+ required: false,
+ default: false
+ },
+ },
+ data() {
+ return {
+ instances: [],
+ charts: [],
+ fetching: true,
+ };
+ },
+ mounted() {
+ this.fetch();
+ this.clock = setInterval(this.fetch, 1000 * 60);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ async fetch() {
+ const instances = await os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 5
+ });
+ const charts = await Promise.all(instances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+ this.instances = instances;
+ this.charts = charts;
+ this.fetching = false;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwalb {
+ $bodyTitleHieght: 18px;
+ $bodyInfoHieght: 16px;
+
+ height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
+ overflow: hidden;
+
+ > .instances {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ > .instance {
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ border-bottom: solid 0.5px var(--divider);
+
+ > img {
+ display: block;
+ width: ($bodyTitleHieght + $bodyInfoHieght);
+ height: ($bodyTitleHieght + $bodyInfoHieght);
+ object-fit: cover;
+ border-radius: 4px;
+ margin-right: 8px;
+ }
+
+ > .body {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+ padding-right: 8px;
+
+ > .a {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: $bodyTitleHieght;
+ }
+
+ > p {
+ margin: 0;
+ font-size: 75%;
+ opacity: 0.7;
+ line-height: $bodyInfoHieght;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .chart {
+ height: 30px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/index.ts b/packages/client/src/widgets/index.ts
new file mode 100644
index 0000000000..51a82af080
--- /dev/null
+++ b/packages/client/src/widgets/index.ts
@@ -0,0 +1,45 @@
+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('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('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('MkwButton', defineAsyncComponent(() => import('./button.vue')));
+ app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
+ app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
+}
+
+export const widgets = [
+ 'memo',
+ 'notifications',
+ 'timeline',
+ 'calendar',
+ 'rss',
+ 'trends',
+ 'clock',
+ 'activity',
+ 'photos',
+ 'digitalClock',
+ 'federation',
+ 'postForm',
+ 'slideshow',
+ 'serverMetric',
+ 'onlineUsers',
+ 'jobQueue',
+ 'button',
+ 'aiscript',
+ 'aichan',
+];
diff --git a/packages/client/src/widgets/job-queue.vue b/packages/client/src/widgets/job-queue.vue
new file mode 100644
index 0000000000..ef440881e5
--- /dev/null
+++ b/packages/client/src/widgets/job-queue.vue
@@ -0,0 +1,183 @@
+<template>
+<div class="mkw-jobQueue _monospace" :class="{ _panel: !props.transparent }">
+ <div class="inbox">
+ <div class="label">Inbox queue<i v-if="inbox.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
+ <div class="values">
+ <div>
+ <div>Process</div>
+ <div :class="{ inc: inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(inbox.activeSincePrevTick) }}</div>
+ </div>
+ <div>
+ <div>Active</div>
+ <div :class="{ inc: inbox.active > prev.inbox.active, dec: inbox.active < prev.inbox.active }">{{ number(inbox.active) }}</div>
+ </div>
+ <div>
+ <div>Delayed</div>
+ <div :class="{ inc: inbox.delayed > prev.inbox.delayed, dec: inbox.delayed < prev.inbox.delayed }">{{ number(inbox.delayed) }}</div>
+ </div>
+ <div>
+ <div>Waiting</div>
+ <div :class="{ inc: inbox.waiting > prev.inbox.waiting, dec: inbox.waiting < prev.inbox.waiting }">{{ number(inbox.waiting) }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="deliver">
+ <div class="label">Deliver queue<i v-if="deliver.waiting > 0" class="fas fa-exclamation-triangle icon"></i></div>
+ <div class="values">
+ <div>
+ <div>Process</div>
+ <div :class="{ inc: deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(deliver.activeSincePrevTick) }}</div>
+ </div>
+ <div>
+ <div>Active</div>
+ <div :class="{ inc: deliver.active > prev.deliver.active, dec: deliver.active < prev.deliver.active }">{{ number(deliver.active) }}</div>
+ </div>
+ <div>
+ <div>Delayed</div>
+ <div :class="{ inc: deliver.delayed > prev.deliver.delayed, dec: deliver.delayed < prev.deliver.delayed }">{{ number(deliver.delayed) }}</div>
+ </div>
+ <div>
+ <div>Waiting</div>
+ <div :class="{ inc: deliver.waiting > prev.deliver.waiting, dec: deliver.waiting < prev.deliver.waiting }">{{ number(deliver.waiting) }}</div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import define from './define';
+import * as os from '@/os';
+import number from '@/filters/number';
+import * as sound from '@/scripts/sound';
+
+const widget = define({
+ name: 'jobQueue',
+ props: () => ({
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ sound: {
+ type: 'boolean',
+ default: false,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ data() {
+ return {
+ connection: markRaw(os.stream.useChannel('queueStats')),
+ inbox: {
+ activeSincePrevTick: 0,
+ active: 0,
+ waiting: 0,
+ delayed: 0,
+ },
+ deliver: {
+ activeSincePrevTick: 0,
+ active: 0,
+ waiting: 0,
+ delayed: 0,
+ },
+ prev: {},
+ sound: sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1)
+ };
+ },
+ created() {
+ for (const domain of ['inbox', 'deliver']) {
+ this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
+ }
+
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 1
+ });
+ },
+ beforeUnmount() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ this.connection.dispose();
+ },
+ methods: {
+ onStats(stats) {
+ for (const domain of ['inbox', 'deliver']) {
+ this.prev[domain] = JSON.parse(JSON.stringify(this[domain]));
+ this[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
+ this[domain].active = stats[domain].active;
+ this[domain].waiting = stats[domain].waiting;
+ this[domain].delayed = stats[domain].delayed;
+
+ if (this[domain].waiting > 0 && this.props.sound && this.sound.paused) {
+ this.sound.play();
+ }
+ }
+ },
+
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ },
+
+ number
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes warnBlink {
+ 0% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+.mkw-jobQueue {
+ font-size: 0.9em;
+
+ > div {
+ padding: 16px;
+
+ &:not(:first-child) {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .label {
+ display: flex;
+
+ > .icon {
+ color: var(--warn);
+ margin-left: auto;
+ animation: warnBlink 1s infinite;
+ }
+ }
+
+ > .values {
+ display: flex;
+
+ > div {
+ flex: 1;
+
+ > div:first-child {
+ opacity: 0.7;
+ }
+
+ > div:last-child {
+ &.inc {
+ color: var(--warn);
+ }
+
+ &.dec {
+ color: var(--success);
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/memo.vue b/packages/client/src/widgets/memo.vue
new file mode 100644
index 0000000000..100f1b2934
--- /dev/null
+++ b/packages/client/src/widgets/memo.vue
@@ -0,0 +1,106 @@
+<template>
+<MkContainer :show-header="props.showHeader">
+ <template #header><i class="fas fa-sticky-note"></i>{{ $ts._widgets.memo }}</template>
+
+ <div class="otgbylcu">
+ <textarea v-model="text" :placeholder="$ts.placeholder" @input="onChange"></textarea>
+ <button @click="saveMemo" :disabled="!changed" class="_buttonPrimary">{{ $ts.save }}</button>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'memo',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer
+ },
+
+ data() {
+ return {
+ text: null,
+ changed: false,
+ timeoutId: null,
+ };
+ },
+
+ created() {
+ this.text = this.$store.state.memo;
+
+ this.$watch(() => this.$store.reactiveState.memo, text => {
+ this.text = text;
+ });
+ },
+
+ methods: {
+ onChange() {
+ this.changed = true;
+ clearTimeout(this.timeoutId);
+ this.timeoutId = setTimeout(this.saveMemo, 1000);
+ },
+
+ saveMemo() {
+ this.$store.set('memo', this.text);
+ this.changed = false;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.otgbylcu {
+ padding-bottom: 28px + 16px;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ padding: 16px;
+ color: var(--fg);
+ background: transparent;
+ border: none;
+ border-bottom: solid 0.5px var(--divider);
+ border-radius: 0;
+ box-sizing: border-box;
+ font: inherit;
+ font-size: 0.9em;
+
+ &:focus-visible {
+ outline: none;
+ }
+ }
+
+ > button {
+ display: block;
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ margin: 0;
+ padding: 0 10px;
+ height: 28px;
+ outline: none;
+ border-radius: 4px;
+
+ &:disabled {
+ opacity: 0.7;
+ cursor: default;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/notifications.vue b/packages/client/src/widgets/notifications.vue
new file mode 100644
index 0000000000..462f39a339
--- /dev/null
+++ b/packages/client/src/widgets/notifications.vue
@@ -0,0 +1,65 @@
+<template>
+<MkContainer :style="`height: ${props.height}px;`" :show-header="props.showHeader" :scrollable="true">
+ <template #header><i class="fas fa-bell"></i>{{ $ts.notifications }}</template>
+ <template #func><button @click="configure()" class="_button"><i class="fas fa-cog"></i></button></template>
+
+ <div>
+ <XNotifications :include-types="props.includingTypes"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import XNotifications from '@/components/notifications.vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'notifications',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ height: {
+ type: 'number',
+ default: 300,
+ },
+ includingTypes: {
+ type: 'array',
+ hidden: true,
+ default: null,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+
+ components: {
+ MkContainer,
+ XNotifications,
+ },
+
+ data() {
+ return {
+ };
+ },
+
+ methods: {
+ configure() {
+ os.popup(import('@/components/notification-setting-window.vue'), {
+ includingTypes: this.props.includingTypes,
+ }, {
+ done: async (res) => {
+ const { includingTypes } = res;
+ this.props.includingTypes = includingTypes;
+ this.save();
+ }
+ }, 'closed');
+ }
+ }
+});
+</script>
diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue
new file mode 100644
index 0000000000..5b889f4816
--- /dev/null
+++ b/packages/client/src/widgets/online-users.vue
@@ -0,0 +1,67 @@
+<template>
+<div class="mkw-onlineUsers" :class="{ _panel: !props.transparent, pad: !props.transparent }">
+ <I18n v-if="onlineUsersCount" :src="$ts.onlineUsersCount" text-tag="span" class="text">
+ <template #n><b>{{ onlineUsersCount }}</b></template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'onlineUsers',
+ props: () => ({
+ transparent: {
+ type: 'boolean',
+ default: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ data() {
+ return {
+ onlineUsersCount: null,
+ clock: null,
+ };
+ },
+ created() {
+ this.tick();
+ this.clock = setInterval(this.tick, 1000 * 15);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ tick() {
+ os.api('get-online-users-count').then(res => {
+ this.onlineUsersCount = res.count;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-onlineUsers {
+ text-align: center;
+
+ &.pad {
+ padding: 16px 0;
+ }
+
+ > .text {
+ ::v-deep(b) {
+ color: #41b781;
+ }
+
+ ::v-deep(span) {
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/photos.vue b/packages/client/src/widgets/photos.vue
new file mode 100644
index 0000000000..0c919526b0
--- /dev/null
+++ b/packages/client/src/widgets/photos.vue
@@ -0,0 +1,113 @@
+<template>
+<MkContainer :show-header="props.showHeader" :naked="props.transparent" :class="$style.root" :data-transparent="props.transparent ? true : null">
+ <template #header><i class="fas fa-camera"></i>{{ $ts._widgets.photos }}</template>
+
+ <div class="">
+ <MkLoading v-if="fetching"/>
+ <div v-else :class="$style.stream">
+ <div v-for="(image, i) in images" :key="i"
+ :class="$style.img"
+ :style="`background-image: url(${thumbnail(image)})`"
+ ></div>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'photos',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer,
+ },
+ data() {
+ return {
+ images: [],
+ fetching: true,
+ connection: null,
+ };
+ },
+ mounted() {
+ this.connection = markRaw(os.stream.useChannel('main'));
+
+ this.connection.on('driveFileCreated', this.onDriveFileCreated);
+
+ os.api('drive/stream', {
+ type: 'image/*',
+ limit: 9
+ }).then(images => {
+ this.images = images;
+ this.fetching = false;
+ });
+ },
+ beforeUnmount() {
+ this.connection.dispose();
+ },
+ methods: {
+ onDriveFileCreated(file) {
+ if (/^image\/.+$/.test(file.type)) {
+ this.images.unshift(file);
+ if (this.images.length > 9) this.images.pop();
+ }
+ },
+
+ thumbnail(image: any): string {
+ return this.$store.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(image.thumbnailUrl)
+ : image.thumbnailUrl;
+ },
+ }
+});
+</script>
+
+<style lang="scss" module>
+.root[data-transparent] {
+ .stream {
+ padding: 0;
+ }
+
+ .img {
+ border: solid 4px transparent;
+ border-radius: 8px;
+ }
+}
+
+.stream {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ padding: 8px;
+
+ .img {
+ flex: 1 1 33%;
+ width: 33%;
+ height: 80px;
+ box-sizing: border-box;
+ background-position: center center;
+ background-size: cover;
+ background-clip: content-box;
+ border: solid 2px transparent;
+ border-radius: 4px;
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/post-form.vue b/packages/client/src/widgets/post-form.vue
new file mode 100644
index 0000000000..5ecaa67b5a
--- /dev/null
+++ b/packages/client/src/widgets/post-form.vue
@@ -0,0 +1,23 @@
+<template>
+<XPostForm class="_panel" :fixed="true" :autofocus="false"/>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPostForm from '@/components/post-form.vue';
+import define from './define';
+
+const widget = define({
+ name: 'postForm',
+ props: () => ({
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+
+ components: {
+ XPostForm,
+ },
+});
+</script>
diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue
new file mode 100644
index 0000000000..235fce574a
--- /dev/null
+++ b/packages/client/src/widgets/rss.vue
@@ -0,0 +1,89 @@
+<template>
+<MkContainer :show-header="props.showHeader">
+ <template #header><i class="fas fa-rss-square"></i>RSS</template>
+ <template #func><button class="_button" @click="setting"><i class="fas fa-cog"></i></button></template>
+
+ <div class="ekmkgxbj">
+ <MkLoading v-if="fetching"/>
+ <div class="feed" v-else>
+ <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'rss',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ url: {
+ type: 'string',
+ default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer
+ },
+ data() {
+ return {
+ items: [],
+ fetching: true,
+ clock: null,
+ };
+ },
+ mounted() {
+ this.fetch();
+ this.clock = setInterval(this.fetch, 60000);
+ this.$watch(() => this.props.url, this.fetch);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ fetch() {
+ fetch(`https://api.rss2json.com/v1/api.json?rss_url=${this.props.url}`, {
+ }).then(res => {
+ res.json().then(feed => {
+ this.items = feed.items;
+ this.fetching = false;
+ });
+ });
+ },
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.ekmkgxbj {
+ > .feed {
+ padding: 0;
+ font-size: 0.9em;
+
+ > a {
+ display: block;
+ padding: 8px 16px;
+ color: var(--fg);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+
+ &:nth-child(even) {
+ background: rgba(#000, 0.05);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/server-metric/cpu-mem.vue b/packages/client/src/widgets/server-metric/cpu-mem.vue
new file mode 100644
index 0000000000..ad9e6a8b0f
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/cpu-mem.vue
@@ -0,0 +1,174 @@
+<template>
+<div class="lcfyofjk">
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <defs>
+ <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0">
+ <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
+ <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
+ </linearGradient>
+ <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+ <polygon
+ :points="cpuPolygonPoints"
+ fill="#fff"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="cpuPolylinePoints"
+ fill="none"
+ stroke="#fff"
+ stroke-width="1"
+ />
+ <circle
+ :cx="cpuHeadX"
+ :cy="cpuHeadY"
+ r="1.5"
+ fill="#fff"
+ />
+ </mask>
+ </defs>
+ <rect
+ x="-2" y="-2"
+ :width="viewBoxX + 4" :height="viewBoxY + 4"
+ :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"
+ />
+ <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text>
+ </svg>
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <defs>
+ <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0">
+ <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
+ <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
+ </linearGradient>
+ <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+ <polygon
+ :points="memPolygonPoints"
+ fill="#fff"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="memPolylinePoints"
+ fill="none"
+ stroke="#fff"
+ stroke-width="1"
+ />
+ <circle
+ :cx="memHeadX"
+ :cy="memHeadY"
+ r="1.5"
+ fill="#fff"
+ />
+ </mask>
+ </defs>
+ <rect
+ x="-2" y="-2"
+ :width="viewBoxX + 4" :height="viewBoxY + 4"
+ :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"
+ />
+ <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text>
+ </svg>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import { v4 as uuid } from 'uuid';
+
+export default defineComponent({
+ props: {
+ connection: {
+ required: true,
+ },
+ meta: {
+ required: true,
+ }
+ },
+ data() {
+ return {
+ viewBoxX: 50,
+ viewBoxY: 30,
+ stats: [],
+ cpuGradientId: uuid(),
+ cpuMaskId: uuid(),
+ memGradientId: uuid(),
+ memMaskId: uuid(),
+ cpuPolylinePoints: '',
+ memPolylinePoints: '',
+ cpuPolygonPoints: '',
+ memPolygonPoints: '',
+ cpuHeadX: null,
+ cpuHeadY: null,
+ memHeadX: null,
+ memHeadY: null,
+ cpuP: '',
+ memP: ''
+ };
+ },
+ mounted() {
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8)
+ });
+ },
+ beforeUnmount() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ },
+ methods: {
+ onStats(stats) {
+ this.stats.push(stats);
+ if (this.stats.length > 50) this.stats.shift();
+
+ const cpuPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - s.cpu) * this.viewBoxY]);
+ const memPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.mem.active / this.meta.mem.total)) * this.viewBoxY]);
+ this.cpuPolylinePoints = cpuPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+ this.memPolylinePoints = memPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+ this.cpuPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.cpuPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
+ this.memPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.memPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
+
+ this.cpuHeadX = cpuPolylinePoints[cpuPolylinePoints.length - 1][0];
+ this.cpuHeadY = cpuPolylinePoints[cpuPolylinePoints.length - 1][1];
+ this.memHeadX = memPolylinePoints[memPolylinePoints.length - 1][0];
+ this.memHeadY = memPolylinePoints[memPolylinePoints.length - 1][1];
+
+ this.cpuP = (stats.cpu * 100).toFixed(0);
+ this.memP = (stats.mem.active / this.meta.mem.total * 100).toFixed(0);
+ },
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.lcfyofjk {
+ display: flex;
+
+ > svg {
+ display: block;
+ padding: 10px;
+ width: 50%;
+
+ &:first-child {
+ padding-right: 5px;
+ }
+
+ &:last-child {
+ padding-left: 5px;
+ }
+
+ > text {
+ font-size: 5px;
+ fill: currentColor;
+
+ > tspan {
+ opacity: 0.5;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/server-metric/cpu.vue b/packages/client/src/widgets/server-metric/cpu.vue
new file mode 100644
index 0000000000..4478ee3065
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/cpu.vue
@@ -0,0 +1,76 @@
+<template>
+<div class="vrvdvrys">
+ <XPie class="pie" :value="usage"/>
+ <div>
+ <p><i class="fas fa-microchip"></i>CPU</p>
+ <p>{{ meta.cpu.cores }} Logical cores</p>
+ <p>{{ meta.cpu.model }}</p>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPie from './pie.vue';
+
+export default defineComponent({
+ components: {
+ XPie
+ },
+ props: {
+ connection: {
+ required: true,
+ },
+ meta: {
+ required: true,
+ }
+ },
+ data() {
+ return {
+ usage: 0,
+ };
+ },
+ mounted() {
+ this.connection.on('stats', this.onStats);
+ },
+ beforeUnmount() {
+ this.connection.off('stats', this.onStats);
+ },
+ methods: {
+ onStats(stats) {
+ this.usage = stats.cpu;
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.vrvdvrys {
+ display: flex;
+ padding: 16px;
+
+ > .pie {
+ height: 82px;
+ flex-shrink: 0;
+ margin-right: 16px;
+ }
+
+ > div {
+ flex: 1;
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+
+ &:first-child {
+ font-weight: bold;
+ margin-bottom: 4px;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/server-metric/disk.vue b/packages/client/src/widgets/server-metric/disk.vue
new file mode 100644
index 0000000000..650101b0ee
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/disk.vue
@@ -0,0 +1,70 @@
+<template>
+<div class="zbwaqsat">
+ <XPie class="pie" :value="usage"/>
+ <div>
+ <p><i class="fas fa-hdd"></i>Disk</p>
+ <p>Total: {{ bytes(total, 1) }}</p>
+ <p>Free: {{ bytes(available, 1) }}</p>
+ <p>Used: {{ bytes(used, 1) }}</p>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPie from './pie.vue';
+import bytes from '@/filters/bytes';
+
+export default defineComponent({
+ components: {
+ XPie
+ },
+ props: {
+ meta: {
+ required: true,
+ }
+ },
+ data() {
+ return {
+ usage: this.meta.fs.used / this.meta.fs.total,
+ total: this.meta.fs.total,
+ used: this.meta.fs.used,
+ available: this.meta.fs.total - this.meta.fs.used,
+ };
+ },
+ methods: {
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zbwaqsat {
+ display: flex;
+ padding: 16px;
+
+ > .pie {
+ height: 82px;
+ flex-shrink: 0;
+ margin-right: 16px;
+ }
+
+ > div {
+ flex: 1;
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+
+ &:first-child {
+ font-weight: bold;
+ margin-bottom: 4px;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/server-metric/index.vue b/packages/client/src/widgets/server-metric/index.vue
new file mode 100644
index 0000000000..cfe3c15df7
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/index.vue
@@ -0,0 +1,82 @@
+<template>
+<MkContainer :show-header="props.showHeader" :naked="props.transparent">
+ <template #header><i class="fas fa-server"></i>{{ $ts._widgets.serverMetric }}</template>
+ <template #func><button @click="toggleView()" class="_button"><i class="fas fa-sort"></i></button></template>
+
+ <div class="mkw-serverMetric" v-if="meta">
+ <XCpuMemory v-if="props.view === 0" :connection="connection" :meta="meta"/>
+ <XNet v-if="props.view === 1" :connection="connection" :meta="meta"/>
+ <XCpu v-if="props.view === 2" :connection="connection" :meta="meta"/>
+ <XMemory v-if="props.view === 3" :connection="connection" :meta="meta"/>
+ <XDisk v-if="props.view === 4" :connection="connection" :meta="meta"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent, markRaw } from 'vue';
+import define from '../define';
+import MkContainer from '@/components/ui/container.vue';
+import XCpuMemory from './cpu-mem.vue';
+import XNet from './net.vue';
+import XCpu from './cpu.vue';
+import XMemory from './mem.vue';
+import XDisk from './disk.vue';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'serverMetric',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ transparent: {
+ type: 'boolean',
+ default: false,
+ },
+ view: {
+ type: 'number',
+ default: 0,
+ hidden: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer,
+ XCpuMemory,
+ XNet,
+ XCpu,
+ XMemory,
+ XDisk,
+ },
+ data() {
+ return {
+ meta: null,
+ connection: null,
+ };
+ },
+ created() {
+ os.api('server-info', {}).then(res => {
+ this.meta = res;
+ });
+ this.connection = markRaw(os.stream.useChannel('serverStats'));
+ },
+ unmounted() {
+ this.connection.dispose();
+ },
+ methods: {
+ toggleView() {
+ if (this.props.view == 4) {
+ this.props.view = 0;
+ } else {
+ this.props.view++;
+ }
+ this.save();
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/widgets/server-metric/mem.vue b/packages/client/src/widgets/server-metric/mem.vue
new file mode 100644
index 0000000000..a6ca7b1175
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/mem.vue
@@ -0,0 +1,85 @@
+<template>
+<div class="zlxnikvl">
+ <XPie class="pie" :value="usage"/>
+ <div>
+ <p><i class="fas fa-memory"></i>RAM</p>
+ <p>Total: {{ bytes(total, 1) }}</p>
+ <p>Used: {{ bytes(used, 1) }}</p>
+ <p>Free: {{ bytes(free, 1) }}</p>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import XPie from './pie.vue';
+import bytes from '@/filters/bytes';
+
+export default defineComponent({
+ components: {
+ XPie
+ },
+ props: {
+ connection: {
+ required: true,
+ },
+ meta: {
+ required: true,
+ }
+ },
+ data() {
+ return {
+ usage: 0,
+ total: 0,
+ used: 0,
+ free: 0,
+ };
+ },
+ mounted() {
+ this.connection.on('stats', this.onStats);
+ },
+ beforeUnmount() {
+ this.connection.off('stats', this.onStats);
+ },
+ methods: {
+ onStats(stats) {
+ this.usage = stats.mem.active / this.meta.mem.total;
+ this.total = this.meta.mem.total;
+ this.used = stats.mem.active;
+ this.free = this.meta.mem.total - stats.mem.active;
+ },
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.zlxnikvl {
+ display: flex;
+ padding: 16px;
+
+ > .pie {
+ height: 82px;
+ flex-shrink: 0;
+ margin-right: 16px;
+ }
+
+ > div {
+ flex: 1;
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+
+ &:first-child {
+ font-weight: bold;
+ margin-bottom: 4px;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/server-metric/net.vue b/packages/client/src/widgets/server-metric/net.vue
new file mode 100644
index 0000000000..23c148eeb6
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/net.vue
@@ -0,0 +1,148 @@
+<template>
+<div class="oxxrhrto">
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <polygon
+ :points="inPolygonPoints"
+ fill="#94a029"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="inPolylinePoints"
+ fill="none"
+ stroke="#94a029"
+ stroke-width="1"
+ />
+ <circle
+ :cx="inHeadX"
+ :cy="inHeadY"
+ r="1.5"
+ fill="#94a029"
+ />
+ <text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text>
+ </svg>
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <polygon
+ :points="outPolygonPoints"
+ fill="#ff9156"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="outPolylinePoints"
+ fill="none"
+ stroke="#ff9156"
+ stroke-width="1"
+ />
+ <circle
+ :cx="outHeadX"
+ :cy="outHeadY"
+ r="1.5"
+ fill="#ff9156"
+ />
+ <text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text>
+ </svg>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import bytes from '@/filters/bytes';
+
+export default defineComponent({
+ props: {
+ connection: {
+ required: true,
+ },
+ meta: {
+ required: true,
+ }
+ },
+ data() {
+ return {
+ viewBoxX: 50,
+ viewBoxY: 30,
+ stats: [],
+ inPolylinePoints: '',
+ outPolylinePoints: '',
+ inPolygonPoints: '',
+ outPolygonPoints: '',
+ inHeadX: null,
+ inHeadY: null,
+ outHeadX: null,
+ outHeadY: null,
+ inRecent: 0,
+ outRecent: 0
+ };
+ },
+ mounted() {
+ this.connection.on('stats', this.onStats);
+ this.connection.on('statsLog', this.onStatsLog);
+ this.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8)
+ });
+ },
+ beforeUnmount() {
+ this.connection.off('stats', this.onStats);
+ this.connection.off('statsLog', this.onStatsLog);
+ },
+ methods: {
+ onStats(stats) {
+ this.stats.push(stats);
+ if (this.stats.length > 50) this.stats.shift();
+
+ const inPeak = Math.max(1024 * 64, Math.max(...this.stats.map(s => s.net.rx)));
+ const outPeak = Math.max(1024 * 64, Math.max(...this.stats.map(s => s.net.tx)));
+
+ const inPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * this.viewBoxY]);
+ const outPolylinePoints = this.stats.map((s, i) => [this.viewBoxX - ((this.stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * this.viewBoxY]);
+ this.inPolylinePoints = inPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+ this.outPolylinePoints = outPolylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+ this.inPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.inPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
+ this.outPolygonPoints = `${this.viewBoxX - (this.stats.length - 1)},${this.viewBoxY} ${this.outPolylinePoints} ${this.viewBoxX},${this.viewBoxY}`;
+
+ this.inHeadX = inPolylinePoints[inPolylinePoints.length - 1][0];
+ this.inHeadY = inPolylinePoints[inPolylinePoints.length - 1][1];
+ this.outHeadX = outPolylinePoints[outPolylinePoints.length - 1][0];
+ this.outHeadY = outPolylinePoints[outPolylinePoints.length - 1][1];
+
+ this.inRecent = stats.net.rx;
+ this.outRecent = stats.net.tx;
+ },
+ onStatsLog(statsLog) {
+ for (const stats of [...statsLog].reverse()) {
+ this.onStats(stats);
+ }
+ },
+ bytes
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.oxxrhrto {
+ display: flex;
+
+ > svg {
+ display: block;
+ padding: 10px;
+ width: 50%;
+
+ &:first-child {
+ padding-right: 5px;
+ }
+
+ &:last-child {
+ padding-left: 5px;
+ }
+
+ > text {
+ font-size: 5px;
+ fill: currentColor;
+
+ > tspan {
+ opacity: 0.5;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/server-metric/pie.vue b/packages/client/src/widgets/server-metric/pie.vue
new file mode 100644
index 0000000000..38dcf6fcd9
--- /dev/null
+++ b/packages/client/src/widgets/server-metric/pie.vue
@@ -0,0 +1,65 @@
+<template>
+<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
+ <circle
+ :r="r"
+ cx="50%" cy="50%"
+ fill="none"
+ stroke-width="0.1"
+ stroke="rgba(0, 0, 0, 0.05)"
+ />
+ <circle
+ :r="r"
+ cx="50%" cy="50%"
+ :stroke-dasharray="Math.PI * (r * 2)"
+ :stroke-dashoffset="strokeDashoffset"
+ fill="none"
+ stroke-width="0.1"
+ :stroke="color"
+ />
+ <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text>
+</svg>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+
+export default defineComponent({
+ props: {
+ value: {
+ type: Number,
+ required: true
+ }
+ },
+ data() {
+ return {
+ r: 0.45
+ };
+ },
+ computed: {
+ color(): string {
+ return `hsl(${180 - (this.value * 180)}, 80%, 70%)`;
+ },
+ strokeDashoffset(): number {
+ return (1 - this.value) * (Math.PI * (this.r * 2));
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.hsalcinq {
+ display: block;
+ height: 100%;
+
+ > circle {
+ transform-origin: center;
+ transform: rotate(-90deg);
+ transition: stroke-dashoffset 0.5s ease;
+ }
+
+ > text {
+ font-size: 0.15px;
+ fill: currentColor;
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue
new file mode 100644
index 0000000000..0909bda67c
--- /dev/null
+++ b/packages/client/src/widgets/slideshow.vue
@@ -0,0 +1,167 @@
+<template>
+<div class="kvausudm _panel">
+ <div @click="choose">
+ <p v-if="props.folderId == null">
+ <template v-if="isCustomizeMode">{{ $t('folder-customize-mode') }}</template>
+ <template v-else>{{ $ts.folder }}</template>
+ </p>
+ <p v-if="props.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
+ <div ref="slideA" class="slide a"></div>
+ <div ref="slideB" class="slide b"></div>
+ </div>
+</div>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'slideshow',
+ props: () => ({
+ height: {
+ type: 'number',
+ default: 300,
+ },
+ folderId: {
+ type: 'string',
+ default: null,
+ hidden: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ data() {
+ return {
+ images: [],
+ fetching: true,
+ clock: null
+ };
+ },
+ mounted() {
+ this.$nextTick(() => {
+ this.applySize();
+ });
+
+ if (this.props.folderId != null) {
+ this.fetch();
+ }
+
+ this.clock = setInterval(this.change, 10000);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ applySize() {
+ let h;
+
+ if (this.props.size == 1) {
+ h = 250;
+ } else {
+ h = 170;
+ }
+
+ this.$el.style.height = `${h}px`;
+ },
+ resize() {
+ if (this.props.size == 1) {
+ this.props.size = 0;
+ } else {
+ this.props.size++;
+ }
+ this.save();
+
+ this.applySize();
+ },
+ change() {
+ if (this.images.length == 0) return;
+
+ const index = Math.floor(Math.random() * this.images.length);
+ const img = `url(${ this.images[index].url })`;
+
+ (this.$refs.slideB as any).style.backgroundImage = img;
+
+ this.$refs.slideB.classList.add('anime');
+ setTimeout(() => {
+ // 既にこのウィジェットがunmountされていたら要素がない
+ if ((this.$refs.slideA as any) == null) return;
+
+ (this.$refs.slideA as any).style.backgroundImage = img;
+
+ this.$refs.slideB.classList.remove('anime');
+ }, 1000);
+ },
+ fetch() {
+ this.fetching = true;
+
+ os.api('drive/files', {
+ folderId: this.props.folderId,
+ type: 'image/*',
+ limit: 100
+ }).then(images => {
+ this.images = images;
+ this.fetching = false;
+ (this.$refs.slideA as any).style.backgroundImage = '';
+ (this.$refs.slideB as any).style.backgroundImage = '';
+ this.change();
+ });
+ },
+ choose() {
+ os.selectDriveFolder(false).then(folder => {
+ if (folder == null) {
+ return;
+ }
+ this.props.folderId = folder.id;
+ this.save();
+ this.fetch();
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.kvausudm {
+ position: relative;
+
+ > div {
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+
+ > p {
+ display: block;
+ margin: 1em;
+ text-align: center;
+ color: #888;
+ }
+
+ > * {
+ pointer-events: none;
+ }
+
+ > .slide {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+ background-position: center;
+
+ &.b {
+ opacity: 0;
+ }
+
+ &.anime {
+ transition: opacity 1s;
+ opacity: 1;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/client/src/widgets/timeline.vue b/packages/client/src/widgets/timeline.vue
new file mode 100644
index 0000000000..0d0629abe2
--- /dev/null
+++ b/packages/client/src/widgets/timeline.vue
@@ -0,0 +1,116 @@
+<template>
+<MkContainer :show-header="props.showHeader" :style="`height: ${props.height}px;`" :scrollable="true">
+ <template #header>
+ <button @click="choose" class="_button">
+ <i v-if="props.src === 'home'" class="fas fa-home"></i>
+ <i v-else-if="props.src === 'local'" class="fas fa-comments"></i>
+ <i v-else-if="props.src === 'social'" class="fas fa-share-alt"></i>
+ <i v-else-if="props.src === 'global'" class="fas fa-globe"></i>
+ <i v-else-if="props.src === 'list'" class="fas fa-list-ul"></i>
+ <i v-else-if="props.src === 'antenna'" class="fas fa-satellite"></i>
+ <span style="margin-left: 8px;">{{ props.src === 'list' ? props.list.name : props.src === 'antenna' ? props.antenna.name : $t('_timelines.' + props.src) }}</span>
+ <i :class="menuOpened ? 'fas fa-angle-up' : 'fas fa-angle-down'" style="margin-left: 8px;"></i>
+ </button>
+ </template>
+
+ <div>
+ <XTimeline :key="props.src === 'list' ? `list:${props.list.id}` : props.src === 'antenna' ? `antenna:${props.antenna.id}` : props.src" :src="props.src" :list="props.list ? props.list.id : null" :antenna="props.antenna ? props.antenna.id : null"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import XTimeline from '@/components/timeline.vue';
+import define from './define';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'timeline',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ height: {
+ type: 'number',
+ default: 300,
+ },
+ src: {
+ type: 'string',
+ default: 'home',
+ hidden: true,
+ },
+ list: {
+ type: 'object',
+ default: null,
+ hidden: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer,
+ XTimeline,
+ },
+
+ data() {
+ return {
+ menuOpened: false,
+ };
+ },
+
+ methods: {
+ async choose(ev) {
+ this.menuOpened = true;
+ const [antennas, lists] = await Promise.all([
+ os.api('antennas/list'),
+ os.api('users/lists/list')
+ ]);
+ const antennaItems = antennas.map(antenna => ({
+ text: antenna.name,
+ icon: 'fas fa-satellite',
+ action: () => {
+ this.props.antenna = antenna;
+ this.setSrc('antenna');
+ }
+ }));
+ const listItems = lists.map(list => ({
+ text: list.name,
+ icon: 'fas fa-list-ul',
+ action: () => {
+ this.props.list = list;
+ this.setSrc('list');
+ }
+ }));
+ os.popupMenu([{
+ text: this.$ts._timelines.home,
+ icon: 'fas fa-home',
+ action: () => { this.setSrc('home') }
+ }, {
+ text: this.$ts._timelines.local,
+ icon: 'fas fa-comments',
+ action: () => { this.setSrc('local') }
+ }, {
+ text: this.$ts._timelines.social,
+ icon: 'fas fa-share-alt',
+ action: () => { this.setSrc('social') }
+ }, {
+ text: this.$ts._timelines.global,
+ icon: 'fas fa-globe',
+ action: () => { this.setSrc('global') }
+ }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget || ev.target).then(() => {
+ this.menuOpened = false;
+ });
+ },
+
+ setSrc(src) {
+ this.props.src = src;
+ this.save();
+ },
+ }
+});
+</script>
diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue
new file mode 100644
index 0000000000..dba3392618
--- /dev/null
+++ b/packages/client/src/widgets/trends.vue
@@ -0,0 +1,111 @@
+<template>
+<MkContainer :show-header="props.showHeader">
+ <template #header><i class="fas fa-hashtag"></i>{{ $ts._widgets.trends }}</template>
+
+ <div class="wbrkwala">
+ <MkLoading v-if="fetching"/>
+ <transition-group tag="div" name="chart" class="tags" v-else>
+ <div v-for="stat in stats" :key="stat.tag">
+ <div class="tag">
+ <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
+ <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
+ </div>
+ <MkMiniChart class="chart" :src="stat.chart"/>
+ </div>
+ </transition-group>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts">
+import { defineComponent } from 'vue';
+import MkContainer from '@/components/ui/container.vue';
+import define from './define';
+import MkMiniChart from '@/components/mini-chart.vue';
+import * as os from '@/os';
+
+const widget = define({
+ name: 'hashtags',
+ props: () => ({
+ showHeader: {
+ type: 'boolean',
+ default: true,
+ },
+ })
+});
+
+export default defineComponent({
+ extends: widget,
+ components: {
+ MkContainer, MkMiniChart
+ },
+ data() {
+ return {
+ stats: [],
+ fetching: true,
+ };
+ },
+ mounted() {
+ this.fetch();
+ this.clock = setInterval(this.fetch, 1000 * 60);
+ },
+ beforeUnmount() {
+ clearInterval(this.clock);
+ },
+ methods: {
+ fetch() {
+ os.api('hashtags/trend').then(stats => {
+ this.stats = stats;
+ this.fetching = false;
+ });
+ }
+ }
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwala {
+ height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
+ overflow: hidden;
+
+ > .tags {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ > div {
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ border-bottom: solid 0.5px var(--divider);
+
+ > .tag {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+
+ > .a {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 18px;
+ }
+
+ > p {
+ margin: 0;
+ font-size: 75%;
+ opacity: 0.7;
+ line-height: 16px;
+ }
+ }
+
+ > .chart {
+ height: 30px;
+ }
+ }
+ }
+}
+</style>