diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/pages/settings | |
| parent | wip: retention for dashboard (diff) | |
| download | misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 misskey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/pages/settings')
37 files changed, 4687 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue new file mode 100644 index 0000000000..1803129aaa --- /dev/null +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -0,0 +1,216 @@ +<template> +<div> + <MkButton v-if="!twoFactorData && !$i.twoFactorEnabled" @click="register">{{ i18n.ts._2fa.registerDevice }}</MkButton> + <template v-if="$i.twoFactorEnabled"> + <p>{{ i18n.ts._2fa.alreadyRegistered }}</p> + <MkButton @click="unregister">{{ i18n.ts.unregister }}</MkButton> + + <template v-if="supportsCredentials"> + <hr class="totp-method-sep"> + + <h2 class="heading">{{ i18n.ts.securityKey }}</h2> + <p>{{ i18n.ts._2fa.securityKeyInfo }}</p> + <div class="key-list"> + <div v-for="key in $i.securityKeysList" class="key"> + <h3>{{ key.name }}</h3> + <div class="last-used">{{ i18n.ts.lastUsed }}<MkTime :time="key.lastUsed"/></div> + <MkButton @click="unregisterKey(key)">{{ i18n.ts.unregister }}</MkButton> + </div> + </div> + + <MkSwitch v-if="$i.securityKeysList.length > 0" v-model="usePasswordLessLogin" @update:model-value="updatePasswordLessLogin">{{ i18n.ts.passwordLessLogin }}</MkSwitch> + + <MkInfo v-if="registration && registration.error" warn>{{ i18n.ts.error }} {{ registration.error }}</MkInfo> + <MkButton v-if="!registration || registration.error" @click="addSecurityKey">{{ i18n.ts._2fa.registerKey }}</MkButton> + + <ol v-if="registration && !registration.error"> + <li v-if="registration.stage >= 0"> + {{ i18n.ts.tapSecurityKey }} + <MkLoading v-if="registration.saving && registration.stage == 0" :em="true"/> + </li> + <li v-if="registration.stage >= 1"> + <MkForm :disabled="registration.stage != 1 || registration.saving"> + <MkInput v-model="keyName" :max="30"> + <template #label>{{ i18n.ts.securityKeyName }}</template> + </MkInput> + <MkButton :disabled="keyName.length == 0" @click="registerKey">{{ i18n.ts.registerSecurityKey }}</MkButton> + <MkLoading v-if="registration.saving && registration.stage == 1" :em="true"/> + </MkForm> + </li> + </ol> + </template> + </template> + <div v-if="twoFactorData && !$i.twoFactorEnabled"> + <ol style="margin: 0; padding: 0 0 0 1em;"> + <li> + <I18n :src="i18n.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>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> + <li> + {{ i18n.ts._2fa.step3 }}<br> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> + <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> + </li> + </ol> + <MkInfo>{{ i18n.ts._2fa.step4 }}</MkInfo> + </div> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { hostname } from '@/config'; +import { byteify, hexify, stringify } from '@/scripts/2fa'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +const twoFactorData = ref<any>(null); +const supportsCredentials = ref(!!navigator.credentials); +const usePasswordLessLogin = ref($i!.usePasswordLessLogin); +const registration = ref<any>(null); +const keyName = ref(''); +const token = ref(null); + +function register() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register', { + password: password, + }).then(data => { + twoFactorData.value = data; + }); + }); +} + +function unregister() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/unregister', { + password: password, + }).then(() => { + usePasswordLessLogin.value = false; + updatePasswordLessLogin(); + }).then(() => { + os.success(); + $i!.twoFactorEnabled = false; + }); + }); +} + +function submit() { + os.api('i/2fa/done', { + token: token.value, + }).then(() => { + os.success(); + $i!.twoFactorEnabled = true; + }).catch(err => { + os.alert({ + type: 'error', + text: err, + }); + }); +} + +function registerKey() { + registration.value.saving = true; + os.api('i/2fa/key-done', { + password: registration.value.password, + name: keyName.value, + challengeId: registration.value.challengeId, + // we convert each 16 bits to a string to serialise + clientDataJSON: stringify(registration.value.credential.response.clientDataJSON), + attestationObject: hexify(registration.value.credential.response.attestationObject), + }).then(key => { + registration.value = null; + key.lastUsed = new Date(); + os.success(); + }); +} + +function unregisterKey(key) { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + return os.api('i/2fa/remove-key', { + password, + credentialId: key.id, + }).then(() => { + usePasswordLessLogin.value = false; + updatePasswordLessLogin(); + }).then(() => { + os.success(); + }); + }); +} + +function addSecurityKey() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/2fa/register-key', { + password, + }).then(reg => { + registration.value = { + password, + challengeId: reg!.challengeId, + stage: 0, + publicKeyOptions: { + challenge: byteify(reg!.challenge, 'base64'), + rp: { + id: hostname, + name: 'Misskey', + }, + user: { + id: byteify($i!.id, 'ascii'), + name: $i!.username, + displayName: $i!.name, + }, + pubKeyCredParams: [{ alg: -7, type: 'public-key' }], + timeout: 60000, + attestation: 'direct', + }, + saving: true, + }; + return navigator.credentials.create({ + publicKey: registration.value.publicKeyOptions, + }); + }).then(credential => { + registration.value.credential = credential; + registration.value.saving = false; + registration.value.stage = 1; + }).catch(err => { + console.warn('Error while registering?', err); + registration.value.error = err.message; + registration.value.stage = -1; + }); + }); +} + +async function updatePasswordLessLogin() { + await os.api('i/2fa/password-less', { + value: !!usePasswordLessLogin.value, + }); +} +</script> diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-info.vue new file mode 100644 index 0000000000..ccd99c162a --- /dev/null +++ b/packages/frontend/src/pages/settings/account-info.vue @@ -0,0 +1,158 @@ +<template> +<div class="_formRoot"> + <MkKeyValue> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </MkKeyValue> + + <FormSection> + <MkKeyValue> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </MkKeyValue> + </FormSection> + + <FormSection v-if="stats"> + <template #label>{{ i18n.ts.statistics }}</template> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.notesCount }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.repliesCount }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.renotesCount }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.repliedCount }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.renotedCount }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pollVotesCount }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pollVotedCount }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.sentReactionsCount }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.receivedReactionsCount }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.noteFavoritesCount }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.local }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followingCount }} ({{ i18n.ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.local }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.followersCount }} ({{ i18n.ts.remote }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pageLikesCount }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.pageLikedCount }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.driveFilesCount }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.driveUsage }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </MkKeyValue> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.other }}</template> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>emailVerified</template> + <template #value>{{ $i.emailVerified ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>twoFactorEnabled</template> + <template #value>{{ $i.twoFactorEnabled ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>securityKeys</template> + <template #value>{{ $i.securityKeys ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $i.usePasswordLessLogin ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>isModerator</template> + <template #value>{{ $i.isModerator ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>isAdmin</template> + <template #value>{{ $i.isAdmin ? i18n.ts.yes : i18n.ts.no }}</template> + </MkKeyValue> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const stats = ref<any>({}); + +onMounted(() => { + os.api('users/stats', { + userId: $i!.id, + }).then(response => { + stats.value = response; + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accountInfo, + icon: 'ti ti-info-circle', +}); +</script> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue new file mode 100644 index 0000000000..493d3b2618 --- /dev/null +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -0,0 +1,143 @@ +<template> +<div class="_formRoot"> + <FormSuspense :p="init"> + <FormButton primary @click="addAccount"><i class="ti ti-plus"></i> {{ i18n.ts.addAccount }}</FormButton> + + <div v-for="account in accounts" :key="account.id" class="_panel _button lcjjdxlm" @click="menu(account, $event)"> + <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> + </FormSuspense> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const storedAccounts = ref<any>(null); +const accounts = ref<any>(null); + +const init = async () => { + getAccounts().then(accounts => { + storedAccounts.value = accounts.filter(x => x.id !== $i!.id); + + console.log(storedAccounts.value); + + return os.api('users/show', { + userIds: storedAccounts.value.map(x => x.id), + }); + }).then(response => { + accounts.value = response; + console.log(accounts.value); + }); +}; + +function menu(account, ev) { + os.popupMenu([{ + text: i18n.ts.switch, + icon: 'ti ti-switch-horizontal', + action: () => switchAccount(account), + }, { + text: i18n.ts.remove, + icon: 'ti ti-trash', + danger: true, + action: () => removeAccount(account), + }], ev.currentTarget ?? ev.target); +} + +function addAccount(ev) { + os.popupMenu([{ + text: i18n.ts.existingAccount, + action: () => { addExistingAccount(); }, + }, { + text: i18n.ts.createAccount, + action: () => { createAccount(); }, + }], ev.currentTarget ?? ev.target); +} + +function removeAccount(account) { + _removeAccount(account.id); +} + +function addExistingAccount() { + os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: res => { + addAccounts(res.id, res.i); + os.success(); + }, + }, 'closed'); +} + +function createAccount() { + os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: res => { + addAccounts(res.id, res.i); + switchAccountWithToken(res.i); + }, + }, 'closed'); +} + +async function switchAccount(account: any) { + const fetchedAccounts: any[] = await getAccounts(); + const token = fetchedAccounts.find(x => x.id === account.id).token; + switchAccountWithToken(token); +} + +function switchAccountWithToken(token: string) { + login(token); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accounts, + icon: 'ti ti-users', +}); +</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/frontend/src/pages/settings/api.vue b/packages/frontend/src/pages/settings/api.vue new file mode 100644 index 0000000000..8d7291cd10 --- /dev/null +++ b/packages/frontend/src/pages/settings/api.vue @@ -0,0 +1,46 @@ +<template> +<div class="_formRoot"> + <FormButton primary class="_formBlock" @click="generateToken">{{ i18n.ts.generateAccessToken }}</FormButton> + <FormLink to="/settings/apps" class="_formBlock">{{ i18n.ts.manageAccessTokens }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null" class="_formBlock">API console</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref } from 'vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const isDesktop = ref(window.innerWidth >= 1100); + +function generateToken() { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), {}, { + done: async result => { + const { name, permissions } = result; + const { token } = await os.api('miauth/gen-token', { + session: null, + name: name, + permission: permissions, + }); + + os.alert({ + type: 'success', + title: i18n.ts.token, + text: token, + }); + }, + }, 'closed'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API', + icon: 'ti ti-api', +}); +</script> diff --git a/packages/frontend/src/pages/settings/apps.vue b/packages/frontend/src/pages/settings/apps.vue new file mode 100644 index 0000000000..05abadff23 --- /dev/null +++ b/packages/frontend/src/pages/settings/apps.vue @@ -0,0 +1,96 @@ +<template> +<div class="_formRoot"> + <FormPagination ref="list" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + </template> + <template #default="{items}"> + <div v-for="token in items" :key="token.id" class="_panel bfomjevm"> + <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/> + <div class="body"> + <div class="name">{{ token.name }}</div> + <div class="description">{{ token.description }}</div> + <div class="_keyValue"> + <div>{{ i18n.ts.installedDate }}:</div> + <div><MkTime :time="token.createdAt"/></div> + </div> + <div class="_keyValue"> + <div>{{ i18n.ts.lastUsedDate }}:</div> + <div><MkTime :time="token.lastUsedAt"/></div> + </div> + <div class="actions"> + <button class="_button" @click="revoke(token)"><i class="ti ti-trash"></i></button> + </div> + <details> + <summary>{{ i18n.ts.details }}</summary> + <ul> + <li v-for="p in token.permission" :key="p">{{ $t(`_permissions.${p}`) }}</li> + </ul> + </details> + </div> + </div> + </template> + </FormPagination> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import FormPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const list = ref<any>(null); + +const pagination = { + endpoint: 'i/apps' as const, + limit: 100, + params: { + sort: '+lastUsedAt', + }, +}; + +function revoke(token) { + os.api('i/revoke-token', { tokenId: token.id }).then(() => { + list.value.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.installedApps, + icon: 'ti ti-plug', +}); +</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/frontend/src/pages/settings/custom-css.vue b/packages/frontend/src/pages/settings/custom-css.vue new file mode 100644 index 0000000000..2caad22b7b --- /dev/null +++ b/packages/frontend/src/pages/settings/custom-css.vue @@ -0,0 +1,46 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts.customCssWarn }}</FormInfo> + + <FormTextarea v-model="localCustomCss" manual-save tall class="_monospace _formBlock" style="tab-size: 2;"> + <template #label>CSS</template> + </FormTextarea> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const localCustomCss = ref(localStorage.getItem('customCss') ?? ''); + +async function apply() { + localStorage.setItem('customCss', localCustomCss.value); + + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +watch(localCustomCss, async () => { + await apply(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.customCss, + icon: 'ti ti-code', +}); +</script> diff --git a/packages/frontend/src/pages/settings/deck.vue b/packages/frontend/src/pages/settings/deck.vue new file mode 100644 index 0000000000..82cefe05d5 --- /dev/null +++ b/packages/frontend/src/pages/settings/deck.vue @@ -0,0 +1,39 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch> + + <FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch> + + <FormRadios v-model="columnAlign" class="_formBlock"> + <template #label>{{ i18n.ts._deck.columnAlign }}</template> + <option value="left">{{ i18n.ts.left }}</option> + <option value="center">{{ i18n.ts.center }}</option> + </FormRadios> +</div> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormInput from '@/components/form/input.vue'; +import { deckStore } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const navWindow = computed(deckStore.makeGetterSetter('navWindow')); +const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); +const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.deck, + icon: 'ti ti-columns', +}); +</script> diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue new file mode 100644 index 0000000000..8a25ff39f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/delete-account.vue @@ -0,0 +1,52 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo class="_formBlock">{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> + <FormButton v-if="!$i.isDeleted" danger class="_formBlock" @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</FormButton> + <FormButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import FormInfo from '@/components/MkInfo.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { signout } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +async function deleteAccount() { + { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (canceled) return; + } + + const { canceled, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password, + }); + + await os.alert({ + title: i18n.ts._accountDelete.started, + }); + + await signout(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._accountDelete.accountDelete, + icon: 'ti ti-alert-triangle', +}); +</script> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue new file mode 100644 index 0000000000..2d45b1add8 --- /dev/null +++ b/packages/frontend/src/pages/settings/drive.vue @@ -0,0 +1,145 @@ +<template> +<div class="_formRoot"> + <FormSection v-if="!fetching"> + <template #label>{{ i18n.ts.usageAmount }}</template> + <div class="_formBlock uawsfosz"> + <div class="meter"><div :style="meterStyle"></div></div> + </div> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.capacity }}</template> + <template #value>{{ bytes(capacity, 1) }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.inUse }}</template> + <template #value>{{ bytes(usage, 1) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.statistics }}</template> + <MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/> + </FormSection> + + <FormSection> + <FormLink @click="chooseUploadFolder()"> + {{ i18n.ts.uploadFolder }} + <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> + <template #suffixIcon><i class="fas fa-folder-open"></i></template> + </FormLink> + <FormSwitch v-model="keepOriginalUploading" class="_formBlock"> + <template #label>{{ i18n.ts.keepOriginalUploading }}</template> + <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> + </FormSwitch> + <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:model-value="saveProfile()"> + <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> + </FormSwitch> + <FormSwitch v-model="autoSensitive" class="_formBlock" @update:model-value="saveProfile()"> + <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import FormLink from '@/components/form/link.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import bytes from '@/filters/bytes'; +import { defaultStore } from '@/store'; +import MkChart from '@/components/MkChart.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; + +const fetching = ref(true); +const usage = ref<any>(null); +const capacity = ref<any>(null); +const uploadFolder = ref<any>(null); +let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); +let autoSensitive = $ref($i.autoSensitive); + +const meterStyle = computed(() => { + return { + width: `${usage.value / capacity.value * 100}%`, + background: tinycolor({ + h: 180 - (usage.value / capacity.value * 180), + s: 0.7, + l: 0.5, + }), + }; +}); + +const keepOriginalUploading = computed(defaultStore.makeGetterSetter('keepOriginalUploading')); + +os.api('drive').then(info => { + capacity.value = info.capacity; + usage.value = info.usage; + fetching.value = false; +}); + +if (defaultStore.state.uploadFolder) { + os.api('drive/folders/show', { + folderId: defaultStore.state.uploadFolder, + }).then(response => { + uploadFolder.value = response; + }); +} + +function chooseUploadFolder() { + os.selectDriveFolder(false).then(async folder => { + defaultStore.set('uploadFolder', folder ? folder.id : null); + os.success(); + if (defaultStore.state.uploadFolder) { + uploadFolder.value = await os.api('drive/folders/show', { + folderId: defaultStore.state.uploadFolder, + }); + } else { + uploadFolder.value = null; + } + }); +} + +function saveProfile() { + os.api('i/update', { + alwaysMarkNsfw: !!alwaysMarkNsfw, + autoSensitive: !!autoSensitive, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.drive, + icon: 'ti ti-cloud', +}); +</script> + +<style lang="scss" scoped> + +@use "sass:math"; + +.uawsfosz { + + > .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/frontend/src/pages/settings/email.vue b/packages/frontend/src/pages/settings/email.vue new file mode 100644 index 0000000000..3fff8c6b1d --- /dev/null +++ b/packages/frontend/src/pages/settings/email.vue @@ -0,0 +1,111 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts.emailAddress }}</template> + <FormInput v-model="emailAddress" type="email" manual-save> + <template #prefix><i class="ti ti-mail"></i></template> + <template v-if="$i.email && !$i.emailVerified" #caption>{{ i18n.ts.verificationEmailSent }}</template> + <template v-else-if="emailAddress === $i.email && $i.emailVerified" #caption><i class="ti ti-check" style="color: var(--success);"></i> {{ i18n.ts.emailVerified }}</template> + </FormInput> + </FormSection> + + <FormSection> + <FormSwitch :model-value="$i.receiveAnnouncementEmail" @update:model-value="onChangeReceiveAnnouncementEmail"> + {{ i18n.ts.receiveAnnouncementFromInstance }} + </FormSwitch> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.emailNotification }}</template> + <FormSwitch v-model="emailNotification_mention" class="_formBlock"> + {{ i18n.ts._notification._types.mention }} + </FormSwitch> + <FormSwitch v-model="emailNotification_reply" class="_formBlock"> + {{ i18n.ts._notification._types.reply }} + </FormSwitch> + <FormSwitch v-model="emailNotification_quote" class="_formBlock"> + {{ i18n.ts._notification._types.quote }} + </FormSwitch> + <FormSwitch v-model="emailNotification_follow" class="_formBlock"> + {{ i18n.ts._notification._types.follow }} + </FormSwitch> + <FormSwitch v-model="emailNotification_receiveFollowRequest" class="_formBlock"> + {{ i18n.ts._notification._types.receiveFollowRequest }} + </FormSwitch> + <FormSwitch v-model="emailNotification_groupInvited" class="_formBlock"> + {{ i18n.ts._notification._types.groupInvited }} + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch } from 'vue'; +import FormSection from '@/components/form/section.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const emailAddress = ref($i!.email); + +const onChangeReceiveAnnouncementEmail = (v) => { + os.api('i/update', { + receiveAnnouncementEmail: v, + }); +}; + +const saveEmailAddress = () => { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.apiWithDialog('i/update-email', { + password: password, + email: emailAddress.value, + }); + }); +}; + +const emailNotification_mention = ref($i!.emailNotificationTypes.includes('mention')); +const emailNotification_reply = ref($i!.emailNotificationTypes.includes('reply')); +const emailNotification_quote = ref($i!.emailNotificationTypes.includes('quote')); +const emailNotification_follow = ref($i!.emailNotificationTypes.includes('follow')); +const emailNotification_receiveFollowRequest = ref($i!.emailNotificationTypes.includes('receiveFollowRequest')); +const emailNotification_groupInvited = ref($i!.emailNotificationTypes.includes('groupInvited')); + +const saveNotificationSettings = () => { + os.api('i/update', { + emailNotificationTypes: [ + ...[emailNotification_mention.value ? 'mention' : null], + ...[emailNotification_reply.value ? 'reply' : null], + ...[emailNotification_quote.value ? 'quote' : null], + ...[emailNotification_follow.value ? 'follow' : null], + ...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null], + ...[emailNotification_groupInvited.value ? 'groupInvited' : null], + ].filter(x => x != null), + }); +}; + +watch([emailNotification_mention, emailNotification_reply, emailNotification_quote, emailNotification_follow, emailNotification_receiveFollowRequest, emailNotification_groupInvited], () => { + saveNotificationSettings(); +}); + +onMounted(() => { + watch(emailAddress, () => { + saveEmailAddress(); + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.email, + icon: 'ti ti-mail', +}); +</script> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue new file mode 100644 index 0000000000..84d99d2fd7 --- /dev/null +++ b/packages/frontend/src/pages/settings/general.vue @@ -0,0 +1,196 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="lang" class="_formBlock"> + <template #label>{{ i18n.ts.uiLanguage }}</template> + <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> + <template #caption> + <I18n :src="i18n.ts.i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </I18n> + </template> + </FormSelect> + + <FormRadios v-model="overridedDeviceKind" class="_formBlock"> + <template #label>{{ i18n.ts.overridedDeviceKind }}</template> + <option :value="null">{{ i18n.ts.auto }}</option> + <option value="smartphone"><i class="ti ti-device-mobile"/> {{ i18n.ts.smartphone }}</option> + <option value="tablet"><i class="ti ti-device-tablet"/> {{ i18n.ts.tablet }}</option> + <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> + </FormRadios> + + <FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ i18n.ts.showFixedPostForm }}</FormSwitch> + + <FormSection> + <template #label>{{ i18n.ts.behavior }}</template> + <FormSwitch v-model="imageNewTab" class="_formBlock">{{ i18n.ts.openImageInNewTab }}</FormSwitch> + <FormSwitch v-model="enableInfiniteScroll" class="_formBlock">{{ i18n.ts.enableInfiniteScroll }}</FormSwitch> + <FormSwitch v-model="useReactionPickerForContextMenu" class="_formBlock">{{ i18n.ts.useReactionPickerForContextMenu }}</FormSwitch> + <FormSwitch v-model="disablePagesScript" class="_formBlock">{{ i18n.ts.disablePagesScript }}</FormSwitch> + + <FormSelect v-model="serverDisconnectedBehavior" class="_formBlock"> + <template #label>{{ i18n.ts.whenServerDisconnected }}</template> + <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + </FormSelect> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.appearance }}</template> + <FormSwitch v-model="disableAnimatedMfm" class="_formBlock">{{ i18n.ts.disableAnimatedMfm }}</FormSwitch> + <FormSwitch v-model="reduceAnimation" class="_formBlock">{{ i18n.ts.reduceUiAnimation }}</FormSwitch> + <FormSwitch v-model="useBlurEffect" class="_formBlock">{{ i18n.ts.useBlurEffect }}</FormSwitch> + <FormSwitch v-model="useBlurEffectForModal" class="_formBlock">{{ i18n.ts.useBlurEffectForModal }}</FormSwitch> + <FormSwitch v-model="showGapBetweenNotesInTimeline" class="_formBlock">{{ i18n.ts.showGapBetweenNotesInTimeline }}</FormSwitch> + <FormSwitch v-model="loadRawImages" class="_formBlock">{{ i18n.ts.loadRawImages }}</FormSwitch> + <FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch> + <FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch> + <FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch> + <div class="_formBlock"> + <FormRadios v-model="emojiStyle"> + <template #label>{{ i18n.ts.emojiStyle }}</template> + <option value="native">{{ i18n.ts.native }}</option> + <option value="fluentEmoji">Fluent Emoji</option> + <option value="twemoji">Twemoji</option> + </FormRadios> + <div style="margin: 8px 0 0 0; font-size: 1.5em;"><Mfm :key="emojiStyle" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> + </div> + + <FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch> + + <FormRadios v-model="fontSize" class="_formBlock"> + <template #label>{{ i18n.ts.fontSize }}</template> + <option :value="null"><span style="font-size: 14px;">Aa</span></option> + <option value="1"><span style="font-size: 15px;">Aa</span></option> + <option value="2"><span style="font-size: 16px;">Aa</span></option> + <option value="3"><span style="font-size: 17px;">Aa</span></option> + </FormRadios> + </FormSection> + + <FormSection> + <FormSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</FormSwitch> + </FormSection> + + <FormSelect v-model="instanceTicker" class="_formBlock"> + <template #label>{{ i18n.ts.instanceTicker }}</template> + <option value="none">{{ i18n.ts._instanceTicker.none }}</option> + <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> + <option value="always">{{ i18n.ts._instanceTicker.always }}</option> + </FormSelect> + + <FormSelect v-model="nsfw" class="_formBlock"> + <template #label>{{ i18n.ts.nsfw }}</template> + <option value="respect">{{ i18n.ts._nsfw.respect }}</option> + <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> + <option value="force">{{ i18n.ts._nsfw.force }}</option> + </FormSelect> + + <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing class="_formBlock"> + <template #label>{{ i18n.ts.numberOfPageCache }}</template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </FormRange> + + <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink> + + <FormLink to="/settings/custom-css" class="_formBlock"><template #icon><i class="ti ti-code"></i></template>{{ i18n.ts.customCss }}</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormRange from '@/components/form/range.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import MkLink from '@/components/MkLink.vue'; +import { langs } from '@/config'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const lang = ref(localStorage.getItem('lang')); +const fontSize = ref(localStorage.getItem('fontSize')); +const useSystemFont = ref(localStorage.getItem('useSystemFont') != null); + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +const overridedDeviceKind = computed(defaultStore.makeGetterSetter('overridedDeviceKind')); +const serverDisconnectedBehavior = computed(defaultStore.makeGetterSetter('serverDisconnectedBehavior')); +const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => !v, v => !v)); +const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); +const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); +const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); +const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v)); +const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); +const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); +const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); +const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); +const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); +const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); +const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript')); +const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); +const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); +const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); +const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); +const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); +const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); +const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode')); + +watch(lang, () => { + localStorage.setItem('lang', lang.value as string); + localStorage.removeItem('locale'); +}); + +watch(fontSize, () => { + if (fontSize.value == null) { + localStorage.removeItem('fontSize'); + } else { + localStorage.setItem('fontSize', fontSize.value); + } +}); + +watch(useSystemFont, () => { + if (useSystemFont.value) { + localStorage.setItem('useSystemFont', 't'); + } else { + localStorage.removeItem('useSystemFont'); + } +}); + +watch([ + lang, + fontSize, + useSystemFont, + enableInfiniteScroll, + squareAvatars, + aiChanMode, + showGapBetweenNotesInTimeline, + instanceTicker, + overridedDeviceKind, +], async () => { + await reloadAsk(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'ti ti-adjustments', +}); +</script> diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue new file mode 100644 index 0000000000..7db267c142 --- /dev/null +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -0,0 +1,165 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.allNotes }}</template> + <FormFolder> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.followingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <FormSwitch v-model="excludeMutingUsers" class="_formBlock"> + {{ i18n.ts._exportOrImport.excludeMutingUsers }} + </FormSwitch> + <FormSwitch v-model="excludeInactiveUsers" class="_formBlock"> + {{ i18n.ts._exportOrImport.excludeInactiveUsers }} + </FormSwitch> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.userLists }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.muteList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._exportOrImport.blockingList }}</template> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </FormFolder> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { selectFile } from '@/scripts/select-file'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const excludeMutingUsers = ref(false); +const excludeInactiveUsers = ref(false); + +const onExportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.exportRequested, + }); +}; + +const onImportSuccess = () => { + os.alert({ + type: 'info', + text: i18n.ts.importRequested, + }); +}; + +const onError = (ev) => { + os.alert({ + type: 'error', + text: ev.message, + }); +}; + +const exportNotes = () => { + os.api('i/export-notes', {}).then(onExportSuccess).catch(onError); +}; + +const exportFollowing = () => { + os.api('i/export-following', { + excludeMuting: excludeMutingUsers.value, + excludeInactive: excludeInactiveUsers.value, + }) + .then(onExportSuccess).catch(onError); +}; + +const exportBlocking = () => { + os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError); +}; + +const exportUserLists = () => { + os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError); +}; + +const exportMuting = () => { + os.api('i/export-mute', {}).then(onExportSuccess).catch(onError); +}; + +const importFollowing = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importUserLists = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importMuting = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const importBlocking = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.importAndExport, + icon: 'ti ti-package', +}); +</script> + +<style module> +.button { + margin-right: 16px; +} +</style> diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue new file mode 100644 index 0000000000..01436cd554 --- /dev/null +++ b/packages/frontend/src/pages/settings/index.vue @@ -0,0 +1,291 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> + <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> + <div class="body"> + <div v-if="!narrow || currentPage?.route.name == null" class="nav"> + <div class="baaadecd"> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu> + </div> + </div> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <div class="bkzroven"> + <RouterView/> + </div> + </div> + </div> + </div> + </MkSpacer> +</mkstickycontainer> +</template> + +<script setup lang="ts"> +import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue'; +import { i18n } from '@/i18n'; +import MkInfo from '@/components/MkInfo.vue'; +import MkSuperMenu from '@/components/MkSuperMenu.vue'; +import { scroll } from '@/scripts/scroll'; +import { signout, $i } from '@/account'; +import { unisonReload } from '@/scripts/unison-reload'; +import { instance } from '@/instance'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import * as os from '@/os'; + +const indexInfo = { + title: i18n.ts.settings, + icon: 'ti ti-settings', + hideHeader: true, +}; +const INFO = ref(indexInfo); +const el = ref<HTMLElement | null>(null); +const childInfo = ref(null); + +const router = useRouter(); + +let narrow = $ref(false); +const NARROW_THRESHOLD = 600; + +let currentPage = $computed(() => router.currentRef.value.child); + +const ro = new ResizeObserver((entries, observer) => { + if (entries.length === 0) return; + narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD; +}); + +const menuDef = computed(() => [{ + title: i18n.ts.basicSettings, + items: [{ + icon: 'ti ti-user', + text: i18n.ts.profile, + to: '/settings/profile', + active: currentPage?.route.name === 'profile', + }, { + icon: 'ti ti-lock-open', + text: i18n.ts.privacy, + to: '/settings/privacy', + active: currentPage?.route.name === 'privacy', + }, { + icon: 'ti ti-mood-happy', + text: i18n.ts.reaction, + to: '/settings/reaction', + active: currentPage?.route.name === 'reaction', + }, { + icon: 'ti ti-cloud', + text: i18n.ts.drive, + to: '/settings/drive', + active: currentPage?.route.name === 'drive', + }, { + icon: 'ti ti-bell', + text: i18n.ts.notifications, + to: '/settings/notifications', + active: currentPage?.route.name === 'notifications', + }, { + icon: 'ti ti-mail', + text: i18n.ts.email, + to: '/settings/email', + active: currentPage?.route.name === 'email', + }, { + icon: 'ti ti-share', + text: i18n.ts.integration, + to: '/settings/integration', + active: currentPage?.route.name === 'integration', + }, { + icon: 'ti ti-lock', + text: i18n.ts.security, + to: '/settings/security', + active: currentPage?.route.name === 'security', + }], +}, { + title: i18n.ts.clientSettings, + items: [{ + icon: 'ti ti-adjustments', + text: i18n.ts.general, + to: '/settings/general', + active: currentPage?.route.name === 'general', + }, { + icon: 'ti ti-palette', + text: i18n.ts.theme, + to: '/settings/theme', + active: currentPage?.route.name === 'theme', + }, { + icon: 'ti ti-menu-2', + text: i18n.ts.navbar, + to: '/settings/navbar', + active: currentPage?.route.name === 'navbar', + }, { + icon: 'ti ti-equal-double', + text: i18n.ts.statusbar, + to: '/settings/statusbar', + active: currentPage?.route.name === 'statusbar', + }, { + icon: 'ti ti-music', + text: i18n.ts.sounds, + to: '/settings/sounds', + active: currentPage?.route.name === 'sounds', + }, { + icon: 'ti ti-plug', + text: i18n.ts.plugins, + to: '/settings/plugin', + active: currentPage?.route.name === 'plugin', + }], +}, { + title: i18n.ts.otherSettings, + items: [{ + icon: 'ti ti-package', + text: i18n.ts.importAndExport, + to: '/settings/import-export', + active: currentPage?.route.name === 'import-export', + }, { + icon: 'ti ti-planet-off', + text: i18n.ts.instanceMute, + to: '/settings/instance-mute', + active: currentPage?.route.name === 'instance-mute', + }, { + icon: 'ti ti-ban', + text: i18n.ts.muteAndBlock, + to: '/settings/mute-block', + active: currentPage?.route.name === 'mute-block', + }, { + icon: 'ti ti-message-off', + text: i18n.ts.wordMute, + to: '/settings/word-mute', + active: currentPage?.route.name === 'word-mute', + }, { + icon: 'ti ti-api', + text: 'API', + to: '/settings/api', + active: currentPage?.route.name === 'api', + }, { + icon: 'ti ti-webhook', + text: 'Webhook', + to: '/settings/webhook', + active: currentPage?.route.name === 'webhook', + }, { + icon: 'ti ti-dots', + text: i18n.ts.other, + to: '/settings/other', + active: currentPage?.route.name === 'other', + }], +}, { + items: [{ + icon: 'ti ti-device-floppy', + text: i18n.ts.preferencesBackups, + to: '/settings/preferences-backups', + active: currentPage?.route.name === 'preferences-backups', + }, { + type: 'button', + icon: 'ti ti-trash', + text: i18n.ts.clearCache, + action: () => { + localStorage.removeItem('locale'); + localStorage.removeItem('theme'); + unisonReload(); + }, + }, { + type: 'button', + icon: 'ti ti-power', + text: i18n.ts.logout, + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.logoutConfirm, + }); + if (canceled) return; + signout(); + }, + danger: true, + }], +}]); + +watch($$(narrow), () => { +}); + +onMounted(() => { + ro.observe(el.value); + + narrow = el.value.offsetWidth < NARROW_THRESHOLD; + + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); + } +}); + +onActivated(() => { + narrow = el.value.offsetWidth < NARROW_THRESHOLD; + + if (!narrow && currentPage?.route.name == null) { + router.replace('/settings/profile'); + } +}); + +onUnmounted(() => { + ro.disconnect(); +}); + +const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); + +provideMetadataReceiver((info) => { + if (info == null) { + childInfo.value = null; + } else { + childInfo.value = info; + } +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); +// w 890 +// h 700 +</script> + +<style lang="scss" scoped> +.vvcocwet { + > .body { + > .nav { + .baaadecd { + > .info { + margin: 16px 0; + } + + > .accounts { + > .avatar { + display: block; + width: 50px; + height: 50px; + margin: 8px auto 16px auto; + } + } + } + } + + > .main { + .bkzroven { + } + } + } + + &.wide { + > .body { + display: flex; + height: 100%; + + > .nav { + width: 34%; + padding-right: 32px; + box-sizing: border-box; + } + + > .main { + flex: 1; + min-width: 0; + } + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/instance-mute.vue b/packages/frontend/src/pages/settings/instance-mute.vue new file mode 100644 index 0000000000..54504de188 --- /dev/null +++ b/packages/frontend/src/pages/settings/instance-mute.vue @@ -0,0 +1,53 @@ +<template> +<div class="_formRoot"> + <MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo> + <FormTextarea v-model="instanceMutes" class="_formBlock"> + <template #label>{{ i18n.ts._instanceMute.heading }}</template> + <template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template> + </FormTextarea> + <MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const instanceMutes = ref($i!.mutedInstances.join('\n')); +const changed = ref(false); + +async function save() { + let mutes = instanceMutes.value + .trim().split('\n') + .map(el => el.trim()) + .filter(el => el); + + await os.api('i/update', { + mutedInstances: mutes, + }); + + changed.value = false; + + // Refresh filtered list to signal to the user how they've been saved + instanceMutes.value = mutes.join('\n'); +} + +watch(instanceMutes, () => { + changed.value = true; +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceMute, + icon: 'ti ti-planet-off', +}); +</script> diff --git a/packages/frontend/src/pages/settings/integration.vue b/packages/frontend/src/pages/settings/integration.vue new file mode 100644 index 0000000000..557fe778e6 --- /dev/null +++ b/packages/frontend/src/pages/settings/integration.vue @@ -0,0 +1,99 @@ +<template> +<div class="_formRoot"> + <FormSection v-if="instance.enableTwitterIntegration"> + <template #label><i class="ti ti-brand-twitter"></i> Twitter</template> + <p v-if="integrations.twitter">{{ i18n.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" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton> + </FormSection> + + <FormSection v-if="instance.enableDiscordIntegration"> + <template #label><i class="ti ti-brand-discord"></i> Discord</template> + <p v-if="integrations.discord">{{ i18n.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" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton> + </FormSection> + + <FormSection v-if="instance.enableGithubIntegration"> + <template #label><i class="ti ti-brand-github"></i> GitHub</template> + <p v-if="integrations.github">{{ i18n.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" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton> + <MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { apiUrl } from '@/config'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const twitterForm = ref<Window | null>(null); +const discordForm = ref<Window | null>(null); +const githubForm = ref<Window | null>(null); + +const integrations = computed(() => $i!.integrations); + +function openWindow(service: string, type: string) { + return window.open(`${apiUrl}/${type}/${service}`, + `${service}_${type}_window`, + 'height=570, width=520', + ); +} + +function connectTwitter() { + twitterForm.value = openWindow('twitter', 'connect'); +} + +function disconnectTwitter() { + openWindow('twitter', 'disconnect'); +} + +function connectDiscord() { + discordForm.value = openWindow('discord', 'connect'); +} + +function disconnectDiscord() { + openWindow('discord', 'disconnect'); +} + +function connectGithub() { + githubForm.value = openWindow('github', 'connect'); +} + +function disconnectGithub() { + openWindow('github', 'disconnect'); +} + +onMounted(() => { + document.cookie = `igi=${$i!.token}; path=/;` + + ' max-age=31536000;' + + (document.location.protocol.startsWith('https') ? ' secure' : ''); + + watch(integrations, () => { + if (integrations.value.twitter) { + if (twitterForm.value) twitterForm.value.close(); + } + if (integrations.value.discord) { + if (discordForm.value) discordForm.value.close(); + } + if (integrations.value.github) { + if (githubForm.value) githubForm.value.close(); + } + }); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'ti ti-share', +}); +</script> diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue new file mode 100644 index 0000000000..1cf33d34db --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -0,0 +1,61 @@ +<template> +<div class="_formRoot"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="mute">{{ i18n.ts.mutedUsers }}</option> + <option value="block">{{ i18n.ts.blockedUsers }}</option> + </MkTab> + <div v-if="tab === 'mute'"> + <MkPagination :pagination="mutingPagination" class="muting"> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> + <MkAcct :user="mute.mutee"/> + </FormLink> + </template> + </MkPagination> + </div> + <div v-if="tab === 'block'"> + <MkPagination :pagination="blockingPagination" class="blocking"> + <template #empty><FormInfo>{{ i18n.ts.noUsers }}</FormInfo></template> + <template #default="{items}"> + <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> + <MkAcct :user="block.blockee"/> + </FormLink> + </template> + </MkPagination> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkTab from '@/components/MkTab.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import FormLink from '@/components/form/link.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let tab = $ref('mute'); + +const mutingPagination = { + endpoint: 'mute/list' as const, + limit: 10, +}; + +const blockingPagination = { + endpoint: 'blocking/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.muteAndBlock, + icon: 'ti ti-ban', +}); +</script> diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue new file mode 100644 index 0000000000..0b2776ec90 --- /dev/null +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -0,0 +1,87 @@ +<template> +<div class="_formRoot"> + <FormTextarea v-model="items" tall manual-save class="_formBlock"> + <template #label>{{ i18n.ts.navbar }}</template> + <template #caption><button class="_textButton" @click="addItem">{{ i18n.ts.addItem }}</button></template> + </FormTextarea> + + <FormRadios v-model="menuDisplay" class="_formBlock"> + <template #label>{{ i18n.ts.display }}</template> + <option value="sideFull">{{ i18n.ts._menuDisplay.sideFull }}</option> + <option value="sideIcon">{{ i18n.ts._menuDisplay.sideIcon }}</option> + <option value="top">{{ i18n.ts._menuDisplay.top }}</option> + <!-- <MkRadio v-model="menuDisplay" value="hide" disabled>{{ i18n.ts._menuDisplay.hide }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </FormRadios> + + <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { navbarItemDef } from '@/navbar'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const items = ref(defaultStore.state.menu.join('\n')); + +const split = computed(() => items.value.trim().split('\n').filter(x => x.trim() !== '')); +const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +async function addItem() { + const menu = Object.keys(navbarItemDef).filter(k => !defaultStore.state.menu.includes(k)); + const { canceled, result: item } = await os.select({ + title: i18n.ts.addItem, + items: [...menu.map(k => ({ + value: k, text: i18n.ts[navbarItemDef[k].title], + })), { + value: '-', text: i18n.ts.divider, + }], + }); + if (canceled) return; + items.value = [...split.value, item].join('\n'); +} + +async function save() { + defaultStore.set('menu', split.value); + await reloadAsk(); +} + +function reset() { + defaultStore.reset('menu'); + items.value = defaultStore.state.menu.join('\n'); +} + +watch(items, async () => { + await save(); +}); + +watch(menuDisplay, async () => { + await reloadAsk(); +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.navbar, + icon: 'ti ti-list', +}); +</script> diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue new file mode 100644 index 0000000000..e85fede157 --- /dev/null +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -0,0 +1,90 @@ +<template> +<div class="_formRoot"> + <FormLink class="_formBlock" @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink> + <FormSection> + <FormLink class="_formBlock" @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> + <FormLink class="_formBlock" @click="readAllUnreadNotes">{{ i18n.ts.markAsReadAllUnreadNotes }}</FormLink> + <FormLink class="_formBlock" @click="readAllMessagingMessages">{{ i18n.ts.markAsReadAllTalkMessages }}</FormLink> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts.pushNotification }}</template> + <MkPushNotificationAllowButton ref="allowButton" /> + <FormSwitch class="_formBlock" :disabled="!pushRegistrationInServer" :model-value="sendReadMessage" @update:model-value="onChangeSendReadMessage"> + <template #label>{{ i18n.ts.sendPushNotificationReadMessage }}</template> + <template #caption> + <I18n :src="i18n.ts.sendPushNotificationReadMessageCaption"> + <template #emptyPushNotificationMessage>{{ i18n.ts._notification.emptyPushNotificationMessage }}</template> + </I18n> + </template> + </FormSwitch> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import FormButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; + +let allowButton = $ref<InstanceType<typeof MkPushNotificationAllowButton>>(); +let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); +let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false); + +async function readAllUnreadNotes() { + await os.api('i/read-all-unread-notes'); +} + +async function readAllMessagingMessages() { + await os.api('i/read-all-messaging-messages'); +} + +async function readAllNotifications() { + await os.api('notifications/mark-all-as-read'); +} + +function configure() { + const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x)); + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.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 => { + $i!.mutingNotificationTypes = i.mutingNotificationTypes; + }); + }, + }, 'closed'); +} + +function onChangeSendReadMessage(v: boolean) { + if (!pushRegistrationInServer) return; + + os.apiWithDialog('sw/update-registration', { + endpoint: pushRegistrationInServer.endpoint, + sendReadMessage: v, + }).then(res => { + if (!allowButton) return; + allowButton.pushRegistrationInServer = res; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notifications, + icon: 'ti ti-bell', +}); +</script> diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue new file mode 100644 index 0000000000..40bb202789 --- /dev/null +++ b/packages/frontend/src/pages/settings/other.vue @@ -0,0 +1,47 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="$i.injectFeaturedNote" class="_formBlock" @update:model-value="onChangeInjectFeaturedNote"> + {{ i18n.ts.showFeaturedNotesInTimeline }} + </FormSwitch> + + <!-- + <FormSwitch v-model="reportError" class="_formBlock">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></FormSwitch> + --> + + <FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink> + + <FormLink to="/registry" class="_formBlock"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> + + <FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormLink from '@/components/form/link.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const reportError = computed(defaultStore.makeGetterSetter('reportError')); + +function onChangeInjectFeaturedNote(v) { + os.api('i/update', { + injectFeaturedNote: v, + }).then((i) => { + $i!.injectFeaturedNote = i.injectFeaturedNote; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'ti ti-dots', +}); +</script> diff --git a/packages/frontend/src/pages/settings/plugin.install.vue b/packages/frontend/src/pages/settings/plugin.install.vue new file mode 100644 index 0000000000..550bba242e --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.install.vue @@ -0,0 +1,124 @@ +<template> +<div class="_formRoot"> + <FormInfo warn class="_formBlock">{{ i18n.ts._plugin.installWarn }}</FormInfo> + + <FormTextarea v-model="code" tall class="_formBlock"> + <template #label>{{ i18n.ts.code }}</template> + </FormTextarea> + + <div class="_formBlock"> + <FormButton :disabled="code == null" primary inline @click="install"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, nextTick, ref } 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 FormButton from '@/components/MkButton.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const code = ref(null); + +function installPlugin({ id, meta, ast, token }) { + ColdDeviceStorage.set('plugins', ColdDeviceStorage.get('plugins').concat({ + ...meta, + id, + active: true, + configData: {}, + token: token, + ast: ast, + })); +} + +async function install() { + let ast; + try { + ast = parse(code.value); + } catch (err) { + os.alert({ + type: 'error', + text: 'Syntax error :(', + }); + return; + } + + const meta = AiScript.collectMetadata(ast); + if (meta == null) { + os.alert({ + type: 'error', + text: 'No metadata found :(', + }); + return; + } + + const metadata = meta.get(null); + if (metadata == null) { + os.alert({ + type: 'error', + text: 'No metadata found :(', + }); + return; + } + + const { name, version, author, description, permissions, config } = metadata; + if (name == null || version == null || author == null) { + os.alert({ + type: 'error', + text: 'Required property not found :(', + }); + return; + } + + const token = permissions == null || permissions.length === 0 ? null : await new Promise((res, rej) => { + os.popup(defineAsyncComponent(() => import('@/components/MkTokenGenerateWindow.vue')), { + title: i18n.ts.tokenRequested, + information: i18n.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'); + }); + + installPlugin({ + id: uuid(), + meta: { + name, version, author, description, permissions, config, + }, + token, + ast: serialize(ast), + }); + + os.success(); + + nextTick(() => { + unisonReload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._plugin.install, + icon: 'ti ti-download', +}); +</script> diff --git a/packages/frontend/src/pages/settings/plugin.vue b/packages/frontend/src/pages/settings/plugin.vue new file mode 100644 index 0000000000..905efd833d --- /dev/null +++ b/packages/frontend/src/pages/settings/plugin.vue @@ -0,0 +1,98 @@ +<template> +<div class="_formRoot"> + <FormLink to="/settings/plugin/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._plugin.install }}</FormLink> + + <FormSection> + <template #label>{{ i18n.ts.manage }}</template> + <div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;"> + <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> + + <FormSwitch class="_formBlock" :model-value="plugin.active" @update:model-value="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch> + + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.author }}</template> + <template #value>{{ plugin.author }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.description }}</template> + <template #value>{{ plugin.description }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ i18n.ts.permission }}</template> + <template #value>{{ plugin.permission }}</template> + </MkKeyValue> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton v-if="plugin.config" inline @click="config(plugin)"><i class="ti ti-settings"></i> {{ i18n.ts.settings }}</MkButton> + <MkButton inline danger @click="uninstall(plugin)"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</MkButton> + </div> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, ref } from 'vue'; +import FormLink from '@/components/form/link.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const plugins = ref(ColdDeviceStorage.get('plugins')); + +function uninstall(plugin) { + ColdDeviceStorage.set('plugins', plugins.value.filter(x => x.id !== plugin.id)); + os.success(); + nextTick(() => { + unisonReload(); + }); +} + +// TODO: この処理をstore側にactionとして移動し、設定画面を開くAiScriptAPIを実装できるようにする +async function 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 coldPlugins = ColdDeviceStorage.get('plugins'); + coldPlugins.find(p => p.id === plugin.id)!.configData = result; + ColdDeviceStorage.set('plugins', coldPlugins); + + nextTick(() => { + location.reload(); + }); +} + +function changeActive(plugin, active) { + const coldPlugins = ColdDeviceStorage.get('plugins'); + coldPlugins.find(p => p.id === plugin.id)!.active = active; + ColdDeviceStorage.set('plugins', coldPlugins); + + nextTick(() => { + location.reload(); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.plugins, + icon: 'ti ti-plug', +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue new file mode 100644 index 0000000000..f427a170c4 --- /dev/null +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -0,0 +1,444 @@ +<template> +<div class="_formRoot"> + <div :class="$style.buttons"> + <MkButton inline primary @click="saveNew">{{ ts._preferencesBackups.saveNew }}</MkButton> + <MkButton inline @click="loadFile">{{ ts._preferencesBackups.loadFile }}</MkButton> + </div> + + <FormSection> + <template #label>{{ ts._preferencesBackups.list }}</template> + <template v-if="profiles && Object.keys(profiles).length > 0"> + <div + v-for="(profile, id) in profiles" + :key="id" + class="_formBlock _panel" + :class="$style.profile" + @click="$event => menu($event, id)" + @contextmenu.prevent.stop="$event => menu($event, id)" + > + <div :class="$style.profileName">{{ profile.name }}</div> + <div :class="$style.profileTime">{{ t('_preferencesBackups.createdAt', { date: (new Date(profile.createdAt)).toLocaleDateString(), time: (new Date(profile.createdAt)).toLocaleTimeString() }) }}</div> + <div v-if="profile.updatedAt" :class="$style.profileTime">{{ t('_preferencesBackups.updatedAt', { date: (new Date(profile.updatedAt)).toLocaleDateString(), time: (new Date(profile.updatedAt)).toLocaleTimeString() }) }}</div> + </div> + </template> + <div v-else-if="profiles"> + <MkInfo>{{ ts._preferencesBackups.noBackups }}</MkInfo> + </div> + <MkLoading v-else/> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, useCssModule } from 'vue'; +import { v4 as uuid } from 'uuid'; +import FormSection from '@/components/form/section.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { stream } from '@/stream'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { version, host } from '@/config'; +import { definePageMetadata } from '@/scripts/page-metadata'; +const { t, ts } = i18n; + +useCssModule(); + +const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ + 'menu', + 'visibility', + 'localOnly', + 'statusbars', + 'widgets', + 'tl', + 'overridedDeviceKind', + 'serverDisconnectedBehavior', + 'nsfw', + 'animation', + 'animatedMfm', + 'loadRawImages', + 'imageNewTab', + 'disableShowingAnimatedImages', + 'disablePagesScript', + 'emojiStyle', + 'disableDrawer', + 'useBlurEffectForModal', + 'useBlurEffect', + 'showFixedPostForm', + 'enableInfiniteScroll', + 'useReactionPickerForContextMenu', + 'showGapBetweenNotesInTimeline', + 'instanceTicker', + 'reactionPickerSize', + 'reactionPickerWidth', + 'reactionPickerHeight', + 'reactionPickerUseDrawerForMobile', + 'defaultSideView', + 'menuDisplay', + 'reportError', + 'squareAvatars', + 'numberOfPageCache', + 'aiChanMode', +]; +const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ + 'lightTheme', + 'darkTheme', + 'syncDeviceDarkMode', + 'plugins', + 'mediaVolume', + 'sound_masterVolume', + 'sound_note', + 'sound_noteMy', + 'sound_notification', + 'sound_chat', + 'sound_chatBg', + 'sound_antenna', + 'sound_channel', +]; + +const scope = ['clientPreferencesProfiles']; + +const profileProps = ['name', 'createdAt', 'updatedAt', 'misskeyVersion', 'settings']; + +type Profile = { + name: string; + createdAt: string; + updatedAt: string | null; + misskeyVersion: string; + host: string; + settings: { + hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; + cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + fontSize: string | null; + useSystemFont: 't' | null; + wallpaper: string | null; + }; +}; + +const connection = $i && stream.useChannel('main'); + +let profiles = $ref<Record<string, Profile> | null>(null); + +os.api('i/registry/get-all', { scope }) + .then(res => { + profiles = res || {}; + }); + +function isObject(value: unknown): value is Record<string, unknown> { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function validate(profile: unknown): void { + if (!isObject(profile)) throw new Error('not an object'); + + // Check if unnecessary properties exist + if (Object.keys(profile).some(key => !profileProps.includes(key))) throw new Error('Unnecessary properties exist'); + + if (!profile.name) throw new Error('Missing required prop: name'); + if (!profile.misskeyVersion) throw new Error('Missing required prop: misskeyVersion'); + + // Check if createdAt and updatedAt is Date + // https://zenn.dev/lollipop_onl/articles/eoz-judge-js-invalid-date + if (!profile.createdAt || Number.isNaN(new Date(profile.createdAt).getTime())) throw new Error('createdAt is falsy or not Date'); + if (profile.updatedAt) { + if (Number.isNaN(new Date(profile.updatedAt).getTime())) { + throw new Error('updatedAt is not Date'); + } + } else if (profile.updatedAt !== null) { + throw new Error('updatedAt is not null'); + } + + if (!profile.settings) throw new Error('Missing required prop: settings'); + if (!isObject(profile.settings)) throw new Error('Invalid prop: settings'); +} + +function getSettings(): Profile['settings'] { + const hot = {} as Record<keyof typeof defaultStoreSaveKeys, unknown>; + for (const key of defaultStoreSaveKeys) { + hot[key] = defaultStore.state[key]; + } + + const cold = {} as Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; + for (const key of coldDeviceStorageSaveKeys) { + cold[key] = ColdDeviceStorage.get(key); + } + + return { + hot, + cold, + fontSize: localStorage.getItem('fontSize'), + useSystemFont: localStorage.getItem('useSystemFont') as 't' | null, + wallpaper: localStorage.getItem('wallpaper'), + }; +} + +async function saveNew(): Promise<void> { + if (!profiles) return; + + const { canceled, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (canceled) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const id = uuid(); + const profile: Profile = { + name, + createdAt: (new Date()).toISOString(), + updatedAt: null, + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +function loadFile(): void { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = false; + input.onchange = async () => { + if (!profiles) return; + if (!input.files || input.files.length === 0) return; + + const file = input.files[0]; + + if (file.type !== 'application/json') { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: ts._preferencesBackups.invalidFile, + }); + } + + let profile: Profile; + try { + profile = JSON.parse(await file.text()) as unknown as Profile; + validate(profile); + } catch (err) { + return os.alert({ + type: 'error', + title: ts._preferencesBackups.cannotLoad, + text: err?.message, + }); + } + + const id = uuid(); + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); +} + +async function applyProfile(id: string): Promise<void> { + if (!profiles) return; + + const profile = profiles[id]; + + const { canceled: cancel1 } = await os.confirm({ + type: 'warning', + title: ts._preferencesBackups.apply, + text: t('_preferencesBackups.applyConfirm', { name: profile.name }), + }); + if (cancel1) return; + + // TODO: バージョン or ホストが違ったらさらに警告を表示 + + const settings = profile.settings; + + // defaultStore + for (const key of defaultStoreSaveKeys) { + if (settings.hot[key] !== undefined) { + defaultStore.set(key, settings.hot[key]); + } + } + + // coldDeviceStorage + for (const key of coldDeviceStorageSaveKeys) { + if (settings.cold[key] !== undefined) { + ColdDeviceStorage.set(key, settings.cold[key]); + } + } + + // fontSize + if (settings.fontSize) { + localStorage.setItem('fontSize', settings.fontSize); + } else { + localStorage.removeItem('fontSize'); + } + + // useSystemFont + if (settings.useSystemFont) { + localStorage.setItem('useSystemFont', settings.useSystemFont); + } else { + localStorage.removeItem('useSystemFont'); + } + + // wallpaper + if (settings.wallpaper != null) { + localStorage.setItem('wallpaper', settings.wallpaper); + } else { + localStorage.removeItem('wallpaper'); + } + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + text: ts.reloadToApplySetting, + }); + if (cancel2) return; + + unisonReload(); +} + +async function deleteProfile(id: string): Promise<void> { + if (!profiles) return; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts.delete, + text: t('deleteAreYouSure', { x: profiles[id].name }), + }); + if (canceled) return; + + await os.apiWithDialog('i/registry/remove', { scope, key: id }); + delete profiles[id]; +} + +async function save(id: string): Promise<void> { + if (!profiles) return; + + const { name, createdAt } = profiles[id]; + + const { canceled } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.save, + text: t('_preferencesBackups.saveConfirm', { name }), + }); + if (canceled) return; + + const profile: Profile = { + name, + createdAt, + updatedAt: (new Date()).toISOString(), + misskeyVersion: version, + host, + settings: getSettings(), + }; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: profile }); +} + +async function rename(id: string): Promise<void> { + if (!profiles) return; + + const { canceled: cancel1, result: name } = await os.inputText({ + title: ts._preferencesBackups.inputName, + }); + if (cancel1 || profiles[id].name === name) return; + + if (Object.values(profiles).some(x => x.name === name)) { + return os.alert({ + title: ts._preferencesBackups.cannotSave, + text: t('_preferencesBackups.nameAlreadyExists', { name }), + }); + } + + const registry = Object.assign({}, { ...profiles[id] }); + + const { canceled: cancel2 } = await os.confirm({ + type: 'info', + title: ts._preferencesBackups.rename, + text: t('_preferencesBackups.renameConfirm', { old: registry.name, new: name }), + }); + if (cancel2) return; + + registry.name = name; + await os.apiWithDialog('i/registry/set', { scope, key: id, value: registry }); +} + +function menu(ev: MouseEvent, profileId: string) { + if (!profiles) return; + + return os.popupMenu([{ + text: ts._preferencesBackups.apply, + icon: 'ti ti-check', + action: () => applyProfile(profileId), + }, { + type: 'a', + text: ts.download, + icon: 'ti ti-download', + href: URL.createObjectURL(new Blob([JSON.stringify(profiles[profileId], null, 2)], { type: 'application/json' })), + download: `${profiles[profileId].name}.json`, + }, null, { + text: ts.rename, + icon: 'ti ti-forms', + action: () => rename(profileId), + }, { + text: ts._preferencesBackups.save, + icon: 'ti ti-device-floppy', + action: () => save(profileId), + }, null, { + text: ts._preferencesBackups.delete, + icon: 'ti ti-trash', + action: () => deleteProfile(profileId), + danger: true, + }], ev.currentTarget ?? ev.target); +} + +onMounted(() => { + // streamingのuser storage updateイベントを監視して更新 + connection?.on('registryUpdated', ({ scope: recievedScope, key, value }) => { + if (!recievedScope || recievedScope.length !== scope.length || recievedScope[0] !== scope[0]) return; + if (!profiles) return; + + profiles[key] = value; + }); +}); + +onUnmounted(() => { + connection?.off('registryUpdated'); +}); + +definePageMetadata(computed(() => ({ + title: ts.preferencesBackups, + icon: 'ti ti-device-floppy', + bg: 'var(--bg)', +}))); +</script> + +<style lang="scss" module> +.buttons { + display: flex; + gap: var(--margin); + flex-wrap: wrap; +} + +.profile { + padding: 20px; + cursor: pointer; + + &Name { + font-weight: 700; + } + + &Time { + font-size: .85em; + opacity: .7; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue new file mode 100644 index 0000000000..915ca05767 --- /dev/null +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -0,0 +1,100 @@ +<template> +<div class="_formRoot"> + <FormSwitch v-model="isLocked" class="_formBlock" @update:model-value="save()">{{ i18n.ts.makeFollowManuallyApprove }}<template #caption>{{ i18n.ts.lockedAccountInfo }}</template></FormSwitch> + <FormSwitch v-if="isLocked" v-model="autoAcceptFollowed" class="_formBlock" @update:model-value="save()">{{ i18n.ts.autoAcceptFollowed }}</FormSwitch> + + <FormSwitch v-model="publicReactions" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.makeReactionsPublic }} + <template #caption>{{ i18n.ts.makeReactionsPublicDescription }}</template> + </FormSwitch> + + <FormSelect v-model="ffVisibility" class="_formBlock" @update:model-value="save()"> + <template #label>{{ i18n.ts.ffVisibility }}</template> + <option value="public">{{ i18n.ts._ffVisibility.public }}</option> + <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> + <option value="private">{{ i18n.ts._ffVisibility.private }}</option> + <template #caption>{{ i18n.ts.ffVisibilityDescription }}</template> + </FormSelect> + + <FormSwitch v-model="hideOnlineStatus" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.hideOnlineStatus }} + <template #caption>{{ i18n.ts.hideOnlineStatusDescription }}</template> + </FormSwitch> + <FormSwitch v-model="noCrawle" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.noCrawle }} + <template #caption>{{ i18n.ts.noCrawleDescription }}</template> + </FormSwitch> + <FormSwitch v-model="isExplorable" class="_formBlock" @update:model-value="save()"> + {{ i18n.ts.makeExplorable }} + <template #caption>{{ i18n.ts.makeExplorableDescription }}</template> + </FormSwitch> + + <FormSection> + <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:model-value="save()">{{ i18n.ts.rememberNoteVisibility }}</FormSwitch> + <FormFolder v-if="!rememberNoteVisibility" class="_formBlock"> + <template #label>{{ i18n.ts.defaultNoteVisibility }}</template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ i18n.ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ i18n.ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ i18n.ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ i18n.ts._visibility.specified }}</template> + + <FormSelect v-model="defaultNoteVisibility" class="_formBlock"> + <option value="public">{{ i18n.ts._visibility.public }}</option> + <option value="home">{{ i18n.ts._visibility.home }}</option> + <option value="followers">{{ i18n.ts._visibility.followers }}</option> + <option value="specified">{{ i18n.ts._visibility.specified }}</option> + </FormSelect> + <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ i18n.ts._visibility.localOnly }}</FormSwitch> + </FormFolder> + </FormSection> + + <FormSwitch v-model="keepCw" class="_formBlock" @update:model-value="save()">{{ i18n.ts.keepCw }}</FormSwitch> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let isLocked = $ref($i.isLocked); +let autoAcceptFollowed = $ref($i.autoAcceptFollowed); +let noCrawle = $ref($i.noCrawle); +let isExplorable = $ref($i.isExplorable); +let hideOnlineStatus = $ref($i.hideOnlineStatus); +let publicReactions = $ref($i.publicReactions); +let ffVisibility = $ref($i.ffVisibility); + +let defaultNoteVisibility = $computed(defaultStore.makeGetterSetter('defaultNoteVisibility')); +let defaultNoteLocalOnly = $computed(defaultStore.makeGetterSetter('defaultNoteLocalOnly')); +let rememberNoteVisibility = $computed(defaultStore.makeGetterSetter('rememberNoteVisibility')); +let keepCw = $computed(defaultStore.makeGetterSetter('keepCw')); + +function save() { + os.api('i/update', { + isLocked: !!isLocked, + autoAcceptFollowed: !!autoAcceptFollowed, + noCrawle: !!noCrawle, + isExplorable: !!isExplorable, + hideOnlineStatus: !!hideOnlineStatus, + publicReactions: !!publicReactions, + ffVisibility: ffVisibility, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.privacy, + icon: 'ti ti-lock-open', +}); +</script> diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue new file mode 100644 index 0000000000..14eeeaaa11 --- /dev/null +++ b/packages/frontend/src/pages/settings/profile.vue @@ -0,0 +1,220 @@ +<template> +<div class="_formRoot"> + <div class="llvierxe" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }"> + <div class="avatar"> + <MkAvatar class="avatar" :user="$i" :disable-link="true" @click="changeAvatar"/> + <MkButton primary rounded class="avatarEdit" @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> + </div> + <MkButton primary rounded class="bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton> + </div> + + <FormInput v-model="profile.name" :max="30" manual-save class="_formBlock"> + <template #label>{{ i18n.ts._profile.name }}</template> + </FormInput> + + <FormTextarea v-model="profile.description" :max="500" tall manual-save class="_formBlock"> + <template #label>{{ i18n.ts._profile.description }}</template> + <template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template> + </FormTextarea> + + <FormInput v-model="profile.location" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.location }}</template> + <template #prefix><i class="ti ti-map-pin"></i></template> + </FormInput> + + <FormInput v-model="profile.birthday" type="date" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.birthday }}</template> + <template #prefix><i class="ti ti-cake"></i></template> + </FormInput> + + <FormSelect v-model="profile.lang" class="_formBlock"> + <template #label>{{ i18n.ts.language }}</template> + <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> + </FormSelect> + + <FormSlot class="_formBlock"> + <FormFolder> + <template #icon><i class="ti ti-list"></i></template> + <template #label>{{ i18n.ts._profile.metadataEdit }}</template> + + <div class="_formRoot"> + <FormSplit v-for="(record, i) in fields" :min-width="250" class="_formBlock"> + <FormInput v-model="record.name" small> + <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template> + </FormInput> + <FormInput v-model="record.value" small> + <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template> + </FormInput> + </FormSplit> + <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </div> + </FormFolder> + <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> + </FormSlot> + + <FormFolder> + <template #label>{{ i18n.ts.advancedSettings }}</template> + + <div class="_formRoot"> + <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> + <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> + </div> + </FormFolder> + + <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> +</div> +</template> + +<script lang="ts" setup> +import { reactive, watch } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormSlot from '@/components/form/slot.vue'; +import { host } from '@/config'; +import { selectFile } from '@/scripts/select-file'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { langmap } from '@/scripts/langmap'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const profile = reactive({ + name: $i.name, + description: $i.description, + location: $i.location, + birthday: $i.birthday, + lang: $i.lang, + isBot: $i.isBot, + isCat: $i.isCat, + showTimelineReplies: $i.showTimelineReplies, +}); + +watch(() => profile, () => { + save(); +}, { + deep: true, +}); + +const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value }))); + +function addField() { + fields.push({ + name: '', + value: '', + }); +} + +while (fields.length < 4) { + addField(); +} + +function saveFields() { + os.apiWithDialog('i/update', { + fields: fields.filter(field => field.name !== '' && field.value !== ''), + }); +} + +function save() { + os.apiWithDialog('i/update', { + name: profile.name || null, + description: profile.description || null, + location: profile.location || null, + birthday: profile.birthday || null, + lang: profile.lang || null, + isBot: !!profile.isBot, + isCat: !!profile.isCat, + showTimelineReplies: !!profile.showTimelineReplies, + }); +} + +function changeAvatar(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.avatar).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + avatarId: originalOrCropped.id, + }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; + }); +} + +function changeBanner(ev) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.banner).then(async (file) => { + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 2, + }); + } + + const i = await os.apiWithDialog('i/update', { + bannerId: originalOrCropped.id, + }); + $i.bannerId = i.bannerId; + $i.bannerUrl = i.bannerUrl; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.profile, + icon: 'ti ti-user', +}); +</script> + +<style lang="scss" scoped> +.llvierxe { + position: relative; + background-size: cover; + background-position: center; + border: solid 1px var(--divider); + border-radius: 10px; + overflow: clip; + + > .avatar { + display: inline-block; + text-align: center; + padding: 16px; + + > .avatar { + display: inline-block; + width: 72px; + height: 72px; + margin: 0 auto 16px auto; + } + } + + > .bannerEdit { + position: absolute; + top: 16px; + right: 16px; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/reaction.vue b/packages/frontend/src/pages/settings/reaction.vue new file mode 100644 index 0000000000..2748cd7d4e --- /dev/null +++ b/packages/frontend/src/pages/settings/reaction.vue @@ -0,0 +1,154 @@ +<template> +<div class="_formRoot"> + <FromSlot class="_formBlock"> + <template #label>{{ i18n.ts.reactionSettingDescription }}</template> + <div v-panel style="border-radius: 6px;"> + <Sortable v-model="reactions" class="zoaiodol" :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="ti ti-plus"></i></button> + </template> + </Sortable> + </div> + <template #caption>{{ i18n.ts.reactionSettingDescription2 }} <button class="_textButton" @click="preview">{{ i18n.ts.preview }}</button></template> + </FromSlot> + + <FormRadios v-model="reactionPickerSize" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerWidth" class="_formBlock"> + <template #label>{{ i18n.ts.numberOfColumn }}</template> + <option :value="1">5</option> + <option :value="2">6</option> + <option :value="3">7</option> + <option :value="4">8</option> + <option :value="5">9</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight" class="_formBlock"> + <template #label>{{ i18n.ts.height }}</template> + <option :value="1">{{ i18n.ts.small }}</option> + <option :value="2">{{ i18n.ts.medium }}</option> + <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + </FormRadios> + + <FormSwitch v-model="reactionPickerUseDrawerForMobile" class="_formBlock"> + {{ i18n.ts.useDrawerReactionPickerForMobile }} + <template #caption>{{ i18n.ts.needReloadToApply }}</template> + </FormSwitch> + + <FormSection> + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton inline @click="preview"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton inline danger @click="setDefault"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> + </div> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, watch } from 'vue'; +import Sortable from 'vuedraggable'; +import FormInput from '@/components/form/input.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FromSlot from '@/components/form/slot.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { deepClone } from '@/scripts/clone'; + +let reactions = $ref(deepClone(defaultStore.state.reactions)); + +const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize')); +const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth')); +const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight')); +const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile')); + +function save() { + defaultStore.set('reactions', reactions); +} + +function remove(reaction, ev: MouseEvent) { + os.popupMenu([{ + text: i18n.ts.remove, + action: () => { + reactions = reactions.filter(x => x !== reaction); + }, + }], ev.currentTarget ?? ev.target); +} + +function preview(ev: MouseEvent) { + os.popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + asReactionPicker: true, + src: ev.currentTarget ?? ev.target, + }, {}, 'closed'); +} + +async function setDefault() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.resetAreYouSure, + }); + if (canceled) return; + + reactions = deepClone(defaultStore.def.reactions.default); +} + +function chooseEmoji(ev: MouseEvent) { + os.pickEmoji(ev.currentTarget ?? ev.target, { + showPinned: false, + }).then(emoji => { + if (!reactions.includes(emoji)) { + reactions.push(emoji); + } + }); +} + +watch($$(reactions), () => { + save(); +}, { + deep: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.reaction, + icon: 'ti ti-mood-happy', + action: { + icon: 'ti ti-eye', + handler: preview, + }, +}); +</script> + +<style lang="scss" scoped> +.zoaiodol { + padding: 12px; + font-size: 1.1em; + + > .item { + display: inline-block; + padding: 8px; + cursor: move; + } + + > .add { + display: inline-block; + padding: 8px; + } +} +</style> diff --git a/packages/frontend/src/pages/settings/security.vue b/packages/frontend/src/pages/settings/security.vue new file mode 100644 index 0000000000..33f49eb3ef --- /dev/null +++ b/packages/frontend/src/pages/settings/security.vue @@ -0,0 +1,160 @@ +<template> +<div class="_formRoot"> + <FormSection> + <template #label>{{ i18n.ts.password }}</template> + <FormButton primary @click="change()">{{ i18n.ts.changePassword }}</FormButton> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.twoStepAuthentication }}</template> + <X2fa/> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.signinHistory }}</template> + <MkPagination :pagination="pagination" disable-auto-load> + <template #default="{items}"> + <div> + <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> + <header> + <i v-if="item.success" class="ti ti-check icon succ"></i> + <i v-else class="ti ti-circle-x icon fail"></i> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </div> + </template> + </MkPagination> + </FormSection> + + <FormSection> + <FormSlot> + <FormButton danger @click="regenerateToken"><i class="ti ti-refresh"></i> {{ i18n.ts.regenerateLoginToken }}</FormButton> + <template #caption>{{ i18n.ts.regenerateLoginTokenDescription }}</template> + </FormSlot> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import X2fa from './2fa.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSlot from '@/components/form/slot.vue'; +import FormButton from '@/components/MkButton.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/signin-history' as const, + limit: 5, +}; + +async function change() { + const { canceled: canceled1, result: currentPassword } = await os.inputText({ + title: i18n.ts.currentPassword, + type: 'password', + }); + if (canceled1) return; + + const { canceled: canceled2, result: newPassword } = await os.inputText({ + title: i18n.ts.newPassword, + type: 'password', + }); + if (canceled2) return; + + const { canceled: canceled3, result: newPassword2 } = await os.inputText({ + title: i18n.ts.newPasswordRetype, + type: 'password', + }); + if (canceled3) return; + + if (newPassword !== newPassword2) { + os.alert({ + type: 'error', + text: i18n.ts.retypedNotMatch, + }); + return; + } + + os.apiWithDialog('i/change-password', { + currentPassword, + newPassword, + }); +} + +function regenerateToken() { + os.inputText({ + title: i18n.ts.password, + type: 'password', + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/regenerate_token', { + password: password, + }); + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'ti ti-lock', +}); +</script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + &:first-child { + border-top-left-radius: 6px; + border-top-right-radius: 6px; + } + + &:last-child { + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + } + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + + > 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/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue new file mode 100644 index 0000000000..62627c6333 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -0,0 +1,45 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="type"> + <template #label>{{ i18n.ts.sound }}</template> + <option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option> + </FormSelect> + <FormRange v-model="volume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock"> + <template #label>{{ i18n.ts.volume }}</template> + </FormRange> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton inline @click="listen"><i class="ti ti-player-play"></i> {{ i18n.ts.listen }}</FormButton> + <FormButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import { i18n } from '@/i18n'; +import { playFile, soundsTypes } from '@/scripts/sound'; + +const props = defineProps<{ + type: string; + volume: number; +}>(); + +const emit = defineEmits<{ + (ev: 'update', result: { type: string; volume: number; }): void; +}>(); + +let type = $ref(props.type); +let volume = $ref(props.volume); + +function listen() { + playFile(type, volume); +} + +function save() { + emit('update', { type, volume }); +} +</script> diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue new file mode 100644 index 0000000000..ef60b2c3c9 --- /dev/null +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -0,0 +1,82 @@ +<template> +<div class="_formRoot"> + <FormRange v-model="masterVolume" :min="0" :max="1" :step="0.05" :text-converter="(v) => `${Math.floor(v * 100)}%`" class="_formBlock"> + <template #label>{{ i18n.ts.masterVolume }}</template> + </FormRange> + + <FormSection> + <template #label>{{ i18n.ts.sounds }}</template> + <FormFolder v-for="type in Object.keys(sounds)" :key="type" style="margin-bottom: 8px;"> + <template #label>{{ $t('_sfx.' + type) }}</template> + <template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template> + + <XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/> + </FormFolder> + </FormSection> + + <FormButton danger class="_formBlock" @click="reset()"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import XSound from './sounds.sound.vue'; +import FormRange from '@/components/form/range.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +import FormSection from '@/components/form/section.vue'; +import FormFolder from '@/components/form/folder.vue'; +import * as os from '@/os'; +import { ColdDeviceStorage } from '@/store'; +import { playFile } from '@/scripts/sound'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const masterVolume = computed({ + get: () => { + return ColdDeviceStorage.get('sound_masterVolume'); + }, + set: (value) => { + ColdDeviceStorage.set('sound_masterVolume', value); + }, +}); + +const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'); + +const sounds = ref({ + note: ColdDeviceStorage.get('sound_note'), + noteMy: ColdDeviceStorage.get('sound_noteMy'), + notification: ColdDeviceStorage.get('sound_notification'), + chat: ColdDeviceStorage.get('sound_chat'), + chatBg: ColdDeviceStorage.get('sound_chatBg'), + antenna: ColdDeviceStorage.get('sound_antenna'), + channel: ColdDeviceStorage.get('sound_channel'), +}); + +async function updated(type, sound) { + const v = { + type: sound.type, + volume: sound.volume, + }; + + ColdDeviceStorage.set('sound_' + type, v); + sounds.value[type] = v; +} + +function reset() { + for (const sound of Object.keys(sounds.value)) { + const v = ColdDeviceStorage.default['sound_' + sound]; + ColdDeviceStorage.set('sound_' + sound, v); + sounds.value[sound] = v; + } +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.sounds, + icon: 'ti ti-music', +}); +</script> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue new file mode 100644 index 0000000000..608222386e --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -0,0 +1,140 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock"> + <template #label>{{ i18n.ts.type }}</template> + <option value="rss">RSS</option> + <option value="federation">Federation</option> + <option value="userList">User list timeline</option> + </FormSelect> + + <MkInput v-model="statusbar.name" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.label }}</template> + </MkInput> + + <MkSwitch v-model="statusbar.black" class="_formBlock"> + <template #label>Black</template> + </MkSwitch> + + <FormRadios v-model="statusbar.size" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option value="verySmall">{{ i18n.ts.small }}+</option> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + <option value="veryLarge">{{ i18n.ts.large }}+</option> + </FormRadios> + + <template v-if="statusbar.type === 'rss'"> + <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url"> + <template #label>URL</template> + </MkInput> + <MkSwitch v-model="statusbar.props.shuffle" class="_formBlock"> + <template #label>{{ i18n.ts.shuffle }}</template> + </MkSwitch> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'federation'"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + <MkSwitch v-model="statusbar.props.colored" class="_formBlock"> + <template #label>{{ i18n.ts.colored }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'userList' && userLists != null"> + <FormSelect v-model="statusbar.props.userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> + </FormSelect> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, ref, watch } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/MkButton.vue'; +import FormRange from '@/components/form/range.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { deepClone } from '@/scripts/clone'; + +const props = defineProps<{ + _id: string; + userLists: any[] | null; +}>(); + +const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id))); + +watch(() => statusbar.type, () => { + if (statusbar.type === 'rss') { + statusbar.name = 'NEWS'; + statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.shuffle = true; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } else if (statusbar.type === 'federation') { + statusbar.name = 'FEDERATION'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + statusbar.props.colored = false; + } else if (statusbar.type === 'userList') { + statusbar.name = 'LIST TL'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } +}); + +watch(statusbar, save); + +async function save() { + const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); + const statusbars = deepClone(defaultStore.state.statusbars); + statusbars[i] = deepClone(statusbar); + defaultStore.set('statusbars', statusbars); +} + +function del() { + defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); +} +</script> diff --git a/packages/frontend/src/pages/settings/statusbar.vue b/packages/frontend/src/pages/settings/statusbar.vue new file mode 100644 index 0000000000..86c69fa2c3 --- /dev/null +++ b/packages/frontend/src/pages/settings/statusbar.vue @@ -0,0 +1,54 @@ +<template> +<div class="_formRoot"> + <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock"> + <template #label>{{ x.type ?? i18n.ts.notSet }}</template> + <template #suffix>{{ x.name }}</template> + <XStatusbar :_id="x.id" :user-lists="userLists"/> + </FormFolder> + <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XStatusbar from './statusbar.statusbar.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const statusbars = defaultStore.reactiveState.statusbars; + +let userLists = $ref(); + +onMounted(() => { + os.api('users/lists/list').then(res => { + userLists = res; + }); +}); + +async function add() { + defaultStore.push('statusbars', { + id: uuid(), + type: null, + black: false, + size: 'medium', + props: {}, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.statusbar, + icon: 'ti ti-list', + bg: 'var(--bg)', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.install.vue b/packages/frontend/src/pages/settings/theme.install.vue new file mode 100644 index 0000000000..52a436e18d --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.install.vue @@ -0,0 +1,80 @@ +<template> +<div class="_formRoot"> + <FormTextarea v-model="installThemeCode" class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton :disabled="installThemeCode == null" inline @click="() => preview(installThemeCode)"><i class="ti ti-eye"></i> {{ i18n.ts.preview }}</FormButton> + <FormButton :disabled="installThemeCode == null" primary inline @click="() => install(installThemeCode)"><i class="ti ti-check"></i> {{ i18n.ts.install }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormButton from '@/components/MkButton.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; +import { addTheme, getThemes } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let installThemeCode = $ref(null); + +function parseThemeCode(code: string) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return false; + } + if (!validateTheme(theme)) { + os.alert({ + type: 'error', + text: i18n.ts._theme.invalid, + }); + return false; + } + if (getThemes().some(t => t.id === theme.id)) { + os.alert({ + type: 'info', + text: i18n.ts._theme.alreadyInstalled, + }); + return false; + } + + return theme; +} + +function preview(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +async function install(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); + os.alert({ + type: 'success', + text: i18n.t('_theme.installed', { name: theme.name }), + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.install, + icon: 'ti ti-download', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..409f0af650 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -0,0 +1,78 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="selectedThemeId" class="_formBlock"> + <template #label>{{ i18n.ts.theme }}</template> + <optgroup :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :model-value="selectedTheme.author" class="_formBlock"> + <template #label>{{ i18n.ts.author }}</template> + </FormInput> + <FormTextarea v-if="selectedTheme.desc" readonly :model-value="selectedTheme.desc" class="_formBlock"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + <FormTextarea readonly tall :model-value="selectedThemeCode" class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + <template #caption><button class="_textButton" @click="copyThemeCode()">{{ i18n.ts.copy }}</button></template> + </FormTextarea> + <FormButton v-if="!builtinThemes.some(t => t.id == selectedTheme.id)" class="_formBlock" danger @click="uninstall()"><i class="ti ti-trash"></i> {{ i18n.ts.uninstall }}</FormButton> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/MkButton.vue'; +import { Theme, getBuiltinThemesRef } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; +import { getThemes, removeTheme } from '@/theme-store'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const installedThemes = ref(getThemes()); +const builtinThemes = getBuiltinThemesRef(); +const selectedThemeId = ref(null); + +const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]); + +const selectedTheme = computed(() => { + if (selectedThemeId.value == null) return null; + return themes.value.find(x => x.id === selectedThemeId.value); +}); + +const selectedThemeCode = computed(() => { + if (selectedTheme.value == null) return null; + return JSON5.stringify(selectedTheme.value, null, '\t'); +}); + +function copyThemeCode() { + copyToClipboard(selectedThemeCode.value); + os.success(); +} + +function uninstall() { + removeTheme(selectedTheme.value as Theme); + installedThemes.value = installedThemes.value.filter(t => t.id !== selectedThemeId.value); + selectedThemeId.value = null; + os.success(); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.manage, + icon: 'ti ti-tool', +}); +</script> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue new file mode 100644 index 0000000000..f37c213b06 --- /dev/null +++ b/packages/frontend/src/pages/settings/theme.vue @@ -0,0 +1,409 @@ +<template> +<div class="_formRoot rsljpzjq"> + <div v-adaptive-border class="rfqxtzch _panel _formBlock"> + <div class="toggle"> + <div class="toggleWrapper"> + <input id="dn" v-model="darkMode" type="checkbox" class="dn"/> + <label for="dn" class="toggle"> + <span class="before">{{ i18n.ts.light }}</span> + <span class="after">{{ i18n.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 class="sync"> + <FormSwitch v-model="syncDeviceDarkMode">{{ i18n.ts.syncDeviceDarkMode }}</FormSwitch> + </div> + </div> + + <div class="selects _formBlock"> + <FormSelect v-model="lightThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForLightMode }}</template> + <template #prefix><i class="ti ti-sun"></i></template> + <option v-if="instanceLightTheme" :key="'instance:' + instanceLightTheme.id" :value="instanceLightTheme.id">{{ instanceLightTheme.name }}</option> + <optgroup v-if="installedLightThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedLightThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinLightThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model="darkThemeId" large class="select"> + <template #label>{{ i18n.ts.themeForDarkMode }}</template> + <template #prefix><i class="ti ti-moon"></i></template> + <option v-if="instanceDarkTheme" :key="'instance:' + instanceDarkTheme.id" :value="instanceDarkTheme.id">{{ instanceDarkTheme.name }}</option> + <optgroup v-if="installedDarkThemes.length > 0" :label="i18n.ts._theme.installedThemes"> + <option v-for="x in installedDarkThemes" :key="'installed:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="i18n.ts._theme.builtinThemes"> + <option v-for="x in builtinDarkThemes" :key="'builtin:' + x.id" :value="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + </div> + + <FormSection> + <div class="_formLinksGrid"> + <FormLink to="/settings/theme/manage"><template #icon><i class="ti ti-tool"></i></template>{{ i18n.ts._theme.manage }}<template #suffix>{{ themesCount }}</template></FormLink> + <FormLink to="https://assets.misskey.io/theme/list" external><template #icon><i class="ti ti-world"></i></template>{{ i18n.ts._theme.explore }}</FormLink> + <FormLink to="/settings/theme/install"><template #icon><i class="ti ti-download"></i></template>{{ i18n.ts._theme.install }}</FormLink> + <FormLink to="/theme-editor"><template #icon><i class="ti ti-paint"></i></template>{{ i18n.ts._theme.make }}</FormLink> + </div> + </FormSection> + + <FormButton v-if="wallpaper == null" class="_formBlock" @click="setWallpaper">{{ i18n.ts.setWallpaper }}</FormButton> + <FormButton v-else class="_formBlock" @click="wallpaper = null">{{ i18n.ts.removeWallpaper }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onActivated, ref, watch } from 'vue'; +import JSON5 from 'json5'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/MkButton.vue'; +import { getBuiltinThemesRef } from '@/scripts/theme'; +import { selectFile } from '@/scripts/select-file'; +import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { uniqueBy } from '@/scripts/array'; +import { fetchThemes, getThemes } from '@/theme-store'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const installedThemes = ref(getThemes()); +const builtinThemes = getBuiltinThemesRef(); + +const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); +const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); +const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id)); + +const darkTheme = ColdDeviceStorage.ref('darkTheme'); +const darkThemeId = computed({ + get() { + return darkTheme.value.id; + }, + set(id) { + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('darkTheme', t); + } + }, +}); +const lightTheme = ColdDeviceStorage.ref('lightTheme'); +const lightThemeId = computed({ + get() { + return lightTheme.value.id; + }, + set(id) { + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('lightTheme', t); + } + }, +}); +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.value) { + defaultStore.set('darkMode', isDeviceDarkmode()); + } +}); + +watch(wallpaper, () => { + if (wallpaper.value == null) { + localStorage.removeItem('wallpaper'); + } else { + localStorage.setItem('wallpaper', wallpaper.value); + } + location.reload(); +}); + +onActivated(() => { + fetchThemes().then(() => { + installedThemes.value = getThemes(); + }); +}); + +fetchThemes().then(() => { + installedThemes.value = getThemes(); +}); + +function setWallpaper(event) { + selectFile(event.currentTarget ?? event.target, null).then(file => { + wallpaper.value = file.url; + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.theme, + icon: 'ti ti-palette', +}); +</script> + +<style lang="scss" scoped> +.rfqxtzch { + border-radius: 6px; + + > .toggle { + position: relative; + padding: 26px 0; + text-align: center; + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } + + > .toggleWrapper { + display: inline-block; + text-align: left; + overflow: clip; + padding: 0 100px; + vertical-align: bottom; + + 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; + 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; + } + } + } + } + + > .sync { + padding: 14px 16px; + border-top: solid 0.5px var(--divider); + } +} + +.rsljpzjq { + > .selects { + display: flex; + gap: 1.5em var(--margin); + flex-wrap: wrap; + + > .select { + flex: 1; + min-width: 280px; + } + } +} +</style> diff --git a/packages/frontend/src/pages/settings/webhook.edit.vue b/packages/frontend/src/pages/settings/webhook.edit.vue new file mode 100644 index 0000000000..c8ec1ea586 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.edit.vue @@ -0,0 +1,95 @@ +<template> +<div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>Name</template> + </FormInput> + + <FormInput v-model="url" type="url" class="_formBlock"> + <template #label>URL</template> + </FormInput> + + <FormInput v-model="secret" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>Secret</template> + </FormInput> + + <FormSection> + <template #label>Events</template> + + <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch> + <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch> + <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch> + <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch> + <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch> + <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch> + <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch> + </FormSection> + + <FormSwitch v-model="active" class="_formBlock">Active</FormSwitch> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton primary inline @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = defineProps<{ + webhookId: string; +}>(); + +const webhook = await os.api('i/webhooks/show', { + webhookId: props.webhookId, +}); + +let name = $ref(webhook.name); +let url = $ref(webhook.url); +let secret = $ref(webhook.secret); +let active = $ref(webhook.active); + +let event_follow = $ref(webhook.on.includes('follow')); +let event_followed = $ref(webhook.on.includes('followed')); +let event_note = $ref(webhook.on.includes('note')); +let event_reply = $ref(webhook.on.includes('reply')); +let event_renote = $ref(webhook.on.includes('renote')); +let event_reaction = $ref(webhook.on.includes('reaction')); +let event_mention = $ref(webhook.on.includes('mention')); + +async function save(): Promise<void> { + const events = []; + if (event_follow) events.push('follow'); + if (event_followed) events.push('followed'); + if (event_note) events.push('note'); + if (event_reply) events.push('reply'); + if (event_renote) events.push('renote'); + if (event_reaction) events.push('reaction'); + if (event_mention) events.push('mention'); + + os.apiWithDialog('i/webhooks/update', { + name, + url, + secret, + webhookId: props.webhookId, + on: events, + active, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Edit webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/webhook.new.vue b/packages/frontend/src/pages/settings/webhook.new.vue new file mode 100644 index 0000000000..00a547da69 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.new.vue @@ -0,0 +1,82 @@ +<template> +<div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>Name</template> + </FormInput> + + <FormInput v-model="url" type="url" class="_formBlock"> + <template #label>URL</template> + </FormInput> + + <FormInput v-model="secret" class="_formBlock"> + <template #prefix><i class="ti ti-lock"></i></template> + <template #label>Secret</template> + </FormInput> + + <FormSection> + <template #label>Events</template> + + <FormSwitch v-model="event_follow" class="_formBlock">Follow</FormSwitch> + <FormSwitch v-model="event_followed" class="_formBlock">Followed</FormSwitch> + <FormSwitch v-model="event_note" class="_formBlock">Note</FormSwitch> + <FormSwitch v-model="event_reply" class="_formBlock">Reply</FormSwitch> + <FormSwitch v-model="event_renote" class="_formBlock">Renote</FormSwitch> + <FormSwitch v-model="event_reaction" class="_formBlock">Reaction</FormSwitch> + <FormSwitch v-model="event_mention" class="_formBlock">Mention</FormSwitch> + </FormSection> + + <div class="_formBlock" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton primary inline @click="create"><i class="ti ti-check"></i> {{ i18n.ts.create }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import FormInput from '@/components/form/input.vue'; +import FormSection from '@/components/form/section.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormButton from '@/components/MkButton.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +let name = $ref(''); +let url = $ref(''); +let secret = $ref(''); + +let event_follow = $ref(true); +let event_followed = $ref(true); +let event_note = $ref(true); +let event_reply = $ref(true); +let event_renote = $ref(true); +let event_reaction = $ref(true); +let event_mention = $ref(true); + +async function create(): Promise<void> { + const events = []; + if (event_follow) events.push('follow'); + if (event_followed) events.push('followed'); + if (event_note) events.push('note'); + if (event_reply) events.push('reply'); + if (event_renote) events.push('renote'); + if (event_reaction) events.push('reaction'); + if (event_mention) events.push('mention'); + + os.apiWithDialog('i/webhooks/create', { + name, + url, + secret, + on: events, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Create new webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/webhook.vue b/packages/frontend/src/pages/settings/webhook.vue new file mode 100644 index 0000000000..9be23ee4f0 --- /dev/null +++ b/packages/frontend/src/pages/settings/webhook.vue @@ -0,0 +1,53 @@ +<template> +<div class="_formRoot"> + <FormSection> + <FormLink :to="`/settings/webhook/new`"> + Create webhook + </FormLink> + </FormSection> + + <FormSection> + <MkPagination :pagination="pagination"> + <template #default="{items}"> + <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit/${webhook.id}`" class="_formBlock"> + <template #icon> + <i v-if="webhook.active === false" class="ti ti-player-pause"></i> + <i v-else-if="webhook.latestStatus === null" class="ti ti-circle"></i> + <i v-else-if="[200, 201, 204].includes(webhook.latestStatus)" class="ti ti-check" :style="{ color: 'var(--success)' }"></i> + <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"></i> + </template> + {{ webhook.name || webhook.url }} + <template #suffix> + <MkTime v-if="webhook.latestSentAt" :time="webhook.latestSentAt"></MkTime> + </template> + </FormLink> + </template> + </MkPagination> + </FormSection> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import MkPagination from '@/components/MkPagination.vue'; +import FormSection from '@/components/form/section.vue'; +import FormLink from '@/components/form/link.vue'; +import { userPage } from '@/filters/user'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagination = { + endpoint: 'i/webhooks/list' as const, + limit: 10, +}; + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Webhook', + icon: 'ti ti-webhook', +}); +</script> diff --git a/packages/frontend/src/pages/settings/word-mute.vue b/packages/frontend/src/pages/settings/word-mute.vue new file mode 100644 index 0000000000..6961d8151d --- /dev/null +++ b/packages/frontend/src/pages/settings/word-mute.vue @@ -0,0 +1,128 @@ +<template> +<div class="_formRoot"> + <MkTab v-model="tab" class="_formBlock"> + <option value="soft">{{ i18n.ts._wordMute.soft }}</option> + <option value="hard">{{ i18n.ts._wordMute.hard }}</option> + </MkTab> + <div class="_formBlock"> + <div v-show="tab === 'soft'"> + <MkInfo class="_formBlock">{{ i18n.ts._wordMute.softDescription }}</MkInfo> + <FormTextarea v-model="softMutedWords" class="_formBlock"> + <span>{{ i18n.ts._wordMute.muteWords }}</span> + <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + </div> + <div v-show="tab === 'hard'"> + <MkInfo class="_formBlock">{{ i18n.ts._wordMute.hardDescription }} {{ i18n.ts.reflectMayTakeTime }}</MkInfo> + <FormTextarea v-model="hardMutedWords" class="_formBlock"> + <span>{{ i18n.ts._wordMute.muteWords }}</span> + <template #caption>{{ i18n.ts._wordMute.muteWordsDescription }}<br>{{ i18n.ts._wordMute.muteWordsDescription2 }}</template> + </FormTextarea> + <MkKeyValue v-if="hardWordMutedNotesCount != null" class="_formBlock"> + <template #key>{{ i18n.ts._wordMute.mutedNotes }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </MkKeyValue> + </div> + </div> + <MkButton primary inline :disabled="!changed" @click="save()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> +</div> +</template> + +<script lang="ts" setup> +import { ref, watch } from 'vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import MkTab from '@/components/MkTab.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const render = (mutedWords) => mutedWords.map(x => { + if (Array.isArray(x)) { + return x.join(' '); + } else { + return x; + } +}).join('\n'); + +const tab = ref('soft'); +const softMutedWords = ref(render(defaultStore.state.mutedWords)); +const hardMutedWords = ref(render($i!.mutedWords)); +const hardWordMutedNotesCount = ref(null); +const changed = ref(false); + +os.api('i/get-word-muted-notes-count', {}).then(response => { + hardWordMutedNotesCount.value = response?.count; +}); + +watch(softMutedWords, () => { + changed.value = true; +}); + +watch(hardMutedWords, () => { + changed.value = true; +}); + +async function save() { + const parseMutes = (mutes, tab) => { + // split into lines, remove empty lines and unnecessary whitespace + let lines = mutes.trim().split('\n').map(line => line.trim()).filter(line => line !== ''); + + // check each line if it is a RegExp or not + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const regexp = line.match(/^\/(.+)\/(.*)$/); + if (regexp) { + // check that the RegExp is valid + try { + new RegExp(regexp[1], regexp[2]); + // note that regex lines will not be split by spaces! + } catch (err: any) { + // invalid syntax: do not save, do not reset changed flag + os.alert({ + type: 'error', + title: i18n.ts.regexpError, + text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(), + }); + // re-throw error so these invalid settings are not saved + throw err; + } + } else { + lines[i] = line.split(' '); + } + } + + return lines; + }; + + let softMutes, hardMutes; + try { + softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft); + hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard); + } catch (err) { + // already displayed error message in parseMutes + return; + } + + defaultStore.set('mutedWords', softMutes); + await os.api('i/update', { + mutedWords: hardMutes, + }); + + changed.value = false; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.wordMute, + icon: 'ti ti-message-off', +}); +</script> |