diff options
| author | Hazelnoot <acomputerdog@gmail.com> | 2025-04-24 14:23:45 -0400 |
|---|---|---|
| committer | Hazelnoot <acomputerdog@gmail.com> | 2025-04-24 14:23:45 -0400 |
| commit | a4dd19fdd427a5adc8fa80871d1c742aa9708730 (patch) | |
| tree | 3983a0e772922042043026b4af168e8dd3525fb2 /packages/frontend/src | |
| parent | Merge branch 'develop' into merge/2025-03-24 (diff) | |
| parent | enhance(backend): DB note (userId) インデクス -> (userId, id) 複合イ... (diff) | |
| download | sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.tar.gz sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.tar.bz2 sharkey-a4dd19fdd427a5adc8fa80871d1c742aa9708730.zip | |
merge upstream again
Diffstat (limited to 'packages/frontend/src')
91 files changed, 3041 insertions, 1727 deletions
diff --git a/packages/frontend/src/accounts.ts b/packages/frontend/src/accounts.ts index 032455bd70..d535c4c313 100644 --- a/packages/frontend/src/accounts.ts +++ b/packages/frontend/src/accounts.ts @@ -21,14 +21,19 @@ type AccountWithToken = Misskey.entities.MeDetailed & { token: string }; export async function getAccounts(): Promise<{ host: string; - user: Misskey.entities.User; + id: Misskey.entities.User['id']; + username: Misskey.entities.User['username']; + user?: Misskey.entities.User | null; token: string | null; }[]> { const tokens = store.s.accountTokens; + const accountInfos = store.s.accountInfos; const accounts = prefer.s.accounts; return accounts.map(([host, user]) => ({ host, - user, + id: user.id, + username: user.username, + user: accountInfos[host + '/' + user.id], token: tokens[host + '/' + user.id] ?? null, })); } @@ -36,7 +41,8 @@ export async function getAccounts(): Promise<{ async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); - prefer.commit('accounts', [...prefer.s.accounts, [host, user]]); + store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user }); + prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]); } } @@ -44,6 +50,10 @@ export async function removeAccount(host: string, id: AccountWithToken['id']) { const tokens = JSON.parse(JSON.stringify(store.s.accountTokens)); delete tokens[host + '/' + id]; store.set('accountTokens', tokens); + const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos)); + delete accountInfos[host + '/' + id]; + store.set('accountInfos', accountInfos); + prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); } @@ -128,14 +138,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) { for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } - prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { - // TODO: $iのホストも比較したいけど通常null - if (user.id === $i.id) { - return [host, $i]; - } else { - return [host, user]; - } - })); + store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i }); $i.token = token; miLocalStorage.setItem('account', JSON.stringify($i)); } @@ -145,17 +148,9 @@ export function updateCurrentAccountPartial(accountData: Partial<Misskey.entitie for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } - prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { - // TODO: $iのホストも比較したいけど通常null - if (user.id === $i.id) { - const newUser = JSON.parse(JSON.stringify($i)); - for (const [key, value] of Object.entries(accountData)) { - newUser[key] = value; - } - return [host, newUser]; - } - return [host, user]; - })); + + store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i }); + miLocalStorage.setItem('account', JSON.stringify($i)); } @@ -230,25 +225,42 @@ export async function openAccountMenu(opts: { }, ev: MouseEvent) { if (!$i) return; - function createItem(host: string, account: Misskey.entities.User): MenuItem { - return { - type: 'user' as const, - user: account, - active: opts.active != null ? opts.active === account.id : false, - action: async () => { - if (opts.onChoose) { - opts.onChoose(account); - } else { - switchAccount(host, account.id); - } - }, - }; + function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem { + if (account) { + return { + type: 'user' as const, + user: account, + active: opts.active != null ? opts.active === id : false, + action: async () => { + if (opts.onChoose) { + opts.onChoose(account); + } else { + switchAccount(host, id); + } + }, + }; + } else { + return { + type: 'button' as const, + text: username, + active: opts.active != null ? opts.active === id : false, + action: async () => { + if (opts.onChoose) { + fetchAccount(token, id).then(account => { + opts.onChoose(account); + }); + } else { + switchAccount(host, id); + } + }, + }; + } } const menuItems: MenuItem[] = []; // TODO: $iのホストも比較したいけど通常null - const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user)); + const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token)); if (opts.withExtraOperation) { menuItems.push({ @@ -261,7 +273,7 @@ export async function openAccountMenu(opts: { }); if (opts.includeCurrentAccount) { - menuItems.push(createItem(host, $i)); + menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token)); } menuItems.push(...accountItems); @@ -302,7 +314,7 @@ export async function openAccountMenu(opts: { }); } else { if (opts.includeCurrentAccount) { - menuItems.push(createItem(host, $i)); + menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token)); } menuItems.push(...accountItems); diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue index 00bf8e68d9..b3331d742b 100644 --- a/packages/frontend/src/components/MkAuthConfirm.vue +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -157,7 +157,7 @@ async function init() { const accounts = await getAccounts(); - const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id)); + const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id)); if (accountIdsToFetch.length > 0) { const usersRes = await misskeyApi('users/show', { @@ -169,7 +169,7 @@ async function init() { users.value.set(user.id, { ...user, - token: accounts.find(a => a.user.id === user.id)!.token, + token: accounts.find(a => a.id === user.id)!.token, }); } } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 321b043d9e..6608eeaa47 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only </li> <li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> </ol> - <ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list"> <li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown"> <span class="name">{{ hashtag }}</span> </li> </ol> - <ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list"> <li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> @@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span> </li> </ol> - <ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list"> <li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown"> <span>{{ tag }}</span> </li> </ol> - <ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list"> + <ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list"> <li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown"> <span>{{ param }}</span> </li> @@ -58,12 +58,44 @@ import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; import { miLocalStorage } from '@/local-storage.js'; import { customEmojis } from '@/custom-emojis.js'; -import { searchEmoji } from '@/utility/search-emoji.js'; +import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js'; import { prefer } from '@/preferences.js'; +export type CompleteInfo = { + user: { + payload: any; + query: string | null; + }, + hashtag: { + payload: string; + query: string; + }, + // `:emo` -> `:emoji:` or some unicode emoji + emoji: { + payload: string; + query: string; + }, + // like emoji but for `:emoji:` -> unicode emoji + emojiComplete: { + payload: string; + query: string; + }, + mfmTag: { + payload: string; + query: string; + }, + mfmParam: { + payload: string; + query: { + tag: string; + params: string[]; + }; + }, +}; + const lib = emojilist.filter(x => x.category !== 'flags'); -const emojiDb = computed(() => { +const unicodeEmojiDB = computed(() => { //#region Unicode Emoji const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : char2fluentEmojiFilePath; @@ -87,6 +119,12 @@ const emojiDb = computed(() => { } unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length); + + return unicodeEmojiDB; +}); + +const emojiDb = computed(() => { + //#region Unicode Emoji //#endregion //#region Custom Emoji @@ -114,7 +152,7 @@ const emojiDb = computed(() => { customEmojiDB.sort((a, b) => a.name.length - b.name.length); //#endregion - return markRaw([...customEmojiDB, ...unicodeEmojiDB]); + return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]); }); export default { @@ -123,18 +161,23 @@ export default { }; </script> -<script lang="ts" setup> -const props = defineProps<{ - type: string; - q: any; - textarea: HTMLTextAreaElement; +<script lang="ts" setup generic="T extends keyof CompleteInfo"> +type PropsType<T extends keyof CompleteInfo> = { + type: T; + q: CompleteInfo[T]['query']; + // なぜかわからないけど HTMLTextAreaElement | HTMLInputElement だと addEventListener/removeEventListenerがエラー + textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement; close: () => void; x: number; y: number; -}>(); +}; +//const props = defineProps<PropsType<keyof CompleteInfo>>(); +// ↑と同じだけど↓にしないとdiscriminated unionにならない。 +// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions +const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>(); const emit = defineEmits<{ - (event: 'done', value: { type: string; value: any }): void; + <T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void; (event: 'closed'): void; }>(); @@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]); const select = ref(-1); const zIndex = os.claimZIndex('high'); -function complete(type: string, value: any) { +function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) { emit('done', { type, value }); emit('closed'); - if (type === 'emoji') { + if (type === 'emoji' || type === 'emojiComplete') { let recents = store.s.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== value); recents.unshift(value); @@ -243,6 +286,8 @@ function exec() { } emojis.value = searchEmoji(props.q.normalize('NFC').toLowerCase(), emojiDb.value); + } else if (props.type === 'emojiComplete') { + emojis.value = searchEmojiExact(props.q.normalize('NFC').toLowerCase(), unicodeEmojiDB.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; diff --git a/packages/frontend/src/components/MkChatHistories.stories.impl.ts b/packages/frontend/src/components/MkChatHistories.stories.impl.ts new file mode 100644 index 0000000000..8268adc36f --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.stories.impl.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { http, HttpResponse } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { chatMessage } from '../../.storybook/fakes'; +import MkChatHistories from './MkChatHistories.vue'; +import type { StoryObj } from '@storybook/vue3'; +import type * as Misskey from 'misskey-js'; +export const Default = { + render(args) { + return { + components: { + MkChatHistories, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkChatHistories v-bind="props" />', + }; + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + http.post('/api/chat/history', async ({ request }) => { + const body = await request.json() as Misskey.entities.ChatHistoryRequest; + action('POST /api/chat/history')(body); + return HttpResponse.json([chatMessage(body.room)]); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkChatHistories>; diff --git a/packages/frontend/src/components/MkChatHistories.vue b/packages/frontend/src/components/MkChatHistories.vue new file mode 100644 index 0000000000..c508ea8451 --- /dev/null +++ b/packages/frontend/src/components/MkChatHistories.vue @@ -0,0 +1,208 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<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"/> +</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 { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i.js'; + +const $i = ensureSignin(); + +const history = ref<{ + id: string; + message: Misskey.entities.ChatMessage; + other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null; + isMe: boolean; +}[]>([]); + +const initializing = ref(true); +const fetching = ref(false); + +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; +} + +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> +.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); + } + } +} + +@container (max-width: 500px) { + .message { + font-size: 90%; + padding: 14px 20px; + } +} + +@container (max-width: 450px) { + .message { + font-size: 80%; + padding: 12px 16px; + } +} + +.messageAvatar { + width: 50px; + height: 50px; + margin: 0 16px 0 0; +} + +@container (max-width: 500px) { + .messageAvatar { + width: 45px; + height: 45px; + } +} + +@container (max-width: 450px) { + .messageAvatar { + width: 40px; + height: 40px; + } +} + +.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; +} +</style> diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a99ab3bd09..d79ecc7302 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #fallback> <MkLoading/> </template> - <XCode v-if="show && lang" :code="code" :lang="lang"/> - <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/> + <pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> <div :class="$style.codePlaceholderContainer"> <div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div> @@ -70,11 +70,9 @@ function copy() { .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - background: var(--MI_THEME-bg); padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; - border-radius: var(--MI-radius-sm); } .codeBlockFallbackCode { diff --git a/packages/frontend/src/components/MkDisableSection.stories.impl.ts b/packages/frontend/src/components/MkDisableSection.stories.impl.ts new file mode 100644 index 0000000000..78e556c63e --- /dev/null +++ b/packages/frontend/src/components/MkDisableSection.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkDisableSection from './MkDisableSection.vue'; +void MkDisableSection; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 218ce9d93a..cb7c270e91 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -640,13 +640,13 @@ function getMenu() { text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', icon: 'ti ti-upload', action: () => { - chooseFileFromPc(true, { keepOriginal: false }); + chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false }); }, }, { text: i18n.ts.upload, icon: 'ti ti-upload', action: () => { - chooseFileFromPc(true, { keepOriginal: true }); + chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true }); }, }, { text: i18n.ts.fromUrl, diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 5231a577d7..c228853bea 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,15 +38,26 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> - <slot></slot> - </MkSpacer> - <div v-else> - <slot></slot> - </div> - <div v-if="$slots.footer" :class="$style.footer"> - <slot name="footer"></slot> - </div> + <MkStickyContainer> + <template #header> + <div v-if="$slots.header" :class="$style.inBodyHeader"> + <slot name="header"></slot> + </div> + </template> + + <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> + <slot></slot> + </MkSpacer> + <div v-else> + <slot></slot> + </div> + + <template #footer> + <div v-if="$slots.footer" :class="$style.inBodyFooter"> + <slot name="footer"></slot> + </div> + </template> + </MkStickyContainer> </div> </KeepAlive> </Transition> @@ -230,14 +241,21 @@ onMounted(() => { &.bgSame { background: var(--MI_THEME-bg); + + .inBodyHeader { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + } } } -.footer { - position: sticky !important; - z-index: 1; - bottom: var(--MI-stickyBottom, 0px); - left: 0; +.inBodyHeader { + background: color(from var(--MI_THEME-panel) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.inBodyFooter { padding: 12px; background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 8be4792cf8..c2fc967e1d 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -31,9 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-for="item in (items2 ?? [])"> <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> - <span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> - <span style="opacity: 0.7;">{{ item.text }}</span> - </span> + <div v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label]"> + <span>{{ item.text }}</span> + </div> <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> @@ -619,12 +619,6 @@ onBeforeUnmount(() => { --menuActiveBg: var(--MI_THEME-accentedBg); } - &.label { - pointer-events: none; - font-size: 0.7em; - padding-bottom: 4px; - } - &.pending { pointer-events: none; opacity: 0.7; @@ -694,6 +688,19 @@ onBeforeUnmount(() => { font-size: 12px; } +.label { + position: relative; + padding: 6px 16px; + box-sizing: border-box; + white-space: nowrap; + font-size: 0.7em; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.7; + pointer-events: none; +} + .divider { margin: 8px 0; border-top: solid 0.5px var(--MI_THEME-divider); diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 6c52db004b..5edae908b0 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notes }"> <component - :is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]" + :is="prefer.s.animation ? TransitionGroup : 'div'" + :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]" :enterActiveClass="$style.transition_x_enterActive" :leaveActiveClass="$style.transition_x_leaveActive" :enterFromClass="$style.transition_x_enterFrom" @@ -22,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only :moveClass=" $style.transition_x_move" tag="div" > - <template v-for="note in notes" :key="note.id"> - <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]"> + <template v-for="(note, i) in notes" :key="note.id"> + <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id"> <DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/> <div :class="$style.ad"> <MkAd :preferForms="['horizontal', 'horizontal-big']"/> </div> </div> - <DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/> + <DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/> </template> </component> </template> @@ -74,6 +75,11 @@ defineExpose({ position: absolute; } +.reverse { + display: flex; + flex-direction: column-reverse; +} + .root { container-type: inline-size; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index a4591a35f7..5b2a8dfc5a 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -346,6 +346,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) right: -2px; width: 20px; height: 20px; + line-height: 20px; box-sizing: border-box; border-radius: var(--MI-radius-full); background: var(--MI_THEME-panel); @@ -360,73 +361,61 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification) } .t_follow, .t_followRequestAccepted, .t_receiveFollowRequest { - padding: 3px; background: var(--eventFollow); pointer-events: none; } .t_renote { - padding: 3px; background: var(--eventRenote); pointer-events: none; } .t_quote { - padding: 3px; background: var(--eventRenote); pointer-events: none; } .t_reply { - padding: 3px; background: var(--eventReply); pointer-events: none; } .t_mention { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_pollEnded { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_achievementEarned { - padding: 3px; background: var(--eventAchievement); pointer-events: none; } .t_exportCompleted { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_roleAssigned { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_login { - padding: 3px; background: var(--eventLogin); pointer-events: none; } .t_createToken { - padding: 3px; background: var(--eventOther); pointer-events: none; } .t_chatRoomInvitationReceived { - padding: 3px; background: var(--eventOther); pointer-events: none; } diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 453a6c83b3..55ee054285 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only tag="div" > <template v-for="(notification, i) in notifications" :key="notification.id"> - <DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true"/> - <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true"/> + <DynamicNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/> + <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/> </template> </component> </template> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 01bc80f397..c530effe4a 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -956,7 +956,7 @@ async function post(ev?: MouseEvent) { if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token; + token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token; } posting.value = true; diff --git a/packages/frontend/src/components/MkHorizontalSwipe.vue b/packages/frontend/src/components/MkSwiper.vue index 1d0ffaea11..1d0ffaea11 100644 --- a/packages/frontend/src/components/MkHorizontalSwipe.vue +++ b/packages/frontend/src/components/MkSwiper.vue diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue new file mode 100644 index 0000000000..a1f30100d0 --- /dev/null +++ b/packages/frontend/src/components/MkTabs.vue @@ -0,0 +1,235 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.tabs"> + <div :class="$style.tabsInner"> + <button + v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" + @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" + > + <div :class="$style.tabInner"> + <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> + <div + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" + :class="$style.tabTitle" + > + {{ t.title }} + </div> + <Transition + v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave" + @afterLeave="afterLeave" + > + <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> + </Transition> + </div> + </button> + </div> + <div + ref="tabHighlightEl" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" + ></div> +</div> +</template> + +<script lang="ts"> +export type Tab = { + key: string; + onClick?: (ev: MouseEvent) => void; +} & ( + | { + iconOnly?: false; + title: string; + icon?: string; + } + | { + iconOnly: true; + icon: string; + } +); +</script> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + tab?: string; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); + (ev: 'tabClick', key: string); +}>(); + +const tabHighlightEl = useTemplateRef('tabHighlightEl'); +const tabRefs: Record<string, HTMLElement | null> = {}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(t: Tab, ev: MouseEvent): void { + emit('tabClick', t.key); + + if (t.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + t.onClick(ev); + } + + if (t.key) { + emit('update:tab', t.key); + } +} + +function renderTab() { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.value.style.width = rect.width + 'px'; + tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px'; + } +} + +let entering = false; + +async function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; + entering = true; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = '0'; + el.style.paddingLeft = '0'; + el.offsetWidth; // reflow + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + nextTick(() => { + entering = false; + }); + + window.setTimeout(renderTab, 170); +} + +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + // element.style.width = ''; +} + +async function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + el.offsetWidth; // reflow + el.style.width = '0'; + el.style.paddingLeft = '0'; +} + +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.width = ''; +} + +onMounted(() => { + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => { + if (entering) return; + renderTab(); + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { +}); +</script> + +<style lang="scss" module> +.tabs { + --height: 40px; + + display: block; + position: relative; + margin: 0; + height: var(--height); + font-size: 85%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tabsInner { + display: inline-block; + height: var(--height); + white-space: nowrap; +} + +.tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + &.animate { + transition: opacity 0.2s ease; + } +} + +.tabInner { + display: flex; + align-items: center; +} + +.tabIcon + .tabTitle { + padding-left: 4px; +} + +.tabTitle { + overflow: hidden; + + &.animate { + transition: width .15s linear, padding-left .15s linear; + } +} + +.tabHighlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--MI_THEME-accent); + border-radius: 999px; + transition: none; + pointer-events: none; + + &.animate { + transition: width 0.15s ease, left 0.15s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkThemePreview.vue b/packages/frontend/src/components/MkThemePreview.vue index 013ab9d6a4..cc4254a2f6 100644 --- a/packages/frontend/src/components/MkThemePreview.vue +++ b/packages/frontend/src/components/MkThemePreview.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <g fill-rule="evenodd"> <rect width="200" height="150" :fill="themeVariables.bg"/> <rect width="64" height="150" :fill="themeVariables.navBg"/> - <rect x="64" width="136" height="41" :fill="themeVariables.bg"/> + <rect x="64" width="136" height="41" :fill="themeVariables.pageHeaderBg"/> <path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/> </g> <circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/> @@ -62,6 +62,7 @@ const themeVariables = ref<{ accent: string; accentedBg: string; navBg: string; + pageHeaderBg: string; success: string; warn: string; error: string; @@ -76,6 +77,7 @@ const themeVariables = ref<{ accent: 'var(--MI_THEME-accent)', accentedBg: 'var(--MI_THEME-accentedBg)', navBg: 'var(--MI_THEME-navBg)', + pageHeaderBg: 'var(--MI_THEME-pageHeaderBg)', success: 'var(--MI_THEME-success)', warn: 'var(--MI_THEME-warn)', error: 'var(--MI_THEME-error)', @@ -104,6 +106,7 @@ watch(() => props.theme, (theme) => { accent: compiled.accent ?? 'var(--MI_THEME-accent)', accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)', navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)', + pageHeaderBg: compiled.pageHeaderBg ?? 'var(--MI_THEME-pageHeaderBg)', success: compiled.success ?? 'var(--MI_THEME-success)', warn: compiled.warn ?? 'var(--MI_THEME-warn)', error: compiled.error ?? 'var(--MI_THEME-error)', diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue new file mode 100644 index 0000000000..95cc4d2a2a --- /dev/null +++ b/packages/frontend/src/components/MkTl.vue @@ -0,0 +1,173 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.items"> + <template v-for="(item, i) in items" :key="item.id"> + <div :class="$style.left"> + <slot v-if="item.type === 'event'" name="left" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + </div> + <div :class="[$style.center, item.type === 'date' ? $style.date : '']"> + <div :class="$style.centerLine"></div> + <div :class="$style.centerPoint"></div> + </div> + <div :class="$style.right"> + <slot v-if="item.type === 'event'" name="right" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + <div v-else :class="$style.dateLabel"><i class="ti ti-chevron-up"></i> {{ item.prevText }}</div> + </div> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +const props = defineProps<{ + events: { + id: string; + timestamp: number; + data: any; + }[]; +}>(); + +const events = computed(() => { + return props.events.toSorted((a, b) => b.timestamp - a.timestamp); +}); + +function getDateText(dateInstance: Date) { + const year = dateInstance.getFullYear(); + const month = dateInstance.getMonth() + 1; + const date = dateInstance.getDate(); + const hour = dateInstance.getHours(); + return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`; +} + +const items = computed<({ + id: string; + type: 'event'; + timestamp: number; + delta: number; + data: any; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date | null; + nextText: string; +})[]>(() => { + const results = []; + for (let i = 0; i < events.value.length; i++) { + const item = events.value[i]; + + const date = new Date(item.timestamp); + const nextDate = events.value[i + 1] ? new Date(events.value[i + 1].timestamp) : null; + + results.push({ + id: item.id, + type: 'event', + timestamp: item.timestamp, + delta: i === events.value.length - 1 ? 0 : item.timestamp - events.value[i + 1].timestamp, + data: item.data, + }); + + if ( + i !== events.value.length - 1 && + nextDate != null && ( + date.getFullYear() !== nextDate.getFullYear() || + date.getMonth() !== nextDate.getMonth() || + date.getDate() !== nextDate.getDate() || + date.getHours() !== nextDate.getHours() + ) + ) { + results.push({ + id: `date-${item.id}`, + type: 'date', + prev: date, + prevText: getDateText(date), + next: nextDate, + nextText: getDateText(nextDate), + }); + } + } + return results; + }); +</script> + +<style lang="scss" module> +.root { + +} + +.items { + display: grid; + grid-template-columns: max-content 18px 1fr; + gap: 0 8px; +} + +.item { +} + +.center { + position: relative; + + &.date { + .centerPoint::before { + position: absolute; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 7px; + height: 7px; + background: var(--MI_THEME-bg); + border-radius: 50%; + } + } +} + +.centerLine { + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: 3px; + height: 100%; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); +} +.centerPoint { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 13px; + height: 13px; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); + border-radius: 50%; +} + +.left { + min-width: 0; + align-self: center; + justify-self: right; +} + +.right { + min-width: 0; + align-self: center; +} + +.dateLabel { + opacity: 0.7; + font-size: 90%; + padding: 4px; + margin: 8px 0; +} +</style> diff --git a/packages/frontend/src/components/SkUserRecentNotes.vue b/packages/frontend/src/components/SkUserRecentNotes.vue index fd67464234..b66a33f644 100644 --- a/packages/frontend/src/components/SkUserRecentNotes.vue +++ b/packages/frontend/src/components/SkUserRecentNotes.vue @@ -67,7 +67,7 @@ async function reload(): Promise<void> { // An additional request is needed to "upgrade" the object. misskeyApi('users/show', { userId: props.userId }), - // Wait for 1 second to match the animation effects in MkHorizontalSwipe, MkPullToRefresh, and MkPagination. + // Wait for 1 second to match the animation effects in MkSwiper, MkPullToRefresh, and MkPagination. // Otherwise, the page appears to load "backwards". new Promise(resolve => window.setTimeout(resolve, 1000)), ]) diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index da518829e2..42bb49e8d9 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -140,11 +140,18 @@ onUnmounted(() => { <style lang="scss" module> .root { - background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px)); - border-bottom: solid 0.5px var(--MI_THEME-divider); + border-bottom: solid 0.5px transparent; width: 100%; + color: var(--MI_THEME-pageHeaderFg); +} + +@container style(--MI_THEME-pageHeaderBg: var(--MI_THEME-bg)) { + .root { + border-bottom: solid 0.5px var(--MI_THEME-divider); + } } .upper, diff --git a/packages/frontend/src/components/global/PageWithHeader.vue b/packages/frontend/src/components/global/PageWithHeader.vue index cd289d04fb..85e61fd532 100644 --- a/packages/frontend/src/components/global/PageWithHeader.vue +++ b/packages/frontend/src/components/global/PageWithHeader.vue @@ -20,6 +20,7 @@ import { useTemplateRef } from 'vue'; import { scrollInContainer } from '@@/js/scroll.js'; import type { PageHeaderItem } from '@/types/page-header.js'; import type { Tab } from './MkPageHeader.tabs.vue'; +import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; @@ -36,6 +37,8 @@ const props = withDefaults(defineProps<{ const tab = defineModel<string>('tab'); const rootEl = useTemplateRef('rootEl'); +useScrollPositionKeeper(rootEl); + defineExpose({ scrollToTop: () => { if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' }); diff --git a/packages/frontend/src/deck.ts b/packages/frontend/src/deck.ts index 269a763ce7..cd6fc9bb5d 100644 --- a/packages/frontend/src/deck.ts +++ b/packages/frontend/src/deck.ts @@ -39,6 +39,7 @@ export const columnTypes = [ 'mentions', 'direct', 'roleTimeline', + 'chat', 'following', ] as const; diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 1dee8c30c7..d955857e20 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> <XOverview/> </MkSpacer> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> <MkInstanceStats/> </MkSpacer> - </MkHorizontalSwipe> + </MkSwiper> </PageWithHeader> </template> @@ -28,7 +28,7 @@ import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/utility/achievements.js'; import { definePage } from '@/page.js'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue')); const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue')); diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue deleted file mode 100644 index fa16f8892c..0000000000 --- a/packages/frontend/src/pages/admin/_header_.vue +++ /dev/null @@ -1,296 +0,0 @@ -<!-- -SPDX-FileCopyrightText: syuilo and misskey-project -SPDX-License-Identifier: AGPL-3.0-only ---> - -<template> -<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick"> - <template v-if="pageMetadata"> - <div class="titleContainer" @click="showTabsPopup"> - <i v-if="pageMetadata.icon" class="icon" :class="pageMetadata.icon"></i> - - <div class="title"> - <div class="title">{{ pageMetadata.title }}</div> - </div> - </div> - <div class="tabs"> - <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> - <i v-if="tab.icon" class="icon" :class="tab.icon"></i> - <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> - </button> - <div ref="tabHighlightEl" class="highlight"></div> - </div> - </template> - <div class="buttons right"> - <template v-if="actions"> - <template v-for="action in actions"> - <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> -</div> -</template> - -<script lang="ts" setup> -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 MkButton from '@/components/MkButton.vue'; -import { globalEvents } from '@/events.js'; -import { DI } from '@/di.js'; - -type Tab = { - key?: string | null; - title: string; - icon?: string; - iconOnly?: boolean; - onClick?: (ev: MouseEvent) => void; -}; - -const props = defineProps<{ - tabs?: Tab[]; - tab?: string; - actions?: { - text: string; - icon: string; - asFullButton?: boolean; - disabled?: boolean; - handler: (ev: MouseEvent) => void; - }[]; - thin?: boolean; -}>(); - -const emit = defineEmits<{ - (ev: 'update:tab', key: string); -}>(); - -const pageMetadata = inject(DI.pageMetadata, ref(null)); - -const el = useTemplateRef('el'); -const tabHighlightEl = useTemplateRef('tabHighlightEl'); -const tabRefs = {}; -const bg = ref<string | null>(null); -const height = ref(0); -const hasTabs = computed(() => { - return props.tabs && props.tabs.length > 0; -}); - -const showTabsPopup = (ev: MouseEvent) => { - if (!hasTabs.value) return; - ev.preventDefault(); - ev.stopPropagation(); - const menu = props.tabs.map(tab => ({ - text: tab.title, - icon: tab.icon, - active: tab.key != null && tab.key === props.tab, - action: (ev) => { - onTabClick(tab, ev); - }, - })); - popupMenu(menu, ev.currentTarget ?? ev.target); -}; - -const preventDrag = (ev: TouchEvent) => { - ev.stopPropagation(); -}; - -const onClick = () => { - scrollToTop(el.value, { behavior: 'smooth' }); -}; - -function onTabMousedown(tab: Tab, ev: MouseEvent): void { - // ユーザビリティの観点からmousedown時にはonClickは呼ばない - if (tab.key) { - emit('update:tab', tab.key); - } -} - -function onTabClick(tab: Tab, ev: MouseEvent): void { - if (tab.onClick) { - ev.preventDefault(); - ev.stopPropagation(); - tab.onClick(ev); - } - if (tab.key) { - emit('update:tab', tab.key); - } -} - -const calcBg = () => { - 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('themeChanging', calcBg); - - watch(() => [props.tab, props.tabs], () => { - nextTick(() => { - const tabEl = tabRefs[props.tab]; - if (tabEl && tabHighlightEl.value) { - // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある - // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 - const parentRect = tabEl.parentElement.getBoundingClientRect(); - const rect = tabEl.getBoundingClientRect(); - tabHighlightEl.value.style.width = rect.width + 'px'; - tabHighlightEl.value.style.left = (rect.left - parentRect.left) + 'px'; - } - }); - }, { - immediate: true, - }); -}); - -onUnmounted(() => { - globalEvents.off('themeChanging', calcBg); -}); -</script> - -<style lang="scss" scoped> -.fdidabkc { - --height: 60px; - display: flex; - width: 100%; - -webkit-backdrop-filter: var(--MI-blur, blur(15px)); - backdrop-filter: var(--MI-blur, blur(15px)); - - > .buttons { - --margin: 8px; - display: flex; - align-items: center; - height: var(--height); - margin: 0 var(--margin); - - &.right { - margin-left: auto; - } - - &:empty { - width: var(--height); - } - - > .button { - display: flex; - align-items: center; - justify-content: center; - height: calc(var(--height) - (var(--margin) * 2)); - width: calc(var(--height) - (var(--margin) * 2)); - box-sizing: border-box; - position: relative; - border-radius: var(--MI-radius-xs); - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &.highlighted { - color: var(--MI_THEME-accent); - } - } - - > .fullButton { - & + .fullButton { - margin-left: 12px; - } - } - } - - > .titleContainer { - display: flex; - align-items: center; - max-width: 400px; - overflow: auto; - white-space: nowrap; - text-align: left; - font-weight: bold; - flex-shrink: 0; - margin-left: 24px; - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: bottom; - margin: 0 8px; - pointer-events: none; - } - - > .icon { - margin-right: 8px; - width: 16px; - text-align: center; - } - - > .title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.1; - - > .subtitle { - opacity: 0.6; - font-size: 0.8em; - font-weight: normal; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &.activeTab { - text-align: center; - - > .chevron { - display: inline-block; - margin-left: 6px; - } - } - } - } - } - - > .tabs { - position: relative; - margin-left: 16px; - font-size: 0.8em; - overflow: auto; - white-space: nowrap; - - > .tab { - display: inline-block; - position: relative; - padding: 0 10px; - height: 100%; - font-weight: normal; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - &.active { - opacity: 1; - } - - > .icon + .title { - margin-left: 8px; - } - } - - > .highlight { - position: absolute; - bottom: 0; - height: 3px; - background: var(--MI_THEME-accent); - border-radius: var(--MI-radius-ellipse); - transition: all 0.2s ease; - pointer-events: none; - } - } -} -</style> diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue index ee87fae606..a569ab7c33 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -4,11 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header> - <XHeader :actions="headerActions" :tabs="headerTabs"/> - </template> - +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div :class="$style.root" class="_gaps_m"> <div :class="$style.addButton"> @@ -41,14 +37,13 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script setup lang="ts"> 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 '@/utility/misskey-api.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index 67d54a85ad..3f1df95f25 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div :class="$style.root" class="_gaps"> <div :class="$style.subMenus" class="_gaps"> @@ -55,12 +54,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> 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'; diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index ebc3d23296..aa8ba2f7c3 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -4,10 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header> - <XHeader :actions="headerActions" :tabs="headerTabs"/> - </template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems"> <template #label>{{ i18n.ts.state }}</template> @@ -81,13 +78,12 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; -import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index f6b331455f..ea7f0cc73d 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div class="_gaps"> <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> @@ -81,12 +80,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> 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'; diff --git a/packages/frontend/src/pages/admin/approvals.vue b/packages/frontend/src/pages/admin/approvals.vue index 521346bd98..c972728c85 100644 --- a/packages/frontend/src/pages/admin/approvals.vue +++ b/packages/frontend/src/pages/admin/approvals.vue @@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div> - <MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <PageWithHeader :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div class="_gaps_m"> <MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50"> @@ -18,13 +17,12 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> </MkSpacer> - </MkStickyContainer> + </PageWithHeader> </div> </template> <script lang="ts" setup> import { computed, useTemplateRef } from 'vue'; -import XHeader from './_header_.vue'; import MkPagination from '@/components/MkPagination.vue'; import SkApprovalUser from '@/components/SkApprovalUser.vue'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/admin/branding.vue b/packages/frontend/src/pages/admin/branding.vue index 84d8ea85da..1afc643a0a 100644 --- a/packages/frontend/src/pages/admin/branding.vue +++ b/packages/frontend/src/pages/admin/branding.vue @@ -4,125 +4,122 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> - <FormSuspense :p="init"> - <div class="_gaps_m"> - <MkInput v-model="iconUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.iconUrl }}</template> - </MkInput> +<PageWithHeader :tabs="headerTabs"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> + <FormSuspense :p="init"> + <div class="_gaps_m"> + <MkInput v-model="iconUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts._serverSettings.iconUrl }}</template> + </MkInput> - <MkInput v-model="app192IconUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template> - <template #caption> - <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> - <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> - <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> - <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div> - </template> - </MkInput> + <MkInput v-model="app192IconUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template> + <template #caption> + <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> + <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> + <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> + <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div> + </template> + </MkInput> - <MkInput v-model="app512IconUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template> - <template #caption> - <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> - <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> - <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> - <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div> - </template> - </MkInput> + <MkInput v-model="app512IconUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template> + <template #caption> + <div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div> + <div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div> + <div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div> + <div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div> + </template> + </MkInput> - <MkInput v-model="sidebarLogoUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts._serverSettings.sidebarLogoUrl }}</template> - <template #caption> - <div>{{ i18n.ts._serverSettings.sidebarLogoDescription }}</div> - <div>({{ i18n.ts._serverSettings.sidebarLogoUsageExample }})</div> - </template> - </MkInput> + <MkInput v-model="sidebarLogoUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts._serverSettings.sidebarLogoUrl }}</template> + <template #caption> + <div>{{ i18n.ts._serverSettings.sidebarLogoDescription }}</div> + <div>({{ i18n.ts._serverSettings.sidebarLogoUsageExample }})</div> + </template> + </MkInput> - <MkInput v-model="bannerUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.bannerUrl }}</template> - </MkInput> + <MkInput v-model="bannerUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.bannerUrl }}</template> + </MkInput> - <MkInput v-model="backgroundImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.backgroundImageUrl }}</template> - </MkInput> + <MkInput v-model="backgroundImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.backgroundImageUrl }}</template> + </MkInput> - <FromSlot> - <template #label>{{ i18n.ts.defaultLike }}</template> - <MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="defaultLike" :normal="true" :noStyle="true"/> - <MkEmoji v-else :emoji="defaultLike" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/> - <MkButton rounded :small="true" @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton> - </FromSlot> + <FromSlot> + <template #label>{{ i18n.ts.defaultLike }}</template> + <MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :name="defaultLike" :normal="true" :noStyle="true"/> + <MkEmoji v-else :emoji="defaultLike" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/> + <MkButton rounded :small="true" @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton> + </FromSlot> - <MkInput v-model="notFoundImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.notFoundDescription }}</template> - </MkInput> + <MkInput v-model="notFoundImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.notFoundDescription }}</template> + </MkInput> - <MkInput v-model="infoImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.nothing }}</template> - </MkInput> + <MkInput v-model="infoImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.nothing }}</template> + </MkInput> - <MkInput v-model="serverErrorImageUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.somethingHappened }}</template> - </MkInput> + <MkInput v-model="serverErrorImageUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.somethingHappened }}</template> + </MkInput> - <MkColorInput v-model="themeColor"> - <template #label>{{ i18n.ts.themeColor }}</template> - </MkColorInput> + <MkColorInput v-model="themeColor"> + <template #label>{{ i18n.ts.themeColor }}</template> + </MkColorInput> - <MkTextarea v-model="defaultLightTheme"> - <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> - <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> - </MkTextarea> + <MkTextarea v-model="defaultLightTheme"> + <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </MkTextarea> - <MkTextarea v-model="defaultDarkTheme"> - <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> - <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> - </MkTextarea> + <MkTextarea v-model="defaultDarkTheme"> + <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </MkTextarea> - <MkInput v-model="repositoryUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.repositoryUrl }}</template> - </MkInput> + <MkInput v-model="repositoryUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.repositoryUrl }}</template> + </MkInput> - <MkInput v-model="feedbackUrl" type="url"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.feedbackUrl }}</template> - </MkInput> + <MkInput v-model="feedbackUrl" type="url"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.feedbackUrl }}</template> + </MkInput> - <MkTextarea v-model="manifestJsonOverride"> - <template #label>{{ i18n.ts._serverSettings.manifestJsonOverride }}</template> - </MkTextarea> - </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> + <MkTextarea v-model="manifestJsonOverride"> + <template #label>{{ i18n.ts._serverSettings.manifestJsonOverride }}</template> + </MkTextarea> </div> - </template> - </MkStickyContainer> -</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> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import JSON5 from 'json5'; -import XHeader from './_header_.vue'; +import { host } from '@@/js/config.js'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import FromSlot from '@/components/form/slot.vue'; @@ -134,7 +131,6 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import MkColorInput from '@/components/MkColorInput.vue'; -import { host } from '@@/js/config.js'; const iconUrl = ref<string | null>(null); const sidebarLogoUrl = ref<string | null>(null); diff --git a/packages/frontend/src/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index d018977bda..f1827d756b 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> +<PageWithHeader :tabs="headerTabs"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> @@ -60,12 +59,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; -import XHeader from './_header_.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import FormInfo from '@/components/MkInfo.vue'; diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue index f9dd5dc568..131989844a 100644 --- a/packages/frontend/src/pages/admin/external-services.vue +++ b/packages/frontend/src/pages/admin/external-services.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> @@ -54,12 +53,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSuspense> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; -import XHeader from './_header_.vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue index 5dd2887024..5dd2887024 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue diff --git a/packages/frontend/src/pages/admin/queue.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.vue index 1ba02d6e0e..4b10d682a5 100644 --- a/packages/frontend/src/pages/admin/queue.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.vue @@ -50,8 +50,8 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> 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 XChart from './federation-job-queue.chart.chart.vue'; +import type { ApQueueDomain } from '@/pages/admin/federation-job-queue.vue'; import number from '@/filters/number.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { useStream } from '@/stream.js'; diff --git a/packages/frontend/src/pages/admin/queue.vue b/packages/frontend/src/pages/admin/federation-job-queue.vue index b5aee1e51e..77e460d0eb 100644 --- a/packages/frontend/src/pages/admin/queue.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.vue @@ -4,22 +4,22 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <XQueue v-if="tab === 'deliver'" domain="deliver"/> <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> <br> - <MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton> + <div class="_buttons"> + <MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton> + <MkButton danger @click="clear"><i class="ti ti-trash"></i> {{ i18n.ts.clearQueue }}</MkButton> + </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <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 XQueue from './federation-job-queue.chart.vue'; import type { Ref } from 'vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; @@ -38,7 +38,7 @@ function clear() { }).then(({ canceled }) => { if (canceled) return; - os.apiWithDialog('admin/queue/clear'); + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: '*' }); }); } @@ -50,7 +50,7 @@ function promoteAllQueues() { }).then(({ canceled }) => { if (canceled) return; - os.apiWithDialog('admin/queue/promote', { type: tab.value }); + os.apiWithDialog('admin/queue/promote-jobs', { queue: tab.value }); }); } @@ -65,7 +65,7 @@ const headerTabs = computed(() => [{ }]); definePage(() => ({ - title: i18n.ts.jobQueue, + title: i18n.ts.federationJobs, icon: 'ti ti-clock-play', })); </script> diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 6c8979c5b5..dcc8c52e55 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -4,65 +4,61 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :actions="headerActions"/></template> - <MkSpacer :contentMax="900"> - <div class="_gaps"> - <div> - <MkInput v-model="host" :debounce="true" class=""> - <template #prefix><i class="ti ti-search"></i></template> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - <FormSplit style="margin-top: var(--MI-margin);"> - <MkSelect v-model="state"> - <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="federating">{{ i18n.ts.federating }}</option> - <option value="subscribing">{{ i18n.ts.subscribing }}</option> - <option value="publishing">{{ i18n.ts.publishing }}</option> - <!-- TODO translate --> - <option value="nsfw">NSFW</option> - <option value="suspended">{{ i18n.ts.suspended }}</option> - <option value="blocked">{{ i18n.ts.blocked }}</option> - <option value="silenced">{{ i18n.ts.silence }}</option> - <option value="notResponding">{{ i18n.ts.notResponding }}</option> - </MkSelect> - <MkSelect v-model="sort"> - <template #label>{{ i18n.ts.sort }}</template> - <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> - </MkSelect> - </FormSplit> - </div> - - <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination" :displayLimit="50"> - <div :class="$style.instances"> - <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.instance" :to="`/instance-info/${instance.host}`"> - <MkInstanceCardMini :instance="instance"/> - </MkA> - </div> - </MkPagination> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="900"> + <div class="_gaps"> + <div> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="ti ti-search"></i></template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + <FormSplit style="margin-top: var(--MI-margin);"> + <MkSelect v-model="state"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="federating">{{ i18n.ts.federating }}</option> + <option value="subscribing">{{ i18n.ts.subscribing }}</option> + <option value="publishing">{{ i18n.ts.publishing }}</option> + <!-- TODO translate --> + <option value="nsfw">NSFW</option> + <option value="suspended">{{ i18n.ts.suspended }}</option> + <option value="blocked">{{ i18n.ts.blocked }}</option> + <option value="silenced">{{ i18n.ts.silence }}</option> + <option value="notResponding">{{ i18n.ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option> + </MkSelect> + </FormSplit> </div> - </MkSpacer> - </MkStickyContainer> -</div> + + <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination" :displayLimit="50"> + <div :class="$style.instances"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.instance" :to="`/instance-info/${instance.host}`"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </MkPagination> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> import * as Misskey from 'misskey-js'; import { computed, ref } from 'vue'; -import XHeader from './_header_.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkPagination from '@/components/MkPagination.vue'; diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index e15724c2a7..12c633bf7f 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -4,40 +4,36 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :actions="headerActions"/></template> - <MkSpacer :contentMax="900"> - <div class="_gaps"> - <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> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkSelect> - <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - </div> - <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> - <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> - <template #label>MIME type</template> - </MkInput> - </div> - <MkFileListForAdmin :pagination="pagination" :viewMode="viewMode"/> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="900"> + <div class="_gaps"> + <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> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> </div> - </MkSpacer> - </MkStickyContainer> -</div> + <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> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkFileListForAdmin :pagination="pagination" :viewMode="viewMode"/> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; -import XHeader from './_header_.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 85c8ba15ba..c366a7cd6a 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkSpacer> </div> - <div v-if="!(narrow && currentPage?.route.name == null)" class="main"> + <div v-if="!(narrow && currentPage?.route.name == null)" class="main _pageContainer" style="height: 100%;"> <NestedRouterView/> </div> </div> @@ -162,9 +162,14 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ active: currentPage.value?.route.name === 'federation', }, { icon: 'ti ti-clock-play', + text: i18n.ts.federationJobs, + to: '/admin/federation-job-queue', + active: currentPage.value?.route.name === 'federationJobQueue', + }, { + icon: 'ti ti-clock-play', text: i18n.ts.jobQueue, - to: '/admin/queue', - active: currentPage.value?.route.name === 'queue', + to: '/admin/job-queue', + active: currentPage.value?.route.name === 'jobQueue', }, { icon: 'ti ti-cloud', text: i18n.ts.files, @@ -351,6 +356,8 @@ defineExpose({ <style lang="scss" scoped> .hiyeyicy { + height: 100%; + &.wide { display: flex; margin: 0 auto; diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 7dae1db839..7f0c35d6bc 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div class="_gaps_m"> <MkFolder :expanded="false"> @@ -51,12 +50,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkPagination> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> 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'; diff --git a/packages/frontend/src/pages/admin/job-queue.chart.vue b/packages/frontend/src/pages/admin/job-queue.chart.vue new file mode 100644 index 0000000000..f42b35105e --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.chart.vue @@ -0,0 +1,127 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, useTemplateRef, watch } from 'vue'; +import { Chart } from '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(); + +const props = defineProps<{ + dataSet: { + completed: number[]; + failed: number[]; + }; + aspectRatio?: number; +}>(); + +const chartEl = useTemplateRef('chartEl'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData() { + if (chartInstance == null) return; + chartInstance.data.labels = []; + for (let i = 0; i < Math.max(props.dataSet.completed.length, props.dataSet.failed.length); i++) { + chartInstance.data.labels.push(''); + } + chartInstance.data.datasets[0].data = props.dataSet.completed; + chartInstance.data.datasets[1].data = props.dataSet.failed; + chartInstance.update(); +} + +watch(() => props.dataSet, () => { + setData(); +}); + +onMounted(() => { + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Completed', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#4caf50', + backgroundColor: alpha('#4caf50', 0.2), + fill: true, + data: [], + }, { + label: 'Failed', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#ff0000', + backgroundColor: alpha('#ff0000', 0.2), + fill: true, + data: [], + }], + }, + options: { + aspectRatio: props.aspectRatio ?? 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + setData(); +}); +</script> diff --git a/packages/frontend/src/pages/admin/job-queue.job.vue b/packages/frontend/src/pages/admin/job-queue.job.vue new file mode 100644 index 0000000000..71efab0272 --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.job.vue @@ -0,0 +1,280 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder> + <template #label> + <span v-if="job.opts.repeat != null" style="margin-right: 1em;"><repeat></span> + <span v-else style="margin-right: 1em;">#{{ job.id }}</span> + <span>{{ job.name }}</span> + </template> + <template #suffix> + <MkTime :time="job.finishedOn ?? job.processedOn ?? job.timestamp" mode="relative"/> + <span v-if="job.progress != null && job.progress > 0" style="margin-left: 1em;">{{ Math.floor(job.progress * 100) }}%</span> + <span v-if="job.opts.attempts != null && job.opts.attempts > 0 && job.attempts > 1" style="margin-left: 1em; color: var(--MI_THEME-warn); font-variant-numeric: diagonal-fractions;">{{ job.attempts }}/{{ job.opts.attempts }}</span> + <span v-if="job.isFailed && job.finishedOn != null" style="margin-left: 1em; color: var(--MI_THEME-error)"><i class="ti ti-circle-x"></i></span> + <span v-else-if="job.isFailed" style="margin-left: 1em; color: var(--MI_THEME-warn)"><i class="ti ti-alert-triangle"></i></span> + <span v-else-if="job.finishedOn != null" style="margin-left: 1em; color: var(--MI_THEME-success)"><i class="ti ti-check"></i></span> + <span v-else-if="job.delay != null && job.delay != 0" style="margin-left: 1em;"><i class="ti ti-clock"></i></span> + <span v-else-if="job.processedOn != null" style="margin-left: 1em; color: var(--MI_THEME-success)"><i class="ti ti-player-play"></i></span> + </template> + <template #header> + <MkTabs + v-model:tab="tab" + :tabs="[{ + key: 'info', + title: 'Info', + icon: 'ti ti-info-circle', + }, { + key: 'timeline', + title: 'Timeline', + icon: 'ti ti-timeline-event', + }, { + key: 'data', + title: 'Data', + icon: 'ti ti-package', + }, ...(canEdit ? [{ + key: 'dataEdit', + title: 'Data (edit)', + icon: 'ti ti-package', + }] : []), + ...(job.returnValue != null ? [{ + key: 'result', + title: 'Result', + icon: 'ti ti-check', + }] : []), + ...(job.stacktrace.length > 0 ? [{ + key: 'error', + title: 'Error', + icon: 'ti ti-alert-triangle', + }] : []), { + key: 'logs', + title: 'Logs', + icon: 'ti ti-logs', + }]" + /> + </template> + <template #footer> + <div class="_buttons"> + <MkButton rounded @click="copyRaw()"><i class="ti ti-copy"></i> Copy raw</MkButton> + <MkButton rounded @click="refresh()"><i class="ti ti-reload"></i> Refresh view</MkButton> + <MkButton rounded @click="promoteJob()"><i class="ti ti-player-track-next"></i> Promote</MkButton> + <MkButton rounded @click="moveJob"><i class="ti ti-arrow-right"></i> Move to</MkButton> + <MkButton danger rounded style="margin-left: auto;" @click="removeJob()"><i class="ti ti-trash"></i> Remove</MkButton> + </div> + </template> + + <div v-if="tab === 'info'" class="_gaps_s"> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px;"> + <MkKeyValue> + <template #key>ID</template> + <template #value>{{ job.id }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Created at</template> + <template #value><MkTime :time="job.timestamp" mode="detail"/></template> + </MkKeyValue> + <MkKeyValue v-if="job.processedOn != null"> + <template #key>Processed at</template> + <template #value><MkTime :time="job.processedOn" mode="detail"/></template> + </MkKeyValue> + <MkKeyValue v-if="job.finishedOn != null"> + <template #key>Finished at</template> + <template #value><MkTime :time="job.finishedOn" mode="detail"/></template> + </MkKeyValue> + <MkKeyValue v-if="job.processedOn != null && job.finishedOn != null"> + <template #key>Spent</template> + <template #value>{{ job.finishedOn - job.processedOn }}ms</template> + </MkKeyValue> + <MkKeyValue v-if="job.failedReason != null"> + <template #key>Failed reason</template> + <template #value><i style="color: var(--MI_THEME-error)" class="ti ti-alert-triangle"></i> {{ job.failedReason }}</template> + </MkKeyValue> + <MkKeyValue v-if="job.opts.attempts != null && job.opts.attempts > 0"> + <template #key>Attempts</template> + <template #value>{{ job.attempts }} of {{ job.opts.attempts }}</template> + </MkKeyValue> + <MkKeyValue v-if="job.progress != null && job.progress > 0"> + <template #key>Progress</template> + <template #value>{{ Math.floor(job.progress * 100) }}%</template> + </MkKeyValue> + </div> + <MkFolder :withSpacer="false"> + <template #label>Options</template> + <MkCode :code="JSON5.stringify(job.opts, null, '\t')" lang="js"/> + </MkFolder> + </div> + <div v-else-if="tab === 'timeline'"> + <MkTl :events="timeline"> + <template #left="{ event }"> + <div> + <template v-if="event.type === 'finished'"> + <template v-if="job.isFailed"> + <b>Finished</b> <i class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> + </template> + <template v-else> + <b>Finished</b> <i class="ti ti-check" style="color: var(--MI_THEME-success);"></i> + </template> + </template> + <template v-else-if="event.type === 'processed'"> + <b>Processed</b> <i class="ti ti-player-play"></i> + </template> + <template v-else-if="event.type === 'attempt'"> + <b>Attempt #{{ event.attempt }}</b> <i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> + </template> + <template v-else-if="event.type === 'created'"> + <b>Created</b> <i class="ti ti-plus"></i> + </template> + </div> + </template> + <template #right="{ event, timestamp, delta }"> + <div style="margin: 8px 0;"> + <template v-if="event.type === 'attempt'"> + <div>at ?</div> + </template> + <template v-else> + <div>at <MkTime :time="timestamp" mode="detail"/></div> + <div style="font-size: 90%; opacity: 0.7;">{{ timestamp }} (+{{ msSMH(delta) }})</div> + </template> + </div> + </template> + </MkTl> + </div> + <div v-else-if="tab === 'data'"> + <MkCode :code="JSON5.stringify(job.data, null, '\t')" lang="js"/> + </div> + <div v-else-if="tab === 'dataEdit'" class="_gaps_s"> + <MkCodeEditor v-model="editData" lang="json5"></MkCodeEditor> + <MkButton><i class="ti ti-device-floppy"></i> Update</MkButton> + </div> + <div v-else-if="tab === 'result'"> + <MkCode :code="job.returnValue"/> + </div> + <div v-else-if="tab === 'error'" class="_gaps_s"> + <MkCode v-for="log in job.stacktrace" :code="log" lang="stacktrace"/> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, computed, watch } from 'vue'; +import JSON5 from 'json5'; +import type { Ref } from 'vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import MkButton from '@/components/MkButton.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkTabs from '@/components/MkTabs.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkCodeEditor from '@/components/MkCodeEditor.vue'; +import MkTl from '@/components/MkTl.vue'; +import kmg from '@/filters/kmg.js'; +import bytes from '@/filters/bytes.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; + +function msSMH(v: number | null) { + if (v == null) return 'N/A'; + if (v === 0) return '0'; + const suffixes = ['ms', 's', 'm', 'h']; + const isMinus = v < 0; + if (isMinus) v = -v; + const i = Math.floor(Math.log(v) / Math.log(1000)); + const value = v / Math.pow(1000, i); + const suffix = suffixes[i]; + return `${isMinus ? '-' : ''}${value.toFixed(1)}${suffix}`; +} + +const props = defineProps<{ + job: any; + queueType: string; +}>(); + +const emit = defineEmits<{ + (ev: 'needRefresh'): void, +}>(); + +const tab = ref('info'); +const editData = ref(JSON5.stringify(props.job.data, null, '\t')); +const canEdit = true; +const timeline = computed(() => { + const events = [{ + id: 'created', + timestamp: props.job.timestamp, + data: { + type: 'created', + }, + }]; + if (props.job.attempts > 1) { + for (let i = 1; i < props.job.attempts; i++) { + events.push({ + id: `attempt-${i}`, + timestamp: props.job.timestamp + i, + data: { + type: 'attempt', + attempt: i, + }, + }); + } + } + if (props.job.processedOn != null) { + events.push({ + id: 'processed', + timestamp: props.job.processedOn, + data: { + type: 'processed', + }, + }); + } + if (props.job.finishedOn != null) { + events.push({ + id: 'finished', + timestamp: props.job.finishedOn, + data: { + type: 'finished', + }, + }); + } + return events; +}); + +async function promoteJob() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/retry-job', { queue: props.queueType, jobId: props.job.id }); +} + +async function removeJob() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/remove-job', { queue: props.queueType, jobId: props.job.id }); +} + +function moveJob() { + // TODO +} + +function refresh() { + emit('needRefresh'); +} + +function copyRaw() { + const raw = JSON.stringify(props.job, null, '\t'); + copyToClipboard(raw); +} +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue new file mode 100644 index 0000000000..528c473c4f --- /dev/null +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -0,0 +1,370 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> + <MkSpacer> + <div v-if="tab === '-'" class="_gaps"> + <div :class="$style.queues"> + <div v-for="q in queueInfos" :key="q.name" :class="$style.queue" @click="tab = q.name"> + <div style="display: flex; align-items: center; font-weight: bold;"><i class="ti ti-http-que" style="margin-right: 0.5em;"></i>{{ q.name }}<i v-if="!q.isPaused" style="color: var(--MI_THEME-success); margin-left: auto;" class="ti ti-player-play"></i></div> + <div :class="$style.queueCounts"> + <MkKeyValue> + <template #key>Active</template> + <template #value>{{ kmg(q.counts.active, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Delayed</template> + <template #value>{{ kmg(q.counts.delayed, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Waiting</template> + <template #value>{{ kmg(q.counts.waiting, 2) }}</template> + </MkKeyValue> + </div> + <XChart :dataSet="{ completed: q.metrics.completed.data, failed: q.metrics.failed.data }"/> + </div> + </div> + </div> + <div v-else-if="queueInfo" class="_gaps"> + <MkFolder :defaultOpen="true"> + <template #label>Overview: {{ tab }}</template> + <template #icon><i class="ti ti-http-que"></i></template> + <template #suffix>#{{ queueInfo.db.processId }}:{{ queueInfo.db.port }} / {{ queueInfo.db.runId }}</template> + <template #caption>{{ queueInfo.qualifiedName }}</template> + <template #footer> + <div class="_buttons"> + <MkButton rounded @click="promoteAllJobs"><i class="ti ti-player-track-next"></i> Promote all jobs</MkButton> + <MkButton rounded @click="createJob"><i class="ti ti-plus"></i> Add job</MkButton> + <MkButton v-if="queueInfo.isPaused" rounded @click="resumeQueue"><i class="ti ti-player-play"></i> Resume queue</MkButton> + <MkButton v-else rounded danger @click="pauseQueue"><i class="ti ti-player-pause"></i> Pause queue</MkButton> + <MkButton rounded danger @click="clearQueue"><i class="ti ti-trash"></i> Empty queue</MkButton> + </div> + </template> + + <div class="_gaps"> + <XChart :dataSet="{ completed: queueInfo.metrics.completed.data, failed: queueInfo.metrics.failed.data }" :aspectRatio="5"/> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px;"> + <MkKeyValue> + <template #key>Active</template> + <template #value>{{ kmg(queueInfo.counts.active, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Delayed</template> + <template #value>{{ kmg(queueInfo.counts.delayed, 2) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Waiting</template> + <template #value>{{ kmg(queueInfo.counts.waiting, 2) }}</template> + </MkKeyValue> + </div> + <hr> + <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px;"> + <MkKeyValue> + <template #key>Clients: Connected</template> + <template #value>{{ queueInfo.db.clients.connected }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Clients: Blocked</template> + <template #value>{{ queueInfo.db.clients.blocked }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Memory: Peak</template> + <template #value>{{ bytes(queueInfo.db.memory.peak, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Memory: Total</template> + <template #value>{{ bytes(queueInfo.db.memory.total, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Memory: Used</template> + <template #value>{{ bytes(queueInfo.db.memory.used, 1) }}</template> + </MkKeyValue> + <MkKeyValue> + <template #key>Uptime</template> + <template #value>{{ queueInfo.db.uptime }}</template> + </MkKeyValue> + </div> + </div> + </MkFolder> + + <MkFolder :defaultOpen="true" :withSpacer="false"> + <template #label>Jobs: {{ tab }}</template> + <template #icon><i class="ti ti-list-check"></i></template> + <template #suffix><A:{{ kmg(queueInfo.counts.active, 2) }}> <D:{{ kmg(queueInfo.counts.delayed, 2) }}> <W:{{ kmg(queueInfo.counts.waiting, 2) }}></template> + <template #header> + <MkTabs + v-model:tab="jobState" + :class="$style.jobsTabs" :tabs="[{ + key: 'all', + title: 'All', + icon: 'ti ti-code-asterisk', + }, { + key: 'latest', + title: 'Latest', + icon: 'ti ti-logs', + }, { + key: 'completed', + title: 'Completed', + icon: 'ti ti-check', + }, { + key: 'failed', + title: 'Failed', + icon: 'ti ti-circle-x', + }, { + key: 'active', + title: 'Active', + icon: 'ti ti-player-play', + }, { + key: 'delayed', + title: 'Delayed', + icon: 'ti ti-clock', + }, { + key: 'wait', + title: 'Waiting', + icon: 'ti ti-hourglass-high', + }, { + key: 'paused', + title: 'Paused', + icon: 'ti ti-player-pause', + }]" + /> + </template> + <template #footer> + <div class="_buttons"> + <MkButton rounded @click="fetchJobs()"><i class="ti ti-reload"></i> Refresh view</MkButton> + <MkButton rounded danger style="margin-left: auto;" @click="removeJobs"><i class="ti ti-trash"></i> Remove jobs</MkButton> + </div> + </template> + + <MkSpacer> + <MkInput + v-model="searchQuery" + :placeholder="i18n.ts.search" + type="search" + style="margin-bottom: 16px;" + > + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + + <MkLoading v-if="jobsFetching"/> + <MkTl + v-else + :events="jobs.map((job) => ({ + id: job.id, + timestamp: job.finishedOn ?? job.processedOn ?? job.timestamp, + data: job, + }))" + class="_monospace" + > + <template #right="{ event: job }"> + <XJob :job="job" :queueType="tab" style="margin: 4px 0;" @needRefresh="refreshJob(job.id)"/> + </template> + </MkTl> + </MkSpacer> + </MkFolder> + </div> + </MkSpacer> +</PageWithHeader> +</template> + +<script lang="ts" setup> +import { ref, computed, watch } from 'vue'; +import JSON5 from 'json5'; +import { debounce } from 'throttle-debounce'; +import { useInterval } from '@@/js/use-interval.js'; +import XChart from './job-queue.chart.vue'; +import XJob from './job-queue.job.vue'; +import type { Ref } from 'vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { definePage } from '@/page.js'; +import MkButton from '@/components/MkButton.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkTabs from '@/components/MkTabs.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkCode from '@/components/MkCode.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkTl from '@/components/MkTl.vue'; +import kmg from '@/filters/kmg.js'; +import MkInput from '@/components/MkInput.vue'; +import bytes from '@/filters/bytes.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; + +const QUEUE_TYPES = [ + 'system', + 'endedPollNotification', + 'deliver', + 'inbox', + 'db', + 'relationship', + 'objectStorage', + 'userWebhookDeliver', + 'systemWebhookDeliver', +] as const; + +const tab: Ref<typeof QUEUE_TYPES[number] | '-'> = ref('-'); +const jobState = ref('all'); +const jobs = ref([]); +const jobsFetching = ref(true); +const queueInfos = ref([]); +const queueInfo = ref(); +const searchQuery = ref(''); + +async function fetchQueues() { + if (tab.value !== '-') return; + queueInfos.value = await misskeyApi('admin/queue/queues'); +} + +async function fetchCurrentQueue() { + if (tab.value === '-') return; + queueInfo.value = await misskeyApi('admin/queue/queue-stats', { queue: tab.value }); +} + +async function fetchJobs() { + jobsFetching.value = true; + const state = jobState.value; + jobs.value = await misskeyApi('admin/queue/jobs', { + queue: tab.value, + state: state === 'all' ? ['completed', 'failed', 'active', 'delayed', 'wait'] : state === 'latest' ? ['completed', 'failed'] : [state], + search: searchQuery.value.trim() === '' ? undefined : searchQuery.value, + }).then(res => { + if (state === 'all') { + res.sort((a, b) => (a.processedOn ?? a.timestamp) > (b.processedOn ?? b.timestamp) ? -1 : 1); + } else if (state === 'latest') { + res.sort((a, b) => a.processedOn > b.processedOn ? -1 : 1); + } else if (state === 'delayed') { + res.sort((a, b) => (a.processedOn ?? a.timestamp) > (b.processedOn ?? b.timestamp) ? -1 : 1); + } + return res; + }); + jobsFetching.value = false; +} + +watch([tab], async () => { + if (tab.value === '-') { + fetchQueues(); + } else { + fetchCurrentQueue(); + fetchJobs(); + } +}, { immediate: true }); + +watch([jobState], () => { + fetchJobs(); +}); + +const search = debounce(1000, () => { + fetchJobs(); +}); + +watch([searchQuery], () => { + search(); +}); + +useInterval(() => { + if (tab.value === '-') { + fetchQueues(); + } else { + fetchCurrentQueue(); + } +}, 1000 * 10, { + immediate: false, + afterMounted: true, +}); + +async function clearQueue() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: '*' }); + + fetchCurrentQueue(); + fetchJobs(); +} + +async function promoteAllJobs() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/promote-jobs', { queue: tab.value }); + + fetchCurrentQueue(); + fetchJobs(); +} + +async function removeJobs() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.ts.areYouSure, + }); + if (canceled) return; + + os.apiWithDialog('admin/queue/clear', { queue: tab.value, state: jobState.value }); + + fetchCurrentQueue(); + fetchJobs(); +} + +async function refreshJob(jobId: string) { + const newJob = await misskeyApi('admin/queue/show-job', { queue: tab.value, jobId }); + const index = jobs.value.findIndex((job) => job.id === jobId); + if (index !== -1) { + jobs.value[index] = newJob; + } +} + +const headerActions = computed(() => []); + +const headerTabs = computed(() => + [{ + key: '-', + title: i18n.ts.overview, + icon: 'ti ti-dashboard', + }].concat(QUEUE_TYPES.map((t) => ({ + key: t, + title: t, + }))), +); + +definePage(() => ({ + title: i18n.ts.jobQueue, + icon: 'ti ti-clock-play', + needWideArea: true, +})); +</script> + +<style lang="scss" module> +.queues { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +.queue { + padding: 14px 18px; + background-color: var(--MI_THEME-panel); + border-radius: 8px; + cursor: pointer; +} + +.queueCounts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + font-size: 85%; + margin: 6px 0; +} + +.jobsTabs { + +} +</style> diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index a71c8f435d..5491a2df45 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -4,160 +4,156 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> - <FormSuspense :p="init"> - <div class="_gaps_m"> - <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> +<PageWithHeader :tabs="headerTabs"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> + <FormSuspense :p="init"> + <div class="_gaps_m"> + <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" @change="onChange_emailRequiredForSignup"> - <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> - </MkSwitch> + <MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup"> + <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + </MkSwitch> - <MkSwitch v-model="approvalRequiredForSignup" @change="onChange_approvalRequiredForSignup"> - <template #label>{{ i18n.ts.approvalRequiredForSignup }}</template> - </MkSwitch> + <MkSwitch v-model="approvalRequiredForSignup" @change="onChange_approvalRequiredForSignup"> + <template #label>{{ i18n.ts.approvalRequiredForSignup }}</template> + </MkSwitch> - <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> + <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> - <!-- TODO translate --> - <MkFolder v-if="bubbleTimelineEnabled"> - <template #icon><i class="ph-drop ph-bold ph-lg"></i></template> - <template #label>Bubble timeline</template> + <!-- TODO translate --> + <MkFolder v-if="bubbleTimelineEnabled"> + <template #icon><i class="ph-drop ph-bold ph-lg"></i></template> + <template #label>Bubble timeline</template> - <div class="_gaps"> - <MkTextarea v-model="bubbleTimeline"> - <template #caption>Choose which instances should be displayed in the bubble.</template> - </MkTextarea> - <MkButton primary @click="save_bubbleTimeline">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="bubbleTimeline"> + <template #caption>Choose which instances should be displayed in the bubble.</template> + </MkTextarea> + <MkButton primary @click="save_bubbleTimeline">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> - <MkFolder> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.trustedLinkUrlPatterns }}</template> + <MkFolder> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.trustedLinkUrlPatterns }}</template> - <div class="_gaps"> - <MkTextarea v-model="trustedLinkUrlPatterns"> - <template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template> - </MkTextarea> - <MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton> - </div> - </MkFolder> + <div class="_gaps"> + <MkTextarea v-model="trustedLinkUrlPatterns"> + <template #caption>{{ i18n.ts.trustedLinkUrlPatternsDescription }}</template> + </MkTextarea> + <MkButton primary @click="save_trustedLinkUrlPatterns">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> - <MkFolder> - <template #icon><i class="ti ti-lock-star"></i></template> - <template #label>{{ i18n.ts.preservedUsernames }}</template> + <MkFolder> + <template #icon><i class="ti ti-lock-star"></i></template> + <template #label>{{ i18n.ts.preservedUsernames }}</template> - <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> + <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> + <MkFolder> + <template #icon><i class="ti ti-message-exclamation"></i></template> + <template #label>{{ i18n.ts.sensitiveWords }}</template> - <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> + <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> + <MkFolder> + <template #icon><i class="ti ti-message-x"></i></template> + <template #label>{{ i18n.ts.prohibitedWords }}</template> - <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> + <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> + <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> + <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> + <MkFolder> + <template #icon><i class="ti ti-eye-off"></i></template> + <template #label>{{ i18n.ts.hiddenTags }}</template> - <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> + <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> + <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> + <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> + <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> + <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> + <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> - </MkStickyContainer> -</div> + <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> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } 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'; diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 5ea1c7f599..5b049fb976 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -131,8 +131,49 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'addRelay'">: {{ log.info.inbox }}</span> <span v-else-if="log.type === 'removeRelay'">: {{ log.info.inbox }}</span> </template> - <template v-if="log.user" #icon> - <MkAvatar :user="log.user" :class="$style.avatar"/> + <template #icon> + <i v-if="log.type === 'updateServerSettings'" class="ti ti-settings"></i> + <i v-else-if="log.type === 'updateUserNote'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'suspend'" class="ti ti-user-x"></i> + <i v-else-if="log.type === 'unsuspend'" class="ti ti-user-check"></i> + <i v-else-if="log.type === 'resetPassword'" class="ti ti-key"></i> + <i v-else-if="log.type === 'assignRole'" class="ti ti-user-plus"></i> + <i v-else-if="log.type === 'unassignRole'" class="ti ti-user-minus"></i> + <i v-else-if="log.type === 'createRole'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateRole'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteRole'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'addCustomEmoji'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateCustomEmoji'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteCustomEmoji'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'markSensitiveDriveFile'" class="ti ti-eye-exclamation"></i> + <i v-else-if="log.type === 'unmarkSensitiveDriveFile'" class="ti ti-eye"></i> + <i v-else-if="log.type === 'suspendRemoteInstance'" class="ti ti-x"></i> + <i v-else-if="log.type === 'unsuspendRemoteInstance'" class="ti ti-check"></i> + <i v-else-if="log.type === 'createGlobalAnnouncement'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateGlobalAnnouncement'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteGlobalAnnouncement'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'createUserAnnouncement'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateUserAnnouncement'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteUserAnnouncement'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deleteNote'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deleteDriveFile'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'createAd'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateAd'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteAd'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'createAvatarDecoration'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateAvatarDecoration'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteAvatarDecoration'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'createSystemWebhook'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateSystemWebhook'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteSystemWebhook'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'createAbuseReportNotificationRecipient'" class="ti ti-plus"></i> + <i v-else-if="log.type === 'updateAbuseReportNotificationRecipient'" class="ti ti-pencil"></i> + <i v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deleteAccount'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deletePage'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deleteFlash'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deleteGalleryPost'" class="ti ti-trash"></i> + <i v-else-if="log.type === 'deleteChatRoom'" class="ti ti-trash"></i> </template> <template #suffix> <MkTime :time="log.createdAt"/> @@ -304,11 +345,6 @@ const props = defineProps<{ </script> <style lang="scss" module> -.avatar { - width: 18px; - height: 18px; -} - .diff { background: #fff; color: #000; diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 32a20e6dd4..2d43e3b790 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -4,10 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> - <div> + <div class="_gaps"> <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> @@ -19,41 +18,68 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </div> - <MkPagination v-slot="{items}" ref="logs" :pagination="pagination" :displayLimit="50" 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> + <MkTl :events="timeline" :displayLimit="50" style="margin-top: var(--MI-margin);"> + <template #left="{ event }"> + <div> + <MkAvatar :user="event.user" style="width: 24px; height: 24px;"/> + </div> + </template> + <template #right="{ event, timestamp, delta }"> + <div style="margin: 4px 0;"> + <XModLog :key="event.id" :log="event"/> + </div> + </template> + </MkTl> + + <MkButton primary rounded style="margin: 0 auto;" @click="fetchMore">{{ i18n.ts.loadMore }}</MkButton> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> -import { computed, useTemplateRef, ref } from 'vue'; +import { computed, useTemplateRef, ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; -import XHeader from './_header_.vue'; import XModLog from './modlog.ModLog.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; -import MkPagination from '@/components/MkPagination.vue'; +import MkTl from '@/components/MkTl.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; - -const logs = useTemplateRef('logs'); +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkButton from '@/components/MkButton.vue'; const type = ref<string | null>(null); const moderatorId = ref(''); -const pagination = { - endpoint: 'admin/show-moderation-logs' as const, - limit: 30, - params: computed(() => ({ +const timeline = ref([]); + +watch([type, moderatorId], async () => { + const res = await misskeyApi('admin/show-moderation-logs', { + type: type.value, + userId: moderatorId.value === '' ? null : moderatorId.value, + }); + timeline.value = res.map(x => ({ + id: x.id, + timestamp: x.createdAt, + data: x, + })); +}, { immediate: true }); + +function fetchMore() { + const last = timeline.value[timeline.value.length - 1]; + misskeyApi('admin/show-moderation-logs', { type: type.value, userId: moderatorId.value === '' ? null : moderatorId.value, - })), -}; + untilId: last.id, + }).then(res => { + timeline.value.push(...res.map(x => ({ + id: x.id, + timestamp: x.createdAt, + data: x, + }))); + }); +} const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/admin/object-storage.vue b/packages/frontend/src/pages/admin/object-storage.vue index da96eb4881..36f4392142 100644 --- a/packages/frontend/src/pages/admin/object-storage.vue +++ b/packages/frontend/src/pages/admin/object-storage.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> +<PageWithHeader :tabs="headerTabs"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> <div class="_gaps_m"> @@ -79,12 +78,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSpacer> </div> </template> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; -import XHeader from './_header_.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInput from '@/components/MkInput.vue'; import FormSuspense from '@/components/form/suspense.vue'; diff --git a/packages/frontend/src/pages/admin/performance.vue b/packages/frontend/src/pages/admin/performance.vue index 6bb0918fea..075db4ebef 100644 --- a/packages/frontend/src/pages/admin/performance.vue +++ b/packages/frontend/src/pages/admin/performance.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <div class="_gaps"> <div class="_panel" style="padding: 16px;"> @@ -104,12 +103,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </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'; diff --git a/packages/frontend/src/pages/admin/relays.vue b/packages/frontend/src/pages/admin/relays.vue index a6280e7075..7803edc360 100644 --- a/packages/frontend/src/pages/admin/relays.vue +++ b/packages/frontend/src/pages/admin/relays.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> <div class="_gaps"> <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;"> @@ -20,13 +19,12 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; 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 '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index 7741064685..62777f59ef 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -4,28 +4,24 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> - <XEditor v-if="data" v-model="data"/> - </MkSpacer> - <template #footer> - <div :class="$style.footer"> - <MkSpacer :contentMax="600" :marginMin="16" :marginMax="16"> - <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> - </MkSpacer> - </div> - </template> - </MkStickyContainer> -</div> +<PageWithHeader :tabs="headerTabs"> + <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32"> + <XEditor v-if="data" v-model="data"/> + </MkSpacer> + <template #footer> + <div :class="$style.footer"> + <MkSpacer :contentMax="600" :marginMin="16" :marginMax="16"> + <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </MkSpacer> + </div> + </template> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, ref } from 'vue'; import * as Misskey from 'misskey-js'; 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 '@/utility/misskey-api.js'; diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 887787e168..992f47bff5 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -4,66 +4,62 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700"> - <div class="_gaps"> - <div class="_buttons"> - <MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> - <MkButton danger rounded @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> - </div> - <MkFolder> - <template #icon><i class="ti ti-info-circle"></i></template> - <template #label>{{ i18n.ts.info }}</template> - <XEditor :modelValue="role" readonly/> - </MkFolder> - <MkFolder v-if="role.target === 'manual'" defaultOpen> - <template #icon><i class="ti ti-users"></i></template> - <template #label>{{ i18n.ts.users }}</template> - <template #suffix>{{ role.usersCount }}</template> - <div class="_gaps"> - <MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="700"> + <div class="_gaps"> + <div class="_buttons"> + <MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> + <MkButton danger rounded @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + <MkFolder> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.info }}</template> + <XEditor :modelValue="role" readonly/> + </MkFolder> + <MkFolder v-if="role.target === 'manual'" defaultOpen> + <template #icon><i class="ti ti-users"></i></template> + <template #label>{{ i18n.ts.users }}</template> + <template #suffix>{{ role.usersCount }}</template> + <div class="_gaps"> + <MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> - <MkPagination :pagination="usersPagination" :displayLimit="50"> - <template #empty> - <div class="_fullinfo"> - <img :src="infoImageUrl" draggable="false"/> - <div>{{ i18n.ts.noUsers }}</div> - </div> - </template> + <MkPagination :pagination="usersPagination" :displayLimit="50"> + <template #empty> + <div class="_fullinfo"> + <img :src="infoImageUrl" draggable="false"/> + <div>{{ i18n.ts.noUsers }}</div> + </div> + </template> - <template #default="{ items }"> - <div class="_gaps_s"> - <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedItems.includes(item.id) }]"> - <div :class="$style.userItemMain"> - <MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`"> - <MkUserCardMini :user="item.user"/> - </MkA> - <button class="_button" :class="$style.userToggle" @click="toggleItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> - <button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button> - </div> - <div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub"> - <div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div> - <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> - <div v-else>Period: {{ i18n.ts.indefinitely }}</div> - </div> + <template #default="{ items }"> + <div class="_gaps_s"> + <div v-for="item in items" :key="item.user.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedItems.includes(item.id) }]"> + <div :class="$style.userItemMain"> + <MkA :class="$style.userItemMainBody" :to="`/admin/user/${item.user.id}`"> + <MkUserCardMini :user="item.user"/> + </MkA> + <button class="_button" :class="$style.userToggle" @click="toggleItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> + <button class="_button" :class="$style.unassign" @click="unassign(item.user, $event)"><i class="ti ti-x"></i></button> + </div> + <div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub"> + <div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div> + <div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> + <div v-else>Period: {{ i18n.ts.indefinitely }}</div> </div> </div> - </template> - </MkPagination> - </div> - </MkFolder> - <MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo> - </div> - </MkSpacer> - </MkStickyContainer> -</div> + </div> + </template> + </MkPagination> + </div> + </MkFolder> + <MkInfo v-else>{{ i18n.ts._role.isConditionalRole }}</MkInfo> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, reactive, ref } from 'vue'; -import XHeader from './_header_.vue'; import XEditor from './roles.editor.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 63e89a47f2..2e9a33d1b8 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -4,323 +4,319 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700"> - <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> - </MkInput> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="700"> + <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> + </MkInput> - <MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])"> - <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> - <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> - <MkRange :modelValue="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => policies.rateLimitFactor = (v / 100)"> - <template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template> - </MkRange> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.rateLimitFactor, 'rateLimitFactor'])"> + <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> + <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> + <MkRange :modelValue="policies.rateLimitFactor * 100" :min="30" :max="300" :step="10" :textConverter="(v) => `${v}%`" @update:modelValue="v => policies.rateLimitFactor = (v / 100)"> + <template #caption>{{ i18n.ts._role._options.descriptionOfRateLimitFactor }}</template> + </MkRange> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.gtlAvailable, 'gtlAvailable'])"> - <template #label>{{ i18n.ts._role._options.gtlAvailable }}</template> - <template #suffix>{{ policies.gtlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.gtlAvailable"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.gtlAvailable, 'gtlAvailable'])"> + <template #label>{{ i18n.ts._role._options.gtlAvailable }}</template> + <template #suffix>{{ policies.gtlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.gtlAvailable"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <!-- TODO translate --> - <MkFolder v-if="matchQuery([i18n.ts._role._options.btlAvailable, 'btlAvailable'])"> - <template #label>{{ i18n.ts._role._options.btlAvailable }}</template> - <template #suffix>{{ policies.btlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> - <div class="_gaps_s"> - <MkInfo :warn="true">After enabling this option navigate to the Moderation section to configure which instances should be shown.</MkInfo> - <MkSwitch v-model="policies.btlAvailable"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </div> - </MkFolder> - - <MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])"> - <template #label>{{ i18n.ts._role._options.ltlAvailable }}</template> - <template #suffix>{{ policies.ltlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.ltlAvailable"> + <!-- TODO translate --> + <MkFolder v-if="matchQuery([i18n.ts._role._options.btlAvailable, 'btlAvailable'])"> + <template #label>{{ i18n.ts._role._options.btlAvailable }}</template> + <template #suffix>{{ policies.btlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> + <div class="_gaps_s"> + <MkInfo :warn="true">After enabling this option navigate to the Moderation section to configure which instances should be shown.</MkInfo> + <MkSwitch v-model="policies.btlAvailable"> <template #label>{{ i18n.ts.enable }}</template> </MkSwitch> - </MkFolder> + </div> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])"> - <template #label>{{ i18n.ts._role._options.canPublicNote }}</template> - <template #suffix>{{ policies.canPublicNote ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canPublicNote"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.ltlAvailable, 'ltlAvailable'])"> + <template #label>{{ i18n.ts._role._options.ltlAvailable }}</template> + <template #suffix>{{ policies.ltlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.ltlAvailable"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canImportNotes, 'canImportNotes'])"> - <template #label>{{ i18n.ts._role._options.canImportNotes }}</template> - <template #suffix>{{ policies.canImportNotes ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canImportNotes"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canPublicNote, 'canPublicNote'])"> + <template #label>{{ i18n.ts._role._options.canPublicNote }}</template> + <template #suffix>{{ policies.canPublicNote ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canPublicNote"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduleNoteMax, 'scheduleNoteMax'])"> - <template #label>{{ i18n.ts._role._options.scheduleNoteMax }}</template> - <template #suffix>{{ policies.scheduleNoteMax }}</template> - <MkInput v-model="policies.scheduleNoteMax" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canImportNotes, 'canImportNotes'])"> + <template #label>{{ i18n.ts._role._options.canImportNotes }}</template> + <template #suffix>{{ policies.canImportNotes ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canImportNotes"> + <template #label>{{ i18n.ts.enable }}</template> + </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.scheduleNoteMax, 'scheduleNoteMax'])"> + <template #label>{{ i18n.ts._role._options.scheduleNoteMax }}</template> + <template #suffix>{{ policies.scheduleNoteMax }}</template> + <MkInput v-model="policies.scheduleNoteMax" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> - <template #label>{{ i18n.ts._role._options.mentionMax }}</template> - <template #suffix>{{ policies.mentionLimit }}</template> - <MkInput v-model="policies.mentionLimit" type="number"> - </MkInput> - </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.canInvite, 'canInvite'])"> - <template #label>{{ i18n.ts._role._options.canInvite }}</template> - <template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canInvite"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.mentionMax, 'mentionLimit'])"> + <template #label>{{ i18n.ts._role._options.mentionMax }}</template> + <template #suffix>{{ policies.mentionLimit }}</template> + <MkInput v-model="policies.mentionLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])"> - <template #label>{{ i18n.ts._role._options.inviteLimit }}</template> - <template #suffix>{{ policies.inviteLimit }}</template> - <MkInput v-model="policies.inviteLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])"> + <template #label>{{ i18n.ts._role._options.canInvite }}</template> + <template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canInvite"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])"> - <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template> - <template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template> - <MkInput v-model="policies.inviteLimitCycle" type="number"> - <template #suffix>{{ i18n.ts._time.minute }}</template> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])"> + <template #label>{{ i18n.ts._role._options.inviteLimit }}</template> + <template #suffix>{{ policies.inviteLimit }}</template> + <MkInput v-model="policies.inviteLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])"> - <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template> - <template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template> - <MkInput v-model="policies.inviteExpirationTime" type="number"> - <template #suffix>{{ i18n.ts._time.minute }}</template> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])"> + <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template> + <template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template> + <MkInput v-model="policies.inviteLimitCycle" type="number"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])"> - <template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template> - <template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canManageAvatarDecorations"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])"> + <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template> + <template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template> + <MkInput v-model="policies.inviteExpirationTime" type="number"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])"> - <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> - <template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canManageCustomEmojis"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageAvatarDecorations, 'canManageAvatarDecorations'])"> + <template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template> + <template #suffix>{{ policies.canManageAvatarDecorations ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canManageAvatarDecorations"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> - <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> - <template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canSearchNotes"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])"> + <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> + <template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canManageCustomEmojis"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canSearchNotes'])"> - <template #label>{{ i18n.ts._role._options.canUseTranslator }}</template> - <template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canUseTranslator"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])"> + <template #label>{{ i18n.ts._role._options.canSearchNotes }}</template> + <template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canSearchNotes"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> - <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> - <template #suffix>{{ policies.driveCapacityMb }}MB</template> - <MkInput v-model="policies.driveCapacityMb" type="number"> - <template #suffix>MB</template> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUseTranslator, 'canSearchNotes'])"> + <template #label>{{ i18n.ts._role._options.canUseTranslator }}</template> + <template #suffix>{{ policies.canUseTranslator ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canUseTranslator"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])"> - <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template> - <template #suffix>{{ policies.alwaysMarkNsfw ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.alwaysMarkNsfw"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> + <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> + <template #suffix>{{ policies.driveCapacityMb }}MB</template> + <MkInput v-model="policies.driveCapacityMb" type="number"> + <template #suffix>MB</template> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> - <template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> - <template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canUpdateBioMedia"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])"> + <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template> + <template #suffix>{{ policies.alwaysMarkNsfw ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.alwaysMarkNsfw"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> - <template #label>{{ i18n.ts._role._options.pinMax }}</template> - <template #suffix>{{ policies.pinLimit }}</template> - <MkInput v-model="policies.pinLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> + <template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> + <template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canUpdateBioMedia"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])"> - <template #label>{{ i18n.ts._role._options.antennaMax }}</template> - <template #suffix>{{ policies.antennaLimit }}</template> - <MkInput v-model="policies.antennaLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> + <template #label>{{ i18n.ts._role._options.pinMax }}</template> + <template #suffix>{{ policies.pinLimit }}</template> + <MkInput v-model="policies.pinLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.wordMuteMax, 'wordMuteLimit'])"> - <template #label>{{ i18n.ts._role._options.wordMuteMax }}</template> - <template #suffix>{{ policies.wordMuteLimit }}</template> - <MkInput v-model="policies.wordMuteLimit" type="number"> - <template #suffix>chars</template> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.antennaMax, 'antennaLimit'])"> + <template #label>{{ i18n.ts._role._options.antennaMax }}</template> + <template #suffix>{{ policies.antennaLimit }}</template> + <MkInput v-model="policies.antennaLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.webhookMax, 'webhookLimit'])"> - <template #label>{{ i18n.ts._role._options.webhookMax }}</template> - <template #suffix>{{ policies.webhookLimit }}</template> - <MkInput v-model="policies.webhookLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.wordMuteMax, 'wordMuteLimit'])"> + <template #label>{{ i18n.ts._role._options.wordMuteMax }}</template> + <template #suffix>{{ policies.wordMuteLimit }}</template> + <MkInput v-model="policies.wordMuteLimit" type="number"> + <template #suffix>chars</template> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.clipMax, 'clipLimit'])"> - <template #label>{{ i18n.ts._role._options.clipMax }}</template> - <template #suffix>{{ policies.clipLimit }}</template> - <MkInput v-model="policies.clipLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.webhookMax, 'webhookLimit'])"> + <template #label>{{ i18n.ts._role._options.webhookMax }}</template> + <template #suffix>{{ policies.webhookLimit }}</template> + <MkInput v-model="policies.webhookLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.noteEachClipsMax, 'noteEachClipsLimit'])"> - <template #label>{{ i18n.ts._role._options.noteEachClipsMax }}</template> - <template #suffix>{{ policies.noteEachClipsLimit }}</template> - <MkInput v-model="policies.noteEachClipsLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.clipMax, 'clipLimit'])"> + <template #label>{{ i18n.ts._role._options.clipMax }}</template> + <template #suffix>{{ policies.clipLimit }}</template> + <MkInput v-model="policies.clipLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.userListMax, 'userListLimit'])"> - <template #label>{{ i18n.ts._role._options.userListMax }}</template> - <template #suffix>{{ policies.userListLimit }}</template> - <MkInput v-model="policies.userListLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.noteEachClipsMax, 'noteEachClipsLimit'])"> + <template #label>{{ i18n.ts._role._options.noteEachClipsMax }}</template> + <template #suffix>{{ policies.noteEachClipsLimit }}</template> + <MkInput v-model="policies.noteEachClipsLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.userEachUserListsMax, 'userEachUserListsLimit'])"> - <template #label>{{ i18n.ts._role._options.userEachUserListsMax }}</template> - <template #suffix>{{ policies.userEachUserListsLimit }}</template> - <MkInput v-model="policies.userEachUserListsLimit" type="number"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.userListMax, 'userListLimit'])"> + <template #label>{{ i18n.ts._role._options.userListMax }}</template> + <template #suffix>{{ policies.userListLimit }}</template> + <MkInput v-model="policies.userListLimit" type="number"> + </MkInput> + </MkFolder> - <MkFolder v-if="matchQuery([i18n.ts._role._options.canHideAds, 'canHideAds'])"> - <template #label>{{ i18n.ts._role._options.canHideAds }}</template> - <template #suffix>{{ policies.canHideAds ? i18n.ts.yes : i18n.ts.no }}</template> - <MkSwitch v-model="policies.canHideAds"> - <template #label>{{ i18n.ts.enable }}</template> - </MkSwitch> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.userEachUserListsMax, 'userEachUserListsLimit'])"> + <template #label>{{ i18n.ts._role._options.userEachUserListsMax }}</template> + <template #suffix>{{ policies.userEachUserListsLimit }}</template> + <MkInput v-model="policies.userEachUserListsLimit" type="number"> + </MkInput> + </MkFolder> - <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="avatarDecorationLimit" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit"> - </MkInput> - </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canHideAds, 'canHideAds'])"> + <template #label>{{ i18n.ts._role._options.canHideAds }}</template> + <template #suffix>{{ policies.canHideAds ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canHideAds"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> - <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.avatarDecorationLimit, 'avatarDecorationLimit'])"> + <template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template> + <template #suffix>{{ policies.avatarDecorationLimit }}</template> + <MkInput v-model="avatarDecorationLimit" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit"> + </MkInput> + </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.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.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.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.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.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.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> - <div class="_gaps_s"> - <MkFoldableSection> - <template #header>{{ i18n.ts._role.manualRoles }}</template> - <div class="_gaps_s"> - <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :forModeration="true"/> - </div> - </MkFoldableSection> - <MkFoldableSection> - <template #header>{{ i18n.ts._role.conditionalRoles }}</template> - <div class="_gaps_s"> - <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :forModeration="true"/> - </div> - </MkFoldableSection> + <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> + <div class="_gaps_s"> + <MkFoldableSection> + <template #header>{{ i18n.ts._role.manualRoles }}</template> + <div class="_gaps_s"> + <MkRolePreview v-for="role in roles.filter(x => x.target === 'manual')" :key="role.id" :role="role" :forModeration="true"/> + </div> + </MkFoldableSection> + <MkFoldableSection> + <template #header>{{ i18n.ts._role.conditionalRoles }}</template> + <div class="_gaps_s"> + <MkRolePreview v-for="role in roles.filter(x => x.target === 'conditional')" :key="role.id" :role="role" :forModeration="true"/> + </div> + </MkFoldableSection> </div> - </MkSpacer> - </MkStickyContainer> -</div> + </div> + </MkSpacer> +</PageWithHeader> </template> <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'; diff --git a/packages/frontend/src/pages/admin/security.vue b/packages/frontend/src/pages/admin/security.vue index 84827b799f..537c788ccf 100644 --- a/packages/frontend/src/pages/admin/security.vue +++ b/packages/frontend/src/pages/admin/security.vue @@ -4,8 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <div class="_gaps_m"> <MkFolder v-if="meta.federation !== 'none'"> @@ -89,13 +88,12 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; import XBotProtection from './bot-protection.vue'; -import XHeader from './_header_.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkSwitch from '@/components/MkSwitch.vue'; diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue index 3b0f1066f5..98cd0775fe 100644 --- a/packages/frontend/src/pages/admin/server-rules.vue +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -4,45 +4,41 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> - <div class="_gaps_m"> - <div>{{ i18n.ts._serverRules.description }}</div> - <Sortable - v-model="serverRules" - class="_gaps_m" - :itemKey="(_, i) => i" - :animation="150" - :handle="'.' + $style.itemHandle" - @start="e => e.item.classList.add('active')" - @end="e => e.item.classList.remove('active')" - > - <template #item="{element,index}"> - <div :class="$style.item"> - <div :class="$style.itemHeader"> - <div :class="$style.itemNumber" v-text="String(index + 1)"/> - <span :class="$style.itemHandle"><i class="ti ti-menu"/></span> - <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button> - </div> - <MkInput v-model="serverRules[index]"/> +<PageWithHeader :tabs="headerTabs"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> + <div class="_gaps_m"> + <div>{{ i18n.ts._serverRules.description }}</div> + <Sortable + v-model="serverRules" + class="_gaps_m" + :itemKey="(_, i) => i" + :animation="150" + :handle="'.' + $style.itemHandle" + @start="e => e.item.classList.add('active')" + @end="e => e.item.classList.remove('active')" + > + <template #item="{element,index}"> + <div :class="$style.item"> + <div :class="$style.itemHeader"> + <div :class="$style.itemNumber" v-text="String(index + 1)"/> + <span :class="$style.itemHandle"><i class="ti ti-menu"/></span> + <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button> </div> - </template> - </Sortable> - <div :class="$style.commands"> - <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> - <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> - </div> + <MkInput v-model="serverRules[index]"/> + </div> + </template> + </Sortable> + <div :class="$style.commands"> + <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> - </MkSpacer> - </MkStickyContainer> -</div> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> import { defineAsyncComponent, ref, computed } from 'vue'; -import XHeader from './_header_.vue'; import * as os from '@/os.js'; import { fetchInstance, instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 50187a03ee..21fd1b4b1a 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -4,294 +4,290 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <template #header><XHeader :tabs="headerTabs"/></template> - <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> - <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> - - <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> +<PageWithHeader :tabs="headerTabs"> + <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> + <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="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> + <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> - <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> + <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 :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="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="infoForm.state.description"> + <template #label>{{ i18n.ts.instanceDescription }}<span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template> + </MkTextarea> - <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> + <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="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 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> - <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.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.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> + <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> - <MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn> - {{ i18n.ts.repositoryUrlOrTarballRequired }} - </MkInfo> + <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.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> + <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> - <MkInput v-model="infoForm.state.donationUrl" type="url"> - <template #label>{{ i18n.ts.donationUrl }}</template> - <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> - </MkInput> - </div> - </MkFolder> + <MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn> + {{ i18n.ts.repositoryUrlOrTarballRequired }} + </MkInfo> - <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> + <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> - <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> + <MkInput v-model="infoForm.state.donationUrl" type="url"> + <template #label>{{ i18n.ts.donationUrl }}</template> + <template #prefix><i class="ph-link ph-bold ph-lg"></i></template> + </MkInput> + </div> + </MkFolder> - <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> + <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> - <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> + <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> - <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> + <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> - <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> + <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> - <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> + <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="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> + <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> - <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> + <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> - <MkButton primary @click="genKeys">{{ i18n.ts.genKeys }}</MkButton> - </template> - </div> - </MkFolder> + <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="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> - <MkFolder> - <template #icon><i class="ph-faders ph-bold ph-lg ti-fw"></i></template> - <template #label>{{ i18n.ts.otherSettings }}</template> - <template v-if="otherForm.modified.value" #footer> - <MkFormFooter :form="otherForm"/> + <MkButton primary @click="genKeys">{{ i18n.ts.genKeys }}</MkButton> </template> + </div> + </MkFolder> - <div class="_gaps"> - <MkSwitch v-model="otherForm.state.enableAchievements"> - <template #label>{{ i18n.ts.enableAchievements }}<span v-if="otherForm.modifiedStates.enableAchievements" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.turnOffAchievements}}</template> - </MkSwitch> + <MkFolder> + <template #icon><i class="ph-faders ph-bold ph-lg ti-fw"></i></template> + <template #label>{{ i18n.ts.otherSettings }}</template> + <template v-if="otherForm.modified.value" #footer> + <MkFormFooter :form="otherForm"/> + </template> - <MkSwitch v-model="otherForm.state.enableBotTrending"> - <template #label>{{ i18n.ts.enableBotTrending }}<span v-if="otherForm.modifiedStates.enableBotTrending" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.turnOffBotTrending }}</template> - </MkSwitch> + <div class="_gaps"> + <MkSwitch v-model="otherForm.state.enableAchievements"> + <template #label>{{ i18n.ts.enableAchievements }}<span v-if="otherForm.modifiedStates.enableAchievements" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts.turnOffAchievements}}</template> + </MkSwitch> - <MkTextarea v-model="otherForm.state.robotsTxt"> - <template #label>{{ i18n.ts.robotsTxt }}<span v-if="otherForm.modifiedStates.robotsTxt" class="_modified">{{ i18n.ts.modified }}</span></template> - <template #caption>{{ i18n.ts.robotsTxtDescription }}</template> - </MkTextarea> - </div> - </MkFolder> + <MkSwitch v-model="otherForm.state.enableBotTrending"> + <template #label>{{ i18n.ts.enableBotTrending }}<span v-if="otherForm.modifiedStates.enableBotTrending" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts.turnOffBotTrending }}</template> + </MkSwitch> - <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> + <MkTextarea v-model="otherForm.state.robotsTxt"> + <template #label>{{ i18n.ts.robotsTxt }}<span v-if="otherForm.modifiedStates.robotsTxt" class="_modified">{{ i18n.ts.modified }}</span></template> + <template #caption>{{ i18n.ts.robotsTxtDescription }}</template> + </MkTextarea> + </div> + </MkFolder> - <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> + <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> + + <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> - </MkFolder> + </div> + </MkFolder> - <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> + <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"> - <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> + <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> + + <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> - <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="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="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="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="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="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> - <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> + <div> + <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> - <div> - <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> - - <div :class="$style.subCaption"> - {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }} - <ul style="padding-left: 20px; margin: 4px 0"> - <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li> - <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li> - <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li> - <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li> - </ul> - </div> + <div :class="$style.subCaption"> + {{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }} + <ul style="padding-left: 20px; margin: 4px 0"> + <li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li> + <li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li> + <li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li> + <li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li> + </ul> </div> - </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"/> + </div> </template> + </div> + </MkFolder> - <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> + <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> - <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> + <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> - <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> + <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> - <div class="_gaps"> - <MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo> + <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> - <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> - </MkSpacer> - </MkStickyContainer> -</div> + <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> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> 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'; diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue index d8eb9b92ee..a3214028e6 100644 --- a/packages/frontend/src/pages/admin/system-webhook.vue +++ b/packages/frontend/src/pages/admin/system-webhook.vue @@ -4,11 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header> - <XHeader :actions="headerActions" :tabs="headerTabs"/> - </template> - +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="900"> <div class="_gaps_m"> <MkButton primary @click="onCreateWebhookClicked"> @@ -22,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </FormSection> </div> </MkSpacer> -</MkStickyContainer> +</PageWithHeader> </template> <script lang="ts" setup> @@ -32,7 +28,6 @@ import XItem from './system-webhook.item.vue'; import FormSection from '@/components/form/section.vue'; 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 '@/utility/misskey-api.js'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 3b95b78c72..d37f7dbf80 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -4,66 +4,62 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div> - <MkStickyContainer> - <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> - <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> - <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> - </MkSelect> - <MkSelect v-model="state" style="flex: 1;"> - <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="available">{{ i18n.ts.normal }}</option> - <option value="approved">{{ i18n.ts.notApproved }}</option> - <option value="admin">{{ i18n.ts.administrator }}</option> - <option value="moderator">{{ i18n.ts.moderator }}</option> - <option value="suspended">{{ i18n.ts.suspend }}</option> - </MkSelect> - <MkSelect v-model="origin" style="flex: 1;"> - <template #label>{{ i18n.ts.instance }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> - </MkSelect> - </div> - <div :class="$style.inputs"> - <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" debounce> - <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'" debounce> - <template #prefix>@</template> - <template #label>{{ i18n.ts.host }}</template> - </MkInput> - </div> - - <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" :displayLimit="50"> - <div :class="$style.users"> - <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/admin/user/${user.id}`"> - <MkUserCardMini :user="user"/> - </MkA> - </div> - </MkPagination> +<PageWithHeader :actions="headerActions" :tabs="headerTabs"> + <MkSpacer :contentMax="900"> + <div class="_gaps"> + <div :class="$style.inputs"> + <MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton> </div> - </MkSpacer> - </MkStickyContainer> -</div> + <div :class="$style.inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="-createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+createdAt">{{ i18n.ts.registeredDate }} ({{ i18n.ts.descendingOrder }})</option> + <option value="-updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ i18n.ts.lastUsed }} ({{ i18n.ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="available">{{ i18n.ts.normal }}</option> + <option value="approved">{{ i18n.ts.notApproved }}</option> + <option value="admin">{{ i18n.ts.administrator }}</option> + <option value="moderator">{{ i18n.ts.moderator }}</option> + <option value="suspended">{{ i18n.ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ i18n.ts.instance }}</template> + <option value="combined">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.local }}</option> + <option value="remote">{{ i18n.ts.remote }}</option> + </MkSelect> + </div> + <div :class="$style.inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" debounce> + <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'"> + <template #prefix>@</template> + <template #label>{{ i18n.ts.host }}</template> + </MkInput> + </div> + + <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" :displayLimit="50"> + <div :class="$style.users"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/admin/user/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </div> + </MkPagination> + </div> + </MkSpacer> +</PageWithHeader> </template> <script lang="ts" setup> import { computed, useTemplateRef, ref, watchEffect } from 'vue'; -import XHeader from './_header_.vue'; -import { defaultMemoryStorage } from '@/memory-storage.js'; +import { defaultMemoryStorage } from '@/memory-storage'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; diff --git a/packages/frontend/src/pages/announcement.vue b/packages/frontend/src/pages/announcement.vue index b9c903e9bf..4bba0acdc2 100644 --- a/packages/frontend/src/pages/announcement.vue +++ b/packages/frontend/src/pages/announcement.vue @@ -23,10 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only <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"/> + <Mfm :text="announcement.title" class="_selectable"/> </div> <div :class="$style.content"> - <Mfm :text="announcement.text" :isBlock="true"/> + <Mfm :text="announcement.text" :isBlock="true" class="_selectable"/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> diff --git a/packages/frontend/src/pages/announcements.vue b/packages/frontend/src/pages/announcements.vue index 36e9e4342a..d529ad592c 100644 --- a/packages/frontend/src/pages/announcements.vue +++ b/packages/frontend/src/pages/announcements.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <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"> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA> </div> <div :class="$style.content"> - <Mfm :text="announcement.text" :isBlock="true" /> + <Mfm :text="announcement.text" :isBlock="true" class="_selectable"/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> <MkA :to="`/announcements/${announcement.id}`"> <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only </section> </MkPagination> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -50,7 +50,7 @@ import { ref, computed } from 'vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; import MkInfo from '@/components/MkInfo.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index e9357577ff..606fb06324 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="channel && tab === 'overview'" class="_gaps"> <div class="_panel" :class="$style.bannerContainer"> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> @@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> </div> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> <template #footer> <div :class="$style.footer"> @@ -93,7 +93,7 @@ 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 MkSwiper from '@/components/MkSwiper.vue'; import { isSupportShare } from '@/utility/navigator.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { notesSearchAvailable } from '@/utility/check-permissions.js'; diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 76800aaf70..27a6a6168d 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1200"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'search'" :class="$style.searchRoot"> <div class="_gaps"> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -67,7 +67,7 @@ import MkInput from '@/components/MkInput.vue'; 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 MkSwiper from '@/components/MkSwiper.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router.js'; diff --git a/packages/frontend/src/pages/chat/home.home.vue b/packages/frontend/src/pages/chat/home.home.vue index a8ed891de0..a0853fb0c9 100644 --- a/packages/frontend/src/pages/chat/home.home.vue +++ b/packages/frontend/src/pages/chat/home.home.vue @@ -34,34 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only <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"/> + <MkChatHistories/> </MkFoldableSection> </div> </template> @@ -81,20 +54,12 @@ import { updateCurrentAccountPartial } from '@/accounts.js'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkInfo from '@/components/MkInfo.vue'; +import MkChatHistories from '@/components/MkChatHistories.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[]>([]); @@ -148,57 +113,8 @@ async function search() { 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(); + updateCurrentAccountPartial({ hasUnreadChatMessages: false }); }); </script> @@ -207,77 +123,6 @@ onMounted(() => { 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); diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue index e29ab28f2d..1edd18ddf0 100644 --- a/packages/frontend/src/pages/chat/home.vue +++ b/packages/frontend/src/pages/chat/home.vue @@ -7,12 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only <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"> + <MkSwiper 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> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -25,7 +25,7 @@ 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 MkSwiper from '@/components/MkSwiper.vue'; import MkPolkadots from '@/components/MkPolkadots.vue'; const tab = ref('home'); diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue index 3063d5a4d6..170d48064f 100644 --- a/packages/frontend/src/pages/drive.file.vue +++ b/packages/frontend/src/pages/drive.file.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/> </template> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'info'" :contentMax="800"> <XFileInfo :fileId="fileId"/> </MkSpacer> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer v-else-if="tab === 'notes'" :contentMax="800"> <XNotes :fileId="fileId"/> </MkSpacer> - </MkHorizontalSwipe> + </MkSwiper> </MkStickyContainer> </template> @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, ref, defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; const props = defineProps<{ fileId: string; diff --git a/packages/frontend/src/pages/explore.vue b/packages/frontend/src/pages/explore.vue index 85b9fe4932..bcece47e35 100644 --- a/packages/frontend/src/pages/explore.vue +++ b/packages/frontend/src/pages/explore.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'featured'"> <XFeatured/> </div> @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'roles'"> <XRoles/> </div> - </MkHorizontalSwipe> + </MkSwiper> </PageWithHeader> </template> @@ -25,7 +25,7 @@ import XFeatured from './explore.featured.vue'; import XUsers from './explore.users.vue'; import XRoles from './explore.roles.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 98ab587b55..4ef33cbe0f 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> <div class="_gaps_s"> @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -43,7 +43,7 @@ import { computed, ref } from 'vue'; import MkFlashPreview from '@/components/MkFlashPreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { useRouter } from '@/router.js'; diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue index 744fa215cd..f6357ba1b1 100644 --- a/packages/frontend/src/pages/follow-requests.vue +++ b/packages/frontend/src/pages/follow-requests.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="800"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <MkPagination ref="paginationComponent" :pagination="pagination"> <template #empty> <div class="_fullinfo"> @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> </MkPagination> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -52,7 +52,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { infoImageUrl } from '@/instance.js'; import { $i } from '@/i.js'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; const paginationComponent = useTemplateRef('paginationComponent'); diff --git a/packages/frontend/src/pages/following-feed.vue b/packages/frontend/src/pages/following-feed.vue index 84cccb0a5a..7f4110f66c 100644 --- a/packages/frontend/src/pages/following-feed.vue +++ b/packages/frontend/src/pages/following-feed.vue @@ -11,15 +11,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div ref="noteScroll" :class="$style.notes"> - <MkHorizontalSwipe v-model:tab="userList" :tabs="headerTabs"> + <MkSwiper v-model:tab="userList" :tabs="headerTabs"> <SkFollowingRecentNotes ref="followingRecentNotes" :selectedUserId="selectedUserId" :userList="userList" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles" @userSelected="userSelected" @loaded="listReady"/> - </MkHorizontalSwipe> + </MkSwiper> </div> <MkLazy ref="userScroll" :class="$style.user"> - <MkHorizontalSwipe v-if="selectedUserId" v-model:tab="userList" :tabs="headerTabs"> + <MkSwiper v-if="selectedUserId" v-model:tab="userList" :tabs="headerTabs"> <SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/> - </MkHorizontalSwipe> + </MkSwiper> </MkLazy> </div> </template> @@ -30,7 +30,7 @@ import type { ComputedRef, Ref } from 'vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import type { PageHeaderItem } from '@/types/page-header.js'; import { i18n } from '@/i18n.js'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import MkPageHeader from '@/components/global/MkPageHeader.vue'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import { createModel, createHeaderItem, followingFeedTabs, followingTabIcon, followingTabName, followingTab } from '@/utility/following-feed-utils.js'; diff --git a/packages/frontend/src/pages/gallery/index.vue b/packages/frontend/src/pages/gallery/index.vue index 806b6df671..f9e1c9c9a3 100644 --- a/packages/frontend/src/pages/gallery/index.vue +++ b/packages/frontend/src/pages/gallery/index.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="1400"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'explore'"> <MkFoldableSection class="_margin"> <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> @@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -50,7 +50,7 @@ import { watch, ref, computed } from 'vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router.js'; diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 853887d61e..eff513d241 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'overview'" class="_gaps_m"> <div class="fnfelxur"> <img :src="faviconUrl" alt="" class="icon"/> @@ -165,7 +165,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkObjectView tall :value="instance"> </MkObjectView> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -192,7 +192,7 @@ import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkPagination from '@/components/MkPagination.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { dateString } from '@/filters/date.js'; import MkTextarea from '@/components/MkTextarea.vue'; diff --git a/packages/frontend/src/pages/my-clips/index.vue b/packages/frontend/src/pages/my-clips/index.vue index 1525bbef9b..5b9b3af90b 100644 --- a/packages/frontend/src/pages/my-clips/index.vue +++ b/packages/frontend/src/pages/my-clips/index.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'my'" class="_gaps"> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'favorites'" class="_gaps"> <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -33,7 +33,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { clipsCache } from '@/cache.js'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; const pagination = { endpoint: 'clips/list' as const, diff --git a/packages/frontend/src/pages/notifications.vue b/packages/frontend/src/pages/notifications.vue index 0a2bc02de5..61a1b2725c 100644 --- a/packages/frontend/src/pages/notifications.vue +++ b/packages/frontend/src/pages/notifications.vue @@ -24,7 +24,7 @@ import { computed, ref } from 'vue'; import { notificationTypes } from '@@/js/const.js'; import XNotifications from '@/components/MkNotifications.vue'; import MkNotes from '@/components/MkNotes.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; diff --git a/packages/frontend/src/pages/pages.vue b/packages/frontend/src/pages/pages.vue index c99d7f1a0f..d412bad616 100644 --- a/packages/frontend/src/pages/pages.vue +++ b/packages/frontend/src/pages/pages.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <MkSpacer :contentMax="700"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <div v-if="tab === 'featured'"> <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> <div class="_gaps"> @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkPagination> </div> - </MkHorizontalSwipe> + </MkSwiper> </MkSpacer> </PageWithHeader> </template> @@ -41,7 +41,7 @@ import { computed, ref } from 'vue'; import MkPagePreview from '@/components/MkPagePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkButton from '@/components/MkButton.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { useRouter } from '@/router.js'; diff --git a/packages/frontend/src/pages/search.vue b/packages/frontend/src/pages/search.vue index e0cb2dcbab..814ddf3cb9 100644 --- a/packages/frontend/src/pages/search.vue +++ b/packages/frontend/src/pages/search.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <MkSpacer v-if="tab === 'note'" :contentMax="800"> <div v-if="notesSearchAvailable || ignoreNotesSearchAvailable"> <XNote v-bind="props"/> @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSpacer v-else-if="tab === 'user'" :contentMax="800"> <XUser v-bind="props"/> </MkSpacer> - </MkHorizontalSwipe> + </MkSwiper> </PageWithHeader> </template> @@ -28,7 +28,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { notesSearchAvailable } from '@/utility/check-permissions.js'; import MkInfo from '@/components/MkInfo.vue'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; const props = withDefaults(defineProps<{ query?: string, diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 5921a8c812..a11ae2a6f6 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -177,7 +177,8 @@ const menuDef = computed<SuperMenuDef[]>(() => [{ action: async () => { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.ts.logoutConfirm, + title: i18n.ts.logoutConfirm, + text: i18n.ts.logoutWillClearClientData, }); if (canceled) return; signout(); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index b8e14cb920..731242425c 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -53,12 +53,15 @@ import { miLocalStorage } from '@/local-storage.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { prefer } from '@/preferences.js'; import { useRouter } from '@/router.js'; +import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js'; provide('shouldOmitHeaderTitle', true); const tlComponent = useTemplateRef('tlComponent'); const rootEl = useTemplateRef('rootEl'); +useScrollPositionKeeper(rootEl); + const router = useRouter(); router.useListener('same', () => { top(); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 88a5ee8849..c057312f63 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="user.followedMessage != null" class="followedMessage"> <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> - <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user"/></MkSparkle></div> + <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div> </MkFukidashi> </div> <div v-if="user.roles.length > 0" class="roles"> @@ -93,8 +93,10 @@ SPDX-License-Identifier: AGPL-3.0-only /> </div> <div class="description"> - <Mfm v-if="user.description" :text="user.description" :isBlock="true" :isNote="false" :author="user"/> - <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + <MkOmit> + <Mfm v-if="user.description" :text="user.description" :isBlock="true" :isNote="false" :author="user" class="_selectable"/> + <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> + </MkOmit> </div> <div class="fields system"> <dl v-if="user.location" class="field"> @@ -113,10 +115,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="user.fields.length > 0" class="fields"> <dl v-for="(field, i) in user.fields" :key="i" class="field"> <dt class="name"> - <Mfm :text="field.name" :author="user" :plain="true" :colored="false"/> + <Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/> </dt> <dd class="value"> - <Mfm :text="field.value" :author="user" :colored="false"/> + <Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/> <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> </dd> </dl> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 9d8eb0da2b..17dd1d5f3c 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :displayBackButton="true" :tabs="headerTabs" :actions="headerActions"> <div v-if="user"> - <MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> + <MkSwiper v-model:tab="tab" :tabs="headerTabs"> <XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> <MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0"> <XTimeline :user="user"/> @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <XFlashs v-else-if="tab === 'flashs'" :user="user"/> <XGallery v-else-if="tab === 'gallery'" :user="user"/> <XRaw v-else-if="tab === 'raw'" :user="user"/> - </MkHorizontalSwipe> + </MkSwiper> </div> <MkError v-else-if="error" @retry="fetchUser()"/> <MkLoading v-else/> @@ -36,7 +36,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; -import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; +import MkSwiper from '@/components/MkSwiper.vue'; import { serverContext, assertServerContext } from '@/server-context.js'; const XHome = defineAsyncComponent(() => import('./home.vue')); diff --git a/packages/frontend/src/preferences/def.ts b/packages/frontend/src/preferences/def.ts index 8560ebefaf..6dd6c15ec5 100644 --- a/packages/frontend/src/preferences/def.ts +++ b/packages/frontend/src/preferences/def.ts @@ -35,10 +35,11 @@ export type SoundStore = { // NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) export const PREF_DEF = { - // TODO: 持つのはホストやユーザーID、ユーザー名など最低限にしといて、その他のプロフィール情報はpreferences外で管理した方が綺麗そう - // 現状だと、updateCurrentAccount/updateCurrentAccountPartialが呼ばれるたびに「設定」へのcommitが行われて不自然(明らかに設定の更新とは捉えにくい)だし accounts: { - default: [] as [host: string, user: Misskey.entities.User][], + default: [] as [host: string, user: { + id: string; + username: string; + }][], }, pinnedUserLists: { diff --git a/packages/frontend/src/router.definition.ts b/packages/frontend/src/router.definition.ts index 0058fdad39..1bd1f4fd13 100644 --- a/packages/frontend/src/router.definition.ts +++ b/packages/frontend/src/router.definition.ts @@ -408,9 +408,13 @@ export const ROUTE_DEF = [{ name: 'avatarDecorations', component: page(() => import('@/pages/avatar-decorations.vue')), }, { - path: '/queue', - name: 'queue', - component: page(() => import('@/pages/admin/queue.vue')), + path: '/federation-job-queue', + name: 'federationJobQueue', + component: page(() => import('@/pages/admin/federation-job-queue.vue')), + }, { + path: '/job-queue', + name: 'jobQueue', + component: page(() => import('@/pages/admin/job-queue.vue')), }, { path: '/files', name: 'files', diff --git a/packages/frontend/src/signout.ts b/packages/frontend/src/signout.ts index f86e098e90..703c6fc534 100644 --- a/packages/frontend/src/signout.ts +++ b/packages/frontend/src/signout.ts @@ -4,7 +4,8 @@ */ import { apiUrl } from '@@/js/config.js'; -import { defaultMemoryStorage } from '@/memory-storage.js'; +import { cloudBackup } from '@/preferences/utility.js'; +import { store } from '@/store.js'; import { waiting } from '@/os.js'; import { unisonReload } from '@/utility/unison-reload.js'; import { clear } from '@/utility/idb-proxy.js'; @@ -13,12 +14,13 @@ import { $i } from '@/i.js'; export async function signout() { if (!$i) return; - // TODO: preferの自動バックアップがオンの場合、いろいろ消す前に強制バックアップ - waiting(); + if (store.s.enablePreferencesAutoCloudBackup) { + await cloudBackup(); + } + localStorage.clear(); - defaultMemoryStorage.clear(); const idbAbortController = new AbortController(); const timeout = window.setTimeout(() => idbAbortController.abort(), 5000); diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 32acaeaf99..36949e272e 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -112,6 +112,10 @@ export const store = markRaw(new Pizzax('base', { where: 'device', default: {} as Record<string, string>, // host/userId, token }, + accountInfos: { + where: 'device', + default: {} as Record<string, Misskey.entities.User>, // host/userId, user + }, enablePreferencesAutoCloudBackup: { where: 'device', diff --git a/packages/frontend/src/style.scss b/packages/frontend/src/style.scss index d32f628f07..304df91617 100644 --- a/packages/frontend/src/style.scss +++ b/packages/frontend/src/style.scss @@ -194,7 +194,6 @@ rt { .ph-bold { width: 1.28em; vertical-align: -12%; - line-height: 1em; &::before { font-size: 128%; diff --git a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue index 37b70847ca..7c2de12221 100644 --- a/packages/frontend/src/ui/_common_/mobile-footer-menu.vue +++ b/packages/frontend/src/ui/_common_/mobile-footer-menu.vue @@ -77,14 +77,17 @@ watch(rootEl, () => { <style lang="scss" module> .root { + position: relative; + z-index: 1; padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; display: grid; grid-template-columns: 1fr 1fr 1fr 1fr 1fr; grid-gap: 8px; width: 100%; box-sizing: border-box; - background: var(--MI_THEME-bg); - border-top: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-navBg); + color: var(--MI_THEME-navFg); + box-shadow: 0px 0px 6px 6px #0000000f; } .item { @@ -109,19 +112,17 @@ watch(rootEl, () => { padding: 0; aspect-ratio: 1; width: 100%; - max-width: 50px; + max-width: 45px; margin: auto; align-content: center; border-radius: 100%; - background: var(--MI_THEME-panel); - color: var(--MI_THEME-fg); &:hover { background: var(--MI_THEME-panelHighlight); } &:active { - background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); + background: var(--MI_THEME-panelHighlight); } } @@ -131,14 +132,16 @@ watch(rootEl, () => { .itemIndicator { position: absolute; - top: 0; + bottom: -4px; left: 0; + right: 0; color: var(--MI_THEME-indicator); - font-size: 16px; + font-size: 10px; + pointer-events: none; &:has(.itemIndicateValueIcon) { animation: none; - font-size: 12px; + font-size: 8px; } } </style> diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 6323d3c5fd..fbe86bc4cb 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -102,6 +102,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue'; import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; +import XChatColumn from '@/ui/deck/chat-column.vue'; import XFollowingColumn from '@/ui/deck/following-column.vue'; import { mainRouter } from '@/router.js'; import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js'; @@ -120,6 +121,7 @@ const columnComponents = { mentions: XMentionsColumn, direct: XDirectColumn, roleTimeline: XRoleTimelineColumn, + chat: XChatColumn, following: XFollowingColumn, }; diff --git a/packages/frontend/src/ui/deck/chat-column.vue b/packages/frontend/src/ui/deck/chat-column.vue new file mode 100644 index 0000000000..791af2e44c --- /dev/null +++ b/packages/frontend/src/ui/deck/chat-column.vue @@ -0,0 +1,27 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<XColumn :column="column" :isStacked="isStacked"> + <template #header><i class="ti ti-messages" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.chat }}</template> + + <div style="padding: 8px;"> + <MkChatHistories/> + </div> +</XColumn> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import { i18n } from '../../i18n.js'; +import XColumn from './column.vue'; +import type { Column } from '@/deck.js'; +import MkChatHistories from '@/components/MkChatHistories.vue'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); +</script> diff --git a/packages/frontend/src/use/use-scroll-position-keeper.ts b/packages/frontend/src/use/use-scroll-position-keeper.ts new file mode 100644 index 0000000000..b584171cbe --- /dev/null +++ b/packages/frontend/src/use/use-scroll-position-keeper.ts @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { throttle } from 'throttle-debounce'; +import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue'; +import type { Ref } from 'vue'; + +// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする + +// TODO: data-scroll-anchor がひとつも存在しない場合、または手動で useAnchor みたいなフラグをfalseで呼ばれた場合、単純にスクロール位置を使用する処理にフォールバックするようにする + +export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void { + let anchorId: string | null = null; + let ready = true; + + watch(scrollContainerRef, (el) => { + if (!el) return; + + const onScroll = () => { + if (!el) return; + if (!ready) return; + + const scrollContainerRect = el.getBoundingClientRect(); + const viewPosition = scrollContainerRect.height / 2; + + const anchorEls = el.querySelectorAll('[data-scroll-anchor]'); + for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い + const anchorEl = anchorEls[i] as HTMLElement; + const anchorRect = anchorEl.getBoundingClientRect(); + const anchorTop = anchorRect.top; + const anchorBottom = anchorRect.bottom; + if (anchorTop <= viewPosition && anchorBottom >= viewPosition) { + anchorId = anchorEl.getAttribute('data-scroll-anchor'); + break; + } + } + }; + + // ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい + // https://github.com/vuejs/vue/issues/9454 + // https://github.com/vuejs/rfcs/pull/284 + el.addEventListener('scroll', throttle(1000, onScroll), { passive: true }); + }, { + immediate: true, + }); + + const restore = () => { + if (!anchorId) return; + const scrollContainer = scrollContainerRef.value; + if (!scrollContainer) return; + const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`); + if (!scrollAnchorEl) return; + scrollAnchorEl.scrollIntoView({ + behavior: 'instant', + block: 'center', + inline: 'center', + }); + }; + + onDeactivated(() => { + ready = false; + }); + + onActivated(() => { + restore(); + nextTick(() => { + restore(); + window.setTimeout(() => { + restore(); + + ready = true; + }, 100); + }); + }); +} diff --git a/packages/frontend/src/utility/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts index 0276cc3c20..885c57caa9 100644 --- a/packages/frontend/src/utility/autocomplete.ts +++ b/packages/frontend/src/utility/autocomplete.ts @@ -7,6 +7,7 @@ import { nextTick, ref, defineAsyncComponent } from 'vue'; import getCaretCoordinates from 'textarea-caret'; import { toASCII } from 'punycode.js'; import type { Ref } from 'vue'; +import type { CompleteInfo } from '@/components/MkAutocomplete.vue'; import { popup } from '@/os.js'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; @@ -19,7 +20,7 @@ export class Autocomplete { close: () => void; } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; - private currentType: string; + private currentType: keyof CompleteInfo | undefined; private textRef: Ref<string | number | null>; private opening: boolean; private onlyType: SuggestionType[]; @@ -74,7 +75,7 @@ export class Autocomplete { * テキスト入力時 */ private onInput() { - const caretPos = this.textarea.selectionStart; + const caretPos = Number(this.textarea.selectionStart); const text = this.text.substring(0, caretPos).split('\n').pop()!; const mentionIndex = text.lastIndexOf('@'); @@ -101,6 +102,8 @@ export class Autocomplete { const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isEmoji = emojiIndex !== -1 && text.split(/:[\p{Letter}\p{Number}\p{Mark}_+-]+:/u).pop()!.includes(':'); + // :ok:などを🆗にするたいおぷ + const isEmojiCompleteToUnicode = !isEmoji && emojiIndex === text.length - 1; let opened = false; @@ -137,6 +140,14 @@ export class Autocomplete { } } + if (isEmojiCompleteToUnicode && !opened && this.onlyType.includes('emoji')) { + const emoji = text.substring(text.lastIndexOf(':', text.length - 2) + 1, text.length - 1); + if (!emoji.includes(' ')) { + this.open('emojiComplete', emoji); + opened = true; + } + } + if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) { const mfmTag = text.substring(mfmTagIndex + 1); if (!mfmTag.includes(' ')) { @@ -164,7 +175,7 @@ export class Autocomplete { /** * サジェストを提示します。 */ - private async open(type: string, q: any) { + private async open<T extends keyof CompleteInfo>(type: T, q: CompleteInfo[T]['query']) { if (type !== this.currentType) { this.close(); } @@ -231,10 +242,10 @@ export class Autocomplete { /** * オートコンプリートする */ - private complete({ type, value }) { + private complete<T extends keyof CompleteInfo>({ type, value }: { type: T; value: CompleteInfo[T]['payload'] }) { this.close(); - const caret = this.textarea.selectionStart; + const caret = Number(this.textarea.selectionStart); if (type === 'user') { const source = this.text; @@ -286,6 +297,22 @@ export class Autocomplete { const pos = trimmedBefore.length + value.length; this.textarea.setSelectionRange(pos, pos); }); + } else if (type === 'emojiComplete') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':', before.length - 2)); + const after = source.substring(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + value.length; + this.textarea.setSelectionRange(pos, pos); + }); } else if (type === 'mfmTag') { const source = this.text; diff --git a/packages/frontend/src/utility/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts index 4192a2df8f..8e6d657310 100644 --- a/packages/frontend/src/utility/search-emoji.ts +++ b/packages/frontend/src/utility/search-emoji.ts @@ -104,3 +104,33 @@ export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30) .slice(0, max) .map(it => it.emoji); } + +export function searchEmojiExact(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { + if (!query) { + return []; + } + + const matched = new Map<string, EmojiScore>(); + // 完全一致(エイリアスなし) + emojiDb.some(x => { + if (x.name === query && !x.aliasOf) { + matched.set(x.name, { emoji: x, score: query.length + 3 }); + } + return matched.size === max; + }); + + // 完全一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name === query && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 }); + } + return matched.size === max; + }); + } + + return [...matched.values()] + .sort((x, y) => y.score - x.score) + .slice(0, max) + .map(it => it.emoji); +} diff --git a/packages/frontend/src/utility/touch.ts b/packages/frontend/src/utility/touch.ts index adc2e4c093..361246b328 100644 --- a/packages/frontend/src/utility/touch.ts +++ b/packages/frontend/src/utility/touch.ts @@ -18,5 +18,5 @@ if (isTouchSupported && !isTouchUsing) { }, { passive: true }); } -/** (MkHorizontalSwipe) 横スワイプ中か? */ +/** (MkSwiper) 横スワイプ中か? */ export const isHorizontalSwipeSwiping = ref(false); diff --git a/packages/frontend/src/widgets/WidgetChat.vue b/packages/frontend/src/widgets/WidgetChat.vue new file mode 100644 index 0000000000..43b2a6e522 --- /dev/null +++ b/packages/frontend/src/widgets/WidgetChat.vue @@ -0,0 +1,52 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkContainer :showHeader="widgetProps.showHeader" class="mkw-chat"> + <template #icon><i class="ti ti-users"></i></template> + <template #header>{{ i18n.ts._widgets.chat }}</template> + <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure()"><i class="ti ti-settings"></i></button></template> + + <div> + <MkChatHistories/> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { useWidgetPropsManager } from './widget.js'; +import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js'; +import type { GetFormResultType } from '@/utility/form.js'; +import MkContainer from '@/components/MkContainer.vue'; +import { i18n } from '@/i18n.js'; +import MkChatHistories from '@/components/MkChatHistories.vue'; + +const name = 'chat'; + +const widgetPropsDef = { + showHeader: { + type: 'boolean' as const, + default: true, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +const props = defineProps<WidgetComponentProps<WidgetProps>>(); +const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); + +const { widgetProps, configure, save } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index 047c6d7104..f495234e58 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -36,6 +36,7 @@ export default function(app: App) { app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue'))); app.component('WidgetSearch', defineAsyncComponent(() => import('./WidgetSearch.vue'))); app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue'))); + app.component('WidgetChat', defineAsyncComponent(() => import('./WidgetChat.vue'))); } // 連合関連のウィジェット(連合無効時に隠す) @@ -72,6 +73,7 @@ export const widgets = [ 'clicker', 'search', 'birthdayFollowings', + 'chat', ...federationWidgets, ]; |