Merge branch 'develop' into mahjong

This commit is contained in:
syuilo
2025-04-10 11:39:35 +09:00
1539 changed files with 96547 additions and 41240 deletions

View File

@@ -5,9 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkLoading v-if="!loaded"/>
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" appear>
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
<div v-show="loaded" :class="$style.root">
<img :src="serverErrorImageUrl" class="_ghost" :class="$style.img"/>
<img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/>
<div class="_gaps">
<div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div>
<div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div>
@@ -27,15 +27,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import { version } from '@@/js/config.js';
import MkButton from '@/components/MkButton.vue';
import MkLink from '@/components/MkLink.vue';
import { version } from '@/config.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { miLocalStorage } from '@/local-storage.js';
import { defaultStore } from '@/store.js';
import { prefer } from '@/preferences.js';
import { serverErrorImageUrl } from '@/instance.js';
const props = withDefaults(defineProps<{
@@ -67,7 +67,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.error,
icon: 'ti ti-alert-triangle',
}));

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div style="overflow: clip;">
<MkSpacer :contentMax="600" :marginMin="20">
<div class="_gaps_m znqjceqz">
@@ -72,10 +71,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@syuilo</span>
</a>
<a href="https://github.com/tamaina" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/7973572?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@tamaina</span>
</a>
<a href="https://github.com/acid-chicken" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@acid-chicken</span>
@@ -113,6 +108,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<a style="display: inline-block;" class="pepabo" title="GMO Pepabo" href="https://pepabo.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/gmo_pepabo.svg" alt="GMO Pepabo"></a>
</div>
<div>
<a style="display: inline-block;" class="purpledotdigital" title="Purple Dot Digital" href="https://purpledotdigital.com/" target="_blank"><img style="width: 100%;" src="https://assets.misskey-hub.net/sponsors/purple-dot-digital.jpg" alt="Purple Dot Digital"></a>
</div>
</div>
</FormSection>
<FormSection>
@@ -131,24 +129,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkSpacer>
</div>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { nextTick, onBeforeUnmount, ref, shallowRef, computed } from 'vue';
import { version } from '@/config.js';
import { nextTick, onBeforeUnmount, ref, useTemplateRef, computed } from 'vue';
import { version } from '@@/js/config.js';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import { physics } from '@/scripts/physics.js';
import { physics } from '@/utility/physics.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
import { $i } from '@/account.js';
import { definePage } from '@/page.js';
import { claimAchievement, claimedAchievements } from '@/utility/achievements.js';
import { $i } from '@/i.js';
import { prefer } from '@/preferences.js';
const patronsWithIcon = [{
name: 'カイヤン',
@@ -258,6 +256,30 @@ const patronsWithIcon = [{
}, {
name: 'えとゔぁす',
icon: 'https://assets.misskey-hub.net/patrons/2578f441b82a44cfaa55ba83a318b26e.jpg',
}, {
name: 'Soli',
icon: 'https://assets.misskey-hub.net/patrons/448070c81ebd41eda4ea2328291b2efe.jpg',
}, {
name: 'ささくれりょう',
icon: 'https://assets.misskey-hub.net/patrons/cf55022cee6c41da8e70a43587aaad9a.jpg',
}, {
name: 'Macop',
icon: 'https://assets.misskey-hub.net/patrons/ee052bf550014d36a643ce3dce595640.jpg',
}, {
name: 'なっかあ',
icon: 'https://assets.misskey-hub.net/patrons/c2f5f3e394e74a64912284a2f4ca710e.jpg',
}, {
name: '如月ユカ',
icon: 'https://assets.misskey-hub.net/patrons/f24a042076a041b6811a2f124eb620ca.jpg',
}, {
name: 'Yatoigawa',
icon: 'https://assets.misskey-hub.net/patrons/505e3568885a4a488431a8f22b4553d0.jpg',
}, {
name: '秋瀬カヲル',
icon: 'https://assets.misskey-hub.net/patrons/0f22aeb866484f4fa51db6721e3f9847.jpg',
}, {
name: '新井 治',
icon: 'https://assets.misskey-hub.net/patrons/d160876f20394674a17963a0e609600a.jpg',
}];
const patrons = [
@@ -363,6 +385,11 @@ const patrons = [
'塩キャベツ',
'はとぽぷさん',
'100の人 (エスパー・イーシア)',
'ケモナーのケシン',
'こまつぶり',
'まゆつな空高',
'asata',
'ruru',
];
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
@@ -375,10 +402,10 @@ const easterEggEmojis = ref<{
emoji: string
}[]>([]);
const easterEggEngine = ref<{ stop: () => void } | null>(null);
const containerEl = shallowRef<HTMLElement>();
const containerEl = useTemplateRef('containerEl');
function iconLoaded() {
const emojis = defaultStore.state.reactions;
const emojis = prefer.s.emojiPalettes[0].emojis;
const containerWidth = containerEl.value.offsetWidth;
for (let i = 0; i < 32; i++) {
easterEggEmojis.value.push({
@@ -422,7 +449,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.aboutMisskey,
icon: null,
}));
@@ -432,7 +459,7 @@ definePageMetadata(() => ({
.znqjceqz {
> .about {
position: relative;
border-radius: var(--radius);
border-radius: var(--MI-radius);
> .treasure {
position: absolute;
@@ -520,17 +547,17 @@ definePageMetadata(() => ({
display: flex;
align-items: center;
padding: 12px;
background: var(--buttonBg);
background: var(--MI_THEME-buttonBg);
border-radius: 6px;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
background: var(--MI_THEME-buttonHoverBg);
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
color: var(--MI_THEME-accent);
background: var(--MI_THEME-buttonHoverBg);
}
}
@@ -553,7 +580,7 @@ definePageMetadata(() => ({
display: flex;
align-items: center;
padding: 12px;
background: var(--buttonBg);
background: var(--MI_THEME-buttonBg);
border-radius: 6px;
}

View File

@@ -44,7 +44,7 @@ import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import { $i } from '@/i.js';
const customEmojiTags = getCustomEmojiTags();
const q = ref('');

View File

@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
<FormSplit style="margin-top: var(--margin);">
<FormSplit style="margin-top: var(--MI-margin);">
<MkSelect v-model="state">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
@@ -54,7 +54,8 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, ref } from 'vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import MkPagination from '@/components/MkPagination.vue';
import type { Paging } from '@/components/MkPagination.vue';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue';
import { i18n } from '@/i18n.js';
@@ -71,9 +72,9 @@ const pagination = {
sort: sort.value,
host: host.value !== '' ? host.value : null,
...(
state.value === 'federating' ? { federating: true } :
state.value === 'subscribing' ? { subscribing: true } :
state.value === 'publishing' ? { publishing: true } :
state.value === 'federating' ? { federating: true, suspended: false, blocked: false } :
state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } :
state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } :
state.value === 'suspended' ? { suspended: true } :
state.value === 'blocked' ? { blocked: true } :
state.value === 'silenced' ? { silenced: true } :

View File

@@ -126,11 +126,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { host, version } from '@/config.js';
import { host, version } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import number from '@/filters/number.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
@@ -147,7 +147,7 @@ const initStats = () => misskeyApi('stats', {});
text-align: center;
border-radius: 10px;
overflow: clip;
background-color: var(--panel);
background-color: var(--MI_THEME-panel);
background-size: cover;
background-position: center center;
}
@@ -183,14 +183,14 @@ const initStats = () => misskeyApi('stats', {});
flex-shrink: 0;
display: flex;
position: sticky;
top: calc(var(--stickyTop, 0px) + 8px);
top: calc(var(--MI-stickyTop, 0px) + 8px);
counter-increment: item;
content: counter(item);
width: 32px;
height: 32px;
line-height: 32px;
background-color: var(--accentedBg);
color: var(--accent);
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
font-size: 13px;
font-weight: bold;
align-items: center;

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<XOverview/>
@@ -13,21 +12,22 @@ 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">
<MkInstanceStats/>
</MkSpacer>
</MkHorizontalSwipe>
</MkStickyContainer>
</PageWithHeader>
</template>
<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';
import { claimAchievement } from '@/utility/achievements.js';
import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue'));
@@ -51,24 +51,36 @@ watch(tab, () => {
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'overview',
title: i18n.ts.overview,
}, {
key: 'emojis',
title: i18n.ts.customEmojis,
icon: 'ti ti-icons',
}, {
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 = [];
definePageMetadata(() => ({
items.push({
key: 'overview',
title: i18n.ts.overview,
}, {
key: 'emojis',
title: i18n.ts.customEmojis,
icon: 'ti ti-icons',
});
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;
});
definePage(() => ({
title: i18n.ts.instanceInfo,
icon: 'ti ti-info-circle',
}));

View File

@@ -4,21 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<PageWithHeader>
<MkSpacer :contentMax="1200">
<MkAchievements :user="$i"/>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, onUnmounted } from 'vue';
import MkAchievements from '@/components/MkAchievements.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i } from '@/account.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { definePage } from '@/page.js';
import { $i } from '@/i.js';
import { claimAchievement } from '@/utility/achievements.js';
let timer: number | null;
@@ -48,7 +47,7 @@ onDeactivated(() => {
}
});
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.achievements,
icon: 'ti ti-medal',
}));

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer v-if="file" :contentMax="600" :marginMin="16" :marginMax="32">
<div v-if="tab === 'overview'" class="cxqhhsmd _gaps_m">
<a class="thumbnail" :href="file.url" target="_blank">
@@ -36,14 +35,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-if="file.user" class="user" :to="`/admin/user/${file.user.id}`">
<MkUserCardMini :user="file.user"/>
</MkA>
<div>
<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">{{ i18n.ts.sensitive }}</MkSwitch>
<MkSwitch :modelValue="isSensitive" @update:modelValue="toggleSensitive">{{ i18n.ts.sensitive }}</MkSwitch>
</div>
<div>
<MkButton danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
<div v-else-if="tab === 'notes' && info" class="_gaps_m">
<XNotes :fileId="fileId"/>
</div>
<div v-else-if="tab === 'ip' && info" class="_gaps_m">
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
<MkKeyValue v-if="info.requestIp" class="_monospace" :copy="info.requestIp" oneline>
@@ -63,11 +66,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkObjectView>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, defineAsyncComponent, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
@@ -79,15 +82,16 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkInfo from '@/components/MkInfo.vue';
import bytes from '@/filters/bytes.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { iAmAdmin, iAmModerator } from '@/account.js';
import { definePage } from '@/page.js';
import { iAmAdmin, iAmModerator } from '@/i.js';
const tab = ref('overview');
const file = ref<Misskey.entities.DriveFile | null>(null);
const info = ref<Misskey.entities.AdminDriveShowFileResponse | null>(null);
const isSensitive = ref<boolean>(false);
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
const props = defineProps<{
fileId: string,
@@ -113,9 +117,21 @@ async function del() {
});
}
async function toggleIsSensitive(v) {
await misskeyApi('drive/files/update', { fileId: props.fileId, isSensitive: v });
isSensitive.value = v;
async function toggleSensitive() {
if (!file.value) return;
const { canceled } = await os.confirm({
type: 'warning',
text: isSensitive.value ? i18n.ts.unmarkAsSensitiveConfirm : i18n.ts.markAsSensitiveConfirm,
});
if (canceled) return;
isSensitive.value = !isSensitive.value;
os.apiWithDialog('drive/files/update', {
fileId: file.value.id,
isSensitive: !file.value.isSensitive,
});
}
const headerActions = computed(() => [{
@@ -131,6 +147,10 @@ const headerTabs = computed(() => [{
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, iAmModerator ? {
key: 'notes',
title: i18n.ts._fileViewer.attachedNotes,
icon: 'ti ti-pencil',
} : null, iAmModerator ? {
key: 'ip',
title: 'IP',
icon: 'ti ti-password',
@@ -140,7 +160,7 @@ const headerTabs = computed(() => [{
icon: 'ti ti-code',
}]);
definePageMetadata(() => ({
definePage(() => ({
title: file.value ? `${i18n.ts.file}: ${file.value.name}` : i18n.ts.file,
icon: 'ti ti-file',
}));

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="600" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_gaps_m">
@@ -22,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<MkInfo v-if="user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
<FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
@@ -37,22 +36,25 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
</MkKeyValue>
-->
<MkKeyValue oneline>
<template #key>{{ i18n.ts.createdAt }}</template>
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.lastActiveDate }}</template>
<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.email }}</template>
<template #value><span class="_monospace">{{ info.email }}</span></template>
</MkKeyValue>
<template v-if="!isSystem">
<MkKeyValue oneline>
<template #key>{{ i18n.ts.createdAt }}</template>
<template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.lastActiveDate }}</template>
<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.email }}</template>
<template #value><span class="_monospace">{{ info.email }}</span></template>
</MkKeyValue>
</template>
</div>
<MkTextarea v-model="moderationNote" manualSave>
<MkTextarea v-if="!isSystem" v-model="moderationNote" manualSave>
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
<!--
@@ -91,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection>
-->
<FormSection>
<FormSection v-if="!isSystem">
<div class="_gaps">
<MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
@@ -152,15 +154,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'announcements'" class="_gaps">
<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
<MkSelect v-model="announcementsStatus">
<template #label>{{ i18n.ts.filter }}</template>
<option value="active">{{ i18n.ts.active }}</option>
<option value="archived">{{ i18n.ts.archived }}</option>
</MkSelect>
<MkPagination :pagination="announcementsPagination">
<template #default="{ items }">
<div class="_gaps_s">
<div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)">
<span style="margin-right: 0.5em;">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</span>
<span>{{ announcement.title }}</span>
<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span>
@@ -199,12 +207,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import MkChart from '@/components/MkChart.vue';
import MkObjectView from '@/components/MkObjectView.vue';
import MkTextarea from '@/components/MkTextarea.vue';
@@ -219,12 +228,11 @@ import FormSuspense from '@/components/form/suspense.vue';
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { url } from '@/config.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { acct } from '@/filters/user.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { iAmAdmin, $i, iAmModerator } from '@/account.js';
import { iAmAdmin, $i, iAmModerator } from '@/i.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
@@ -245,6 +253,7 @@ const ap = ref<any>(null);
const moderator = ref(false);
const silenced = ref(false);
const suspended = ref(false);
const isSystem = ref(false);
const moderationNote = ref('');
const filesPagination = {
endpoint: 'admin/drive/files' as const,
@@ -253,11 +262,15 @@ const filesPagination = {
userId: props.userId,
})),
};
const announcementsStatus = ref<'active' | 'archived'>('active');
const announcementsPagination = {
endpoint: 'admin/announcements/list' as const,
limit: 10,
params: computed(() => ({
userId: props.userId,
status: announcementsStatus.value,
})),
};
const expandedRoles = ref([]);
@@ -277,6 +290,7 @@ function createFetcher() {
silenced.value = info.value.isSilenced;
suspended.value = info.value.isSuspended;
moderationNote.value = info.value.moderationNote;
isSystem.value = user.value.host == null && user.value.username.includes('.');
watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });
@@ -407,7 +421,7 @@ async function deleteAccount() {
}
async function assignRole() {
const roles = await misskeyApi('admin/roles/list');
const roles = await misskeyApi('admin/roles/list').then(it => it.filter(r => r.target === 'manual'));
const { canceled, result: roleId } = await os.select({
title: i18n.ts._role.chooseRoleToAssign,
@@ -496,7 +510,15 @@ watch(user, () => {
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
const headerTabs = computed(() => isSystem.value ? [{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, {
key: 'raw',
title: 'Raw',
icon: 'ti ti-code',
}] : [{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
@@ -522,7 +544,7 @@ const headerTabs = computed(() => [{
icon: 'ti ti-code',
}]);
definePageMetadata(() => ({
definePage(() => ({
title: user.value ? acct(user.value) : i18n.ts.userInfo,
icon: 'ti ti-user-exclamation',
}));
@@ -581,18 +603,18 @@ definePageMetadata(() => ({
}
> .suspended {
color: var(--error);
border-color: var(--error);
color: var(--MI_THEME-error);
border-color: var(--MI_THEME-error);
}
> .silenced {
color: var(--warn);
border-color: var(--warn);
color: var(--MI_THEME-warn);
border-color: var(--MI_THEME-warn);
}
> .moderator {
color: var(--success);
border-color: var(--success);
color: var(--MI_THEME-success);
border-color: var(--MI_THEME-success);
}
}
}
@@ -616,6 +638,7 @@ definePageMetadata(() => ({
<style lang="scss" module>
.ip {
display: flex;
word-break: break-all;
> :global(.date) {
opacity: 0.7;
@@ -639,7 +662,7 @@ definePageMetadata(() => ({
.roleItemSub {
padding: 6px 12px;
font-size: 85%;
color: var(--fgTransparentWeak);
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
.roleUnassign {

View File

@@ -71,7 +71,7 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { deepClone } from '@/scripts/clone.js';
import { deepClone } from '@/utility/clone.js';
import { rolesCache } from '@/cache.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -155,12 +155,12 @@ function removeSelf() {
}
.item {
border: solid 2px var(--divider);
border-radius: var(--radius);
border: solid 2px var(--MI_THEME-divider);
border-radius: var(--MI-radius);
padding: 12px;
&:hover {
border-color: var(--accent);
border-color: var(--MI_THEME-accent);
}
}
</style>

View File

@@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="buttons right">
<template v-if="actions">
<template v-for="action in actions">
<MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
<button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
<MkButton v-if="action.asFullButton" class="fullButton" primary :disabled="action.disabled" @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton>
<button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" :disabled="action.disabled" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>
</template>
</div>
@@ -33,13 +33,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, shallowRef, watch, nextTick } from 'vue';
import { computed, onMounted, onUnmounted, ref, useTemplateRef, watch, nextTick, inject } from 'vue';
import tinycolor from 'tinycolor2';
import { scrollToTop } from '@@/js/scroll.js';
import { popupMenu } from '@/os.js';
import { scrollToTop } from '@/scripts/scroll.js';
import MkButton from '@/components/MkButton.vue';
import { globalEvents } from '@/events.js';
import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
import { DI } from '@/di.js';
type Tab = {
key?: string | null;
@@ -56,6 +56,7 @@ const props = defineProps<{
text: string;
icon: string;
asFullButton?: boolean;
disabled?: boolean;
handler: (ev: MouseEvent) => void;
}[];
thin?: boolean;
@@ -65,11 +66,11 @@ const emit = defineEmits<{
(ev: 'update:tab', key: string);
}>();
const pageMetadata = injectReactiveMetadata();
const pageMetadata = inject(DI.pageMetadata, ref(null));
const el = shallowRef<HTMLElement>(null);
const el = useTemplateRef('el');
const tabHighlightEl = useTemplateRef('tabHighlightEl');
const tabRefs = {};
const tabHighlightEl = shallowRef<HTMLElement | null>(null);
const bg = ref<string | null>(null);
const height = ref(0);
const hasTabs = computed(() => {
@@ -118,15 +119,15 @@ function onTabClick(tab: Tab, ev: MouseEvent): void {
}
const calcBg = () => {
const rawBg = pageMetadata.value?.bg ?? 'var(--bg)';
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
const rawBg = pageMetadata.value.bg ?? 'var(--MI_THEME-bg)';
const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(window.document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
tinyBg.setAlpha(0.85);
bg.value = tinyBg.toRgbString();
};
onMounted(() => {
calcBg();
globalEvents.on('themeChanged', calcBg);
globalEvents.on('themeChanging', calcBg);
watch(() => [props.tab, props.tabs], () => {
nextTick(() => {
@@ -146,7 +147,7 @@ onMounted(() => {
});
onUnmounted(() => {
globalEvents.off('themeChanged', calcBg);
globalEvents.off('themeChanging', calcBg);
});
</script>
@@ -155,8 +156,8 @@ onUnmounted(() => {
--height: 60px;
display: flex;
width: 100%;
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
> .buttons {
--margin: 8px;
@@ -188,7 +189,7 @@ onUnmounted(() => {
}
&.highlighted {
color: var(--accent);
color: var(--MI_THEME-accent);
}
}
@@ -285,7 +286,7 @@ onUnmounted(() => {
position: absolute;
bottom: 0;
height: 3px;
background: var(--accent);
background: var(--MI_THEME-accent);
border-radius: 999px;
transition: all 0.2s ease;
pointer-events: none;

View File

@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
{{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
</template>
<div v-if="loading === 0">
<MkSpacer :marginMin="20" :marginMax="28">
<div v-if="loading === 0" style="display: flex; flex-direction: column; min-height: 100%;">
<MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
<div :class="$style.root" class="_gaps_m">
<MkInput v-model="title">
<template #label>{{ i18n.ts.title }}</template>
@@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ webhook.name }}
</option>
</MkSelect>
<MkButton rounded @click="onEditSystemWebhookClicked">
<MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked">
<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
<span v-else class="ti ti-settings" style="line-height: normal"/>
</MkButton>
@@ -60,8 +60,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer>
<div :class="$style.footer" class="_buttonsCenter">
<MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
<MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
<MkButton primary rounded :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
<MkButton rounded @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
</div>
</div>
<div v-else>
@@ -71,15 +71,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, shallowRef, toRefs } from 'vue';
import { computed, onMounted, ref, useTemplateRef, toRefs } from 'vue';
import { entities } from 'misskey-js';
import type { MkSystemWebhookResult } from '@/components/MkSystemWebhookEditor.impl.js';
import MkButton from '@/components/MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import MkInput from '@/components/MkInput.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkSelect from '@/components/MkSelect.vue';
import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkDivider from '@/components/MkDivider.vue';
import * as os from '@/os.js';
@@ -99,7 +100,7 @@ const props = defineProps<{
const { mode, id } = toRefs(props);
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
const dialogEl = useTemplateRef('dialogEl');
const loading = ref<number>(0);
@@ -289,10 +290,15 @@ onMounted(async () => {
}
.footer {
display: flex;
justify-content: center;
align-items: flex-end;
margin-top: 20px;
position: sticky;
z-index: 10000;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--MI_THEME-divider);
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
.systemWebhook {
@@ -301,16 +307,16 @@ onMounted(async () => {
justify-content: stretch;
align-items: flex-end;
gap: 8px;
}
button {
min-width: 0;
min-height: 0;
width: 34px;
height: 34px;
flex-shrink: 0;
box-sizing: border-box;
margin: 1px 0;
padding: 6px;
}
.systemWebhookEditButton {
min-width: 0;
min-height: 0;
width: 34px;
height: 34px;
flex-shrink: 0;
box-sizing: border-box;
margin: 1px 0;
padding: 6px;
}
</style>

View File

@@ -87,7 +87,7 @@ function onDeleteButtonClicked() {
}
.rightDivider {
border-right: 0.5px solid var(--divider);
border-right: 0.5px solid var(--MI_THEME-divider);
}
.recipientButtons {
@@ -108,7 +108,7 @@ function onDeleteButtonClicked() {
padding: 8px;
&:hover {
background-color: var(--buttonBg);
background-color: var(--MI_THEME-buttonBg);
}
}
</style>

View File

@@ -49,7 +49,7 @@ import { entities } from 'misskey-js';
import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
import XRecipient from './notification-recipient.item.vue';
import XHeader from '@/pages/admin/_header_.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';

View File

@@ -9,9 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="900">
<div :class="$style.root" class="_gaps">
<div :class="$style.subMenus" class="_gaps">
<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton>
<MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ i18n.ts.notificationSetting }}</MkButton>
</div>
<MkInfo v-if="!store.r.abusesTutorial.value" closable @close="closeTutorial()">
{{ i18n.ts._abuseUserReport.resolveTutorial }}
</MkInfo>
<div :class="$style.inputs" class="_gaps">
<MkSelect v-model="state" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.state }}</template>
@@ -44,8 +48,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
-->
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination">
<div class="_gaps">
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
</div>
</MkPagination>
</div>
</MkSpacer>
@@ -53,17 +59,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, shallowRef, ref } from 'vue';
import { computed, useTemplateRef, ref } from 'vue';
import XHeader from './_header_.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import XAbuseReport from '@/components/MkAbuseReport.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import { store } from '@/store.js';
const reports = shallowRef<InstanceType<typeof MkPagination>>();
const reports = useTemplateRef('reports');
const state = ref('unresolved');
const reporterOrigin = ref('combined');
@@ -85,11 +92,15 @@ function resolved(reportId) {
reports.value?.removeItem(reportId);
}
function closeTutorial() {
store.set('abusesTutorial', false);
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.abuseReports,
icon: 'ti ti-exclamation-circle',
}));

View File

@@ -65,18 +65,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkTextarea v-model="ad.memo">
<template #label>{{ i18n.ts.memo }}</template>
</MkTextarea>
<div class="buttons">
<MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)">
<div class="_buttons">
<MkButton inline primary style="margin-right: 12px;" @click="save(ad)">
<i
class="ti ti-device-floppy"
></i> {{ i18n.ts.save }}
</MkButton>
<MkButton class="button" inline danger @click="remove(ad)">
<MkButton inline danger @click="remove(ad)">
<i class="ti ti-trash"></i> {{ i18n.ts.remove }}
</MkButton>
</div>
</div>
<MkButton class="button" @click="more()">
<MkButton @click="more()">
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
</MkButton>
</div>
@@ -96,9 +96,9 @@ import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const ads = ref<Misskey.entities.Ad[]>([]);
@@ -255,7 +255,7 @@ const headerActions = computed(() => [{
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.ads,
icon: 'ti ti-ad',
}));
@@ -266,7 +266,7 @@ definePageMetadata(() => ({
padding: 32px;
&:not(:last-child) {
margin-bottom: var(--margin);
margin-bottom: var(--MI-margin);
}
}
.input {

View File

@@ -11,85 +11,111 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo>
<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
<MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null">
<template #label>{{ announcement.title }}</template>
<template #icon>
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
</template>
<template #caption>{{ announcement.text }}</template>
<MkSelect v-model="announcementsStatus">
<template #label>{{ i18n.ts.filter }}</template>
<option value="active">{{ i18n.ts.active }}</option>
<option value="archived">{{ i18n.ts.archived }}</option>
</MkSelect>
<div class="_gaps_m">
<MkInput v-model="announcement.title">
<template #label>{{ i18n.ts.title }}</template>
</MkInput>
<MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkRadios v-model="announcement.icon">
<template #label>{{ i18n.ts.icon }}</template>
<option value="info"><i class="ti ti-info-circle"></i></option>
<option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option>
<option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option>
<option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option>
</MkRadios>
<MkRadios v-model="announcement.display">
<template #label>{{ i18n.ts.display }}</template>
<option value="normal">{{ i18n.ts.normal }}</option>
<option value="banner">{{ i18n.ts.banner }}</option>
<option value="dialog">{{ i18n.ts.dialog }}</option>
</MkRadios>
<MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo>
<MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription">
{{ i18n.ts._announcement.forExistingUsers }}
</MkSwitch>
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
{{ i18n.ts._announcement.silence }}
</MkSwitch>
<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
{{ i18n.ts._announcement.needConfirmationToRead }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
<MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
<MkLoading v-if="loading"/>
<template v-else>
<MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null">
<template #label>{{ announcement.title }}</template>
<template #icon>
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</template>
<template #caption>{{ announcement.text }}</template>
<template #footer>
<div class="_buttons">
<MkButton rounded primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="announcement.id != null && announcement.isActive" rounded @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton>
<MkButton v-if="announcement.id != null && !announcement.isActive" rounded @click="unarchive(announcement)"><i class="ti ti-restore"></i> {{ i18n.ts.unarchive }}</MkButton>
<MkButton v-if="announcement.id != null" rounded danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</template>
<div class="_gaps">
<MkInput v-model="announcement.title">
<template #label>{{ i18n.ts.title }}</template>
</MkInput>
<MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts.text }}</template>
</MkTextarea>
<MkInput v-model="announcement.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkRadios v-model="announcement.icon">
<template #label>{{ i18n.ts.icon }}</template>
<option value="info"><i class="ti ti-info-circle"></i></option>
<option value="warning"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i></option>
<option value="error"><i class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i></option>
<option value="success"><i class="ti ti-check" style="color: var(--MI_THEME-success);"></i></option>
</MkRadios>
<MkRadios v-model="announcement.display">
<template #label>{{ i18n.ts.display }}</template>
<option value="normal">{{ i18n.ts.normal }}</option>
<option value="banner">{{ i18n.ts.banner }}</option>
<option value="dialog">{{ i18n.ts.dialog }}</option>
</MkRadios>
<MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo>
<MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription">
{{ i18n.ts._announcement.forExistingUsers }}
</MkSwitch>
<MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription">
{{ i18n.ts._announcement.silence }}
</MkSwitch>
<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription">
{{ i18n.ts._announcement.needConfirmationToRead }}
</MkSwitch>
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
</div>
</div>
</MkFolder>
<MkButton class="button" @click="more()">
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
</MkButton>
</MkFolder>
<MkLoading v-if="loadingMore"/>
<MkButton @click="more()">
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
</MkButton>
</template>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue';
import MkTextarea from '@/components/MkTextarea.vue';
const announcementsStatus = ref<'active' | 'archived'>('active');
const loading = ref(true);
const loadingMore = ref(false);
const announcements = ref<any[]>([]);
misskeyApi('admin/announcements/list').then(announcementResponse => {
announcements.value = announcementResponse;
});
watch(announcementsStatus, (to) => {
loading.value = true;
misskeyApi('admin/announcements/list', {
status: to,
}).then(announcementResponse => {
announcements.value = announcementResponse;
loading.value = false;
});
}, { immediate: true });
function add() {
announcements.value.unshift({
@@ -125,6 +151,14 @@ async function archive(announcement) {
refresh();
}
async function unarchive(announcement) {
await os.apiWithDialog('admin/announcements/update', {
...announcement,
isActive: true,
});
refresh();
}
async function save(announcement) {
if (announcement.id == null) {
await os.apiWithDialog('admin/announcements/create', announcement);
@@ -135,29 +169,37 @@ async function save(announcement) {
}
function more() {
misskeyApi('admin/announcements/list', { untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id }).then(announcementResponse => {
loadingMore.value = true;
misskeyApi('admin/announcements/list', {
status: announcementsStatus.value,
untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id,
}).then(announcementResponse => {
announcements.value = announcements.value.concat(announcementResponse);
loadingMore.value = false;
});
}
function refresh() {
misskeyApi('admin/announcements/list').then(announcementResponse => {
loading.value = true;
misskeyApi('admin/announcements/list', {
status: announcementsStatus.value,
}).then(announcementResponse => {
announcements.value = announcementResponse;
loading.value = false;
});
}
refresh();
const headerActions = computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
text: i18n.ts.add,
handler: add,
disabled: announcementsStatus.value === 'archived',
}]);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.announcements,
icon: 'ti ti-speakerphone',
}));

View File

@@ -4,145 +4,270 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<FormSuspense :p="init">
<div class="_gaps_m">
<MkRadios v-model="provider">
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
</MkRadios>
<MkFolder>
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="botProtectionForm.savedState.provider === 'hcaptcha'" #suffix>hCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<template #footer>
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
</template>
<template v-if="provider === 'hcaptcha'">
<MkInput v-model="hcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="hcaptchaSecretKey">
<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="hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
</FormSlot>
</template>
<template v-else-if="provider === 'mcaptcha'">
<MkInput v-model="mcaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="mcaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
</MkInput>
<MkInput v-model="mcaptchaInstanceUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
</MkInput>
<FormSlot v-if="mcaptchaSiteKey && mcaptchaInstanceUrl">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="mcaptcha" :sitekey="mcaptchaSiteKey" :instanceUrl="mcaptchaInstanceUrl"/>
</FormSlot>
</template>
<template v-else-if="provider === 'recaptcha'">
<MkInput v-model="recaptchaSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="recaptchaSecretKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="recaptchaSiteKey">
<template #label>{{ i18n.ts.preview }}</template>
<MkCaptcha provider="recaptcha" :sitekey="recaptchaSiteKey"/>
</FormSlot>
</template>
<template v-else-if="provider === 'turnstile'">
<MkInput v-model="turnstileSiteKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</MkInput>
<MkInput v-model="turnstileSecretKey">
<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="turnstileSiteKey || '1x00000000000000000000AA'"/>
</FormSlot>
</template>
<div class="_gaps_m">
<MkRadios v-model="botProtectionForm.state.provider">
<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>
<option value="turnstile">Turnstile</option>
<option value="testcaptcha">testCaptcha</option>
</MkRadios>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</FormSuspense>
</div>
<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
<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" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
</MkInput>
<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" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<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" 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._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" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
</MkInput>
<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._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" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
</MkInput>
<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 === 'testcaptcha'">
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
<FormSlot>
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
</FormSlot>
</template>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import type { CaptchaProvider } from '@/components/MkCaptcha.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 MkButton from '@/components/MkButton.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSlot from '@/components/form/slot.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import type { ApiWithDialogCustomErrors } from '@/os.js';
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
const provider = ref<CaptchaProvider | null>(null);
const hcaptchaSiteKey = ref<string | null>(null);
const hcaptchaSecretKey = ref<string | null>(null);
const mcaptchaSiteKey = ref<string | null>(null);
const mcaptchaSecretKey = ref<string | null>(null);
const mcaptchaInstanceUrl = ref<string | null>(null);
const recaptchaSiteKey = ref<string | null>(null);
const recaptchaSecretKey = ref<string | null>(null);
const turnstileSiteKey = ref<string | null>(null);
const turnstileSecretKey = ref<string | null>(null);
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,
},
};
async function init() {
const meta = await misskeyApi('admin/meta');
hcaptchaSiteKey.value = meta.hcaptchaSiteKey;
hcaptchaSecretKey.value = meta.hcaptchaSecretKey;
mcaptchaSiteKey.value = meta.mcaptchaSiteKey;
mcaptchaSecretKey.value = meta.mcaptchaSecretKey;
mcaptchaInstanceUrl.value = meta.mcaptchaInstanceUrl;
recaptchaSiteKey.value = meta.recaptchaSiteKey;
recaptchaSecretKey.value = meta.recaptchaSecretKey;
turnstileSiteKey.value = meta.turnstileSiteKey;
turnstileSecretKey.value = meta.turnstileSecretKey;
const captchaResult = ref<string | null>(null);
provider.value = meta.enableHcaptcha ? 'hcaptcha' :
meta.enableRecaptcha ? 'recaptcha' :
meta.enableTurnstile ? 'turnstile' :
meta.enableMcaptcha ? 'mcaptcha' : null;
}
const meta = await misskeyApi('admin/captcha/current');
const botProtectionForm = useForm({
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,
}, async (state) => {
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
: null;
const secret = provider === 'hcaptcha'
? state.hcaptchaSecretKey
: provider === 'mcaptcha'
? state.mcaptchaSecretKey
: provider === 'recaptcha'
? state.recaptchaSecretKey
: provider === 'turnstile'
? state.turnstileSecretKey
: 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);
});
function save() {
os.apiWithDialog('admin/update-meta', {
enableHcaptcha: provider.value === 'hcaptcha',
hcaptchaSiteKey: hcaptchaSiteKey.value,
hcaptchaSecretKey: hcaptchaSecretKey.value,
enableMcaptcha: provider.value === 'mcaptcha',
mcaptchaSiteKey: mcaptchaSiteKey.value,
mcaptchaSecretKey: mcaptchaSecretKey.value,
mcaptchaInstanceUrl: mcaptchaInstanceUrl.value,
enableRecaptcha: provider.value === 'recaptcha',
recaptchaSiteKey: recaptchaSiteKey.value,
recaptchaSecretKey: recaptchaSecretKey.value,
enableTurnstile: provider.value === 'turnstile',
turnstileSiteKey: turnstileSiteKey.value,
turnstileSecretKey: turnstileSecretKey.value,
}).then(() => {
fetchInstance(true);
});
}
</script>
<style lang="scss" module>
.captchaInfoMsg {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>

View File

@@ -111,13 +111,13 @@ import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { instance, fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkColorInput from '@/components/MkColorInput.vue';
import { host } from '@/config.js';
import { host } from '@@/js/config.js';
const iconUrl = ref<string | null>(null);
const app192IconUrl = ref<string | null>(null);
@@ -175,7 +175,7 @@ function save() {
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.branding,
icon: 'ti ti-paint',
}));
@@ -183,7 +183,7 @@ definePageMetadata(() => ({
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
</style>

View File

@@ -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 [];
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import type { GridSetting } from '@/components/grid/grid.js';
import * as os from '@/os.js';
import {
emptyStrToEmptyArray,
emptyStrToNull,
emptyStrToUndefined,
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 { misskeyApi } from '@/utility/misskey-api.js';
import MkPagingButtons from '@/components/MkPagingButtons.vue';
import { selectFile } from '@/utility/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 != null ? Boolean(searchQuery.value.sensitive).valueOf() : undefined,
localOnly: searchQuery.value.localOnly != null ? 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>

View File

@@ -0,0 +1,477 @@
<!--
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="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 type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import type { DroppedFile } from '@/utility/file-drop.js';
import type { GridSetting } from '@/components/grid/grid.js';
import type { GridRow } from '@/components/grid/row.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import {
emptyStrToEmptyArray,
emptyStrToNull,
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 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 '@/utility/select-file.js';
import { uploadFile } from '@/utility/upload.js';
import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
import { prefer } from '@/preferences.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(prefer.s.uploadFolder);
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(/\.[^.]+$/, ''),
true,
),
}),
),
),
() => {
},
() => {
},
),
);
} 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: true,
// 拡張子は消す
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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,500 @@
<!--
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 '@/utility/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, gridSortOrderKeys } from '@/pages/admin/custom-emojis-manager.impl.js';
import MkFolder from '@/components/MkFolder.vue';
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
import * as os from '@/os.js';
import { deviceKind } from '@/utility/device-kind.js';
import MkPagingButtons from '@/components/MkPagingButtons.vue';
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
import { useLoading } from '@/components/hook/useLoading.js';
import type { GridSortOrderKey, RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
import type { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import type { GridSetting } from '@/components/grid/grid.js';
import type { SortOrder } from '@/components/MkSortOrderEditor.define.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>

View File

@@ -0,0 +1,160 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { delay, http, HttpResponse } from 'msw';
import type { 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())),
});

View File

@@ -0,0 +1,51 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkStickyContainer>
<template #header>
<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/>
</template>
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
<XGridRemoteComponent v-else/>
</MkStickyContainer>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.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,
}]);
definePage(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>

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800" :marginMin="16" :marginMax="32">
<FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory">
<MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;">
@@ -14,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import bytes from '@/filters/bytes.js';
import number from '@/filters/number.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const databasePromiseFactory = () => misskeyApi('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size));
@@ -33,7 +32,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.database,
icon: 'ti ti-database',
}));

View File

@@ -73,10 +73,10 @@ import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import FormSection from '@/components/form/section.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
const enableEmail = ref<boolean>(false);
@@ -130,7 +130,7 @@ function save() {
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.emailServer,
icon: 'ti ti-mail',
}));
@@ -138,7 +138,7 @@ definePageMetadata(() => ({
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
</style>

View File

@@ -8,28 +8,36 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<FormSection>
<template #label>DeepL Translation</template>
<div class="_gaps_m">
<MkFolder>
<template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template>
<div class="_gaps_m">
<MkInput v-model="deeplAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>DeepL Auth Key</template>
</MkInput>
<MkSwitch v-model="deeplIsPro">
<template #label>Pro account</template>
</MkSwitch>
</div>
</FormSection>
<div class="_gaps_m">
<MkInput v-model="googleAnalyticsMeasurementId">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Measurement ID</template>
</MkInput>
<MkButton primary @click="save_googleAnalytics">Save</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #label>DeepL Translation</template>
<div class="_gaps_m">
<MkInput v-model="deeplAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>DeepL Auth Key</template>
</MkInput>
<MkSwitch v-model="deeplIsPro">
<template #label>Pro account</template>
</MkSwitch>
<MkButton primary @click="save_deepl">Save</MkButton>
</div>
</MkFolder>
</div>
</FormSuspense>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</div>
</template>
</MkStickyContainer>
</template>
@@ -40,23 +48,26 @@ import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSection from '@/components/form/section.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue';
const deeplAuthKey = ref<string>('');
const deeplIsPro = ref<boolean>(false);
const googleAnalyticsMeasurementId = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
deeplAuthKey.value = meta.deeplAuthKey;
deeplAuthKey.value = meta.deeplAuthKey ?? '';
deeplIsPro.value = meta.deeplIsPro;
googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? '';
}
function save() {
function save_deepl() {
os.apiWithDialog('admin/update-meta', {
deeplAuthKey: deeplAuthKey.value,
deeplIsPro: deeplIsPro.value,
@@ -65,19 +76,20 @@ function save() {
});
}
function save_googleAnalytics() {
os.apiWithDialog('admin/update-meta', {
googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value,
}).then(() => {
fetchInstance(true);
});
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.externalServices,
icon: 'ti ti-link',
}));
</script>
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
<FormSplit style="margin-top: var(--margin);">
<FormSplit style="margin-top: var(--MI-margin);">
<MkSelect v-model="state">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
@@ -67,7 +67,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const host = ref('');
const state = ref('federating');
@@ -80,9 +80,9 @@ const pagination = {
sort: sort.value,
host: host.value !== '' ? host.value : null,
...(
state.value === 'federating' ? { federating: true } :
state.value === 'subscribing' ? { subscribing: true } :
state.value === 'publishing' ? { publishing: true } :
state.value === 'federating' ? { federating: true, suspended: false, blocked: false } :
state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } :
state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } :
state.value === 'suspended' ? { suspended: true } :
state.value === 'blocked' ? { blocked: true } :
state.value === 'silenced' ? { silenced: true } :
@@ -112,7 +112,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.federation,
icon: 'ti ti-whirl',
}));

View File

@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader :actions="headerActions"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps">
<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<div class="inputs" style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;">
<MkSelect v-model="origin" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.instance }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
</div>
<div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<div class="inputs" style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;">
<MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;">
<template #label>User ID</template>
</MkInput>
@@ -42,9 +42,9 @@ import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
import * as os from '@/os.js';
import { lookupFile } from '@/scripts/admin-lookup.js';
import { lookupFile } from '@/utility/admin-lookup.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const origin = ref('local');
const type = ref<string | null>(null);
@@ -85,7 +85,7 @@ const headerActions = computed(() => [{
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.files,
icon: 'ti ti-cloud',
}));

View File

@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
<MkSpacer :contentMax="700" :marginMin="16">
<div class="lxpfedzu">
<div class="lxpfedzu _gaps">
<div class="banner">
<img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/>
</div>
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkInfo v-if="thereIsUnresolvedAbuseReport" warn>{{ i18n.ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
<MkInfo v-if="noMaintainerInformation" warn>{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/moderation" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noInquiryUrl" warn>{{ i18n.ts.noInquiryUrlWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noBotProtection" warn>{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
</div>
@@ -25,23 +25,25 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer>
</div>
<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
<RouterView/>
<NestedRouterView/>
</div>
</div>
</template>
<script lang="ts" setup>
import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue';
import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
import type { PageMetadata } from '@/page.js';
import { i18n } from '@/i18n.js';
import MkSuperMenu from '@/components/MkSuperMenu.vue';
import MkInfo from '@/components/MkInfo.vue';
import { instance } from '@/instance.js';
import { lookup } from '@/scripts/lookup.js';
import { lookup } from '@/utility/lookup.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js';
import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { useRouter } from '@/router/supplier.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js';
import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
import { useRouter } from '@/router.js';
const isEmpty = (x: string | null) => x == null || x === '';
@@ -55,16 +57,16 @@ 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);
const el = ref<HTMLDivElement | null>(null);
const pageProps = ref({});
let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail);
let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile;
let noEmailServer = !instance.enableEmail;
let noInquiryUrl = isEmpty(instance.inquiryUrl);
const noMaintainerInformation = computed(() => isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail));
const noBotProtection = computed(() => !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile && !instance.enableMcaptcha);
const noEmailServer = computed(() => !instance.enableEmail);
const noInquiryUrl = computed(() => isEmpty(instance.inquiryUrl));
const thereIsUnresolvedAbuseReport = ref(false);
const currentPage = computed(() => router.currentRef.value.child);
@@ -81,7 +83,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',
@@ -89,7 +91,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,
@@ -121,6 +123,11 @@ const menuDef = computed(() => [{
text: i18n.ts.customEmojis,
to: '/admin/emojis',
active: currentPage.value?.route.name === 'emojis',
}, {
icon: 'ti ti-icons',
text: i18n.ts.customEmojis + '(beta)',
to: '/admin/emojis2',
active: currentPage.value?.route.name === 'emojis2',
}, {
icon: 'ti ti-sparkles',
text: i18n.ts.avatarDecorations,
@@ -199,16 +206,6 @@ const menuDef = computed(() => [{
text: i18n.ts.relays,
to: '/admin/relays',
active: currentPage.value?.route.name === 'relays',
}, {
icon: 'ti ti-ban',
text: i18n.ts.instanceBlocking,
to: '/admin/instance-block',
active: currentPage.value?.route.name === 'instance-block',
}, {
icon: 'ti ti-ghost',
text: i18n.ts.proxyAccount,
to: '/admin/proxy-account',
active: currentPage.value?.route.name === 'proxy-account',
}, {
icon: 'ti ti-link',
text: i18n.ts.externalServices,
@@ -220,10 +217,10 @@ const menuDef = computed(() => [{
to: '/admin/system-webhook',
active: currentPage.value?.route.name === 'system-webhook',
}, {
icon: 'ti ti-adjustments',
text: i18n.ts.other,
to: '/admin/other-settings',
active: currentPage.value?.route.name === 'other-settings',
icon: 'ti ti-bolt',
text: i18n.ts.performance,
to: '/admin/performance',
active: currentPage.value?.route.name === 'performance',
}],
}, {
title: i18n.ts.info,
@@ -235,25 +232,22 @@ const menuDef = computed(() => [{
}],
}]);
watch(narrow.value, () => {
if (currentPage.value?.route.name == null && !narrow.value) {
router.push('/admin/overview');
}
});
onMounted(() => {
ro.observe(el.value);
narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
if (el.value != null) {
ro.observe(el.value);
narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
}
if (currentPage.value?.route.name == null && !narrow.value) {
router.push('/admin/overview');
router.replace('/admin/overview');
}
});
onActivated(() => {
narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
if (el.value != null) {
narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
}
if (currentPage.value?.route.name == null && !narrow.value) {
router.push('/admin/overview');
router.replace('/admin/overview');
}
});
@@ -324,7 +318,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => INFO.value);
definePage(() => INFO.value);
defineExpose({
header: {
@@ -338,15 +332,16 @@ defineExpose({
&.wide {
display: flex;
margin: 0 auto;
height: 100%;
> .nav {
position: sticky;
top: 0;
width: 32%;
max-width: 280px;
box-sizing: border-box;
border-right: solid 0.5px var(--divider);
border-right: solid 0.5px var(--MI_THEME-divider);
overflow: auto;
height: 100%;
height: 100cqh;
}
> .main {

View File

@@ -1,73 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<MkTextarea v-if="tab === 'block'" v-model="blockedHosts">
<span>{{ i18n.ts.blockedInstances }}</span>
<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
</MkTextarea>
<MkTextarea v-else-if="tab === 'silence'" v-model="silencedHosts" class="_formBlock">
<span>{{ i18n.ts.silencedInstances }}</span>
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const blockedHosts = ref<string>('');
const silencedHosts = ref<string>('');
const tab = ref('block');
async function init() {
const meta = await misskeyApi('admin/meta');
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts.join('\n');
}
function save() {
os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.value.split('\n') || [],
silencedHosts: silencedHosts.value.split('\n') || [],
}).then(() => {
fetchInstance(true);
});
}
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'block',
title: i18n.ts.block,
icon: 'ti ti-ban',
}, {
key: 'silence',
title: i18n.ts.silence,
icon: 'ti ti-eye-off',
}]);
definePageMetadata(() => ({
title: i18n.ts.instanceBlocking,
icon: 'ti ti-ban',
}));
</script>

View File

@@ -55,21 +55,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef } from 'vue';
import { computed, ref, useTemplateRef } from 'vue';
import XHeader from './_header_.vue';
import type { Paging } from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkInviteCode from '@/components/MkInviteCode.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagingComponent = useTemplateRef('pagingComponent');
const type = ref('all');
const sort = ref('+createdAt');
@@ -113,7 +114,7 @@ function deleted(id: string) {
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.invite,
icon: 'ti ti-user-plus',
}));

View File

@@ -10,61 +10,118 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkSwitch v-model="enableRegistration">
<template #label>{{ i18n.ts.enableRegistration }}</template>
<MkSwitch :modelValue="enableRegistration" @update:modelValue="onChange_enableRegistration">
<template #label>{{ i18n.ts._serverSettings.openRegistration }}</template>
<template #caption>
<div>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.openRegistrationWarning }}</div>
</template>
</MkSwitch>
<MkSwitch v-model="emailRequiredForSignup">
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">
<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
</MkSwitch>
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkInput v-model="tosUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput>
<MkInput v-model="privacyPolicyUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
</MkInput>
<MkInput v-model="inquiryUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template>
<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
</MkInput>
<MkTextarea v-model="preservedUsernames">
<MkFolder>
<template #icon><i class="ti ti-lock-star"></i></template>
<template #label>{{ i18n.ts.preservedUsernames }}</template>
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkTextarea v-model="sensitiveWords">
<div class="_gaps">
<MkTextarea v-model="preservedUsernames">
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_preservedUsernames">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-message-exclamation"></i></template>
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<MkTextarea v-model="prohibitedWords">
<div class="_gaps">
<MkTextarea v-model="sensitiveWords">
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-message-x"></i></template>
<template #label>{{ i18n.ts.prohibitedWords }}</template>
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkTextarea v-model="hiddenTags">
<div class="_gaps">
<MkTextarea v-model="prohibitedWords">
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-x"></i></template>
<template #label>{{ i18n.ts.prohibitedWordsForNameOfUser }}</template>
<div class="_gaps">
<MkTextarea v-model="prohibitedWordsForNameOfUser">
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.hiddenTags }}</template>
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
</MkTextarea>
<div class="_gaps">
<MkTextarea v-model="hiddenTags">
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_hiddenTags">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.silencedInstances }}</template>
<div class="_gaps">
<MkTextarea v-model="silencedHosts">
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_silencedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.mediaSilencedInstances }}</template>
<div class="_gaps">
<MkTextarea v-model="mediaSilencedHosts">
<template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_mediaSilencedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-ban"></i></template>
<template #label>{{ i18n.ts.blockedInstances }}</template>
<div class="_gaps">
<MkTextarea v-model="blockedHosts">
<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_blockedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</div>
</FormSuspense>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</div>
</template>
</MkStickyContainer>
</div>
</template>
@@ -77,22 +134,24 @@ import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);
const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>('');
const tosUrl = ref<string | null>(null);
const privacyPolicyUrl = ref<string | null>(null);
const inquiryUrl = ref<string | null>(null);
const blockedHosts = ref<string>('');
const silencedHosts = ref<string>('');
const mediaSilencedHosts = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
@@ -100,40 +159,108 @@ async function init() {
emailRequiredForSignup.value = meta.emailRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n');
tosUrl.value = meta.tosUrl;
privacyPolicyUrl.value = meta.privacyPolicyUrl;
inquiryUrl.value = meta.inquiryUrl;
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts?.join('\n') ?? '';
mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
}
function save() {
async function onChange_enableRegistration(value: boolean) {
if (value) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.acknowledgeNotesAndEnable,
});
if (canceled) return;
}
enableRegistration.value = value;
os.apiWithDialog('admin/update-meta', {
disableRegistration: !value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_emailRequiredForSignup(value: boolean) {
os.apiWithDialog('admin/update-meta', {
emailRequiredForSignup: value,
}).then(() => {
fetchInstance(true);
});
}
function save_preservedUsernames() {
os.apiWithDialog('admin/update-meta', {
disableRegistration: !enableRegistration.value,
emailRequiredForSignup: emailRequiredForSignup.value,
tosUrl: tosUrl.value,
privacyPolicyUrl: privacyPolicyUrl.value,
inquiryUrl: inquiryUrl.value,
sensitiveWords: sensitiveWords.value.split('\n'),
prohibitedWords: prohibitedWords.value.split('\n'),
hiddenTags: hiddenTags.value.split('\n'),
preservedUsernames: preservedUsernames.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_sensitiveWords() {
os.apiWithDialog('admin/update-meta', {
sensitiveWords: sensitiveWords.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_prohibitedWords() {
os.apiWithDialog('admin/update-meta', {
prohibitedWords: prohibitedWords.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_prohibitedWordsForNameOfUser() {
os.apiWithDialog('admin/update-meta', {
prohibitedWordsForNameOfUser: prohibitedWordsForNameOfUser.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_hiddenTags() {
os.apiWithDialog('admin/update-meta', {
hiddenTags: hiddenTags.value.split('\n'),
}).then(() => {
fetchInstance(true);
});
}
function save_blockedHosts() {
os.apiWithDialog('admin/update-meta', {
blockedHosts: blockedHosts.value.split('\n') || [],
}).then(() => {
fetchInstance(true);
});
}
function save_silencedHosts() {
os.apiWithDialog('admin/update-meta', {
silencedHosts: silencedHosts.value.split('\n') || [],
}).then(() => {
fetchInstance(true);
});
}
function save_mediaSilencedHosts() {
os.apiWithDialog('admin/update-meta', {
mediaSilencedHosts: mediaSilencedHosts.value.split('\n') || [],
}).then(() => {
fetchInstance(true);
});
}
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.moderation,
icon: 'ti ti-shield',
}));
</script>
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View File

@@ -21,12 +21,12 @@ SPDX-License-Identifier: AGPL-3.0-only
].includes(log.type),
[$style.logYellow]: [
'markSensitiveDriveFile',
'resetPassword'
'resetPassword',
'suspendRemoteInstance',
].includes(log.type),
[$style.logRed]: [
'suspend',
'deleteRole',
'suspendRemoteInstance',
'deleteGlobalAnnouncement',
'deleteUserAnnouncement',
'deleteCustomEmoji',
@@ -36,6 +36,11 @@ SPDX-License-Identifier: AGPL-3.0-only
'deleteAvatarDecoration',
'deleteSystemWebhook',
'deleteAbuseReportNotificationRecipient',
'deleteAccount',
'deletePage',
'deleteFlash',
'deleteGalleryPost',
'deleteChatRoom',
].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
@@ -72,6 +77,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
<span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
<span v-else-if="log.type === 'deleteAccount'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'deletePage'">: @{{ log.info.pageUserUsername }}</span>
<span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span>
<span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span>
<span v-else-if="log.type === 'deleteChatRoom'">: @{{ log.info.room.name }}</span>
</template>
<template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
@@ -81,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div>
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<div style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;">
<div style="flex: 1;">{{ i18n.ts.moderator }}: <MkA :to="`/admin/user/${log.userId}`" class="_link">@{{ log.user?.username }}</MkA></div>
<div style="flex: 1;">{{ i18n.ts.dateAndTime }}: <MkTime :time="log.createdAt" mode="detail"/></div>
</div>
@@ -143,7 +153,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<template v-else-if="log.type === 'updateRemoteInstanceNote'">
<div>{{ i18n.ts.user }}: {{ log.info.userId }}</div>
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
@@ -158,6 +167,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateAbuseReportNote'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
</template>
<template v-else-if="log.type === 'updateProxyAccountDescription'">
<div :class="$style.diff">
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
</template>
<details>
<summary>raw</summary>
@@ -193,14 +212,14 @@ const props = defineProps<{
}
.logYellow {
color: var(--warn);
color: var(--MI_THEME-warn);
}
.logRed {
color: var(--error);
color: var(--MI_THEME-error);
}
.logGreen {
color: var(--success);
color: var(--MI_THEME-success);
}
</style>

View File

@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div>
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
<div style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;">
<MkSelect v-model="type" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.type }}</template>
<option :value="null">{{ i18n.ts.all }}</option>
@@ -19,10 +19,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</div>
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);">
<div class="_gaps_s">
<XModLog v-for="item in items" :key="item.id" :log="item"/>
</div>
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--MI-margin);">
<MkDateSeparatedList v-slot="{ item }" :items="items" :noGap="false" style="--MI-margin: 8px;">
<XModLog :key="item.id" :log="item"/>
</MkDateSeparatedList>
</MkPagination>
</div>
</MkSpacer>
@@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, shallowRef, ref } from 'vue';
import { computed, useTemplateRef, ref } from 'vue';
import * as Misskey from 'misskey-js';
import XHeader from './_header_.vue';
import XModLog from './modlog.ModLog.vue';
@@ -38,9 +38,10 @@ import MkSelect from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
const logs = shallowRef<InstanceType<typeof MkPagination>>();
const logs = useTemplateRef('logs');
const type = ref<string | null>(null);
const moderatorId = ref('');
@@ -58,7 +59,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.moderationLogs,
icon: 'ti ti-list-search',
}));

View File

@@ -90,10 +90,10 @@ import MkInput from '@/components/MkInput.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
const useObjectStorage = ref<boolean>(false);
@@ -149,7 +149,7 @@ function save() {
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.objectStorage,
icon: 'ti ti-cloud',
}));
@@ -157,7 +157,7 @@ definePageMetadata(() => ({
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
</style>

View File

@@ -1,93 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps">
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableServerMachineStats">
<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableIdenticonGeneration">
<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForRemoteUser">
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</div>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import XHeader from './_header_.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkSwitch from '@/components/MkSwitch.vue';
const enableServerMachineStats = ref<boolean>(false);
const enableIdenticonGeneration = ref<boolean>(false);
const enableChartsForRemoteUser = ref<boolean>(false);
const enableChartsForFederatedInstances = ref<boolean>(false);
async function init() {
const meta = await misskeyApi('admin/meta');
enableServerMachineStats.value = meta.enableServerMachineStats;
enableIdenticonGeneration.value = meta.enableIdenticonGeneration;
enableChartsForRemoteUser.value = meta.enableChartsForRemoteUser;
enableChartsForFederatedInstances.value = meta.enableChartsForFederatedInstances;
}
function save() {
os.apiWithDialog('admin/update-meta', {
enableServerMachineStats: enableServerMachineStats.value,
enableIdenticonGeneration: enableIdenticonGeneration.value,
enableChartsForRemoteUser: enableChartsForRemoteUser.value,
enableChartsForFederatedInstances: enableChartsForFederatedInstances.value,
}).then(() => {
fetchInstance(true);
});
}
const headerActions = computed(() => [{
asFullButton: true,
icon: 'ti ti-check',
text: i18n.ts.save,
handler: save,
}]);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: i18n.ts.other,
icon: 'ti ti-adjustments',
}));
</script>

View File

@@ -13,18 +13,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, shallowRef, ref } from 'vue';
import { onMounted, useTemplateRef, ref } from 'vue';
import { Chart } from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { initChart } from '@/scripts/init-chart.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { initChart } from '@/utility/init-chart.js';
initChart();
const chartEl = shallowRef<HTMLCanvasElement>(null);
const chartEl = useTemplateRef('chartEl');
const now = new Date();
let chartInstance: Chart = null;
const chartLimit = 7;
@@ -54,7 +54,7 @@ async function renderChart() {
const raw = await misskeyApi('charts/active-users', { limit: chartLimit, span: 'day' });
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const colorRead = '#3498db';
const colorWrite = '#2ecc71';

View File

@@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { StoryObj } from '@storybook/vue3';
import { http, HttpResponse } from 'msw';
import { action } from '@storybook/addon-actions';
import { commonHandlers } from '../../../.storybook/mocks.js';
import overview_ap_requests from './overview.ap-requests.vue';
export const Default = {
render(args) {
return {
components: {
overview_ap_requests,
},
setup() {
return {
args,
};
},
template: '<overview_ap_requests />',
};
},
parameters: {
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/charts/ap-request', async ({ request }) => {
action('POST /api/charts/ap-request')(await request.json());
return HttpResponse.json({
deliverFailed: [0, 0, 0, 2, 0, 0, 1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 1, 0, 0, 0, 3, 1, 1, 2, 0, 0],
deliverSucceeded: [0, 1, 51, 34, 136, 189, 51, 17, 17, 34, 1, 17, 18, 51, 34, 68, 287, 0, 17, 33, 32, 96, 96, 0, 49, 64, 0, 32, 0, 32, 81, 48, 65, 1, 16, 50, 90, 148, 33, 43, 72, 127, 17, 138, 78, 91, 78, 91, 13, 52],
inboxReceived: [507, 1173, 1096, 871, 958, 937, 908, 1026, 956, 909, 807, 1002, 832, 995, 1039, 1047, 1109, 930, 711, 835, 764, 679, 835, 958, 634, 654, 691, 895, 811, 676, 1044, 1389, 1318, 863, 887, 952, 1011, 1061, 592, 900, 611, 595, 604, 562, 607, 621, 854, 666, 1197, 644],
});
}),
],
},
},
} satisfies StoryObj<typeof overview_ap_requests>;

View File

@@ -20,28 +20,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, shallowRef, ref } from 'vue';
import { onMounted, useTemplateRef, ref } from 'vue';
import { Chart } from 'chart.js';
import gradient from 'chartjs-plugin-gradient';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { defaultStore } from '@/store.js';
import { alpha } from '@/scripts/color.js';
import { initChart } from '@/scripts/init-chart.js';
import isChromatic from 'chromatic';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { store } from '@/store.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';
initChart();
const chartLimit = 50;
const chartEl = shallowRef<HTMLCanvasElement>();
const chartEl2 = shallowRef<HTMLCanvasElement>();
const chartEl = useTemplateRef('chartEl');
const chartEl2 = useTemplateRef('chartEl2');
const fetching = ref(true);
const { handler: externalTooltipHandler } = useChartTooltip();
const { handler: externalTooltipHandler2 } = useChartTooltip();
onMounted(async () => {
const now = new Date();
const now = isChromatic() ? new Date('2024-08-31T10:00:00Z') : new Date();
const getDate = (ago: number) => {
const y = now.getFullYear();
@@ -51,14 +52,14 @@ onMounted(async () => {
return new Date(y, m, d - ago);
};
const format = (arr) => {
const format = (arr: number[]) => {
return arr.map((v, i) => ({
x: getDate(i).getTime(),
y: v,
}));
};
const formatMinus = (arr) => {
const formatMinus = (arr: number[]) => {
return arr.map((v, i) => ({
x: getDate(i).getTime(),
y: -v,
@@ -67,7 +68,7 @@ onMounted(async () => {
const raw = await misskeyApi('charts/ap-request', { limit: chartLimit, span: 'day' });
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const succColor = '#87e000';
const failColor = '#ff4400';
@@ -78,7 +79,6 @@ onMounted(async () => {
type: 'line',
data: {
datasets: [{
stack: 'a',
parsing: false,
label: 'Out: Succ',
data: format(raw.deliverSucceeded).slice().reverse(),
@@ -92,7 +92,6 @@ onMounted(async () => {
fill: true,
clip: 8,
}, {
stack: 'a',
parsing: false,
label: 'Out: Fail',
data: formatMinus(raw.deliverFailed).slice().reverse(),
@@ -137,7 +136,6 @@ onMounted(async () => {
min: getDate(chartLimit).getTime(),
},
y: {
stacked: true,
position: 'left',
suggestedMax: 10,
grid: {
@@ -171,6 +169,9 @@ onMounted(async () => {
duration: 0,
},
external: externalTooltipHandler,
callbacks: {
label: context => `${context.dataset.label}: ${Math.abs(context.parsed.y)}`,
},
},
gradient,
},
@@ -277,7 +278,7 @@ onMounted(async () => {
padding: 16px;
&:first-child {
border-bottom: solid 0.5px var(--divider);
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
}
}

View File

@@ -47,13 +47,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import XPie, { type InstanceForPie } from './overview.pie.vue';
import XPie from './overview.pie.vue';
import type { InstanceForPie } from './overview.pie.vue';
import * as os from '@/os.js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { misskeyApiGet } from '@/utility/misskey-api.js';
import number from '@/filters/number.js';
import MkNumberDiff from '@/components/MkNumberDiff.vue';
import { i18n } from '@/i18n.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
@@ -151,8 +152,8 @@ onMounted(async () => {
height: 100%;
aspect-ratio: 1;
margin-right: 12px;
background: var(--accentedBg);
color: var(--accent);
background: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
border-radius: 10px;
}

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
<MkLoading v-if="fetching"/>
<div v-else :class="$style.instances">
<MkA v-for="(instance, i) in instances" :key="instance.id" v-tooltip.mfm.noDelay="`${instance.name}\n${instance.host}\n${instance.softwareName} ${instance.softwareVersion}`" :to="`/instance-info/${instance.host}`" :class="$style.instance">
@@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import * as Misskey from 'misskey-js';
import { useInterval } from '@/scripts/use-interval.js';
import { useInterval } from '@@/js/use-interval.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import { defaultStore } from '@/store.js';
import { prefer } from '@/preferences.js';
const instances = ref<Misskey.entities.FederationInstance[]>([]);
const fetching = ref(true);

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
<MkLoading v-if="fetching"/>
<div v-else :class="$style.root" class="_panel">
<MkA v-for="user in moderators" :key="user.id" class="user" :to="`/admin/user/${user.id}`">
@@ -18,9 +18,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import * as Misskey from 'misskey-js';
import { defaultStore } from '@/store.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
const moderators = ref<Misskey.entities.UserDetailed[] | null>(null);
const fetching = ref(true);

View File

@@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, shallowRef } from 'vue';
import { onMounted, useTemplateRef } from 'vue';
import { Chart } from 'chart.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { initChart } from '@/scripts/init-chart.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { initChart } from '@/utility/init-chart.js';
export type InstanceForPie = {
name: string,
@@ -26,7 +26,7 @@ const props = defineProps<{
data: InstanceForPie[];
}>();
const chartEl = shallowRef<HTMLCanvasElement>(null);
const chartEl = useTemplateRef('chartEl');
const { handler: externalTooltipHandler } = useChartTooltip({
position: 'middle',
@@ -41,7 +41,7 @@ onMounted(() => {
labels: props.data.map(x => x.name),
datasets: [{
backgroundColor: props.data.map(x => x.color),
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'),
borderWidth: 2,
hoverOffset: 0,
data: props.data.map(x => x.value),

View File

@@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, shallowRef } from 'vue';
import { onMounted, useTemplateRef } from 'vue';
import { Chart } from 'chart.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { alpha } from '@/scripts/color.js';
import { initChart } from '@/scripts/init-chart.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';
initChart();
@@ -22,7 +22,7 @@ const props = defineProps<{
type: string;
}>();
const chartEl = shallowRef<HTMLCanvasElement>(null);
const chartEl = useTemplateRef('chartEl');
const { handler: externalTooltipHandler } = useChartTooltip();
@@ -67,7 +67,7 @@ const color =
'?' as never;
onMounted(() => {
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
chartInstance = new Chart(chartEl.value, {
type: 'line',

View File

@@ -35,8 +35,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import XChart from './overview.queue.chart.vue';
import type { ApQueueDomain } from '@/pages/admin/queue.vue';
import number from '@/filters/number.js';
import { useStream } from '@/stream.js';
@@ -46,16 +48,16 @@ const activeSincePrevTick = ref(0);
const active = ref(0);
const delayed = ref(0);
const waiting = ref(0);
const chartProcess = shallowRef<InstanceType<typeof XChart>>();
const chartActive = shallowRef<InstanceType<typeof XChart>>();
const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
const chartProcess = useTemplateRef('chartProcess');
const chartActive = useTemplateRef('chartActive');
const chartDelayed = useTemplateRef('chartDelayed');
const chartWaiting = useTemplateRef('chartWaiting');
const props = defineProps<{
domain: string;
domain: ApQueueDomain;
}>();
const onStats = (stats) => {
function onStats(stats: Misskey.entities.QueueStats) {
activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
active.value = stats[props.domain].active;
delayed.value = stats[props.domain].delayed;
@@ -65,13 +67,13 @@ const onStats = (stats) => {
chartActive.value.pushData(stats[props.domain].active);
chartDelayed.value.pushData(stats[props.domain].delayed);
chartWaiting.value.pushData(stats[props.domain].waiting);
};
}
const onStatsLog = (statsLog) => {
const dataProcess = [];
const dataActive = [];
const dataDelayed = [];
const dataWaiting = [];
function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) {
const dataProcess: Misskey.entities.QueueStats[ApQueueDomain]['activeSincePrevTick'][] = [];
const dataActive: Misskey.entities.QueueStats[ApQueueDomain]['active'][] = [];
const dataDelayed: Misskey.entities.QueueStats[ApQueueDomain]['delayed'][] = [];
const dataWaiting: Misskey.entities.QueueStats[ApQueueDomain]['waiting'][] = [];
for (const stats of [...statsLog].reverse()) {
dataProcess.push(stats[props.domain].activeSincePrevTick);
@@ -84,7 +86,7 @@ const onStatsLog = (statsLog) => {
chartActive.value.setData(dataActive);
chartDelayed.value.setData(dataDelayed);
chartWaiting.value.setData(dataWaiting);
};
}
onMounted(() => {
connection.on('stats', onStats);
@@ -117,8 +119,8 @@ onUnmounted(() => {
> .chart {
min-width: 0;
padding: 16px;
background: var(--panel);
border-radius: var(--radius);
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
> .title {
font-size: 0.85em;

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
<MkLoading v-if="fetching"/>
<div v-else :class="$style.root">
<div class="item _panel users">
@@ -63,12 +63,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import MkNumberDiff from '@/components/MkNumberDiff.vue';
import MkNumber from '@/components/MkNumber.vue';
import { i18n } from '@/i18n.js';
import { customEmojis } from '@/custom-emojis.js';
import { defaultStore } from '@/store.js';
import { prefer } from '@/preferences.js';
const stats = ref<Misskey.entities.StatsResponse | null>(null);
const usersComparedToThePrevDay = ref<number>();
@@ -114,8 +114,8 @@ onMounted(async () => {
height: 100%;
aspect-ratio: 1;
margin-right: 12px;
background: var(--accentedBg);
color: var(--accent);
background: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
border-radius: 10px;
}

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" mode="out-in">
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in">
<MkLoading v-if="fetching"/>
<div v-else class="users">
<MkA v-for="(user, i) in newUsers" :key="user.id" :to="`/admin/user/${user.id}`" class="user">
@@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import * as Misskey from 'misskey-js';
import { useInterval } from '@/scripts/use-interval.js';
import { useInterval } from '@@/js/use-interval.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { defaultStore } from '@/store.js';
import { prefer } from '@/preferences.js';
const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null);
const fetching = ref(true);
@@ -47,14 +47,14 @@ useInterval(fetch, 1000 * 60, {
.root {
&:global {
> .users {
.chart-move {
transition: transform 1s ease;
}
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-gap: 12px;
.chart-move {
transition: transform 1s ease;
}
> .user:hover {
text-decoration: none;
}

View File

@@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed } from 'vue';
import { markRaw, onMounted, onBeforeUnmount, nextTick, shallowRef, ref, computed, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import XFederation from './overview.federation.vue';
import XInstances from './overview.instances.vue';
@@ -79,13 +79,13 @@ import XModerators from './overview.moderators.vue';
import XHeatmap from './overview.heatmap.vue';
import type { InstanceForPie } from './overview.pie.vue';
import * as os from '@/os.js';
import { misskeyApi, misskeyApiGet } from '@/scripts/misskey-api.js';
import { misskeyApi, misskeyApiGet } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
const rootEl = shallowRef<HTMLElement>();
const rootEl = useTemplateRef('rootEl');
const serverInfo = ref<Misskey.entities.ServerInfoResponse | null>(null);
const topSubInstancesForPie = ref<InstanceForPie[] | null>(null);
const topPubInstancesForPie = ref<InstanceForPie[] | null>(null);
@@ -184,7 +184,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.dashboard,
icon: 'ti ti-dashboard',
}));

View File

@@ -0,0 +1,209 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<div class="_gaps">
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats">
<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration">
<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser">
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances">
<template #label>{{ i18n.ts.enableStatsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<template v-if="fttForm.savedState.enableFanoutTimeline" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="fttForm.modified.value" #footer>
<MkFormFooter :form="fttForm"/>
</template>
<div class="_gaps">
<MkSwitch v-model="fttForm.state.enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}<span v-if="fttForm.modifiedStates.enableFanoutTimeline" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<div>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</div>
<div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div>
</template>
</MkSwitch>
<template v-if="fttForm.state.enableFanoutTimeline">
<MkSwitch v-model="fttForm.state.enableFanoutTimelineDbFallback">
<template #label>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}<span v-if="fttForm.modifiedStates.enableFanoutTimelineDbFallback" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</template>
</MkSwitch>
<MkInput v-model="fttForm.state.perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perLocalUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="fttForm.state.perRemoteUserUserTimelineCacheMax" type="number">
<template #label>perRemoteUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perRemoteUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="fttForm.state.perUserHomeTimelineCacheMax" type="number">
<template #label>perUserHomeTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserHomeTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="fttForm.state.perUserListTimelineCacheMax" type="number">
<template #label>perUserListTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserListTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</template>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Reactions Boost Technology (RBT)<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="rbtForm.modified.value" #footer>
<MkFormFooter :form="rbtForm"/>
</template>
<div class="_gaps_m">
<MkSwitch v-model="rbtForm.state.enableReactionsBuffering">
<template #label>{{ i18n.ts.enable }}<span v-if="rbtForm.modifiedStates.enableReactionsBuffering" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</template>
</MkSwitch>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import XHeader from './_header_.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue';
import MkLink from '@/components/MkLink.vue';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const meta = await misskeyApi('admin/meta');
const enableServerMachineStats = ref(meta.enableServerMachineStats);
const enableIdenticonGeneration = ref(meta.enableIdenticonGeneration);
const enableChartsForRemoteUser = ref(meta.enableChartsForRemoteUser);
const enableStatsForFederatedInstances = ref(meta.enableStatsForFederatedInstances);
const enableChartsForFederatedInstances = ref(meta.enableChartsForFederatedInstances);
function onChange_enableServerMachineStats(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableServerMachineStats: value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_enableIdenticonGeneration(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableIdenticonGeneration: value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_enableChartsForRemoteUser(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableChartsForRemoteUser: value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_enableStatsForFederatedInstances(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableStatsForFederatedInstances: value,
}).then(() => {
fetchInstance(true);
});
}
function onChange_enableChartsForFederatedInstances(value: boolean) {
os.apiWithDialog('admin/update-meta', {
enableChartsForFederatedInstances: value,
}).then(() => {
fetchInstance(true);
});
}
const fttForm = useForm({
enableFanoutTimeline: meta.enableFanoutTimeline,
enableFanoutTimelineDbFallback: meta.enableFanoutTimelineDbFallback,
perLocalUserUserTimelineCacheMax: meta.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: meta.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: meta.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: meta.perUserListTimelineCacheMax,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableFanoutTimeline: state.enableFanoutTimeline,
enableFanoutTimelineDbFallback: state.enableFanoutTimelineDbFallback,
perLocalUserUserTimelineCacheMax: state.perLocalUserUserTimelineCacheMax,
perRemoteUserUserTimelineCacheMax: state.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: state.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: state.perUserListTimelineCacheMax,
});
fetchInstance(true);
});
const rbtForm = useForm({
enableReactionsBuffering: meta.enableReactionsBuffering,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableReactionsBuffering: state.enableReactionsBuffering,
});
fetchInstance(true);
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => ({
title: i18n.ts.other,
icon: 'ti ti-adjustments',
}));
</script>

View File

@@ -1,71 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkKeyValue>
<template #key>{{ i18n.ts.proxyAccount }}</template>
<template #value>{{ proxyAccount ? `@${proxyAccount.username}` : i18n.ts.none }}</template>
</MkKeyValue>
<MkButton primary @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</MkButton>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const proxyAccount = ref<Misskey.entities.UserDetailed | null>(null);
const proxyAccountId = ref<string | null>(null);
async function init() {
const meta = await misskeyApi('admin/meta');
proxyAccountId.value = meta.proxyAccountId;
if (proxyAccountId.value) {
proxyAccount.value = await misskeyApi('users/show', { userId: proxyAccountId.value });
}
}
function chooseProxyAccount() {
os.selectUser({ localOnly: true }).then(user => {
proxyAccount.value = user;
proxyAccountId.value = user.id;
save();
});
}
function save() {
os.apiWithDialog('admin/update-meta', {
proxyAccountId: proxyAccountId.value,
}).then(() => {
fetchInstance(true);
});
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: i18n.ts.proxyAccount,
icon: 'ti ti-ghost',
}));
</script>

View File

@@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted, shallowRef } from 'vue';
import { onMounted, useTemplateRef } from 'vue';
import { Chart } from 'chart.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { alpha } from '@/scripts/color.js';
import { initChart } from '@/scripts/init-chart.js';
import { store } from '@/store.js';
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
import { chartVLine } from '@/utility/chart-vline.js';
import { alpha } from '@/utility/color.js';
import { initChart } from '@/utility/init-chart.js';
initChart();
@@ -22,7 +22,7 @@ const props = defineProps<{
type: string;
}>();
const chartEl = shallowRef<HTMLCanvasElement>(null);
const chartEl = useTemplateRef('chartEl');
const { handler: externalTooltipHandler } = useChartTooltip();
@@ -67,7 +67,7 @@ const color =
'?' as never;
onMounted(() => {
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
chartInstance = new Chart(chartEl.value, {
type: 'line',

View File

@@ -48,10 +48,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import XChart from './queue.chart.chart.vue';
import type { ApQueueDomain } from '@/pages/admin/queue.vue';
import number from '@/filters/number.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
@@ -62,17 +64,17 @@ const activeSincePrevTick = ref(0);
const active = ref(0);
const delayed = ref(0);
const waiting = ref(0);
const jobs = ref<(string | number)[][]>([]);
const chartProcess = shallowRef<InstanceType<typeof XChart>>();
const chartActive = shallowRef<InstanceType<typeof XChart>>();
const chartDelayed = shallowRef<InstanceType<typeof XChart>>();
const chartWaiting = shallowRef<InstanceType<typeof XChart>>();
const jobs = ref<Misskey.Endpoints[`admin/queue/${ApQueueDomain}-delayed`]['res']>([]);
const chartProcess = useTemplateRef('chartProcess');
const chartActive = useTemplateRef('chartActive');
const chartDelayed = useTemplateRef('chartDelayed');
const chartWaiting = useTemplateRef('chartWaiting');
const props = defineProps<{
domain: string;
domain: ApQueueDomain;
}>();
const onStats = (stats) => {
function onStats(stats: Misskey.entities.QueueStats) {
activeSincePrevTick.value = stats[props.domain].activeSincePrevTick;
active.value = stats[props.domain].active;
delayed.value = stats[props.domain].delayed;
@@ -82,13 +84,13 @@ const onStats = (stats) => {
chartActive.value.pushData(stats[props.domain].active);
chartDelayed.value.pushData(stats[props.domain].delayed);
chartWaiting.value.pushData(stats[props.domain].waiting);
};
}
const onStatsLog = (statsLog) => {
const dataProcess = [];
const dataActive = [];
const dataDelayed = [];
const dataWaiting = [];
function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) {
const dataProcess: Misskey.entities.QueueStats[ApQueueDomain]['activeSincePrevTick'][] = [];
const dataActive: Misskey.entities.QueueStats[ApQueueDomain]['active'][] = [];
const dataDelayed: Misskey.entities.QueueStats[ApQueueDomain]['delayed'][] = [];
const dataWaiting: Misskey.entities.QueueStats[ApQueueDomain]['waiting'][] = [];
for (const stats of [...statsLog].reverse()) {
dataProcess.push(stats[props.domain].activeSincePrevTick);
@@ -101,14 +103,12 @@ const onStatsLog = (statsLog) => {
chartActive.value.setData(dataActive);
chartDelayed.value.setData(dataDelayed);
chartWaiting.value.setData(dataWaiting);
};
}
onMounted(() => {
if (props.domain === 'inbox' || props.domain === 'deliver') {
misskeyApi(`admin/queue/${props.domain}-delayed`).then(result => {
jobs.value = result;
});
}
misskeyApi(`admin/queue/${props.domain}-delayed`).then(result => {
jobs.value = result;
});
connection.on('stats', onStats);
connection.on('statsLog', onStatsLog);
@@ -135,8 +135,8 @@ onUnmounted(() => {
.chart {
min-width: 0;
padding: 16px;
background: var(--panel);
border-radius: var(--radius);
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
}
.chartTitle {

View File

@@ -17,15 +17,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as config from '@@/js/config.js';
import XQueue from './queue.chart.vue';
import XHeader from './_header_.vue';
import type { Ref } from 'vue';
import * as os from '@/os.js';
import * as config from '@/config.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
const tab = ref('deliver');
export type ApQueueDomain = 'deliver' | 'inbox';
const tab: Ref<ApQueueDomain> = ref('deliver');
function clear() {
os.confirm({
@@ -51,14 +54,7 @@ function promoteAllQueues() {
});
}
const headerActions = computed(() => [{
asFullButton: true,
icon: 'ti ti-external-link',
text: i18n.ts.dashboard,
handler: () => {
window.open(config.url + '/queue', '_blank', 'noopener');
},
}]);
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'deliver',
@@ -68,7 +64,7 @@ const headerTabs = computed(() => [{
title: 'Inbox',
}]);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.jobQueue,
icon: 'ti ti-clock-play',
}));

View File

@@ -11,8 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
<div style="margin: 8px 0;">
<i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--success);"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--error);"></i>
<i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--MI_THEME-success);"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--MI_THEME-error);"></i>
<i v-else class="ti ti-clock" :class="$style.icon"></i>
<span>{{ i18n.ts._relayStatus[relay.status] }}</span>
</div>
@@ -29,9 +29,9 @@ import * as Misskey from 'misskey-js';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const relays = ref<Misskey.entities.AdminRelaysListResponse>([]);
@@ -84,7 +84,7 @@ const headerActions = computed(() => [{
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.relays,
icon: 'ti ti-planet',
}));

View File

@@ -28,12 +28,12 @@ import { v4 as uuid } from 'uuid';
import XHeader from './_header_.vue';
import XEditor from './roles.editor.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import { rolesCache } from '@/cache.js';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const router = useRouter();
@@ -87,7 +87,7 @@ async function save() {
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: role.value ? `${i18n.ts._role.edit}: ${role.value.name}` : i18n.ts._role.new,
icon: 'ti ti-badge',
}));
@@ -95,7 +95,7 @@ definePageMetadata(() => ({
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
</style>

View File

@@ -52,6 +52,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkSwitch v-model="role.preserveAssignmentOnMoveAccount" :readonly="readonly">
<template #label>{{ i18n.ts._role.preserveAssignmentOnMoveAccount }}</template>
<template #caption>{{ i18n.ts._role.preserveAssignmentOnMoveAccount_description }}</template>
</MkSwitch>
<MkSwitch v-model="role.canEditMembersByModerator" :readonly="readonly">
<template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template>
<template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template>
@@ -160,6 +165,29 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>
<span v-if="role.policies.chatAvailability.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.chatAvailability.value === 'available' ? i18n.ts.yes : role.policies.chatAvailability.value === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.chatAvailability)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
<option value="available">{{ i18n.ts.enabled }}</option>
<option value="readonly">{{ i18n.ts.readonly }}</option>
<option value="unavailable">{{ i18n.ts.disabled }}</option>
</MkSelect>
<MkRange v-model="role.policies.chatAvailability.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 : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>
@@ -582,7 +610,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 : ''">
@@ -590,6 +618,106 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportAntennas, 'canImportAntennas'])">
<template #label>{{ i18n.ts._role._options.canImportAntennas }}</template>
<template #suffix>
<span v-if="role.policies.canImportAntennas.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canImportAntennas.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canImportAntennas)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canImportAntennas.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canImportAntennas.value" :disabled="role.policies.canImportAntennas.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canImportAntennas.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 : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportBlocking, 'canImportBlocking'])">
<template #label>{{ i18n.ts._role._options.canImportBlocking }}</template>
<template #suffix>
<span v-if="role.policies.canImportBlocking.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canImportBlocking.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canImportBlocking)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canImportBlocking.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canImportBlocking.value" :disabled="role.policies.canImportBlocking.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canImportBlocking.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 : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportFollowing, 'canImportFollowing'])">
<template #label>{{ i18n.ts._role._options.canImportFollowing }}</template>
<template #suffix>
<span v-if="role.policies.canImportFollowing.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canImportFollowing.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canImportFollowing)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canImportFollowing.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canImportFollowing.value" :disabled="role.policies.canImportFollowing.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canImportFollowing.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 : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportMuting, 'canImportMuting'])">
<template #label>{{ i18n.ts._role._options.canImportMuting }}</template>
<template #suffix>
<span v-if="role.policies.canImportMuting.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canImportMuting.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canImportMuting)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canImportMuting.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canImportMuting.value" :disabled="role.policies.canImportMuting.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canImportMuting.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 : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportUserLists, 'canImportUserLists'])">
<template #label>{{ i18n.ts._role._options.canImportUserLists }}</template>
<template #suffix>
<span v-if="role.policies.canImportUserLists.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canImportUserLists.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canImportUserLists)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canImportUserLists.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canImportUserLists.value" :disabled="role.policies.canImportUserLists.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canImportUserLists.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 : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
</div>
@@ -598,6 +726,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';
@@ -608,9 +737,8 @@ 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 '@/const.js';
import { instance } from '@/instance.js';
import { deepClone } from '@/scripts/clone.js';
import { deepClone } from '@/utility/clone.js';
const emit = defineEmits<{
(ev: 'update:modelValue', v: any): void;
@@ -634,6 +762,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) => {

View File

@@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination :pagination="usersPagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.noUsers }}</div>
</div>
</template>
@@ -67,15 +67,15 @@ import XHeader from './_header_.vue';
import XEditor from './roles.editor.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkPagination from '@/components/MkPagination.vue';
import { infoImageUrl } from '@/instance.js';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const router = useRouter();
@@ -170,7 +170,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: `${i18n.ts.role}: ${role.name}`,
icon: 'ti ti-badge',
}));
@@ -184,7 +184,7 @@ definePageMetadata(() => ({
.userItemSub {
padding: 6px 12px;
font-size: 85%;
color: var(--fgTransparentWeak);
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
.userItemMainBody {

View File

@@ -11,6 +11,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps">
<MkFolder>
<template #label>{{ i18n.ts._role.baseRole }}</template>
<template #footer>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
</template>
<div class="_gaps_s">
<MkInput v-model="baseRoleQ" type="search">
<template #prefix><i class="ti ti-search"></i></template>
@@ -48,6 +51,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.chatAvailability, 'chatAvailability'])">
<template #label>{{ i18n.ts._role._options.chatAvailability }}</template>
<template #suffix>{{ policies.chatAvailability === 'available' ? i18n.ts.yes : policies.chatAvailability === 'readonly' ? i18n.ts.readonly : i18n.ts.no }}</template>
<MkSelect v-model="policies.chatAvailability">
<template #label>{{ i18n.ts.enable }}</template>
<option value="available">{{ i18n.ts.enabled }}</option>
<option value="readonly">{{ i18n.ts.readonly }}</option>
<option value="unavailable">{{ i18n.ts.disabled }}</option>
</MkSelect>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])">
<template #label>{{ i18n.ts._role._options.mentionMax }}</template>
<template #suffix>{{ policies.mentionLimit }}</template>
@@ -210,11 +224,49 @@ 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>
<MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportAntennas, 'canImportAntennas'])">
<template #label>{{ i18n.ts._role._options.canImportAntennas }}</template>
<template #suffix>{{ policies.canImportAntennas ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canImportAntennas">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportBlocking, 'canImportBlocking'])">
<template #label>{{ i18n.ts._role._options.canImportBlocking }}</template>
<template #suffix>{{ policies.canImportBlocking ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canImportBlocking">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportFollowing, 'canImportFollowing'])">
<template #label>{{ i18n.ts._role._options.canImportFollowing }}</template>
<template #suffix>{{ policies.canImportFollowing ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canImportFollowing">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportMuting, 'canImportMuting'])">
<template #label>{{ i18n.ts._role._options.canImportMuting }}</template>
<template #suffix>{{ policies.canImportMuting ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canImportMuting">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canImportUserLists, 'canImportUserList'])">
<template #label>{{ i18n.ts._role._options.canImportUserLists }}</template>
<template #suffix>{{ policies.canImportUserLists ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canImportUserLists">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
</div>
</MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>
@@ -240,21 +292,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue';
import { ROLE_POLICIES } from '@@/js/const.js';
import XHeader from './_header_.vue';
import MkInput from '@/components/MkInput.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRange from '@/components/MkRange.vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { instance, fetchInstance } from '@/instance.js';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { ROLE_POLICIES } from '@/const.js';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const router = useRouter();
const baseRoleQ = ref('');
@@ -266,6 +319,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()));
@@ -286,7 +350,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.roles,
icon: 'ti ti-badges',
}));

View File

@@ -7,119 +7,115 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkFolder>
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="enableHcaptcha" #suffix>hCaptcha</template>
<template v-else-if="enableMcaptcha" #suffix>mCaptcha</template>
<template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template>
<template v-else-if="enableTurnstile" #suffix>Turnstile</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<div class="_gaps_m">
<XBotProtection/>
<XBotProtection/>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
<template v-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
<template v-else #suffix>{{ i18n.ts.none }}</template>
<template v-if="sensitiveMediaDetectionForm.modified.value" #footer>
<MkFormFooter :form="sensitiveMediaDetectionForm"/>
</template>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
<template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
<template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
<template v-else #suffix>{{ i18n.ts.none }}</template>
<div class="_gaps_m">
<span>{{ i18n.ts._sensitiveMediaDetection.description }}</span>
<div class="_gaps_m">
<span>{{ i18n.ts._sensitiveMediaDetection.description }}</span>
<MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection">
<option value="none">{{ i18n.ts.none }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.localOnly }}</option>
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
<MkRadios v-model="sensitiveMediaDetection">
<option value="none">{{ i18n.ts.none }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.localOnly }}</option>
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
<MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
</MkRange>
<MkRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
</MkRange>
<MkSwitch v-model="sensitiveMediaDetectionForm.state.enableSensitiveMediaDetectionForVideos">
<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
</MkSwitch>
<MkSwitch v-model="enableSensitiveMediaDetectionForVideos">
<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
</MkSwitch>
<MkSwitch v-model="sensitiveMediaDetectionForm.state.setSensitiveFlagAutomatically">
<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
</MkSwitch>
<MkSwitch v-model="setSensitiveFlagAutomatically">
<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
</MkSwitch>
<!-- 現状 false positive が多すぎて実用に耐えない
<MkSwitch v-model="disallowUploadWhenPredictedAsPorn">
<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
</MkSwitch>
-->
</div>
</MkFolder>
<!-- 現状 false positive が多すぎて実用に耐えない
<MkSwitch v-model="disallowUploadWhenPredictedAsPorn">
<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
</MkSwitch>
-->
<MkFolder>
<template #label>Active Email Validation</template>
<template v-if="emailValidationForm.savedState.enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="emailValidationForm.modified.value" #footer>
<MkFormFooter :form="emailValidationForm"/>
</template>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="emailValidationForm.state.enableActiveEmailValidation">
<template #label>Enable</template>
</MkSwitch>
<MkSwitch v-model="emailValidationForm.state.enableVerifymailApi">
<template #label>Use Verifymail.io API</template>
</MkSwitch>
<MkInput v-model="emailValidationForm.state.verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template>
</MkInput>
<MkSwitch v-model="emailValidationForm.state.enableTruemailApi">
<template #label>Use TrueMail API</template>
</MkSwitch>
<MkInput v-model="emailValidationForm.state.truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template>
</MkInput>
<MkInput v-model="emailValidationForm.state.truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template>
</MkInput>
</div>
</MkFolder>
<MkFolder>
<template #label>Active Email Validation</template>
<template v-if="enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<MkFolder>
<template #label>Banned Email Domains</template>
<template v-if="bannedEmailDomainsForm.modified.value" #footer>
<MkFormFooter :form="bannedEmailDomainsForm"/>
</template>
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="enableActiveEmailValidation">
<template #label>Enable</template>
</MkSwitch>
<MkSwitch v-model="enableVerifymailApi">
<template #label>Use Verifymail.io API</template>
</MkSwitch>
<MkInput v-model="verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template>
</MkInput>
<MkSwitch v-model="enableTruemailApi">
<template #label>Use TrueMail API</template>
</MkSwitch>
<MkInput v-model="truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template>
</MkInput>
<MkInput v-model="truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template>
</MkInput>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps_m">
<MkTextarea v-model="bannedEmailDomainsForm.state.bannedEmailDomains">
<template #label>Banned Email Domains List</template>
</MkTextarea>
</div>
</MkFolder>
<MkFolder>
<template #label>Banned Email Domains</template>
<MkFolder>
<template #label>Log IP address</template>
<template v-if="ipLoggingForm.savedState.enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="ipLoggingForm.modified.value" #footer>
<MkFormFooter :form="ipLoggingForm"/>
</template>
<div class="_gaps_m">
<MkTextarea v-model="bannedEmailDomains">
<template #label>Banned Email Domains List</template>
</MkTextarea>
<MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<MkFolder>
<template #label>Log IP address</template>
<template v-if="enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<div class="_gaps_m">
<MkSwitch v-model="enableIpLogging" @update:modelValue="save">
<template #label>Enable</template>
</MkSwitch>
</div>
</MkFolder>
</div>
</FormSuspense>
<div class="_gaps_m">
<MkSwitch v-model="ipLoggingForm.state.enableIpLogging">
<template #label>Enable</template>
</MkSwitch>
</div>
</MkFolder>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
@@ -131,89 +127,86 @@ import XHeader from './_header_.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSuspense from '@/components/form/suspense.vue';
import MkRange from '@/components/MkRange.vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
const enableHcaptcha = ref<boolean>(false);
const enableMcaptcha = ref<boolean>(false);
const enableRecaptcha = ref<boolean>(false);
const enableTurnstile = ref<boolean>(false);
const sensitiveMediaDetection = ref<string>('none');
const sensitiveMediaDetectionSensitivity = ref<number>(0);
const setSensitiveFlagAutomatically = ref<boolean>(false);
const enableSensitiveMediaDetectionForVideos = ref<boolean>(false);
const enableIpLogging = ref<boolean>(false);
const enableActiveEmailValidation = ref<boolean>(false);
const enableVerifymailApi = ref<boolean>(false);
const verifymailAuthKey = ref<string | null>(null);
const enableTruemailApi = ref<boolean>(false);
const truemailInstance = ref<string | null>(null);
const truemailAuthKey = ref<string | null>(null);
const bannedEmailDomains = ref<string>('');
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
enableHcaptcha.value = meta.enableHcaptcha;
enableMcaptcha.value = meta.enableMcaptcha;
enableRecaptcha.value = meta.enableRecaptcha;
enableTurnstile.value = meta.enableTurnstile;
sensitiveMediaDetection.value = meta.sensitiveMediaDetection;
sensitiveMediaDetectionSensitivity.value =
meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0;
setSensitiveFlagAutomatically.value = meta.setSensitiveFlagAutomatically;
enableSensitiveMediaDetectionForVideos.value = meta.enableSensitiveMediaDetectionForVideos;
enableIpLogging.value = meta.enableIpLogging;
enableActiveEmailValidation.value = meta.enableActiveEmailValidation;
enableVerifymailApi.value = meta.enableVerifymailApi;
verifymailAuthKey.value = meta.verifymailAuthKey;
enableTruemailApi.value = meta.enableTruemailApi;
truemailInstance.value = meta.truemailInstance;
truemailAuthKey.value = meta.truemailAuthKey;
bannedEmailDomains.value = meta.bannedEmailDomains?.join('\n') || '';
}
function save() {
os.apiWithDialog('admin/update-meta', {
sensitiveMediaDetection: sensitiveMediaDetection.value,
const sensitiveMediaDetectionForm = useForm({
sensitiveMediaDetection: meta.sensitiveMediaDetection,
sensitiveMediaDetectionSensitivity: meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 :
meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 :
meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 :
meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 :
meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0,
setSensitiveFlagAutomatically: meta.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: meta.enableSensitiveMediaDetectionForVideos,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
sensitiveMediaDetection: state.sensitiveMediaDetection,
sensitiveMediaDetectionSensitivity:
sensitiveMediaDetectionSensitivity.value === 0 ? 'veryLow' :
sensitiveMediaDetectionSensitivity.value === 1 ? 'low' :
sensitiveMediaDetectionSensitivity.value === 2 ? 'medium' :
sensitiveMediaDetectionSensitivity.value === 3 ? 'high' :
sensitiveMediaDetectionSensitivity.value === 4 ? 'veryHigh' :
state.sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' :
state.sensitiveMediaDetectionSensitivity === 1 ? 'low' :
state.sensitiveMediaDetectionSensitivity === 2 ? 'medium' :
state.sensitiveMediaDetectionSensitivity === 3 ? 'high' :
state.sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' :
0,
setSensitiveFlagAutomatically: setSensitiveFlagAutomatically.value,
enableSensitiveMediaDetectionForVideos: enableSensitiveMediaDetectionForVideos.value,
enableIpLogging: enableIpLogging.value,
enableActiveEmailValidation: enableActiveEmailValidation.value,
enableVerifymailApi: enableVerifymailApi.value,
verifymailAuthKey: verifymailAuthKey.value,
enableTruemailApi: enableTruemailApi.value,
truemailInstance: truemailInstance.value,
truemailAuthKey: truemailAuthKey.value,
bannedEmailDomains: bannedEmailDomains.value.split('\n'),
}).then(() => {
fetchInstance(true);
setSensitiveFlagAutomatically: state.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: state.enableSensitiveMediaDetectionForVideos,
});
}
fetchInstance(true);
});
const ipLoggingForm = useForm({
enableIpLogging: meta.enableIpLogging,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableIpLogging: state.enableIpLogging,
});
fetchInstance(true);
});
const emailValidationForm = useForm({
enableActiveEmailValidation: meta.enableActiveEmailValidation,
enableVerifymailApi: meta.enableVerifymailApi,
verifymailAuthKey: meta.verifymailAuthKey,
enableTruemailApi: meta.enableTruemailApi,
truemailInstance: meta.truemailInstance,
truemailAuthKey: meta.truemailAuthKey,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableActiveEmailValidation: state.enableActiveEmailValidation,
enableVerifymailApi: state.enableVerifymailApi,
verifymailAuthKey: state.verifymailAuthKey,
enableTruemailApi: state.enableTruemailApi,
truemailInstance: state.truemailInstance,
truemailAuthKey: state.truemailAuthKey,
});
fetchInstance(true);
});
const bannedEmailDomainsForm = useForm({
bannedEmailDomains: meta.bannedEmailDomains?.join('\n') || '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
bannedEmailDomains: state.bannedEmailDomains.split('\n'),
});
fetchInstance(true);
});
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.security,
icon: 'ti ti-lock',
}));

View File

@@ -46,7 +46,7 @@ import XHeader from './_header_.vue';
import * as os from '@/os.js';
import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@@ -67,7 +67,7 @@ const remove = (index: number): void => {
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.serverRules,
icon: 'ti ti-checkbox',
}));
@@ -76,7 +76,7 @@ definePageMetadata(() => ({
<style lang="scss" module>
.item {
display: block;
color: var(--navFg);
color: var(--MI_THEME-navFg);
}
.itemHeader {
@@ -96,8 +96,8 @@ definePageMetadata(() => ({
.itemNumber {
display: flex;
background-color: var(--accentedBg);
color: var(--accent);
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
font-size: 14px;
font-weight: bold;
width: 28px;
@@ -117,12 +117,12 @@ definePageMetadata(() => ({
.itemRemove {
width: 40px;
height: 40px;
color: var(--error);
color: var(--MI_THEME-error);
margin-left: auto;
border-radius: 6px;
&:hover {
background: var(--X5);
background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
}
}

View File

@@ -8,173 +8,191 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<div class="_gaps_m">
<MkInput v-model="name">
<template #label>{{ i18n.ts.instanceName }}</template>
</MkInput>
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
<template v-if="infoForm.modified.value" #footer>
<MkFormFooter :form="infoForm"/>
</template>
<MkInput v-model="shortName">
<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})</template>
<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
</MkInput>
<MkTextarea v-model="description">
<template #label>{{ i18n.ts.instanceDescription }}</template>
</MkTextarea>
<FormSplit :minWidth="300">
<MkInput v-model="maintainerName">
<template #label>{{ i18n.ts.maintainerName }}</template>
<div class="_gaps">
<MkInput v-model="infoForm.state.name">
<template #label>{{ i18n.ts.instanceName }}<span v-if="infoForm.modifiedStates.name" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="maintainerEmail" type="email">
<template #prefix><i class="ti ti-mail"></i></template>
<template #label>{{ i18n.ts.maintainerEmail }}</template>
<MkInput v-model="infoForm.state.shortName">
<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})<span v-if="infoForm.modifiedStates.shortName" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
</MkInput>
</FormSplit>
<MkInput v-model="repositoryUrl" type="url">
<template #label>{{ i18n.ts.repositoryUrl }}</template>
<template #prefix><i class="ti ti-link"></i></template>
<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
</MkInput>
<MkTextarea v-model="infoForm.state.description">
<template #label>{{ i18n.ts.instanceDescription }}<span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkTextarea>
<MkInfo v-if="!instance.providesTarball && !repositoryUrl" warn>
{{ i18n.ts.repositoryUrlOrTarballRequired }}
</MkInfo>
<FormSplit :minWidth="300">
<MkInput v-model="infoForm.state.maintainerName">
<template #label>{{ i18n.ts.maintainerName }}<span v-if="infoForm.modifiedStates.maintainerName" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}</template>
<template #prefix><i class="ti ti-link"></i></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>
</MkInput>
<MkInput v-model="infoForm.state.maintainerEmail" type="email">
<template #label>{{ i18n.ts.maintainerEmail }}<span v-if="infoForm.modifiedStates.maintainerEmail" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-mail"></i></template>
</MkInput>
</FormSplit>
<MkTextarea v-model="pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<MkInput v-model="infoForm.state.tosUrl" type="url">
<template #label>{{ i18n.ts.tosUrl }}<span v-if="infoForm.modifiedStates.tosUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.privacyPolicyUrl" type="url">
<template #label>{{ i18n.ts.privacyPolicyUrl }}<span v-if="infoForm.modifiedStates.privacyPolicyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.inquiryUrl" type="url">
<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}<span v-if="infoForm.modifiedStates.inquiryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.repositoryUrl" type="url">
<template #label>{{ i18n.ts.repositoryUrl }}<span v-if="infoForm.modifiedStates.repositoryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn>
{{ i18n.ts.repositoryUrlOrTarballRequired }}
</MkInfo>
<MkInput v-model="infoForm.state.impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}<span v-if="infoForm.modifiedStates.impressumUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-star"></i></template>
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<template v-if="pinnedUsersForm.modified.value" #footer>
<MkFormFooter :form="pinnedUsersForm"/>
</template>
<MkTextarea v-model="pinnedUsersForm.state.pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}<span v-if="pinnedUsersForm.modifiedStates.pinnedUsers" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
</MkTextarea>
</MkFolder>
<FormSection>
<template #label>{{ i18n.ts.files }}</template>
<MkFolder>
<template #icon><i class="ti ti-cloud"></i></template>
<template #label>{{ i18n.ts.files }}</template>
<template v-if="filesForm.modified.value" #footer>
<MkFormFooter :form="filesForm"/>
</template>
<div class="_gaps_m">
<MkSwitch v-model="cacheRemoteFiles">
<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
<div class="_gaps">
<MkSwitch v-model="filesForm.state.cacheRemoteFiles">
<template #label>{{ i18n.ts.cacheRemoteFiles }}<span v-if="filesForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
</MkSwitch>
<template v-if="filesForm.state.cacheRemoteFiles">
<MkSwitch v-model="filesForm.state.cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}<span v-if="filesForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
</template>
</div>
</MkFolder>
<template v-if="cacheRemoteFiles">
<MkSwitch v-model="cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
</template>
</div>
</FormSection>
<MkFolder>
<template #icon><i class="ti ti-world-cog"></i></template>
<template #label>ServiceWorker</template>
<template v-if="serviceWorkerForm.modified.value" #footer>
<MkFormFooter :form="serviceWorkerForm"/>
</template>
<FormSection>
<template #label>ServiceWorker</template>
<div class="_gaps">
<MkSwitch v-model="serviceWorkerForm.state.enableServiceWorker">
<template #label>{{ i18n.ts.enableServiceworker }}<span v-if="serviceWorkerForm.modifiedStates.enableServiceWorker" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
</MkSwitch>
<div class="_gaps_m">
<MkSwitch v-model="enableServiceWorker">
<template #label>{{ i18n.ts.enableServiceworker }}</template>
<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
</MkSwitch>
<template v-if="enableServiceWorker">
<MkInput v-model="swPublicKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Public key</template>
</MkInput>
<MkInput v-model="swPrivateKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Private key</template>
</MkInput>
</template>
</div>
</FormSection>
<FormSection>
<template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<div class="_gaps_m">
<MkSwitch v-model="enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}</template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</template>
</MkSwitch>
<MkSwitch v-model="enableFanoutTimelineDbFallback">
<template #label>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}</template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</template>
</MkSwitch>
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax</template>
<template v-if="serviceWorkerForm.state.enableServiceWorker">
<MkInput v-model="serviceWorkerForm.state.swPublicKey">
<template #label>Public key<span v-if="serviceWorkerForm.modifiedStates.swPublicKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
<MkInput v-model="perRemoteUserUserTimelineCacheMax" type="number">
<template #label>perRemoteUserUserTimelineCacheMax</template>
<MkInput v-model="serviceWorkerForm.state.swPrivateKey">
<template #label>Private key<span v-if="serviceWorkerForm.modifiedStates.swPrivateKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
</template>
</div>
</MkFolder>
<MkInput v-model="perUserHomeTimelineCacheMax" type="number">
<template #label>perUserHomeTimelineCacheMax</template>
</MkInput>
<MkFolder>
<template #icon><i class="ti ti-ad"></i></template>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<template v-if="adForm.modified.value" #footer>
<MkFormFooter :form="adForm"/>
</template>
<MkInput v-model="perUserListTimelineCacheMax" type="number">
<template #label>perUserListTimelineCacheMax</template>
<div class="_gaps">
<div class="_gaps_s">
<MkInput v-model="adForm.state.notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}<span v-if="adForm.modifiedStates.notesPerOneAd" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="adForm.state.notesPerOneAd > 0 && adForm.state.notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</FormSection>
</div>
</MkFolder>
<FormSection>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<MkFolder>
<template #icon><i class="ti ti-world-search"></i></template>
<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
<template v-if="urlPreviewForm.modified.value" #footer>
<MkFormFooter :form="urlPreviewForm"/>
</template>
<div class="_gaps_m">
<div class="_gaps_s">
<MkInput v-model="notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}</template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="notesPerOneAd > 0 && notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</div>
</FormSection>
<div class="_gaps">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewEnabled">
<template #label>{{ i18n.ts._urlPreviewSetting.enable }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewEnabled" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkSwitch>
<FormSection>
<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
<div class="_gaps_m">
<MkSwitch v-model="urlPreviewEnabled">
<template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
</MkSwitch>
<MkSwitch v-model="urlPreviewRequireContentLength">
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</template>
<template v-if="urlPreviewForm.state.urlPreviewEnabled">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength">
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
</MkSwitch>
<MkInput v-model="urlPreviewMaximumContentLength" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</template>
<MkInput v-model="urlPreviewForm.state.urlPreviewMaximumContentLength" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewMaximumContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewTimeout" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}</template>
<MkInput v-model="urlPreviewForm.state.urlPreviewTimeout" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewTimeout" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewUserAgent" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}</template>
<MkInput v-model="urlPreviewForm.state.urlPreviewUserAgent" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewUserAgent" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
</MkInput>
<div>
<MkInput v-model="urlPreviewSummaryProxyUrl" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</template>
<MkInput v-model="urlPreviewForm.state.urlPreviewSummaryProxyUrl" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewSummaryProxyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
</MkInput>
@@ -188,147 +206,200 @@ SPDX-License-Identifier: AGPL-3.0-only
</ul>
</div>
</div>
</div>
</FormSection>
</div>
</FormSuspense>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</MkSpacer>
</template>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-planet"></i></template>
<template #label>{{ i18n.ts.federation }}</template>
<template v-if="federationForm.savedState.federation === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="federationForm.savedState.federation === 'specified'" #suffix>{{ i18n.ts.specifyHost }}</template>
<template v-else-if="federationForm.savedState.federation === 'none'" #suffix>{{ i18n.ts.none }}</template>
<template v-if="federationForm.modified.value" #footer>
<MkFormFooter :form="federationForm"/>
</template>
<div class="_gaps">
<MkRadios v-model="federationForm.state.federation">
<template #label>{{ i18n.ts.behavior }}<span v-if="federationForm.modifiedStates.federation" class="_modified">{{ i18n.ts.modified }}</span></template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="specified">{{ i18n.ts.specifyHost }}</option>
<option value="none">{{ i18n.ts.none }}</option>
</MkRadios>
<MkTextarea v-if="federationForm.state.federation === 'specified'" v-model="federationForm.state.federationHosts">
<template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template>
</MkTextarea>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-ghost"></i></template>
<template #label>{{ i18n.ts.proxyAccount }}</template>
<template v-if="proxyAccountForm.modified.value" #footer>
<MkFormFooter :form="proxyAccountForm"/>
</template>
<div class="_gaps">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
</div>
</MkFolder>
</div>
</template>
</MkSpacer>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, reactive } from 'vue';
import XHeader from './_header_.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import { useForm } from '@/use/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkRadios from '@/components/MkRadios.vue';
const name = ref<string | null>(null);
const shortName = ref<string | null>(null);
const description = ref<string | null>(null);
const maintainerName = ref<string | null>(null);
const maintainerEmail = ref<string | null>(null);
const repositoryUrl = ref<string | null>(null);
const impressumUrl = ref<string | null>(null);
const pinnedUsers = ref<string>('');
const cacheRemoteFiles = ref<boolean>(false);
const cacheRemoteSensitiveFiles = ref<boolean>(false);
const enableServiceWorker = ref<boolean>(false);
const swPublicKey = ref<string | null>(null);
const swPrivateKey = ref<string | null>(null);
const enableFanoutTimeline = ref<boolean>(false);
const enableFanoutTimelineDbFallback = ref<boolean>(false);
const perLocalUserUserTimelineCacheMax = ref<number>(0);
const perRemoteUserUserTimelineCacheMax = ref<number>(0);
const perUserHomeTimelineCacheMax = ref<number>(0);
const perUserListTimelineCacheMax = ref<number>(0);
const notesPerOneAd = ref<number>(0);
const urlPreviewEnabled = ref<boolean>(true);
const urlPreviewTimeout = ref<number>(10000);
const urlPreviewMaximumContentLength = ref<number>(1024 * 1024 * 10);
const urlPreviewRequireContentLength = ref<boolean>(true);
const urlPreviewUserAgent = ref<string | null>(null);
const urlPreviewSummaryProxyUrl = ref<string | null>(null);
const meta = await misskeyApi('admin/meta');
async function init(): Promise<void> {
const meta = await misskeyApi('admin/meta');
name.value = meta.name;
shortName.value = meta.shortName;
description.value = meta.description;
maintainerName.value = meta.maintainerName;
maintainerEmail.value = meta.maintainerEmail;
repositoryUrl.value = meta.repositoryUrl;
impressumUrl.value = meta.impressumUrl;
pinnedUsers.value = meta.pinnedUsers.join('\n');
cacheRemoteFiles.value = meta.cacheRemoteFiles;
cacheRemoteSensitiveFiles.value = meta.cacheRemoteSensitiveFiles;
enableServiceWorker.value = meta.enableServiceWorker;
swPublicKey.value = meta.swPublickey;
swPrivateKey.value = meta.swPrivateKey;
enableFanoutTimeline.value = meta.enableFanoutTimeline;
enableFanoutTimelineDbFallback.value = meta.enableFanoutTimelineDbFallback;
perLocalUserUserTimelineCacheMax.value = meta.perLocalUserUserTimelineCacheMax;
perRemoteUserUserTimelineCacheMax.value = meta.perRemoteUserUserTimelineCacheMax;
perUserHomeTimelineCacheMax.value = meta.perUserHomeTimelineCacheMax;
perUserListTimelineCacheMax.value = meta.perUserListTimelineCacheMax;
notesPerOneAd.value = meta.notesPerOneAd;
urlPreviewEnabled.value = meta.urlPreviewEnabled;
urlPreviewTimeout.value = meta.urlPreviewTimeout;
urlPreviewMaximumContentLength.value = meta.urlPreviewMaximumContentLength;
urlPreviewRequireContentLength.value = meta.urlPreviewRequireContentLength;
urlPreviewUserAgent.value = meta.urlPreviewUserAgent;
urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
}
const proxyAccount = await misskeyApi('users/show', { userId: meta.proxyAccountId });
async function save() {
const infoForm = useForm({
name: meta.name ?? '',
shortName: meta.shortName ?? '',
description: meta.description ?? '',
maintainerName: meta.maintainerName ?? '',
maintainerEmail: meta.maintainerEmail ?? '',
tosUrl: meta.tosUrl ?? '',
privacyPolicyUrl: meta.privacyPolicyUrl ?? '',
inquiryUrl: meta.inquiryUrl ?? '',
repositoryUrl: meta.repositoryUrl ?? '',
impressumUrl: meta.impressumUrl ?? '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
name: name.value,
shortName: shortName.value === '' ? null : shortName.value,
description: description.value,
maintainerName: maintainerName.value,
maintainerEmail: maintainerEmail.value,
repositoryUrl: repositoryUrl.value,
impressumUrl: impressumUrl.value,
pinnedUsers: pinnedUsers.value.split('\n'),
cacheRemoteFiles: cacheRemoteFiles.value,
cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value,
enableServiceWorker: enableServiceWorker.value,
swPublicKey: swPublicKey.value,
swPrivateKey: swPrivateKey.value,
enableFanoutTimeline: enableFanoutTimeline.value,
enableFanoutTimelineDbFallback: enableFanoutTimelineDbFallback.value,
perLocalUserUserTimelineCacheMax: perLocalUserUserTimelineCacheMax.value,
perRemoteUserUserTimelineCacheMax: perRemoteUserUserTimelineCacheMax.value,
perUserHomeTimelineCacheMax: perUserHomeTimelineCacheMax.value,
perUserListTimelineCacheMax: perUserListTimelineCacheMax.value,
notesPerOneAd: notesPerOneAd.value,
urlPreviewEnabled: urlPreviewEnabled.value,
urlPreviewTimeout: urlPreviewTimeout.value,
urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
urlPreviewUserAgent: urlPreviewUserAgent.value,
urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
name: state.name,
shortName: state.shortName === '' ? null : state.shortName,
description: state.description,
maintainerName: state.maintainerName,
maintainerEmail: state.maintainerEmail,
tosUrl: state.tosUrl,
privacyPolicyUrl: state.privacyPolicyUrl,
inquiryUrl: state.inquiryUrl,
repositoryUrl: state.repositoryUrl,
impressumUrl: state.impressumUrl,
});
fetchInstance(true);
}
});
const pinnedUsersForm = useForm({
pinnedUsers: meta.pinnedUsers.join('\n'),
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
pinnedUsers: state.pinnedUsers.split('\n'),
});
fetchInstance(true);
});
const filesForm = useForm({
cacheRemoteFiles: meta.cacheRemoteFiles,
cacheRemoteSensitiveFiles: meta.cacheRemoteSensitiveFiles,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
cacheRemoteFiles: state.cacheRemoteFiles,
cacheRemoteSensitiveFiles: state.cacheRemoteSensitiveFiles,
});
fetchInstance(true);
});
const serviceWorkerForm = useForm({
enableServiceWorker: meta.enableServiceWorker,
swPublicKey: meta.swPublickey ?? '',
swPrivateKey: meta.swPrivateKey ?? '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
enableServiceWorker: state.enableServiceWorker,
swPublicKey: state.swPublicKey,
swPrivateKey: state.swPrivateKey,
});
fetchInstance(true);
});
const adForm = useForm({
notesPerOneAd: meta.notesPerOneAd,
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
notesPerOneAd: state.notesPerOneAd,
});
fetchInstance(true);
});
const urlPreviewForm = useForm({
urlPreviewEnabled: meta.urlPreviewEnabled,
urlPreviewTimeout: meta.urlPreviewTimeout,
urlPreviewMaximumContentLength: meta.urlPreviewMaximumContentLength,
urlPreviewRequireContentLength: meta.urlPreviewRequireContentLength,
urlPreviewUserAgent: meta.urlPreviewUserAgent ?? '',
urlPreviewSummaryProxyUrl: meta.urlPreviewSummaryProxyUrl ?? '',
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
urlPreviewEnabled: state.urlPreviewEnabled,
urlPreviewTimeout: state.urlPreviewTimeout,
urlPreviewMaximumContentLength: state.urlPreviewMaximumContentLength,
urlPreviewRequireContentLength: state.urlPreviewRequireContentLength,
urlPreviewUserAgent: state.urlPreviewUserAgent,
urlPreviewSummaryProxyUrl: state.urlPreviewSummaryProxyUrl,
});
fetchInstance(true);
});
const federationForm = useForm({
federation: meta.federation,
federationHosts: meta.federationHosts.join('\n'),
}, async (state) => {
await os.apiWithDialog('admin/update-meta', {
federation: state.federation,
federationHosts: state.federationHosts.split('\n'),
});
fetchInstance(true);
});
const proxyAccountForm = useForm({
description: proxyAccount.description,
}, async (state) => {
await os.apiWithDialog('admin/update-proxy-account', {
description: state.description,
});
fetchInstance(true);
});
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.general,
icon: 'ti ti-settings',
}));
</script>
<style lang="scss" module>
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
.subCaption {
font-size: 0.85em;
color: var(--fgTransparentWeak);
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
</style>

View File

@@ -4,33 +4,50 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.main">
<span :class="$style.icon">
<MkFolder>
<template #label>{{ entity.name || entity.url }}</template>
<template v-if="entity.name != null && entity.name != ''" #caption>{{ entity.url }}</template>
<template #icon>
<i v-if="!entity.isActive" class="ti ti-player-pause"/>
<i v-else-if="entity.latestStatus === null" class="ti ti-circle"/>
<i
v-else-if="[200, 201, 204].includes(entity.latestStatus)"
class="ti ti-check"
:style="{ color: 'var(--success)' }"
:style="{ color: 'var(--MI_THEME-success)' }"
/>
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"/>
</span>
<span :class="$style.text">{{ entity.name || entity.url }}</span>
<span :class="$style.suffix">
<i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--MI_THEME-error)' }"/>
</template>
<template #suffix>
<MkTime v-if="entity.latestSentAt" :time="entity.latestSentAt" style="margin-right: 8px"/>
<button :class="$style.suffixButton" @click="onEditClick">
<i class="ti ti-settings"></i>
</button>
<button :class="$style.suffixButton" @click="onDeleteClick">
<i class="ti ti-trash"></i>
</button>
</span>
</div>
<span v-else>-</span>
</template>
<template #footer>
<div class="_buttons">
<MkButton @click="onEditClick">
<i class="ti ti-settings"></i> {{ i18n.ts.edit }}
</MkButton>
<MkButton danger @click="onDeleteClick">
<i class="ti ti-trash"></i> {{ i18n.ts.delete }}
</MkButton>
</div>
</template>
<div class="_gaps">
<MkKeyValue>
<template #key>latestStatus</template>
<template #value>{{ entity.latestStatus ?? '-' }}</template>
</MkKeyValue>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { entities } from 'misskey-js';
import { toRefs } from 'vue';
import MkFolder from '@/components/MkFolder.vue';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
const emit = defineEmits<{
(ev: 'edit', value: entities.SystemWebhook): void;
@@ -54,64 +71,10 @@ function onDeleteClick() {
</script>
<style module lang="scss">
.main {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
background: var(--buttonBg);
border: none;
border-radius: 6px;
font-size: 0.9em;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
}
}
.icon {
margin-right: 0.75em;
flex-shrink: 0;
text-align: center;
color: var(--fgTransparentWeak);
}
.text {
flex-shrink: 1;
white-space: normal;
padding-right: 12px;
text-align: center;
}
.suffix {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gaps: 4px;
margin-left: auto;
margin-right: -8px;
opacity: 0.7;
white-space: nowrap;
}
.suffixButton {
background: transparent;
border: none;
border-radius: 9999px;
margin-top: -8px;
margin-bottom: -8px;
padding: 8px;
&:hover {
background: var(--buttonBg);
}
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
</style>

View File

@@ -11,8 +11,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="900">
<div class="_gaps_m">
<MkButton :class="$style.linkButton" full @click="onCreateWebhookClicked">
{{ i18n.ts._webhookSettings.createWebhook }}
<MkButton primary @click="onCreateWebhookClicked">
<i class="ti ti-plus"></i> {{ i18n.ts._webhookSettings.createWebhook }}
</MkButton>
<FormSection>
@@ -30,11 +30,11 @@ import { computed, onMounted, ref } from 'vue';
import { entities } from 'misskey-js';
import XItem from './system-webhook.item.vue';
import FormSection from '@/components/form/section.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import XHeader from '@/pages/admin/_header_.vue';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import * as os from '@/os.js';
@@ -82,15 +82,12 @@ onMounted(async () => {
await fetchWebhooks();
});
definePageMetadata(() => ({
definePage(() => ({
title: 'SystemWebhook',
icon: 'ti ti-webhook',
}));
</script>
<style module lang="scss">
.linkButton {
text-align: left;
padding: 10px 18px;
}
</style>

View File

@@ -9,6 +9,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps">
<div :class="$style.inputs">
<MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton>
</div>
<div :class="$style.inputs">
<MkSelect v-model="sort" style="flex: 1;">
<template #label>{{ i18n.ts.sort }}</template>
@@ -33,11 +36,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSelect>
</div>
<div :class="$style.inputs">
<MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()">
<MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false">
<template #prefix>@</template>
<template #label>{{ i18n.ts.username }}</template>
</MkInput>
<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
<MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'">
<template #prefix>@</template>
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
@@ -57,25 +60,36 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, shallowRef, ref } from 'vue';
import { computed, useTemplateRef, ref, watchEffect } from 'vue';
import XHeader from './_header_.vue';
import { defaultMemoryStorage } from '@/memory-storage';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os.js';
import { lookupUser } from '@/scripts/admin-lookup.js';
import { lookupUser } from '@/utility/admin-lookup.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { dateString } from '@/filters/date.js';
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
type SearchQuery = {
sort?: string;
state?: string;
origin?: string;
username?: string;
hostname?: string;
};
const sort = ref('+createdAt');
const state = ref('all');
const origin = ref('local');
const searchUsername = ref('');
const searchHost = ref('');
const paginationComponent = useTemplateRef('paginationComponent');
const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery;
const sort = ref(storedQuery.sort ?? '+createdAt');
const state = ref(storedQuery.state ?? 'all');
const origin = ref(storedQuery.origin ?? 'local');
const searchUsername = ref(storedQuery.username ?? '');
const searchHost = ref(storedQuery.hostname ?? '');
const pagination = {
endpoint: 'admin/show-users' as const,
limit: 10,
@@ -99,19 +113,19 @@ async function addUser() {
const { canceled: canceled1, result: username } = await os.inputText({
title: i18n.ts.username,
});
if (canceled1) return;
if (canceled1 || username == null) return;
const { canceled: canceled2, result: password } = await os.inputText({
title: i18n.ts.password,
type: 'password',
});
if (canceled2) return;
if (canceled2 || password == null) return;
os.apiWithDialog('admin/accounts/create', {
username: username,
password: password,
}).then(res => {
paginationComponent.value.reload();
paginationComponent.value?.reload();
});
}
@@ -119,6 +133,14 @@ function show(user) {
os.pageWindow(`/admin/user/${user.id}`);
}
function resetQuery() {
sort.value = '+createdAt';
state.value = 'all';
origin.value = 'local';
searchUsername.value = '';
searchHost.value = '';
}
const headerActions = computed(() => [{
icon: 'ti ti-search',
text: i18n.ts.search,
@@ -137,7 +159,17 @@ const headerActions = computed(() => [{
const headerTabs = computed(() => []);
definePageMetadata(() => ({
watchEffect(() => {
defaultMemoryStorage.setItem('admin-users-query', JSON.stringify({
sort: sort.value,
state: state.value,
origin: origin.value,
username: searchUsername.value,
hostname: searchHost.value,
}));
});
definePage(() => ({
title: i18n.ts.users,
icon: 'ti ti-users',
}));

View File

@@ -4,23 +4,21 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<PageWithHeader>
<MkSpacer :contentMax="500">
<div class="_gaps">
<MkAd v-for="ad in instance.ads" :key="ad.id" :specify="ad"/>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.ads,
icon: 'ti ti-ad',
}));

View File

@@ -4,14 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800">
<Transition
:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
:enterActiveClass="prefer.s.animation ? $style.fadeEnterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.fadeLeaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.fadeEnterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.fadeLeaveTo : ''"
mode="out-in"
>
<div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement">
@@ -20,9 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
<span style="margin-right: 0.5em;">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</span>
<Mfm :text="announcement.title"/>
</div>
@@ -44,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-else/>
</Transition>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
@@ -52,11 +51,12 @@ import { ref, computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i, updateAccount } from '@/account.js';
import { defaultStore } from '@/store.js';
import { definePage } from '@/page.js';
import { $i } from '@/i.js';
import { prefer } from '@/preferences.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
const props = defineProps<{
announcementId: string;
@@ -90,7 +90,7 @@ async function read(target: Misskey.entities.Announcement): Promise<void> {
target.isRead = true;
await misskeyApi('i/read-announcement', { announcementId: target.id });
if ($i) {
updateAccount({
updateCurrentAccountPartial({
unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
});
}
@@ -102,8 +102,8 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements,
definePage(() => ({
title: announcement.value ? announcement.value.title : i18n.ts.announcements,
icon: 'ti ti-speakerphone',
}));
</script>

View File

@@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div :key="tab" class="_gaps">
<div class="_gaps">
<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo>
<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps">
<section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement">
@@ -17,9 +16,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
<span style="margin-right: 0.5em;">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</span>
<MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA>
</div>
@@ -43,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkHorizontalSwipe>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
@@ -53,10 +52,11 @@ import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { $i, updateAccount } from '@/account.js';
import { definePage } from '@/page.js';
import { $i } from '@/i.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
const paginationCurrent = {
endpoint: 'announcements' as const,
@@ -94,7 +94,7 @@ async function read(target) {
return a;
});
misskeyApi('i/read-announcement', { announcementId: target.id });
updateAccount({
updateCurrentAccountPartial({
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
});
}
@@ -111,7 +111,7 @@ const headerTabs = computed(() => [{
icon: 'ti ti-point',
}]);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.announcements,
icon: 'ti ti-speakerphone',
}));

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800">
<div ref="rootEl">
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
@@ -20,19 +19,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, watch, ref, shallowRef } from 'vue';
import { computed, watch, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { scrollInContainer } from '@@/js/scroll.js';
import MkTimeline from '@/components/MkTimeline.vue';
import { scroll } from '@/scripts/scroll.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const router = useRouter();
@@ -42,15 +41,15 @@ const props = defineProps<{
const antenna = ref<Misskey.entities.Antenna | null>(null);
const queue = ref(0);
const rootEl = shallowRef<HTMLElement>();
const tlEl = shallowRef<InstanceType<typeof MkTimeline>>();
const rootEl = useTemplateRef('rootEl');
const tlEl = useTemplateRef('tlEl');
function queueUpdated(q) {
queue.value = q;
}
function top() {
scroll(rootEl.value, { top: 0 });
scrollInContainer(rootEl.value, { top: 0 });
}
async function timetravel() {
@@ -88,7 +87,7 @@ const headerActions = computed(() => antenna.value ? [{
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: antenna.value ? antenna.value.name : i18n.ts.antennas,
icon: 'ti ti-antenna',
}));
@@ -97,26 +96,26 @@ definePageMetadata(() => ({
<style lang="scss" module>
.new {
position: sticky;
top: calc(var(--stickyTop, 0px) + 16px);
top: calc(var(--MI-stickyTop, 0px) + 16px);
z-index: 1000;
width: 100%;
margin: calc(-0.675em - 8px) 0;
&:first-child {
margin-top: calc(-0.675em - 8px - var(--margin));
margin-top: calc(-0.675em - 8px - var(--MI-margin));
}
}
.newButton {
display: block;
margin: var(--margin) auto 0 auto;
margin: var(--MI-margin) auto 0 auto;
padding: 8px 16px;
border-radius: 32px;
}
.tl {
background: var(--bg);
border-radius: var(--radius);
background: var(--MI_THEME-bg);
border-radius: var(--MI-radius);
overflow: clip;
}
</style>

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<div class="_gaps_m">
<div class="_gaps_m">
@@ -30,19 +29,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import JSON5 from 'json5';
import { Endpoints } from 'misskey-js';
import type { Endpoints } from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
const body = ref('{}');
const endpoint = ref('');
@@ -87,7 +86,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: 'API console',
icon: 'ti ti-terminal-2',
}));

View File

@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
@@ -38,7 +38,7 @@ const emit = defineEmits<{
const app = computed(() => props.session.app);
const name = computed(() => {
const el = document.createElement('div');
const el = window.document.createElement('div');
el.textContent = app.value.name;
return el.innerHTML;
});

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="500">
<div v-if="state == 'fetch-session-error'">
<p>{{ i18n.ts.somethingHappened }}</p>
@@ -38,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSignin @login="onLogin"/>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
@@ -46,10 +45,11 @@ import { onMounted, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import XForm from './auth.form.vue';
import MkSignin from '@/components/MkSignin.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { $i, login } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { $i } from '@/i.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { login } from '@/accounts.js';
const props = defineProps<{
token: string;
@@ -62,8 +62,8 @@ function accepted() {
state.value = 'accepted';
if (session.value && session.value.app.callbackUrl) {
const url = new URL(session.value.app.callbackUrl);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url');
location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`;
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(url.protocol)) throw new Error('invalid url');
window.location.href = `${session.value.app.callbackUrl}?token=${session.value.token}`;
}
}
@@ -97,7 +97,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts._auth.shareAccessTitle,
icon: 'ti ti-apps',
}));

View File

@@ -0,0 +1,220 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkWindow
ref="windowEl"
:initialWidth="400"
:initialHeight="500"
:canResize="true"
@close="windowEl?.close()"
@closed="emit('closed')"
>
<template v-if="avatarDecoration" #header>{{ avatarDecoration.name }}</template>
<template v-else #header>New decoration</template>
<div style="display: flex; flex-direction: column; min-height: 100%;">
<MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
<div class="_gaps_m">
<div :class="$style.preview">
<div :class="[$style.previewItem, $style.light]">
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="url != '' ? [{ url }] : []" forceShowDecoration/>
</div>
<div :class="[$style.previewItem, $style.dark]">
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="url != '' ? [{ url }] : []" forceShowDecoration/>
</div>
</div>
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkInput v-model="url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<MkTextarea v-model="description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkFolder>
<template #label>{{ i18n.ts.availableRoles }}</template>
<template #suffix>{{ rolesThatCanBeUsedThisDecoration.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisDecoration.length }}</template>
<div class="_gaps">
<MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<div v-for="role in rolesThatCanBeUsedThisDecoration" :key="role.id" :class="$style.roleItem">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
</div>
</MkFolder>
<MkButton v-if="avatarDecoration" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</MkSpacer>
<div :class="$style.footer">
<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.avatarDecoration ? i18n.ts.update : i18n.ts.create }}</MkButton>
</div>
</div>
</MkWindow>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkWindow from '@/components/MkWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRolePreview from '@/components/MkRolePreview.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const props = defineProps<{
avatarDecoration?: any,
}>();
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
const url = ref<string>(props.avatarDecoration ? props.avatarDecoration.url : '');
const name = ref<string>(props.avatarDecoration ? props.avatarDecoration.name : '');
const description = ref<string>(props.avatarDecoration ? props.avatarDecoration.description : '');
const roleIdsThatCanBeUsedThisDecoration = ref(props.avatarDecoration ? props.avatarDecoration.roleIdsThatCanBeUsedThisDecoration : []);
const rolesThatCanBeUsedThisDecoration = ref<Misskey.entities.Role[]>([]);
watch(roleIdsThatCanBeUsedThisDecoration, async () => {
rolesThatCanBeUsedThisDecoration.value = (await Promise.all(roleIdsThatCanBeUsedThisDecoration.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
async function addRole() {
const roles = await misskeyApi('admin/roles/list');
const currentRoleIds = rolesThatCanBeUsedThisDecoration.value.map(x => x.id);
const { canceled, result: role } = await os.select({
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
});
if (canceled || role == null) return;
rolesThatCanBeUsedThisDecoration.value.push(role);
}
async function removeRole(role, ev) {
rolesThatCanBeUsedThisDecoration.value = rolesThatCanBeUsedThisDecoration.value.filter(x => x.id !== role.id);
}
async function done() {
const params = {
url: url.value,
name: name.value,
description: description.value,
roleIdsThatCanBeUsedThisDecoration: rolesThatCanBeUsedThisDecoration.value.map(x => x.id),
};
if (props.avatarDecoration) {
await os.apiWithDialog('admin/avatar-decorations/update', {
id: props.avatarDecoration.id,
...params,
});
emit('done', {
updated: {
id: props.avatarDecoration.id,
...params,
},
});
windowEl.value?.close();
} else {
const created = await os.apiWithDialog('admin/avatar-decorations/create', params);
emit('done', {
created: created,
});
windowEl.value?.close();
}
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.removeAreYouSure({ x: name.value }),
});
if (canceled) return;
misskeyApi('admin/avatar-decorations/delete', {
id: props.avatarDecoration.id,
}).then(() => {
emit('done', {
deleted: true,
});
windowEl.value?.close();
});
}
</script>
<style lang="scss" module>
.preview {
display: grid;
place-items: center;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
gap: var(--MI-margin);
}
.previewItem {
width: 100%;
height: 100%;
min-height: 160px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--MI-radius);
&.light {
background: #eee;
}
&.dark {
background: #222;
}
}
.roleItem {
display: flex;
}
.role {
flex: 1;
}
.roleUnassign {
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
}
.footer {
position: sticky;
z-index: 10000;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--MI_THEME-divider);
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
</style>

View File

@@ -4,79 +4,39 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="900">
<div class="_gaps">
<MkFolder v-for="avatarDecoration in avatarDecorations" :key="avatarDecoration.id ?? avatarDecoration._id" :defaultOpen="avatarDecoration.id == null">
<template #label>{{ avatarDecoration.name }}</template>
<template #caption>{{ avatarDecoration.description }}</template>
<div class="_gaps_m">
<MkInput v-model="avatarDecoration.name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="avatarDecoration.description">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkInput v-model="avatarDecoration.url">
<template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput>
<div class="buttons _buttons">
<MkButton class="button" inline primary @click="save(avatarDecoration)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="avatarDecoration.id != null" class="button" inline danger @click="del(avatarDecoration)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
<div :class="$style.decorations">
<div
v-for="avatarDecoration in avatarDecorations"
:key="avatarDecoration.id"
v-panel
:class="$style.decoration"
@click="edit(avatarDecoration)"
>
<div :class="$style.decorationName"><MkCondensedLine :minScale="0.5">{{ avatarDecoration.name }}</MkCondensedLine></div>
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decorations="[{ url: avatarDecoration.url }]" forceShowDecoration/>
</div>
</MkFolder>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { ref, computed, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { ensureSignin } from '@/i.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkFolder from '@/components/MkFolder.vue';
import { definePage } from '@/page.js';
const $i = ensureSignin();
const avatarDecorations = ref<Misskey.entities.AdminAvatarDecorationsListResponse>([]);
function add() {
avatarDecorations.value.unshift({
_id: Math.random().toString(36),
id: null,
name: '',
description: '',
url: '',
});
}
function del(avatarDecoration) {
os.confirm({
type: 'warning',
text: i18n.tsx.deleteAreYouSure({ x: avatarDecoration.name }),
}).then(({ canceled }) => {
if (canceled) return;
avatarDecorations.value = avatarDecorations.value.filter(x => x !== avatarDecoration);
misskeyApi('admin/avatar-decorations/delete', avatarDecoration);
});
}
async function save(avatarDecoration) {
if (avatarDecoration.id == null) {
await os.apiWithDialog('admin/avatar-decorations/create', avatarDecoration);
load();
} else {
os.apiWithDialog('admin/avatar-decorations/update', avatarDecoration);
}
}
function load() {
misskeyApi('admin/avatar-decorations/list').then(_avatarDecorations => {
avatarDecorations.value = _avatarDecorations;
@@ -85,6 +45,37 @@ function load() {
load();
async function add(ev: MouseEvent) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
}, {
done: result => {
if (result.created) {
avatarDecorations.value.unshift(result.created);
}
},
closed: () => dispose(),
});
}
function edit(avatarDecoration) {
const { dispose } = os.popup(defineAsyncComponent(() => import('./avatar-decoration-edit-dialog.vue')), {
avatarDecoration: avatarDecoration,
}, {
done: result => {
if (result.updated) {
const index = avatarDecorations.value.findIndex(x => x.id === avatarDecoration.id);
avatarDecorations.value[index] = {
...avatarDecorations.value[index],
...result.updated,
};
} else if (result.deleted) {
avatarDecorations.value = avatarDecorations.value.filter(x => x.id !== avatarDecoration.id);
}
},
closed: () => dispose(),
});
}
const headerActions = computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
@@ -94,8 +85,33 @@ const headerActions = computed(() => [{
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.avatarDecorations,
icon: 'ti ti-sparkles',
}));
</script>
<style lang="scss" module>
.decorations {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-gap: 12px;
}
.decoration {
cursor: pointer;
padding: 16px 16px 28px 16px;
border-radius: 8px;
text-align: center;
font-size: 90%;
overflow: clip;
contain: content;
}
.decorationName {
position: relative;
z-index: 10;
font-weight: bold;
margin-bottom: 20px;
}
</style>

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<div v-if="channelId == null || channel != null" class="_gaps_m">
<MkInput v-model="name">
@@ -65,7 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
@@ -74,15 +73,15 @@ import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkColorInput from '@/components/MkColorInput.vue';
import { selectFile } from '@/scripts/select-file.js';
import { selectFile } from '@/utility/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -202,7 +201,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
definePage(() => ({
title: props.channelId ? i18n.ts._channel.edit : i18n.ts._channel.create,
icon: 'ti ti-device-tv',
}));
@@ -216,7 +215,7 @@ definePageMetadata(() => ({
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
color: var(--navFg);
color: var(--MI_THEME-navFg);
}
.pinnedNoteRemove {

View File

@@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :class="$style.main">
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div v-if="channel && tab === 'overview'" key="overview" class="_gaps">
<div v-if="channel && tab === 'overview'" class="_gaps">
<div class="_panel" :class="$style.bannerContainer">
<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
<MkButton v-if="favorited" v-tooltip="i18n.ts.unfavorite" asLike class="button" rounded primary :class="$style.favorite" @click="unfavorite()"><i class="ti ti-star"></i></MkButton>
@@ -33,19 +32,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFoldableSection>
</div>
<div v-if="channel && tab === 'timeline'" key="timeline" class="_gaps">
<div v-if="channel && tab === 'timeline'" class="_gaps">
<MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo>
<!-- スマホタブレットの場合キーボードが表示されると投稿が見づらくなるのでデスクトップ場合のみ自動でフォーカスを当てる -->
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkPostForm v-if="$i && prefer.r.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/>
<MkTimeline :key="channelId" src="channel" :channel="channelId" @before="before" @after="after" @note="miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.id}`, Date.now())"/>
</div>
<div v-else-if="tab === 'featured'" key="featured">
<div v-else-if="tab === 'featured'">
<MkNotes :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'" key="search">
<div class="_gaps">
<div v-else-if="tab === 'search'">
<div v-if="notesSearchAvailable" class="_gaps">
<div>
<MkInput v-model="searchQuery" @enter="search()">
<template #prefix><i class="ti ti-search"></i></template>
@@ -54,6 +53,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>
@@ -66,36 +68,37 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSpacer>
</div>
</template>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import type { PageHeaderItem } from '@/types/page-header.js';
import MkPostForm from '@/components/MkPostForm.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import XChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { $i, iAmModerator } from '@/account.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { $i, iAmModerator } from '@/i.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { definePage } from '@/page.js';
import { deviceKind } from '@/utility/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config.js';
import { favoritedChannelsCache } from '@/cache.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import { defaultStore } from '@/store.js';
import { prefer } from '@/preferences.js';
import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
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 { isSupportShare } from '@/utility/navigator.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { notesSearchAvailable } from '@/utility/check-permissions.js';
import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const router = useRouter();
@@ -206,7 +209,6 @@ const headerActions = computed(() => {
return;
}
copyToClipboard(`${url}/channels/${channel.value.id}`);
os.success();
},
});
@@ -261,22 +263,18 @@ const headerTabs = computed(() => [{
icon: 'ti ti-search',
}]);
definePageMetadata(() => ({
definePage(() => ({
title: channel.value ? channel.value.name : i18n.ts.channel,
icon: 'ti ti-device-tv',
}));
</script>
<style lang="scss" module>
.main {
min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
}
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
background: var(--acrylicBg);
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
border-top: solid 0.5px var(--MI_THEME-divider);
}
.bannerContainer {
@@ -310,7 +308,7 @@ definePageMetadata(() => ({
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0));
}
.bannerStatus {
@@ -335,7 +333,7 @@ definePageMetadata(() => ({
bottom: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.7);
color: var(--warn);
color: var(--MI_THEME-warn);
border-radius: 6px;
font-weight: bold;
font-size: 1em;

View File

@@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700">
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="1200">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'search'" key="search">
<div v-if="tab === '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>
@@ -25,30 +24,38 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkChannelList :key="key" :pagination="channelPagination"/>
</MkFoldableSection>
</div>
<div v-if="tab === 'featured'" key="featured">
<div v-if="tab === '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">
<div v-else-if="tab === '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">
<div v-else-if="tab === '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">
<div v-else-if="tab === '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>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
@@ -61,9 +68,9 @@ import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router/supplier.js';
import { useRouter } from '@/router.js';
const router = useRouter();
@@ -85,6 +92,7 @@ onMounted(() => {
const featuredPagination = {
endpoint: 'channels/featured' as const,
limit: 10,
noPaging: true,
};
const favoritesPagination = {
@@ -152,8 +160,22 @@ const headerTabs = computed(() => [{
icon: 'ti ti-edit',
}]);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.channel,
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>

View File

@@ -0,0 +1,325 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[$style.root, { [$style.isMe]: isMe }]">
<MkAvatar :class="$style.avatar" :user="message.fromUser!" :link="!isMe" :preview="false"/>
<div :class="$style.body" @contextmenu.stop="onContextmenu">
<div :class="$style.header"><MkUserName v-if="!isMe && prefer.s['chat.showSenderName'] && message.fromUser != null" :user="message.fromUser"/></div>
<MkFukidashi :class="$style.fukidashi" :tail="isMe ? 'right' : 'left'" :accented="isMe">
<Mfm
v-if="message.text"
ref="text"
class="_selectable"
:text="message.text"
:i="$i"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<div :class="$style.footer">
<button class="_textButton" style="color: currentColor;" @click="showMenu"><i class="ti ti-dots-circle-horizontal"></i></button>
<MkTime :class="$style.time" :time="message.createdAt"/>
<MkA v-if="isSearchResult && 'toRoom' in message && message.toRoom != null" :to="`/chat/room/${message.toRoomId}`">{{ message.toRoom.name }}</MkA>
<MkA v-if="isSearchResult && 'toUser' in message && message.toUser != null && isMe" :to="`/chat/user/${message.toUserId}`">@{{ message.toUser.username }}</MkA>
</div>
<TransitionGroup
:enterActiveClass="prefer.s.animation ? $style.transition_reaction_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_reaction_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_reaction_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_reaction_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_reaction_move : ''"
tag="div" :class="$style.reactions"
>
<div v-for="record in message.reactions" :key="record.reaction + record.user.id" :class="[$style.reaction, record.user.id === $i.id ? $style.reactionMy : null]" @click="onReactionClick(record)">
<MkAvatar :user="record.user" :link="false" :class="$style.reactionAvatar"/>
<MkReactionIcon
:withTooltip="true"
:reaction="record.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
:class="$style.reactionIcon"
/>
</div>
</TransitionGroup>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import { isLink } from '@@/js/is-link.js';
import type { MenuItem } from '@/types/menu.js';
import type { NormalizedChatMessage } from './room.vue';
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm.js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import MkFukidashi from '@/components/MkFukidashi.vue';
import * as os from '@/os.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import MkMediaList from '@/components/MkMediaList.vue';
import { reactionPicker } from '@/utility/reaction-picker.js';
import * as sound from '@/utility/sound.js';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js';
const $i = ensureSignin();
const props = defineProps<{
message: NormalizedChatMessage | Misskey.entities.ChatMessage;
isSearchResult?: boolean;
}>();
const isMe = computed(() => props.message.fromUserId === $i.id);
const urls = computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []);
provide(DI.mfmEmojiReactCallback, (reaction) => {
if ($i.policies.chatAvailability !== 'available') return;
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
messageId: props.message.id,
reaction: reaction,
});
});
function react(ev: MouseEvent) {
if ($i.policies.chatAvailability !== 'available') return;
const targetEl = getHTMLElementOrNull(ev.currentTarget ?? ev.target);
if (!targetEl) return;
reactionPicker.show(targetEl, null, async (reaction) => {
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
messageId: props.message.id,
reaction: reaction,
});
});
}
function onReactionClick(record: Misskey.entities.ChatMessage['reactions'][0]) {
if ($i.policies.chatAvailability !== 'available') return;
if (record.user.id === $i.id) {
misskeyApi('chat/messages/unreact', {
messageId: props.message.id,
reaction: record.reaction,
});
} else {
if (!props.message.reactions.some(r => r.user.id === $i.id && r.reaction === record.reaction)) {
sound.playMisskeySfx('reaction');
misskeyApi('chat/messages/react', {
messageId: props.message.id,
reaction: record.reaction,
});
}
}
}
function onContextmenu(ev: MouseEvent) {
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
showMenu(ev, true);
}
function showMenu(ev: MouseEvent, contextmenu = false) {
const menu: MenuItem[] = [];
if (!isMe.value && $i.policies.chatAvailability === 'available') {
menu.push({
text: i18n.ts.reaction,
icon: 'ti ti-mood-plus',
action: (ev) => {
react(ev);
},
});
menu.push({
type: 'divider',
});
}
menu.push({
text: i18n.ts.copyContent,
icon: 'ti ti-copy',
action: () => {
copyToClipboard(props.message.text ?? '');
},
});
menu.push({
type: 'divider',
});
if (isMe.value && $i.policies.chatAvailability === 'available') {
menu.push({
text: i18n.ts.delete,
icon: 'ti ti-trash',
danger: true,
action: () => {
misskeyApi('chat/messages/delete', {
messageId: props.message.id,
});
},
});
}
if (!isMe.value && props.message.fromUser != null) {
menu.push({
text: i18n.ts.reportAbuse,
icon: 'ti ti-exclamation-circle',
action: () => {
const localUrl = `${url}/chat/messages/${props.message.id}`;
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), {
user: props.message.fromUser!,
initialComment: `${localUrl}\n-----\n`,
}, {
closed: () => dispose(),
});
},
});
}
if (contextmenu) {
os.contextMenu(menu, ev);
} else {
os.popupMenu(menu, ev.currentTarget ?? ev.target);
}
}
</script>
<style lang="scss" module>
.transition_reaction_move,
.transition_reaction_enterActive,
.transition_reaction_leaveActive {
transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
}
.transition_reaction_enterFrom,
.transition_reaction_leaveTo {
opacity: 0;
transform: scale(0.7);
}
.transition_reaction_leaveActive {
position: absolute;
}
.root {
position: relative;
display: flex;
&.isMe {
flex-direction: row-reverse;
text-align: right;
.footer {
flex-direction: row-reverse;
}
}
}
.avatar {
position: sticky;
top: calc(16px + var(--MI-stickyTop, 0px));
display: block;
width: 50px;
height: 50px;
}
@container (max-width: 450px) {
.root {
&.isMe {
.avatar {
display: none;
}
}
}
.avatar {
width: 42px;
height: 42px;
}
.fukidashi {
font-size: 90%;
}
}
.body {
margin: 0 12px;
}
.header {
min-height: 4px; // fukidashiの位置調整も兼ねるため
font-size: 80%;
}
.fukidashi {
text-align: left;
}
.content {
overflow: clip;
overflow-wrap: break-word;
word-break: break-word;
}
.footer {
display: flex;
flex-direction: row;
gap: 0.5em;
margin-top: 4px;
font-size: 75%;
}
.time {
opacity: 0.5;
}
.reactions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
&:empty {
display: none;
}
}
.reaction {
display: flex;
align-items: center;
border: solid 1px var(--MI_THEME-divider);
border-radius: 999px;
padding: 8px;
&.reactionMy {
border-color: var(--MI_THEME-accent);
}
}
.reactionAvatar {
width: 24px;
height: 24px;
margin-right: 8px;
}
.reactionIcon {
width: 24px;
height: 24px;
}
</style>

View File

@@ -0,0 +1,41 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkA :to="`/chat/room/${room.id}`" class="_panel _gaps_s" :class="$style.root">
<div :class="$style.header">
<div style="font-weight: bold;">{{ room.name }}</div>
<MkAvatar :user="room.owner" :link="false" :class="$style.headerAvatar"/>
</div>
<hr>
<div>{{ room.description }}</div>
</MkA>
</template>
<script lang="ts" setup>
import * as Misskey from 'misskey-js';
const props = defineProps<{
room: Misskey.entities.ChatRoom;
}>();
</script>
<style lang="scss" module>
.root {
padding: 16px;
}
.header {
display: flex;
align-items: center;
}
.headerAvatar {
width: 30px;
height: 30px;
margin-left: auto;
}
</style>

View File

@@ -0,0 +1,286 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkButton v-if="$i.policies.chatAvailability === 'available'" primary gradate rounded :class="$style.start" @click="start"><i class="ti ti-plus"></i> {{ i18n.ts.startChat }}</MkButton>
<MkInfo v-else>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
<MkInput
v-model="searchQuery"
:placeholder="i18n.ts._chat.searchMessages"
type="search"
>
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkButton v-if="searchQuery.length > 0" primary rounded @click="search">{{ i18n.ts.search }}</MkButton>
<MkFoldableSection v-if="searched">
<template #header>{{ i18n.ts.searchResult }}</template>
<div class="_gaps_s">
<div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem">
<XMessage :message="message" :isSearchResult="true"/>
</div>
</div>
</MkFoldableSection>
<MkFoldableSection>
<template #header>{{ i18n.ts._chat.history }}</template>
<div v-if="history.length > 0" class="_gaps_s">
<MkA
v-for="item in history"
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/>
</MkFoldableSection>
</div>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import XMessage from './XMessage.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { ensureSignin } from '@/i.js';
import { useRouter } from '@/router.js';
import * as os from '@/os.js';
import { updateCurrentAccountPartial } from '@/accounts.js';
import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInfo from '@/components/MkInfo.vue';
const $i = ensureSignin();
const router = useRouter();
const initializing = ref(true);
const fetching = ref(false);
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
isMe: boolean;
}[]>([]);
const searchQuery = ref('');
const searched = ref(false);
const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
function start(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts._chat.individualChat,
caption: i18n.ts._chat.individualChat_description,
icon: 'ti ti-user',
action: () => { startUser(); },
}, { type: 'divider' }, {
type: 'parent',
text: i18n.ts._chat.roomChat,
caption: i18n.ts._chat.roomChat_description,
icon: 'ti ti-users-group',
children: [{
text: i18n.ts._chat.createRoom,
icon: 'ti ti-plus',
action: () => { createRoom(); },
}],
}], ev.currentTarget ?? ev.target);
}
async function startUser() {
// TODO: localOnly は連合に対応したら消す
os.selectUser({ localOnly: true }).then(user => {
router.push(`/chat/user/${user.id}`);
});
}
async function createRoom() {
const { canceled, result } = await os.inputText({
title: i18n.ts.name,
minLength: 1,
});
if (canceled) return;
const room = await misskeyApi('chat/rooms/create', {
name: result,
});
router.push(`/chat/room/${room.id}`);
}
async function search() {
const res = await misskeyApi('chat/messages/search', {
query: searchQuery.value,
});
searchResults.value = res;
searched.value = true;
}
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
message: m,
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
isMe: m.fromUserId === $i.id,
}));
fetching.value = false;
initializing.value = false;
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => {
fetchHistory();
});
</script>
<style lang="scss" module>
.start {
margin: 0 auto;
}
.message {
position: relative;
display: flex;
padding: 16px 24px;
&.isRead,
&.isMe {
opacity: 0.8;
}
&:not(.isMe):not(.isRead) {
&::before {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: var(--MI_THEME-accent);
}
}
}
.messageAvatar {
width: 50px;
height: 50px;
margin: 0 16px 0 0;
}
.messageBody {
flex: 1;
min-width: 0;
}
.messageHeader {
display: flex;
align-items: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: clip;
}
.messageHeaderName {
margin: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1em;
font-weight: bold;
}
.messageHeaderUsername {
margin: 0 8px;
}
.messageHeaderTime {
margin-left: auto;
}
.messageBodyText {
overflow: hidden;
overflow-wrap: break-word;
font-size: 1.1em;
}
.youSaid {
font-weight: bold;
margin-right: 0.5em;
}
.searchResultItem {
padding: 12px;
border: solid 1px var(--MI_THEME-divider);
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,93 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<div v-if="invitations.length > 0" class="_gaps_s">
<MkFolder v-for="invitation in invitations" :key="invitation.id" :defaultOpen="true">
<template #icon><i class="ti ti-users-group"></i></template>
<template #label>{{ invitation.room.name }}</template>
<template #suffix><MkTime :time="invitation.createdAt"/></template>
<template #footer>
<div class="_buttons">
<MkButton primary @click="join(invitation)"><i class="ti ti-plus"></i> {{ i18n.ts._chat.join }}</MkButton>
<MkButton danger @click="ignore(invitation)"><i class="ti ti-x"></i> {{ i18n.ts._chat.ignore }}</MkButton>
</div>
</template>
<div :class="$style.invitationBody">
<MkAvatar :user="invitation.room.owner" :class="$style.invitationBodyAvatar" link/>
<div style="flex: 1;" class="_gaps_s">
<MkUserName :user="invitation.room.owner"/>
<hr>
<div>{{ invitation.room.description === '' ? i18n.ts.noDescription : invitation.room.description }}</div>
</div>
</div>
</MkFolder>
</div>
<div v-if="!fetching && invitations.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noInvitations }}</div>
</div>
<MkLoading v-if="fetching"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useRouter } from '@/router.js';
import MkFolder from '@/components/MkFolder.vue';
const router = useRouter();
const fetching = ref(true);
const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]);
async function fetchInvitations() {
fetching.value = true;
const res = await misskeyApi('chat/rooms/invitations/inbox');
invitations.value = res;
fetching.value = false;
}
async function join(invitation: Misskey.entities.ChatRoomInvitation) {
await misskeyApi('chat/rooms/join', {
roomId: invitation.room.id,
});
router.push(`/chat/room/${invitation.room.id}`);
}
async function ignore(invitation: Misskey.entities.ChatRoomInvitation) {
await misskeyApi('chat/rooms/invitations/ignore', {
roomId: invitation.room.id,
});
invitations.value = invitations.value.filter(i => i.id !== invitation.id);
}
onMounted(() => {
fetchInvitations();
});
</script>
<style lang="scss" module>
.invitationBody {
display: flex;
align-items: center;
}
.invitationBodyAvatar {
margin-right: 12px;
width: 45px;
height: 45px;
}
</style>

View File

@@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<div v-if="memberships.length > 0" class="_gaps_s">
<XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/>
</div>
<div v-if="!fetching && memberships.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noRooms }}</div>
</div>
<MkLoading v-if="fetching"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import XRoom from './XRoom.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
const fetching = ref(true);
const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
async function fetchRooms() {
fetching.value = true;
const res = await misskeyApi('chat/rooms/joining');
memberships.value = res;
fetching.value = false;
}
onMounted(() => {
fetchRooms();
});
</script>
<style lang="scss" module>
</style>

View File

@@ -0,0 +1,46 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<div v-if="rooms.length > 0" class="_gaps_s">
<XRoom v-for="room in rooms" :key="room.id" :room="room"/>
</div>
<div v-if="!fetching && rooms.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noRooms }}</div>
</div>
<MkLoading v-if="fetching"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import XRoom from './XRoom.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
const fetching = ref(true);
const rooms = ref<Misskey.entities.ChatRoom[]>([]);
async function fetchRooms() {
fetching.value = true;
const res = await misskeyApi('chat/rooms/owned', {
});
rooms.value = res;
fetching.value = false;
}
onMounted(() => {
fetchRooms();
});
</script>
<style lang="scss" module>
</style>

View File

@@ -0,0 +1,60 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkPolkadots v-if="tab === 'home'" accented/>
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<XHome v-if="tab === 'home'"/>
<XInvitations v-else-if="tab === 'invitations'"/>
<XJoiningRooms v-else-if="tab === 'joiningRooms'"/>
<XOwnedRooms v-else-if="tab === 'ownedRooms'"/>
</MkHorizontalSwipe>
</MkSpacer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import XHome from './home.home.vue';
import XInvitations from './home.invitations.vue';
import XJoiningRooms from './home.joiningRooms.vue';
import XOwnedRooms from './home.ownedRooms.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkPolkadots from '@/components/MkPolkadots.vue';
const tab = ref('home');
const headerActions = computed(() => []);
const headerTabs = computed(() => [{
key: 'home',
title: i18n.ts._chat.home,
icon: 'ti ti-home',
}, {
key: 'invitations',
title: i18n.ts._chat.invitations,
icon: 'ti ti-ticket',
}, {
key: 'joiningRooms',
title: i18n.ts._chat.joiningRooms,
icon: 'ti ti-users-group',
}, {
key: 'ownedRooms',
title: i18n.ts._chat.yourRooms,
icon: 'ti ti-settings',
}]);
definePage(() => ({
title: i18n.ts.chat + ' (beta)',
icon: 'ti ti-messages',
}));
</script>
<style lang="scss" module>
</style>

View File

@@ -0,0 +1,51 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader>
<MkSpacer :contentMax="700">
<div v-if="initializing || message == null">
<MkLoading/>
</div>
<div v-else>
<XMessage :message="message" :isSearchResult="true"/>
</div>
</MkSpacer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import XMessage from './XMessage.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
const props = defineProps<{
messageId?: string;
}>();
const initializing = ref(true);
const message = ref<Misskey.entities.ChatMessage | null>();
async function initialize() {
initializing.value = true;
message.value = await misskeyApi('chat/messages/show', {
messageId: props.messageId,
});
initializing.value = false;
}
onMounted(() => {
initialize();
});
definePage({
title: i18n.ts.chat,
});
</script>

View File

@@ -0,0 +1,350 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
:class="$style.root"
@dragover.stop="onDragover"
@drop.stop="onDrop"
>
<textarea
ref="textareaEl"
v-model="text"
:class="$style.textarea"
class="_acrylic"
:placeholder="i18n.ts.inputMessageHere"
:readonly="textareaReadOnly"
@keydown="onKeydown"
@paste="onPaste"
></textarea>
<footer :class="$style.footer">
<div v-if="file" :class="$style.file" @click="file = null">{{ file.name }}</div>
<div :class="$style.buttons">
<button class="_button" :class="$style.button" @click="chooseFile"><i class="ti ti-photo-plus"></i></button>
<button class="_button" :class="$style.button" @click="insertEmoji"><i class="ti ti-mood-happy"></i></button>
<button class="_button" :class="[$style.button, $style.send]" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
<template v-if="!sending"><i class="ti ti-send"></i></template><template v-if="sending"><MkLoading :em="true"/></template>
</button>
</div>
</footer>
<input ref="fileEl" style="display: none;" type="file" @change="onChangeFile"/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, watch, ref, shallowRef, computed, nextTick, readonly, onBeforeUnmount } from 'vue';
import * as Misskey from 'misskey-js';
//import insertTextAtCursor from 'insert-text-at-cursor';
import { formatTimeString } from '@/utility/format-time-string.js';
import { selectFile } from '@/utility/select-file.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { uploadFile } from '@/utility/upload.js';
import { miLocalStorage } from '@/local-storage.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { prefer } from '@/preferences.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import { emojiPicker } from '@/utility/emoji-picker.js';
const props = defineProps<{
user?: Misskey.entities.UserDetailed | null;
room?: Misskey.entities.ChatRoom | null;
}>();
const textareaEl = shallowRef<HTMLTextAreaElement>();
const fileEl = shallowRef<HTMLInputElement>();
const text = ref<string>('');
const file = ref<Misskey.entities.DriveFile | null>(null);
const sending = ref(false);
const textareaReadOnly = ref(false);
let autocompleteInstance: Autocomplete | null = null;
const canSend = computed(() => (text.value != null && text.value !== '') || file.value != null);
function getDraftKey() {
return props.user ? 'user:' + props.user.id : 'room:' + props.room?.id;
}
watch([text, file], saveDraft);
async function onPaste(ev: ClipboardEvent) {
if (!ev.clipboardData) return;
const pastedFileName = 'yyyy-MM-dd HH-mm-ss [{{number}}]';
const clipboardData = ev.clipboardData;
const items = clipboardData.items;
if (items.length === 1) {
if (items[0].kind === 'file') {
const pastedFile = items[0].getAsFile();
if (!pastedFile) return;
const lio = pastedFile.name.lastIndexOf('.');
const ext = lio >= 0 ? pastedFile.name.slice(lio) : '';
const formatted = formatTimeString(new Date(pastedFile.lastModified), pastedFileName).replace(/{{number}}/g, '1') + ext;
if (formatted) upload(pastedFile, formatted);
}
} else {
if (items[0].kind === 'file') {
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached,
});
}
}
}
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
ev.preventDefault();
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
case 'copy':
case 'copyLink':
case 'copyMove':
ev.dataTransfer.dropEffect = 'copy';
break;
case 'linkMove':
case 'move':
ev.dataTransfer.dropEffect = 'move';
break;
default:
ev.dataTransfer.dropEffect = 'none';
break;
}
}
}
function onDrop(ev: DragEvent): void {
if (!ev.dataTransfer) return;
// ファイルだったら
if (ev.dataTransfer.files.length === 1) {
ev.preventDefault();
upload(ev.dataTransfer.files[0]);
return;
} else if (ev.dataTransfer.files.length > 1) {
ev.preventDefault();
os.alert({
type: 'error',
text: i18n.ts.onlyOneFileCanBeAttached,
});
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile !== '') {
file.value = JSON.parse(driveFile);
ev.preventDefault();
}
//#endregion
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Enter') {
if (prefer.s['chat.sendOnEnter']) {
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {
send();
}
} else {
if ((ev.ctrlKey || ev.metaKey)) {
send();
}
}
}
}
function chooseFile(ev: MouseEvent) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => {
file.value = selectedFile;
});
}
function onChangeFile() {
if (fileEl.value == null || fileEl.value.files == null) return;
if (fileEl.value.files[0]) upload(fileEl.value.files[0]);
}
function upload(fileToUpload: File, name?: string) {
uploadFile(fileToUpload, prefer.s.uploadFolder, name).then(res => {
file.value = res;
});
}
function send() {
if (!canSend.value) return;
sending.value = true;
if (props.user) {
misskeyApi('chat/messages/create-to-user', {
toUserId: props.user.id,
text: text.value ? text.value : undefined,
fileId: file.value ? file.value.id : undefined,
}).then(message => {
clear();
}).catch(err => {
console.error(err);
}).then(() => {
sending.value = false;
});
} else if (props.room) {
misskeyApi('chat/messages/create-to-room', {
toRoomId: props.room.id,
text: text.value ? text.value : undefined,
fileId: file.value ? file.value.id : undefined,
}).then(message => {
clear();
}).catch(err => {
console.error(err);
}).then(() => {
sending.value = false;
});
}
}
function clear() {
text.value = '';
file.value = null;
deleteDraft();
}
function saveDraft() {
const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
drafts[getDraftKey()] = {
updatedAt: new Date(),
data: {
text: text.value,
file: file.value,
},
};
miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
}
function deleteDraft() {
const drafts = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}');
delete drafts[getDraftKey()];
miLocalStorage.setItem('chatMessageDrafts', JSON.stringify(drafts));
}
async function insertEmoji(ev: MouseEvent) {
textareaReadOnly.value = true;
const target = ev.currentTarget ?? ev.target;
if (target == null) return;
// emojiPickerはダイアログが閉じずにtextareaとやりとりするので、
// focustrapをかけているとinsertTextAtCursorが効かない
// そのため、投稿フォームのテキストに直接注入する
// See: https://github.com/misskey-dev/misskey/pull/14282
// https://github.com/misskey-dev/misskey/issues/14274
let pos = textareaEl.value?.selectionStart ?? 0;
let posEnd = textareaEl.value?.selectionEnd ?? text.value.length;
emojiPicker.show(
target as HTMLElement,
emoji => {
const textBefore = text.value.substring(0, pos);
const textAfter = text.value.substring(posEnd);
text.value = textBefore + emoji + textAfter;
pos += emoji.length;
posEnd += emoji.length;
},
() => {
textareaReadOnly.value = false;
nextTick(() => focus());
},
);
}
onMounted(() => {
if (textareaEl.value != null) {
autocompleteInstance = new Autocomplete(textareaEl.value, text);
}
// 書きかけの投稿を復元
const draft = JSON.parse(miLocalStorage.getItem('chatMessageDrafts') || '{}')[getDraftKey()];
if (draft) {
text.value = draft.data.text;
file.value = draft.data.file;
}
});
onBeforeUnmount(() => {
if (autocompleteInstance) {
autocompleteInstance.detach();
autocompleteInstance = null;
}
});
</script>
<style lang="scss" module>
.root {
position: relative;
border-bottom: none;
border-radius: 14px 14px 0 0;
overflow: clip;
}
.textarea {
cursor: auto;
display: block;
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 80px;
margin: 0;
padding: 16px 16px 0 16px;
resize: none;
font-size: 1em;
font-family: inherit;
outline: none;
border: none;
border-radius: 0;
box-shadow: none;
box-sizing: border-box;
color: var(--MI_THEME-fg);
field-sizing: content;
}
.footer {
position: sticky;
bottom: 0;
background: var(--MI_THEME-panel);
}
.file {
padding: 8px;
cursor: pointer;
}
.buttons {
display: flex;
}
.button {
height: 50px;
aspect-ratio: 1;
&:hover {
color: var(--MI_THEME-accent);
}
}
.send {
margin-left: auto;
color: var(--MI_THEME-accent);
}
</style>

View File

@@ -0,0 +1,99 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkInput v-model="name_" :disabled="!isOwner">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkTextarea v-model="description_" :disabled="!isOwner">
<template #label>{{ i18n.ts.description }}</template>
</MkTextarea>
<MkButton v-if="isOwner" primary @click="save">{{ i18n.ts.save }}</MkButton>
<hr>
<MkButton v-if="isOwner || ($i.isAdmin || $i.isModerator)" danger @click="del">{{ i18n.ts._chat.deleteRoom }}</MkButton>
<MkSwitch v-if="!isOwner" v-model="isMuted">
<template #label>{{ i18n.ts._chat.muteThisRoom }}</template>
</MkSwitch>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { useRouter } from '@/router.js';
const router = useRouter();
const $i = ensureSignin();
const props = defineProps<{
room: Misskey.entities.ChatRoom;
}>();
const isOwner = computed(() => {
return props.room.ownerId === $i.id;
});
const name_ = ref(props.room.name);
const description_ = ref(props.room.description);
function save() {
os.apiWithDialog('chat/rooms/update', {
roomId: props.room.id,
name: name_.value,
description: description_.value,
});
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.deleteAreYouSure({ x: name_.value }),
});
if (canceled) return;
await os.apiWithDialog('chat/rooms/delete', {
roomId: props.room.id,
});
router.push('/chat');
}
const isMuted = ref(props.room.isMuted ?? false);
watch(isMuted, async () => {
await os.apiWithDialog('chat/rooms/mute', {
roomId: props.room.id,
mute: isMuted.value,
});
});
</script>
<style lang="scss" module>
.membership {
display: flex;
}
.membershipBody {
flex: 1;
min-width: 0;
margin-right: 8px;
&:hover {
text-decoration: none;
}
}
</style>

View File

@@ -0,0 +1,98 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkButton v-if="isOwner" primary rounded style="margin: 0 auto;" @click="emit('inviteUser')"><i class="ti ti-plus"></i> {{ i18n.ts._chat.inviteUser }}</MkButton>
<MkA :class="$style.membershipBody" :to="`${userPage(room.owner)}`">
<MkUserCardMini :user="room.owner"/>
</MkA>
<hr v-if="memberships.length > 0">
<div v-for="membership in memberships" :key="membership.id" :class="$style.membership">
<MkA :class="$style.membershipBody" :to="`${userPage(membership.user!)}`">
<MkUserCardMini :user="membership.user!"/>
</MkA>
</div>
<template v-if="isOwner">
<hr>
<div>{{ i18n.ts._chat.sentInvitations }}</div>
<div v-for="invitation in invitations" :key="invitation.id" :class="$style.invitation">
<MkA :class="$style.invitationBody" :to="`${userPage(invitation.user)}`">
<MkUserCardMini :user="invitation.user"/>
</MkA>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { userPage } from '@/filters/user.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const props = defineProps<{
room: Misskey.entities.ChatRoom;
}>();
const emit = defineEmits<{
(ev: 'inviteUser'): void,
}>();
const isOwner = computed(() => {
return props.room.ownerId === $i.id;
});
const memberships = ref<Misskey.entities.ChatRoomMembership[]>([]);
const invitations = ref<Misskey.entities.ChatRoomInvitation[]>([]);
onMounted(async () => {
memberships.value = await misskeyApi('chat/rooms/members', {
roomId: props.room.id,
limit: 50,
});
if (isOwner.value) {
invitations.value = await misskeyApi('chat/rooms/invitations/outbox', {
roomId: props.room.id,
limit: 50,
});
}
});
</script>
<style lang="scss" module>
.membership {
display: flex;
}
.membershipBody {
flex: 1;
min-width: 0;
margin-right: 8px;
}
.invitation {
display: flex;
}
.invitationBody {
flex: 1;
min-width: 0;
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,73 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkInput
v-model="searchQuery"
:placeholder="i18n.ts._chat.searchMessages"
type="search"
@enter="search()"
>
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkButton primary rounded @click="search">{{ i18n.ts.search }}</MkButton>
<MkFoldableSection v-if="searched">
<template #header>{{ i18n.ts.searchResult }}</template>
<div v-if="searchResults.length > 0" class="_gaps_s">
<div v-for="message in searchResults" :key="message.id" :class="$style.searchResultItem">
<XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/>
</div>
</div>
<div v-else class="_fullinfo">
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.notFound }}</div>
</div>
</MkFoldableSection>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import XMessage from './XMessage.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
const props = defineProps<{
userId?: string;
roomId?: string;
}>();
const searchQuery = ref('');
const searched = ref(false);
const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
async function search() {
const res = await misskeyApi('chat/messages/search', {
query: searchQuery.value,
roomId: props.roomId,
userId: props.userId,
});
searchResults.value = res;
searched.value = true;
}
</script>
<style lang="scss" module>
.searchResultItem {
padding: 12px;
border: solid 1px var(--MI_THEME-divider);
border-radius: 12px;
}
</style>

View File

@@ -0,0 +1,517 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader v-model:tab="tab" :reversed="tab === 'chat'" :tabs="headerTabs" :actions="headerActions">
<MkSpacer v-if="tab === 'chat'" :contentMax="700">
<div class="_gaps">
<div v-if="initializing">
<MkLoading/>
</div>
<div v-else-if="messages.length === 0">
<div class="_gaps" style="text-align: center;">
<div>{{ i18n.ts._chat.noMessagesYet }}</div>
<template v-if="user">
<div v-if="user.chatScope === 'followers'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowers }}</div>
<div v-else-if="user.chatScope === 'following'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromFollowing }}</div>
<div v-else-if="user.chatScope === 'mutual'">{{ i18n.ts._chat.thisUserAllowsChatOnlyFromMutualFollowing }}</div>
<div v-else-if="user.chatScope === 'none'">{{ i18n.ts._chat.thisUserNotAllowedChatAnyone }}</div>
</template>
<template v-else-if="room">
<div>{{ i18n.ts._chat.inviteUserToChat }}</div>
</template>
</div>
</div>
<div v-else ref="timelineEl" class="_gaps">
<div v-if="canFetchMore">
<MkButton :class="$style.more" :wait="moreFetching" primary rounded @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton>
</div>
<TransitionGroup
:enterActiveClass="prefer.s.animation ? $style.transition_x_enterActive : ''"
:leaveActiveClass="prefer.s.animation ? $style.transition_x_leaveActive : ''"
:enterFromClass="prefer.s.animation ? $style.transition_x_enterFrom : ''"
:leaveToClass="prefer.s.animation ? $style.transition_x_leaveTo : ''"
:moveClass="prefer.s.animation ? $style.transition_x_move : ''"
tag="div" class="_gaps"
>
<template v-for="item in timeline.toReversed()" :key="item.id">
<XMessage v-if="item.type === 'item'" :message="item.data"/>
<div v-else-if="item.type === 'date'" :class="$style.dateDivider">
<span><i class="ti ti-chevron-up"></i> {{ item.nextText }}</span>
<span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
<span>{{ item.prevText }} <i class="ti ti-chevron-down"></i></span>
</div>
</template>
</TransitionGroup>
</div>
<div v-if="user && (!user.canChat || user.host !== null)">
<MkInfo warn>{{ i18n.ts._chat.chatNotAvailableInOtherAccount }}</MkInfo>
</div>
<MkInfo v-if="$i.policies.chatAvailability !== 'available'" warn>{{ $i.policies.chatAvailability === 'readonly' ? i18n.ts._chat.chatIsReadOnlyForThisAccountOrServer : i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
</div>
</MkSpacer>
<MkSpacer v-else-if="tab === 'search'" :contentMax="700">
<XSearch :userId="userId" :roomId="roomId"/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'members'" :contentMax="700">
<XMembers v-if="room != null" :room="room" @inviteUser="inviteUser"/>
</MkSpacer>
<MkSpacer v-else-if="tab === 'info'" :contentMax="700">
<XInfo v-if="room != null" :room="room"/>
</MkSpacer>
<template #footer>
<div v-if="tab === 'chat'" :class="$style.footer">
<div class="_gaps">
<Transition name="fade">
<div v-show="showIndicator" :class="$style.new">
<button class="_buttonPrimary" :class="$style.newButton" @click="onIndicatorClick">
<i class="fas ti-fw fa-arrow-circle-down" :class="$style.newIcon"></i>{{ i18n.ts._chat.newMessage }}
</button>
</div>
</Transition>
<XForm v-if="!initializing" :user="user" :room="room" :class="$style.form"/>
</div>
</div>
</template>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef, computed, onMounted, onBeforeUnmount, onDeactivated, onActivated } from 'vue';
import * as Misskey from 'misskey-js';
import { getScrollContainer } from '@@/js/scroll.js';
import XMessage from './XMessage.vue';
import XForm from './room.form.vue';
import XSearch from './room.search.vue';
import XMembers from './room.members.vue';
import XInfo from './room.info.vue';
import type { MenuItem } from '@/types/menu.js';
import type { PageHeaderItem } from '@/types/page-header.js';
import * as os from '@/os.js';
import { useStream } from '@/stream.js';
import * as sound from '@/utility/sound.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js';
import MkButton from '@/components/MkButton.vue';
import { useRouter } from '@/router.js';
import { useMutationObserver } from '@/use/use-mutation-observer.js';
import MkInfo from '@/components/MkInfo.vue';
import { makeDateSeparatedTimelineComputedRef } from '@/utility/timeline-date-separate.js';
const $i = ensureSignin();
const router = useRouter();
const props = defineProps<{
userId?: string;
roomId?: string;
}>();
export type NormalizedChatMessage = Omit<Misskey.entities.ChatMessageLite, 'fromUser' | 'reactions'> & {
fromUser: Misskey.entities.UserLite;
reactions: (Misskey.entities.ChatMessageLite['reactions'][number] & {
user: Misskey.entities.UserLite;
})[];
};
const initializing = ref(true);
const moreFetching = ref(false);
const messages = ref<NormalizedChatMessage[]>([]);
const canFetchMore = ref(false);
const user = ref<Misskey.entities.UserDetailed | null>(null);
const room = ref<Misskey.entities.ChatRoom | null>(null);
const connection = ref<Misskey.IChannelConnection<Misskey.Channels['chatUser']> | Misskey.IChannelConnection<Misskey.Channels['chatRoom']> | null>(null);
const showIndicator = ref(false);
const timelineEl = useTemplateRef('timelineEl');
const timeline = makeDateSeparatedTimelineComputedRef(messages);
const SCROLL_HEAD_THRESHOLD = 200;
// column-reverseなので本来はスクロール位置の最下部への追従は不要なはずだが、おそらくブラウザのバグにより、最下部にスクロールした状態でも追従されない場合がある(スクロール位置が少数になることがあるのが関わっていそう)
// そのため補助としてMutationObserverを使って追従を行う
useMutationObserver(timelineEl, {
subtree: true,
childList: true,
attributes: false,
}, () => {
const scrollContainer = getScrollContainer(timelineEl.value)!;
// column-reverseなのでscrollTopは負になる
if (-scrollContainer.scrollTop < SCROLL_HEAD_THRESHOLD) {
scrollContainer.scrollTo({
top: 0,
behavior: 'instant',
});
}
});
function normalizeMessage(message: Misskey.entities.ChatMessageLite | Misskey.entities.ChatMessage): NormalizedChatMessage {
return {
...message,
fromUser: message.fromUser ?? (message.fromUserId === $i.id ? $i : user.value!),
reactions: message.reactions.map(record => ({
...record,
user: record.user ?? (message.fromUserId === $i.id ? user.value! : $i),
})),
};
}
async function initialize() {
const LIMIT = 20;
initializing.value = true;
if (props.userId) {
const [u, m] = await Promise.all([
misskeyApi('users/show', { userId: props.userId }),
misskeyApi('chat/messages/user-timeline', { userId: props.userId, limit: LIMIT }),
]);
user.value = u;
messages.value = m.map(x => normalizeMessage(x));
if (messages.value.length === LIMIT) {
canFetchMore.value = true;
}
connection.value = useStream().useChannel('chatUser', {
otherId: user.value.id,
});
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
connection.value.on('react', onReact);
connection.value.on('unreact', onUnreact);
} else {
const [r, m] = await Promise.all([
misskeyApi('chat/rooms/show', { roomId: props.roomId }),
misskeyApi('chat/messages/room-timeline', { roomId: props.roomId, limit: LIMIT }),
]);
room.value = r as Misskey.entities.ChatRoomsShowResponse;
messages.value = (m as Misskey.entities.ChatMessagesRoomTimelineResponse).map(x => normalizeMessage(x));
if (messages.value.length === LIMIT) {
canFetchMore.value = true;
}
connection.value = useStream().useChannel('chatRoom', {
roomId: room.value.id,
});
connection.value.on('message', onMessage);
connection.value.on('deleted', onDeleted);
connection.value.on('react', onReact);
connection.value.on('unreact', onUnreact);
}
window.document.addEventListener('visibilitychange', onVisibilitychange);
initializing.value = false;
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
async function fetchMore() {
const LIMIT = 30;
moreFetching.value = true;
const newMessages = props.userId ? await misskeyApi('chat/messages/user-timeline', {
userId: user.value!.id,
limit: LIMIT,
untilId: messages.value[messages.value.length - 1].id,
}) : await misskeyApi('chat/messages/room-timeline', {
roomId: room.value!.id,
limit: LIMIT,
untilId: messages.value[messages.value.length - 1].id,
});
messages.value.push(...newMessages.map(x => normalizeMessage(x)));
canFetchMore.value = newMessages.length === LIMIT;
moreFetching.value = false;
}
function onMessage(message: Misskey.entities.ChatMessageLite) {
sound.playMisskeySfx('chatMessage');
messages.value.unshift(normalizeMessage(message));
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (message.fromUserId !== $i.id && !window.document.hidden && isActivated) {
connection.value?.send('read', {
id: message.id,
});
}
if (message.fromUserId !== $i.id) {
//notifyNewMessage();
}
}
function onDeleted(id: string) {
const index = messages.value.findIndex(m => m.id === id);
if (index !== -1) {
messages.value.splice(index, 1);
}
}
function onReact(ctx: Parameters<Misskey.Channels['chatUser']['events']['react']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['react']>[0]) {
const message = messages.value.find(m => m.id === ctx.messageId);
if (message) {
if (room.value == null) { // 1on1の時はuserは省略される
message.reactions.push({
reaction: ctx.reaction,
user: message.fromUserId === $i.id ? user.value! : $i,
});
} else {
message.reactions.push({
reaction: ctx.reaction,
user: ctx.user!,
});
}
}
}
function onUnreact(ctx: Parameters<Misskey.Channels['chatUser']['events']['unreact']>[0] | Parameters<Misskey.Channels['chatRoom']['events']['unreact']>[0]) {
const message = messages.value.find(m => m.id === ctx.messageId);
if (message) {
const index = message.reactions.findIndex(r => r.reaction === ctx.reaction && r.user.id === ctx.user!.id);
if (index !== -1) {
message.reactions.splice(index, 1);
}
}
}
function onIndicatorClick() {
showIndicator.value = false;
}
function notifyNewMessage() {
showIndicator.value = true;
}
function onVisibilitychange() {
if (window.document.hidden) return;
// TODO
}
onMounted(() => {
initialize();
});
onBeforeUnmount(() => {
connection.value?.dispose();
window.document.removeEventListener('visibilitychange', onVisibilitychange);
});
async function inviteUser() {
if (room.value == null) return;
const invitee = await os.selectUser({ includeSelf: false, localOnly: true });
os.apiWithDialog('chat/rooms/invitations/create', {
roomId: room.value.id,
userId: invitee.id,
});
}
async function leaveRoom() {
if (room.value == null) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.areYouSure,
});
if (canceled) return;
misskeyApi('chat/rooms/leave', {
roomId: room.value.id,
});
router.push('/chat');
}
function showMenu(ev: MouseEvent) {
const menuItems: MenuItem[] = [];
if (room.value) {
if (room.value.ownerId === $i.id) {
menuItems.push({
text: i18n.ts._chat.inviteUser,
icon: 'ti ti-user-plus',
action: () => {
inviteUser();
},
});
} else {
menuItems.push({
text: i18n.ts._chat.leave,
icon: 'ti ti-x',
action: () => {
leaveRoom();
},
});
}
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
const tab = ref('chat');
const headerTabs = computed(() => room.value ? [{
key: 'chat',
title: i18n.ts.chat,
icon: 'ti ti-messages',
}, {
key: 'members',
title: i18n.ts._chat.members,
icon: 'ti ti-users',
}, {
key: 'search',
title: i18n.ts.search,
icon: 'ti ti-search',
}, {
key: 'info',
title: i18n.ts.info,
icon: 'ti ti-info-circle',
}] : [{
key: 'chat',
title: i18n.ts.chat,
icon: 'ti ti-messages',
}, {
key: 'search',
title: i18n.ts.search,
icon: 'ti ti-search',
}]);
const headerActions = computed<PageHeaderItem[]>(() => [{
icon: 'ti ti-dots',
text: '',
handler: showMenu,
}]);
definePage(computed(() => {
if (!initializing.value) {
if (user.value) {
return {
userName: user.value,
title: user.value.name ?? user.value.username,
avatar: user.value,
};
} else if (room.value) {
return {
title: room.value.name,
icon: 'ti ti-users',
};
} else {
return {
title: i18n.ts.chat,
};
}
} else {
return {
title: i18n.ts.chat,
};
}
}));
</script>
<style lang="scss" module>
.transition_x_move,
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.2s cubic-bezier(0,.5,.5,1), transform 0.2s cubic-bezier(0,.5,.5,1) !important;
}
.transition_x_enterFrom,
.transition_x_leaveTo {
opacity: 0;
transform: translateY(80px);
}
.transition_x_leaveActive {
position: absolute;
}
.root {
}
.more {
margin: 0 auto;
}
.footer {
width: 100%;
padding-top: 8px;
}
.new {
width: 100%;
padding-bottom: 8px;
text-align: center;
}
.newButton {
display: inline-block;
margin: 0;
padding: 0 12px;
line-height: 32px;
font-size: 12px;
border-radius: 16px;
}
.newIcon {
display: inline-block;
margin-right: 8px;
}
.footer {
}
.form {
margin: 0 auto;
width: 100%;
max-width: 700px;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.1s;
}
.fade-enter-from, .fade-leave-to {
transition: opacity 0.5s;
opacity: 0;
}
.dateDivider {
display: flex;
font-size: 85%;
align-items: center;
justify-content: center;
gap: 0.5em;
opacity: 0.75;
border: solid 0.5px var(--MI_THEME-divider);
border-radius: 999px;
width: fit-content;
padding: 0.5em 1em;
margin: 0 auto;
}
</style>

View File

@@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<PageWithHeader>
<MkSpacer :contentMax="800">
<MkClickerGame/>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import MkClickerGame from '@/components/MkClickerGame.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
definePageMetadata(() => ({
definePage(() => ({
title: '🍪👈',
icon: 'ti ti-cookie',
}));

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions"/></template>
<PageWithHeader :actions="headerActions">
<MkSpacer :contentMax="800">
<div v-if="clip" class="_gaps">
<div class="_panel">
@@ -27,29 +26,35 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkNotes :pagination="pagination" :detail="true"/>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, watch, provide, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import type { MenuItem } from '@/types/menu.js';
import MkNotes from '@/components/MkNotes.vue';
import { $i } from '@/account.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { url } from '@/config.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import { clipsCache } from '@/cache.js';
import { isSupportShare } from '@/scripts/navigator.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { isSupportShare } from '@/utility/navigator.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { genEmbedCode } from '@/utility/get-embed-code.js';
import { assertServerContext, serverContext } from '@/server-context.js';
// contextは非ログイン状態の情報しかないためログイン時は利用できない
const CTX_CLIP = !$i && assertServerContext(serverContext, 'clip') ? serverContext.clip : null;
const props = defineProps<{
clipId: string,
}>();
const clip = ref<Misskey.entities.Clip | null>(null);
const clip = ref<Misskey.entities.Clip | null>(CTX_CLIP);
const favorited = ref(false);
const pagination = {
endpoint: 'clips/notes' as const,
@@ -62,6 +67,11 @@ const pagination = {
const isOwned = computed<boolean | null>(() => $i && clip.value && ($i.id === clip.value.userId));
watch(() => props.clipId, async () => {
if (CTX_CLIP && CTX_CLIP.id === props.clipId) {
clip.value = CTX_CLIP;
return;
}
clip.value = await misskeyApi('clips/show', {
clipId: props.clipId,
});
@@ -127,21 +137,40 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
clipsCache.delete();
},
}, ...(clip.value.isPublic ? [{
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
handler: async (): Promise<void> => {
copyToClipboard(`${url}/clips/${clip.value.id}`);
os.success();
},
}] : []), ...(clip.value.isPublic && isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {
navigator.share({
title: clip.value.name,
text: clip.value.description,
url: `${url}/clips/${clip.value.id}`,
handler: (ev: MouseEvent): void => {
const menuItems: MenuItem[] = [];
menuItems.push({
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
action: () => {
copyToClipboard(`${url}/clips/${clip.value!.id}`);
},
}, {
icon: 'ti ti-code',
text: i18n.ts.embed,
action: () => {
genEmbedCode('clips', clip.value!.id);
},
});
if (isSupportShare()) {
menuItems.push({
icon: 'ti ti-share',
text: i18n.ts.share,
action: async () => {
navigator.share({
title: clip.value!.name,
text: clip.value!.description ?? '',
url: `${url}/clips/${clip.value!.id}`,
});
},
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
},
}] : []), {
icon: 'ti ti-trash',
@@ -162,7 +191,7 @@ const headerActions = computed(() => clip.value && isOwned.value ? [{
},
}] : null);
definePageMetadata(() => ({
definePage(() => ({
title: clip.value ? clip.value.name : i18n.ts.clip,
icon: 'ti ti-paperclip',
}));
@@ -176,7 +205,7 @@ definePageMetadata(() => ({
.user {
--height: 32px;
padding: 16px;
border-top: solid 0.5px var(--divider);
border-top: solid 0.5px var(--MI_THEME-divider);
line-height: var(--height);
}

View File

@@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<PageWithHeader>
<MkSpacer :contentMax="600" :marginMin="20">
<div class="_gaps_m">
<MkKeyValue :copy="instance.maintainerName">
@@ -31,17 +30,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkKeyValue>
</div>
</MkSpacer>
</MkStickyContainer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkLink from '@/components/MkLink.vue';
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.inquiry,
icon: 'ti ti-help-circle',
}));

View File

@@ -4,89 +4,88 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
<MkSwitch v-model="selectMode" style="margin: 8px 0;">
<template #label>Select mode</template>
</MkSwitch>
<div v-if="selectMode" class="_buttons">
<MkButton inline @click="selectAll">Select all</MkButton>
<MkButton inline @click="setCategoryBulk">Set category</MkButton>
<MkButton inline @click="setTagBulk">Set tag</MkButton>
<MkButton inline @click="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
<MkButton inline @click="setLicenseBulk">Set License</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton>
</div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<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.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<FormSplit>
<MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
<MkSwitch v-model="selectMode" style="margin: 8px 0;">
<template #label>Select mode</template>
</MkSwitch>
<div v-if="selectMode" class="_buttons">
<MkButton inline @click="selectAll">Select all</MkButton>
<MkButton inline @click="setCategoryBulk">Set category</MkButton>
<MkButton inline @click="setTagBulk">Set tag</MkButton>
<MkButton inline @click="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
<MkButton inline @click="setLicenseBulk">Set License</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton>
</div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<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"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
</div>
</template>
</MkPagination>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<FormSplit>
<MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
<MkInput v-model="host" :debounce="true">
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
</FormSplit>
<MkPagination :pagination="remotePagination">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<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"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
</div>
<MkInput v-model="host" :debounce="true">
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
</FormSplit>
<MkPagination :pagination="remotePagination">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<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="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>
</div>
</div>
</template>
</MkPagination>
</div>
</div>
</template>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</div>
</MkSpacer>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
import { computed, defineAsyncComponent, ref, useTemplateRef } 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 { selectFile } from '@/utility/select-file.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { definePage } from '@/page.js';
const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const emojisPaginationComponent = useTemplateRef('emojisPaginationComponent');
const tab = ref('local');
const query = ref<string | null>(null);
@@ -116,7 +115,7 @@ const selectAll = () => {
if (selectedEmojis.value.length > 0) {
selectedEmojis.value = [];
} else {
selectedEmojis.value = Array.from(emojisPaginationComponent.value.items.values(), item => item.id);
selectedEmojis.value = Array.from(emojisPaginationComponent.value?.items.values(), item => item.id);
}
};
@@ -133,7 +132,7 @@ const add = async (ev: MouseEvent) => {
}, {
done: result => {
if (result.created) {
emojisPaginationComponent.value.prepend(result.created);
emojisPaginationComponent.value?.prepend(result.created);
}
},
closed: () => dispose(),
@@ -146,18 +145,31 @@ const edit = (emoji) => {
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
emojisPaginationComponent.value?.updateItem(result.updated.id, (oldEmoji) => ({
...oldEmoji,
...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value.removeItem(emoji.id);
emojisPaginationComponent.value?.removeItem(emoji.id);
}
},
closed: () => dispose(),
});
};
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,
@@ -168,6 +180,10 @@ 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',
@@ -226,7 +242,7 @@ const setCategoryBulk = async () => {
ids: selectedEmojis.value,
category: result,
});
emojisPaginationComponent.value.reload();
emojisPaginationComponent.value?.reload();
};
const setLicenseBulk = async () => {
@@ -238,43 +254,43 @@ const setLicenseBulk = async () => {
ids: selectedEmojis.value,
license: result,
});
emojisPaginationComponent.value.reload();
emojisPaginationComponent.value?.reload();
};
const addTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
});
if (canceled) return;
if (canceled || result == null) return;
await os.apiWithDialog('admin/emoji/add-aliases-bulk', {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value.reload();
emojisPaginationComponent.value?.reload();
};
const removeTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
});
if (canceled) return;
if (canceled || result == null) return;
await os.apiWithDialog('admin/emoji/remove-aliases-bulk', {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value.reload();
emojisPaginationComponent.value?.reload();
};
const setTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
});
if (canceled) return;
if (canceled || result == null) return;
await os.apiWithDialog('admin/emoji/set-aliases-bulk', {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value.reload();
emojisPaginationComponent.value?.reload();
};
const delBulk = async () => {
@@ -286,7 +302,7 @@ const delBulk = async () => {
await os.apiWithDialog('admin/emoji/delete-bulk', {
ids: selectedEmojis.value,
});
emojisPaginationComponent.value.reload();
emojisPaginationComponent.value?.reload();
};
const headerActions = computed(() => [{
@@ -307,7 +323,7 @@ const headerTabs = computed(() => [{
title: i18n.ts.remote,
}]);
definePageMetadata(() => ({
definePage(() => ({
title: i18n.ts.customEmojis,
icon: 'ti ti-icons',
}));
@@ -317,33 +333,34 @@ definePageMetadata(() => ({
.ogwlenmc {
> .local {
.empty {
margin: var(--margin);
margin: var(--MI-margin);
}
.ldhfsamy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
margin: var(--MI-margin) 0;
> .emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
border: solid 1px var(--MI_THEME-panel);
&:hover {
border-color: var(--inputBorderHover);
border-color: var(--MI_THEME-inputBorderHover);
}
&.selected {
border-color: var(--accent);
border-color: var(--MI_THEME-accent);
}
> .img {
width: 42px;
height: 42px;
object-fit: contain;
}
> .body {
@@ -368,14 +385,14 @@ definePageMetadata(() => ({
> .remote {
.empty {
margin: var(--margin);
margin: var(--MI-margin);
}
.ldhfsamy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
margin: var(--MI-margin) 0;
> .emoji {
display: flex;
@@ -384,12 +401,13 @@ definePageMetadata(() => ({
text-align: left;
&:hover {
color: var(--accent);
color: var(--MI_THEME-accent);
}
> .img {
width: 32px;
height: 32px;
object-fit: contain;
}
> .body {

View File

@@ -37,11 +37,17 @@ SPDX-License-Identifier: AGPL-3.0-only
</button>
</div>
</div>
<div>
<button class="_button" :class="$style.fileAltEditBtn" @click="describe()">
<div class="_gaps_s">
<button class="_button" :class="$style.kvEditBtn" @click="move()">
<MkKeyValue>
<template #key>{{ i18n.ts.folder }}</template>
<template #value>{{ folderHierarchy.join(' > ') }}<i class="ti ti-pencil" :class="$style.kvEditIcon"></i></template>
</MkKeyValue>
</button>
<button class="_button" :class="$style.kvEditBtn" @click="describe()">
<MkKeyValue :class="$style.multiline">
<template #key>{{ i18n.ts.description }}</template>
<template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.fileAltEditIcon"></i></template>
<template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.kvEditIcon"></i></template>
</MkKeyValue>
</button>
<MkKeyValue :class="$style.fileMetaDataChildren">
@@ -63,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-else class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<img :src="infoImageUrl" draggable="false"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
@@ -79,8 +85,8 @@ import bytes from '@/filters/bytes.js';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useRouter } from '@/router/supplier.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useRouter } from '@/router.js';
const router = useRouter();
@@ -90,6 +96,18 @@ const props = defineProps<{
const fetching = ref(true);
const file = ref<Misskey.entities.DriveFile>();
const folderHierarchy = computed(() => {
if (!file.value) return [i18n.ts.drive];
const folderNames = [i18n.ts.drive];
function get(folder: Misskey.entities.DriveFolder) {
if (folder.parent) get(folder.parent);
folderNames.push(folder.name);
}
if (file.value.folder) get(file.value.folder);
return folderNames;
});
const isImage = computed(() => file.value?.type.startsWith('image/'));
async function fetch() {
@@ -122,6 +140,19 @@ function crop() {
});
}
function move() {
if (!file.value) return;
os.selectDriveFolder(false).then(folder => {
misskeyApi('drive/files/update', {
fileId: file.value.id,
folderId: folder[0] ? folder[0].id : null,
}).then(async () => {
await fetch();
});
});
}
function toggleSensitive() {
if (!file.value) return;
@@ -200,8 +231,8 @@ onMounted(async () => {
<style lang="scss" module>
.filePreviewRoot {
background: var(--panel);
border-radius: var(--radius);
background: var(--MI_THEME-panel);
border-radius: var(--MI-radius);
// MkMediaList 内の上部マージン 4px
padding: calc(1rem - 4px) 1rem 1rem;
}
@@ -231,8 +262,8 @@ onMounted(async () => {
&:hover,
&:focus-visible {
background-color: var(--accentedBg);
color: var(--accent);
background-color: var(--MI_THEME-accentedBg);
color: var(--MI_THEME-accent);
text-decoration: none;
outline: none;
}
@@ -254,7 +285,7 @@ onMounted(async () => {
align-items: center;
min-width: 0;
font-weight: 700;
border-radius: var(--radius);
border-radius: var(--MI-radius);
font-size: .8rem;
>.fileNameEditIcon {
@@ -268,12 +299,12 @@ onMounted(async () => {
}
&:hover {
background-color: var(--accentedBg);
background-color: var(--MI_THEME-accentedBg);
>.fileName,
>.fileNameEditIcon {
visibility: visible;
color: var(--accent);
color: var(--MI_THEME-accent);
}
}
}
@@ -282,14 +313,18 @@ onMounted(async () => {
padding: .5rem 1rem;
}
.fileAltEditBtn {
.multiline {
white-space: pre-wrap;
}
.kvEditBtn {
text-align: start;
display: block;
width: 100%;
padding: .5rem 1rem;
border-radius: var(--radius);
border-radius: var(--MI-radius);
.fileAltEditIcon {
.kvEditIcon {
display: inline-block;
color: transparent;
visibility: hidden;
@@ -297,11 +332,11 @@ onMounted(async () => {
}
&:hover {
color: var(--accent);
background-color: var(--accentedBg);
color: var(--MI_THEME-accent);
background-color: var(--MI_THEME-accentedBg);
.fileAltEditIcon {
color: var(--accent);
.kvEditIcon {
color: var(--MI_THEME-accent);
visibility: visible;
}
}

Some files were not shown because too many files have changed in this diff Show More