diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-10-17 20:12:00 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-10-17 20:12:00 +0900 |
| commit | 7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a (patch) | |
| tree | 2263a06acec7fa21882366bae26d1a983ce21135 /src/client/pages/settings | |
| parent | CW の input でも投稿ショートカットが動作するように (#6690) (diff) | |
| download | misskey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.gz misskey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.tar.bz2 misskey-7199e6f4e0b3a2c2bc198e689c3e0cd0d0f0354a.zip | |
Migrate to Vue3 (#6587)
* Update reaction.vue
* fix bug
* wip
* wip
* wjio
* wip
* Revert "wip"
This reverts commit e427f2160adf4e8a4147006e25a89854edab0033.
* wip
* wip
* wip
* Update init.ts
* Update drive-window.vue
* wip
* wip
* Use PascalCase for components
* Use PascalCase for components
* update dep
* wip
* wip
* wip
* Update init.ts
* wip
* Update paging.ts
* Update test.vue
* watch deep
* wip
* lint
* wip
* wip
* wip
* wip
* wiop
* wip
* Update webpack.config.ts
* alllow null poll
* wip
* wip
* wip
* wiop
* UI redesign & refactor (#6714)
* wip
* wip
* wip
* wip
* wip
* Update drive.vue
* Update word-mute.vue
* wip
* wip
* wip
* clean up
* wip
* Update default.vue
* wip
* Update notes.vue
* Update mfm.ts
* Update index.home.vue
* Update post-form.vue
* Update post-form-attaches.vue
* wip
* Update post-form.vue
* Update sidebar.vue
* wip
* wip
* Update index.vue
* wip
* Update default.vue
* Update index.vue
* Update index.vue
* wip
* Update post-form-attaches.vue
* Update note.vue
* wip
* clean up
* Update notes.vue
* wip
* wip
* Update ja-JP.yml
* wip
* wip
* Update index.vue
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update default.vue
* wip
* Update _dark.json5
* wip
* wip
* wip
* clean up
* wip
* wip
* Update index.vue
* Update test.vue
* wip
* wip
* fix
* wip
* wip
* wip
* wip
* clena yop
* wip
* wip
* Update store.ts
* Update messaging-room.vue
* Update default.widgets.vue
* fix
* wip
* wip
* Update modal.vue
* wip
* Update os.ts
* Update os.ts
* Update deck.vue
* Update init.ts
* wip
* Update ja-JP.yml
* v-sizeは単にwindowのresizeを監視するだけで良いかもしれない
* Update modal.vue
* wip
* Update tooltip.ts
* wip
* wip
* wip
* wip
* wip
* Update image-viewer.vue
* wip
* wip
* Update style.scss
* Update style.scss
* Update visitor.vue
* wip
* Update init.ts
* Update init.ts
* wip
* wip
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* Update visitor.vue
* wip
* wip
* Update modal.vue
* Update header.vue
* Update menu.vue
* Update about.vue
* Update about-misskey.vue
* wip
* wip
* Update visitor.vue
* Update tooltip.ts
* wip
* Update drive.vue
* wip
* Update style.scss
* Update header.vue
* wip
* wip
* Update users.user.vue
* Update announcements.vue
* wip
* wip
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update style.scss
* Update users.vue
* wip
* Update style.scss
* wip
* Update welcome.entrance.vue
* Update radio.vue
* Update size.ts
* Update emoji-edit-dialog.vue
* wip
* Update emojis.vue
* wip
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* wip
* wip
* wip
* wip
* Update file-dialog.vue
* wip
* wip
* Update token-generate-window.vue
* Update notification-setting-window.vue
* wip
* wip
* Update _error_.vue
* Update ja-JP.yml
* wip
* wip
* Update store.ts
* Update emojis.vue
* Update emojis.vue
* Update emojis.vue
* Update announcements.vue
* Update store.ts
* wip
* Update page-editor.vue
* wip
* wip
* Update modal.vue
* wip
* Update select-file.ts
* Update timeline.vue
* Update emojis.vue
* Update os.ts
* wip
* Update user-select.vue
* Update mfm.ts
* Update get-file-info.ts
* Update drive.vue
* Update init.ts
* Update mfm.ts
* wip
* wip
* Update window.vue
* Update note.vue
* wip
* wip
* Update user-info.vue
* wip
* wip
* wip
* wip
* wip
* Update header.vue
* Update header.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update webpack.config.ts
* wip
* wip
* wip
* wip
* wip
* wip
* Update autocomplete.ts
* wip
* wip
* wip
* Update toast.vue
* wip
* Update post-form-dialog.vue
* wip
* wip
* wip
* wip
* wip
* Update users.vue
* wip
* Update explore.vue
* wip
* wip
* wip
* Update package.json
* wip
* Update icon-dialog.vue
* wip
* wip
* Update user-preview.ts
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* Update user-name.vue
* Update federation.vue
* Update instance.vue
* wip
* wip
* Update tag.vue
* wip
* wip
* wip
* wip
* wip
* Update instance.vue
* wip
* Update os.ts
* Update os.ts
* wip
* wip
* wip
* Update router.ts
* wip
* Update init.ts
* Update note.vue
* Update messages.vue
* wip
* wip
* wip
* wip
* wip
* google
* wip
* wip
* wip
* wip
* Update theme-editor.vue
* wip
* wip
* Update room.vue
* Update channel-editor.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update window.vue
* Update window.vue
* wip
* Update menu.vue
* wip
* wip
* wip
* wip
* Update messaging-room.vue
* wip
* Update post-form.vue
* Update default.widgets.vue
* Update window.vue
* wip
Diffstat (limited to 'src/client/pages/settings')
| -rw-r--r-- | src/client/pages/settings/api.vue | 59 | ||||
| -rw-r--r-- | src/client/pages/settings/drive.vue | 60 | ||||
| -rw-r--r-- | src/client/pages/settings/general.vue | 219 | ||||
| -rw-r--r-- | src/client/pages/settings/import-export.vue | 119 | ||||
| -rw-r--r-- | src/client/pages/settings/index.vue | 154 | ||||
| -rw-r--r-- | src/client/pages/settings/integration.vue | 136 | ||||
| -rw-r--r-- | src/client/pages/settings/mute-block.vue | 93 | ||||
| -rw-r--r-- | src/client/pages/settings/notifications.vue | 93 | ||||
| -rw-r--r-- | src/client/pages/settings/other.vue | 51 | ||||
| -rw-r--r-- | src/client/pages/settings/plugins.vue | 200 | ||||
| -rw-r--r-- | src/client/pages/settings/privacy.vue | 86 | ||||
| -rw-r--r-- | src/client/pages/settings/profile.vue | 232 | ||||
| -rw-r--r-- | src/client/pages/settings/reaction.vue | 95 | ||||
| -rw-r--r-- | src/client/pages/settings/security.2fa.vue | 235 | ||||
| -rw-r--r-- | src/client/pages/settings/security.vue | 102 | ||||
| -rw-r--r-- | src/client/pages/settings/sidebar.vue | 110 | ||||
| -rw-r--r-- | src/client/pages/settings/sounds.vue | 152 | ||||
| -rw-r--r-- | src/client/pages/settings/theme.vue | 499 | ||||
| -rw-r--r-- | src/client/pages/settings/word-mute.vue | 101 |
19 files changed, 2796 insertions, 0 deletions
diff --git a/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue new file mode 100644 index 0000000000..326ba90062 --- /dev/null +++ b/src/client/pages/settings/api.vue @@ -0,0 +1,59 @@ +<template> +<section class="_section"> + <div class="_content"> + <MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faKey } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, MkInput + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: 'API', + icon: faKey + }] + }, + }; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async generateToken() { + os.popup(await 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.$t('token'), + text: token + }); + }, + }, 'closed'); + }, + } +}); +</script> diff --git a/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue new file mode 100644 index 0000000000..a7d623be37 --- /dev/null +++ b/src/client/pages/settings/drive.vue @@ -0,0 +1,60 @@ +<template> +<section class="uawsfosz _section"> + <div class="_title"><Fa :icon="faCloud"/> {{ $t('drive') }}</div> + <div class="_content"> + <span>{{ $t('uploadFolder') }}: {{ uploadFolder ? uploadFolder.name : '-' }}</span> + <MkButton primary @click="chooseUploadFolder()"><Fa :icon="faFolderOpen"/> {{ $t('selectFolder') }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCloud, faFolderOpen } from '@fortawesome/free-solid-svg-icons'; +import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + }, + + data() { + return { + uploadFolder: null, + faCloud, faClock, faEyeSlash, faFolderOpen, faTrashAlt + } + }, + + async created() { + if (this.$store.state.settings.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.settings.uploadFolder + }); + } + }, + + methods: { + chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + await this.$store.dispatch('settings/set', { key: 'uploadFolder', value: folder ? folder.id : null }); + os.success(); + if (this.$store.state.settings.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.settings.uploadFolder + }); + } else { + this.uploadFolder = null; + } + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.uawsfosz { + +} +</style> diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue new file mode 100644 index 0000000000..80152c5e6a --- /dev/null +++ b/src/client/pages/settings/general.vue @@ -0,0 +1,219 @@ +<template> +<div class="_section"> + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faCog"/> {{ $t('general') }}</div> + <div class="_content"> + <div>{{ $t('whenServerDisconnected') }}</div> + <MkRadio v-model="serverDisconnectedBehavior" value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</MkRadio> + <MkRadio v-model="serverDisconnectedBehavior" value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</MkRadio> + <MkRadio v-model="serverDisconnectedBehavior" value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</MkRadio> + </div> + <div class="_content"> + <MkSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</MkSwitch> + <MkSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</MkSwitch> + <MkSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</MkSwitch> + <MkSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('chatOpenBehavior') }}</div> + <MkRadio v-model="chatOpenBehavior" value="page">{{ $t('showInPage') }}</MkRadio> + <MkRadio v-model="chatOpenBehavior" value="window">{{ $t('openInWindow') }}</MkRadio> + <MkRadio v-model="chatOpenBehavior" value="popout">{{ $t('popout') }}</MkRadio> + </div> + <div class="_content"> + <MkSelect v-model:value="lang"> + <template #label>{{ $t('uiLanguage') }}</template> + + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </MkSelect> + </div> + </section> + + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faCog"/> {{ $t('appearance') }}</div> + <div class="_content"> + <MkSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</MkSwitch> + <MkSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</MkSwitch> + <MkSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</MkSwitch> + <MkSwitch v-model:value="useOsNativeEmojis"> + {{ $t('useOsNativeEmojis') }} + <template #desc><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template> + </MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('fontSize') }}</div> + <MkRadio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></MkRadio> + <MkRadio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></MkRadio> + <MkRadio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></MkRadio> + <MkRadio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></MkRadio> + </div> + </section> + + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faColumns"/> {{ $t('deck') }}</div> + <div class="_content"> + <MkSwitch v-model:value="deckAlwaysShowMainColumn"> + {{ $t('_deck.alwaysShowMainColumn') }} + </MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('_deck.columnAlign') }}</div> + <MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio> + <MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio> + </div> + </section> + + <MkButton @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</MkButton> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faImage, faCog, faColumns, faCogs } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkRadio from '@/components/ui/radio.vue'; +import MkRange from '@/components/ui/range.vue'; +import { langs } from '@/config'; +import { clientDb, set } from '@/db'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + MkSelect, + MkRadio, + MkRange, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('general'), + icon: faCogs + }] + }, + langs, + lang: localStorage.getItem('lang'), + fontSize: localStorage.getItem('fontSize'), + faImage, faCog, faColumns + } + }, + + computed: { + serverDisconnectedBehavior: { + get() { return this.$store.state.device.serverDisconnectedBehavior; }, + set(value) { this.$store.commit('device/set', { key: 'serverDisconnectedBehavior', value }); } + }, + + reduceAnimation: { + get() { return !this.$store.state.device.animation; }, + set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); } + }, + + useBlurEffectForModal: { + get() { return this.$store.state.device.useBlurEffectForModal; }, + set(value) { this.$store.commit('device/set', { key: 'useBlurEffectForModal', value: value }); } + }, + + disableAnimatedMfm: { + get() { return !this.$store.state.device.animatedMfm; }, + set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); } + }, + + useOsNativeEmojis: { + get() { return this.$store.state.device.useOsNativeEmojis; }, + set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); } + }, + + imageNewTab: { + get() { return this.$store.state.device.imageNewTab; }, + set(value) { this.$store.commit('device/set', { key: 'imageNewTab', value }); } + }, + + disablePagesScript: { + get() { return this.$store.state.device.disablePagesScript; }, + set(value) { this.$store.commit('device/set', { key: 'disablePagesScript', value }); } + }, + + showFixedPostForm: { + get() { return this.$store.state.device.showFixedPostForm; }, + set(value) { this.$store.commit('device/set', { key: 'showFixedPostForm', value }); } + }, + + chatOpenBehavior: { + get() { return this.$store.state.device.chatOpenBehavior; }, + set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); } + }, + + enableInfiniteScroll: { + get() { return this.$store.state.device.enableInfiniteScroll; }, + set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); } + }, + + deckAlwaysShowMainColumn: { + get() { return this.$store.state.device.deckAlwaysShowMainColumn; }, + set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); } + }, + + deckColumnAlign: { + get() { return this.$store.state.device.deckColumnAlign; }, + set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } + }, + }, + + watch: { + lang() { + localStorage.setItem('lang', this.lang); + + return set('_version_', `changeLang-${(new Date()).toJSON()}`, clientDb.i18n) + .then(() => location.reload()) + .catch(() => { + os.dialog({ + type: 'error', + }); + }); + }, + + fontSize() { + if (this.fontSize == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', this.fontSize); + } + location.reload(); + }, + + enableInfiniteScroll() { + location.reload() + }, + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + cacheClear() { + // Clear cache (service worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + + navigator.serviceWorker.getRegistrations().then(registrations => { + for (const registration of registrations) registration.unregister(); + }); + } catch (e) { + console.error(e); + } + + // Force reload + location.reload(true); + } + } +}); +</script> diff --git a/src/client/pages/settings/import-export.vue b/src/client/pages/settings/import-export.vue new file mode 100644 index 0000000000..a5a0085277 --- /dev/null +++ b/src/client/pages/settings/import-export.vue @@ -0,0 +1,119 @@ +<template> +<section class="_section"> + <div class="_title"><Fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div> + <div class="_content"> + <MkSelect v-model:value="exportTarget"> + <option value="notes">{{ $t('_exportOrImport.allNotes') }}</option> + <option value="following">{{ $t('_exportOrImport.followingList') }}</option> + <option value="user-lists">{{ $t('_exportOrImport.userLists') }}</option> + <option value="mute">{{ $t('_exportOrImport.muteList') }}</option> + <option value="blocking">{{ $t('_exportOrImport.blockingList') }}</option> + </MkSelect> + <MkButton inline @click="doExport()"><Fa :icon="faDownload"/> {{ $t('export') }}</MkButton> + <MkButton inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><Fa :icon="faUpload"/> {{ $t('import') }}</MkButton> + </div> + <input ref="file" type="file" style="display: none;" @change="onChangeFile"/> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faDownload, faUpload, faBoxes } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import { apiUrl } from '@/config'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + }, + + data() { + return { + exportTarget: 'notes', + faDownload, faUpload, faBoxes + } + }, + + methods: { + doExport() { + os.api( + this.exportTarget == 'notes' ? 'i/export-notes' : + this.exportTarget == 'following' ? 'i/export-following' : + this.exportTarget == 'blocking' ? 'i/export-blocking' : + this.exportTarget == 'user-lists' ? 'i/export-user-lists' : + null, {}) + .then(() => { + os.dialog({ + type: 'info', + text: this.$t('exportRequested') + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + doImport() { + (this.$refs.file as any).click(); + }, + + onChangeFile() { + const [file] = Array.from((this.$refs.file as any).files); + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + const dialog = os.dialog({ + type: 'waiting', + text: this.$t('uploading') + '...', + showOkButton: false, + showCancelButton: false, + cancelableByBgClick: false + }); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.reqImport(f); + }) + .catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }) + .finally(() => { + dialog.close(); + }); + }, + + reqImport(file) { + os.api( + this.exportTarget == 'following' ? 'i/import-following' : + this.exportTarget == 'user-lists' ? 'i/import-user-lists' : + null, { + fileId: file.id + }).then(() => { + os.dialog({ + type: 'info', + text: this.$t('importRequested') + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue new file mode 100644 index 0000000000..4ca30ee686 --- /dev/null +++ b/src/client/pages/settings/index.vue @@ -0,0 +1,154 @@ +<template> +<div class="vvcocwet" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || $route.name === 'settings'"> + <div class="menu"> + <div class="label">{{ $t('basicSettings') }}</div> + <router-link class="item" replace to="/settings/profile"><Fa :icon="faUser" fixed-width class="icon"/>{{ $t('profile') }}</router-link> + <router-link class="item" replace to="/settings/privacy"><Fa :icon="faLockOpen" fixed-width class="icon"/>{{ $t('privacy') }}</router-link> + <router-link class="item" replace to="/settings/reaction"><Fa :icon="faLaugh" fixed-width class="icon"/>{{ $t('reaction') }}</router-link> + <router-link class="item" replace to="/settings/notifications"><Fa :icon="faBell" fixed-width class="icon"/>{{ $t('notifications') }}</router-link> + <router-link class="item" replace to="/settings/integration"><Fa :icon="faShareAlt" fixed-width class="icon"/>{{ $t('integration') }}</router-link> + <router-link class="item" replace to="/settings/security"><Fa :icon="faLock" fixed-width class="icon"/>{{ $t('security') }}</router-link> + </div> + <div class="menu"> + <div class="label">{{ $t('clientSettings') }}</div> + <router-link class="item" replace to="/settings/general"><Fa :icon="faCogs" fixed-width class="icon"/>{{ $t('general') }}</router-link> + <router-link class="item" replace to="/settings/theme"><Fa :icon="faPalette" fixed-width class="icon"/>{{ $t('theme') }}</router-link> + <router-link class="item" replace to="/settings/sidebar"><Fa :icon="faListUl" fixed-width class="icon"/>{{ $t('sidebar') }}</router-link> + <router-link class="item" replace to="/settings/sounds"><Fa :icon="faMusic" fixed-width class="icon"/>{{ $t('sounds') }}</router-link> + <router-link class="item" replace to="/settings/plugins"><Fa :icon="faPlug" fixed-width class="icon"/>{{ $t('plugins') }}</router-link> + </div> + <div class="menu"> + <div class="label">{{ $t('otherSettings') }}</div> + <router-link class="item" replace to="/settings/mute-block"><Fa :icon="faBan" fixed-width class="icon"/>{{ $t('muteAndBlock') }}</router-link> + <router-link class="item" replace to="/settings/word-mute"><Fa :icon="faCommentSlash" fixed-width class="icon"/>{{ $t('wordMute') }}</router-link> + <router-link class="item" replace to="/settings/api"><Fa :icon="faKey" fixed-width class="icon"/>API</router-link> + <router-link class="item" replace to="/settings/other"><Fa :icon="faEllipsisH" fixed-width class="icon"/>{{ $t('other') }}</router-link> + </div> + <div class="menu"> + <button class="_button item" @click="logout">{{ $t('logout') }}</button> + </div> + </div> + <div class="main"> + <router-view v-slot="{ Component }"> + <transition :name="($store.state.device.animation && !narrow) ? 'view-slide' : ''" appear mode="out-in"> + <component :is="Component" @info="onInfo"/> + </transition> + </router-view> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, ref } from 'vue'; +import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey } from '@fortawesome/free-solid-svg-icons'; +import { faLaugh, faBell } from '@fortawesome/free-regular-svg-icons'; +import { store } from '@/store'; +import { i18n } from '@/i18n'; + +export default defineComponent({ + setup(props, context) { + const INFO = ref({ + header: [{ + title: i18n.global.t('settings'), + icon: faCog + }] + }); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const onInfo = (viewInfo) => { + INFO.value = viewInfo; + }; + + onMounted(() => { + narrow.value = el.value.offsetWidth < 650; + }); + + return { + INFO, + narrow, + view, + el, + onInfo, + logout: () => { + store.dispatch('logout'); + location.href = '/'; + }, + faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.view-slide-enter-active, .view-slide-leave-active { + transition: opacity 0.3s, transform 0.3s !important; +} +.view-slide-enter-from, .view-slide-leave-to { + opacity: 0; + transform: translateX(32px); +} + +.vvcocwet { + max-width: 1000px; + margin: 0 auto; + + > .nav { + > .menu { + margin: 16px 0; + + > .label { + padding: 8px 32px; + font-size: 80%; + opacity: 0.7; + } + + > .item { + display: block; + width: 100%; + box-sizing: border-box; + padding: 0 32px; + line-height: 48px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + //background: var(--panel); + //border-bottom: solid 1px var(--divider); + transition: padding 0.2s ease, color 0.1s ease; + + &:first-of-type { + //border-top: solid 1px var(--divider); + } + + &.router-link-active { + color: var(--accent); + padding-left: 42px; + } + + &:hover { + text-decoration: none; + padding-left: 42px; + } + + > .icon { + margin-right: 0.5em; + } + } + } + } + + &.wide { + display: flex; + + > .nav { + width: 30%; + max-width: 260px; + } + + > .main { + flex: 1; + } + } +} +</style> diff --git a/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue new file mode 100644 index 0000000000..4f07417160 --- /dev/null +++ b/src/client/pages/settings/integration.vue @@ -0,0 +1,136 @@ +<template> +<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> + <div class="_content" v-if="enableTwitterIntegration"> + <header><Fa :icon="faTwitter"/> Twitter</header> + <p v-if="integrations.twitter">{{ $t('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">{{ $t('disconnectSerice') }}</MkButton> + <MkButton v-else @click="connectTwitter">{{ $t('connectSerice') }}</MkButton> + </div> + + <div class="_content" v-if="enableDiscordIntegration"> + <header><Fa :icon="faDiscord"/> Discord</header> + <p v-if="integrations.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.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">{{ $t('disconnectSerice') }}</MkButton> + <MkButton v-else @click="connectDiscord">{{ $t('connectSerice') }}</MkButton> + </div> + + <div class="_content" v-if="enableGithubIntegration"> + <header><Fa :icon="faGithub"/> GitHub</header> + <p v-if="integrations.github">{{ $t('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">{{ $t('disconnectSerice') }}</MkButton> + <MkButton v-else @click="connectGithub">{{ $t('connectSerice') }}</MkButton> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faShareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import { apiUrl } from '@/config'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('integration'), + icon: faShareAlt + }] + }, + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + faShareAlt, faTwitter, faDiscord, faGithub + }; + }, + + computed: { + integrations() { + return this.$store.state.i.integrations; + }, + + meta() { + return this.$store.state.instance.meta; + }, + }, + + created() { + this.enableTwitterIntegration = this.meta.enableTwitterIntegration; + this.enableDiscordIntegration = this.meta.enableDiscordIntegration; + this.enableGithubIntegration = this.meta.enableGithubIntegration; + }, + + mounted() { + this.$emit('info', this.INFO); + + document.cookie = `igi=${this.$store.state.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/src/client/pages/settings/mute-block.vue b/src/client/pages/settings/mute-block.vue new file mode 100644 index 0000000000..5a08a8caae --- /dev/null +++ b/src/client/pages/settings/mute-block.vue @@ -0,0 +1,93 @@ +<template> +<section class="rrfwjxfl _section"> + <MkTab v-model:value="tab" :items="[{ label: $t('mutedUsers'), value: 'mute' }, { label: $t('blockedUsers'), value: 'block' }]" style="margin-bottom: var(--margin);"/> + <div class="_content" v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template> + <template #default="{items}"> + <div class="user" v-for="mute in items" :key="mute.id"> + <router-link class="name" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </router-link> + </div> + </template> + </MkPagination> + </div> + <div class="_content" v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><MkInfo>{{ $t('noUsers') }}</MkInfo></template> + <template #default="{items}"> + <div class="user" v-for="block in items" :key="block.id"> + <router-link class="name" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </router-link> + </div> + </template> + </MkPagination> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faBan } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkPagination, + MkTab, + MkInfo, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('muteAndBlock'), + icon: faBan + }] + }, + tab: 'mute', + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + userPage + } +}); +</script> + +<style lang="scss" scoped> +.rrfwjxfl { + > ._content { + max-height: 350px; + overflow: auto; + + > .muting, + > .blocking { + > .empty { + opacity: 0.5 !important; + } + } + } +} +</style> diff --git a/src/client/pages/settings/notifications.vue b/src/client/pages/settings/notifications.vue new file mode 100644 index 0000000000..98dc85ea52 --- /dev/null +++ b/src/client/pages/settings/notifications.vue @@ -0,0 +1,93 @@ +<template> +<div> + <div class="_section"> + <MkButton full primary @click="configure"><Fa :icon="faCog"/> {{ $t('notificationSetting') }}</MkButton> + </div> + <div class="_section"> + <div class="_card"> + <div class="_content"> + <MkSwitch v-model:value="$store.state.i.autoWatch" @update:value="onChangeAutoWatch"> + {{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template> + </MkSwitch> + </div> + </div> + </div> + <div class="_section"> + <MkButton full @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</MkButton> + <MkButton full @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</MkButton> + <MkButton full @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</MkButton> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import { notificationTypes } from '../../../types'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('notifications'), + icon: faBell + }] + }, + faCog + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + onChangeAutoWatch(v) { + os.api('i/update', { + autoWatch: v + }); + }, + + readAllUnreadNotes() { + os.api('i/read-all-unread-notes'); + }, + + readAllMessagingMessages() { + os.api('i/read-all-messaging-messages'); + }, + + readAllNotifications() { + os.api('notifications/mark-all-as-read'); + }, + + async configure() { + const includingTypes = notificationTypes.filter(x => !this.$store.state.i.mutingNotificationTypes.includes(x)); + os.popup(await 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.$store.state.i.mutingNotificationTypes = i.mutingNotificationTypes; + }); + } + }, 'closed'); + }, + } +}); +</script> diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue new file mode 100644 index 0000000000..ebc5644162 --- /dev/null +++ b/src/client/pages/settings/other.vue @@ -0,0 +1,51 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_content"> + <MkSwitch v-model:value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote"> + {{ $t('showFeaturedNotesInTimeline') }} + </MkSwitch> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkSelect, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('other'), + icon: faEllipsisH + }] + }, + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v + }); + }, + } +}); +</script> diff --git a/src/client/pages/settings/plugins.vue b/src/client/pages/settings/plugins.vue new file mode 100644 index 0000000000..246624ddd4 --- /dev/null +++ b/src/client/pages/settings/plugins.vue @@ -0,0 +1,200 @@ +<template> +<section class="_section"> + <div class="_title"><Fa :icon="faPlug"/> {{ $t('plugins') }}</div> + <div class="_content"> + <details> + <summary><Fa :icon="faDownload"/> {{ $t('install') }}</summary> + <MkInfo warn>{{ $t('pluginInstallWarn') }}</MkInfo> + <MkTextarea v-model:value="script" tall> + <span>{{ $t('script') }}</span> + </MkTextarea> + <MkButton @click="install()" primary><Fa :icon="faSave"/> {{ $t('install') }}</MkButton> + </details> + </div> + <div class="_content"> + <details> + <summary><Fa :icon="faFolderOpen"/> {{ $t('manage') }}</summary> + <MkSelect v-model:value="selectedPluginId"> + <option v-for="x in $store.state.deviceUser.plugins" :value="x.id" :key="x.id">{{ x.name }}</option> + </MkSelect> + <template v-if="selectedPlugin"> + <div style="margin: -8px 0 8px 0;"> + <MkSwitch :value="selectedPlugin.active" @update:value="changeActive(selectedPlugin, $event)">{{ $t('makeActive') }}</MkSwitch> + </div> + <div class="_keyValue"> + <div>{{ $t('version') }}:</div> + <div>{{ selectedPlugin.version }}</div> + </div> + <div class="_keyValue"> + <div>{{ $t('author') }}:</div> + <div>{{ selectedPlugin.author }}</div> + </div> + <div class="_keyValue"> + <div>{{ $t('description') }}:</div> + <div>{{ selectedPlugin.description }}</div> + </div> + <div style="margin-top: 8px;"> + <MkButton @click="config()" inline v-if="selectedPlugin.config"><Fa :icon="faCog"/> {{ $t('settings') }}</MkButton> + <MkButton @click="uninstall()" inline><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton> + </div> + </template> + </details> + </div> +</section> +</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 { faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkSelect, + MkInfo, + MkSwitch, + }, + + data() { + return { + script: '', + selectedPluginId: null, + faPlug, faSave, faTrashAlt, faFolderOpen, faDownload, faCog + } + }, + + computed: { + selectedPlugin() { + if (this.selectedPluginId == null) return null; + return this.$store.state.deviceUser.plugins.find(x => x.id === this.selectedPluginId); + }, + }, + + methods: { + async install() { + let ast; + try { + ast = parse(this.script); + } 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(async (res, rej) => { + os.popup(await import('@/components/token-generate-window.vue'), { + title: this.$t('tokenRequested'), + information: this.$t('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.$store.commit('deviceUser/installPlugin', { + id: uuid(), + meta: { + name, version, author, description, permissions, config + }, + token, + ast: serialize(ast) + }); + + os.success(); + + this.$nextTick(() => { + location.reload(); + }); + }, + + uninstall() { + this.$store.commit('deviceUser/uninstallPlugin', this.selectedPluginId); + os.success(); + this.$nextTick(() => { + location.reload(); + }); + }, + + // TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする + async config() { + const config = this.selectedPlugin.config; + for (const key in this.selectedPlugin.configData) { + config[key].default = this.selectedPlugin.configData[key]; + } + + const { canceled, result } = await os.form(this.selectedPlugin.name, config); + if (canceled) return; + + this.$store.commit('deviceUser/configPlugin', { + id: this.selectedPluginId, + config: result + }); + + this.$nextTick(() => { + location.reload(); + }); + }, + + changeActive(plugin, active) { + this.$store.commit('deviceUser/changePluginActive', { + id: plugin.id, + active: active + }); + + this.$nextTick(() => { + location.reload(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue new file mode 100644 index 0000000000..a92baca9d9 --- /dev/null +++ b/src/client/pages/settings/privacy.vue @@ -0,0 +1,86 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_content"> + <MkSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</MkSwitch> + <MkSwitch v-model:value="autoAcceptFollowed" v-if="isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</MkSwitch> + </div> + <div class="_content"> + <MkSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</MkSwitch> + <MkSelect v-model:value="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility"> + <template #label>{{ $t('defaultNoteVisibility') }}</template> + <option value="public">{{ $t('_visibility.public') }}</option> + <option value="home">{{ $t('_visibility.home') }}</option> + <option value="followers">{{ $t('_visibility.followers') }}</option> + <option value="specified">{{ $t('_visibility.specified') }}</option> + </MkSelect> + <MkSwitch v-model:value="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</MkSwitch> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLockOpen } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkSelect, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('privacy'), + icon: faLockOpen + }] + }, + isLocked: false, + autoAcceptFollowed: false, + } + }, + + computed: { + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } + }, + + defaultNoteLocalOnly: { + get() { return this.$store.state.settings.defaultNoteLocalOnly; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteLocalOnly', value }); } + }, + + rememberNoteVisibility: { + get() { return this.$store.state.settings.rememberNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'rememberNoteVisibility', value }); } + }, + }, + + created() { + this.isLocked = this.$store.state.i.isLocked; + this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + save() { + os.api('i/update', { + isLocked: !!this.isLocked, + autoAcceptFollowed: !!this.autoAcceptFollowed, + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue new file mode 100644 index 0000000000..4444b4f484 --- /dev/null +++ b/src/client/pages/settings/profile.vue @@ -0,0 +1,232 @@ +<template> +<div class="_section"> + <div class="llvierxe _card"> + <div class="_title"><Fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div> + <div class="_content"> + <div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner"> + <MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/> + </div> + + <MkInput v-model:value="name" :max="30"> + <span>{{ $t('_profile.name') }}</span> + </MkInput> + + <MkTextarea v-model:value="description" :max="500"> + <span>{{ $t('_profile.description') }}</span> + <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> + </MkTextarea> + + <MkInput v-model:value="location"> + <span>{{ $t('location') }}</span> + <template #prefix><Fa :icon="faMapMarkerAlt"/></template> + </MkInput> + + <MkInput v-model:value="birthday" type="date"> + <template #title>{{ $t('birthday') }}</template> + <template #prefix><Fa :icon="faBirthdayCake"/></template> + </MkInput> + + <details class="fields"> + <summary>{{ $t('_profile.metadata') }}</summary> + <div class="row"> + <MkInput v-model:value="fieldName0">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue0">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + <div class="row"> + <MkInput v-model:value="fieldName1">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue1">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + <div class="row"> + <MkInput v-model:value="fieldName2">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue2">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + <div class="row"> + <MkInput v-model:value="fieldName3">{{ $t('_profile.metadataLabel') }}</MkInput> + <MkInput v-model:value="fieldValue3">{{ $t('_profile.metadataContent') }}</MkInput> + </div> + </details> + + <MkSwitch v-model:value="isBot">{{ $t('flagAsBot') }}</MkSwitch> + <MkSwitch v-model:value="isCat">{{ $t('flagAsCat') }}</MkSwitch> + </div> + <div class="_footer"> + <MkButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons'; +import { faSave } from '@fortawesome/free-regular-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/ui/input.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import { host } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkInput, + MkTextarea, + MkSwitch, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('profile'), + icon: faUser + }] + }, + host, + name: null, + description: null, + birthday: 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, + saving: false, + faSave, faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake + } + }, + + created() { + this.name = this.$store.state.i.name; + this.description = this.$store.state.i.description; + this.location = this.$store.state.i.location; + this.birthday = this.$store.state.i.birthday; + this.avatarId = this.$store.state.i.avatarId; + this.bannerId = this.$store.state.i.bannerId; + this.isBot = this.$store.state.i.isBot; + this.isCat = this.$store.state.i.isCat; + + this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null; + this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null; + this.fieldName1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].name : null; + this.fieldValue1 = this.$store.state.i.fields[1] ? this.$store.state.i.fields[1].value : null; + this.fieldName2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].name : null; + this.fieldValue2 = this.$store.state.i.fields[2] ? this.$store.state.i.fields[2].value : null; + this.fieldName3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].name : null; + this.fieldValue3 = this.$store.state.i.fields[3] ? this.$store.state.i.fields[3].value : null; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + changeAvatar(e) { + selectFile(e.currentTarget || e.target, this.$t('avatar')).then(file => { + os.api('i/update', { + avatarId: file.id, + }); + }); + }, + + changeBanner(e) { + selectFile(e.currentTarget || e.target, this.$t('banner')).then(file => { + os.api('i/update', { + bannerId: file.id, + }); + }); + }, + + save(notify) { + 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 }, + ]; + + this.saving = true; + + os.api('i/update', { + name: this.name || null, + description: this.description || null, + location: this.location || null, + birthday: this.birthday || null, + fields, + isBot: !!this.isBot, + isCat: !!this.isCat, + }).then(i => { + this.saving = false; + this.$store.state.i.avatarId = i.avatarId; + this.$store.state.i.avatarUrl = i.avatarUrl; + this.$store.state.i.bannerId = i.bannerId; + this.$store.state.i.bannerUrl = i.bannerUrl; + + if (notify) { + os.success(); + } + }).catch(err => { + this.saving = false; + os.dialog({ + type: 'error', + text: err.id + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + > ._content { + > .header { + position: relative; + height: 150px; + overflow: hidden; + background-size: cover; + background-position: center; + border-radius: 5px; + border: solid 1px var(--divider); + box-sizing: border-box; + cursor: pointer; + + > .avatar { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: block; + width: 72px; + height: 72px; + margin: auto; + cursor: pointer; + box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5); + } + } + + > .fields { + > .row { + > * { + display: inline-block; + width: 50%; + margin-bottom: 0; + } + } + } + } +} +</style> diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue new file mode 100644 index 0000000000..683cf6dfbe --- /dev/null +++ b/src/client/pages/settings/reaction.vue @@ -0,0 +1,95 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_title"><Fa :icon="faLaugh"/> {{ $t('reaction') }}</div> + <div class="_content"> + <MkInput v-model:value="reactions" style="font-family: 'Segoe UI Emoji', 'Noto Color Emoji', Roboto, HelveticaNeue, Arial, sans-serif"> + {{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></template> + </MkInput> + <MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton> + </div> + <div class="_footer"> + <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton inline @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons'; +import { faUndo } from '@fortawesome/free-solid-svg-icons'; +import MkInput from '@/components/ui/input.vue'; +import MkButton from '@/components/ui/button.vue'; +import { emojiRegexWithCustom } from '../../../misc/emoji-regex'; +import { defaultSettings } from '@/store'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkInput, + MkButton, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('reaction'), + icon: faLaugh + }] + }, + reactions: this.$store.state.settings.reactions.join(''), + changed: false, + faLaugh, faSave, faEye, faUndo + } + }, + + computed: { + splited(): any { + return this.reactions.match(emojiRegexWithCustom); + }, + }, + + watch: { + reactions: { + handler() { + this.changed = true; + }, + deep: true + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + save() { + this.$store.dispatch('settings/set', { key: 'reactions', value: this.splited }); + this.changed = false; + }, + + async preview(ev) { + os.popup(await import('@/components/reaction-picker.vue'), { + reactions: this.splited, + showFocus: false, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + }, + + setDefault() { + this.reactions = defaultSettings.reactions.join(''); + }, + + async chooseEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target).then(emoji => { + this.reactions += emoji; + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/security.2fa.vue b/src/client/pages/settings/security.2fa.vue new file mode 100644 index 0000000000..22b3878445 --- /dev/null +++ b/src/client/pages/settings/security.2fa.vue @@ -0,0 +1,235 @@ +<template> +<section class="_card"> + <div class="_title"><Fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div> + <div class="_content"> + <MkButton v-if="!data && !$store.state.i.twoFactorEnabled" @click="register">{{ $t('_2fa.registerDevice') }}</MkButton> + <template v-if="$store.state.i.twoFactorEnabled"> + <p>{{ $t('_2fa.alreadyRegistered') }}</p> + <MkButton @click="unregister">{{ $t('unregister') }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ $t('securityKey') }}</h2> + <p>{{ $t('_2fa.securityKeyInfo') }}</p> + <div class="key-list"> + <div class="key" v-for="key in $store.state.i.securityKeysList"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ $t('lastUsed') }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ $t('unregister') }}</MkButton> + </div> + </div> + + <MkSwitch v-model:value="usePasswordLessLogin" @update:value="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0">{{ $t('passwordLessLogin') }}</MkSwitch> + + <MkInfo warn v-if="registration && registration.error">{{ $t('error') }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('_2fa.registerKey') }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ $t('tapSecurityKey') }} + <Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" /> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model:value="keyName" :max="30"> + <span>{{ $t('securityKeyName') }}</span> + </MkInput> + <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $t('registerSecurityKey') }}</MkButton> + <Fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="data && !$store.state.i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <i18n-t keypath="_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-t> + </li> + <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li> + <li>{{ $t('_2fa.step3') }}<br> + <MkInput v-model:value="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</MkInput> + <MkButton primary @click="submit">{{ $t('done') }}</MkButton> + </li> + </ol> + <MkInfo>{{ $t('_2fa.step4') }}</MkInfo> + </div> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +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/ui/input.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, MkInfo, MkInput, MkSwitch + }, + data() { + return { + data: null, + supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, + registration: null, + keyName: '', + token: null, + faLock + }; + }, + methods: { + register() { + os.dialog({ + title: this.$t('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.$t('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.$store.state.i.twoFactorEnabled = false; + }); + }); + }, + + submit() { + os.api('i/2fa/done', { + token: this.token + }).then(() => { + os.success(); + this.$store.state.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.$t('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.$t('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.$store.state.i.id, 'ascii'), + name: this.$store.state.i.username, + displayName: this.$store.state.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/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue new file mode 100644 index 0000000000..e56d4ae99d --- /dev/null +++ b/src/client/pages/settings/security.vue @@ -0,0 +1,102 @@ +<template> +<div> + <div class="_section"> + <X2fa/> + </div> + <div class="_section"> + <MkButton primary @click="change()" full>{{ $t('changePassword') }}</MkButton> + </div> + <div class="_section"> + <MkButton class="_vMargin" primary @click="regenerateToken" full><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</MkButton> + <div class="_caption _vMargin" style="padding: 0 6px;">{{ $t('regenerateLoginTokenDescription') }}</div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faLock, faSyncAlt } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import X2fa from './security.2fa.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + X2fa, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('security'), + icon: faLock + }] + }, + faLock, faSyncAlt + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await os.dialog({ + title: this.$t('currentPassword'), + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.dialog({ + title: this.$t('newPassword'), + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.dialog({ + title: this.$t('newPasswordRetype'), + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.dialog({ + type: 'error', + text: this.$t('retypedNotMatch') + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword + }); + }, + + regenerateToken() { + os.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password + }); + }); + }, + } +}); +</script> diff --git a/src/client/pages/settings/sidebar.vue b/src/client/pages/settings/sidebar.vue new file mode 100644 index 0000000000..e55899df97 --- /dev/null +++ b/src/client/pages/settings/sidebar.vue @@ -0,0 +1,110 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_content"> + <MkTextarea v-model:value="items" tall> + <span>{{ $t('sidebar') }}</span> + <template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template> + </MkTextarea> + </div> + <div class="_content"> + <div>{{ $t('display') }}</div> + <MkRadio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</MkRadio> + <MkRadio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</MkRadio> + <!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </div> + <div class="_footer"> + <MkButton inline @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + <MkButton inline @click="reset()"><Fa :icon="faRedo"/> {{ $t('default') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkRadio from '@/components/ui/radio.vue'; +import { defaultDeviceUserSettings } from '@/store'; +import * as os from '@/os'; +import { sidebarDef } from '@/sidebar'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkRadio, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('sidebar'), + icon: faListUl + }] + }, + menuDef: sidebarDef, + items: '', + faSave, faRedo + } + }, + + computed: { + splited(): string[] { + return this.items.trim().split('\n').filter(x => x.trim() !== ''); + }, + + sidebarDisplay: { + get() { return this.$store.state.device.sidebarDisplay; }, + set(value) { this.$store.commit('device/set', { key: 'sidebarDisplay', value }); } + }, + }, + + created() { + this.items = this.$store.state.deviceUser.menu.join('\n'); + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + async addItem() { + const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.deviceUser.menu.includes(k)); + const { canceled, result: item } = await os.dialog({ + type: null, + title: this.$t('addItem'), + select: { + items: [...menu.map(k => ({ + value: k, text: this.$t(this.menuDef[k].title) + })), ...[{ + value: '-', text: this.$t('divider') + }]] + }, + showCancelButton: true + }); + if (canceled) return; + this.items = [...this.splited, item].join('\n'); + this.save(); + }, + + save() { + this.$store.commit('deviceUser/setMenu', this.splited); + }, + + reset() { + this.$store.commit('deviceUser/setMenu', defaultDeviceUserSettings.menu); + this.items = this.$store.state.deviceUser.menu.join('\n'); + }, + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/src/client/pages/settings/sounds.vue b/src/client/pages/settings/sounds.vue new file mode 100644 index 0000000000..fc6b751fed --- /dev/null +++ b/src/client/pages/settings/sounds.vue @@ -0,0 +1,152 @@ +<template> +<div class="_section"> + <div class="_card"> + <div class="_title"><Fa :icon="faMusic"/> {{ $t('sounds') }}</div> + <div class="_content"> + <MkRange v-model:value="sfxVolume" :min="0" :max="1" :step="0.1"> + <Fa slot="icon" :icon="volumeIcon"/> + <span slot="title">{{ $t('volume') }}</span> + </MkRange> + </div> + <div class="_content"> + <MkSelect v-model:value="sfxNote"> + <template #label>{{ $t('_sfx.note') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxNoteMy"> + <template #label>{{ $t('_sfx.noteMy') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxNotification"> + <template #label>{{ $t('_sfx.notification') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxChat"> + <template #label>{{ $t('_sfx.chat') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxChatBg"> + <template #label>{{ $t('_sfx.chatBg') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxAntenna"> + <template #label>{{ $t('_sfx.antenna') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + <MkSelect v-model:value="sfxChannel"> + <template #label>{{ $t('_sfx.channel') }}</template> + <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> + <template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> + </MkSelect> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '@/components/ui/select.vue'; +import MkRange from '@/components/ui/range.vue'; +import * as os from '@/os'; + +const sounds = [ + 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', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba', + 'noizenecio/kick_gaba2', +]; + +export default defineComponent({ + components: { + MkSelect, + MkRange, + }, + + data() { + return { + sounds, + faMusic, faPlay, faVolumeUp, faVolumeMute, + } + }, + + computed: { + sfxVolume: { + get() { return this.$store.state.device.sfxVolume; }, + set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); } + }, + + sfxNote: { + get() { return this.$store.state.device.sfxNote; }, + set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); } + }, + + sfxNoteMy: { + get() { return this.$store.state.device.sfxNoteMy; }, + set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); } + }, + + sfxNotification: { + get() { return this.$store.state.device.sfxNotification; }, + set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); } + }, + + sfxChat: { + get() { return this.$store.state.device.sfxChat; }, + set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); } + }, + + sfxChatBg: { + get() { return this.$store.state.device.sfxChatBg; }, + set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); } + }, + + sfxAntenna: { + get() { return this.$store.state.device.sfxAntenna; }, + set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); } + }, + + sfxChannel: { + get() { return this.$store.state.device.sfxChannel; }, + set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); } + }, + + volumeIcon: { + get() { + return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp; + } + } + }, + + methods: { + listen(sound) { + const audio = new Audio(`/assets/sounds/${sound}.mp3`); + audio.volume = this.$store.state.device.sfxVolume; + audio.play(); + }, + } +}); +</script> diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue new file mode 100644 index 0000000000..0571b6c5d1 --- /dev/null +++ b/src/client/pages/settings/theme.vue @@ -0,0 +1,499 @@ +<template> +<div class="_section"> + <div class="rfqxtzch _card _vMargin"> + <div class="_content"> + <div class="darkMode" :class="{ disabled: syncDeviceDarkMode }"> + <div class="toggleWrapper"> + <input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/> + <label for="dn" class="toggle"> + <span class="before">{{ $t('light') }}</span> + <span class="after">{{ $t('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> + <MkSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</MkSwitch> + </div> + <div class="_content"> + <MkSelect v-model:value="lightTheme"> + <template #label>{{ $t('themeForLightMode') }}</template> + <optgroup :label="$t('lightThemes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('darkThemes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model:value="darkTheme"> + <template #label>{{ $t('themeForDarkMode') }}</template> + <optgroup :label="$t('darkThemes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('lightThemes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </MkSelect> + <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<router-link to="/theme-editor" class="_link">{{ $t('_theme.make') }}</router-link> + </div> + <div class="_content"> + <MkButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</MkButton> + <MkButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</MkButton> + </div> + </div> + <div class="_card _vMargin"> + <div class="_title"><Fa :icon="faDownload"/> {{ $t('_theme.install') }}</div> + <div class="_content"> + <MkTextarea v-model:value="installThemeCode"> + <span>{{ $t('_theme.code') }}</span> + </MkTextarea> + <MkButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</MkButton> + <MkButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton> + </div> + </div> + <div class="_card _vMargin"> + <div class="_title"><Fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</div> + <div class="_content"> + <MkSelect v-model:value="selectedThemeId"> + <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </MkSelect> + <template v-if="selectedTheme"> + <MkTextarea readonly tall :value="selectedThemeCode"> + <span>{{ $t('_theme.code') }}</span> + <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template> + </MkTextarea> + <MkButton @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton> + </template> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons'; +import * as JSON5 from 'json5'; +import MkButton from '@/components/ui/button.vue'; +import MkSelect from '@/components/ui/select.vue'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import { Theme, builtinThemes, applyTheme, validateTheme } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSelect, + MkSwitch, + MkTextarea, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('theme'), + icon: faPalette + }] + }, + builtinThemes, + installThemeCode: null, + selectedThemeId: null, + wallpaper: localStorage.getItem('wallpaper'), + faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye + } + }, + + computed: { + themes(): Theme[] { + return builtinThemes.concat(this.$store.state.device.themes); + }, + + installedThemes(): Theme[] { + return this.$store.state.device.themes; + }, + + darkThemes(): Theme[] { + return this.themes.filter(t => t.base == 'dark' || t.kind == 'dark'); + }, + + lightThemes(): Theme[] { + return this.themes.filter(t => t.base == 'light' || t.kind == 'light'); + }, + + darkTheme: { + get() { return this.$store.state.device.darkTheme; }, + set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); } + }, + + lightTheme: { + get() { return this.$store.state.device.lightTheme; }, + set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); } + }, + + darkMode: { + get() { return this.$store.state.device.darkMode; }, + set(value) { this.$store.commit('device/set', { key: 'darkMode', value }); } + }, + + syncDeviceDarkMode: { + get() { return this.$store.state.device.syncDeviceDarkMode; }, + set(value) { this.$store.commit('device/set', { key: 'syncDeviceDarkMode', value }); } + }, + + 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'); + }, + }, + + watch: { + darkTheme() { + if (this.$store.state.device.darkMode) { + applyTheme(this.themes.find(x => x.id === this.darkTheme)); + } + }, + + lightTheme() { + if (!this.$store.state.device.darkMode) { + applyTheme(this.themes.find(x => x.id === this.lightTheme)); + } + }, + + syncDeviceDarkMode() { + if (this.$store.state.device.syncDeviceDarkMode) { + this.$store.commit('device/set', { key: 'darkMode', value: isDeviceDarkmode() }); + } + }, + + wallpaper() { + if (this.wallpaper == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', this.wallpaper); + } + location.reload(); + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + setWallpaper(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + this.wallpaper = file.url; + }); + }, + + copyThemeCode() { + copyToClipboard(this.selectedThemeCode); + os.success(); + }, + + parseThemeCode(code) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$t('_theme.invalid') + }); + return false; + } + if (!validateTheme(theme)) { + os.dialog({ + type: 'error', + text: this.$t('_theme.invalid') + }); + return false; + } + if (this.$store.state.device.themes.some(t => t.id === theme.id)) { + os.dialog({ + type: 'info', + text: this.$t('_theme.alreadyInstalled') + }); + return false; + } + + return theme; + }, + + preview(code) { + const theme = this.parseThemeCode(code); + if (theme) applyTheme(theme, false); + }, + + install(code) { + const theme = this.parseThemeCode(code); + if (!theme) return; + const themes = this.$store.state.device.themes.concat(theme); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + }, + + uninstall() { + const theme = this.selectedTheme; + const themes = this.$store.state.device.themes.filter(t => t.id != theme.id); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + os.success(); + }, + } +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + > ._content { + > .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/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue new file mode 100644 index 0000000000..a517536a1c --- /dev/null +++ b/src/client/pages/settings/word-mute.vue @@ -0,0 +1,101 @@ +<template> +<div class="_section"> + <div class="_card"> + <MkTab v-model:value="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/> + <div class="_content"> + <div v-show="tab === 'soft'"> + <MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo> + <MkTextarea v-model:value="softMutedWords"> + <span>{{ $t('_wordMute.muteWords') }}</span> + <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> + </MkTextarea> + </div> + <div v-show="tab === 'hard'"> + <MkInfo>{{ $t('_wordMute.hardDescription') }}</MkInfo> + <MkTextarea v-model:value="hardMutedWords" style="margin-bottom: 16px;"> + <span>{{ $t('_wordMute.muteWords') }}</span> + <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> + </MkTextarea> + <div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div> + </div> + </div> + <div class="_footer"> + <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/ui/textarea.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkTab, + MkInfo, + }, + + emits: ['info'], + + data() { + return { + INFO: { + header: [{ + title: this.$t('wordMute'), + icon: faCommentSlash + }] + }, + tab: 'soft', + softMutedWords: '', + hardMutedWords: '', + hardWordMutedNotesCount: null, + changed: false, + faSave, + } + }, + + watch: { + softMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + hardMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + }, + + async created() { + this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n'); + this.hardMutedWords = this.$store.state.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.INFO); + }, + + methods: { + async save() { + this.$store.dispatch('settings/set', { key: 'mutedWords', value: 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; + }, + } +}); +</script> |