rename: client -> frontend

This commit is contained in:
syuilo
2022-12-27 14:36:33 +09:00
parent db6fff6f26
commit 9384f5399d
592 changed files with 111 additions and 111 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 }} &lt;-&gt; {{ 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 }} &lt;-&gt; {{ 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 }} &lt;-&gt; {{ 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>