merge upstream 2025-02-03
This commit is contained in:
commit
a4e86758c1
264 changed files with 15775 additions and 4919 deletions
|
|
@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template v-else-if="botProtectionForm.savedState.provider === 'fc'" #suffix>FriendlyCaptcha</template>
|
||||
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
|
||||
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
|
||||
<template v-if="botProtectionForm.modified.value" #footer>
|
||||
<MkFormFooter :form="botProtectionForm"/>
|
||||
<template #footer>
|
||||
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
|
||||
</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkRadios v-model="botProtectionForm.state.provider">
|
||||
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
|
||||
<option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
|
||||
<option value="hcaptcha">hCaptcha</option>
|
||||
<option value="mcaptcha">mCaptcha</option>
|
||||
<option value="recaptcha">reCAPTCHA</option>
|
||||
|
|
@ -30,71 +30,126 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkRadios>
|
||||
|
||||
<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
|
||||
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="hcaptcha"
|
||||
:sitekey="botProtectionForm.state.hcaptchaSiteKey"
|
||||
:secretKey="botProtectionForm.state.hcaptchaSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
|
||||
<div>
|
||||
<span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce>
|
||||
<template #prefix><i class="ti ti-link"></i></template>
|
||||
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
|
||||
</MkInput>
|
||||
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"/>
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="mcaptcha"
|
||||
:sitekey="botProtectionForm.state.mcaptchaSiteKey"
|
||||
:secretKey="botProtectionForm.state.mcaptchaSecretKey"
|
||||
:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"
|
||||
/>
|
||||
</FormSlot>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'recaptcha'">
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="recaptcha" :sitekey="botProtectionForm.state.recaptchaSiteKey"/>
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="recaptcha"
|
||||
:sitekey="botProtectionForm.state.recaptchaSiteKey"
|
||||
:secretKey="botProtectionForm.state.recaptchaSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
|
||||
<div>
|
||||
<span>ref: </span>
|
||||
<a
|
||||
href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do"
|
||||
target="_blank"
|
||||
>reCAPTCHA FAQ</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'turnstile'">
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
|
||||
</MkInput>
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
|
||||
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="turnstile"
|
||||
:sitekey="botProtectionForm.state.turnstileSiteKey"
|
||||
:secretKey="botProtectionForm.state.turnstileSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>
|
||||
{{ i18n.ts._captcha.testSiteKeyMessage }}
|
||||
</div>
|
||||
<div>
|
||||
<span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'fc'">
|
||||
<MkInput v-model="botProtectionForm.state.fcSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.fcSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.fcSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.fcSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
|
|
@ -102,12 +157,32 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="fc" :sitekey="botProtectionForm.state.fcSiteKey"/>
|
||||
</FormSlot>
|
||||
<FormSlot v-if="botProtectionForm.state.fcSiteKey">
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="fc"
|
||||
:sitekey="botProtectionForm.state.fcSiteKey"
|
||||
:secretKey="botProtectionForm.state.fcSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>
|
||||
{{ i18n.ts._captcha.testSiteKeyMessage }}
|
||||
</div>
|
||||
<div>
|
||||
<span>ref: </span><a href="https://docs.friendlycaptcha.com/#/installation?id=_3-verifying-the-captcha-solution-on-the-server" target="_blank">FriendlyCaptcha Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
|
||||
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="testcaptcha"/>
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
|
||||
</FormSlot>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -115,7 +190,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
|
|
@ -127,56 +203,113 @@ import { useForm } from '@/scripts/use-form.js';
|
|||
import MkFormFooter from '@/components/MkFormFooter.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { ApiWithDialogCustomErrors } from '@/os.js';
|
||||
|
||||
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
|
||||
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
const errorHandler: ApiWithDialogCustomErrors = {
|
||||
// 検証リクエストそのものに失敗
|
||||
'0f4fe2f1-2c15-4d6e-b714-efbfcde231cd': {
|
||||
title: i18n.ts._captcha._error._requestFailed.title,
|
||||
text: i18n.ts._captcha._error._requestFailed.text,
|
||||
},
|
||||
// 検証リクエストの結果が不正
|
||||
'c41c067f-24f3-4150-84b2-b5a3ae8c2214': {
|
||||
title: i18n.ts._captcha._error._verificationFailed.title,
|
||||
text: i18n.ts._captcha._error._verificationFailed.text,
|
||||
},
|
||||
// 不明なエラー
|
||||
'f868d509-e257-42a9-99c1-42614b031a97': {
|
||||
title: i18n.ts._captcha._error._unknown.title,
|
||||
text: i18n.ts._captcha._error._unknown.text,
|
||||
},
|
||||
};
|
||||
|
||||
const captchaResult = ref<string | null>(null);
|
||||
|
||||
const meta = await misskeyApi('admin/captcha/current');
|
||||
const botProtectionForm = useForm({
|
||||
provider: meta.enableHcaptcha
|
||||
? 'hcaptcha'
|
||||
: meta.enableRecaptcha
|
||||
? 'recaptcha'
|
||||
: meta.enableTurnstile
|
||||
? 'turnstile'
|
||||
: meta.enableMcaptcha
|
||||
? 'mcaptcha'
|
||||
: meta.enableFC
|
||||
? 'fc'
|
||||
: meta.enableTestcaptcha
|
||||
? 'testcaptcha'
|
||||
: null,
|
||||
hcaptchaSiteKey: meta.hcaptchaSiteKey,
|
||||
hcaptchaSecretKey: meta.hcaptchaSecretKey,
|
||||
mcaptchaSiteKey: meta.mcaptchaSiteKey,
|
||||
mcaptchaSecretKey: meta.mcaptchaSecretKey,
|
||||
mcaptchaInstanceUrl: meta.mcaptchaInstanceUrl,
|
||||
recaptchaSiteKey: meta.recaptchaSiteKey,
|
||||
recaptchaSecretKey: meta.recaptchaSecretKey,
|
||||
turnstileSiteKey: meta.turnstileSiteKey,
|
||||
turnstileSecretKey: meta.turnstileSecretKey,
|
||||
fcSiteKey: meta.fcSiteKey,
|
||||
fcSecretKey: meta.fcSecretKey,
|
||||
provider: meta.provider,
|
||||
hcaptchaSiteKey: meta.hcaptcha.siteKey,
|
||||
hcaptchaSecretKey: meta.hcaptcha.secretKey,
|
||||
mcaptchaSiteKey: meta.mcaptcha.siteKey,
|
||||
mcaptchaSecretKey: meta.mcaptcha.secretKey,
|
||||
mcaptchaInstanceUrl: meta.mcaptcha.instanceUrl,
|
||||
recaptchaSiteKey: meta.recaptcha.siteKey,
|
||||
recaptchaSecretKey: meta.recaptcha.secretKey,
|
||||
turnstileSiteKey: meta.turnstile.siteKey,
|
||||
turnstileSecretKey: meta.turnstile.secretKey,
|
||||
fcSiteKey: meta.fc.siteKey,
|
||||
fcSecretKey: meta.fc.secretKey,
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
enableHcaptcha: state.provider === 'hcaptcha',
|
||||
hcaptchaSiteKey: state.hcaptchaSiteKey,
|
||||
hcaptchaSecretKey: state.hcaptchaSecretKey,
|
||||
enableMcaptcha: state.provider === 'mcaptcha',
|
||||
mcaptchaSiteKey: state.mcaptchaSiteKey,
|
||||
mcaptchaSecretKey: state.mcaptchaSecretKey,
|
||||
mcaptchaInstanceUrl: state.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: state.provider === 'recaptcha',
|
||||
recaptchaSiteKey: state.recaptchaSiteKey,
|
||||
recaptchaSecretKey: state.recaptchaSecretKey,
|
||||
enableTurnstile: state.provider === 'turnstile',
|
||||
turnstileSiteKey: state.turnstileSiteKey,
|
||||
turnstileSecretKey: state.turnstileSecretKey,
|
||||
enableFC: state.provider === 'fc',
|
||||
fcSiteKey: state.fcSiteKey,
|
||||
fcSecretKey: state.fcSecretKey,
|
||||
enableTestcaptcha: state.provider === 'testcaptcha',
|
||||
});
|
||||
fetchInstance(true);
|
||||
const provider = state.provider;
|
||||
if (provider === 'none') {
|
||||
await os.apiWithDialog(
|
||||
'admin/captcha/save',
|
||||
{ provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'] },
|
||||
undefined,
|
||||
errorHandler,
|
||||
);
|
||||
} else {
|
||||
const sitekey = provider === 'hcaptcha'
|
||||
? state.hcaptchaSiteKey
|
||||
: provider === 'mcaptcha'
|
||||
? state.mcaptchaSiteKey
|
||||
: provider === 'recaptcha'
|
||||
? state.recaptchaSiteKey
|
||||
: provider === 'turnstile'
|
||||
? state.turnstileSiteKey
|
||||
: provider === 'fc'
|
||||
? state.fcSiteKey
|
||||
: null;
|
||||
const secret = provider === 'hcaptcha'
|
||||
? state.hcaptchaSecretKey
|
||||
: provider === 'mcaptcha'
|
||||
? state.mcaptchaSecretKey
|
||||
: provider === 'recaptcha'
|
||||
? state.recaptchaSecretKey
|
||||
: provider === 'turnstile'
|
||||
? state.turnstileSecretKey
|
||||
: provider === 'fc'
|
||||
? state.fcSecretKey
|
||||
: null;
|
||||
|
||||
await os.apiWithDialog(
|
||||
'admin/captcha/save',
|
||||
{
|
||||
provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'],
|
||||
sitekey: sitekey,
|
||||
secret: secret,
|
||||
instanceUrl: state.mcaptchaInstanceUrl,
|
||||
captchaResult: captchaResult.value,
|
||||
},
|
||||
undefined,
|
||||
errorHandler,
|
||||
);
|
||||
}
|
||||
|
||||
await fetchInstance(true);
|
||||
});
|
||||
|
||||
watch(botProtectionForm.state, () => {
|
||||
captchaResult.value = null;
|
||||
});
|
||||
|
||||
const canSaving = computed((): boolean => {
|
||||
return (botProtectionForm.state.provider === 'none') ||
|
||||
(botProtectionForm.state.provider === 'hcaptcha' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'mcaptcha' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'recaptcha' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'turnstile' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'testcaptcha' && !!captchaResult.value);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.captchaInfoMsg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type RequestLogItem = {
|
||||
failed: boolean;
|
||||
url: string;
|
||||
name: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const gridSortOrderKeys = [
|
||||
'name',
|
||||
'category',
|
||||
'aliases',
|
||||
'type',
|
||||
'license',
|
||||
'host',
|
||||
'uri',
|
||||
'publicUrl',
|
||||
'isSensitive',
|
||||
'localOnly',
|
||||
'updatedAt',
|
||||
] as const satisfies string[];
|
||||
|
||||
export type GridSortOrderKey = typeof gridSortOrderKeys[number];
|
||||
|
||||
export function emptyStrToUndefined(value: string | null) {
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
export function emptyStrToNull(value: string) {
|
||||
return value === '' ? null : value;
|
||||
}
|
||||
|
||||
export function emptyStrToEmptyArray(value: string) {
|
||||
return value === '' ? [] : value.split(' ').map(it => it.trim());
|
||||
}
|
||||
|
||||
export function roleIdsParser(text: string): { id: string, name: string }[] {
|
||||
// idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない
|
||||
try {
|
||||
const obj = JSON.parse(text);
|
||||
if (!Array.isArray(obj)) {
|
||||
return [];
|
||||
}
|
||||
if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return obj.map(it => ({ id: it.id, name: it.name }));
|
||||
} catch (ex) {
|
||||
console.warn(ex);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkWindow
|
||||
ref="uiWindow"
|
||||
:initialWidth="400"
|
||||
:initialHeight="500"
|
||||
:canResize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<i class="ti ti-notes" style="margin-right: 0.5em;"></i> {{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}
|
||||
</template>
|
||||
<MkSpacer>
|
||||
<XRegisterLogs :logs="logs"/>
|
||||
</MkSpacer>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
import type { RequestLogItem } from './custom-emojis-manager.impl.js';
|
||||
|
||||
defineProps<{
|
||||
logs: RequestLogItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkWindow
|
||||
ref="uiWindow"
|
||||
:initialWidth="400"
|
||||
:initialHeight="500"
|
||||
:canResize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<i class="ti ti-search" style="margin-right: 0.5em;"></i> {{ i18n.ts.search }}
|
||||
</template>
|
||||
<div :class="$style.root">
|
||||
<MkSpacer>
|
||||
<div class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkInput
|
||||
v-model="model.name"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>name</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.category"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>category</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.aliases"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>aliases</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="model.type"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>type</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.license"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>license</template>
|
||||
</MkInput>
|
||||
<MkSelect
|
||||
v-model="model.sensitive"
|
||||
>
|
||||
<template #label>sensitive</template>
|
||||
<option :value="null">-</option>
|
||||
<option :value="true">true</option>
|
||||
<option :value="false">false</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect
|
||||
v-model="model.localOnly"
|
||||
>
|
||||
<template #label>localOnly</template>
|
||||
<option :value="null">-</option>
|
||||
<option :value="true">true</option>
|
||||
<option :value="false">false</option>
|
||||
</MkSelect>
|
||||
<MkInput
|
||||
v-model="model.updatedAtFrom"
|
||||
type="date"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>updatedAt(from)</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.updatedAtTo"
|
||||
type="date"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>updatedAt(to)</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="queryRolesText"
|
||||
type="text"
|
||||
readonly
|
||||
autocapitalize="off"
|
||||
@click="onQueryRolesEditClicked"
|
||||
>
|
||||
<template #label>role</template>
|
||||
<template #suffix><i class="ti ti-pencil"></i></template>
|
||||
</MkInput>
|
||||
</div>
|
||||
<MkFolder :spacerMax="8" :spacerMin="8">
|
||||
<template #icon><i class="ti ti-arrows-sort"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
|
||||
<MkSortOrderEditor
|
||||
:baseOrderKeyNames="gridSortOrderKeys"
|
||||
:currentOrders="sortOrders"
|
||||
@update="onSortOrderUpdate"
|
||||
/>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<div :class="$style.footerActions">
|
||||
<MkButton primary @click="onSearchRequest">
|
||||
{{ i18n.ts.search }}
|
||||
</MkButton>
|
||||
<MkButton @click="onQueryResetButtonClicked">
|
||||
{{ i18n.ts.reset }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
|
||||
|
||||
import {
|
||||
gridSortOrderKeys,
|
||||
} from './custom-emojis-manager.impl.js';
|
||||
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
import type { EmojiSearchQuery } from './custom-emojis-manager.local.list.vue';
|
||||
import type { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
import type { GridSortOrderKey } from './custom-emojis-manager.impl.js';
|
||||
|
||||
const props = defineProps<{
|
||||
query: EmojiSearchQuery;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
(ev: 'queryUpdated', query: EmojiSearchQuery): void;
|
||||
(ev: 'sortOrderUpdated', orders: SortOrder<GridSortOrderKey>[]): void;
|
||||
(ev: 'search'): void;
|
||||
}>();
|
||||
|
||||
const model = ref<EmojiSearchQuery>(props.query);
|
||||
const queryRolesText = computed(() => model.value.roles.map(it => it.name).join(','));
|
||||
|
||||
watch(model, () => {
|
||||
emit('queryUpdated', model.value);
|
||||
}, { deep: true });
|
||||
|
||||
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
|
||||
|
||||
function onSortOrderUpdate(orders: SortOrder<GridSortOrderKey>[]) {
|
||||
sortOrders.value = orders;
|
||||
emit('sortOrderUpdated', orders);
|
||||
}
|
||||
|
||||
function onSearchRequest() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function onQueryResetButtonClicked() {
|
||||
model.value.name = '';
|
||||
model.value.category = '';
|
||||
model.value.aliases = '';
|
||||
model.value.type = '';
|
||||
model.value.license = '';
|
||||
model.value.sensitive = null;
|
||||
model.value.localOnly = null;
|
||||
model.value.updatedAtFrom = '';
|
||||
model.value.updatedAtTo = '';
|
||||
sortOrders.value = [];
|
||||
}
|
||||
|
||||
async function onQueryRolesEditClicked() {
|
||||
const result = await os.selectRole({
|
||||
initialRoleIds: model.value.roles.map(it => it.id),
|
||||
title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle,
|
||||
publicOnly: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value.roles = result.result;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: var(--MI-margin);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader :overridePageMetadata="headerPageMetadata" :actions="headerActions"/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="_gaps" :class="$style.main">
|
||||
<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
|
||||
<template v-else>
|
||||
<div v-if="gridItems.length === 0" style="text-align: center">
|
||||
{{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div :class="$style.grid">
|
||||
<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="gridItems.length > 0" :class="$style.footer">
|
||||
<div :class="$style.left">
|
||||
<MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">
|
||||
{{ i18n.ts.delete }} ({{ deleteItemsCount }})
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
<div :class="$style.center">
|
||||
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.right">
|
||||
<MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
|
||||
{{ i18n.ts.update }} ({{ updatedItemsCount }})
|
||||
</MkButton>
|
||||
<MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
import type { GridSortOrderKey } from './custom-emojis-manager.impl.js';
|
||||
|
||||
export type EmojiSearchQuery = {
|
||||
name: string | null;
|
||||
category: string | null;
|
||||
aliases: string | null;
|
||||
type: string | null;
|
||||
license: string | null;
|
||||
updatedAtFrom: string | null;
|
||||
updatedAtTo: string | null;
|
||||
sensitive: string | null;
|
||||
localOnly: string | null;
|
||||
roles: { id: string, name: string }[];
|
||||
sortOrders: SortOrder<GridSortOrderKey>[];
|
||||
limit: number;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onMounted, ref, nextTick, useCssModule } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import {
|
||||
emptyStrToEmptyArray,
|
||||
emptyStrToNull,
|
||||
emptyStrToUndefined,
|
||||
RequestLogItem,
|
||||
roleIdsParser,
|
||||
} from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { validators } from '@/components/grid/cell-validators.js';
|
||||
import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import MkPagingButtons from '@/components/MkPagingButtons.vue';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
|
||||
import { useLoading } from "@/components/hook/useLoading.js";
|
||||
|
||||
type GridItem = {
|
||||
checked: boolean;
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
category: string;
|
||||
aliases: string;
|
||||
license: string;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
|
||||
fileId?: string;
|
||||
updatedAt: string | null;
|
||||
publicUrl?: string | null;
|
||||
originalUrl?: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
const $style = useCssModule();
|
||||
|
||||
const required = validators.required();
|
||||
const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
|
||||
const unique = validators.unique();
|
||||
return {
|
||||
root: {
|
||||
noOverflowStyle: true,
|
||||
rounded: false,
|
||||
outerBorder: false,
|
||||
},
|
||||
row: {
|
||||
showNumber: true,
|
||||
selectable: true,
|
||||
// グリッドの行数をあらかじめ100行確保する
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [
|
||||
{
|
||||
// 初期値から変わっていたら背景色を変更
|
||||
condition: ({ row }) => JSON.stringify(gridItems.value[row.index]) !== JSON.stringify(originGridItems.value[row.index]),
|
||||
applyStyle: { className: $style.changedRow },
|
||||
},
|
||||
{
|
||||
// バリデーションに引っかかっていたら背景色を変更
|
||||
condition: ({ cells }) => cells.some(it => !it.violation.valid),
|
||||
applyStyle: { className: $style.violationRow },
|
||||
},
|
||||
],
|
||||
// 行のコンテキストメニュー設定
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(gridItems, context),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRows,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => {
|
||||
for (const rangedRow of context.rangedRows) {
|
||||
gridItems.value[rangedRow.index].checked = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
events: {
|
||||
delete(rows) {
|
||||
// 行削除時は元データの行を消さず、削除対象としてマークするのみにする
|
||||
for (const row of rows) {
|
||||
gridItems.value[row.index].checked = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 },
|
||||
{
|
||||
bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required],
|
||||
async customValueEditor(row, col, value, cellElement) {
|
||||
const file = await selectFile(cellElement);
|
||||
gridItems.value[row.index].url = file.url;
|
||||
gridItems.value[row.index].fileId = file.id;
|
||||
|
||||
return file.url;
|
||||
},
|
||||
},
|
||||
{
|
||||
bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
|
||||
validators: [required, regex, unique],
|
||||
},
|
||||
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
||||
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
||||
{
|
||||
bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
|
||||
valueTransformer(row) {
|
||||
// バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
|
||||
return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
|
||||
.map((it) => it.name)
|
||||
.join(',');
|
||||
},
|
||||
async customValueEditor(row) {
|
||||
// ID直記入は体験的に最悪なのでモーダルを使って入力する
|
||||
const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
|
||||
const result = await os.selectRole({
|
||||
initialRoleIds: current.map(it => it.id),
|
||||
title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
|
||||
infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
|
||||
publicOnly: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const transform = result.result.map(it => ({ id: it.id, name: it.name }));
|
||||
gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
|
||||
|
||||
return transform;
|
||||
},
|
||||
events: {
|
||||
paste: roleIdsParser,
|
||||
delete(cell) {
|
||||
// デフォルトはundefinedになるが、このプロパティは空配列にしたい
|
||||
gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
|
||||
},
|
||||
},
|
||||
},
|
||||
{ bindTo: 'type', type: 'text', editable: false, width: 90 },
|
||||
{ bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'publicUrl', type: 'text', editable: false, width: 180 },
|
||||
{ bindTo: 'originalUrl', type: 'text', editable: false, width: 180 },
|
||||
],
|
||||
cells: {
|
||||
// セルのコンテキストメニュー設定
|
||||
contextMenuFactory(col, row, value, context) {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => {
|
||||
return copyGridDataToClipboard(gridItems, context);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => {
|
||||
removeDataFromGrid(context, (cell) => {
|
||||
gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRanges,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => {
|
||||
for (const rowIdx of [...new Set(context.rangedCells.map(it => it.row.index)).values()]) {
|
||||
gridItems.value[rowIdx].checked = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const loadingHandler = useLoading();
|
||||
|
||||
const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
|
||||
const allPages = ref<number>(0);
|
||||
const currentPage = ref<number>(0);
|
||||
|
||||
const searchQuery = ref<EmojiSearchQuery>({
|
||||
name: null,
|
||||
category: null,
|
||||
aliases: null,
|
||||
type: null,
|
||||
license: null,
|
||||
updatedAtFrom: null,
|
||||
updatedAtTo: null,
|
||||
sensitive: null,
|
||||
localOnly: null,
|
||||
roles: [],
|
||||
sortOrders: [],
|
||||
limit: 25,
|
||||
});
|
||||
let searchWindowOpening = false;
|
||||
|
||||
const previousQuery = ref<string | undefined>(undefined);
|
||||
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
|
||||
const requestLogs = ref<RequestLogItem[]>([]);
|
||||
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
const originGridItems = ref<GridItem[]>([]);
|
||||
const updateButtonDisabled = ref<boolean>(false);
|
||||
|
||||
const updatedItemsCount = computed(() => {
|
||||
return gridItems.value.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(originGridItems.value[idx])).length;
|
||||
});
|
||||
const deleteItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
|
||||
|
||||
async function onUpdateButtonClicked() {
|
||||
const _items = gridItems.value;
|
||||
const _originItems = originGridItems.value;
|
||||
if (_items.length !== _originItems.length) {
|
||||
throw new Error('The number of items has been changed. Please refresh the page and try again.');
|
||||
}
|
||||
|
||||
const updatedItems = _items.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(_originItems[idx]));
|
||||
if (updatedItems.length === 0) {
|
||||
await os.alert({
|
||||
type: 'info',
|
||||
text: i18n.ts._customEmojisManager._local._list.alertUpdateEmojisNothingDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._list.confirmUpdateEmojisDescription({ count: updatedItems.length }),
|
||||
});
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = () => {
|
||||
return updatedItems.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/update',
|
||||
{
|
||||
// eslint-disable-next-line
|
||||
id: item.id!,
|
||||
name: item.name,
|
||||
category: emptyStrToNull(item.category),
|
||||
aliases: emptyStrToEmptyArray(item.aliases),
|
||||
license: emptyStrToNull(item.license),
|
||||
isSensitive: item.isSensitive,
|
||||
localOnly: item.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
|
||||
fileId: item.fileId,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
);
|
||||
};
|
||||
|
||||
const result = await os.promiseDialog(Promise.all(action()));
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
requestLogs.value = result.map(it => ({
|
||||
failed: !it.success,
|
||||
url: it.item.url,
|
||||
name: it.item.name,
|
||||
error: it.err ? JSON.stringify(it.err) : undefined,
|
||||
}));
|
||||
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function onDeleteButtonClicked() {
|
||||
const _items = gridItems.value;
|
||||
const _originItems = originGridItems.value;
|
||||
if (_items.length !== _originItems.length) {
|
||||
throw new Error('The number of items has been changed. Please refresh the page and try again.');
|
||||
}
|
||||
|
||||
const deleteItems = _items.filter((it) => it.checked);
|
||||
if (deleteItems.length === 0) {
|
||||
await os.alert({
|
||||
type: 'info',
|
||||
text: i18n.ts._customEmojisManager._local._list.alertDeleteEmojisNothingDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._list.confirmDeleteEmojisDescription({ count: deleteItems.length }),
|
||||
});
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function action() {
|
||||
const deleteIds = deleteItems.map(it => it.id!);
|
||||
await misskeyApi('admin/emoji/delete-bulk', { ids: deleteIds });
|
||||
}
|
||||
|
||||
await os.promiseDialog(
|
||||
action(),
|
||||
);
|
||||
}
|
||||
|
||||
async function onGridResetButtonClicked() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts.resetAreYouSure,
|
||||
text: i18n.ts._customEmojisManager._local._list.confirmResetDescription,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
refreshGridItems();
|
||||
}
|
||||
|
||||
async function onSearchRequest() {
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function onPageChanged(pageNumber: number) {
|
||||
if (updatedItemsCount.value > 0) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts._customEmojisManager._local._list.confirmMovePage,
|
||||
text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
currentPage.value = pageNumber;
|
||||
await nextTick();
|
||||
refreshCustomEmojis();
|
||||
}
|
||||
|
||||
function onGridEvent(event: GridEvent) {
|
||||
switch (event.type) {
|
||||
case 'cell-validation':
|
||||
onGridCellValidation(event);
|
||||
break;
|
||||
case 'cell-value-change':
|
||||
onGridCellValueChange(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGridCellValidation(event: GridCellValidationEvent) {
|
||||
updateButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
|
||||
}
|
||||
|
||||
function onGridCellValueChange(event: GridCellValueChangeEvent) {
|
||||
const { row, column, newValue } = event;
|
||||
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
|
||||
gridItems.value[row.index][column.setting.bindTo] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCustomEmojis() {
|
||||
const limit = searchQuery.value.limit;
|
||||
|
||||
const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
|
||||
name: emptyStrToUndefined(searchQuery.value.name),
|
||||
type: emptyStrToUndefined(searchQuery.value.type),
|
||||
aliases: emptyStrToUndefined(searchQuery.value.aliases),
|
||||
category: emptyStrToUndefined(searchQuery.value.category),
|
||||
license: emptyStrToUndefined(searchQuery.value.license),
|
||||
isSensitive: searchQuery.value.sensitive ? Boolean(searchQuery.value.sensitive).valueOf() : undefined,
|
||||
localOnly: searchQuery.value.localOnly ? Boolean(searchQuery.value.localOnly).valueOf() : undefined,
|
||||
updatedAtFrom: emptyStrToUndefined(searchQuery.value.updatedAtFrom),
|
||||
updatedAtTo: emptyStrToUndefined(searchQuery.value.updatedAtTo),
|
||||
roleIds: searchQuery.value.roles.map(it => it.id),
|
||||
hostType: 'local',
|
||||
};
|
||||
|
||||
if (JSON.stringify(query) !== previousQuery.value) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
|
||||
query: query,
|
||||
limit: limit,
|
||||
page: currentPage.value,
|
||||
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}` as any),
|
||||
}));
|
||||
|
||||
customEmojis.value = result.emojis;
|
||||
allPages.value = result.allPages;
|
||||
|
||||
previousQuery.value = JSON.stringify(query);
|
||||
|
||||
refreshGridItems();
|
||||
}
|
||||
|
||||
function refreshGridItems() {
|
||||
gridItems.value = customEmojis.value.map(it => ({
|
||||
checked: false,
|
||||
id: it.id,
|
||||
fileId: undefined,
|
||||
url: it.publicUrl,
|
||||
name: it.name,
|
||||
host: it.host ?? '',
|
||||
category: it.category ?? '',
|
||||
aliases: it.aliases.join(','),
|
||||
license: it.license ?? '',
|
||||
isSensitive: it.isSensitive,
|
||||
localOnly: it.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
updatedAt: it.updatedAt,
|
||||
publicUrl: it.publicUrl,
|
||||
originalUrl: it.originalUrl,
|
||||
type: it.type,
|
||||
}));
|
||||
originGridItems.value = JSON.parse(JSON.stringify(gridItems.value));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCustomEmojis();
|
||||
});
|
||||
|
||||
const headerPageMetadata = computed(() => ({
|
||||
title: i18n.ts._customEmojisManager._local.tabTitleList,
|
||||
icon: 'ti ti-icons',
|
||||
}));
|
||||
|
||||
const headerActions = computed(() => [{
|
||||
icon: 'ti ti-search',
|
||||
text: i18n.ts.search,
|
||||
handler: () => {
|
||||
if (searchWindowOpening) return;
|
||||
searchWindowOpening = true;
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.search.vue')), {
|
||||
query: searchQuery.value,
|
||||
}, {
|
||||
queryUpdated: (query: EmojiSearchQuery) => {
|
||||
searchQuery.value = query;
|
||||
},
|
||||
sortOrderUpdated: (orders: SortOrder<GridSortOrderKey>[]) => {
|
||||
sortOrders.value = orders;
|
||||
},
|
||||
search: () => {
|
||||
onSearchRequest();
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
searchWindowOpening = false;
|
||||
},
|
||||
});
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-list-numbers',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.searchLimit,
|
||||
handler: (ev: MouseEvent) => {
|
||||
async function changeSearchLimit(to: number) {
|
||||
if (updatedItemsCount.value > 0) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts._customEmojisManager._local._list.confirmChangeView,
|
||||
text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
searchQuery.value.limit = to;
|
||||
refreshCustomEmojis();
|
||||
}
|
||||
|
||||
os.popupMenu([{
|
||||
type: 'radioOption',
|
||||
text: '25',
|
||||
active: computed(() => searchQuery.value.limit === 25),
|
||||
action: () => changeSearchLimit(25),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: '50',
|
||||
active: computed(() => searchQuery.value.limit === 50),
|
||||
action: () => changeSearchLimit(50),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: '100',
|
||||
active: computed(() => searchQuery.value.limit === 100),
|
||||
action: () => changeSearchLimit(100),
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-notes',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.registrationLogs,
|
||||
handler: () => {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.logs.vue')), {
|
||||
logs: requestLogs.value,
|
||||
}, {
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
}]);
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.violationRow {
|
||||
background-color: var(--MI_THEME-infoWarnBg);
|
||||
}
|
||||
|
||||
.changedRow {
|
||||
background-color: var(--MI_THEME-infoBg);
|
||||
}
|
||||
|
||||
.editedRow {
|
||||
background-color: var(--MI_THEME-infoBg);
|
||||
}
|
||||
|
||||
.main {
|
||||
height: calc(100vh - var(--MI-stickyTop) - var(--MI-stickyBottom));
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.grid {
|
||||
width: max-content;
|
||||
border-bottom: 1px solid var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
|
||||
padding: var(--MI-margin);
|
||||
border-top: 1px solid var(--MI_THEME-divider);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
|
||||
& .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 8px 0;
|
||||
border-top: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-settings"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._local._register.uploadSettingTitle }}</template>
|
||||
<template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSelect v-model="selectedFolderId">
|
||||
<template #label>{{ i18n.ts.uploadFolder }}</template>
|
||||
<option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
|
||||
{{ folder.name }}
|
||||
</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSwitch v-model="keepOriginalUploading">
|
||||
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
|
||||
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="directoryToCategory">
|
||||
<template #label>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryLabel }}</template>
|
||||
<template #caption>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryCaption }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-notes"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
|
||||
<template #caption>
|
||||
{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
|
||||
</template>
|
||||
<XRegisterLogs :logs="requestLogs"/>
|
||||
</MkFolder>
|
||||
|
||||
<div
|
||||
:class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div style="margin-top: 1em">
|
||||
{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }}
|
||||
</div>
|
||||
<ul>
|
||||
<li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li>
|
||||
<li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li>
|
||||
<li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="gridItems.length > 0" :class="$style.gridArea">
|
||||
<MkGrid
|
||||
:data="gridItems"
|
||||
:settings="setupGrid()"
|
||||
@event="onGridEvent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="gridItems.length > 0" :class="$style.footer">
|
||||
<MkButton primary :disabled="registerButtonDisabled" @click="onRegistryClicked">
|
||||
{{ i18n.ts.registration }}
|
||||
</MkButton>
|
||||
<MkButton @click="onClearClicked">
|
||||
{{ i18n.ts.clear }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { onMounted, ref, useCssModule } from 'vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import {
|
||||
emptyStrToEmptyArray,
|
||||
emptyStrToNull,
|
||||
RequestLogItem,
|
||||
roleIdsParser,
|
||||
} from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { validators } from '@/components/grid/cell-validators.js';
|
||||
import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js';
|
||||
import { uploadFile } from '@/scripts/upload.js';
|
||||
import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
|
||||
const MAXIMUM_EMOJI_REGISTER_COUNT = 100;
|
||||
|
||||
type FolderItem = {
|
||||
id?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type GridItem = {
|
||||
fileId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
category: string;
|
||||
aliases: string;
|
||||
license: string;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
const $style = useCssModule();
|
||||
|
||||
const required = validators.required();
|
||||
const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
|
||||
const unique = validators.unique();
|
||||
|
||||
function removeRows(rows: GridRow[]) {
|
||||
const idxes = [...new Set(rows.map(it => it.index))];
|
||||
gridItems.value = gridItems.value.filter((_, i) => !idxes.includes(i));
|
||||
}
|
||||
|
||||
return {
|
||||
row: {
|
||||
showNumber: true,
|
||||
selectable: true,
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [
|
||||
{
|
||||
// 1つでもバリデーションエラーがあれば行全体をエラー表示する
|
||||
condition: ({ cells }) => cells.some(it => !it.violation.valid),
|
||||
applyStyle: { className: $style.violationRow },
|
||||
},
|
||||
],
|
||||
// 行のコンテキストメニュー設定
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(gridItems, context),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRows,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => removeRows(context.rangedRows),
|
||||
},
|
||||
];
|
||||
},
|
||||
events: {
|
||||
delete(rows) {
|
||||
removeRows(rows);
|
||||
},
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] },
|
||||
{
|
||||
bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
|
||||
validators: [required, regex, unique],
|
||||
},
|
||||
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
||||
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
||||
{
|
||||
bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
|
||||
valueTransformer: (row) => {
|
||||
// バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
|
||||
return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
|
||||
.map((it) => it.name)
|
||||
.join(',');
|
||||
},
|
||||
customValueEditor: async (row) => {
|
||||
// ID直記入は体験的に最悪なのでモーダルを使って入力する
|
||||
const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
|
||||
const result = await os.selectRole({
|
||||
initialRoleIds: current.map(it => it.id),
|
||||
title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
|
||||
infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
|
||||
publicOnly: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const transform = result.result.map(it => ({ id: it.id, name: it.name }));
|
||||
gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
|
||||
|
||||
return transform;
|
||||
},
|
||||
events: {
|
||||
paste: roleIdsParser,
|
||||
delete(cell) {
|
||||
// デフォルトはundefinedになるが、このプロパティは空配列にしたい
|
||||
gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
|
||||
},
|
||||
},
|
||||
},
|
||||
{ bindTo: 'type', type: 'text', editable: false, width: 90 },
|
||||
],
|
||||
cells: {
|
||||
// セルのコンテキストメニュー設定
|
||||
contextMenuFactory: (col, row, value, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(gridItems, context),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => removeRows(context.rangedCells.map(it => it.row)),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uploadFolders = ref<FolderItem[]>([]);
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
const selectedFolderId = ref(defaultStore.state.uploadFolder);
|
||||
const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
|
||||
const directoryToCategory = ref<boolean>(false);
|
||||
const registerButtonDisabled = ref<boolean>(false);
|
||||
const requestLogs = ref<RequestLogItem[]>([]);
|
||||
const isDragOver = ref<boolean>(false);
|
||||
|
||||
async function onRegistryClicked() {
|
||||
const dialogSelection = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._register.confirmRegisterEmojisDescription({ count: MAXIMUM_EMOJI_REGISTER_COUNT }),
|
||||
});
|
||||
|
||||
if (dialogSelection.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = gridItems.value;
|
||||
const upload = () => {
|
||||
return items.slice(0, MAXIMUM_EMOJI_REGISTER_COUNT)
|
||||
.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/add', {
|
||||
name: item.name,
|
||||
category: emptyStrToNull(item.category),
|
||||
aliases: emptyStrToEmptyArray(item.aliases),
|
||||
license: emptyStrToNull(item.license),
|
||||
isSensitive: item.isSensitive,
|
||||
localOnly: item.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
|
||||
fileId: item.fileId!,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
);
|
||||
};
|
||||
|
||||
const result = await os.promiseDialog(Promise.all(upload()));
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
requestLogs.value = result.map(it => ({
|
||||
failed: !it.success,
|
||||
url: it.item.url,
|
||||
name: it.item.name,
|
||||
error: it.err ? JSON.stringify(it.err) : undefined,
|
||||
}));
|
||||
|
||||
// 登録に成功したものは一覧から除く
|
||||
const successItems = result.filter(it => it.success).map(it => it.item);
|
||||
gridItems.value = gridItems.value.filter(it => !successItems.includes(it));
|
||||
}
|
||||
|
||||
async function onClearClicked() {
|
||||
const result = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts._customEmojisManager._local._register.confirmClearEmojisDescription,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
gridItems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(ev: DragEvent) {
|
||||
isDragOver.value = false;
|
||||
|
||||
const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }),
|
||||
});
|
||||
if (confirm.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>();
|
||||
try {
|
||||
uploadedItems.push(
|
||||
...await os.promiseDialog(
|
||||
Promise.all(
|
||||
droppedFiles.map(async (it) => ({
|
||||
droppedFile: it,
|
||||
driveFile: await uploadFile(
|
||||
it.file,
|
||||
selectedFolderId.value,
|
||||
it.file.name.replace(/\.[^.]+$/, ''),
|
||||
keepOriginalUploading.value,
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
},
|
||||
() => {
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// ダイアログは共通部品側で出ているはずなので何もしない
|
||||
return;
|
||||
}
|
||||
|
||||
const items = uploadedItems.map(({ droppedFile, driveFile }) => {
|
||||
const item = fromDriveFile(driveFile);
|
||||
if (directoryToCategory.value) {
|
||||
item.category = droppedFile.path
|
||||
.replace(/^\//, '')
|
||||
.replace(/\/[^/]+$/, '')
|
||||
.replace(droppedFile.file.name, '');
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
gridItems.value.push(...items);
|
||||
}
|
||||
|
||||
async function onFileSelectClicked() {
|
||||
const driveFiles = await chooseFileFromPc(
|
||||
true,
|
||||
{
|
||||
uploadFolder: selectedFolderId.value,
|
||||
keepOriginal: keepOriginalUploading.value,
|
||||
// 拡張子は消す
|
||||
nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
|
||||
},
|
||||
);
|
||||
|
||||
gridItems.value.push(...driveFiles.map(fromDriveFile));
|
||||
}
|
||||
|
||||
async function onDriveSelectClicked() {
|
||||
const driveFiles = await chooseFileFromDrive(true);
|
||||
gridItems.value.push(...driveFiles.map(fromDriveFile));
|
||||
}
|
||||
|
||||
function onGridEvent(event: GridEvent) {
|
||||
switch (event.type) {
|
||||
case 'cell-validation':
|
||||
onGridCellValidation(event);
|
||||
break;
|
||||
case 'cell-value-change':
|
||||
onGridCellValueChange(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGridCellValidation(event: GridCellValidationEvent) {
|
||||
registerButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
|
||||
}
|
||||
|
||||
function onGridCellValueChange(event: GridCellValueChangeEvent) {
|
||||
const { row, column, newValue } = event;
|
||||
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
|
||||
gridItems.value[row.index][column.setting.bindTo] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
function fromDriveFile(it: Misskey.entities.DriveFile): GridItem {
|
||||
return {
|
||||
fileId: it.id,
|
||||
url: it.url,
|
||||
name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, ''),
|
||||
host: '',
|
||||
category: '',
|
||||
aliases: '',
|
||||
license: '',
|
||||
isSensitive: it.isSensitive,
|
||||
localOnly: false,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
|
||||
type: it.type,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshUploadFolders() {
|
||||
const result = await misskeyApi('drive/folders', {});
|
||||
uploadFolders.value = Array.of<FolderItem>({ name: '-' }, ...result);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshUploadFolders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.violationRow {
|
||||
background-color: var(--MI_THEME-infoWarnBg);
|
||||
}
|
||||
|
||||
.uploadBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 0.5px dotted var(--MI_THEME-accentedBg);
|
||||
border-radius: var(--MI-radius);
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
box-sizing: border-box;
|
||||
|
||||
&.dragOver {
|
||||
cursor: copy;
|
||||
}
|
||||
}
|
||||
|
||||
.gridArea {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
|
||||
position: sticky;
|
||||
left:0;
|
||||
bottom:0;
|
||||
z-index: 1;
|
||||
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
|
||||
margin-top: calc(var(--MI-margin) * -1);
|
||||
margin-bottom: calc(var(--MI-margin) * -1);
|
||||
padding-top: var(--MI-margin);
|
||||
padding-bottom: var(--MI-margin);
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs" hideTitle thin/>
|
||||
</template>
|
||||
<XListComponent v-if="headerTab === 'list'" key="localList"/>
|
||||
<MkSpacer v-else key="localRegister">
|
||||
<XRegisterComponent/>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import XListComponent from '@/pages/admin/custom-emojis-manager.local.list.vue';
|
||||
import XRegisterComponent from '@/pages/admin/custom-emojis-manager.local.register.vue';
|
||||
|
||||
type PageMode = 'list' | 'register';
|
||||
|
||||
const headerTab = ref<PageMode>('list');
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'list',
|
||||
title: i18n.ts._customEmojisManager._local.tabTitleList,
|
||||
}, {
|
||||
key: 'register',
|
||||
title: i18n.ts._customEmojisManager._local.tabTitleRegister,
|
||||
}]);
|
||||
</script>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;">
|
||||
<MkSwitch v-model="showingSuccessLogs">
|
||||
<template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template>
|
||||
</MkSwitch>
|
||||
<div>
|
||||
<div v-if="filteredLogs.length > 0">
|
||||
<MkGrid
|
||||
:data="filteredLogs"
|
||||
:settings="setupGrid()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ i18n.ts._customEmojisManager._logs.failureLogNothing }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ i18n.ts._customEmojisManager._logs.logNothing }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
|
||||
|
||||
import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import type { GridSetting } from '@/components/grid/grid.js';
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
return {
|
||||
row: {
|
||||
showNumber: false,
|
||||
selectable: false,
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(logs, context),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'failed', title: 'failed', type: 'boolean', editable: false, width: 50 },
|
||||
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: false, width: 140 },
|
||||
{ bindTo: 'error', title: 'log', type: 'text', editable: false, width: 'auto' },
|
||||
],
|
||||
cells: {
|
||||
contextMenuFactory: (col, row, value, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(logs, context),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
logs: RequestLogItem[];
|
||||
}>();
|
||||
|
||||
const { logs } = toRefs(props);
|
||||
const showingSuccessLogs = ref<boolean>(false);
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const forceShowing = showingSuccessLogs.value;
|
||||
return logs.value.filter((log) => forceShowing || log.failed);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #default>
|
||||
<div :class="$style.root" class="_gaps">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-search"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
|
||||
<template #caption>
|
||||
{{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
|
||||
</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
|
||||
<MkInput
|
||||
v-model="queryName"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col1, $style.row1]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>name</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="queryHost"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col2, $style.row1]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>host</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="queryLicense"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col3, $style.row1]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>license</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="queryUri"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col1, $style.row2]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>uri</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="queryPublicUrl"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col2, $style.row2]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>publicUrl</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<MkFolder :spacerMax="8" :spacerMin="8">
|
||||
<template #icon><i class="ti ti-arrows-sort"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
|
||||
<MkSortOrderEditor
|
||||
:baseOrderKeyNames="gridSortOrderKeys"
|
||||
:currentOrders="sortOrders"
|
||||
@update="onSortOrderUpdate"
|
||||
/>
|
||||
</MkFolder>
|
||||
|
||||
<MkInput
|
||||
v-model="queryLimit"
|
||||
type="number"
|
||||
:max="100"
|
||||
>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchLimit }}</template>
|
||||
</MkInput>
|
||||
|
||||
<div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
|
||||
<MkButton primary @click="onSearchRequest">
|
||||
{{ i18n.ts.search }}
|
||||
</MkButton>
|
||||
<MkButton @click="onQueryResetButtonClicked">
|
||||
{{ i18n.ts.reset }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-notes"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
|
||||
<template #caption>
|
||||
{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
|
||||
</template>
|
||||
<XRegisterLogs :logs="requestLogs"/>
|
||||
</MkFolder>
|
||||
|
||||
<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
|
||||
<template v-else>
|
||||
<div v-if="gridItems.length === 0" style="text-align: center">
|
||||
{{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="gridItems.length > 0" :class="$style.gridArea">
|
||||
<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.footer">
|
||||
<div>
|
||||
<!-- レイアウト調整用のスペース -->
|
||||
</div>
|
||||
|
||||
<div :class="$style.center">
|
||||
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.right">
|
||||
<MkButton primary @click="onImportClicked">
|
||||
{{
|
||||
i18n.ts._customEmojisManager._remote.importEmojisButton
|
||||
}} ({{ checkedItemsCount }})
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, useCssModule } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import {
|
||||
emptyStrToUndefined,
|
||||
GridSortOrderKey,
|
||||
gridSortOrderKeys,
|
||||
RequestLogItem,
|
||||
} from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
import MkPagingButtons from '@/components/MkPagingButtons.vue';
|
||||
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
|
||||
import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
import { useLoading } from '@/components/hook/useLoading.js';
|
||||
|
||||
type GridItem = {
|
||||
checked: boolean;
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
const $style = useCssModule();
|
||||
|
||||
return {
|
||||
row: {
|
||||
// グリッドの行数をあらかじめ100行確保する
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [
|
||||
{
|
||||
// チェックされたら背景色を変える
|
||||
condition: ({ row }) => gridItems.value[row.index].checked,
|
||||
applyStyle: { className: $style.changedRow },
|
||||
},
|
||||
],
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._remote.importSelectionRows,
|
||||
icon: 'ti ti-download',
|
||||
action: async () => {
|
||||
const targets = context.rangedRows.map(it => gridItems.value[it.index]);
|
||||
await importEmojis(targets);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'checked', icon: 'ti-download', type: 'boolean', editable: true, width: 34 },
|
||||
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'host', title: 'host', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'license', title: 'license', type: 'text', editable: false, width: 200 },
|
||||
{ bindTo: 'uri', title: 'uri', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'publicUrl', title: 'publicUrl', type: 'text', editable: false, width: 'auto' },
|
||||
],
|
||||
cells: {
|
||||
contextMenuFactory: (col, row, value, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._remote.selectionRowDetail,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
const target = customEmojis.value[row.index];
|
||||
const { dispose } = os.popup(MkRemoteEmojiEditDialog, {
|
||||
emoji: {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
host: target.host!,
|
||||
license: target.license,
|
||||
url: target.publicUrl,
|
||||
},
|
||||
}, {
|
||||
done: () => {
|
||||
dispose();
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._remote.importSelectionRangesRows,
|
||||
icon: 'ti ti-download',
|
||||
action: async () => {
|
||||
const targets = context.rangedCells.map(it => gridItems.value[it.row.index]);
|
||||
await importEmojis(targets);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const loadingHandler = useLoading();
|
||||
|
||||
const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
|
||||
const allPages = ref<number>(0);
|
||||
const currentPage = ref<number>(0);
|
||||
|
||||
const queryName = ref<string | null>(null);
|
||||
const queryHost = ref<string | null>(null);
|
||||
const queryLicense = ref<string | null>(null);
|
||||
const queryUri = ref<string | null>(null);
|
||||
const queryPublicUrl = ref<string | null>(null);
|
||||
const queryLimit = ref<number>(25);
|
||||
const previousQuery = ref<string | undefined>(undefined);
|
||||
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
|
||||
const requestLogs = ref<RequestLogItem[]>([]);
|
||||
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
|
||||
const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
|
||||
const checkedItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
|
||||
|
||||
function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
|
||||
sortOrders.value = _sortOrders;
|
||||
}
|
||||
|
||||
async function onSearchRequest() {
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
function onQueryResetButtonClicked() {
|
||||
queryName.value = null;
|
||||
queryHost.value = null;
|
||||
queryLicense.value = null;
|
||||
queryUri.value = null;
|
||||
queryPublicUrl.value = null;
|
||||
}
|
||||
|
||||
async function onPageChanged(pageNumber: number) {
|
||||
currentPage.value = pageNumber;
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function onImportClicked() {
|
||||
const targets = gridItems.value.filter(it => it.checked);
|
||||
await importEmojis(targets);
|
||||
}
|
||||
|
||||
function onGridEvent(event: GridEvent) {
|
||||
switch (event.type) {
|
||||
case 'cell-value-change':
|
||||
onGridCellValueChange(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGridCellValueChange(event: GridCellValueChangeEvent) {
|
||||
const { row, column, newValue } = event;
|
||||
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
|
||||
gridItems.value[row.index][column.setting.bindTo] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function importEmojis(targets: GridItem[]) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
title: i18n.ts._customEmojisManager._remote.confirmImportEmojisTitle,
|
||||
text: i18n.tsx._customEmojisManager._remote.confirmImportEmojisDescription({ count: targets.length }),
|
||||
});
|
||||
|
||||
if (confirm.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await os.promiseDialog(
|
||||
Promise.all(
|
||||
targets.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/copy',
|
||||
{
|
||||
emojiId: item.id!,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
),
|
||||
),
|
||||
);
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
requestLogs.value = result.map(it => ({
|
||||
failed: !it.success,
|
||||
url: it.item.url,
|
||||
name: it.item.name,
|
||||
error: it.err ? JSON.stringify(it.err) : undefined,
|
||||
}));
|
||||
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function refreshCustomEmojis() {
|
||||
const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
|
||||
name: emptyStrToUndefined(queryName.value),
|
||||
host: emptyStrToUndefined(queryHost.value),
|
||||
license: emptyStrToUndefined(queryLicense.value),
|
||||
uri: emptyStrToUndefined(queryUri.value),
|
||||
publicUrl: emptyStrToUndefined(queryPublicUrl.value),
|
||||
hostType: 'remote',
|
||||
};
|
||||
|
||||
if (JSON.stringify(query) !== previousQuery.value) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
|
||||
limit: queryLimit.value,
|
||||
query: query,
|
||||
page: currentPage.value,
|
||||
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`) as never[],
|
||||
}));
|
||||
|
||||
customEmojis.value = result.emojis;
|
||||
allPages.value = result.allPages;
|
||||
previousQuery.value = JSON.stringify(query);
|
||||
gridItems.value = customEmojis.value.map(it => ({
|
||||
checked: false,
|
||||
id: it.id,
|
||||
url: it.publicUrl,
|
||||
name: it.name,
|
||||
license: it.license,
|
||||
host: it.host!,
|
||||
}));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCustomEmojis();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.row1 {
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.col1 {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
|
||||
.col2 {
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
|
||||
.col3 {
|
||||
grid-column: 3 / 4;
|
||||
}
|
||||
|
||||
.root {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.changedRow {
|
||||
background-color: var(--MI_THEME-infoBg);
|
||||
}
|
||||
|
||||
.searchArea {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.searchButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchButtonsSp {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchAreaSp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gridArea {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
background-color: var(--MI_THEME-buttonBg);
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
margin: 0 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
|
||||
position: sticky;
|
||||
left:0;
|
||||
bottom:0;
|
||||
z-index: 1;
|
||||
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
|
||||
margin-top: calc(var(--MI-margin) * -1);
|
||||
margin-bottom: calc(var(--MI-margin) * -1);
|
||||
padding-top: var(--MI-margin);
|
||||
padding-bottom: var(--MI-margin);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
|
||||
& .center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& .right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { entities } from 'misskey-js';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import { emoji } from '../../../.storybook/fakes.js';
|
||||
import { fakeId } from '../../../.storybook/fake-utils.js';
|
||||
import custom_emojis_manager2 from './custom-emojis-manager2.vue';
|
||||
|
||||
function createRender(params: {
|
||||
emojis: entities.EmojiDetailedAdmin[];
|
||||
}) {
|
||||
const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis];
|
||||
const storedDriveFiles: entities.DriveFile[] = [];
|
||||
|
||||
return {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
custom_emojis_manager2,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<custom_emojis_manager2 v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/v2/admin/emoji/list', async ({ request }) => {
|
||||
await delay(100);
|
||||
|
||||
const bodyStream = request.body as ReadableStream;
|
||||
const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest;
|
||||
|
||||
const emojis = storedEmojis;
|
||||
const limit = body.limit ?? 10;
|
||||
const page = body.page ?? 1;
|
||||
const result = emojis.slice((page - 1) * limit, page * limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
emojis: result,
|
||||
count: Math.min(emojis.length, limit),
|
||||
allCount: emojis.length,
|
||||
allPages: Math.ceil(emojis.length / limit),
|
||||
});
|
||||
}),
|
||||
http.post('/api/drive/folders', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.post('/api/drive/files', () => {
|
||||
return HttpResponse.json(storedDriveFiles);
|
||||
}),
|
||||
http.post('/api/drive/files/create', async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const file = data.get('file');
|
||||
if (!file || !(file instanceof File)) {
|
||||
return HttpResponse.json({ error: 'file is required' }, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある
|
||||
const base64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(new Blob([file], { type: 'image/webp' }));
|
||||
});
|
||||
|
||||
const driveFile: entities.DriveFile = {
|
||||
id: fakeId(file.name),
|
||||
createdAt: new Date().toISOString(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
md5: '',
|
||||
size: file.size,
|
||||
isSensitive: false,
|
||||
blurhash: null,
|
||||
properties: {},
|
||||
url: base64,
|
||||
thumbnailUrl: null,
|
||||
comment: null,
|
||||
folderId: null,
|
||||
folder: null,
|
||||
userId: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
storedDriveFiles.push(driveFile);
|
||||
|
||||
return HttpResponse.json(driveFile);
|
||||
}),
|
||||
http.post('api/admin/emoji/add', async ({ request }) => {
|
||||
await delay(100);
|
||||
|
||||
const bodyStream = request.body as ReadableStream;
|
||||
const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest;
|
||||
|
||||
const fileId = body.fileId;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const file = storedDriveFiles.find(f => f.id === fileId)!;
|
||||
|
||||
const em = emoji({
|
||||
id: fakeId(file.name),
|
||||
name: body.name,
|
||||
publicUrl: file.url,
|
||||
originalUrl: file.url,
|
||||
type: file.type,
|
||||
aliases: body.aliases,
|
||||
category: body.category ?? undefined,
|
||||
license: body.license ?? undefined,
|
||||
localOnly: body.localOnly,
|
||||
isSensitive: body.isSensitive,
|
||||
});
|
||||
storedEmojis.push(em);
|
||||
|
||||
return HttpResponse.json(null);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof custom_emojis_manager2>;
|
||||
}
|
||||
|
||||
export const Default = createRender({
|
||||
emojis: [],
|
||||
});
|
||||
|
||||
export const List10 = createRender({
|
||||
emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
|
||||
});
|
||||
|
||||
export const List100 = createRender({
|
||||
emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
|
||||
});
|
||||
|
||||
export const List1000 = createRender({
|
||||
emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
|
||||
});
|
||||
51
packages/frontend/src/pages/admin/custom-emojis-manager2.vue
Normal file
51
packages/frontend/src/pages/admin/custom-emojis-manager2.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/>
|
||||
</template>
|
||||
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
|
||||
<XGridRemoteComponent v-else/>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue';
|
||||
import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue';
|
||||
import MkPageHeader from '@/components/global/MkPageHeader.vue';
|
||||
import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
|
||||
|
||||
type PageMode = 'local' | 'remote';
|
||||
|
||||
const headerTab = ref<PageMode>('local');
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'local',
|
||||
title: i18n.ts.local,
|
||||
}, {
|
||||
key: 'remote',
|
||||
title: i18n.ts.remote,
|
||||
}]);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: i18n.ts.customEmojis,
|
||||
icon: 'ti ti-icons',
|
||||
needWideArea: true,
|
||||
})));
|
||||
</script>
|
||||
|
||||
<style lang="css" module>
|
||||
.local {
|
||||
height: calc(100dvh - var(--MI-stickyTop) - var(--MI-stickyBottom));
|
||||
overflow: clip;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -35,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
||||
import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { lookup } from '@/scripts/lookup.js';
|
||||
|
|
@ -56,7 +57,7 @@ const indexInfo = {
|
|||
|
||||
provide('shouldOmitHeaderTitle', false);
|
||||
|
||||
const INFO = ref(indexInfo);
|
||||
const INFO = ref<PageMetadata>(indexInfo);
|
||||
const childInfo = ref<null | PageMetadata>(null);
|
||||
const narrow = ref(false);
|
||||
const view = ref(null);
|
||||
|
|
@ -91,7 +92,7 @@ const ro = new ResizeObserver((entries, observer) => {
|
|||
narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
|
||||
});
|
||||
|
||||
const menuDef = computed(() => [{
|
||||
const menuDef = computed<SuperMenuDef[]>(() => [{
|
||||
title: i18n.ts.quickAction,
|
||||
items: [{
|
||||
type: 'button',
|
||||
|
|
@ -99,7 +100,7 @@ const menuDef = computed(() => [{
|
|||
text: i18n.ts.lookup,
|
||||
action: adminLookup,
|
||||
}, ...(instance.disableRegistration ? [{
|
||||
type: 'button',
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-user-plus',
|
||||
text: i18n.ts.createInviteCode,
|
||||
action: invite,
|
||||
|
|
@ -136,6 +137,11 @@ const menuDef = computed(() => [{
|
|||
text: i18n.ts.customEmojis,
|
||||
to: '/admin/emojis',
|
||||
active: currentPage.value?.route.name === 'emojis',
|
||||
}, {
|
||||
icon: 'ti ti-icons',
|
||||
text: i18n.ts.customEmojis + '(beta)',
|
||||
to: '/admin/emojis2',
|
||||
active: currentPage.value?.route.name === 'emojis2',
|
||||
}, {
|
||||
icon: 'ti ti-sparkles',
|
||||
text: i18n.ts.avatarDecorations,
|
||||
|
|
@ -343,12 +349,14 @@ defineExpose({
|
|||
height: 100%;
|
||||
|
||||
> .nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 32%;
|
||||
max-width: 280px;
|
||||
box-sizing: border-box;
|
||||
border-right: solid 0.5px var(--MI_THEME-divider);
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
> .main {
|
||||
|
|
|
|||
|
|
@ -641,7 +641,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0">
|
||||
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit">
|
||||
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.avatarDecorationLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
|
|
@ -757,6 +757,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { ROLE_POLICIES } from '@@/js/const.js';
|
||||
import RolesEditorFormula from './RolesEditorFormula.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkColorInput from '@/components/MkColorInput.vue';
|
||||
|
|
@ -767,7 +768,6 @@ import MkSwitch from '@/components/MkSwitch.vue';
|
|||
import MkRange from '@/components/MkRange.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ROLE_POLICIES } from '@@/js/const.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
|
||||
|
|
@ -793,6 +793,12 @@ for (const ROLE_POLICY of ROLE_POLICIES) {
|
|||
}
|
||||
}
|
||||
|
||||
function updateAvatarDecorationLimit(value: string | number) {
|
||||
const numValue = Number(value);
|
||||
const limited = Math.min(16, Math.max(0, numValue));
|
||||
role.value.policies.avatarDecorationLimit.value = limited;
|
||||
}
|
||||
|
||||
const rolePermission = computed({
|
||||
get: () => role.value.isAdministrator ? 'administrator' : role.value.isModerator ? 'moderator' : 'normal',
|
||||
set: (val) => {
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
|
||||
<template #suffix>{{ policies.avatarDecorationLimit }}</template>
|
||||
<MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0">
|
||||
<MkInput v-model="avatarDecorationLimit" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit">
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
|
|
@ -334,6 +334,17 @@ for (const ROLE_POLICY of ROLE_POLICIES) {
|
|||
policies[ROLE_POLICY] = instance.policies[ROLE_POLICY];
|
||||
}
|
||||
|
||||
const avatarDecorationLimit = computed({
|
||||
get: () => Math.min(16, Math.max(0, policies.avatarDecorationLimit)),
|
||||
set: (value) => {
|
||||
policies.avatarDecorationLimit = Math.min(Number(value), 16);
|
||||
},
|
||||
});
|
||||
|
||||
function updateAvatarDecorationLimit(value: string | number) {
|
||||
avatarDecorationLimit.value = Number(value);
|
||||
}
|
||||
|
||||
function matchQuery(keywords: string[]): boolean {
|
||||
if (baseRoleQ.value.trim().length === 0) return true;
|
||||
return keywords.some(keyword => keyword.toLowerCase().includes(baseRoleQ.value.toLowerCase()));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue