diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-13 12:23:49 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2021-11-13 12:23:49 +0900 |
| commit | 2795fe457909c687f668d020ef65d52abc3182fb (patch) | |
| tree | 0a52e4e4d854333496fcc487560c93c3de5d5eb5 /packages/client/src/pages/settings | |
| parent | Merge branch 'develop' (diff) | |
| parent | 12.96.0 (diff) | |
| download | misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.gz misskey-2795fe457909c687f668d020ef65d52abc3182fb.tar.bz2 misskey-2795fe457909c687f668d020ef65d52abc3182fb.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client/src/pages/settings')
37 files changed, 4975 insertions, 0 deletions
diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..dce217559a --- /dev/null +++ b/packages/client/src/pages/settings/2fa.vue @@ -0,0 +1,247 @@ +<template> +<section class="_card"> + <div class="_title"><i class="fas fa-lock"></i> {{ $ts.twoStepAuthentication }}</div> + <div class="_content"> + <MkButton v-if="!data && !$i.twoFactorEnabled" @click="register">{{ $ts._2fa.registerDevice }}</MkButton> + <template v-if="$i.twoFactorEnabled"> + <p>{{ $ts._2fa.alreadyRegistered }}</p> + <MkButton @click="unregister">{{ $ts.unregister }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ $ts.securityKey }}</h2> + <p>{{ $ts._2fa.securityKeyInfo }}</p> + <div class="key-list"> + <div class="key" v-for="key in $i.securityKeysList"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ $ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ $ts.unregister }}</MkButton> + </div> + </div> + + <MkSwitch v-model="usePasswordLessLogin" @update:modelValue="updatePasswordLessLogin" v-if="$i.securityKeysList.length > 0">{{ $ts.passwordLessLogin }}</MkSwitch> + + <MkInfo warn v-if="registration && registration.error">{{ $ts.error }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ $ts._2fa.registerKey }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ $ts.tapSecurityKey }} + <i v-if="registration.saving && registration.stage == 0" class="fas fa-spinner fa-pulse fa-fw"></i> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model="keyName" :max="30"> + <template #label>{{ $ts.securityKeyName }}</template> + </MkInput> + <MkButton @click="registerKey" :disabled="keyName.length == 0">{{ $ts.registerSecurityKey }}</MkButton> + <i v-if="registration.saving && registration.stage == 1" class="fas fa-spinner fa-pulse fa-fw"></i> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="data && !$i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <I18n :src="$ts._2fa.step1" tag="span"> + <template #a> + <a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a> + </template> + <template #b> + <a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a> + </template> + </I18n> + </li> + <li>{{ $ts._2fa.step2 }}<br><img :src="data.qr"></li> + <li>{{ $ts._2fa.step3 }}<br> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ $ts.token }}</template></MkInput> + <MkButton primary @click="submit">{{ $ts.done }}</MkButton> + </li> + </ol> + <MkInfo>{{ $ts._2fa.step4 }}</MkInfo> + </div> + </div> +</section> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { hostname } from '@/config'; +import { byteify, hexify, stringify } from '@/scripts/2fa'; +import MkButton from '@/components/ui/button.vue'; +import MkInfo from '@/components/ui/info.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + MkButton, MkInfo, MkInput, MkSwitch + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.twoStepAuthentication, + icon: 'fas fa-lock' + }, + data: null, + supportsCredentials: !!navigator.credentials, + usePasswordLessLogin: this.$i.usePasswordLessLogin, + registration: null, + keyName: '', + token: null, + }; + }, + + methods: { + register() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/unregister', { + password: password + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + os.success(); + this.$i.twoFactorEnabled = false; + }); + }); + }, + + submit() { + os.api('i/2fa/done', { + token: this.token + }).then(() => { + os.success(); + this.$i.twoFactorEnabled = true; + }).catch(e => { + os.dialog({ + type: 'error', + text: e + }); + }); + }, + + registerKey() { + this.registration.saving = true; + os.api('i/2fa/key-done', { + password: this.registration.password, + name: this.keyName, + challengeId: this.registration.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(this.registration.credential.response.clientDataJSON), + attestationObject: hexify(this.registration.credential.response.attestationObject) + }).then(key => { + this.registration = null; + key.lastUsed = new Date(); + os.success(); + }) + }, + + unregisterKey(key) { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + return os.api('i/2fa/remove-key', { + password, + credentialId: key.id + }).then(() => { + this.usePasswordLessLogin = false; + this.updatePasswordLessLogin(); + }).then(() => { + os.success(); + }); + }); + }, + + addSecurityKey() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register-key', { + password + }).then(registration => { + this.registration = { + password, + challengeId: registration.challengeId, + stage: 0, + publicKeyOptions: { + challenge: byteify(registration.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey' + }, + user: { + id: byteify(this.$i.id, 'ascii'), + name: this.$i.username, + displayName: this.$i.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct' + }, + saving: true + }; + return navigator.credentials.create({ + publicKey: this.registration.publicKeyOptions + }); + }).then(credential => { + this.registration.credential = credential; + this.registration.saving = false; + this.registration.stage = 1; + }).catch(err => { + console.warn('Error while registering?', err); + this.registration.error = err.message; + this.registration.stage = -1; + }); + }); + }, + + updatePasswordLessLogin() { + os.api('i/2fa/password-less', { + value: !!this.usePasswordLessLogin + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..f3d5e2f2c3 --- /dev/null +++ b/packages/client/src/pages/settings/account-info.vue @@ -0,0 +1,185 @@ +<template> +<FormBase> + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </FormKeyValueView> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="stats"> + <template #label>{{ $ts.statistics }}</template> + <FormKeyValueView> + <template #key>{{ $ts.notesCount }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.repliesCount }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.renotesCount }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.repliedCount }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.renotedCount }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pollVotesCount }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pollVotedCount }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.sentReactionsCount }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.receivedReactionsCount }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.noteFavoritesCount }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }} ({{ $ts.local }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followingCount }} ({{ $ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }} ({{ $ts.local }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.followersCount }} ({{ $ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pageLikesCount }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.pageLikedCount }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.driveFilesCount }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.driveUsage }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.reversiCount }}</template> + <template #value>{{ number(stats.reversiCount) }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <template #label>{{ $ts.other }}</template> + <FormKeyValueView> + <template #key>emailVerified</template> + <template #value>{{ $i.emailVerified ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>twoFactorEnabled</template> + <template #value>{{ $i.twoFactorEnabled ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>securityKeys</template> + <template #value>{{ $i.securityKeys ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $i.usePasswordLessLogin ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isModerator</template> + <template #value>{{ $i.isModerator ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isAdmin</template> + <template #value>{{ $i.isAdmin ? $ts.yes : $ts.no }}</template> + </FormKeyValueView> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.accountInfo, + icon: 'fas fa-info-circle' + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('users/stats', { + userId: this.$i.id + }).then(stats => { + this.stats = stats; + }); + }, + + methods: { + number, + bytes, + } +}); +</script> diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..94a3c9483d --- /dev/null +++ b/packages/client/src/pages/settings/accounts.vue @@ -0,0 +1,149 @@ +<template> +<FormBase> + <FormSuspense :p="init"> + <FormButton @click="addAccount" primary><i class="fas fa-plus"></i> {{ $ts.addAccount }}</FormButton> + + <div class="_debobigegoItem _button" v-for="account in accounts" :key="account.id" @click="menu(account, $event)"> + <div class="_debobigegoPanel lcjjdxlm"> + <div class="avatar"> + <MkAvatar :user="account" class="avatar"/> + </div> + <div class="body"> + <div class="name"> + <MkUserName :user="account"/> + </div> + <div class="acct"> + <MkAcct :user="account"/> + </div> + </div> + </div> + </div> + </FormSuspense> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSuspense from '@/components/debobigego/suspense.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import { getAccounts, addAccount, login } from '@/account'; + +export default defineComponent({ + components: { + FormBase, + FormSuspense, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.accounts, + icon: 'fas fa-users', + bg: 'var(--bg)', + }, + storedAccounts: getAccounts().then(accounts => accounts.filter(x => x.id !== this.$i.id)), + accounts: null, + init: async () => os.api('users/show', { + userIds: (await this.storedAccounts).map(x => x.id) + }).then(accounts => { + this.accounts = accounts; + }), + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + menu(account, ev) { + os.popupMenu([{ + text: this.$ts.switch, + icon: 'fas fa-exchange-alt', + action: () => this.switchAccount(account), + }, { + text: this.$ts.remove, + icon: 'fas fa-trash-alt', + danger: true, + action: () => this.removeAccount(account), + }], ev.currentTarget || ev.target); + }, + + addAccount(ev) { + os.popupMenu([{ + text: this.$ts.existingAccount, + action: () => { this.addExistingAccount(); }, + }, { + text: this.$ts.createAccount, + action: () => { this.createAccount(); }, + }], ev.currentTarget || ev.target); + }, + + addExistingAccount() { + os.popup(import('@/components/signin-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + os.success(); + }, + }, 'closed'); + }, + + createAccount() { + os.popup(import('@/components/signup-dialog.vue'), {}, { + done: res => { + addAccount(res.id, res.i); + this.switchAccountWithToken(res.i); + }, + }, 'closed'); + }, + + async switchAccount(account: any) { + const storedAccounts = await getAccounts(); + const token = storedAccounts.find(x => x.id === account.id).token; + this.switchAccountWithToken(token); + }, + + switchAccountWithToken(token: string) { + login(token); + }, + } +}); +</script> + +<style lang="scss" scoped> +.lcjjdxlm { + display: flex; + padding: 16px; + + > .avatar { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + + > .avatar { + width: 50px; + height: 50px; + } + } + + > .body { + display: flex; + flex-direction: column; + justify-content: center; + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue new file mode 100644 index 0000000000..1def0189ec --- /dev/null +++ b/packages/client/src/pages/settings/api.vue @@ -0,0 +1,65 @@ +<template> +<FormBase> + <FormButton @click="generateToken" primary>{{ $ts.generateAccessToken }}</FormButton> + <FormLink to="/settings/apps">{{ $ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'API', + icon: 'fas fa-key', + bg: 'var(--bg)', + }, + isDesktop: window.innerWidth >= 1100, + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + generateToken() { + os.popup(import('@/components/token-generate-window.vue'), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.dialog({ + type: 'success', + title: this.$ts.token, + text: token + }); + }, + }, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue new file mode 100644 index 0000000000..6eec80d805 --- /dev/null +++ b/packages/client/src/pages/settings/apps.vue @@ -0,0 +1,113 @@ +<template> +<FormBase> + <FormPagination :pagination="pagination" ref="list"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.nothing }}</div> + </div> + </template> + <template #default="{items}"> + <div class="_debobigegoPanel bfomjevm" v-for="token in items" :key="token.id"> + <img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/> + <div class="body"> + <div class="name">{{ token.name }}</div> + <div class="description">{{ token.description }}</div> + <div class="_keyValue"> + <div>{{ $ts.installedDate }}:</div> + <div><MkTime :time="token.createdAt"/></div> + </div> + <div class="_keyValue"> + <div>{{ $ts.lastUsedDate }}:</div> + <div><MkTime :time="token.lastUsedAt"/></div> + </div> + <div class="actions"> + <button class="_button" @click="revoke(token)"><i class="fas fa-trash-alt"></i></button> + </div> + <details> + <summary>{{ $ts.details }}</summary> + <ul> + <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </details> + </div> + </div> + </template> + </FormPagination> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormPagination from '@/components/debobigego/pagination.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormPagination, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.installedApps, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/apps', + limit: 100, + params: { + sort: '+lastUsedAt' + } + }, + }; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + revoke(token) { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { + this.$refs.list.reload(); + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.bfomjevm { + display: flex; + padding: 16px; + + > .icon { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 50px; + height: 50px; + border-radius: 8px; + } + + > .body { + width: calc(100% - 62px); + position: relative; + + > .name { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..8c878fb084 --- /dev/null +++ b/packages/client/src/pages/settings/custom-css.vue @@ -0,0 +1,73 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts.customCssWarn }}</FormInfo> + + <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace" style="tab-size: 2;"> + <span>{{ $ts.local }}</span> + </FormTextarea> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; +import { defaultStore } from '@/store'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.customCss, + icon: 'fas fa-code', + bg: 'var(--bg)', + }, + localCustomCss: localStorage.getItem('customCss') + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + this.$watch('localCustomCss', this.apply); + }, + + methods: { + async apply() { + localStorage.setItem('customCss', this.localCustomCss); + + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue new file mode 100644 index 0000000000..a96c6cd685 --- /dev/null +++ b/packages/client/src/pages/settings/deck.vue @@ -0,0 +1,107 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $ts.defaultNavigationBehaviour }}</template> + <FormSwitch v-model="navWindow">{{ $ts.openInWindow }}</FormSwitch> + </FormGroup> + + <FormSwitch v-model="alwaysShowMainColumn">{{ $ts._deck.alwaysShowMainColumn }}</FormSwitch> + + <FormRadios v-model="columnAlign"> + <template #desc>{{ $ts._deck.columnAlign }}</template> + <option value="left">{{ $ts.left }}</option> + <option value="center">{{ $ts.center }}</option> + </FormRadios> + + <FormRadios v-model="columnHeaderHeight"> + <template #desc>{{ $ts._deck.columnHeaderHeight }}</template> + <option :value="42">{{ $ts.narrow }}</option> + <option :value="45">{{ $ts.medium }}</option> + <option :value="48">{{ $ts.wide }}</option> + </FormRadios> + + <FormInput v-model="columnMargin" type="number"> + <span>{{ $ts._deck.columnMargin }}</span> + <template #suffix>px</template> + </FormInput> + + <FormLink @click="setProfile">{{ $ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSwitch, + FormLink, + FormInput, + FormRadios, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.deck, + icon: 'fas fa-columns', + bg: 'var(--bg)', + }, + } + }, + + computed: { + navWindow: deckStore.makeGetterSetter('navWindow'), + alwaysShowMainColumn: deckStore.makeGetterSetter('alwaysShowMainColumn'), + columnAlign: deckStore.makeGetterSetter('columnAlign'), + columnMargin: deckStore.makeGetterSetter('columnMargin'), + columnHeaderHeight: deckStore.makeGetterSetter('columnHeaderHeight'), + profile: deckStore.makeGetterSetter('profile'), + }, + + watch: { + async navWindow() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async setProfile() { + const { canceled, result: name } = await os.dialog({ + title: this.$ts._deck.profile, + input: { + allowEmpty: false + } + }); + if (canceled) return; + this.profile = name; + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..018f7c795e --- /dev/null +++ b/packages/client/src/pages/settings/delete-account.vue @@ -0,0 +1,68 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo>{{ $ts._accountDelete.sendEmail }}</FormInfo> + <FormButton @click="deleteAccount" danger v-if="!$i.isDeleted">{{ $ts._accountDelete.requestAccountDelete }}</FormButton> + <FormButton disabled v-else>{{ $ts._accountDelete.inProgress }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { debug } from '@/config'; +import { signout } from '@/account'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormGroup, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._accountDelete.accountDelete, + icon: 'fas fa-exclamation-triangle', + bg: 'var(--bg)', + }, + debug, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async deleteAccount() { + const { canceled, result: password } = await os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password + }); + + await os.dialog({ + title: this.$ts._accountDelete.started, + }); + + signout(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue new file mode 100644 index 0000000000..ed5282e23d --- /dev/null +++ b/packages/client/src/pages/settings/drive.vue @@ -0,0 +1,147 @@ +<template> +<FormBase class=""> + <FormGroup v-if="!fetching"> + <template #label>{{ $ts.usageAmount }}</template> + <div class="_debobigegoItem uawsfosz"> + <div class="_debobigegoPanel"> + <div class="meter"><div :style="meterStyle"></div></div> + </div> + </div> + <FormKeyValueView> + <template #key>{{ $ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </FormKeyValueView> + </FormGroup> + + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.statistics }}</div> + <div class="_debobigegoPanel"> + <div ref="chart"></div> + </div> + </div> + + <FormButton :center="false" @click="chooseUploadFolder()" primary> + {{ $ts.uploadFolder }} + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="fas fa-folder-open"></i></template> + </FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as tinycolor from 'tinycolor2'; +import FormButton from '@/components/debobigego/button.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import * as symbols from '@/symbols'; + +// TODO: render chart + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.drive, + icon: 'fas fa-cloud', + bg: 'var(--bg)', + }, + fetching: true, + usage: null, + capacity: null, + uploadFolder: null, + } + }, + + computed: { + meterStyle(): any { + return { + width: `${this.usage / this.capacity * 100}%`, + background: tinycolor({ + h: 180 - (this.usage / this.capacity * 180), + s: 0.7, + l: 0.5 + }) + }; + } + }, + + async created() { + os.api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + this.$nextTick(() => { + this.renderChart(); + }); + }); + + if (this.$store.state.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.uploadFolder + }); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + this.$store.set('uploadFolder', folder ? folder.id : null); + os.success(); + if (this.$store.state.uploadFolder) { + this.uploadFolder = await os.api('drive/folders/show', { + folderId: this.$store.state.uploadFolder + }); + } else { + this.uploadFolder = null; + } + }); + }, + + bytes + } +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.uawsfosz { + > div { + padding: 24px; + + > .meter { + $size: 12px; + background: rgba(0, 0, 0, 0.1); + border-radius: math.div($size, 2); + overflow: hidden; + + > div { + height: $size; + border-radius: math.div($size, 2); + } + } + } +} +</style> diff --git a/packages/client/src/pages/settings/email-address.vue b/packages/client/src/pages/settings/email-address.vue new file mode 100644 index 0000000000..476d0c0e17 --- /dev/null +++ b/packages/client/src/pages/settings/email-address.vue @@ -0,0 +1,70 @@ +<template> +<FormBase> + <FormGroup> + <FormInput v-model="emailAddress" type="email"> + {{ $ts.emailAddress }} + <template #desc v-if="$i.email && !$i.emailVerified">{{ $ts.verificationEmailSent }}</template> + <template #desc v-else-if="emailAddress === $i.email && $i.emailVerified">{{ $ts.emailVerified }}</template> + </FormInput> + </FormGroup> + <FormButton @click="save" primary>{{ $ts.save }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/form/input.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormInput, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailAddress, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + emailAddress: null, + code: null, + } + }, + + created() { + this.emailAddress = this.$i.email; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.apiWithDialog('i/update-email', { + password: password, + email: this.emailAddress, + }); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/email-notification.vue b/packages/client/src/pages/settings/email-notification.vue new file mode 100644 index 0000000000..c1735a0728 --- /dev/null +++ b/packages/client/src/pages/settings/email-notification.vue @@ -0,0 +1,91 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model="mention"> + {{ $ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model="reply"> + {{ $ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model="quote"> + {{ $ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model="follow"> + {{ $ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model="receiveFollowRequest"> + {{ $ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model="groupInvited"> + {{ $ts._notification._types.groupInvited }} + </FormSwitch> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSwitch, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.emailNotification, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + + mention: this.$i.emailNotificationTypes.includes('mention'), + reply: this.$i.emailNotificationTypes.includes('reply'), + quote: this.$i.emailNotificationTypes.includes('quote'), + follow: this.$i.emailNotificationTypes.includes('follow'), + receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'), + groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'), + } + }, + + created() { + this.$watch('mention', this.save); + this.$watch('reply', this.save); + this.$watch('quote', this.save); + this.$watch('follow', this.save); + this.$watch('receiveFollowRequest', this.save); + this.$watch('groupInvited', this.save); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.api('i/update', { + emailNotificationTypes: [ + ...[this.mention ? 'mention' : null], + ...[this.reply ? 'reply' : null], + ...[this.quote ? 'quote' : null], + ...[this.follow ? 'follow' : null], + ...[this.receiveFollowRequest ? 'receiveFollowRequest' : null], + ...[this.groupInvited ? 'groupInvited' : null], + ].filter(x => x != null) + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue new file mode 100644 index 0000000000..d1dda20f00 --- /dev/null +++ b/packages/client/src/pages/settings/email.vue @@ -0,0 +1,66 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $ts.emailAddress }}</template> + <FormLink to="/settings/email/address"> + <template v-if="$i.email && !$i.emailVerified" #icon><i class="fas fa-exclamation-triangle" style="color: var(--warn);"></i></template> + <template v-else-if="$i.email && $i.emailVerified" #icon><i class="fas fa-check" style="color: var(--success);"></i></template> + {{ $i.email || $ts.notSet }} + </FormLink> + </FormGroup> + + <FormLink to="/settings/email/notification"> + <template #icon><i class="fas fa-bell"></i></template> + {{ $ts.emailNotification }} + </FormLink> + + <FormSwitch :value="$i.receiveAnnouncementEmail" @update:modelValue="onChangeReceiveAnnouncementEmail"> + {{ $ts.receiveAnnouncementFromInstance }} + </FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormSwitch, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.email, + icon: 'fas fa-envelope', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + onChangeReceiveAnnouncementEmail(v) { + os.api('i/update', { + receiveAnnouncementEmail: v + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/experimental-features.vue b/packages/client/src/pages/settings/experimental-features.vue new file mode 100644 index 0000000000..5a7bcb3b41 --- /dev/null +++ b/packages/client/src/pages/settings/experimental-features.vue @@ -0,0 +1,52 @@ +<template> +<FormBase> + <FormButton @click="error()">error test</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.experimentalFeatures, + icon: 'fas fa-flask' + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + error() { + throw new Error('Test error'); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue new file mode 100644 index 0000000000..8e3dcc3e41 --- /dev/null +++ b/packages/client/src/pages/settings/general.vue @@ -0,0 +1,223 @@ +<template> +<FormBase> + <FormSwitch v-model="showFixedPostForm">{{ $ts.showFixedPostForm }}</FormSwitch> + + <FormSelect v-model="lang"> + <template #label>{{ $ts.uiLanguage }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="$ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.behavior }}</template> + <FormSwitch v-model="imageNewTab">{{ $ts.openImageInNewTab }}</FormSwitch> + <FormSwitch v-model="enableInfiniteScroll">{{ $ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model="useReactionPickerForContextMenu">{{ $ts.useReactionPickerForContextMenu }}</FormSwitch> + <FormSwitch v-model="disablePagesScript">{{ $ts.disablePagesScript }}</FormSwitch> + </FormGroup> + + <FormSelect v-model="serverDisconnectedBehavior"> + <template #label>{{ $ts.whenServerDisconnected }}</template> + <option value="reload">{{ $ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ $ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ $ts._serverDisconnectedBehavior.quiet }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.appearance }}</template> + <FormSwitch v-model="disableAnimatedMfm">{{ $ts.disableAnimatedMfm }}</FormSwitch> + <FormSwitch v-model="reduceAnimation">{{ $ts.reduceUiAnimation }}</FormSwitch> + <FormSwitch v-model="useBlurEffect">{{ $ts.useBlurEffect }}</FormSwitch> + <FormSwitch v-model="useBlurEffectForModal">{{ $ts.useBlurEffectForModal }}</FormSwitch> + <FormSwitch v-model="showGapBetweenNotesInTimeline">{{ $ts.showGapBetweenNotesInTimeline }}</FormSwitch> + <FormSwitch v-model="loadRawImages">{{ $ts.loadRawImages }}</FormSwitch> + <FormSwitch v-model="disableShowingAnimatedImages">{{ $ts.disableShowingAnimatedImages }}</FormSwitch> + <FormSwitch v-model="squareAvatars">{{ $ts.squareAvatars }}</FormSwitch> + <FormSwitch v-model="useSystemFont">{{ $ts.useSystemFont }}</FormSwitch> + <FormSwitch v-model="useOsNativeEmojis">{{ $ts.useOsNativeEmojis }} + <div><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪" :key="useOsNativeEmojis"/></div> + </FormSwitch> + </FormGroup> + + <FormGroup> + <FormSwitch v-model="aiChanMode">{{ $ts.aiChanMode }}</FormSwitch> + </FormGroup> + + <FormRadios v-model="fontSize"> + <template #desc>{{ $ts.fontSize }}</template> + <option value="small"><span style="font-size: 14px;">Aa</span></option> + <option :value="null"><span style="font-size: 16px;">Aa</span></option> + <option value="large"><span style="font-size: 18px;">Aa</span></option> + <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option> + </FormRadios> + + <FormSelect v-model="instanceTicker"> + <template #label>{{ $ts.instanceTicker }}</template> + <option value="none">{{ $ts._instanceTicker.none }}</option> + <option value="remote">{{ $ts._instanceTicker.remote }}</option> + <option value="always">{{ $ts._instanceTicker.always }}</option> + </FormSelect> + + <FormSelect v-model="nsfw"> + <template #label>{{ $ts.nsfw }}</template> + <option value="respect">{{ $ts._nsfw.respect }}</option> + <option value="ignore">{{ $ts._nsfw.ignore }}</option> + <option value="force">{{ $ts._nsfw.force }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $ts.defaultNavigationBehaviour }}</template> + <FormSwitch v-model="defaultSideView">{{ $ts.openInSideView }}</FormSwitch> + </FormGroup> + + <FormSelect v-model="chatOpenBehavior"> + <template #label>{{ $ts.chatOpenBehavior }}</template> + <option value="page">{{ $ts.showInPage }}</option> + <option value="window">{{ $ts.openInWindow }}</option> + <option value="popout">{{ $ts.popout }}</option> + </FormSelect> + + <FormLink to="/settings/deck">{{ $ts.deck }}</FormLink> + + <FormLink to="/settings/custom-css"><template #icon><i class="fas fa-code"></i></template>{{ $ts.customCss }}</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import MkLink from '@/components/link.vue'; +import { langs } from '@/config'; +import { defaultStore } from '@/store'; +import { ColdDeviceStorage } from '@/store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkLink, + FormSwitch, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.general, + icon: 'fas fa-cogs', + bg: 'var(--bg)' + }, + langs, + lang: localStorage.getItem('lang'), + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') != null, + } + }, + + computed: { + serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'), + reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v), + useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'), + useBlurEffect: defaultStore.makeGetterSetter('useBlurEffect'), + showGapBetweenNotesInTimeline: defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline'), + disableAnimatedMfm: defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v), + useOsNativeEmojis: defaultStore.makeGetterSetter('useOsNativeEmojis'), + disableShowingAnimatedImages: defaultStore.makeGetterSetter('disableShowingAnimatedImages'), + loadRawImages: defaultStore.makeGetterSetter('loadRawImages'), + imageNewTab: defaultStore.makeGetterSetter('imageNewTab'), + nsfw: defaultStore.makeGetterSetter('nsfw'), + disablePagesScript: defaultStore.makeGetterSetter('disablePagesScript'), + showFixedPostForm: defaultStore.makeGetterSetter('showFixedPostForm'), + defaultSideView: defaultStore.makeGetterSetter('defaultSideView'), + chatOpenBehavior: ColdDeviceStorage.makeGetterSetter('chatOpenBehavior'), + instanceTicker: defaultStore.makeGetterSetter('instanceTicker'), + enableInfiniteScroll: defaultStore.makeGetterSetter('enableInfiniteScroll'), + useReactionPickerForContextMenu: defaultStore.makeGetterSetter('useReactionPickerForContextMenu'), + squareAvatars: defaultStore.makeGetterSetter('squareAvatars'), + aiChanMode: defaultStore.makeGetterSetter('aiChanMode'), + }, + + watch: { + lang() { + localStorage.setItem('lang', this.lang); + localStorage.removeItem('locale'); + this.reloadAsk(); + }, + + fontSize() { + if (this.fontSize == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', this.fontSize); + } + this.reloadAsk(); + }, + + useSystemFont() { + if (this.useSystemFont) { + localStorage.setItem('useSystemFont', 't'); + } else { + localStorage.removeItem('useSystemFont'); + } + this.reloadAsk(); + }, + + enableInfiniteScroll() { + this.reloadAsk(); + }, + + squareAvatars() { + this.reloadAsk(); + }, + + aiChanMode() { + this.reloadAsk(); + }, + + showGapBetweenNotesInTimeline() { + this.reloadAsk(); + }, + + instanceTicker() { + this.reloadAsk(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async reloadAsk() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..8923483b98 --- /dev/null +++ b/packages/client/src/pages/settings/import-export.vue @@ -0,0 +1,112 @@ +<template> +<div style="margin: 16px;"> + <FormSection> + <template #label>{{ $ts._exportOrImport.allNotes }}</template> + <MkButton :class="$style.button" inline @click="doExport('notes')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.followingList }}</template> + <MkButton :class="$style.button" inline @click="doExport('following')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('following', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.userLists }}</template> + <MkButton :class="$style.button" inline @click="doExport('user-lists')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('user-lists', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.muteList }}</template> + <MkButton :class="$style.button" inline @click="doExport('muting')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('muting', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> + <FormSection> + <template #label>{{ $ts._exportOrImport.blockingList }}</template> + <MkButton :class="$style.button" inline @click="doExport('blocking')"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <MkButton :class="$style.button" inline @click="doImport('blocking', $event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormSection> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import FormSection from '@/components/form/section.vue'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSection, + MkButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.importAndExport, + icon: 'fas fa-boxes', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + doExport(target) { + os.api( + target === 'notes' ? 'i/export-notes' : + target === 'following' ? 'i/export-following' : + target === 'blocking' ? 'i/export-blocking' : + target === 'user-lists' ? 'i/export-user-lists' : + target === 'muting' ? 'i/export-mute' : + null, {}) + .then(() => { + os.dialog({ + type: 'info', + text: this.$ts.exportRequested + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + + async doImport(target, e) { + const file = await selectFile(e.currentTarget || e.target); + + os.api( + target === 'following' ? 'i/import-following' : + target === 'user-lists' ? 'i/import-user-lists' : + target === 'muting' ? 'i/import-muting' : + target === 'blocking' ? 'i/import-blocking' : + null, { + fileId: file.id + }).then(() => { + os.dialog({ + type: 'info', + text: this.$ts.importRequested + }); + }).catch((e: any) => { + os.dialog({ + type: 'error', + text: e.message + }); + }); + }, + } +}); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue new file mode 100644 index 0000000000..b9d3903269 --- /dev/null +++ b/packages/client/src/pages/settings/index.vue @@ -0,0 +1,326 @@ +<template> +<div class="vvcocwet" :class="{ wide: !narrow }" ref="el"> + <div class="nav" v-if="!narrow || page == null"> + <MkSpacer :content-max="700"> + <div class="baaadecd"> + <div class="title">{{ $ts.settings }}</div> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="page == null"></MkSuperMenu> + </div> + </MkSpacer> + </div> + <div class="main"> + <component :is="component" :key="page" v-bind="pageProps"/> + </div> +</div> +</template> + +<script lang="ts"> +import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkInfo from '@/components/ui/info.vue'; +import MkSuperMenu from '@/components/ui/super-menu.vue'; +import { scroll } from '@/scripts/scroll'; +import { signout } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; +import { instance } from '@/instance'; +import { $i } from '@/account'; + +export default defineComponent({ + components: { + MkInfo, + MkSuperMenu, + }, + + props: { + initialPage: { + type: String, + required: false + } + }, + + setup(props, context) { + const indexInfo = { + title: i18n.locale.settings, + icon: 'fas fa-cog', + bg: 'var(--bg)', + hideHeader: true, + }; + const INFO = ref(indexInfo); + const page = ref(props.initialPage); + const narrow = ref(false); + const view = ref(null); + const el = ref(null); + const menuDef = computed(() => [{ + title: i18n.locale.basicSettings, + items: [{ + icon: 'fas fa-user', + text: i18n.locale.profile, + to: '/settings/profile', + active: page.value === 'profile', + }, { + icon: 'fas fa-lock-open', + text: i18n.locale.privacy, + to: '/settings/privacy', + active: page.value === 'privacy', + }, { + icon: 'fas fa-laugh', + text: i18n.locale.reaction, + to: '/settings/reaction', + active: page.value === 'reaction', + }, { + icon: 'fas fa-cloud', + text: i18n.locale.drive, + to: '/settings/drive', + active: page.value === 'drive', + }, { + icon: 'fas fa-bell', + text: i18n.locale.notifications, + to: '/settings/notifications', + active: page.value === 'notifications', + }, { + icon: 'fas fa-envelope', + text: i18n.locale.email, + to: '/settings/email', + active: page.value === 'email', + }, { + icon: 'fas fa-share-alt', + text: i18n.locale.integration, + to: '/settings/integration', + active: page.value === 'integration', + }, { + icon: 'fas fa-lock', + text: i18n.locale.security, + to: '/settings/security', + active: page.value === 'security', + }], + }, { + title: i18n.locale.clientSettings, + items: [{ + icon: 'fas fa-cogs', + text: i18n.locale.general, + to: '/settings/general', + active: page.value === 'general', + }, { + icon: 'fas fa-palette', + text: i18n.locale.theme, + to: '/settings/theme', + active: page.value === 'theme', + }, { + icon: 'fas fa-list-ul', + text: i18n.locale.menu, + to: '/settings/menu', + active: page.value === 'menu', + }, { + icon: 'fas fa-music', + text: i18n.locale.sounds, + to: '/settings/sounds', + active: page.value === 'sounds', + }, { + icon: 'fas fa-plug', + text: i18n.locale.plugins, + to: '/settings/plugin', + active: page.value === 'plugin', + }], + }, { + title: i18n.locale.otherSettings, + items: [{ + icon: 'fas fa-boxes', + text: i18n.locale.importAndExport, + to: '/settings/import-export', + active: page.value === 'import-export', + }, { + icon: 'fas fa-ban', + text: i18n.locale.muteAndBlock, + to: '/settings/mute-block', + active: page.value === 'mute-block', + }, { + icon: 'fas fa-comment-slash', + text: i18n.locale.wordMute, + to: '/settings/word-mute', + active: page.value === 'word-mute', + }, { + icon: 'fas fa-key', + text: 'API', + to: '/settings/api', + active: page.value === 'api', + }, { + icon: 'fas fa-ellipsis-h', + text: i18n.locale.other, + to: '/settings/other', + active: page.value === 'other', + }], + }, { + items: [{ + type: 'button', + icon: 'fas fa-trash', + text: i18n.locale.clearCache, + action: () => { + localStorage.removeItem('locale'); + localStorage.removeItem('theme'); + unisonReload(); + }, + }, { + type: 'button', + icon: 'fas fa-sign-in-alt fa-flip-horizontal', + text: i18n.locale.logout, + action: () => { + signout(); + }, + danger: true, + },], + }]); + + const pageProps = ref({}); + const component = computed(() => { + if (page.value == null) return null; + switch (page.value) { + case 'accounts': return defineAsyncComponent(() => import('./accounts.vue')); + case 'profile': return defineAsyncComponent(() => import('./profile.vue')); + case 'privacy': return defineAsyncComponent(() => import('./privacy.vue')); + case 'reaction': return defineAsyncComponent(() => import('./reaction.vue')); + case 'drive': return defineAsyncComponent(() => import('./drive.vue')); + case 'notifications': return defineAsyncComponent(() => import('./notifications.vue')); + case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue')); + case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue')); + case 'integration': return defineAsyncComponent(() => import('./integration.vue')); + case 'security': return defineAsyncComponent(() => import('./security.vue')); + case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); + case 'api': return defineAsyncComponent(() => import('./api.vue')); + case 'apps': return defineAsyncComponent(() => import('./apps.vue')); + case 'other': return defineAsyncComponent(() => import('./other.vue')); + case 'general': return defineAsyncComponent(() => import('./general.vue')); + case 'email': return defineAsyncComponent(() => import('./email.vue')); + case 'email/address': return defineAsyncComponent(() => import('./email-address.vue')); + case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue')); + case 'theme': return defineAsyncComponent(() => import('./theme.vue')); + case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); + case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); + case 'menu': return defineAsyncComponent(() => import('./menu.vue')); + case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); + case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); + case 'deck': return defineAsyncComponent(() => import('./deck.vue')); + case 'plugin': return defineAsyncComponent(() => import('./plugin.vue')); + case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue')); + case 'plugin/manage': return defineAsyncComponent(() => import('./plugin.manage.vue')); + case 'import-export': return defineAsyncComponent(() => import('./import-export.vue')); + case 'account-info': return defineAsyncComponent(() => import('./account-info.vue')); + case 'update': return defineAsyncComponent(() => import('./update.vue')); + case 'registry': return defineAsyncComponent(() => import('./registry.vue')); + case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue')); + case 'experimental-features': return defineAsyncComponent(() => import('./experimental-features.vue')); + } + if (page.value.startsWith('registry/keys/system/')) { + return defineAsyncComponent(() => import('./registry.keys.vue')); + } + if (page.value.startsWith('registry/value/system/')) { + return defineAsyncComponent(() => import('./registry.value.vue')); + } + }); + + watch(component, () => { + pageProps.value = {}; + + if (page.value) { + if (page.value.startsWith('registry/keys/system/')) { + pageProps.value.scope = page.value.replace('registry/keys/system/', '').split('/'); + } + if (page.value.startsWith('registry/value/system/')) { + const path = page.value.replace('registry/value/system/', '').split('/'); + pageProps.value.xKey = path.pop(); + pageProps.value.scope = path; + } + } + + nextTick(() => { + scroll(el.value, { top: 0 }); + }); + }, { immediate: true }); + + watch(() => props.initialPage, () => { + if (props.initialPage == null && !narrow.value) { + page.value = 'profile'; + } else { + page.value = props.initialPage; + if (props.initialPage == null) { + INFO.value = indexInfo; + } + } + }); + + onMounted(() => { + narrow.value = el.value.offsetWidth < 800; + if (!narrow.value) { + page.value = 'profile'; + } + }); + + const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); + + return { + [symbols.PAGE_INFO]: INFO, + page, + menuDef, + narrow, + view, + el, + pageProps, + component, + emailNotConfigured, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.vvcocwet { + > .nav { + .baaadecd { + > .title { + margin: 16px; + font-size: 1.5em; + font-weight: bold; + } + + > .info { + margin: 0 16px; + } + + > .accounts { + > .avatar { + display: block; + width: 50px; + height: 50px; + margin: 8px auto 16px auto; + } + } + } + } + + &.wide { + display: flex; + max-width: 1000px; + margin: 0 auto; + height: 100%; + + > .nav { + width: 32%; + box-sizing: border-box; + overflow: auto; + + .baaadecd { + > .title { + margin: 24px 0; + } + } + } + + > .main { + flex: 1; + min-width: 0; + overflow: auto; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue new file mode 100644 index 0000000000..405f93b779 --- /dev/null +++ b/packages/client/src/pages/settings/integration.vue @@ -0,0 +1,141 @@ +<template> +<FormBase> + <div class="_debobigegoItem" v-if="enableTwitterIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-twitter"></i> Twitter</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.twitter">{{ $ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p> + <MkButton v-if="integrations.twitter" @click="disconnectTwitter" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectTwitter" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> + + <div class="_debobigegoItem" v-if="enableDiscordIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-discord"></i> Discord</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.discord">{{ $ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p> + <MkButton v-if="integrations.discord" @click="disconnectDiscord" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectDiscord" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> + + <div class="_debobigegoItem" v-if="enableGithubIntegration"> + <div class="_debobigegoLabel"><i class="fab fa-github"></i> GitHub</div> + <div class="_debobigegoPanel" style="padding: 16px;"> + <p v-if="integrations.github">{{ $ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p> + <MkButton v-if="integrations.github" @click="disconnectGithub" danger>{{ $ts.disconnectService }}</MkButton> + <MkButton v-else @click="connectGithub" primary>{{ $ts.connectService }}</MkButton> + </div> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { apiUrl } from '@/config'; +import FormBase from '@/components/debobigego/base.vue'; +import MkButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + MkButton + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.integration, + icon: 'fas fa-share-alt', + bg: 'var(--bg)', + }, + apiUrl, + twitterForm: null, + discordForm: null, + githubForm: null, + enableTwitterIntegration: false, + enableDiscordIntegration: false, + enableGithubIntegration: false, + }; + }, + + computed: { + integrations() { + return this.$i.integrations; + }, + + meta() { + return this.$instance; + }, + }, + + created() { + this.enableTwitterIntegration = this.meta.enableTwitterIntegration; + this.enableDiscordIntegration = this.meta.enableDiscordIntegration; + this.enableGithubIntegration = this.meta.enableGithubIntegration; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + document.cookie = `igi=${this.$i.token}; path=/;` + + ` max-age=31536000;` + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + + this.$watch('integrations', () => { + if (this.integrations.twitter) { + if (this.twitterForm) this.twitterForm.close(); + } + if (this.integrations.discord) { + if (this.discordForm) this.discordForm.close(); + } + if (this.integrations.github) { + if (this.githubForm) this.githubForm.close(); + } + }, { + deep: true + }); + }, + + methods: { + connectTwitter() { + this.twitterForm = window.open(apiUrl + '/connect/twitter', + 'twitter_connect_window', + 'height=570, width=520'); + }, + + disconnectTwitter() { + window.open(apiUrl + '/disconnect/twitter', + 'twitter_disconnect_window', + 'height=570, width=520'); + }, + + connectDiscord() { + this.discordForm = window.open(apiUrl + '/connect/discord', + 'discord_connect_window', + 'height=570, width=520'); + }, + + disconnectDiscord() { + window.open(apiUrl + '/disconnect/discord', + 'discord_disconnect_window', + 'height=570, width=520'); + }, + + connectGithub() { + this.githubForm = window.open(apiUrl + '/connect/github', + 'github_connect_window', + 'height=570, width=520'); + }, + + disconnectGithub() { + window.open(apiUrl + '/disconnect/github', + 'github_disconnect_window', + 'height=570, width=520'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue new file mode 100644 index 0000000000..e40740a3a4 --- /dev/null +++ b/packages/client/src/pages/settings/menu.vue @@ -0,0 +1,117 @@ +<template> +<FormBase> + <FormTextarea v-model="items" tall manual-save> + <span>{{ $ts.menu }}</span> + <template #desc><button class="_textButton" @click="addItem">{{ $ts.addItem }}</button></template> + </FormTextarea> + + <FormRadios v-model="menuDisplay"> + <template #desc>{{ $ts.display }}</template> + <option value="sideFull">{{ $ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ $ts._menuDisplay.sideIcon }}</option> + <option value="top">{{ $ts._menuDisplay.top }}</option> + <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ $ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </FormRadios> + + <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; +import { unisonReload } from '@/scripts/unison-reload'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormRadios, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.menu, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', + }, + menuDef: menuDef, + items: defaultStore.state.menu.join('\n'), + } + }, + + computed: { + splited(): string[] { + return this.items.trim().split('\n').filter(x => x.trim() !== ''); + }, + + menuDisplay: defaultStore.makeGetterSetter('menuDisplay') + }, + + watch: { + menuDisplay() { + this.reloadAsk(); + }, + + items() { + this.save(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async addItem() { + const menu = Object.keys(this.menuDef).filter(k => !this.$store.state.menu.includes(k)); + const { canceled, result: item } = await os.dialog({ + type: null, + title: this.$ts.addItem, + select: { + items: [...menu.map(k => ({ + value: k, text: this.$ts[this.menuDef[k].title] + })), ...[{ + value: '-', text: this.$ts.divider + }]] + }, + showCancelButton: true + }); + if (canceled) return; + this.items = [...this.splited, item].join('\n'); + }, + + save() { + this.$store.set('menu', this.splited); + this.reloadAsk(); + }, + + reset() { + this.$store.reset('menu'); + this.items = this.$store.state.menu.join('\n'); + }, + + async reloadAsk() { + const { canceled } = await os.dialog({ + type: 'info', + text: this.$ts.reloadToApplySetting, + showCancelButton: true + }); + if (canceled) return; + + unisonReload(); + } + }, +}); +</script> diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..4a9633a20d --- /dev/null +++ b/packages/client/src/pages/settings/mute-block.vue @@ -0,0 +1,85 @@ +<template> +<FormBase> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ $ts.mutedUsers }}</option> + <option value="block">{{ $ts.blockedUsers }}</option> + </MkTab> + <div v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormGroup> + <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </FormLink> + </FormGroup> + </template> + </MkPagination> + </div> + <div v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormGroup> + <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </FormLink> + </FormGroup> + </template> + </MkPagination> + </div> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkTab from '@/components/tab.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkPagination, + MkTab, + FormInfo, + FormBase, + FormGroup, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.muteAndBlock, + icon: 'fas fa-ban', + bg: 'var(--bg)', + }, + tab: 'mute', + mutingPagination: { + endpoint: 'mute/list', + limit: 10, + }, + blockingPagination: { + endpoint: 'blocking/list', + limit: 10, + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + userPage + } +}); +</script> diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..7de10a182c --- /dev/null +++ b/packages/client/src/pages/settings/notifications.vue @@ -0,0 +1,77 @@ +<template> +<FormBase> + <FormLink @click="configure">{{ $ts.notificationSetting }}</FormLink> + <FormGroup> + <FormButton @click="readAllNotifications">{{ $ts.markAsReadAllNotifications }}</FormButton> + <FormButton @click="readAllUnreadNotes">{{ $ts.markAsReadAllUnreadNotes }}</FormButton> + <FormButton @click="readAllMessagingMessages">{{ $ts.markAsReadAllTalkMessages }}</FormButton> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { notificationTypes } from 'misskey-js'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.notifications, + icon: 'fas fa-bell', + bg: 'var(--bg)', + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + readAllUnreadNotes() { + os.api('i/read-all-unread-notes'); + }, + + readAllMessagingMessages() { + os.api('i/read-all-messaging-messages'); + }, + + readAllNotifications() { + os.api('notifications/mark-all-as-read'); + }, + + configure() { + const includingTypes = notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x)); + os.popup(import('@/components/notification-setting-window.vue'), { + includingTypes, + showGlobalToggle: false, + }, { + done: async (res) => { + const { includingTypes: value } = res; + await os.apiWithDialog('i/update', { + mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)), + }).then(i => { + this.$i.mutingNotificationTypes = i.mutingNotificationTypes; + }); + } + }, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue new file mode 100644 index 0000000000..fbc895a07d --- /dev/null +++ b/packages/client/src/pages/settings/other.vue @@ -0,0 +1,97 @@ +<template> +<FormBase> + <FormLink to="/settings/update">Misskey Update</FormLink> + + <FormSwitch :value="$i.injectFeaturedNote" @update:modelValue="onChangeInjectFeaturedNote"> + {{ $ts.showFeaturedNotesInTimeline }} + </FormSwitch> + + <FormSwitch v-model="reportError">{{ $ts.sendErrorReports }}<template #desc>{{ $ts.sendErrorReportsDescription }}</template></FormSwitch> + + <FormLink to="/settings/account-info">{{ $ts.accountInfo }}</FormLink> + <FormLink to="/settings/experimental-features">{{ $ts.experimentalFeatures }}</FormLink> + + <FormGroup> + <template #label>{{ $ts.developer }}</template> + <FormSwitch v-model="debug" @update:modelValue="changeDebug"> + DEBUG MODE + </FormSwitch> + <template v-if="debug"> + <FormButton @click="taskmanager">Task Manager</FormButton> + </template> + </FormGroup> + + <FormLink to="/settings/registry"><template #icon><i class="fas fa-cogs"></i></template>{{ $ts.registry }}</FormLink> + + <FormLink to="/bios" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>BIOS</FormLink> + <FormLink to="/cli" behavior="browser"><template #icon><i class="fas fa-door-open"></i></template>CLI</FormLink> + + <FormLink to="/settings/delete-account"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ $ts.closeAccount }}</FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { debug } from '@/config'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.other, + icon: 'fas fa-ellipsis-h', + bg: 'var(--bg)', + }, + debug, + } + }, + + computed: { + reportError: defaultStore.makeGetterSetter('reportError'), + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + changeDebug(v) { + console.log(v); + localStorage.setItem('debug', v.toString()); + unisonReload(); + }, + + onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v + }); + }, + + taskmanager() { + os.popup(import('@/components/taskmanager.vue'), { + }, {}, 'closed'); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..9958f98f58 --- /dev/null +++ b/packages/client/src/pages/settings/plugin.install.vue @@ -0,0 +1,147 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts._plugin.installWarn }}</FormInfo> + + <FormGroup> + <FormTextarea v-model="code" tall> + <span>{{ $ts.code }}</span> + </FormTextarea> + </FormGroup> + + <FormButton @click="install" :disabled="code == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { AiScript, parse } from '@syuilo/aiscript'; +import { serialize } from '@syuilo/aiscript/built/serializer'; +import { v4 as uuid } from 'uuid'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._plugin.install, + icon: 'fas fa-download', + bg: 'var(--bg)', + }, + code: null, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + installPlugin({ id, meta, ast, token }) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + ast: ast + })); + }, + + async install() { + let ast; + try { + ast = parse(this.code); + } catch (e) { + os.dialog({ + type: 'error', + text: 'Syntax error :(' + }); + return; + } + const meta = AiScript.collectMetadata(ast); + if (meta == null) { + os.dialog({ + type: 'error', + text: 'No metadata found :(' + }); + return; + } + const data = meta.get(null); + if (data == null) { + os.dialog({ + type: 'error', + text: 'No metadata found :(' + }); + return; + } + const { name, version, author, description, permissions, config } = data; + if (name == null || version == null || author == null) { + os.dialog({ + type: 'error', + text: 'Required property not found :(' + }); + return; + } + + const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { + os.popup(import('@/components/token-generate-window.vue'), { + title: this.$ts.tokenRequested, + information: this.$ts.pluginTokenRequestedDescription, + initialName: name, + initialPermissions: permissions + }, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + res(token); + } + }, 'closed'); + }); + + this.installPlugin({ + id: uuid(), + meta: { + name, version, author, description, permissions, config + }, + token, + ast: serialize(ast) + }); + + os.success(); + + this.$nextTick(() => { + unisonReload(); + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/plugin.manage.vue b/packages/client/src/pages/settings/plugin.manage.vue new file mode 100644 index 0000000000..3a0168d13d --- /dev/null +++ b/packages/client/src/pages/settings/plugin.manage.vue @@ -0,0 +1,115 @@ +<template> +<FormBase> + <FormGroup v-for="plugin in plugins" :key="plugin.id"> + <template #label><span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span></template> + + <FormSwitch :value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ $ts.makeActive }}</FormSwitch> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <div class="_keyValue"> + <div>{{ $ts.author }}:</div> + <div>{{ plugin.author }}</div> + </div> + <div class="_keyValue"> + <div>{{ $ts.description }}:</div> + <div>{{ plugin.description }}</div> + </div> + <div class="_keyValue"> + <div>{{ $ts.permission }}:</div> + <div>{{ plugin.permissions }}</div> + </div> + </div> + </div> + <div class="_debobigegoItem"> + <div class="_debobigegoPanel" style="padding: 16px;"> + <MkButton @click="config(plugin)" inline v-if="plugin.config"><i class="fas fa-cog"></i> {{ $ts.settings }}</MkButton> + <MkButton @click="uninstall(plugin)" inline danger><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</MkButton> + </div> + </div> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkTextarea from '@/components/form/textarea.vue'; +import MkSelect from '@/components/form/select.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + MkButton, + MkTextarea, + MkSelect, + FormSwitch, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._plugin.manage, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + plugins: ColdDeviceStorage.get('plugins'), + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + uninstall(plugin) { + ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id)); + os.success(); + this.$nextTick(() => { + unisonReload(); + }); + }, + + // TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする + async config(plugin) { + const config = plugin.config; + for (const key in plugin.configData) { + config[key].default = plugin.configData[key]; + } + + const { canceled, result } = await os.form(plugin.name, config); + if (canceled) return; + + const plugins = ColdDeviceStorage.get('plugins'); + plugins.find(p => p.id === plugin.id).configData = result; + ColdDeviceStorage.set('plugins', plugins); + + this.$nextTick(() => { + location.reload(); + }); + }, + + changeActive(plugin, active) { + const plugins = ColdDeviceStorage.get('plugins'); + plugins.find(p => p.id === plugin.id).active = active; + ColdDeviceStorage.set('plugins', plugins); + + this.$nextTick(() => { + location.reload(); + }); + } + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..50e53f459f --- /dev/null +++ b/packages/client/src/pages/settings/plugin.vue @@ -0,0 +1,44 @@ +<template> +<FormBase> + <FormLink to="/settings/plugin/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._plugin.install }}</FormLink> + <FormLink to="/settings/plugin/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._plugin.manage }}<template #suffix>{{ plugins }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.plugins, + icon: 'fas fa-plug', + bg: 'var(--bg)', + }, + plugins: ColdDeviceStorage.get('plugins').length, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..94afba9aa4 --- /dev/null +++ b/packages/client/src/pages/settings/privacy.vue @@ -0,0 +1,120 @@ +<template> +<FormBase> + <FormGroup> + <FormSwitch v-model="isLocked" @update:modelValue="save()">{{ $ts.makeFollowManuallyApprove }}</FormSwitch> + <FormSwitch v-model="autoAcceptFollowed" :disabled="!isLocked" @update:modelValue="save()">{{ $ts.autoAcceptFollowed }}</FormSwitch> + <template #caption>{{ $ts.lockedAccountInfo }}</template> + </FormGroup> + <FormSwitch v-model="publicReactions" @update:modelValue="save()"> + {{ $ts.makeReactionsPublic }} + <template #desc>{{ $ts.makeReactionsPublicDescription }}</template> + </FormSwitch> + <FormGroup> + <template #label>{{ $ts.ffVisibility }}</template> + <FormSelect v-model="ffVisibility"> + <option value="public">{{ $ts._ffVisibility.public }}</option> + <option value="followers">{{ $ts._ffVisibility.followers }}</option> + <option value="private">{{ $ts._ffVisibility.private }}</option> + </FormSelect> + <template #caption>{{ $ts.ffVisibilityDescription }}</template> + </FormGroup> + <FormSwitch v-model="hideOnlineStatus" @update:modelValue="save()"> + {{ $ts.hideOnlineStatus }} + <template #desc>{{ $ts.hideOnlineStatusDescription }}</template> + </FormSwitch> + <FormSwitch v-model="noCrawle" @update:modelValue="save()"> + {{ $ts.noCrawle }} + <template #desc>{{ $ts.noCrawleDescription }}</template> + </FormSwitch> + <FormSwitch v-model="isExplorable" @update:modelValue="save()"> + {{ $ts.makeExplorable }} + <template #desc>{{ $ts.makeExplorableDescription }}</template> + </FormSwitch> + <FormSwitch v-model="rememberNoteVisibility" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch> + <FormGroup v-if="!rememberNoteVisibility"> + <template #label>{{ $ts.defaultNoteVisibility }}</template> + <FormSelect v-model="defaultNoteVisibility"> + <option value="public">{{ $ts._visibility.public }}</option> + <option value="home">{{ $ts._visibility.home }}</option> + <option value="followers">{{ $ts._visibility.followers }}</option> + <option value="specified">{{ $ts._visibility.specified }}</option> + </FormSelect> + <FormSwitch v-model="defaultNoteLocalOnly">{{ $ts._visibility.localOnly }}</FormSwitch> + </FormGroup> + <FormSwitch v-model="keepCw" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormGroup, + FormSwitch, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.privacy, + icon: 'fas fa-lock-open', + bg: 'var(--bg)', + }, + isLocked: false, + autoAcceptFollowed: false, + noCrawle: false, + isExplorable: false, + hideOnlineStatus: false, + publicReactions: false, + ffVisibility: 'public', + } + }, + + computed: { + defaultNoteVisibility: defaultStore.makeGetterSetter('defaultNoteVisibility'), + defaultNoteLocalOnly: defaultStore.makeGetterSetter('defaultNoteLocalOnly'), + rememberNoteVisibility: defaultStore.makeGetterSetter('rememberNoteVisibility'), + keepCw: defaultStore.makeGetterSetter('keepCw'), + }, + + created() { + this.isLocked = this.$i.isLocked; + this.autoAcceptFollowed = this.$i.autoAcceptFollowed; + this.noCrawle = this.$i.noCrawle; + this.isExplorable = this.$i.isExplorable; + this.hideOnlineStatus = this.$i.hideOnlineStatus; + this.publicReactions = this.$i.publicReactions; + this.ffVisibility = this.$i.ffVisibility; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + os.api('i/update', { + isLocked: !!this.isLocked, + autoAcceptFollowed: !!this.autoAcceptFollowed, + noCrawle: !!this.noCrawle, + isExplorable: !!this.isExplorable, + hideOnlineStatus: !!this.hideOnlineStatus, + publicReactions: !!this.publicReactions, + ffVisibility: this.ffVisibility, + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue new file mode 100644 index 0000000000..a7ddc6d178 --- /dev/null +++ b/packages/client/src/pages/settings/profile.vue @@ -0,0 +1,281 @@ +<template> +<FormBase> + <FormGroup> + <div class="_debobigegoItem _debobigegoPanel llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <MkAvatar class="avatar" :user="$i"/> + </div> + <FormButton @click="changeAvatar" primary>{{ $ts._profile.changeAvatar }}</FormButton> + <FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton> + </FormGroup> + + <FormInput v-model="name" :max="30" manual-save> + <span>{{ $ts._profile.name }}</span> + </FormInput> + + <FormTextarea v-model="description" :max="500" tall manual-save> + <span>{{ $ts._profile.description }}</span> + <template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template> + </FormTextarea> + + <FormInput v-model="location" manual-save> + <span>{{ $ts.location }}</span> + <template #prefix><i class="fas fa-map-marker-alt"></i></template> + </FormInput> + + <FormInput v-model="birthday" type="date" manual-save> + <span>{{ $ts.birthday }}</span> + <template #prefix><i class="fas fa-birthday-cake"></i></template> + </FormInput> + + <FormSelect v-model="lang"> + <template #label>{{ $ts.language }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + </FormSelect> + + <FormGroup> + <FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton> + <template #caption>{{ $ts._profile.metadataDescription }}</template> + </FormGroup> + + <FormSwitch v-model="isCat">{{ $ts.flagAsCat }}<template #desc>{{ $ts.flagAsCatDescription }}</template></FormSwitch> + + <FormSwitch v-model="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch> + + <FormSwitch v-model="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import { host, langs } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.profile, + icon: 'fas fa-user', + bg: 'var(--bg)', + }, + host, + langs, + name: null, + description: null, + birthday: null, + lang: null, + location: null, + fieldName0: null, + fieldValue0: null, + fieldName1: null, + fieldValue1: null, + fieldName2: null, + fieldValue2: null, + fieldName3: null, + fieldValue3: null, + avatarId: null, + bannerId: null, + isBot: false, + isCat: false, + alwaysMarkNsfw: false, + saving: false, + } + }, + + created() { + this.name = this.$i.name; + this.description = this.$i.description; + this.location = this.$i.location; + this.birthday = this.$i.birthday; + this.lang = this.$i.lang; + this.avatarId = this.$i.avatarId; + this.bannerId = this.$i.bannerId; + this.isBot = this.$i.isBot; + this.isCat = this.$i.isCat; + this.alwaysMarkNsfw = this.$i.alwaysMarkNsfw; + + this.fieldName0 = this.$i.fields[0] ? this.$i.fields[0].name : null; + this.fieldValue0 = this.$i.fields[0] ? this.$i.fields[0].value : null; + this.fieldName1 = this.$i.fields[1] ? this.$i.fields[1].name : null; + this.fieldValue1 = this.$i.fields[1] ? this.$i.fields[1].value : null; + this.fieldName2 = this.$i.fields[2] ? this.$i.fields[2].name : null; + this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null; + this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null; + this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null; + + this.$watch('name', this.save); + this.$watch('description', this.save); + this.$watch('location', this.save); + this.$watch('birthday', this.save); + this.$watch('lang', this.save); + this.$watch('isBot', this.save); + this.$watch('isCat', this.save); + this.$watch('alwaysMarkNsfw', this.save); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + changeAvatar(e) { + selectFile(e.currentTarget || e.target, this.$ts.avatar).then(file => { + os.api('i/update', { + avatarId: file.id, + }); + }); + }, + + changeBanner(e) { + selectFile(e.currentTarget || e.target, this.$ts.banner).then(file => { + os.api('i/update', { + bannerId: file.id, + }); + }); + }, + + async editMetadata() { + const { canceled, result } = await os.form(this.$ts._profile.metadata, { + fieldName0: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 1', + default: this.fieldName0, + }, + fieldValue0: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 1', + default: this.fieldValue0, + }, + fieldName1: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 2', + default: this.fieldName1, + }, + fieldValue1: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 2', + default: this.fieldValue1, + }, + fieldName2: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 3', + default: this.fieldName2, + }, + fieldValue2: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 3', + default: this.fieldValue2, + }, + fieldName3: { + type: 'string', + label: this.$ts._profile.metadataLabel + ' 4', + default: this.fieldName3, + }, + fieldValue3: { + type: 'string', + label: this.$ts._profile.metadataContent + ' 4', + default: this.fieldValue3, + }, + }); + if (canceled) return; + + this.fieldName0 = result.fieldName0; + this.fieldValue0 = result.fieldValue0; + this.fieldName1 = result.fieldName1; + this.fieldValue1 = result.fieldValue1; + this.fieldName2 = result.fieldName2; + this.fieldValue2 = result.fieldValue2; + this.fieldName3 = result.fieldName3; + this.fieldValue3 = result.fieldValue3; + + const fields = [ + { name: this.fieldName0, value: this.fieldValue0 }, + { name: this.fieldName1, value: this.fieldValue1 }, + { name: this.fieldName2, value: this.fieldValue2 }, + { name: this.fieldName3, value: this.fieldValue3 }, + ]; + + os.api('i/update', { + fields, + }).then(i => { + os.success(); + }).catch(err => { + os.dialog({ + type: 'error', + text: err.id + }); + }); + }, + + save() { + this.saving = true; + + os.apiWithDialog('i/update', { + name: this.name || null, + description: this.description || null, + location: this.location || null, + birthday: this.birthday || null, + lang: this.lang || null, + isBot: !!this.isBot, + isCat: !!this.isCat, + alwaysMarkNsfw: !!this.alwaysMarkNsfw, + }).then(i => { + this.saving = false; + this.$i.avatarId = i.avatarId; + this.$i.avatarUrl = i.avatarUrl; + this.$i.bannerId = i.bannerId; + this.$i.bannerUrl = i.bannerUrl; + }).catch(err => { + this.saving = false; + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + position: relative; + height: 150px; + background-size: cover; + background-position: center; + + > * { + pointer-events: none; + } + + > .avatar { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: block; + width: 72px; + height: 72px; + margin: auto; + box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5); + } +} +</style> diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..905a3e4957 --- /dev/null +++ b/packages/client/src/pages/settings/reaction.vue @@ -0,0 +1,152 @@ +<template> +<FormBase> + <div class="_debobigegoItem"> + <div class="_debobigegoLabel">{{ $ts.reactionSettingDescription }}</div> + <div class="_debobigegoPanel"> + <XDraggable class="zoaiodol" v-model="reactions" :item-key="item => item" animation="150" delay="100" delay-on-touch-only="true"> + <template #item="{element}"> + <button class="_button item" @click="remove(element, $event)"> + <MkEmoji :emoji="element" :normal="true"/> + </button> + </template> + <template #footer> + <button class="_button add" @click="chooseEmoji"><i class="fas fa-plus"></i></button> + </template> + </XDraggable> + </div> + <div class="_debobigegoCaption">{{ $ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ $ts.preview }}</button></div> + </div> + + <FormRadios v-model="reactionPickerWidth"> + <template #desc>{{ $ts.width }}</template> + <option :value="1">{{ $ts.small }}</option> + <option :value="2">{{ $ts.medium }}</option> + <option :value="3">{{ $ts.large }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight"> + <template #desc>{{ $ts.height }}</template> + <option :value="1">{{ $ts.small }}</option> + <option :value="2">{{ $ts.medium }}</option> + <option :value="3">{{ $ts.large }}</option> + </FormRadios> + <FormButton @click="preview"><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + <FormButton danger @click="setDefault"><i class="fas fa-undo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import XDraggable from 'vuedraggable'; +import FormInput from '@/components/debobigego/input.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormInput, + FormButton, + FormBase, + FormRadios, + XDraggable, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.reaction, + icon: 'fas fa-laugh', + action: { + icon: 'fas fa-eye', + handler: this.preview + }, + bg: 'var(--bg)', + }, + reactions: JSON.parse(JSON.stringify(this.$store.state.reactions)), + } + }, + + computed: { + reactionPickerWidth: defaultStore.makeGetterSetter('reactionPickerWidth'), + reactionPickerHeight: defaultStore.makeGetterSetter('reactionPickerHeight'), + }, + + watch: { + reactions: { + handler() { + this.save(); + }, + deep: true + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + save() { + this.$store.set('reactions', this.reactions); + }, + + remove(reaction, ev) { + os.popupMenu([{ + text: this.$ts.remove, + action: () => { + this.reactions = this.reactions.filter(x => x !== reaction) + } + }], ev.currentTarget || ev.target); + }, + + preview(ev) { + os.popup(import('@/components/emoji-picker-dialog.vue'), { + asReactionPicker: true, + src: ev.currentTarget || ev.target, + }, {}, 'closed'); + }, + + async setDefault() { + const { canceled } = await os.dialog({ + type: 'warning', + text: this.$ts.resetAreYouSure, + showCancelButton: true + }); + if (canceled) return; + + this.reactions = JSON.parse(JSON.stringify(this.$store.def.reactions.default)); + }, + + chooseEmoji(ev) { + os.pickEmoji(ev.currentTarget || ev.target, { + showPinned: false + }).then(emoji => { + if (!this.reactions.includes(emoji)) { + this.reactions.push(emoji); + } + }); + } + } +}); +</script> + +<style lang="scss" scoped> +.zoaiodol { + padding: 16px; + + > .item { + display: inline-block; + padding: 8px; + cursor: move; + } + + > .add { + display: inline-block; + padding: 8px; + } +} +</style> diff --git a/packages/client/src/pages/settings/registry.keys.vue b/packages/client/src/pages/settings/registry.keys.vue new file mode 100644 index 0000000000..ca4d01cc94 --- /dev/null +++ b/packages/client/src/pages/settings/registry.keys.vue @@ -0,0 +1,114 @@ +<template> +<FormBase> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts._registry.domain }}</template> + <template #value>{{ $ts.system }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="keys"> + <template #label>{{ $ts._registry.keys }}</template> + <FormLink v-for="key in keys" :to="`/settings/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> + </FormGroup> + + <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + props: { + scope: { + required: true + } + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + keys: null, + } + }, + + watch: { + scope() { + this.fetch(); + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.fetch(); + }, + + methods: { + fetch() { + os.api('i/registry/keys-with-type', { + scope: this.scope + }).then(keys => { + this.keys = Object.entries(keys).sort((a, b) => a[0].localeCompare(b[0])); + }); + }, + + async createKey() { + const { canceled, result } = await os.form(this.$ts._registry.createKey, { + key: { + type: 'string', + label: this.$ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: this.$ts.value, + }, + scope: { + type: 'string', + label: this.$ts._registry.scope, + default: this.scope.join('/') + } + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + this.fetch(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/registry.value.vue b/packages/client/src/pages/settings/registry.value.vue new file mode 100644 index 0000000000..36f989dbc5 --- /dev/null +++ b/packages/client/src/pages/settings/registry.value.vue @@ -0,0 +1,149 @@ +<template> +<FormBase> + <FormInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</FormInfo> + + <template v-if="value"> + <FormGroup> + <FormKeyValueView> + <template #key>{{ $ts._registry.domain }}</template> + <template #value>{{ $ts.system }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.scope }}</template> + <template #value>{{ scope.join('/') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts._registry.key }}</template> + <template #value>{{ xKey }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <FormTextarea tall v-model="valueForEditor" class="_monospace" style="tab-size: 2;"> + <span>{{ $ts.value }} (JSON)</span> + </FormTextarea> + <FormButton @click="save" primary><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormGroup> + + <FormKeyValueView> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime :time="value.updatedAt" mode="detail"/></template> + </FormKeyValueView> + + <FormButton danger @click="del"><i class="fas fa-trash"></i> {{ $ts.delete }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormInfo from '@/components/debobigego/info.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormInfo, + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormTextarea, + FormGroup, + FormKeyValueView, + }, + + props: { + scope: { + required: true + }, + xKey: { + required: true + }, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + value: null, + valueForEditor: null, + } + }, + + watch: { + key() { + this.fetch(); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + this.fetch(); + }, + + methods: { + fetch() { + os.api('i/registry/get-detail', { + scope: this.scope, + key: this.xKey + }).then(value => { + this.value = value; + this.valueForEditor = JSON5.stringify(this.value.value, null, '\t'); + }); + }, + + save() { + try { + JSON5.parse(this.valueForEditor); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts.invalidValue + }); + return; + } + + os.dialog({ + type: 'warning', + text: this.$ts.saveConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: this.scope, + key: this.xKey, + value: JSON5.parse(this.valueForEditor) + }); + }); + }, + + del() { + os.dialog({ + type: 'warning', + text: this.$ts.deleteConfirm, + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + os.apiWithDialog('i/registry/remove', { + scope: this.scope, + key: this.xKey + }); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/registry.vue b/packages/client/src/pages/settings/registry.vue new file mode 100644 index 0000000000..0bfed0ddb7 --- /dev/null +++ b/packages/client/src/pages/settings/registry.vue @@ -0,0 +1,90 @@ +<template> +<FormBase> + <FormGroup v-if="scopes"> + <template #label>{{ $ts.system }}</template> + <FormLink v-for="scope in scopes" :to="`/settings/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink> + </FormGroup> + <FormButton @click="createKey" primary>{{ $ts._registry.createKey }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.registry, + icon: 'fas fa-cogs', + bg: 'var(--bg)', + }, + scopes: null, + } + }, + + created() { + this.fetch(); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + fetch() { + os.api('i/registry/scopes').then(scopes => { + this.scopes = scopes.slice().sort((a, b) => a.join('/').localeCompare(b.join('/'))); + }); + }, + + async createKey() { + const { canceled, result } = await os.form(this.$ts._registry.createKey, { + key: { + type: 'string', + label: this.$ts._registry.key, + }, + value: { + type: 'string', + multiline: true, + label: this.$ts.value, + }, + scope: { + type: 'string', + label: this.$ts._registry.scope, + } + }); + if (canceled) return; + os.apiWithDialog('i/registry/set', { + scope: result.scope.split('/'), + key: result.key, + value: JSON5.parse(result.value), + }).then(() => { + this.fetch(); + }); + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue new file mode 100644 index 0000000000..4d81bf1b9e --- /dev/null +++ b/packages/client/src/pages/settings/security.vue @@ -0,0 +1,158 @@ +<template> +<FormBase> + <X2fa/> + <FormLink to="/settings/2fa"><template #icon><i class="fas fa-mobile-alt"></i></template>{{ $ts.twoStepAuthentication }}</FormLink> + <FormButton primary @click="change()">{{ $ts.changePassword }}</FormButton> + <FormPagination :pagination="pagination"> + <template #label>{{ $ts.signinHistory }}</template> + <template #default="{items}"> + <div class="_debobigegoPanel timnmucd" v-for="item in items" :key="item.id"> + <header> + <i v-if="item.success" class="fas fa-check icon succ"></i> + <i v-else class="fas fa-times-circle icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </template> + </FormPagination> + <FormGroup> + <FormButton danger @click="regenerateToken"><i class="fas fa-sync-alt"></i> {{ $ts.regenerateLoginToken }}</FormButton> + <template #caption>{{ $ts.regenerateLoginTokenDescription }}</template> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormPagination from '@/components/debobigego/pagination.vue'; +import * as os from '@/os'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormPagination, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.security, + icon: 'fas fa-lock', + bg: 'var(--bg)', + }, + pagination: { + endpoint: 'i/signin-history', + limit: 5, + }, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async change() { + const { canceled: canceled1, result: currentPassword } = await os.dialog({ + title: this.$ts.currentPassword, + input: { + type: 'password' + } + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.dialog({ + title: this.$ts.newPassword, + input: { + type: 'password' + } + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.dialog({ + title: this.$ts.newPasswordRetype, + input: { + type: 'password' + } + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.dialog({ + type: 'error', + text: this.$ts.retypedNotMatch + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword + }); + }, + + regenerateToken() { + os.dialog({ + title: this.$ts.password, + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password + }); + }); + }, + } +}); +</script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + > header { + display: flex; + align-items: center; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.succ { + color: var(--success); + } + + &.fail { + color: var(--error); + } + } + + > .ip { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 12px; + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} +</style> diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ea3daced9d --- /dev/null +++ b/packages/client/src/pages/settings/sounds.vue @@ -0,0 +1,155 @@ +<template> +<FormBase> + <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05"> + <template #label><i class="fas fa-volume-icon"></i> {{ $ts.masterVolume }}</template> + </FormRange> + + <FormGroup> + <template #label>{{ $ts.sounds }}</template> + <FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)"> + {{ $t('_sfx.' + type) }} + <template #suffix>{{ sounds[type].type || $ts.none }}</template> + <template #suffixIcon><i class="fas fa-chevron-down"></i></template> + </FormButton> + </FormGroup> + + <FormButton @click="reset()" danger><i class="fas fa-redo"></i> {{ $ts.default }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormRange from '@/components/debobigego/range.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { playFile } from '@/scripts/sound'; +import * as symbols from '@/symbols'; + +const soundsTypes = [ + null, + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + 'syuilo/poi1', + 'syuilo/poi2', + 'syuilo/pirori', + 'syuilo/pirori-wet', + 'syuilo/pirori-square-wet', + 'syuilo/square-pico', + 'syuilo/reverved', + 'syuilo/ryukyu', + 'syuilo/kick', + 'syuilo/snare', + 'syuilo/queue-jammed', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba', + 'noizenecio/kick_gaba2', +]; + +export default defineComponent({ + components: { + FormSelect, + FormButton, + FormBase, + FormRange, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.sounds, + icon: 'fas fa-music', + bg: 'var(--bg)', + }, + sounds: {}, + } + }, + + computed: { + masterVolume: { // TODO: (外部)関数にcomputedを使うのはアレなので直す + get() { return ColdDeviceStorage.get('sound_masterVolume'); }, + set(value) { ColdDeviceStorage.set('sound_masterVolume', value); } + }, + volumeIcon() { + return this.masterVolume === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'; + } + }, + + created() { + this.sounds.note = ColdDeviceStorage.get('sound_note'); + this.sounds.noteMy = ColdDeviceStorage.get('sound_noteMy'); + this.sounds.notification = ColdDeviceStorage.get('sound_notification'); + this.sounds.chat = ColdDeviceStorage.get('sound_chat'); + this.sounds.chatBg = ColdDeviceStorage.get('sound_chatBg'); + this.sounds.antenna = ColdDeviceStorage.get('sound_antenna'); + this.sounds.channel = ColdDeviceStorage.get('sound_channel'); + this.sounds.reversiPutBlack = ColdDeviceStorage.get('sound_reversiPutBlack'); + this.sounds.reversiPutWhite = ColdDeviceStorage.get('sound_reversiPutWhite'); + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async edit(type) { + const { canceled, result } = await os.form(this.$t('_sfx.' + type), { + type: { + type: 'enum', + enum: soundsTypes.map(x => ({ + value: x, + label: x == null ? this.$ts.none : x, + })), + label: this.$ts.sound, + default: this.sounds[type].type, + }, + volume: { + type: 'range', + mim: 0, + max: 1, + step: 0.05, + label: this.$ts.volume, + default: this.sounds[type].volume + }, + listen: { + type: 'button', + content: this.$ts.listen, + action: (_, values) => { + playFile(values.type, values.volume); + } + } + }); + if (canceled) return; + + const v = { + type: result.type, + volume: result.volume, + }; + + ColdDeviceStorage.set('sound_' + type, v); + this.sounds[type] = v; + }, + + reset() { + for (const sound of Object.keys(this.sounds)) { + const v = ColdDeviceStorage.default['sound_' + sound]; + ColdDeviceStorage.set('sound_' + sound, v); + this.sounds[sound] = v; + } + } + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..59ad3ad9b7 --- /dev/null +++ b/packages/client/src/pages/settings/theme.install.vue @@ -0,0 +1,105 @@ +<template> +<FormBase> + <FormGroup> + <FormTextarea v-model="installThemeCode"> + <span>{{ $ts._theme.code }}</span> + </FormTextarea> + <FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><i class="fas fa-eye"></i> {{ $ts.preview }}</FormButton> + </FormGroup> + + <FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><i class="fas fa-check"></i> {{ $ts.install }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { addTheme, getThemes } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._theme.install, + icon: 'fas fa-download', + bg: 'var(--bg)', + }, + installThemeCode: null, + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + parseThemeCode(code) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return false; + } + if (!validateTheme(theme)) { + os.dialog({ + type: 'error', + text: this.$ts._theme.invalid + }); + return false; + } + if (getThemes().some(t => t.id === theme.id)) { + os.dialog({ + type: 'info', + text: this.$ts._theme.alreadyInstalled + }); + return false; + } + + return theme; + }, + + preview(code) { + const theme = this.parseThemeCode(code); + if (theme) applyTheme(theme, false); + }, + + async install(code) { + const theme = this.parseThemeCode(code); + if (!theme) return; + await addTheme(theme); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..8a24481ae2 --- /dev/null +++ b/packages/client/src/pages/settings/theme.manage.vue @@ -0,0 +1,105 @@ +<template> +<FormBase> + <FormSelect v-model="selectedThemeId"> + <template #label>{{ $ts.theme }}</template> + <optgroup :label="$ts._theme.installedThemes"> + <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts._theme.builtinThemes"> + <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :modelValue="selectedTheme.author"> + <span>{{ $ts.author }}</span> + </FormInput> + <FormTextarea readonly :modelValue="selectedTheme.desc" v-if="selectedTheme.desc"> + <span>{{ $ts._theme.description }}</span> + </FormTextarea> + <FormTextarea readonly tall :modelValue="selectedThemeCode"> + <span>{{ $ts._theme.code }}</span> + <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $ts.copy }}</button></template> + </FormTextarea> + <FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><i class="fas fa-trash-alt"></i> {{ $ts.uninstall }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/debobigego/textarea.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormRadios from '@/components/debobigego/radios.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormInput from '@/components/debobigego/input.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { Theme, builtinThemes } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { getThemes, removeTheme } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormInput, + FormButton, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts._theme.manage, + icon: 'fas fa-folder-open', + bg: 'var(--bg)', + }, + installedThemes: getThemes(), + builtinThemes, + selectedThemeId: null, + } + }, + + computed: { + themes(): Theme[] { + return this.builtinThemes.concat(this.installedThemes); + }, + + selectedTheme() { + if (this.selectedThemeId == null) return null; + return this.themes.find(x => x.id === this.selectedThemeId); + }, + + selectedThemeCode() { + if (this.selectedTheme == null) return null; + return JSON5.stringify(this.selectedTheme, null, '\t'); + }, + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + copyThemeCode() { + copyToClipboard(this.selectedThemeCode); + os.success(); + }, + + uninstall() { + removeTheme(this.selectedTheme); + this.installedThemes = this.installedThemes.filter(t => t.id !== this.selectedThemeId); + this.selectedThemeId = null; + os.success(); + }, + } +}); +</script> diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue new file mode 100644 index 0000000000..a9cca40f3c --- /dev/null +++ b/packages/client/src/pages/settings/theme.vue @@ -0,0 +1,424 @@ +<template> +<FormBase> + <FormGroup> + <div class="rfqxtzch _debobigegoItem _debobigegoPanel"> + <div class="darkMode"> + <div class="toggleWrapper"> + <input type="checkbox" class="dn" id="dn" v-model="darkMode"/> + <label for="dn" class="toggle"> + <span class="before">{{ $ts.light }}</span> + <span class="after">{{ $ts.dark }}</span> + <span class="toggle__handler"> + <span class="crater crater--1"></span> + <span class="crater crater--2"></span> + <span class="crater crater--3"></span> + </span> + <span class="star star--1"></span> + <span class="star star--2"></span> + <span class="star star--3"></span> + <span class="star star--4"></span> + <span class="star star--5"></span> + <span class="star star--6"></span> + </label> + </div> + </div> + </div> + <FormSwitch v-model="syncDeviceDarkMode">{{ $ts.syncDeviceDarkMode }}</FormSwitch> + </FormGroup> + + <template v-if="darkMode"> + <FormSelect v-model="darkThemeId"> + <template #label>{{ $ts.themeForDarkMode }}</template> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="lightThemeId"> + <template #label>{{ $ts.themeForLightMode }}</template> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </template> + <template v-else> + <FormSelect v-model="lightThemeId"> + <template #label>{{ $ts.themeForLightMode }}</template> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="darkThemeId"> + <template #label>{{ $ts.themeForDarkMode }}</template> + <optgroup :label="$ts.darkThemes"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$ts.lightThemes"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </template> + + <FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $ts.setWallpaper }}</FormButton> + <FormButton primary v-else @click="wallpaper = null">{{ $ts.removeWallpaper }}</FormButton> + + <FormGroup> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="fas fa-globe"></i></template>{{ $ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="fas fa-download"></i></template>{{ $ts._theme.install }}</FormLink> + </FormGroup> + + <FormGroup> + <FormLink to="/theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }}</FormLink> + <!--<FormLink to="/advanced-theme-editor"><template #icon><i class="fas fa-paint-roller"></i></template>{{ $ts._theme.make }} ({{ $ts.advanced }})</FormLink>--> + </FormGroup> + + <FormLink to="/settings/theme/manage"><template #icon><i class="fas fa-folder-open"></i></template>{{ $ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> +</FormBase> +</template> + +<script lang="ts"> +import { computed, defineComponent, onActivated, onMounted, ref, watch } from 'vue'; +import FormSwitch from '@/components/debobigego/switch.vue'; +import FormSelect from '@/components/debobigego/select.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import { builtinThemes } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { ColdDeviceStorage } from '@/store'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { fetchThemes, getThemes } from '@/theme-store'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormSwitch, + FormSelect, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + setup(props, { emit }) { + const INFO = { + title: i18n.locale.theme, + icon: 'fas fa-palette', + bg: 'var(--bg)', + }; + + const installedThemes = ref(getThemes()); + const themes = computed(() => builtinThemes.concat(installedThemes.value)); + const darkThemes = computed(() => themes.value.filter(t => t.base == 'dark' || t.kind == 'dark')); + const lightThemes = computed(() => themes.value.filter(t => t.base == 'light' || t.kind == 'light')); + const darkTheme = ColdDeviceStorage.ref('darkTheme'); + const darkThemeId = computed({ + get() { + return darkTheme.value.id; + }, + set(id) { + ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id)) + } + }); + const lightTheme = ColdDeviceStorage.ref('lightTheme'); + const lightThemeId = computed({ + get() { + return lightTheme.value.id; + }, + set(id) { + ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id)) + } + }); + const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); + const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); + const wallpaper = ref(localStorage.getItem('wallpaper')); + const themesCount = installedThemes.value.length; + + watch(syncDeviceDarkMode, () => { + if (syncDeviceDarkMode) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } + }); + + watch(wallpaper, () => { + if (wallpaper.value == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', wallpaper.value); + } + location.reload(); + }); + + onMounted(() => { + emit('info', INFO); + }); + + onActivated(() => { + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); + }); + + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); + + return { + [symbols.PAGE_INFO]: INFO, + darkThemes, + lightThemes, + darkThemeId, + lightThemeId, + darkMode, + syncDeviceDarkMode, + themesCount, + wallpaper, + setWallpaper(e) { + selectFile(e.currentTarget || e.target, null, false).then(file => { + wallpaper.value = file.url; + }); + }, + }; + } +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + padding: 16px; + + > .darkMode { + position: relative; + padding: 32px 0; + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + .toggleWrapper { + position: absolute; + top: 50%; + left: 50%; + overflow: hidden; + padding: 0 100px; + transform: translate3d(-50%, -50%, 0); + + input { + position: absolute; + left: -99em; + } + } + + .toggle { + cursor: pointer; + display: inline-block; + position: relative; + width: 90px; + height: 50px; + background-color: #83D8FF; + border-radius: 90px - 6; + transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + + > .before, > .after { + position: absolute; + top: 15px; + font-size: 18px; + transition: color 1s ease; + } + + > .before { + left: -70px; + color: var(--accent); + } + + > .after { + right: -68px; + color: var(--fg); + } + } + + .toggle__handler { + display: inline-block; + position: relative; + z-index: 1; + top: 3px; + left: 3px; + width: 50px - 6; + height: 50px - 6; + background-color: #FFCF96; + border-radius: 50px; + box-shadow: 0 2px 6px rgba(0,0,0,.3); + transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; + transform: rotate(-45deg); + + .crater { + position: absolute; + background-color: #E8CDA5; + opacity: 0; + transition: opacity 200ms ease-in-out !important; + border-radius: 100%; + } + + .crater--1 { + top: 18px; + left: 10px; + width: 4px; + height: 4px; + } + + .crater--2 { + top: 28px; + left: 22px; + width: 6px; + height: 6px; + } + + .crater--3 { + top: 10px; + left: 25px; + width: 8px; + height: 8px; + } + } + + .star { + position: absolute; + background-color: #ffffff; + transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + border-radius: 50%; + } + + .star--1 { + top: 10px; + left: 35px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--2 { + top: 18px; + left: 28px; + z-index: 1; + width: 30px; + height: 3px; + } + + .star--3 { + top: 27px; + left: 40px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--4, + .star--5, + .star--6 { + opacity: 0; + transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--4 { + top: 16px; + left: 11px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + .star--5 { + top: 32px; + left: 17px; + z-index: 0; + width: 3px; + height: 3px; + transform: translate3d(3px,0,0); + } + + .star--6 { + top: 36px; + left: 28px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + input:checked { + + .toggle { + background-color: #749DD6; + + > .before { + color: var(--fg); + } + + > .after { + color: var(--accent); + } + + .toggle__handler { + background-color: #FFE5B5; + transform: translate3d(40px, 0, 0) rotate(0); + + .crater { opacity: 1; } + } + + .star--1 { + width: 2px; + height: 2px; + } + + .star--2 { + width: 4px; + height: 4px; + transform: translate3d(-5px, 0, 0); + } + + .star--3 { + width: 2px; + height: 2px; + transform: translate3d(-7px, 0, 0); + } + + .star--4, + .star--5, + .star--6 { + opacity: 1; + transform: translate3d(0,0,0); + } + + .star--4 { + transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--5 { + transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--6 { + transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + } + } + } +} +</style> diff --git a/packages/client/src/pages/settings/update.vue b/packages/client/src/pages/settings/update.vue new file mode 100644 index 0000000000..aa4050fe9f --- /dev/null +++ b/packages/client/src/pages/settings/update.vue @@ -0,0 +1,95 @@ +<template> +<FormBase> + <template v-if="meta"> + <FormInfo v-if="version === meta.version">{{ $ts.youAreRunningUpToDateClient }}</FormInfo> + <FormInfo v-else warn>{{ $ts.newVersionOfClientAvailable }}</FormInfo> + </template> + <FormGroup> + <template #label>{{ instanceName }}</template> + <FormKeyValueView> + <template #key>{{ $ts.currentVersion }}</template> + <template #value>{{ version }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $ts.latestVersion }}</template> + <template #value v-if="meta">{{ meta.version }}</template> + <template #value v-else><MkEllipsis/></template> + </FormKeyValueView> + </FormGroup> + <FormGroup> + <template #label>Misskey</template> + <FormKeyValueView> + <template #key>{{ $ts.latestVersion }}</template> + <template #value v-if="releases">{{ releases[0].tag_name }}</template> + <template #value v-else><MkEllipsis/></template> + </FormKeyValueView> + <template #caption v-if="releases"><MkTime :time="releases[0].published_at" mode="detail"/></template> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/debobigego/link.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormGroup from '@/components/debobigego/group.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import * as os from '@/os'; +import { version, instanceName } from '@/config'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: 'Misskey Update', + icon: 'fas fa-sync-alt', + bg: 'var(--bg)', + }, + version, + instanceName, + releases: null, + meta: null + } + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + + os.api('meta', { + detail: false + }).then(meta => { + this.meta = meta; + localStorage.setItem('v', meta.version); + }); + + fetch('https://api.github.com/repos/misskey-dev/misskey/releases', { + method: 'GET', + }) + .then(res => res.json()) + .then(res => { + this.releases = res; + }); + }, + + methods: { + } +}); +</script> diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..c2162bb1f3 --- /dev/null +++ b/packages/client/src/pages/settings/word-mute.vue @@ -0,0 +1,110 @@ +<template> +<div> + <MkTab v-model="tab"> + <option value="soft">{{ $ts._wordMute.soft }}</option> + <option value="hard">{{ $ts._wordMute.hard }}</option> + </MkTab> + <FormBase> + <div class="_debobigegoItem"> + <div v-show="tab === 'soft'"> + <FormInfo>{{ $ts._wordMute.softDescription }}</FormInfo> + <FormTextarea v-model="softMutedWords"> + <span>{{ $ts._wordMute.muteWords }}</span> + <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + </div> + <div v-show="tab === 'hard'"> + <FormInfo>{{ $ts._wordMute.hardDescription }}</FormInfo> + <FormTextarea v-model="hardMutedWords"> + <span>{{ $ts._wordMute.muteWords }}</span> + <template #desc>{{ $ts._wordMute.muteWordsDescription }}<br>{{ $ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + <FormKeyValueView v-if="hardWordMutedNotesCount != null"> + <template #key>{{ $ts._wordMute.mutedNotes }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </FormKeyValueView> + </div> + </div> + <FormButton @click="save()" primary inline :disabled="!changed"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + </FormBase> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/debobigego/base.vue'; +import FormKeyValueView from '@/components/debobigego/key-value-view.vue'; +import FormButton from '@/components/debobigego/button.vue'; +import FormInfo from '@/components/debobigego/info.vue'; +import MkTab from '@/components/tab.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import * as symbols from '@/symbols'; + +export default defineComponent({ + components: { + FormBase, + FormButton, + FormTextarea, + FormKeyValueView, + MkTab, + FormInfo, + }, + + emits: ['info'], + + data() { + return { + [symbols.PAGE_INFO]: { + title: this.$ts.wordMute, + icon: 'fas fa-comment-slash', + bg: 'var(--bg)', + }, + tab: 'soft', + softMutedWords: '', + hardMutedWords: '', + hardWordMutedNotesCount: null, + changed: false, + } + }, + + watch: { + softMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + hardMutedWords: { + handler() { + this.changed = true; + }, + deep: true + }, + }, + + async created() { + this.softMutedWords = this.$store.state.mutedWords.map(x => x.join(' ')).join('\n'); + this.hardMutedWords = this.$i.mutedWords.map(x => x.join(' ')).join('\n'); + + this.hardWordMutedNotesCount = (await os.api('i/get-word-muted-notes-count', {})).count; + }, + + mounted() { + this.$emit('info', this[symbols.PAGE_INFO]); + }, + + methods: { + async save() { + this.$store.set('mutedWords', this.softMutedWords.trim().split('\n').map(x => x.trim().split(' '))); + await os.api('i/update', { + mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')), + }); + this.changed = false; + }, + + number + } +}); +</script> |