summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/pages')
-rw-r--r--packages/frontend/src/pages/about-misskey.vue0
-rw-r--r--packages/frontend/src/pages/about.vue47
-rw-r--r--packages/frontend/src/pages/admin/bot-protection.vue277
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts57
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue39
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue213
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue660
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue481
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.vue35
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue88
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue503
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts160
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager2.vue51
-rw-r--r--packages/frontend/src/pages/admin/index.vue16
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue10
-rw-r--r--packages/frontend/src/pages/admin/roles.vue13
-rw-r--r--packages/frontend/src/pages/channel.vue6
-rw-r--r--packages/frontend/src/pages/channels.vue35
-rw-r--r--packages/frontend/src/pages/clip.vue5
-rw-r--r--packages/frontend/src/pages/custom-emojis-manager.vue29
-rw-r--r--packages/frontend/src/pages/emoji-edit-dialog.vue2
-rw-r--r--packages/frontend/src/pages/explore.users.vue3
-rw-r--r--packages/frontend/src/pages/miauth.vue21
-rw-r--r--packages/frontend/src/pages/note.vue12
-rw-r--r--packages/frontend/src/pages/search.note.vue24
-rw-r--r--packages/frontend/src/pages/search.user.vue5
-rw-r--r--packages/frontend/src/pages/settings/accounts.vue107
-rw-r--r--packages/frontend/src/pages/settings/general.vue3
-rw-r--r--packages/frontend/src/pages/settings/index.vue2
-rw-r--r--packages/frontend/src/pages/settings/mute-block.vue29
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue9
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue3
-rw-r--r--packages/frontend/src/pages/theme-editor.vue2
-rw-r--r--packages/frontend/src/pages/user/files.vue56
-rw-r--r--packages/frontend/src/pages/user/home.vue9
-rw-r--r--packages/frontend/src/pages/user/index.files.vue58
-rw-r--r--packages/frontend/src/pages/user/index.vue13
-rw-r--r--packages/frontend/src/pages/welcome.entrance.a.vue2
38 files changed, 2867 insertions, 218 deletions
diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/packages/frontend/src/pages/about-misskey.vue
diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue
index f35cbe8d5a..1f36589a49 100644
--- a/packages/frontend/src/pages/about.vue
+++ b/packages/frontend/src/pages/about.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
<XEmojis/>
</MkSpacer>
- <MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
+ <MkSpacer v-else-if="instance.federation !== 'none' && tab === 'federation'" :contentMax="1000" :marginMin="20">
<XFederation/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
@@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
+import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -51,22 +52,34 @@ watch(tab, () => {
const headerActions = computed(() => []);
-const headerTabs = computed(() => [{
- key: 'overview',
- title: i18n.ts.overview,
-}, {
- key: 'emojis',
- title: i18n.ts.customEmojis,
- icon: 'ph-smiley ph-bold ph-lg',
-}, {
- key: 'federation',
- title: i18n.ts.federation,
- icon: 'ti ti-whirl',
-}, {
- key: 'charts',
- title: i18n.ts.charts,
- icon: 'ti ti-chart-line',
-}]);
+const headerTabs = computed(() => {
+ const items = [];
+
+ items.push({
+ key: 'overview',
+ title: i18n.ts.overview,
+ }, {
+ key: 'emojis',
+ title: i18n.ts.customEmojis,
+ icon: 'ph-smiley ph-bold ph-lg',
+ });
+
+ if (instance.federation !== 'none') {
+ items.push({
+ key: 'federation',
+ title: i18n.ts.federation,
+ icon: 'ti ti-whirl',
+ });
+ }
+
+ items.push({
+ key: 'charts',
+ title: i18n.ts.charts,
+ icon: 'ti ti-chart-line',
+ });
+
+ return items;
+});
definePageMetadata(() => ({
title: i18n.ts.instanceInfo,
diff --git a/packages/frontend/src/pages/admin/bot-protection.vue b/packages/frontend/src/pages/admin/bot-protection.vue
index 2f6dac8097..23c5a0f9b7 100644
--- a/packages/frontend/src/pages/admin/bot-protection.vue
+++ b/packages/frontend/src/pages/admin/bot-protection.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
new file mode 100644
index 0000000000..141ab858d3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.impl.ts
@@ -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 [];
+ }
+}
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue
new file mode 100644
index 0000000000..4b145db0ed
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.logs.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
new file mode 100644
index 0000000000..ae43507d66
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
new file mode 100644
index 0000000000..c4ea3b93e3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
new file mode 100644
index 0000000000..cc8b625cd5
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.register.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
new file mode 100644
index 0000000000..6e7e7e53e3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue
new file mode 100644
index 0000000000..eef55a9f7e
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.logs.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
new file mode 100644
index 0000000000..eecf8d7390
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager.remote.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts
new file mode 100644
index 0000000000..f62304277a
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.stories.impl.ts
@@ -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())),
+});
diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager2.vue b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
new file mode 100644
index 0000000000..fb930064ff
--- /dev/null
+++ b/packages/frontend/src/pages/admin/custom-emojis-manager2.vue
@@ -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>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 6cdf0eda7a..cbd0d12dcc 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -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,
@@ -137,6 +138,11 @@ const menuDef = computed(() => [{
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,
to: '/admin/avatar-decorations',
@@ -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 {
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 5d896db98c..6bab594d36 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -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) => {
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 036f18fe0d..f67b1cd582 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -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()));
diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue
index 9cd2546312..fb99379a0a 100644
--- a/packages/frontend/src/pages/channel.vue
+++ b/packages/frontend/src/pages/channel.vue
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNotes :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'" key="search">
- <div class="_gaps">
+ <div v-if="notesSearchAvailable" class="_gaps">
<div>
<MkInput v-model="searchQuery" @enter="search()">
<template #prefix><i class="ti ti-search"></i></template>
@@ -54,6 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
</div>
+ <div v-else>
+ <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
+ </div>
</div>
</MkHorizontalSwipe>
</MkSpacer>
@@ -94,6 +97,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
+import { notesSearchAvailable } from '@/scripts/check-permissions.js';
import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/router/supplier.js';
import { deepMerge } from '@/scripts/merge.js';
diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue
index bde1650754..6830c1ace4 100644
--- a/packages/frontend/src/pages/channels.vue
+++ b/packages/frontend/src/pages/channels.vue
@@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
- <MkSpacer :contentMax="700">
+ <MkSpacer :contentMax="1200">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
- <div v-if="tab === 'search'" key="search">
+ <div v-if="tab === 'search'" key="search" :class="$style.searchRoot">
<div class="_gaps">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
<template #prefix><i class="ti ti-search"></i></template>
@@ -27,23 +27,31 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-if="tab === 'featured'" key="featured">
<MkPagination v-slot="{items}" :pagination="featuredPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+ <div :class="$style.root">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
+ </div>
</MkPagination>
</div>
<div v-else-if="tab === 'favorites'" key="favorites">
<MkPagination v-slot="{items}" :pagination="favoritesPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+ <div :class="$style.root">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
+ </div>
</MkPagination>
</div>
<div v-else-if="tab === 'following'" key="following">
<MkPagination v-slot="{items}" :pagination="followingPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+ <div :class="$style.root">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
+ </div>
</MkPagination>
</div>
<div v-else-if="tab === 'owned'" key="owned">
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
<MkPagination v-slot="{items}" :pagination="ownedPagination">
- <MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
+ <div :class="$style.root">
+ <MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
+ </div>
</MkPagination>
</div>
</MkHorizontalSwipe>
@@ -85,6 +93,7 @@ onMounted(() => {
const featuredPagination = {
endpoint: 'channels/featured' as const,
+ limit: 10,
noPaging: true,
};
const favoritesPagination = {
@@ -157,3 +166,17 @@ definePageMetadata(() => ({
icon: 'ti ti-device-tv',
}));
</script>
+
+<style lang="scss" module>
+.searchRoot {
+ width: 100%;
+ max-width: 700px;
+ margin: 0 auto;
+}
+
+.root {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
+ gap: var(--MI-margin);
+}
+</style>
diff --git a/packages/frontend/src/pages/clip.vue b/packages/frontend/src/pages/clip.vue
index 716cd9a73f..240f395e04 100644
--- a/packages/frontend/src/pages/clip.vue
+++ b/packages/frontend/src/pages/clip.vue
@@ -46,9 +46,10 @@ import { clipsCache } from '@/cache.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { genEmbedCode } from '@/scripts/get-embed-code.js';
-import { getServerContext } from '@/server-context.js';
+import { assertServerContext, serverContext } from '@/server-context.js';
-const CTX_CLIP = getServerContext('clip');
+// contextは非ログイン状態の情報しかないためログイン時は利用できない
+const CTX_CLIP = !$i && assertServerContext(serverContext, 'clip') ? serverContext.clip : null;
const props = defineProps<{
clipId: string,
diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue
index 850c1c5eb0..107a0d760c 100644
--- a/packages/frontend/src/pages/custom-emojis-manager.vue
+++ b/packages/frontend/src/pages/custom-emojis-manager.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{items}">
<div class="ldhfsamy">
<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
- <img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
+ <img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{items}">
<div class="ldhfsamy">
<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
- <img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/>
+ <img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
@@ -78,11 +78,13 @@ import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkPagination from '@/components/MkPagination.vue';
+import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSplit from '@/components/form/split.vue';
import { selectFile } from '@/scripts/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
+import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
@@ -161,6 +163,19 @@ const edit = (emoji) => {
});
};
+const detailRemoteEmoji = (emoji) => {
+ const { dispose } = os.popup(MkRemoteEmojiEditDialog, {
+ emoji: emoji,
+ }, {
+ done: () => {
+ dispose();
+ },
+ closed: () => {
+ dispose();
+ },
+ });
+};
+
const importEmoji = (emoji) => {
os.apiWithDialog('admin/emoji/copy', {
emojiId: emoji.id,
@@ -171,13 +186,15 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
os.popupMenu([{
type: 'label',
text: ':' + emoji.name + ':',
- },
- {
+ }, {
+ text: i18n.ts.details,
+ icon: 'ti ti-info-circle',
+ action: () => { detailRemoteEmoji(emoji); },
+ }, {
text: i18n.ts.import,
icon: 'ti ti-plus',
action: () => { importEmoji(emoji); },
- },
- {
+ }, {
text: i18n.ts.delete,
icon: 'ph-trash ph-bold ph-lg',
action: () => {
diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue
index d3e9ca0dcf..c8e6dfb05a 100644
--- a/packages/frontend/src/pages/emoji-edit-dialog.vue
+++ b/packages/frontend/src/pages/emoji-edit-dialog.vue
@@ -118,7 +118,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
-const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
+const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null);
async function changeImage(ev: Event) {
file.value = await selectFile(ev.currentTarget ?? ev.target, null);
diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue
index 2550100a42..0d2c6217d4 100644
--- a/packages/frontend/src/pages/explore.users.vue
+++ b/packages/frontend/src/pages/explore.users.vue
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkSpacer :contentMax="1200">
- <MkTab v-model="origin" style="margin-bottom: var(--MI-margin);">
+ <MkTab v-if="instance.federation !== 'none'" v-model="origin" style="margin-bottom: var(--MI-margin);">
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkTab>
@@ -69,6 +69,7 @@ import MkUserList from '@/components/MkUserList.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkTab from '@/components/MkTab.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
+import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue
index e85d2c29c1..ab060587c5 100644
--- a/packages/frontend/src/pages/miauth.vue
+++ b/packages/frontend/src/pages/miauth.vue
@@ -59,18 +59,18 @@ async function onAccept(token: string) {
name: props.name,
iconUrl: props.icon,
permission: _permissions.value,
- }, token).catch(() => {
+ }, token).then(() => {
+ if (props.callback && props.callback !== '') {
+ const cbUrl = new URL(props.callback);
+ if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url');
+ cbUrl.searchParams.set('session', props.session);
+ location.href = cbUrl.toString();
+ } else {
+ authRoot.value?.showUI('success');
+ }
+ }).catch(() => {
authRoot.value?.showUI('failed');
});
-
- if (props.callback && props.callback !== '') {
- const cbUrl = new URL(props.callback);
- if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url');
- cbUrl.searchParams.set('session', props.session);
- location.href = cbUrl.toString();
- } else {
- authRoot.value?.showUI('success');
- }
}
function onDeny() {
@@ -117,5 +117,6 @@ definePageMetadata(() => ({
border-radius: var(--MI-radius);
background-color: var(--MI_THEME-panel);
overflow-x: scroll;
+ white-space: nowrap;
}
</style>
diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue
index 737b0eea4c..b70bff052a 100644
--- a/packages/frontend/src/pages/note.vue
+++ b/packages/frontend/src/pages/note.vue
@@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
+import { host } from '@@/js/config.js';
import type { Paging } from '@/components/MkPagination.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
@@ -61,9 +62,11 @@ import { dateString } from '@/filters/date.js';
import MkClipPreview from '@/components/MkClipPreview.vue';
import { defaultStore } from '@/store.js';
import { pleaseLogin } from '@/scripts/please-login.js';
-import { getServerContext } from '@/server-context.js';
+import { serverContext, assertServerContext } from '@/server-context.js';
+import { $i } from '@/account.js';
-const CTX_NOTE = getServerContext('note');
+// contextは非ログイン状態の情報しかないためログイン時は利用できない
+const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
const MkNoteDetailed = defineAsyncComponent(() =>
(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteDetailed.vue') :
@@ -146,7 +149,12 @@ function fetchNote() {
}).catch(err => {
if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') {
pleaseLogin({
+ path: '/',
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
+ openOnRemote: {
+ type: 'lookup',
+ url: `https://${host}/notes/${props.noteId}`,
+ },
});
}
error.value = err;
diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue
index d64537d289..73b2839a44 100644
--- a/packages/frontend/src/pages/search.note.vue
+++ b/packages/frontend/src/pages/search.note.vue
@@ -13,16 +13,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>{{ i18n.ts.options }}</template>
<div class="_gaps_m">
- <MkRadios v-model="hostSelect">
- <template #label>{{ i18n.ts.host }}</template>
- <option value="all" default>{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
- </MkRadios>
- <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
- <template #prefix><i class="ti ti-server"></i></template>
- </MkInput>
+ <template v-if="instance.federation !== 'none'">
+ <MkRadios v-model="hostSelect">
+ <template #label>{{ i18n.ts.host }}</template>
+ <option value="all" default>{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
+ </MkRadios>
+ <MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
+ <template #prefix><i class="ti ti-server"></i></template>
+ </MkInput>
+ </template>
+
<MkSwitch v-model="order">Sort by newest to oldest</MkSwitch>
+
<MkSelect v-model="filetype" small>
<template #label>File Type</template>
<option :value="null">None</option>
@@ -114,7 +118,7 @@ setHostSelectWithInput(hostInput.value, undefined);
watch(hostInput, setHostSelectWithInput);
const searchHost = computed(() => {
- if (hostSelect.value === 'local') return '.';
+ if (hostSelect.value === 'local' || instance.federation === 'none') return '.';
if (hostSelect.value === 'specified') return hostInput.value;
return null;
});
diff --git a/packages/frontend/src/pages/search.user.vue b/packages/frontend/src/pages/search.user.vue
index a355c0eeaa..772ee91d63 100644
--- a/packages/frontend/src/pages/search.user.vue
+++ b/packages/frontend/src/pages/search.user.vue
@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
- <MkRadios v-model="searchOrigin" @update:modelValue="search()">
+ <MkRadios v-if="instance.federation !== 'none'" v-model="searchOrigin" @update:modelValue="search()">
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
@@ -33,6 +33,7 @@ import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
import * as os from '@/os.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
@@ -118,7 +119,7 @@ async function search() {
limit: 10,
params: {
query: query,
- origin: searchOrigin.value,
+ origin: instance.federation === 'none' ? 'local' : searchOrigin.value,
},
};
diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue
index 97e960675f..c2588736b3 100644
--- a/packages/frontend/src/pages/settings/accounts.vue
+++ b/packages/frontend/src/pages/settings/accounts.vue
@@ -12,7 +12,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton>
</div>
- <MkUserCardMini v-for="user in accounts" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/>
+ <template v-for="[id, user] in accounts">
+ <MkUserCardMini v-if="user != null" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/>
+ <button v-else v-panel class="_button" :class="$style.unknownUser" @click="menu(id, $event)">
+ <div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div>
+ <div>
+ <div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}</div>
+ <div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ id }}</span></div>
+ </div>
+ </button>
+ </template>
</div>
</FormSuspense>
</div>
@@ -29,9 +38,10 @@ import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWith
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import { MenuItem } from '@/types/menu';
const storedAccounts = ref<{ id: string, token: string }[] | null>(null);
-const accounts = ref<Misskey.entities.UserDetailed[]>([]);
+const accounts = ref(new Map<string, Misskey.entities.UserDetailed | null>());
const init = async () => {
getAccounts().then(accounts => {
@@ -41,21 +51,35 @@ const init = async () => {
userIds: storedAccounts.value.map(x => x.id),
});
}).then(response => {
- accounts.value = response;
+ if (storedAccounts.value == null) return;
+ accounts.value = new Map(storedAccounts.value.map(x => [x.id, response.find((y: Misskey.entities.UserDetailed) => y.id === x.id) ?? null]));
});
};
-function menu(account: Misskey.entities.UserDetailed, ev: MouseEvent) {
- os.popupMenu([{
- text: i18n.ts.switch,
- icon: 'ti ti-switch-horizontal',
- action: () => switchAccount(account),
- }, {
- text: i18n.ts.logout,
- icon: 'ti ti-trash',
- danger: true,
- action: () => removeAccount(account),
- }], ev.currentTarget ?? ev.target);
+function menu(account: Misskey.entities.UserDetailed | string, ev: MouseEvent) {
+ let menu: MenuItem[];
+
+ if (typeof account === 'string') {
+ menu = [{
+ text: i18n.ts.logout,
+ icon: 'ti ti-trash',
+ danger: true,
+ action: () => removeAccount(account),
+ }];
+ } else {
+ menu = [{
+ text: i18n.ts.switch,
+ icon: 'ti ti-switch-horizontal',
+ action: () => switchAccount(account.id),
+ }, {
+ text: i18n.ts.logout,
+ icon: 'ti ti-trash',
+ danger: true,
+ action: () => removeAccount(account.id),
+ }];
+ }
+
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
function addAccount(ev: MouseEvent) {
@@ -68,9 +92,9 @@ function addAccount(ev: MouseEvent) {
}], ev.currentTarget ?? ev.target);
}
-async function removeAccount(account: Misskey.entities.UserDetailed) {
- await _removeAccount(account.id);
- accounts.value = accounts.value.filter(x => x.id !== account.id);
+async function removeAccount(id: string) {
+ await _removeAccount(id);
+ accounts.value.delete(id);
}
function addExistingAccount() {
@@ -90,9 +114,9 @@ function createAccount() {
});
}
-async function switchAccount(account: Misskey.entities.UserDetailed) {
+async function switchAccount(id: string) {
const fetchedAccounts = await getAccounts();
- const token = fetchedAccounts.find(x => x.id === account.id)!.token;
+ const token = fetchedAccounts.find(x => x.id === id)!.token;
switchAccountWithToken(token);
}
@@ -112,6 +136,49 @@ definePageMetadata(() => ({
<style lang="scss" module>
.user {
- cursor: pointer;
+ cursor: pointer;
+}
+
+.unknownUser {
+ display: flex;
+ align-items: center;
+ text-align: start;
+ padding: 16px;
+ background: var(--MI_THEME-panel);
+ border-radius: 8px;
+ font-size: 0.9em;
+}
+
+.unknownUserAvatarMock {
+ display: block;
+ width: 34px;
+ height: 34px;
+ line-height: 34px;
+ text-align: center;
+ font-size: 16px;
+ margin-right: 12px;
+ background-color: color-mix(in srgb, var(--MI_THEME-fg), transparent 85%);
+ color: color-mix(in srgb, var(--MI_THEME-fg), transparent 25%);
+ border-radius: 50%;
+}
+
+.unknownUserTitle {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 18px;
+}
+
+.unknownUserSub {
+ display: block;
+ width: 100%;
+ font-size: 95%;
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 16px;
}
</style>
diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue
index cb0451c8b4..f8a0575a77 100644
--- a/packages/frontend/src/pages/settings/general.vue
+++ b/packages/frontend/src/pages/settings/general.vue
@@ -106,7 +106,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch>
</div>
- <MkSelect v-model="instanceTicker">
+ <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
<template #label>{{ i18n.ts.instanceTicker }}</template>
<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
@@ -357,6 +357,7 @@ import MkInfo from '@/components/MkInfo.vue';
import { searchEngineMap } from '@/scripts/search-engine-map.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
+import { instance } from '@/instance.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { reloadAsk } from '@/scripts/reload-ask.js';
import { i18n } from '@/i18n.js';
diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue
index 552b4ee028..b7bf8c5dc1 100644
--- a/packages/frontend/src/pages/settings/index.vue
+++ b/packages/frontend/src/pages/settings/index.vue
@@ -43,7 +43,7 @@ const indexInfo = {
icon: 'ti ti-settings',
hideHeader: true,
};
-const INFO = ref(indexInfo);
+const INFO = ref<PageMetadata>(indexInfo);
const el = shallowRef<HTMLElement | null>(null);
const childInfo = ref<null | PageMetadata>(null);
diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue
index 82aeb6063f..d6ee45e074 100644
--- a/packages/frontend/src/pages/settings/mute-block.vue
+++ b/packages/frontend/src/pages/settings/mute-block.vue
@@ -9,17 +9,24 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ph-envelope ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.wordMute }}</template>
- <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/>
+ <div class="_gaps_m">
+ <MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo>
+ <MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch>
+ <XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/>
+ </div>
</MkFolder>
<MkFolder>
<template #icon><i class="ph-x-square ph-bold ph-lg"></i></template>
<template #label>{{ i18n.ts.hardWordMute }}</template>
- <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
+ <div class="_gaps_m">
+ <MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
+ <XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
+ </div>
</MkFolder>
- <MkFolder>
+ <MkFolder v-if="instance.federation !== 'none'">
<template #icon><i class="ti ti-planet-off"></i></template>
<template #label>{{ i18n.ts.instanceMute }}</template>
@@ -126,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
-import { ref, computed } from 'vue';
+import { ref, computed, watch } from 'vue';
import XInstanceMute from './mute-block.instance-mute.vue';
import XWordMute from './mute-block.word-mute.vue';
import MkPagination from '@/components/MkPagination.vue';
@@ -135,9 +142,13 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os.js';
-import { infoImageUrl } from '@/instance.js';
+import { instance, infoImageUrl } from '@/instance.js';
import { signinRequired } from '@/account.js';
+import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import { defaultStore } from '@/store';
+import { reloadAsk } from '@/scripts/reload-ask.js';
const $i = signinRequired();
@@ -160,6 +171,14 @@ const expandedRenoteMuteItems = ref([]);
const expandedMuteItems = ref([]);
const expandedBlockItems = ref([]);
+const showSoftWordMutedWord = computed(defaultStore.makeGetterSetter('showSoftWordMutedWord'));
+
+watch([
+ showSoftWordMutedWord,
+], async () => {
+ await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
+});
+
async function unrenoteMute(user, ev) {
os.popupMenu([{
text: i18n.ts.renoteUnmute,
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index 790f9e44e2..ce4c229a3a 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>
<div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div>
- <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div>
+ <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div>
</template>
</MkSwitch>
@@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>
<div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div>
- <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
+ <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
</template>
</FormSlot>
@@ -129,7 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>
<div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div>
- <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
+ <div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
</template>
</FormSlot>
</div>
@@ -171,6 +171,7 @@ import MkFolder from '@/components/MkFolder.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
import { signinRequired } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import FormSlot from '@/components/form/slot.vue';
@@ -224,7 +225,7 @@ watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => {
});
async function update_requireSigninToViewContents(value: boolean) {
- if (value) {
+ if (value === true && instance.federation !== 'none') {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.acknowledgeNotesAndEnable,
diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
index 67943524ef..140b6beb14 100644
--- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue
+++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSelect v-model="statusbar.type" placeholder="Please select">
<template #label>{{ i18n.ts.type }}</template>
<option value="rss">RSS</option>
- <option value="federation">Federation</option>
+ <option v-if="instance.federation !== 'none'" value="federation">Federation</option>
<option value="userList">User list timeline</option>
</MkSelect>
@@ -96,6 +96,7 @@ import MkButton from '@/components/MkButton.vue';
import MkRange from '@/components/MkRange.vue';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
+import { instance } from '@/instance.js';
import { deepClone } from '@/scripts/clone.js';
const props = defineProps<{
diff --git a/packages/frontend/src/pages/theme-editor.vue b/packages/frontend/src/pages/theme-editor.vue
index a530f4b5d6..e49d6af470 100644
--- a/packages/frontend/src/pages/theme-editor.vue
+++ b/packages/frontend/src/pages/theme-editor.vue
@@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
-import { toUnicode } from 'punycode/';
+import { toUnicode } from 'punycode.js';
import tinycolor from 'tinycolor2';
import { v4 as uuid } from 'uuid';
import JSON5 from 'json5';
diff --git a/packages/frontend/src/pages/user/files.vue b/packages/frontend/src/pages/user/files.vue
new file mode 100644
index 0000000000..b6c7c1c777
--- /dev/null
+++ b/packages/frontend/src/pages/user/files.vue
@@ -0,0 +1,56 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+ <MkSpacer :contentMax="1100">
+ <div :class="$style.root">
+ <MkPagination v-slot="{items}" :pagination="pagination">
+ <div :class="$style.stream">
+ <MkNoteMediaGrid v-for="note in items" :note="note" square/>
+ </div>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+
+import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
+import MkPagination from '@/components/MkPagination.vue';
+
+const props = defineProps<{
+ user: Misskey.entities.UserDetailed;
+}>();
+
+const pagination = {
+ endpoint: 'users/notes' as const,
+ limit: 15,
+ params: computed(() => ({
+ userId: props.user.id,
+ withFiles: true,
+ })),
+};
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 8px;
+}
+
+.stream {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: var(--MI-marginHalf);
+}
+
+@media screen and (min-width: 600px) {
+ .stream {
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ }
+
+}
+</style>
diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue
index 5565555ca4..645c3b3c3c 100644
--- a/packages/frontend/src/pages/user/home.vue
+++ b/packages/frontend/src/pages/user/home.vue
@@ -138,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo v-if="user.pinnedNotes.length === 0 && $i?.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
<template v-if="narrow">
<MkLazy>
- <XFiles :key="user.id" :user="user" :collapsed="true"/>
+ <XFiles :key="user.id" :user="user" :collapsed="true" @unfold="emit('unfoldFiles')"/>
</MkLazy>
<MkLazy>
<XActivity :key="user.id" :user="user" :collapsed="true"/>
@@ -180,7 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
- <XFiles :key="user.id" :user="user"/>
+ <XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
<XActivity :key="user.id" :user="user"/>
<XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/>
</div>
@@ -242,7 +242,6 @@ function calcAge(birthdate: string): number {
const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
const XListenBrainz = defineAsyncComponent(() => import('./index.listenbrainz.vue'));
-//const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed;
@@ -252,6 +251,10 @@ const props = withDefaults(defineProps<{
disableNotes: false,
});
+const emit = defineEmits<{
+ (ev: 'unfoldFiles'): void;
+}>();
+
const router = useRouter();
const user = ref(props.user);
diff --git a/packages/frontend/src/pages/user/index.files.vue b/packages/frontend/src/pages/user/index.files.vue
index 7fe90da865..44e35e3479 100644
--- a/packages/frontend/src/pages/user/index.files.vue
+++ b/packages/frontend/src/pages/user/index.files.vue
@@ -4,30 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkContainer :max-height="300" :foldable="true" :expanded="!collapsed">
+<MkContainer :max-height="300" :foldable="true" :expanded="!collapsed" :onUnfold="unfoldContainer">
<template #icon><i class="ti ti-photo"></i></template>
<template #header>{{ i18n.ts.files }}</template>
<div :class="$style.root">
<MkLoading v-if="fetching"/>
- <div v-if="!fetching && files.length > 0" :class="$style.stream">
- <template v-for="file in files" :key="file.note.id + file.file.id">
- <div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.img" @click="showingFiles.push(file.file.id)">
- <!-- TODO: 画像以外のファイルに対応 -->
- <ImgWithBlurhash :class="$style.sensitiveImg" :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name" :forceBlurhash="true"/>
- <div :class="$style.sensitive">
- <div>
- <div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div>
- <div>{{ i18n.ts.clickToShow }}</div>
- </div>
- </div>
- </div>
- <MkA v-else :class="$style.img" :to="notePage(file.note)">
- <!-- TODO: 画像以外のファイルに対応 -->
- <ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/>
- </MkA>
- </template>
+ <div v-if="!fetching && notes.length > 0" :class="$style.stream">
+ <MkNoteMediaGrid v-for="note in notes" :note="note"/>
</div>
- <p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
+ <p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
</div>
</MkContainer>
</template>
@@ -35,13 +20,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
-import { getStaticImageUrl } from '@/scripts/media-proxy.js';
-import { notePage } from '@/filters/note.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import MkContainer from '@/components/MkContainer.vue';
-import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
-import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
+import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed;
@@ -50,33 +32,25 @@ const props = withDefaults(defineProps<{
collapsed: false,
});
+const emit = defineEmits<{
+ (ev: 'unfold'): void;
+}>();
+
const fetching = ref(true);
-const files = ref<{
- note: Misskey.entities.Note;
- file: Misskey.entities.DriveFile;
-}[]>([]);
-const showingFiles = ref<string[]>([]);
+const notes = ref<Misskey.entities.Note[]>([]);
-function thumbnail(image: Misskey.entities.DriveFile): string {
- return defaultStore.state.disableShowingAnimatedImages
- ? getStaticImageUrl(image.url)
- : image.thumbnailUrl;
+function unfoldContainer(): boolean {
+ emit('unfold');
+ return false;
}
onMounted(() => {
misskeyApi('users/notes', {
userId: props.user.id,
withFiles: true,
- limit: 15,
- }).then(notes => {
- for (const note of notes) {
- for (const file of note.files) {
- files.value.push({
- note,
- file,
- });
- }
- }
+ limit: 10,
+ }).then(_notes => {
+ notes.value = _notes;
fetching.value = false;
});
});
diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue
index a35250bf5f..ba02559d68 100644
--- a/packages/frontend/src/pages/user/index.vue
+++ b/packages/frontend/src/pages/user/index.vue
@@ -9,10 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<div v-if="user">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
- <XHome v-if="tab === 'home'" key="home" :user="user"/>
+ <XHome v-if="tab === 'home'" key="home" :user="user" @unfoldFiles="() => { tab = 'files'; }"/>
<MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0">
<XTimeline :user="user"/>
</MkSpacer>
+ <XFiles v-else-if="tab === 'files'" :user="user"/>
<XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/>
<XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/>
<XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/>
@@ -39,10 +40,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
-import { getServerContext } from '@/server-context.js';
+import { serverContext, assertServerContext } from '@/server-context.js';
const XHome = defineAsyncComponent(() => import('./home.vue'));
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
+const XFiles = defineAsyncComponent(() => import('./files.vue'));
const XActivity = defineAsyncComponent(() => import('./activity.vue'));
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
@@ -53,7 +55,8 @@ const XFlashs = defineAsyncComponent(() => import('./flashs.vue'));
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
const XRaw = defineAsyncComponent(() => import('./raw.vue'));
-const CTX_USER = getServerContext('user');
+// contextは非ログイン状態の情報しかないためログイン時は利用できない
+const CTX_USER = !$i && assertServerContext(serverContext, 'user') ? serverContext.user : null;
const props = withDefaults(defineProps<{
acct: string;
@@ -103,6 +106,10 @@ const headerTabs = computed(() => user.value ? [{
title: i18n.ts.notes,
icon: 'ti ti-pencil',
}, {
+ key: 'files',
+ title: i18n.ts.files,
+ icon: 'ti ti-photo',
+}, {
key: 'activity',
title: i18n.ts.activity,
icon: 'ti ti-chart-line',
diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue
index f1842255e0..c5731bd2a9 100644
--- a/packages/frontend/src/pages/welcome.entrance.a.vue
+++ b/packages/frontend/src/pages/welcome.entrance.a.vue
@@ -53,12 +53,14 @@ function getInstanceIcon(instance: Misskey.entities.FederationInstance): string
if (!instance.iconUrl) {
return '';
}
+
return getProxiedImageUrl(instance.iconUrl, 'preview');
}
misskeyApiGet('federation/instances', {
sort: '+pubSub',
limit: 20,
+ blocked: 'false',
}).then(_instances => {
instances.value = _instances;
});