summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-04-19 21:24:31 +0900
committerGitHub <noreply@github.com>2023-04-19 21:24:31 +0900
commite1f9ab77f86f5a12091c864cdb502970715cd46e (patch)
tree46990eae87d352e6674e43a64c3bdcd74c13119e /packages/frontend/src
parentUpdate test-frontend.yml (diff)
downloadsharkey-e1f9ab77f86f5a12091c864cdb502970715cd46e.tar.gz
sharkey-e1f9ab77f86f5a12091c864cdb502970715cd46e.tar.bz2
sharkey-e1f9ab77f86f5a12091c864cdb502970715cd46e.zip
feat: Server rules (#10660)
* enhance(frontend): サーバールールのデザイン調整 * enhance(frontend): i18n * enhance(frontend): 利用規約URLの設定を「モデレーション」ページへ移動 * enhance(frontend): サーバールールのデザイン調整 * Update CHANGELOG.md * 不要な差分を削除 * fix(frontend): lint * ui tweak * test: add stories * tweak * test: bind args * test: add interaction tests * fix bug * Update packages/frontend/src/pages/admin/server-rules.vue Co-authored-by: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> * Update misskey-js.api.md * chore: windowを明示 * :art: * refactor * :art: * :art: * fix e2e test * :art: * :art: * fix icon * fix e2e --------- Co-authored-by: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com> Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkFolder.vue8
-rw-r--r--packages/frontend/src/components/MkModal.vue6
-rw-r--r--packages/frontend/src/components/MkModalWindow.vue5
-rw-r--r--packages/frontend/src/components/MkSignup.vue263
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue272
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts94
-rw-r--r--packages/frontend/src/components/MkSignupDialog.rules.vue114
-rw-r--r--packages/frontend/src/components/MkSignupDialog.vue45
-rw-r--r--packages/frontend/src/components/MkSwitch.vue2
-rw-r--r--packages/frontend/src/pages/about.vue2
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue9
-rw-r--r--packages/frontend/src/pages/admin/server-rules.vue128
-rw-r--r--packages/frontend/src/pages/admin/settings.vue8
-rw-r--r--packages/frontend/src/router.ts4
-rw-r--r--packages/frontend/src/store.ts4
15 files changed, 670 insertions, 294 deletions
diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index 58cc0de5c8..fd070a5f13 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -1,8 +1,8 @@
<template>
-<div ref="rootEl" :class="$style.root">
+<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer>
<template #header>
- <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle">
+ <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div :class="$style.headerTextMain">
@@ -20,7 +20,7 @@
</div>
</template>
- <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }">
+ <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened">
<Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
@@ -196,7 +196,7 @@ onMounted(() => {
.headerRight {
margin-left: auto;
- opacity: 0.7;
+ color: var(--fgTransparentWeak);
white-space: nowrap;
}
diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue
index 852c72f6ff..99df9e8150 100644
--- a/packages/frontend/src/components/MkModal.vue
+++ b/packages/frontend/src/components/MkModal.vue
@@ -404,16 +404,10 @@ defineExpose({
right: 0;
margin: auto;
padding: 32px;
- // TODO: mask-imageはiOSだとやたら重い。なんとかしたい
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
- overflow: auto;
display: flex;
@media (max-width: 500px) {
padding: 16px;
- -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
- mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
}
}
}
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index dd115246ff..2d2f8411f1 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -1,6 +1,6 @@
<template>
<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
- <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown">
+ <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: height ? `${height}px` : null }" @keydown="onKeydown">
<div ref="headerEl" class="header">
<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<span class="title">
@@ -25,13 +25,11 @@ const props = withDefaults(defineProps<{
okButtonDisabled: boolean;
width: number;
height: number | null;
- scroll: boolean;
}>(), {
withOkButton: false,
okButtonDisabled: false,
width: 400,
height: null,
- scroll: true,
});
const emit = defineEmits<{
@@ -86,6 +84,7 @@ defineExpose({
<style lang="scss" scoped>
.ebkgoccj {
margin: auto;
+ max-height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue
deleted file mode 100644
index 30279148f8..0000000000
--- a/packages/frontend/src/components/MkSignup.vue
+++ /dev/null
@@ -1,263 +0,0 @@
-<template>
-<form class="qlvuhzng _gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
- <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
- <template #label>{{ i18n.ts.invitationCode }}</template>
- <template #prefix><i class="ti ti-key"></i></template>
- </MkInput>
- <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
- <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
- <template #prefix>@</template>
- <template #suffix>@{{ host }}</template>
- <template #caption>
- <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
- <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
- <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
- <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
- <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
- <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
- <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
- <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
- </template>
- </MkInput>
- <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
- <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
- <template #prefix><i class="ti ti-mail"></i></template>
- <template #caption>
- <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
- <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
- <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
- <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
- <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
- <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
- <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
- <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
- <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
- </template>
- </MkInput>
- <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
- <template #label>{{ i18n.ts.password }}</template>
- <template #prefix><i class="ti ti-lock"></i></template>
- <template #caption>
- <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
- <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
- <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
- </template>
- </MkInput>
- <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
- <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
- <template #prefix><i class="ti ti-lock"></i></template>
- <template #caption>
- <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
- <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
- </template>
- </MkInput>
- <MkSwitch v-model="ToSAgreement" class="tou">
- <template #label>{{ i18n.ts.agreeBelow }}</template>
- </MkSwitch>
- <ul style="margin: 0; padding-left: 2em;">
- <li v-if="instance.tosUrl"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a></li>
- <li><a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }}</a></li>
- </ul>
- <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
- <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
- <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
- <MkButton type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton>
-</form>
-</template>
-
-<script lang="ts" setup>
-import { } from 'vue';
-import getPasswordStrength from 'syuilo-password-strength';
-import { toUnicode } from 'punycode/';
-import MkButton from './MkButton.vue';
-import MkInput from './MkInput.vue';
-import MkSwitch from './MkSwitch.vue';
-import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
-import * as config from '@/config';
-import * as os from '@/os';
-import { login } from '@/account';
-import { instance } from '@/instance';
-import { i18n } from '@/i18n';
-
-const props = withDefaults(defineProps<{
- autoSet?: boolean;
-}>(), {
- autoSet: false,
-});
-
-const emit = defineEmits<{
- (ev: 'signup', user: Record<string, any>): void;
- (ev: 'signupEmailPending'): void;
-}>();
-
-const host = toUnicode(config.host);
-
-let hcaptcha = $ref<Captcha | undefined>();
-let recaptcha = $ref<Captcha | undefined>();
-let turnstile = $ref<Captcha | undefined>();
-
-let username: string = $ref('');
-let password: string = $ref('');
-let retypedPassword: string = $ref('');
-let invitationCode: string = $ref('');
-let email = $ref('');
-let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
-let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
-let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
-let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
-let submitting: boolean = $ref(false);
-let ToSAgreement: boolean = $ref(false);
-let hCaptchaResponse = $ref(null);
-let reCaptchaResponse = $ref(null);
-let turnstileResponse = $ref(null);
-let usernameAbortController: null | AbortController = $ref(null);
-let emailAbortController: null | AbortController = $ref(null);
-
-const shouldDisableSubmitting = $computed((): boolean => {
- return submitting ||
- instance.tosUrl && !ToSAgreement ||
- instance.enableHcaptcha && !hCaptchaResponse ||
- instance.enableRecaptcha && !reCaptchaResponse ||
- instance.enableTurnstile && !turnstileResponse ||
- instance.emailRequiredForSignup && emailState !== 'ok' ||
- usernameState !== 'ok' ||
- passwordRetypeState !== 'match';
-});
-
-function onChangeUsername(): void {
- if (username === '') {
- usernameState = null;
- return;
- }
-
- {
- const err =
- !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
- username.length < 1 ? 'min-range' :
- username.length > 20 ? 'max-range' :
- null;
-
- if (err) {
- usernameState = err;
- return;
- }
- }
-
- if (usernameAbortController != null) {
- usernameAbortController.abort();
- }
- usernameState = 'wait';
- usernameAbortController = new AbortController();
-
- os.api('username/available', {
- username,
- }, undefined, usernameAbortController.signal).then(result => {
- usernameState = result.available ? 'ok' : 'unavailable';
- }).catch((err) => {
- if (err.name !== 'AbortError') {
- usernameState = 'error';
- }
- });
-}
-
-function onChangeEmail(): void {
- if (email === '') {
- emailState = null;
- return;
- }
-
- if (emailAbortController != null) {
- emailAbortController.abort();
- }
- emailState = 'wait';
- emailAbortController = new AbortController();
-
- os.api('email-address/available', {
- emailAddress: email,
- }, undefined, emailAbortController.signal).then(result => {
- emailState = result.available ? 'ok' :
- result.reason === 'used' ? 'unavailable:used' :
- result.reason === 'format' ? 'unavailable:format' :
- result.reason === 'disposable' ? 'unavailable:disposable' :
- result.reason === 'mx' ? 'unavailable:mx' :
- result.reason === 'smtp' ? 'unavailable:smtp' :
- 'unavailable';
- }).catch((err) => {
- if (err.name !== 'AbortError') {
- emailState = 'error';
- }
- });
-}
-
-function onChangePassword(): void {
- if (password === '') {
- passwordStrength = '';
- return;
- }
-
- const strength = getPasswordStrength(password);
- passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
-}
-
-function onChangePasswordRetype(): void {
- if (retypedPassword === '') {
- passwordRetypeState = null;
- return;
- }
-
- passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
-}
-
-async function onSubmit(): Promise<void> {
- if (submitting) return;
- submitting = true;
-
- try {
- await os.api('signup', {
- username,
- password,
- emailAddress: email,
- invitationCode,
- 'hcaptcha-response': hCaptchaResponse,
- 'g-recaptcha-response': reCaptchaResponse,
- 'turnstile-response': turnstileResponse,
- });
- if (instance.emailRequiredForSignup) {
- os.alert({
- type: 'success',
- title: i18n.ts._signup.almostThere,
- text: i18n.t('_signup.emailSent', { email }),
- });
- emit('signupEmailPending');
- } else {
- const res = await os.api('signin', {
- username,
- password,
- });
- emit('signup', res);
-
- if (props.autoSet) {
- return login(res.i);
- }
- }
- } catch {
- submitting = false;
- hcaptcha?.reset?.();
- recaptcha?.reset?.();
- turnstile?.reset?.();
-
- os.alert({
- type: 'error',
- text: i18n.ts.somethingHappened,
- });
- }
-}
-</script>
-
-<style lang="scss" scoped>
-.qlvuhzng {
- .captcha {
- margin: 16px 0;
- }
-}
-</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
new file mode 100644
index 0000000000..0e8bdb321e
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -0,0 +1,272 @@
+<template>
+<div>
+ <div :class="$style.banner">
+ <i class="ti ti-user-edit"></i>
+ </div>
+ <MkSpacer :margin-min="20" :margin-max="32">
+ <form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
+ <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
+ <template #label>{{ i18n.ts.invitationCode }}</template>
+ <template #prefix><i class="ti ti-key"></i></template>
+ </MkInput>
+ <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
+ <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
+ <template #prefix>@</template>
+ <template #suffix>@{{ host }}</template>
+ <template #caption>
+ <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
+ <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
+ <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
+ <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
+ <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
+ <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
+ <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
+ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
+ <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
+ <template #prefix><i class="ti ti-mail"></i></template>
+ <template #caption>
+ <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
+ <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
+ <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
+ <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
+ <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
+ <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
+ <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
+ <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
+ <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
+ <template #label>{{ i18n.ts.password }}</template>
+ <template #prefix><i class="ti ti-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
+ <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
+ <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
+ </template>
+ </MkInput>
+ <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
+ <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
+ <template #prefix><i class="ti ti-lock"></i></template>
+ <template #caption>
+ <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
+ <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
+ </template>
+ </MkInput>
+ <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
+ <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
+ <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
+ <MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
+ <template v-if="submitting">
+ <MkLoading :em="true" :colored="false"/>
+ </template>
+ <template v-else>{{ i18n.ts.start }}</template>
+ </MkButton>
+ </form>
+ </MkSpacer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import getPasswordStrength from 'syuilo-password-strength';
+import { toUnicode } from 'punycode/';
+import MkButton from './MkButton.vue';
+import MkInput from './MkInput.vue';
+import MkSwitch from './MkSwitch.vue';
+import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
+import * as config from '@/config';
+import * as os from '@/os';
+import { login } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+
+const props = withDefaults(defineProps<{
+ autoSet?: boolean;
+}>(), {
+ autoSet: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'signup', user: Record<string, any>): void;
+ (ev: 'signupEmailPending'): void;
+}>();
+
+const host = toUnicode(config.host);
+
+let hcaptcha = $ref<Captcha | undefined>();
+let recaptcha = $ref<Captcha | undefined>();
+let turnstile = $ref<Captcha | undefined>();
+
+let username: string = $ref('');
+let password: string = $ref('');
+let retypedPassword: string = $ref('');
+let invitationCode: string = $ref('');
+let email = $ref('');
+let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
+let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
+let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
+let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
+let submitting: boolean = $ref(false);
+let hCaptchaResponse = $ref(null);
+let reCaptchaResponse = $ref(null);
+let turnstileResponse = $ref(null);
+let usernameAbortController: null | AbortController = $ref(null);
+let emailAbortController: null | AbortController = $ref(null);
+
+const shouldDisableSubmitting = $computed((): boolean => {
+ return submitting ||
+ instance.enableHcaptcha && !hCaptchaResponse ||
+ instance.enableRecaptcha && !reCaptchaResponse ||
+ instance.enableTurnstile && !turnstileResponse ||
+ instance.emailRequiredForSignup && emailState !== 'ok' ||
+ usernameState !== 'ok' ||
+ passwordRetypeState !== 'match';
+});
+
+function onChangeUsername(): void {
+ if (username === '') {
+ usernameState = null;
+ return;
+ }
+
+ {
+ const err =
+ !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
+ username.length < 1 ? 'min-range' :
+ username.length > 20 ? 'max-range' :
+ null;
+
+ if (err) {
+ usernameState = err;
+ return;
+ }
+ }
+
+ if (usernameAbortController != null) {
+ usernameAbortController.abort();
+ }
+ usernameState = 'wait';
+ usernameAbortController = new AbortController();
+
+ os.api('username/available', {
+ username,
+ }, undefined, usernameAbortController.signal).then(result => {
+ usernameState = result.available ? 'ok' : 'unavailable';
+ }).catch((err) => {
+ if (err.name !== 'AbortError') {
+ usernameState = 'error';
+ }
+ });
+}
+
+function onChangeEmail(): void {
+ if (email === '') {
+ emailState = null;
+ return;
+ }
+
+ if (emailAbortController != null) {
+ emailAbortController.abort();
+ }
+ emailState = 'wait';
+ emailAbortController = new AbortController();
+
+ os.api('email-address/available', {
+ emailAddress: email,
+ }, undefined, emailAbortController.signal).then(result => {
+ emailState = result.available ? 'ok' :
+ result.reason === 'used' ? 'unavailable:used' :
+ result.reason === 'format' ? 'unavailable:format' :
+ result.reason === 'disposable' ? 'unavailable:disposable' :
+ result.reason === 'mx' ? 'unavailable:mx' :
+ result.reason === 'smtp' ? 'unavailable:smtp' :
+ 'unavailable';
+ }).catch((err) => {
+ if (err.name !== 'AbortError') {
+ emailState = 'error';
+ }
+ });
+}
+
+function onChangePassword(): void {
+ if (password === '') {
+ passwordStrength = '';
+ return;
+ }
+
+ const strength = getPasswordStrength(password);
+ passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
+}
+
+function onChangePasswordRetype(): void {
+ if (retypedPassword === '') {
+ passwordRetypeState = null;
+ return;
+ }
+
+ passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
+}
+
+async function onSubmit(): Promise<void> {
+ if (submitting) return;
+ submitting = true;
+
+ try {
+ await os.api('signup', {
+ username,
+ password,
+ emailAddress: email,
+ invitationCode,
+ 'hcaptcha-response': hCaptchaResponse,
+ 'g-recaptcha-response': reCaptchaResponse,
+ 'turnstile-response': turnstileResponse,
+ });
+ if (instance.emailRequiredForSignup) {
+ os.alert({
+ type: 'success',
+ title: i18n.ts._signup.almostThere,
+ text: i18n.t('_signup.emailSent', { email }),
+ });
+ emit('signupEmailPending');
+ } else {
+ const res = await os.api('signin', {
+ username,
+ password,
+ });
+ emit('signup', res);
+
+ if (props.autoSet) {
+ return login(res.i);
+ }
+ }
+ } catch {
+ submitting = false;
+ hcaptcha?.reset?.();
+ recaptcha?.reset?.();
+ turnstile?.reset?.();
+
+ os.alert({
+ type: 'error',
+ text: i18n.ts.somethingHappened,
+ });
+ }
+}
+</script>
+
+<style lang="scss" module>
+.banner {
+ padding: 16px;
+ text-align: center;
+ font-size: 26px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+}
+
+.captcha {
+ margin: 16px 0;
+}
+</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
new file mode 100644
index 0000000000..1308dfff9a
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts
@@ -0,0 +1,94 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { expect } from '@storybook/jest';
+import { userEvent, waitFor, within } from '@storybook/testing-library';
+import { StoryObj } from '@storybook/vue3';
+import { onBeforeUnmount } from 'vue';
+import MkSignupServerRules from './MkSignupDialog,rules.vue';
+import { i18n } from '@/i18n';
+import { instance } from '@/instance';
+export const Empty = {
+ render(args) {
+ return {
+ components: {
+ MkSignupServerRules,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkSignupServerRules v-bind="props" />',
+ };
+ },
+ async play({ canvasElement }) {
+ const canvas = within(canvasElement);
+ const groups = await canvas.findAllByRole('group');
+ const buttons = await canvas.findAllByRole('button');
+ for (const group of groups) {
+ if (group.ariaExpanded === 'true') {
+ continue;
+ }
+ const button = await within(group).findByRole('button');
+ userEvent.click(button);
+ await waitFor(() => expect(group).toHaveAttribute('aria-expanded', 'true'));
+ }
+ const labels = await canvas.findAllByText(i18n.ts.agree);
+ for (const label of labels) {
+ expect(buttons.at(-1)).toBeDisabled();
+ await waitFor(() => userEvent.click(label));
+ }
+ expect(buttons.at(-1)).toBeEnabled();
+ },
+ args: {
+ serverRules: [],
+ tosUrl: null,
+ },
+ decorators: [
+ (_, context) => ({
+ setup() {
+ instance.serverRules = context.args.serverRules;
+ instance.tosUrl = context.args.tosUrl;
+ onBeforeUnmount(() => {
+ // FIXME: 呼び出されない
+ instance.serverRules = [];
+ instance.tosUrl = null;
+ });
+ },
+ template: '<story/>',
+ }),
+ ],
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
+export const ServerRulesOnly = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ serverRules: [
+ 'ルール',
+ ],
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
+export const TOSOnly = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ tosUrl: 'https://example.com/tos',
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
+export const ServerRulesAndTOS = {
+ ...Empty,
+ args: {
+ ...Empty.args,
+ serverRules: ServerRulesOnly.args.serverRules,
+ tosUrl: TOSOnly.args.tosUrl,
+ },
+} satisfies StoryObj<typeof MkSignupServerRules>;
diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue
new file mode 100644
index 0000000000..be1c7f8c35
--- /dev/null
+++ b/packages/frontend/src/components/MkSignupDialog.rules.vue
@@ -0,0 +1,114 @@
+<template>
+<div>
+ <div :class="$style.banner">
+ <i class="ti ti-checklist"></i>
+ </div>
+ <MkSpacer :margin-min="20" :margin-max="28">
+ <div class="_gaps_m">
+ <div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
+
+ <MkFolder v-if="availableServerRules" :default-open="true">
+ <template #label>{{ i18n.ts.serverRules }}</template>
+ <template #suffix><i v-if="agreeServerRules" class="ti ti-check" style="color: var(--success)"></i></template>
+
+ <ol class="_gaps_s" :class="$style.rules">
+ <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
+ </ol>
+
+ <MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
+ </MkFolder>
+
+ <MkFolder v-if="availableTos">
+ <template #label>{{ i18n.ts.termsOfService }}</template>
+ <template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template>
+
+ <a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
+
+ <MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
+ </MkFolder>
+
+ <MkFolder data-cy-signup-rules-notes>
+ <template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template>
+ <template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--success)"></i></template>
+
+ <a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
+
+ <MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch>
+ </MkFolder>
+
+ <MkButton primary rounded gradate style="margin: 0 auto;" :disabled="!agreed" data-cy-signup-rules-continue @click="emit('accept')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
+ </div>
+ </MkSpacer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref } from 'vue';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+
+const availableServerRules = instance.serverRules.length > 0;
+const availableTos = instance.tosUrl != null;
+
+const agreeServerRules = ref(false);
+const agreeTos = ref(false);
+const agreeNote = ref(false);
+
+const agreed = computed(() => {
+ return (!availableServerRules || agreeServerRules.value) && (!availableTos || agreeTos.value) && agreeNote.value;
+});
+
+const emit = defineEmits<{
+ (ev: 'accept'): void;
+}>();
+</script>
+
+<style lang="scss" module>
+.banner {
+ padding: 16px;
+ text-align: center;
+ font-size: 26px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+}
+
+.rules {
+ counter-reset: item;
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.rule {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ word-break: break-word;
+
+ &::before {
+ flex-shrink: 0;
+ display: flex;
+ position: sticky;
+ top: calc(var(--stickyTop, 0px) + 8px);
+ counter-increment: item;
+ content: counter(item);
+ width: 32px;
+ height: 32px;
+ line-height: 32px;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: bold;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ }
+}
+
+.ruleText {
+ padding-top: 6px;
+}
+</style>
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index 790c1e94df..b4fc564d36 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -1,24 +1,40 @@
<template>
<MkModalWindow
ref="dialog"
- :width="366"
- :height="500"
+ :width="500"
+ :height="600"
@close="dialog.close()"
@closed="$emit('closed')"
>
<template #header>{{ i18n.ts.signup }}</template>
- <MkSpacer :margin-min="20" :margin-max="28">
- <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
- </MkSpacer>
+ <div style="overflow-x: clip;">
+ <Transition
+ mode="out-in"
+ :enter-active-class="$style.transition_x_enterActive"
+ :leave-active-class="$style.transition_x_leaveActive"
+ :enter-from-class="$style.transition_x_enterFrom"
+ :leave-to-class="$style.transition_x_leaveTo"
+ >
+ <template v-if="!isAcceptedServerRule">
+ <XServerRules @accept="isAcceptedServerRule = true"/>
+ </template>
+ <template v-else>
+ <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
+ </template>
+ </Transition>
+ </div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
-import XSignup from '@/components/MkSignup.vue';
+import { $ref } from 'vue/macros';
+import XSignup from '@/components/MkSignupDialog.form.vue';
+import XServerRules from '@/components/MkSignupDialog.rules.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n';
+import { instance } from '@/instance';
const props = withDefaults(defineProps<{
autoSet?: boolean;
@@ -33,6 +49,8 @@ const emit = defineEmits<{
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
+const isAcceptedServerRule = $ref(false);
+
function onSignup(res) {
emit('done', res);
dialog.close();
@@ -42,3 +60,18 @@ function onSignupEmailPending() {
dialog.close();
}
</script>
+
+<style lang="scss" module>
+.transition_x_enterActive,
+.transition_x_leaveActive {
+ transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
+}
+.transition_x_enterFrom {
+ opacity: 0;
+ transform: translateX(50px);
+}
+.transition_x_leaveTo {
+ opacity: 0;
+ transform: translateX(-50px);
+}
+</style>
diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue
index 8bb8637dda..d9f6716f92 100644
--- a/packages/frontend/src/components/MkSwitch.vue
+++ b/packages/frontend/src/components/MkSwitch.vue
@@ -9,7 +9,7 @@
:disabled="disabled"
@keydown.enter="toggle"
>
- <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle">
+ <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" data-cy-switch-toggle @click.prevent="toggle">
<div class="knob"></div>
</span>
<span class="label">
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index d54d93eaee..1ca5ba6ca7 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -41,7 +41,7 @@
<template #value>{{ instance.maintainerEmail }}</template>
</MkKeyValue>
</FormSplit>
- <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.tos }}</FormLink>
+ <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
</div>
</FormSection>
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index ebe1a8ade0..e7e3cb5368 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -3,10 +3,15 @@
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<FormSuspense :p="init">
<div class="_gaps_m">
<FormSection first>
<div class="_gaps_m">
+ <MkInput v-model="tosUrl">
+ <template #prefix><i class="ti ti-link"></i></template>
+ <template #label>{{ i18n.ts.tosUrl }}</template>
+ </MkInput>
<MkTextarea v-model="sensitiveWords">
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template>
@@ -41,16 +46,20 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue';
+import FormLink from "@/components/form/link.vue";
let sensitiveWords: string = $ref('');
+let tosUrl: string | null = $ref(null);
async function init() {
const meta = await os.api('admin/meta');
sensitiveWords = meta.sensitiveWords.join('\n');
+ tosUrl = meta.tosUrl;
}
function save() {
os.apiWithDialog('admin/update-meta', {
+ tosUrl,
sensitiveWords: sensitiveWords.split('\n'),
}).then(() => {
fetchInstance();
diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue
new file mode 100644
index 0000000000..85781c0bd0
--- /dev/null
+++ b/packages/frontend/src/pages/admin/server-rules.vue
@@ -0,0 +1,128 @@
+<template>
+<div>
+ <MkStickyContainer>
+ <template #header><XHeader :tabs="headerTabs"/></template>
+ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
+ <div class="_gaps_m">
+ <div>{{ i18n.ts._serverRules.description }}</div>
+ <Sortable
+ v-model="serverRules"
+ class="_gaps_m"
+ :item-key="(_, i) => i"
+ :animation="150"
+ :handle="'.' + $style.itemHandle"
+ @start="e => e.item.classList.add('active')"
+ @end="e => e.item.classList.remove('active')"
+ >
+ <template #item="{element,index}">
+ <div :class="$style.item">
+ <div :class="$style.itemHeader">
+ <div :class="$style.itemNumber" v-text="String(index + 1)"/>
+ <span :class="$style.itemHandle"><i class="ti ti-menu"/></span>
+ <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button>
+ </div>
+ <MkInput v-model="serverRules[index]"/>
+ </div>
+ </template>
+ </Sortable>
+ <div :class="$style.commands">
+ <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
+ <MkButton primary rounded :class="$style.buttonSave" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+ </MkStickyContainer>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import XHeader from './_header_.vue';
+import * as os from '@/os';
+import { fetchInstance, instance } from '@/instance';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkButton from '@/components/MkButton.vue';
+import MkInput from '@/components/MkInput.vue';
+
+const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
+
+let serverRules: string[] = $ref(instance.serverRules);
+
+const save = async () => {
+ await os.apiWithDialog('admin/update-meta', {
+ serverRules,
+ });
+ fetchInstance();
+};
+
+const remove = (index: number): void => {
+ serverRules.splice(index, 1);
+};
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.serverRules,
+ icon: 'ti ti-checkbox',
+});
+</script>
+
+<style lang="scss" module>
+.item {
+ display: block;
+ color: var(--navFg);
+}
+
+.itemHeader {
+ display: flex;
+ margin-bottom: 8px;
+ align-items: center;
+}
+
+.itemHandle {
+ display: flex;
+ width: 40px;
+ height: 40px;
+ align-items: center;
+ justify-content: center;
+ cursor: move;
+}
+
+.itemNumber {
+ display: flex;
+ background-color: var(--accentedBg);
+ color: var(--accent);
+ font-size: 14px;
+ font-weight: bold;
+ width: 28px;
+ height: 28px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 999px;
+ margin-right: 8px;
+}
+
+.itemEdit {
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+}
+
+.itemRemove {
+ width: 40px;
+ height: 40px;
+ color: var(--error);
+ margin-left: auto;
+ border-radius: 6px;
+
+ &:hover {
+ background: var(--X5);
+ }
+}
+
+.commands {
+ display: flex;
+ gap: 16px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 65e64930d5..e9de6f7b0e 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -13,11 +13,6 @@
<template #label>{{ i18n.ts.instanceDescription }}</template>
</MkTextarea>
- <MkInput v-model="tosUrl">
- <template #prefix><i class="ti ti-link"></i></template>
- <template #label>{{ i18n.ts.tosUrl }}</template>
- </MkInput>
-
<FormSplit :min-width="300">
<MkInput v-model="maintainerName">
<template #label>{{ i18n.ts.maintainerName }}</template>
@@ -169,7 +164,6 @@ import MkButton from '@/components/MkButton.vue';
let name: string | null = $ref(null);
let description: string | null = $ref(null);
-let tosUrl: string | null = $ref(null);
let maintainerName: string | null = $ref(null);
let maintainerEmail: string | null = $ref(null);
let iconUrl: string | null = $ref(null);
@@ -194,7 +188,6 @@ async function init() {
const meta = await os.api('admin/meta');
name = meta.name;
description = meta.description;
- tosUrl = meta.tosUrl;
iconUrl = meta.iconUrl;
bannerUrl = meta.bannerUrl;
backgroundImageUrl = meta.backgroundImageUrl;
@@ -220,7 +213,6 @@ function save() {
os.apiWithDialog('admin/update-meta', {
name,
description,
- tosUrl,
iconUrl,
bannerUrl,
backgroundImageUrl,
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 0769ec2614..fa19682f32 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -428,6 +428,10 @@ export const routes = [{
name: 'other-settings',
component: page(() => import('./pages/admin/other-settings.vue')),
}, {
+ path: '/server-rules',
+ name: 'server-rules',
+ component: page(() => import('./pages/admin/server-rules.vue')),
+ }, {
path: '/',
component: page(() => import('./pages/_empty_.vue')),
}],
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index 710b08d9e0..af1b97d87f 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -164,7 +164,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
animation: {
where: 'device',
- default: !matchMedia('(prefers-reduced-motion)').matches,
+ default: !window.matchMedia('(prefers-reduced-motion)').matches,
},
animatedMfm: {
where: 'device',
@@ -188,7 +188,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
disableShowingAnimatedImages: {
where: 'device',
- default: matchMedia('(prefers-reduced-motion)').matches,
+ default: window.matchMedia('(prefers-reduced-motion)').matches,
},
emojiStyle: {
where: 'device',