Merge upstream
This commit is contained in:
commit
4b6cbdab39
73 changed files with 1199 additions and 356 deletions
|
@ -6,7 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div v-if="user" :class="$style.root">
|
||||
<i class="ti ti-plane-departure" style="margin-right: 8px;"></i>
|
||||
{{ i18n.ts.accountMoved }}
|
||||
<span v-if="movedTo">{{ i18n.ts.accountMoved }}</span>
|
||||
<span v-if="movedFrom">{{ i18n.ts.accountMovedFrom }}</span>
|
||||
<MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -22,10 +23,11 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
|||
const user = ref<Misskey.entities.UserLite>();
|
||||
|
||||
const props = defineProps<{
|
||||
movedTo: string; // user id
|
||||
movedTo?: string; // user id
|
||||
movedFrom?: string; // user id
|
||||
}>();
|
||||
|
||||
misskeyApi('users/show', { userId: props.movedTo }).then(u => user.value = u);
|
||||
misskeyApi('users/show', { userId: props.movedTo ?? props.movedFrom }).then(u => user.value = u);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
@ -46,6 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts">
|
||||
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
|
@ -189,6 +190,7 @@ const mfmTags = ref<string[]>([]);
|
|||
const mfmParams = ref<string[]>([]);
|
||||
const select = ref(-1);
|
||||
const zIndex = os.claimZIndex('high');
|
||||
const abortController = ref<AbortController>();
|
||||
|
||||
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
|
||||
emit('done', { type, value });
|
||||
|
@ -217,6 +219,39 @@ function setPosition() {
|
|||
}
|
||||
}
|
||||
|
||||
const searchUsers = debounce(1000, (query: string, cacheKey: string) => {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
}
|
||||
abortController.value = new AbortController();
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: query,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}, undefined, abortController.value.signal).then(searchedUsers => {
|
||||
users.value = searchedUsers as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
|
||||
});
|
||||
});
|
||||
|
||||
const searchHashtags = debounce(1000, (query: string, cacheKey: string) => {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
}
|
||||
abortController.value = new AbortController();
|
||||
misskeyApi('hashtags/search', {
|
||||
query,
|
||||
limit: 30,
|
||||
}, undefined, abortController.value.signal).then(searchedHashtags => {
|
||||
hashtags.value = searchedHashtags as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
|
||||
});
|
||||
});
|
||||
|
||||
function exec() {
|
||||
select.value = -1;
|
||||
if (suggests.value) {
|
||||
|
@ -238,16 +273,7 @@ function exec() {
|
|||
users.value = JSON.parse(cache);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: props.q,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(searchedUsers => {
|
||||
users.value = searchedUsers as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
|
||||
});
|
||||
searchUsers(props.q, cacheKey);
|
||||
}
|
||||
} else if (props.type === 'hashtag') {
|
||||
if (!props.q || props.q === '') {
|
||||
|
@ -261,15 +287,7 @@ function exec() {
|
|||
hashtags.value = hashtags;
|
||||
fetching.value = false;
|
||||
} else {
|
||||
misskeyApi('hashtags/search', {
|
||||
query: props.q,
|
||||
limit: 30,
|
||||
}).then(searchedHashtags => {
|
||||
hashtags.value = searchedHashtags as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
|
||||
});
|
||||
searchHashtags(props.q, cacheKey);
|
||||
}
|
||||
}
|
||||
} else if (props.type === 'emoji') {
|
||||
|
|
|
@ -594,6 +594,7 @@ defineExpose({
|
|||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
@ -700,7 +701,7 @@ defineExpose({
|
|||
|
||||
> .item {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
padding: 0 3px;
|
||||
width: var(--eachSize);
|
||||
height: var(--eachSize);
|
||||
contain: strict;
|
||||
|
|
|
@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:withTooltip="true"
|
||||
:reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')"
|
||||
:noStyle="true"
|
||||
style="width: 100%; height: 100%;"
|
||||
style="width: 100%; height: 100% !important; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -123,7 +123,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:withTooltip="true"
|
||||
:reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')"
|
||||
:noStyle="true"
|
||||
style="width: 100%; height: 100%;"
|
||||
style="width: 100%; height: 100% !important; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -36,6 +36,7 @@ const emit = defineEmits<{
|
|||
.icon {
|
||||
display: block;
|
||||
width: 60px;
|
||||
max-height: 60px;
|
||||
font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
|
||||
margin: 0 auto;
|
||||
object-fit: contain;
|
||||
|
|
|
@ -63,6 +63,7 @@ function getReactionName(reaction: string): string {
|
|||
.reactionIcon {
|
||||
display: block;
|
||||
width: 60px;
|
||||
max-height: 60px;
|
||||
font-size: 60px; // unicodeな絵文字についてはwidthが効かないため
|
||||
object-fit: contain;
|
||||
margin: 0 auto;
|
||||
|
|
|
@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
{{ message }}
|
||||
</MkInfo>
|
||||
<div v-if="!totpLogin" class="normal-signin _gaps_m">
|
||||
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
|
||||
<MkInput v-model="username" :debounce="true" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
|
||||
<template #prefix>@</template>
|
||||
<template #suffix>@{{ host }}</template>
|
||||
</MkInput>
|
||||
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption><button class="_textButton" type="button" @click="resetPassword">{{ i18n.ts.forgotPassword }}</button></template>
|
||||
</MkInput>
|
||||
<MkButton type="submit" large primary rounded :disabled="signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
||||
<MkButton type="submit" large primary rounded :disabled="!user || signing" style="margin: 0 auto;">{{ signing ? i18n.ts.loggingIn : i18n.ts.login }}</MkButton>
|
||||
</div>
|
||||
<div v-if="totpLogin" class="2fa-signin" :class="{ securityKeys: user && user.securityKeys }">
|
||||
<div v-if="user && user.securityKeys" class="twofa-group tap-group">
|
||||
|
@ -64,6 +64,7 @@ import { login } from '@/account.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const signing = ref(false);
|
||||
const userAbortController = ref<AbortController>();
|
||||
const user = ref<Misskey.entities.UserDetailed | null>(null);
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
|
@ -97,9 +98,13 @@ const props = defineProps({
|
|||
});
|
||||
|
||||
function onUsernameChange(): void {
|
||||
if (userAbortController.value) {
|
||||
userAbortController.value.abort();
|
||||
}
|
||||
userAbortController.value = new AbortController();
|
||||
misskeyApi('users/show', {
|
||||
username: username.value,
|
||||
}).then(userResponse => {
|
||||
}, undefined, userAbortController.value.signal).then(userResponse => {
|
||||
user.value = userResponse;
|
||||
}, () => {
|
||||
user.value = null;
|
||||
|
|
|
@ -189,7 +189,7 @@ async function onSubmit(): Promise<void> {
|
|||
submitting.value = true;
|
||||
|
||||
try {
|
||||
await misskeyApi('signup', {
|
||||
await os.apiWithDialog('signup', {
|
||||
username: username.value,
|
||||
password: password.value.password,
|
||||
emailAddress: email.value,
|
||||
|
@ -198,35 +198,27 @@ async function onSubmit(): Promise<void> {
|
|||
'm-captcha-response': mCaptchaResponse.value,
|
||||
'g-recaptcha-response': reCaptchaResponse.value,
|
||||
'turnstile-response': turnstileResponse.value,
|
||||
});
|
||||
if (instance.emailRequiredForSignup) {
|
||||
os.alert({
|
||||
type: 'success',
|
||||
title: i18n.ts._signup.almostThere,
|
||||
text: i18n.tsx._signup.emailSent({ email: email.value }),
|
||||
});
|
||||
emit('signupEmailPending');
|
||||
} else {
|
||||
const res = await misskeyApi('signin', {
|
||||
username: username.value,
|
||||
password: password.value.password,
|
||||
});
|
||||
emit('signup', res);
|
||||
}, undefined, (res) => {
|
||||
if (instance.emailRequiredForSignup) {
|
||||
os.alert({
|
||||
type: 'success',
|
||||
title: i18n.ts._signup.almostThere,
|
||||
text: i18n.tsx._signup.emailSent({ email: email.value }),
|
||||
});
|
||||
emit('signupEmailPending');
|
||||
} else {
|
||||
emit('signup', { id: res.id, i: res.token });
|
||||
|
||||
if (props.autoSet) {
|
||||
return login(res.i, '/onboarding');
|
||||
if (props.autoSet) {
|
||||
login(res.i, '/onboarding');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
submitting.value = false;
|
||||
hcaptcha.value?.reset?.();
|
||||
recaptcha.value?.reset?.();
|
||||
turnstile.value?.reset?.();
|
||||
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
{{ i18n.ts._announcement.silence }}
|
||||
<template #caption>{{ i18n.ts._announcement.silenceDescription }}</template>
|
||||
</MkSwitch>
|
||||
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }}</p>
|
||||
<p v-if="reads">{{ i18n.tsx.nUsersRead({ n: reads }) }} <span v-if="lastReadAt">(<MkTime :time="lastReadAt" mode="absolute"/>)</span></p>
|
||||
<MkUserCardMini v-if="props.user.id" :user="props.user"></MkUserCardMini>
|
||||
<MkButton v-if="announcement" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
|
@ -94,6 +94,7 @@ const closeDuration = ref<number>(props.announcement ? props.announcement.closeD
|
|||
const displayOrder = ref<number>(props.announcement ? props.announcement.displayOrder : 0);
|
||||
const silence = ref<boolean>(props.announcement ? props.announcement.silence : false);
|
||||
const reads = ref<number>(props.announcement ? props.announcement.reads : 0);
|
||||
const lastReadAt = ref<string | null>(props.announcement ? props.announcement.lastReadAt : null);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
|
||||
|
|
|
@ -16,16 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header>{{ i18n.ts.selectUser }}</template>
|
||||
<div>
|
||||
<div :class="$style.form">
|
||||
<MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search">
|
||||
<MkInput v-if="localOnly" v-model="username" :debounce="true" :autofocus="true" @update:modelValue="search">
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
<FormSplit v-else :minWidth="170">
|
||||
<MkInput v-model="username" :autofocus="true" @update:modelValue="search">
|
||||
<MkInput v-model="username" :debounce="true" :autofocus="true" @update:modelValue="search">
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="host" :datalist="[hostname]" @update:modelValue="search">
|
||||
<MkInput v-model="host" :debounce="true" :datalist="[hostname]" @update:modelValue="search">
|
||||
<template #label>{{ i18n.ts.host }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
|
@ -92,18 +92,23 @@ const users = ref<Misskey.entities.UserLite[]>([]);
|
|||
const recentUsers = ref<Misskey.entities.UserDetailed[]>([]);
|
||||
const selected = ref<Misskey.entities.UserLite | null>(null);
|
||||
const dialogEl = ref();
|
||||
const abortController = ref<AbortController>();
|
||||
|
||||
function search() {
|
||||
if (abortController.value) {
|
||||
abortController.value.abort();
|
||||
}
|
||||
if (username.value === '' && host.value === '') {
|
||||
users.value = [];
|
||||
return;
|
||||
}
|
||||
abortController.value = new AbortController();
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: username.value,
|
||||
host: props.localOnly ? '.' : host.value,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(_users => {
|
||||
}, undefined, abortController.value.signal).then(_users => {
|
||||
users.value = _users.filter((u) => {
|
||||
if (props.includeSelf) {
|
||||
return true;
|
||||
|
|
|
@ -34,60 +34,77 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints = keyof Misskey
|
|||
endpoint: E,
|
||||
data: P = {} as P,
|
||||
token?: string | null | undefined,
|
||||
) => {
|
||||
onSuccess?: ((res: Misskey.api.SwitchCaseResponseType<E, P>) => void) | null | undefined,
|
||||
onFailure?: ((err: Misskey.api.APIError) => void) | null,
|
||||
): Promise<Misskey.api.SwitchCaseResponseType<E, P>> => {
|
||||
const promise = misskeyApi(endpoint, data, token);
|
||||
promiseDialog(promise, null, async (err) => {
|
||||
let title: string | undefined;
|
||||
let text = err.message + '\n' + err.id;
|
||||
if (err.code === 'INTERNAL_ERROR') {
|
||||
title = i18n.ts.internalServerError;
|
||||
text = i18n.ts.internalServerErrorDescription;
|
||||
const date = new Date().toISOString();
|
||||
const { result } = await actions({
|
||||
type: 'error',
|
||||
title,
|
||||
text,
|
||||
details: err.info,
|
||||
actions: [{
|
||||
value: 'ok',
|
||||
text: i18n.ts.gotIt,
|
||||
primary: true,
|
||||
}, {
|
||||
value: 'copy',
|
||||
text: i18n.ts.copyErrorInfo,
|
||||
}],
|
||||
});
|
||||
if (result === 'copy') {
|
||||
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
|
||||
success();
|
||||
}
|
||||
return;
|
||||
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
title = i18n.ts.cannotPerformTemporary;
|
||||
text = i18n.ts.cannotPerformTemporaryDescription;
|
||||
} else if (err.code === 'INVALID_PARAM') {
|
||||
title = i18n.ts.invalidParamError;
|
||||
text = i18n.ts.invalidParamErrorDescription;
|
||||
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
|
||||
title = i18n.ts.permissionDeniedError;
|
||||
text = i18n.ts.permissionDeniedErrorDescription;
|
||||
} else if (err.code.startsWith('TOO_MANY')) {
|
||||
title = i18n.ts.youCannotCreateAnymore;
|
||||
text = `${i18n.ts.error}: ${err.id}`;
|
||||
} else if (err.message.startsWith('Unexpected token')) {
|
||||
title = i18n.ts.gotInvalidResponseError;
|
||||
text = i18n.ts.gotInvalidResponseErrorDescription;
|
||||
}
|
||||
alert({
|
||||
promiseDialog(promise, onSuccess, onFailure ?? (err => apiErrorHandler(err, endpoint)));
|
||||
|
||||
return promise;
|
||||
});
|
||||
|
||||
export async function apiErrorHandler(err: Misskey.api.APIError, endpoint?: string): Promise<void> {
|
||||
let title: string | undefined;
|
||||
let text = err.message + '\n' + err.id;
|
||||
|
||||
if (err.code === 'INTERNAL_ERROR') {
|
||||
title = i18n.ts.internalServerError;
|
||||
text = i18n.ts.internalServerErrorDescription;
|
||||
const date = new Date().toISOString();
|
||||
const { result } = await actions({
|
||||
type: 'error',
|
||||
title,
|
||||
text,
|
||||
details: err.info,
|
||||
actions: [{
|
||||
value: 'ok',
|
||||
text: i18n.ts.gotIt,
|
||||
primary: true,
|
||||
}, {
|
||||
value: 'copy',
|
||||
text: i18n.ts.copyErrorInfo,
|
||||
}],
|
||||
});
|
||||
});
|
||||
if (result === 'copy') {
|
||||
copyToClipboard(`Endpoint: ${endpoint}\nInfo: ${JSON.stringify(err.info)}\nDate: ${date}`);
|
||||
success();
|
||||
}
|
||||
return;
|
||||
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
title = i18n.ts.cannotPerformTemporary;
|
||||
text = i18n.ts.cannotPerformTemporaryDescription;
|
||||
} else if (err.code === 'INVALID_PARAM') {
|
||||
title = i18n.ts.invalidParamError;
|
||||
text = i18n.ts.invalidParamErrorDescription;
|
||||
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
|
||||
title = i18n.ts.permissionDeniedError;
|
||||
text = i18n.ts.permissionDeniedErrorDescription;
|
||||
} else if (err.code?.startsWith('TOO_MANY')) {
|
||||
title = i18n.ts.youCannotCreateAnymore;
|
||||
text = `${i18n.ts.error}: ${err.id}`;
|
||||
}
|
||||
|
||||
return promise;
|
||||
}) as typeof misskeyApi;
|
||||
// @ts-expect-error Misskey内部で定義されていない不明なエラー
|
||||
if (!err.id && (err.statusCode ?? 0) > 499) {
|
||||
title = i18n.ts.gotInvalidResponseError;
|
||||
text = i18n.ts.gotInvalidResponseErrorDescription;
|
||||
}
|
||||
|
||||
if (err.id && !title) {
|
||||
title = i18n.ts.somethingHappened;
|
||||
} else if (!title) {
|
||||
title = i18n.ts.somethingHappened;
|
||||
text = err.message;
|
||||
}
|
||||
|
||||
alert({
|
||||
type: 'error',
|
||||
title,
|
||||
text,
|
||||
// @ts-expect-error Misskeyのエラーならinfoを、そうでなければそのまま表示
|
||||
details: err.id ? err.info : err as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
export function promiseDialog<T>(
|
||||
promise: Promise<T>,
|
||||
|
|
|
@ -149,7 +149,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
|
||||
</span>
|
||||
<span>{{ announcement.title }}</span>
|
||||
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span>
|
||||
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -76,7 +76,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
|
||||
{{ i18n.ts._announcement.silence }}
|
||||
</MkSwitch>
|
||||
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
|
||||
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }} <span v-if="announcement.lastReadAt">(<MkTime :time="announcement.lastReadAt" mode="absolute"/>)</span></p>
|
||||
<MkUserCardMini v-if="announcement.userId" :user="announcement.user" @click="editUser(announcement)"></MkUserCardMini>
|
||||
<MkButton v-else class="button" inline primary @click="editUser(announcement)">{{ i18n.ts.specifyUser }}</MkButton>
|
||||
<div class="buttons _buttons">
|
||||
|
|
|
@ -156,6 +156,11 @@ const menuDef = computed(() => [{
|
|||
text: i18n.ts.moderationLogs,
|
||||
to: '/admin/modlog',
|
||||
active: currentPage.value?.route.name === 'modlog',
|
||||
}, {
|
||||
icon: 'ti ti-list-search',
|
||||
text: i18n.ts.userAccountMoveLogs,
|
||||
to: '/admin/useraccountmovelog',
|
||||
active: currentPage.value?.route.name === 'useraccountmovelog',
|
||||
}],
|
||||
}, {
|
||||
title: i18n.ts.settings,
|
||||
|
|
98
packages/frontend/src/pages/admin/useraccountmovelog.vue
Normal file
98
packages/frontend/src/pages/admin/useraccountmovelog.vue
Normal file
|
@ -0,0 +1,98 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="900">
|
||||
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
|
||||
<MkInput v-model="movedFromId" style="margin: 0; flex: 1;">
|
||||
<template #label> {{ i18n.ts.moveFromId }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="movedToId" style="margin: 0; flex: 1;">
|
||||
<template #label> {{ i18n.ts.movedToId }}</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
|
||||
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="item in items" :key="item.id">
|
||||
<template #label>
|
||||
{{ i18n.tsx.userAccountMoveLogsTitle({
|
||||
from: '@' + item.movedFrom.username + (item.movedFrom.host ? `@${item.movedFrom.host}` : ''),
|
||||
to: '@' + item.movedTo.username + (item.movedTo.host ? `@${item.movedTo.host}` : '')
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<div :class="$style.card">
|
||||
<MkA :to="userPage(item.movedFrom)" :class="$style.cardContent">
|
||||
<MkAvatar :user="item.movedFrom" :class="$style.avatar" link/>
|
||||
<MkAcct :user="item.movedFrom"/>
|
||||
</MkA>
|
||||
→
|
||||
<MkA :to="userPage(item.movedTo)" :class="$style.cardContent">
|
||||
<MkAvatar :user="item.movedTo" :class="$style.avatar"/>
|
||||
<MkAcct :user="item.movedTo"/>
|
||||
</MkA>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, shallowRef, ref } from 'vue';
|
||||
import XHeader from './_header_.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
const logs = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
const movedToId = ref('');
|
||||
const movedFromId = ref('');
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'admin/show-user-account-move-logs' as const,
|
||||
limit: 30,
|
||||
params: computed(() => ({
|
||||
movedFromId: movedFromId.value === '' ? null : movedFromId.value,
|
||||
movedToId: movedToId.value === '' ? null : movedToId.value,
|
||||
})),
|
||||
};
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: i18n.ts.userAccountMoveLogs,
|
||||
icon: 'ti ti-list-search',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
display: flex;
|
||||
gap: var(--margin);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--margin);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.cardContent{
|
||||
display: flex;
|
||||
gap: var(--margin);
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
</style>
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSpacer :contentMax="700">
|
||||
<div class="_gaps_m">
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="endpoint" :datalist="endpoints" @update:modelValue="onEndpointChange()">
|
||||
<MkInput v-model="endpoint" :debounce="true" :datalist="endpoints" @update:modelValue="onEndpointChange()">
|
||||
<template #label>Endpoint</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-model="body" code>
|
||||
|
@ -50,6 +50,7 @@ const endpoints = ref<string[]>([]);
|
|||
const sending = ref(false);
|
||||
const res = ref('');
|
||||
const withCredential = ref(true);
|
||||
const endpointAbortController = ref<AbortController>();
|
||||
|
||||
misskeyApi('endpoints').then(endpointResponse => {
|
||||
endpoints.value = endpointResponse;
|
||||
|
@ -68,7 +69,11 @@ function send() {
|
|||
}
|
||||
|
||||
function onEndpointChange() {
|
||||
misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => {
|
||||
if (endpointAbortController.value) {
|
||||
endpointAbortController.value.abort();
|
||||
}
|
||||
endpointAbortController.value = new AbortController();
|
||||
misskeyApi('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null, endpointAbortController.value.signal).then(resp => {
|
||||
const endpointBody = {};
|
||||
for (const p of resp.params) {
|
||||
endpointBody[p.name] =
|
||||
|
|
|
@ -349,6 +349,7 @@ definePageMetadata(() => ({
|
|||
> .img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
> .body {
|
||||
|
@ -395,6 +396,7 @@ definePageMetadata(() => ({
|
|||
> .img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
> .body {
|
||||
|
|
|
@ -132,7 +132,9 @@ async function deleteAccount() {
|
|||
{
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.deleteAccountConfirm,
|
||||
text: i18n.ts.deleteAccountConfirmAndWarn,
|
||||
okWaitInitiate: 'dialog',
|
||||
okWaitDuration: 5,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
@ -147,6 +149,7 @@ async function deleteAccount() {
|
|||
|
||||
await os.alert({
|
||||
title: i18n.ts._accountDelete.started,
|
||||
text: i18n.ts._accountDelete.dontLogin,
|
||||
});
|
||||
|
||||
await signout();
|
||||
|
|
|
@ -13,6 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<div class="profile _gaps">
|
||||
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
|
||||
<MkAccountMoved v-if="movedFromLog" :movedFrom="movedFromLog[0]?.movedFromId"/>
|
||||
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/>
|
||||
|
||||
<div :key="user.id" class="main _panel">
|
||||
|
@ -281,6 +282,7 @@ const memoDraft = ref(props.user.memo);
|
|||
const isEditingMemo = ref(false);
|
||||
const moderationNote = ref(props.user.moderationNote);
|
||||
const editModerationNote = ref(false);
|
||||
const movedFromLog = ref<null | {movedFromId:string;}[]>(null);
|
||||
|
||||
const hideModerationNote = !iAmModerator || (defaultStore.state.privateMode && defaultStore.state.hideModerationLog);
|
||||
const hideRoleList = defaultStore.state.privateMode && defaultStore.state.hideRoleList;
|
||||
|
@ -307,6 +309,15 @@ function menu(ev: MouseEvent) {
|
|||
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
|
||||
}
|
||||
|
||||
async function fetchMovedFromLog() {
|
||||
if (!props.user.id) {
|
||||
movedFromLog.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
movedFromLog.value = await misskeyApi('admin/show-user-account-move-logs', { movedToId: props.user.id });
|
||||
}
|
||||
|
||||
function parallaxLoop() {
|
||||
parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop);
|
||||
parallax();
|
||||
|
@ -383,6 +394,9 @@ function buildSkebStatus(): string {
|
|||
watch([props.user], () => {
|
||||
memoDraft.value = props.user.memo;
|
||||
fetchSkebStatus();
|
||||
if ($i?.isModerator) {
|
||||
fetchMovedFromLog();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -401,6 +415,9 @@ onMounted(() => {
|
|||
}
|
||||
}
|
||||
fetchSkebStatus();
|
||||
if ($i?.isModerator) {
|
||||
fetchMovedFromLog();
|
||||
}
|
||||
nextTick(() => {
|
||||
adjustMemoTextarea();
|
||||
});
|
||||
|
|
|
@ -436,6 +436,10 @@ const routes: RouteDef[] = [{
|
|||
path: '/modlog',
|
||||
name: 'modlog',
|
||||
component: page(() => import('@/pages/admin/modlog.vue')),
|
||||
}, {
|
||||
path: '/useraccountmovelog',
|
||||
name: 'useraccountmovelog',
|
||||
component: page(() => import('@/pages/admin/useraccountmovelog.vue')),
|
||||
}, {
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
|
|
|
@ -44,14 +44,15 @@ export function misskeyApi<
|
|||
},
|
||||
signal,
|
||||
}).then(async (res) => {
|
||||
const body = res.status === 204 ? null : await res.json();
|
||||
|
||||
if (res.status === 200) {
|
||||
if (res.ok && res.status !== 204) {
|
||||
const body = await res.json();
|
||||
resolve(body);
|
||||
} else if (res.status === 204) {
|
||||
resolve(undefined as _ResT); // void -> undefined
|
||||
} else {
|
||||
reject(body.error);
|
||||
// エラー応答で JSON.parse に失敗した場合は HTTP ステータスコードとメッセージを返す
|
||||
const body = await res.json().catch(() => ({ statusCode: res.status, message: res.statusText }));
|
||||
reject(typeof body.error === 'object' ? body.error : body);
|
||||
}
|
||||
}).catch(reject);
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue