diff options
Diffstat (limited to 'packages/frontend/src/pages')
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; }); |