diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2020-01-30 04:37:25 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2020-01-30 04:37:25 +0900 |
| commit | f6154dc0af1a0d65819e87240f4385f9573095cb (patch) | |
| tree | 699a5ca07d6727b7f8497d4769f25d6d62f94b5a /src/client/pages/settings | |
| parent | Add Event activity-type support (#5785) (diff) | |
| download | misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.gz misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.tar.bz2 misskey-f6154dc0af1a0d65819e87240f4385f9573095cb.zip | |
v12 (#5712)
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Diffstat (limited to 'src/client/pages/settings')
| -rw-r--r-- | src/client/pages/settings/2fa.vue | 264 | ||||
| -rw-r--r-- | src/client/pages/settings/drive.vue | 212 | ||||
| -rw-r--r-- | src/client/pages/settings/general.vue | 108 | ||||
| -rw-r--r-- | src/client/pages/settings/import-export.vue | 121 | ||||
| -rw-r--r-- | src/client/pages/settings/index.vue | 94 | ||||
| -rw-r--r-- | src/client/pages/settings/integration.vue | 122 | ||||
| -rw-r--r-- | src/client/pages/settings/mute-block.vue | 76 | ||||
| -rw-r--r-- | src/client/pages/settings/privacy.vue | 69 | ||||
| -rw-r--r-- | src/client/pages/settings/profile.vue | 246 | ||||
| -rw-r--r-- | src/client/pages/settings/reaction.vue | 62 | ||||
| -rw-r--r-- | src/client/pages/settings/security.vue | 87 | ||||
| -rw-r--r-- | src/client/pages/settings/theme.vue | 76 |
12 files changed, 1537 insertions, 0 deletions
diff --git a/src/client/pages/settings/2fa.vue b/src/client/pages/settings/2fa.vue new file mode 100644 index 0000000000..7163f2ece4 --- /dev/null +++ b/src/client/pages/settings/2fa.vue @@ -0,0 +1,264 @@ +<template> +<section class="_section"> + <div class="_title"><fa :icon="faLock"/> {{ $t('twoStepAuthentication') }}</div> + <div class="_content"> + <p v-if="!data && !$store.state.i.twoFactorEnabled"><mk-button @click="register">{{ $t('_2fa.registerDevice') }}</mk-button></p> + <template v-if="$store.state.i.twoFactorEnabled"> + <h2 class="heading">{{ $t('totp-header') }}</h2> + <p>{{ $t('already-registered') }}</p> + <mk-button @click="unregister">{{ $t('unregister') }}</mk-button> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ $t('security-key-header') }}</h2> + <p>{{ $t('security-key') }}</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('last-used') }} + <mk-time :time="key.lastUsed"/> + </div> + <mk-button @click="unregisterKey(key)"> + {{ $t('unregister') }} + </mk-button> + </div> + </div> + + <mk-switch v-model="usePasswordLessLogin" @change="updatePasswordLessLogin" v-if="$store.state.i.securityKeysList.length > 0"> + {{ $t('use-password-less-login') }} + </mk-switch> + + <mk-info warn v-if="registration && registration.error">{{ $t('something-went-wrong') }} {{ registration.error }}</mk-info> + <mk-button v-if="!registration || registration.error" @click="addSecurityKey">{{ $t('register') }}</mk-button> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ $t('activate-key') }} + <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 0" /> + </li> + <li v-if="registration.stage >= 1"> + <mk-form :disabled="registration.stage != 1 || registration.saving"> + <mk-input v-model="keyName" :max="30"> + <span>{{ $t('security-key-name') }}</span> + </mk-input> + <mk-button @click="registerKey" :disabled="this.keyName.length == 0"> + {{ $t('register-security-key') }} + </mk-button> + <fa icon="spinner" pulse fixed-width v-if="registration.saving && registration.stage == 1" /> + </mk-form> + </li> + </ol> + </template> + </template> + <div v-if="data && !$store.state.i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <i18n path="_2fa.step1" tag="span"> + <a href="https://authy.com/" rel="noopener" target="_blank" place="a" style="color: var(--link);">Authy</a> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" place="b" style="color: var(--link);">Google Authenticator</a> + </i18n> + </li> + <li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li> + <li>{{ $t('_2fa.step3') }}<br> + <mk-input v-model="token" type="number" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false">{{ $t('token') }}</mk-input> + <mk-button primary @click="submit">{{ $t('done') }}</mk-button> + </li> + </ol> + <mk-info>{{ $t('_2fa.step4') }}</mk-info> + </div> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../i18n'; +import { hostname } from '../../config'; +import { hexifyAB } from '../../scripts/2fa'; +import MkButton from '../../components/ui/button.vue'; +import MkInfo from '../../components/ui/info.vue'; +import MkInput from '../../components/ui/input.vue'; + +function stringifyAB(buffer) { + return String.fromCharCode.apply(null, new Uint8Array(buffer)); +} + +export default Vue.extend({ + i18n, + components: { + MkButton, MkInfo, MkInput + }, + data() { + return { + data: null, + supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, + registration: null, + keyName: '', + token: null, + faLock + }; + }, + methods: { + register() { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/2fa/unregister', { + password: password + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$store.state.i.twoFactorEnabled = false; + }); + }); + }, + + submit() { + this.$root.api('i/2fa/done', { + token: this.token + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + this.$store.state.i.twoFactorEnabled = true; + }).catch(e => { + this.$root.dialog({ + type: 'error', + iconOnly: true, autoClose: true + }); + }); + }, + + registerKey() { + this.registration.saving = true; + this.$root.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: stringifyAB(this.registration.credential.response.clientDataJSON), + attestationObject: hexifyAB(this.registration.credential.response.attestationObject) + }).then(key => { + this.registration = null; + key.lastUsed = new Date(); + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }) + }, + + unregisterKey(key) { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + return this.$root.api('i/2fa/remove-key', { + password, + credentialId: key.id + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }); + }); + }, + + addSecurityKey() { + this.$root.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + this.$root.api('i/2fa/register-key', { + password + }).then(registration => { + this.registration = { + password, + challengeId: registration.challengeId, + stage: 0, + publicKeyOptions: { + challenge: Buffer.from( + registration.challenge + .replace(/\-/g, "+") + .replace(/_/g, "/"), + 'base64' + ), + rp: { + id: hostname, + name: 'Misskey' + }, + user: { + id: Uint8Array.from(this.$store.state.i.id, c => c.charCodeAt(0)), + 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() { + this.$root.api('i/2fa/password-less', { + value: !!this.usePasswordLessLogin + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/drive.vue b/src/client/pages/settings/drive.vue new file mode 100644 index 0000000000..d0c18a07e5 --- /dev/null +++ b/src/client/pages/settings/drive.vue @@ -0,0 +1,212 @@ +<template> +<section class="mk-settings-page-drive _section"> + <div class="_title"><fa :icon="faCloud"/> {{ $t('drive') }}</div> + <div class="_content"> + <mk-pagination :pagination="drivePagination" #default="{items}" class="drive" ref="drive"> + <div class="file" v-for="(file, i) in items" :key="file.id" :data-index="i" @click="selected = file" :class="{ selected: selected && (selected.id === file.id) }"> + <x-file-thumbnail class="thumbnail" :file="file" fit="cover"/> + <div class="body"> + <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> + <footer> + <span class="type"><x-file-type-icon :type="file.type" class="icon"/>{{ file.type }}</span> + <span class="separator"></span> + <span class="data-size">{{ file.size | bytes }}</span> + <span class="separator"></span> + <span class="created-at"><fa :icon="faClock"/><mk-time :time="file.createdAt"/></span> + <template v-if="file.isSensitive"> + <span class="separator"></span> + <span class="nsfw"><fa :icon="faEyeSlash"/> {{ $t('nsfw') }}</span> + </template> + </footer> + </div> + </div> + </mk-pagination> + </div> + <div class="_footer"> + <mk-button primary inline :disabled="selected == null" @click="download()"><fa :icon="faDownload"/> {{ $t('download') }}</mk-button> + <mk-button inline :disabled="selected == null" @click="del()"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCloud, faDownload } from '@fortawesome/free-solid-svg-icons'; +import { faClock, faEyeSlash, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import XFileTypeIcon from '../../components/file-type-icon.vue'; +import XFileThumbnail from '../../components/drive-file-thumbnail.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkPagination from '../../components/ui/pagination.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + XFileTypeIcon, + XFileThumbnail, + MkPagination, + MkButton, + }, + + data() { + return { + selected: null, + connection: null, + drivePagination: { + endpoint: 'drive/files', + limit: 10, + }, + faCloud, faClock, faEyeSlash, faDownload, faTrashAlt + } + }, + + created() { + this.connection = this.$root.stream.useSharedConnection('drive'); + + this.connection.on('fileCreated', this.onStreamDriveFileCreated); + this.connection.on('fileUpdated', this.onStreamDriveFileUpdated); + this.connection.on('fileDeleted', this.onStreamDriveFileDeleted); + }, + + beforeDestroy() { + this.connection.dispose(); + }, + + methods: { + onStreamDriveFileCreated(file) { + this.$refs.drive.prepend(file); + }, + + onStreamDriveFileUpdated(file) { + // TODO + }, + + onStreamDriveFileDeleted(fileId) { + this.$refs.drive.remove(x => x.id === fileId); + }, + + download() { + window.open(this.selected.url, '_blank'); + }, + + async del() { + const { canceled } = await this.$root.dialog({ + type: 'warning', + text: this.$t('driveFileDeleteConfirm', { name: this.selected.name }), + showCancelButton: true + }); + if (canceled) return; + + this.$root.api('drive/files/delete', { + fileId: this.selected.id + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page-drive { + > ._content { + max-height: 350px; + overflow: auto; + + > .drive { + > .file { + display: grid; + margin: 0 auto; + grid-template-columns: 64px 1fr; + grid-column-gap: 10px; + cursor: pointer; + + &.selected { + background: var(--accent); + box-shadow: 0 0 0 8px var(--accent); + color: #fff; + } + + &:not(:last-child) { + margin-bottom: 16px; + } + + > .thumbnail { + width: 64px; + height: 64px; + } + + > .body { + display: block; + word-break: break-all; + padding-top: 4px; + + > .name { + display: block; + margin: 0; + padding: 0; + font-size: 0.9em; + font-weight: bold; + word-break: break-word; + + > .ext { + opacity: 0.5; + } + } + + > .tags { + display: block; + margin: 4px 0 0 0; + padding: 0; + list-style: none; + font-size: 0.5em; + + > .tag { + display: inline-block; + margin: 0 5px 0 0; + padding: 1px 5px; + border-radius: 2px; + } + } + + > footer { + display: block; + margin: 4px 0 0 0; + font-size: 0.7em; + + > .separator { + padding: 0 4px; + } + + > .type { + opacity: 0.7; + + > .icon { + margin-right: 4px; + } + } + + > .data-size { + opacity: 0.7; + } + + > .created-at { + opacity: 0.7; + + > [data-icon] { + margin-right: 2px; + } + } + + > .nsfw { + color: #bf4633; + } + } + } + } + } + } +} +</style> diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue new file mode 100644 index 0000000000..6b63da742c --- /dev/null +++ b/src/client/pages/settings/general.vue @@ -0,0 +1,108 @@ +<template> +<section class="mk-settings-page-general _section"> + <div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div> + <div class="_content"> + <mk-input type="file" @change="onWallpaperChange" style="margin-top: 0;"> + <span>{{ $t('wallpaper') }}</span> + <template #icon><fa :icon="faImage"/></template> + <template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template> + </mk-input> + <mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button> + </div> + <div class="_content"> + <mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch"> + {{ $t('auto-watch') }}<template #desc>{{ $t('auto-watch-desc') }}</template> + </mk-switch> + </div> + <div class="_content"> + <mk-button @click="readAllNotifications">{{ $t('mark-as-read-all-notifications') }}</mk-button> + <mk-button @click="readAllUnreadNotes">{{ $t('mark-as-read-all-unread-notes') }}</mk-button> + <mk-button @click="readAllMessagingMessages">{{ $t('mark-as-read-all-talk-messages') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faImage, faCog } from '@fortawesome/free-solid-svg-icons'; +import MkInput from '../../components/ui/input.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import i18n from '../../i18n'; +import { apiUrl } from '../../config'; + +export default Vue.extend({ + i18n, + + components: { + MkInput, + MkButton, + MkSwitch, + }, + + data() { + return { + wallpaperUploading: false, + faImage, faCog + } + }, + + computed: { + wallpaper: { + get() { return this.$store.state.settings.wallpaper; }, + set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); } + }, + }, + + methods: { + onWallpaperChange([file]) { + this.wallpaperUploading = true; + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.wallpaper = f.url; + this.wallpaperUploading = false; + document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`; + }) + .catch(e => { + this.wallpaperUploading = false; + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + delWallpaper() { + this.wallpaper = null; + document.documentElement.style.backgroundImage = 'none'; + }, + + onChangeAutoWatch(v) { + this.$root.api('i/update', { + autoWatch: v + }); + }, + + readAllUnreadNotes() { + this.$root.api('i/read_all_unread_notes'); + }, + + readAllMessagingMessages() { + this.$root.api('i/read_all_messaging_messages'); + }, + + readAllNotifications() { + this.$root.api('notifications/mark_all_as_read'); + } + } +}); +</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..5714aabbf6 --- /dev/null +++ b/src/client/pages/settings/import-export.vue @@ -0,0 +1,121 @@ +<template> +<section class="mk-settings-page-import-export _section"> + <div class="_title"><fa :icon="faBoxes"/> {{ $t('importAndExport') }}</div> + <div class="_content"> + <input ref="file" type="file" style="display: none;" @change="onChangeFile"/> + <mk-select v-model="exportTarget" style="margin-top: 0;"> + <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> + </mk-select> + <mk-button inline @click="doExport()"><fa :icon="faDownload"/> {{ $t('export') }}</mk-button> + <mk-button inline @click="doImport()" :disabled="!['following', 'user-lists'].includes(exportTarget)"><fa :icon="faUpload"/> {{ $t('import') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue 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 i18n from '../../i18n'; +import { apiUrl } from '../../config'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkSelect, + }, + + data() { + return { + exportTarget: 'notes', + faDownload, faUpload, faBoxes + } + }, + + methods: { + doExport() { + this.$root.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(() => { + this.$root.dialog({ + type: 'info', + text: this.$t('exportRequested') + }); + }).catch((e: any) => { + this.$root.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 = this.$root.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 => { + this.$root.dialog({ + type: 'error', + text: e + }); + }) + .finally(() => { + dialog.close(); + }); + }, + + reqImport(file) { + this.$root.api( + this.exportTarget == 'following' ? 'i/import-following' : + this.exportTarget == 'user-lists' ? 'i/import-user-lists' : + null, { + fileId: file.id + }).then(() => { + this.$root.dialog({ + type: 'info', + text: this.$t('importRequested') + }); + }).catch((e: any) => { + this.$root.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..1a00c65760 --- /dev/null +++ b/src/client/pages/settings/index.vue @@ -0,0 +1,94 @@ +<template> +<div class="mk-settings-page"> + <portal to="icon"><fa :icon="faCog"/></portal> + <portal to="title">{{ $t('settings') }}</portal> + + <x-profile-setting/> + <x-privacy-setting/> + <x-reaction-setting/> + <x-theme/> + <x-import-export/> + <x-drive/> + <x-general/> + <x-mute-block/> + <x-security/> + <x-2fa/> + <x-integration/> + + <mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button> + <mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; +import XProfileSetting from './profile.vue'; +import XPrivacySetting from './privacy.vue'; +import XImportExport from './import-export.vue'; +import XDrive from './drive.vue'; +import XGeneral from './general.vue'; +import XReactionSetting from './reaction.vue'; +import XMuteBlock from './mute-block.vue'; +import XSecurity from './security.vue'; +import XTheme from './theme.vue'; +import X2fa from './2fa.vue'; +import XIntegration from './integration.vue'; +import MkButton from '../../components/ui/button.vue'; + +export default Vue.extend({ + metaInfo() { + return { + title: this.$t('settings') as string + }; + }, + + components: { + XProfileSetting, + XPrivacySetting, + XImportExport, + XDrive, + XGeneral, + XReactionSetting, + XMuteBlock, + XSecurity, + XTheme, + X2fa, + XIntegration, + MkButton, + }, + + data() { + return { + faCog + } + }, + + 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> + +<style lang="scss" scoped> +.mk-settings-page { + > .logout, + > .cacheClear { + margin: 8px auto; + } +} +</style> diff --git a/src/client/pages/settings/integration.vue b/src/client/pages/settings/integration.vue new file mode 100644 index 0000000000..b156e13027 --- /dev/null +++ b/src/client/pages/settings/integration.vue @@ -0,0 +1,122 @@ +<template> +<section class="_section" v-if="enableTwitterIntegration || enableDiscordIntegration || enableGithubIntegration"> + <div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div> + <div class="_content" v-if="enableTwitterIntegration"> + <header><fa :icon="faTwitter"/> Twitter</header> + <p v-if="$store.state.i.twitter">{{ $t('connectedTo') }}: <a :href="`https://twitter.com/${$store.state.i.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.twitter.screenName }}</a></p> + <mk-button v-if="$store.state.i.twitter" @click="disconnectTwitter">{{ $t('disconnectSerice') }}</mk-button> + <mk-button v-else @click="connectTwitter">{{ $t('connectSerice') }}</mk-button> + </div> + + <div class="_content" v-if="enableDiscordIntegration"> + <header><fa :icon="faDiscord"/> Discord</header> + <p v-if="$store.state.i.discord">{{ $t('connectedTo') }}: <a :href="`https://discordapp.com/users/${$store.state.i.discord.id}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.discord.username }}#{{ $store.state.i.discord.discriminator }}</a></p> + <mk-button v-if="$store.state.i.discord" @click="disconnectDiscord">{{ $t('disconnectSerice') }}</mk-button> + <mk-button v-else @click="connectDiscord">{{ $t('connectSerice') }}</mk-button> + </div> + + <div class="_content" v-if="enableGithubIntegration"> + <header><fa :icon="faGithub"/> GitHub</header> + <p v-if="$store.state.i.github">{{ $t('connectedTo') }}: <a :href="`https://github.com/${$store.state.i.github.login}`" rel="nofollow noopener" target="_blank">@{{ $store.state.i.github.login }}</a></p> + <mk-button v-if="$store.state.i.github" @click="disconnectGithub">{{ $t('disconnectSerice') }}</mk-button> + <mk-button v-else @click="connectGithub">{{ $t('connectSerice') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faShareAlt } from '@fortawesome/free-solid-svg-icons'; +import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; +import i18n from '../../i18n'; +import { apiUrl } from '../../config'; +import MkButton from '../../components/ui/button.vue'; + +export default Vue.extend({ + i18n, + + components: { + MkButton + }, + + data() { + return { + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + faShareAlt, faTwitter, faDiscord, faGithub + }; + }, + + created() { + this.$root.getMeta().then(meta => { + this.enableTwitterIntegration = meta.enableTwitterIntegration; + this.enableDiscordIntegration = meta.enableDiscordIntegration; + this.enableGithubIntegration = meta.enableGithubIntegration; + }); + }, + + mounted() { + if (!document.cookie.match(/i=(\w+)/)) { + document.cookie = `i=${this.$store.state.i.token}; path=/;` + + ` domain=${document.location.hostname}; max-age=31536000;` + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + } + this.$watch('$store.state.i', () => { + if (this.$store.state.i.twitter) { + if (this.twitterForm) this.twitterForm.close(); + } + if (this.$store.state.i.discord) { + if (this.discordForm) this.discordForm.close(); + } + if (this.$store.state.i.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..109b33d4f5 --- /dev/null +++ b/src/client/pages/settings/mute-block.vue @@ -0,0 +1,76 @@ +<template> +<section class="mk-settings-page-mute-block _section"> + <div class="_title"><fa :icon="faBan"/> {{ $t('muteAndBlock') }}</div> + <div class="_content"> + <span>{{ $t('mutedUsers') }}</span> + <mk-pagination :pagination="mutingPagination" class="muting"> + <template #empty><span>{{ $t('noUsers') }}</span></template> + <template #default="{items}"> + <div class="user" v-for="(mute, i) in items" :key="mute.id" :data-index="i"> + <router-link class="name" :to="mute.mutee | userPage"> + <mk-acct :user="mute.mutee"/> + </router-link> + </div> + </template> + </mk-pagination> + </div> + <div class="_content"> + <span>{{ $t('blockedUsers') }}</span> + <mk-pagination :pagination="blockingPagination" class="blocking"> + <template #empty><span>{{ $t('noUsers') }}</span></template> + <template #default="{items}"> + <div class="user" v-for="(block, i) in items" :key="block.id" :data-index="i"> + <router-link class="name" :to="block.blockee | userPage"> + <mk-acct :user="block.blockee"/> + </router-link> + </div> + </template> + </mk-pagination> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBan } from '@fortawesome/free-solid-svg-icons'; +import MkPagination from '../../components/ui/pagination.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkPagination, + }, + + data() { + return { + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + faBan + } + }, +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page-mute-block { + > ._content { + max-height: 350px; + overflow: auto; + + > .muting, + > .blocking { + > .empty { + opacity: 0.5 !important; + } + } + } +} +</style> diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue new file mode 100644 index 0000000000..0fc67d5b7d --- /dev/null +++ b/src/client/pages/settings/privacy.vue @@ -0,0 +1,69 @@ +<template> +<section class="mk-settings-page-privacy _section"> + <div class="_title"><fa :icon="faLock"/> {{ $t('privacy') }}</div> + <div class="_content"> + <mk-switch v-model="isLocked" @change="save()">{{ $t('makeFollowManuallyApprove') }}</mk-switch> + <mk-switch v-model="autoAcceptFollowed" :disabled="!isLocked" @change="save()">{{ $t('autoAcceptFollowed') }}</mk-switch> + </div> + <div class="_content"> + <mk-select v-model="defaultNoteVisibility" style="margin-top: 8px;"> + <template #label>{{ $t('defaultNoteVisibility') }}</template> + <option value="public">{{ $t('_visibility.public') }}</option> + <option value="followers">{{ $t('_visibility.followers') }}</option> + <option value="specified">{{ $t('_visibility.specified') }}</option> + </mk-select> + <mk-switch v-model="rememberNoteVisibility" @change="save()">{{ $t('rememberNoteVisibility') }}</mk-switch> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import MkSelect from '../../components/ui/select.vue'; +import MkSwitch from '../../components/ui/switch.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkSelect, + MkSwitch, + }, + + data() { + return { + isLocked: false, + autoAcceptFollowed: false, + faLock + } + }, + + computed: { + defaultNoteVisibility: { + get() { return this.$store.state.settings.defaultNoteVisibility; }, + set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', 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; + }, + + methods: { + save() { + this.$root.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..e6219c2d56 --- /dev/null +++ b/src/client/pages/settings/profile.vue @@ -0,0 +1,246 @@ +<template> +<section class="mk-settings-page-profile _section"> + <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"> + <mk-input v-model="name" :max="30"> + <span>{{ $t('_profile.name') }}</span> + </mk-input> + + <mk-textarea v-model="description" :max="500"> + <span>{{ $t('_profile.description') }}</span> + <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> + </mk-textarea> + + <mk-input v-model="location"> + <span>{{ $t('location') }}</span> + <template #prefix><fa :icon="faMapMarkerAlt"/></template> + </mk-input> + + <mk-input v-model="birthday" type="date"> + <template #title>{{ $t('birthday') }}</template> + <template #prefix><fa :icon="faBirthdayCake"/></template> + </mk-input> + + <mk-input type="file" @change="onAvatarChange"> + <span>{{ $t('avatar') }}</span> + <template #icon><fa :icon="faImage"/></template> + <template #desc v-if="avatarUploading">{{ $t('uploading') }}<mk-ellipsis/></template> + </mk-input> + + <mk-input type="file" @change="onBannerChange"> + <span>{{ $t('banner') }}</span> + <template #icon><fa :icon="faImage"/></template> + <template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template> + </mk-input> + + <details class="fields"> + <summary>{{ $t('_profile.metadata') }}</summary> + <div class="row"> + <mk-input v-model="fieldName0">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue0">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + <div class="row"> + <mk-input v-model="fieldName1">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue1">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + <div class="row"> + <mk-input v-model="fieldName2">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue2">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + <div class="row"> + <mk-input v-model="fieldName3">{{ $t('_profile.metadataLabel') }}</mk-input> + <mk-input v-model="fieldValue3">{{ $t('_profile.metadataContent') }}</mk-input> + </div> + </details> + + <mk-switch v-model="isBot">{{ $t('flagAsBot') }}</mk-switch> + <mk-switch v-model="isCat">{{ $t('flagAsCat') }}</mk-switch> + </div> + <div class="_footer"> + <mk-button @click="save(true)" primary><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faUnlockAlt, faCogs, faImage, 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 i18n from '../../i18n'; +import { apiUrl, host } from '../../config'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + MkInput, + MkTextarea, + MkSwitch, + }, + + data() { + return { + 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, + avatarUploading: false, + bannerUploading: false, + faSave, faUnlockAlt, faCogs, faImage, 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; + }, + + methods: { + onAvatarChange([file]) { + this.avatarUploading = true; + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.avatarId = f.id; + this.avatarUploading = false; + }) + .catch(e => { + this.avatarUploading = false; + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + onBannerChange([file]) { + this.bannerUploading = true; + + const data = new FormData(); + data.append('file', file); + data.append('i', this.$store.state.i.token); + + fetch(apiUrl + '/drive/files/create', { + method: 'POST', + body: data + }) + .then(response => response.json()) + .then(f => { + this.bannerId = f.id; + this.bannerUploading = false; + }) + .catch(e => { + this.bannerUploading = false; + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + 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; + + this.$root.api('i/update', { + name: this.name || null, + description: this.description || null, + location: this.location || null, + birthday: this.birthday || null, + avatarId: this.avatarId || undefined, + bannerId: this.bannerId || undefined, + 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) { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + } + }).catch(err => { + this.saving = false; + this.$root.dialog({ + type: 'error', + text: err.id + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.mk-settings-page-profile { + > ._content { + > *:first-child { + margin-top: 0; + } + + > .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..310237b5fd --- /dev/null +++ b/src/client/pages/settings/reaction.vue @@ -0,0 +1,62 @@ +<template> +<section class="mk-settings-page-reaction _section"> + <div class="_title"><fa :icon="faLaugh"/> {{ $t('reaction') }}</div> + <div class="_content"> + <mk-textarea v-model="reactions" style="margin-top: 16px;">{{ $t('reaction') }}<template #desc>{{ $t('reactionSettingDescription') }}</template></mk-textarea> + </div> + <div class="_footer"> + <mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> + <mk-button inline @click="preview"><fa :icon="faEye"/> {{ $t('preview') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons'; +import MkTextarea from '../../components/ui/textarea.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkReactionPicker from '../../components/reaction-picker.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkTextarea, + MkButton, + }, + + data() { + return { + reactions: this.$store.state.settings.reactions.join('\n'), + changed: false, + faLaugh, faSave, faEye + } + }, + + watch: { + reactions() { + this.changed = true; + } + }, + + methods: { + save() { + this.$store.dispatch('settings/set', { key: 'reactions', value: this.reactions.trim().split('\n') }); + this.changed = false; + }, + + preview(ev) { + const picker = this.$root.new(MkReactionPicker, { + source: ev.currentTarget || ev.target, + reactions: this.reactions.trim().split('\n'), + showFocus: false, + }); + picker.$once('chosen', reaction => { + picker.close(); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue new file mode 100644 index 0000000000..ecf9c01dd5 --- /dev/null +++ b/src/client/pages/settings/security.vue @@ -0,0 +1,87 @@ +<template> +<section class="_section"> + <div class="_title"><fa :icon="faLock"/> {{ $t('password') }}</div> + <div class="_content"> + <mk-button primary @click="change()">{{ $t('changePassword') }}</mk-button> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faLock } from '@fortawesome/free-solid-svg-icons'; +import MkButton from '../../components/ui/button.vue'; +import i18n from '../../i18n'; + +export default Vue.extend({ + i18n, + + components: { + MkButton, + }, + + data() { + return { + faLock + } + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await this.$root.dialog({ + title: this.$t('currentPassword'), + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await this.$root.dialog({ + title: this.$t('newPassword'), + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await this.$root.dialog({ + title: this.$t('newPasswordRetype'), + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + this.$root.dialog({ + type: 'error', + text: this.$t('retypedNotMatch') + }); + return; + } + + const dialog = this.$root.dialog({ + type: 'waiting', + iconOnly: true + }); + + this.$root.api('i/change-password', { + currentPassword, + newPassword + }).then(() => { + this.$root.dialog({ + type: 'success', + iconOnly: true, autoClose: true + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }).finally(() => { + dialog.close(); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue new file mode 100644 index 0000000000..71628ab2b9 --- /dev/null +++ b/src/client/pages/settings/theme.vue @@ -0,0 +1,76 @@ +<template> +<section class="mk-settings-page-theme _section"> + <div class="_title"><fa :icon="faPalette"/> {{ $t('theme') }}</div> + <div class="_content"> + <mk-select v-model="theme" :placeholder="$t('theme')"> + <template #label>{{ $t('theme') }}</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> + </mk-select> + </div> +</section> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faPalette } from '@fortawesome/free-solid-svg-icons'; +import MkInput from '../../components/ui/input.vue'; +import MkButton from '../../components/ui/button.vue'; +import MkSelect from '../../components/ui/select.vue'; +import i18n from '../../i18n'; +import { Theme, builtinThemes, applyTheme } from '../../theme'; + +export default Vue.extend({ + i18n, + + components: { + MkInput, + MkButton, + MkSelect, + }, + + data() { + return { + wallpaperUploading: false, + faPalette + } + }, + + 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'); + }, + + theme: { + get() { return this.$store.state.device.theme; }, + set(value) { this.$store.commit('device/set', { key: 'theme', value }); } + }, + }, + + watch: { + theme() { + applyTheme(this.themes.find(x => x.id === this.theme)); + } + }, + + methods: { + + } +}); +</script> |