diff options
| author | misskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com> | 2025-10-08 13:18:08 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-10-08 13:18:08 +0000 |
| commit | 56cc89b521e8ca0d302230d123c3924e4461556d (patch) | |
| tree | 242411d50ffd1ed7096f95ecdafe91b482628a46 /packages/frontend/src/pages | |
| parent | Merge pull request #16521 from misskey-dev/develop (diff) | |
| parent | Release: 2025.10.0 (diff) | |
| download | misskey-56cc89b521e8ca0d302230d123c3924e4461556d.tar.gz misskey-56cc89b521e8ca0d302230d123c3924e4461556d.tar.bz2 misskey-56cc89b521e8ca0d302230d123c3924e4461556d.zip | |
Merge pull request #16591 from misskey-dev/develop
Release: 2025.10.0
Diffstat (limited to 'packages/frontend/src/pages')
80 files changed, 1955 insertions, 654 deletions
diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index 7e514c5a73..3957cc422f 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -11,12 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off"> <template #prefix><i class="ti ti-search"></i></template> </MkInput> - - <!-- たくさんあると邪魔 - <div class="tags"> - <span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span> - </div> - --> </div> <MkFoldableSection v-if="searchEmojis"> @@ -26,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFoldableSection> - <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category ?? '___root___'"> + <MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category ?? '___root___'" :expanded="false"> <template #header>{{ category || i18n.ts.other }}</template> <div :class="$style.emojis"> <XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/> @@ -42,51 +36,33 @@ import XEmoji from './emojis.emoji.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; -import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; +import { customEmojis, customEmojiCategories } from '@/custom-emojis.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/i.js'; -const customEmojiTags = getCustomEmojiTags(); const q = ref(''); const searchEmojis = ref<Misskey.entities.EmojiSimple[] | null>(null); -const selectedTags = ref(new Set()); function search() { - if ((q.value === '' || q.value == null) && selectedTags.value.size === 0) { + if (q.value === '' || q.value == null) { searchEmojis.value = null; return; } - if (selectedTags.value.size === 0) { - const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g); - - if (queryarry) { - searchEmojis.value = customEmojis.value.filter(emoji => - queryarry.includes(`:${emoji.name}:`), - ); - } else { - searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value)); - } - } else { - searchEmojis.value = customEmojis.value.filter(emoji => (emoji.name.includes(q.value) || emoji.aliases.includes(q.value)) && [...selectedTags.value].every(t => emoji.aliases.includes(t))); - } -} + const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g); -function toggleTag(tag) { - if (selectedTags.value.has(tag)) { - selectedTags.value.delete(tag); + if (queryarry) { + searchEmojis.value = customEmojis.value.filter(emoji => + queryarry.includes(`:${emoji.name}:`), + ); } else { - selectedTags.value.add(tag); + searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value)); } } watch(q, () => { search(); }); - -watch(selectedTags, () => { - search(); -}, { deep: true }); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index fd5e061d52..bbfb9a3b7c 100644 --- a/packages/frontend/src/pages/about.federation.vue +++ b/packages/frontend/src/pages/about.federation.vue @@ -11,56 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.host }}</template> </MkInput> <FormSplit style="margin-top: var(--MI-margin);"> - <MkSelect v-model="state"> + <MkSelect v-model="state" :items="stateDef"> <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> - <option value="suspended">{{ i18n.ts.suspended }}</option> - <option value="silenced">{{ i18n.ts.silence }}</option> - <option value="blocked">{{ i18n.ts.blocked }}</option> - <option value="notResponding">{{ i18n.ts.notResponding }}</option> </MkSelect> - <MkSelect - v-model="sort" :items="[{ - label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, - value: '+pubSub', - }, { - label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, - value: '-pubSub', - }, { - label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, - value: '+notes', - }, { - label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, - value: '-notes', - }, { - label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, - value: '+users', - }, { - label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, - value: '-users', - }, { - label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, - value: '+following', - }, { - label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, - value: '-following', - }, { - label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, - value: '+followers', - }, { - label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, - value: '-followers', - }, { - label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, - value: '+firstRetrievedAt', - }, { - label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, - value: '-firstRetrievedAt', - }] as const" - > + <MkSelect v-model="sort" :items="sortDef"> <template #label>{{ i18n.ts.sort }}</template> </MkSelect> </FormSplit> @@ -85,11 +39,46 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; const host = ref(''); -const state = ref('federating'); -const sort = ref<NonNullable<Misskey.entities.FederationInstancesRequest['sort']>>('+pubSub'); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.federating, value: 'federating' }, + { label: i18n.ts.subscribing, value: 'subscribing' }, + { label: i18n.ts.publishing, value: 'publishing' }, + { label: i18n.ts.suspended, value: 'suspended' }, + { label: i18n.ts.silence, value: 'silenced' }, + { label: i18n.ts.blocked, value: 'blocked' }, + { label: i18n.ts.notResponding, value: 'notResponding' }, + ], + initialValue: 'federating', +}); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' }, + { label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' }, + { label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' }, + { label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' }, + { label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' }, + { label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' }, + { label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' }, + { label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' }, + { label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' }, + { label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' }, + ], + initialValue: '+pubSub', +}); const paginator = markRaw(new Paginator('federation/instances', { limit: 10, offsetMode: true, diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index 38e3c7a11b..6d3cc9c1b7 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -151,19 +151,17 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'announcements'" class="_gaps"> - <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> + <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.createNew }}</MkButton> - <MkSelect v-model="announcementsStatus"> + <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef"> <template #label>{{ i18n.ts.filter }}</template> - <option value="active">{{ i18n.ts.active }}</option> - <option value="archived">{{ i18n.ts.archived }}</option> </MkSelect> <MkPagination :paginator="announcementsPaginator"> <template #default="{ items }"> <div class="_gaps_s"> <div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> - <span style="margin-right: 0.5em;"> + <span v-if="'icon' in announcement" style="margin-right: 0.5em;"> <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> @@ -184,8 +182,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'chart'" class="_gaps_m"> <div class="cmhjzshm"> <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="per-user-notes">{{ i18n.ts.notes }}</option> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;"> </MkSelect> </div> <div class="charts"> @@ -229,10 +226,12 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { acct } from '@/filters/user.js'; import { definePage } from '@/page.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js'; import MkRolePreview from '@/components/MkRolePreview.vue'; import MkPagination from '@/components/MkPagination.vue'; import { Paginator } from '@/utility/paginator.js'; +import type { ChartSrc } from '@/components/MkChart.vue'; const $i = ensureSignin(); @@ -246,7 +245,15 @@ const props = withDefaults(defineProps<{ const result = await _fetch_(); const tab = ref(props.initialTab); -const chartSrc = ref('per-user-notes'); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.notes, value: 'per-user-notes' }, +], + initialValue: 'per-user-notes', +}); const user = ref(result.user); const info = ref(result.info); const ips = ref(result.ips); @@ -263,7 +270,16 @@ const filesPaginator = markRaw(new Paginator('admin/drive/files', { })), })); -const announcementsStatus = ref<'active' | 'archived'>('active'); +const { + model: announcementsStatus, + def: announcementsStatusDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.active, value: 'active' }, + { label: i18n.ts.archived, value: 'archived' }, + ], + initialValue: 'active', +}); const announcementsPaginator = markRaw(new Paginator('admin/announcements/list', { limit: 10, @@ -427,22 +443,22 @@ async function assignRole() { const { canceled, result: roleId } = await os.select({ title: i18n.ts._role.chooseRoleToAssign, - items: roles.map(r => ({ text: r.name, value: r.id })), + items: roles.map(r => ({ label: r.name, value: r.id })), }); - if (canceled) return; + if (canceled || roleId == null) return; const { canceled: canceled2, result: period } = await os.select({ title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name, items: [{ - value: 'indefinitely', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'oneHour', text: i18n.ts.oneHour, + value: 'oneHour', label: i18n.ts.oneHour, }, { - value: 'oneDay', text: i18n.ts.oneDay, + value: 'oneDay', label: i18n.ts.oneDay, }, { - value: 'oneWeek', text: i18n.ts.oneWeek, + value: 'oneWeek', label: i18n.ts.oneWeek, }, { - value: 'oneMonth', text: i18n.ts.oneMonth, + value: 'oneMonth', label: i18n.ts.oneMonth, }], default: 'indefinitely', }); diff --git a/packages/frontend/src/pages/admin/RolesEditorFormula.vue b/packages/frontend/src/pages/admin/RolesEditorFormula.vue index 89ecc155b2..9d9db9158d 100644 --- a/packages/frontend/src/pages/admin/RolesEditorFormula.vue +++ b/packages/frontend/src/pages/admin/RolesEditorFormula.vue @@ -6,26 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> <div :class="$style.header"> - <MkSelect v-model="type" :class="$style.typeSelect"> - <option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option> - <option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option> - <option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option> - <option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option> - <option value="isBot">{{ i18n.ts._role._condition.isBot }}</option> - <option value="isCat">{{ i18n.ts._role._condition.isCat }}</option> - <option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option> - <option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option> - <option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option> - <option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option> - <option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option> - <option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option> - <option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option> - <option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option> - <option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option> - <option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option> - <option value="and">{{ i18n.ts._role._condition.and }}</option> - <option value="or">{{ i18n.ts._role._condition.or }}</option> - <option value="not">{{ i18n.ts._role._condition.not }}</option> + <MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect"> </MkSelect> <button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle"> <i class="ti ti-menu-2"></i> @@ -58,8 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> </MkInput> - <MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId"> - <option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option> + <MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId" :items="assignedToDef"> </MkSelect> </div> </template> @@ -69,6 +49,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue'; import { genId } from '@/utility/id.js'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/utility/clone.js'; @@ -99,7 +80,29 @@ watch(v, () => { emit('update:modelValue', v.value); }, { deep: true }); -const type = computed({ +const typeDef = [ + { label: i18n.ts._role._condition.isLocal, value: 'isLocal' }, + { label: i18n.ts._role._condition.isRemote, value: 'isRemote' }, + { label: i18n.ts._role._condition.isSuspended, value: 'isSuspended' }, + { label: i18n.ts._role._condition.isLocked, value: 'isLocked' }, + { label: i18n.ts._role._condition.isBot, value: 'isBot' }, + { label: i18n.ts._role._condition.isCat, value: 'isCat' }, + { label: i18n.ts._role._condition.isExplorable, value: 'isExplorable' }, + { label: i18n.ts._role._condition.roleAssignedTo, value: 'roleAssignedTo' }, + { label: i18n.ts._role._condition.createdLessThan, value: 'createdLessThan' }, + { label: i18n.ts._role._condition.createdMoreThan, value: 'createdMoreThan' }, + { label: i18n.ts._role._condition.followersLessThanOrEq, value: 'followersLessThanOrEq' }, + { label: i18n.ts._role._condition.followersMoreThanOrEq, value: 'followersMoreThanOrEq' }, + { label: i18n.ts._role._condition.followingLessThanOrEq, value: 'followingLessThanOrEq' }, + { label: i18n.ts._role._condition.followingMoreThanOrEq, value: 'followingMoreThanOrEq' }, + { label: i18n.ts._role._condition.notesLessThanOrEq, value: 'notesLessThanOrEq' }, + { label: i18n.ts._role._condition.notesMoreThanOrEq, value: 'notesMoreThanOrEq' }, + { label: i18n.ts._role._condition.and, value: 'and' }, + { label: i18n.ts._role._condition.or, value: 'or' }, + { label: i18n.ts._role._condition.not, value: 'not' }, +] as const satisfies MkSelectItem[]; + +const type = computed<GetMkSelectValueTypesFromDef<typeof typeDef>>({ get: () => v.value.type, set: (t) => { if (t === 'and') v.value.values = []; @@ -118,6 +121,8 @@ const type = computed({ }, }); +const assignedToDef = computed(() => roles.filter(r => r.target === 'manual').map(r => ({ label: r.name, value: r.id })) satisfies MkSelectItem[]); + function addValue() { v.value.values.push({ id: genId(), type: 'isRemote' }); } diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue index b69c818b48..7c3f736506 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -22,27 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="title"> <template #label>{{ i18n.ts.title }}</template> </MkInput> - <MkSelect v-model="method"> + <MkSelect v-model="method" :items="methodDef"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> - <option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option> - <option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option> <template #caption> {{ methodCaption }} </template> </MkSelect> <div> - <MkSelect v-if="method === 'email'" v-model="userId"> + <MkSelect v-if="method === 'email'" v-model="userId" :items="userIdDef"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template> - <option v-for="user in moderators" :key="user.id" :value="user.id"> - {{ user.name ? `${user.name}(${user.username})` : user.username }} - </option> </MkSelect> <div v-else-if="method === 'webhook'" :class="$style.systemWebhook"> - <MkSelect v-model="systemWebhookId" style="flex: 1"> + <MkSelect v-model="systemWebhookId" :items="systemWebhookIdDef" style="flex: 1"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template> - <option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id"> - {{ webhook.name }} - </option> </MkSelect> <MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked"> <span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/> @@ -79,14 +71,13 @@ import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; import MkInput from '@/components/MkInput.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkSelect from '@/components/MkSelect.vue'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkDivider from '@/components/MkDivider.vue'; import * as os from '@/os.js'; -type NotificationRecipientMethod = 'email' | 'webhook'; - const emit = defineEmits<{ (ev: 'submitted'): void; (ev: 'canceled'): void; @@ -105,9 +96,28 @@ const dialogEl = useTemplateRef('dialogEl'); const loading = ref<number>(0); const title = ref<string>(''); -const method = ref<NotificationRecipientMethod>('email'); -const userId = ref<string | null>(null); -const systemWebhookId = ref<string | null>(null); +const { + model: method, + def: methodDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' }, + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' }, + ], + initialValue: 'email', +}); +const { + model: userId, + def: userIdDef, +} = useMkSelect({ + items: computed(() => moderators.value.map(u => ({ label: u.name ? `${u.name}(${u.username})` : u.username, value: u.id as string | null }))), +}); +const { + model: systemWebhookId, + def: systemWebhookIdDef, +} = useMkSelect({ + items: computed(() => systemWebhooks.value.map(w => ({ label: w.name, value: w.id }))), +}); const isActive = ref<boolean>(true); const moderators = ref<entities.User[]>([]); 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 f5e77cbe4e..893bd8d6d3 100644 --- a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -13,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> <div :class="$style.subMenus" class="_gaps_s"> - <MkSelect v-model="filterMethod" style="flex: 1"> + <MkSelect v-model="filterMethod" :items="filterMethodDef" style="flex: 1"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> - <option :value="null">-</option> - <option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option> - <option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option> </MkSelect> <MkInput v-model="filterText" type="search" style="flex: 1"> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template> @@ -51,10 +48,21 @@ import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import MkDivider from '@/components/MkDivider.vue'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]); -const filterMethod = ref<string | null>(null); +const { + model: filterMethod, + def: filterMethodDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: null }, + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' }, + { label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' }, + ], + initialValue: null, +}); const filterText = ref<string>(''); const filteredRecipients = computed(() => { diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue index ab462229a7..76bf20b409 100644 --- a/packages/frontend/src/pages/admin/abuses.vue +++ b/packages/frontend/src/pages/admin/abuses.vue @@ -16,23 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkTip> <div :class="$style.inputs" class="_gaps"> - <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <MkSelect v-model="state" :items="stateDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="unresolved">{{ i18n.ts.unresolved }}</option> - <option value="resolved">{{ i18n.ts.resolved }}</option> </MkSelect> - <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <MkSelect v-model="targetUserOrigin" :items="targetUserOriginDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.reporteeOrigin }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> - <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <MkSelect v-model="reporterOrigin" :items="reporterOriginDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.reporterOrigin }}</template> - <option value="combined">{{ i18n.ts.all }}</option> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> </MkSelect> </div> @@ -64,13 +55,44 @@ import MkPagination from '@/components/MkPagination.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkButton from '@/components/MkButton.vue'; import { store } from '@/store.js'; import { Paginator } from '@/utility/paginator.js'; -const state = ref('unresolved'); -const reporterOrigin = ref('combined'); -const targetUserOrigin = ref('combined'); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.unresolved, value: 'unresolved' }, + { label: i18n.ts.resolved, value: 'resolved' }, + ], + initialValue: 'unresolved', +}); +const { + model: reporterOrigin, + def: reporterOriginDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: 'combined', +}); +const { + model: targetUserOrigin, + def: targetUserOriginDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: 'combined', +}); const searchUsername = ref(''); const searchHost = ref(''); diff --git a/packages/frontend/src/pages/admin/ads.vue b/packages/frontend/src/pages/admin/ads.vue index 06a28db088..94940a84ae 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -6,27 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 900px;"> - <MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems"> + <MkSelect v-model="filterType" :items="filterTypeDef" :class="$style.input" @update:modelValue="filterItems"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="publishing">{{ i18n.ts.publishing }}</option> - <option value="expired">{{ i18n.ts.expired }}</option> </MkSelect> + <div> <div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad"> <MkAd v-if="ad.url" :key="ad.id" :specify="ad"/> + <MkInput v-model="ad.url" type="url"> <template #label>URL</template> </MkInput> + <MkInput v-model="ad.imageUrl" type="url"> <template #label>{{ i18n.ts.imageUrl }}</template> </MkInput> + <MkRadios v-model="ad.place"> <template #label>Form</template> <option value="square">square</option> <option value="horizontal">horizontal</option> <option value="horizontal-big">horizontal-big</option> </MkRadios> + <!-- <div style="margin: 32px 0;"> {{ i18n.ts.priority }} @@ -35,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> </div> --> + <FormSplit> <MkInput v-model="ad.ratio" type="number"> <template #label>{{ i18n.ts.ratio }}</template> @@ -46,6 +49,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.expiration }}</template> </MkInput> </FormSplit> + + <MkSwitch v-model="ad.isSensitive"> + <template #label>{{ i18n.ts.sensitive }}</template> + </MkSwitch> + <MkFolder> <template #label>{{ i18n.ts.advancedSettings }}</template> <span> @@ -59,9 +67,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </span> </MkFolder> + <MkTextarea v-model="ad.memo"> <template #label>{{ i18n.ts.memo }}</template> </MkTextarea> + <div class="_buttons"> <MkButton inline primary style="margin-right: 12px;" @click="save(ad)"> <i @@ -73,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkButton> </div> </div> + <MkButton @click="more()"> <i class="ti ti-reload"></i>{{ i18n.ts.more }} </MkButton> @@ -91,10 +102,12 @@ import MkRadios from '@/components/MkRadios.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const ads = ref<Misskey.entities.Ad[]>([]); @@ -102,7 +115,17 @@ const ads = ref<Misskey.entities.Ad[]>([]); const localTime = new Date(); const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday]; -const filterType = ref('all'); +const { + model: filterType, + def: filterTypeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.publishing, value: 'publishing' }, + { label: i18n.ts.expired, value: 'expired' }, + ], + initialValue: 'all', +}); let publishing: boolean | null = null; misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { @@ -121,7 +144,7 @@ misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { } }); -const filterItems = (v) => { +const filterItems = (v: typeof filterType.value) => { if (v === 'publishing') { publishing = true; } else if (v === 'expired') { @@ -134,7 +157,7 @@ const filterItems = (v) => { }; // 選択された曜日(index)のビットフラグを操作する -function toggleDayOfWeek(ad, index) { +function toggleDayOfWeek(ad: Misskey.entities.Ad, index: number) { ad.dayOfWeek ^= 1 << index; } @@ -150,10 +173,11 @@ function add() { expiresAt: new Date().toISOString(), startsAt: new Date().toISOString(), dayOfWeek: 0, + isSensitive: false, }); } -function remove(ad) { +function remove(ad: Misskey.entities.Ad) { os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: ad.url }), @@ -169,7 +193,7 @@ function remove(ad) { }); } -function save(ad) { +function save(ad: Misskey.entities.Ad) { if (ad.id === '') { misskeyApi('admin/ad/create', { ...ad, diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e5903d6257..b90a724b17 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -10,10 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> <MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> - <MkSelect v-model="announcementsStatus"> + <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef"> <template #label>{{ i18n.ts.filter }}</template> - <option value="active">{{ i18n.ts.active }}</option> - <option value="archived">{{ i18n.ts.archived }}</option> </MkSelect> <MkLoading v-if="loading"/> @@ -98,8 +96,18 @@ import { definePage } from '@/page.js'; import MkFolder from '@/components/MkFolder.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import { genId } from '@/utility/id.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; -const announcementsStatus = ref<'active' | 'archived'>('active'); +const { + model: announcementsStatus, + def: announcementsStatusDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.active, value: 'active' }, + { label: i18n.ts.archived, value: 'archived' }, + ], + initialValue: 'active', +}); const loading = ref(true); const loadingMore = ref(false); diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue index 9938d5cc4a..6b5272914b 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue @@ -56,20 +56,24 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkSelect v-model="model.sensitive" + :items="[ + { label: '-', value: null }, + { label: 'true', value: 'true' }, + { label: 'false', value: 'false' }, + ]" > <template #label>sensitive</template> - <option :value="null">-</option> - <option :value="true">true</option> - <option :value="false">false</option> </MkSelect> <MkSelect v-model="model.localOnly" + :items="[ + { label: '-', value: null }, + { label: 'true', value: 'true' }, + { label: 'false', value: 'false' }, + ]" > <template #label>localOnly</template> - <option :value="null">-</option> - <option :value="true">true</option> - <option :value="false">false</option> </MkSelect> <MkInput v-model="model.updatedAtFrom" diff --git a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue index 621ec8a6a8..c343d88eb1 100644 --- a/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue +++ b/packages/frontend/src/pages/admin/custom-emojis-manager.register.vue @@ -12,11 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template> <div class="_gaps"> - <MkSelect v-model="selectedFolderId"> + <MkSelect v-model="selectedFolderId" :items="selectedFolderIdDef"> <template #label>{{ i18n.ts.uploadFolder }}</template> - <option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id"> - {{ folder.name }} - </option> </MkSelect> <MkSwitch v-model="directoryToCategory"> @@ -63,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script setup lang="ts"> /* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as Misskey from 'misskey-js'; -import { onMounted, ref, useCssModule } from 'vue'; +import { computed, onMounted, ref, useCssModule } from 'vue'; import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; import type { DroppedFile } from '@/utility/file-drop.js'; @@ -87,6 +84,7 @@ import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { prefer } from '@/preferences.js'; @@ -229,7 +227,13 @@ function setupGrid(): GridSetting { const uploadFolders = ref<FolderItem[]>([]); const gridItems = ref<GridItem[]>([]); -const selectedFolderId = ref(prefer.s.uploadFolder); +const { + model: selectedFolderId, + def: selectedFolderIdDef, +} = useMkSelect({ + items: computed(() => uploadFolders.value.map(folder => ({ label: folder.name, value: folder.id || '' }))), + initialValue: prefer.s.uploadFolder, +}); const directoryToCategory = ref<boolean>(false); const registerButtonDisabled = ref<boolean>(false); const requestLogs = ref<RequestLogItem[]>([]); @@ -303,8 +307,8 @@ async function onFileSelectClicked() { const driveFiles = await chooseFileFromPcAndUpload({ multiple: true, folderId: selectedFolderId.value, - // 拡張子は消す - nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), + // // 拡張子は消す + // nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), }); gridItems.value.push(...driveFiles.map(fromDriveFile)); diff --git a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue index 9a311b5772..420219c22c 100644 --- a/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue @@ -26,10 +26,10 @@ const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; function setData(values) { - if (chartInstance == null) return; + if (chartInstance == null || chartInstance.data.labels == null) return; for (const value of values) { chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); @@ -42,7 +42,7 @@ function setData(values) { } function pushData(value) { - if (chartInstance == null) return; + if (chartInstance == null || chartInstance.data.labels == null) return; chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); if (chartInstance.data.datasets[0].data.length > 200) { @@ -69,6 +69,8 @@ const color = onMounted(() => { const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'line', data: { diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index ddc3ff7b79..cbf7dbbff5 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -13,31 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label>{{ i18n.ts.host }}</template> </MkInput> <FormSplit style="margin-top: var(--MI-margin);"> - <MkSelect v-model="state"> + <MkSelect v-model="state" :items="stateDef"> <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> - <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"> + <MkSelect v-model="sort" :items="sortDef"> <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> @@ -64,11 +44,46 @@ import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import FormSplit from '@/components/form/split.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; const host = ref(''); -const state = ref('federating'); -const sort = ref('+pubSub'); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.federating, value: 'federating' }, + { label: i18n.ts.subscribing, value: 'subscribing' }, + { label: i18n.ts.publishing, value: 'publishing' }, + { label: i18n.ts.suspended, value: 'suspended' }, + { label: i18n.ts.blocked, value: 'blocked' }, + { label: i18n.ts.silence, value: 'silenced' }, + { label: i18n.ts.notResponding, value: 'notResponding' }, + ], + initialValue: 'federating', +}); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' }, + { label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' }, + { label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' }, + { label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' }, + { label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' }, + { label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' }, + { label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' }, + { label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' }, + { label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' }, + { label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' }, + { label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' }, + ], + initialValue: '+pubSub', +}); const paginator = markRaw(new Paginator('federation/instances', { limit: 10, offsetMode: true, diff --git a/packages/frontend/src/pages/admin/files.vue b/packages/frontend/src/pages/admin/files.vue index b4ec930997..c8b5980883 100644 --- a/packages/frontend/src/pages/admin/files.vue +++ b/packages/frontend/src/pages/admin/files.vue @@ -8,11 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_spacer" style="--MI_SPACER-w: 900px;"> <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;"> + <MkSelect v-model="origin" :items="originDef" 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="paginator.computedParams?.value?.origin === 'local'"> <template #label>{{ i18n.ts.host }}</template> @@ -42,9 +39,20 @@ import * as os from '@/os.js'; import { lookupFile } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; -const origin = ref<NonNullable<Misskey.entities.AdminDriveFilesRequest['origin']>>('local'); +const { + model: origin, + def: originDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: 'local', +}); const type = ref<string | null>(null); const searchHost = ref(''); const userId = ref(''); diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 1c551cb477..d52a57e582 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -26,19 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkFolder> <div :class="$style.inputs"> - <MkSelect v-model="type" :class="$style.input"> + <MkSelect v-model="type" :items="typeDef" :class="$style.input"> <template #label>{{ i18n.ts.state }}</template> - <option value="all">{{ i18n.ts.all }}</option> - <option value="unused">{{ i18n.ts.unused }}</option> - <option value="used">{{ i18n.ts.used }}</option> - <option value="expired">{{ i18n.ts.expired }}</option> </MkSelect> - <MkSelect v-model="sort" :class="$style.input"> + <MkSelect v-model="sort" :items="sortDef" :class="$style.input"> <template #label>{{ i18n.ts.sort }}</template> - <option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option> - <option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option> - <option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option> </MkSelect> </div> <MkPagination :paginator="paginator"> @@ -67,10 +59,33 @@ import MkSwitch from '@/components/MkSwitch.vue'; import MkPagination from '@/components/MkPagination.vue'; import MkInviteCode from '@/components/MkInviteCode.vue'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { Paginator } from '@/utility/paginator.js'; -const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all'); -const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt'); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.unused, value: 'unused' }, + { label: i18n.ts.used, value: 'used' }, + { label: i18n.ts.expired, value: 'expired' }, + ], + initialValue: 'all', +}); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.createdAt} (${i18n.ts.ascendingOrder})`, value: '+createdAt' }, + { label: `${i18n.ts.createdAt} (${i18n.ts.descendingOrder})`, value: '-createdAt' }, + { label: `${i18n.ts.usedAt} (${i18n.ts.ascendingOrder})`, value: '+usedAt' }, + { label: `${i18n.ts.usedAt} (${i18n.ts.descendingOrder})`, value: '-usedAt' }, + ], + initialValue: '+createdAt', +}); const paginator = markRaw(new Paginator('admin/invite/list', { limit: 10, diff --git a/packages/frontend/src/pages/admin/job-queue.vue b/packages/frontend/src/pages/admin/job-queue.vue index 0856bac860..b18049cb11 100644 --- a/packages/frontend/src/pages/admin/job-queue.vue +++ b/packages/frontend/src/pages/admin/job-queue.vue @@ -210,6 +210,7 @@ async function fetchCurrentQueue() { } async function fetchJobs() { + if (tab.value === '-') return; jobsFetching.value = true; const state = jobState.value; jobs.value = await misskeyApi('admin/queue/jobs', { @@ -307,6 +308,7 @@ async function removeJobs() { } async function refreshJob(jobId: string) { + if (tab.value === '-') return; const newJob = await misskeyApi('admin/queue/show-job', { queue: tab.value, jobId }); const index = jobs.value.findIndex((job) => job.id === jobId); if (index !== -1) { diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 435dd9c462..a11278b68a 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -25,18 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']"> - <MkSelect - v-model="ugcVisibilityForVisitor" :items="[{ - value: 'all', - label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, - }, { - value: 'local', - label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly + ' (' + i18n.ts.recommended + ')', - }, { - value: 'none', - label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, - }] as const" @update:modelValue="onChange_ugcVisibilityForVisitor" - > + <MkSelect v-model="ugcVisibilityForVisitor" :items="ugcVisibilityForVisitorDef" @update:modelValue="onChange_ugcVisibilityForVisitor"> <template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template> <template #caption> <div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div> @@ -176,6 +165,7 @@ import { misskeyApi } from '@/utility/misskey-api.js'; import { fetchInstance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -185,7 +175,17 @@ const meta = await misskeyApi('admin/meta'); const enableRegistration = ref(!meta.disableRegistration); const emailRequiredForSignup = ref(meta.emailRequiredForSignup); -const ugcVisibilityForVisitor = ref(meta.ugcVisibilityForVisitor); +const { + model: ugcVisibilityForVisitor, + def: ugcVisibilityForVisitorDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, value: 'all' }, + { label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly, value: 'local' }, + { label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, value: 'none' }, + ], + initialValue: meta.ugcVisibilityForVisitor, +}); const sensitiveWords = ref(meta.sensitiveWords.join('\n')); const prohibitedWords = ref(meta.prohibitedWords.join('\n')); const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n')); @@ -221,7 +221,7 @@ function onChange_emailRequiredForSignup(value: boolean) { }); } -function onChange_ugcVisibilityForVisitor(value: Misskey.entities.AdminUpdateMetaRequest['ugcVisibilityForVisitor']) { +function onChange_ugcVisibilityForVisitor(value: typeof ugcVisibilityForVisitor.value) { os.apiWithDialog('admin/update-meta', { ugcVisibilityForVisitor: value, }).then(() => { diff --git a/packages/frontend/src/pages/admin/modlog.vue b/packages/frontend/src/pages/admin/modlog.vue index 08bdc8d254..cb75be7edd 100644 --- a/packages/frontend/src/pages/admin/modlog.vue +++ b/packages/frontend/src/pages/admin/modlog.vue @@ -8,10 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_gaps"> <MkPaginationControl :paginator="paginator" canFilter> - <MkSelect v-model="type" style="margin: 0; flex: 1;"> + <MkSelect v-model="type" :items="typeDef" style="margin: 0; flex: 1;"> <template #label>{{ i18n.ts.type }}</template> - <option :value="null">{{ i18n.ts.all }}</option> - <option v-for="t in Misskey.moderationLogTypes" :key="t" :value="t">{{ i18n.ts._moderationLogTypes[t] ?? t }}</option> </MkSelect> <MkInput v-model="moderatorId" style="margin: 0; flex: 1;"> @@ -54,12 +52,22 @@ import MkTl from '@/components/MkTl.vue'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import MkButton from '@/components/MkButton.vue'; import MkPaginationControl from '@/components/MkPaginationControl.vue'; import { Paginator } from '@/utility/paginator.js'; -const type = ref<string | null>(null); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: null }, + ...Misskey.moderationLogTypes.map(t => ({ label: i18n.ts._moderationLogTypes[t] ?? t, value: t })), + ], + initialValue: null, +}); const moderatorId = ref(''); const paginator = markRaw(new Paginator('admin/show-moderation-logs', { diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 6c85f11cb1..32a5a6976e 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -26,7 +26,7 @@ initChart(); const chartEl = useTemplateRef('chartEl'); const now = new Date(); -let chartInstance: Chart = null; +let chartInstance: Chart | null = null; const chartLimit = 7; const fetching = ref(true); diff --git a/packages/frontend/src/pages/admin/overview.federation.vue b/packages/frontend/src/pages/admin/overview.federation.vue index 50f12cbf45..3c737ad32b 100644 --- a/packages/frontend/src/pages/admin/overview.federation.vue +++ b/packages/frontend/src/pages/admin/overview.federation.vue @@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="item _panel sub"> <div class="icon"><i class="ti ti-world-download"></i></div> <div class="body"> - <div class="value"> + <div v-if="federationSubActive != null" class="value"> {{ number(federationSubActive) }} - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> + <MkNumberDiff v-if="federationSubActiveDiff != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> </div> <div class="label">Sub</div> </div> @@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="item _panel pub"> <div class="icon"><i class="ti ti-world-upload"></i></div> <div class="body"> - <div class="value"> + <div v-if="federationPubActive != null" class="value"> {{ number(federationPubActive) }} - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> + <MkNumberDiff v-if="federationPubActiveDiff != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> </div> <div class="label">Pub</div> </div> diff --git a/packages/frontend/src/pages/admin/overview.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue index 7b2b142b16..5edc01404c 100644 --- a/packages/frontend/src/pages/admin/overview.heatmap.vue +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_panel" :class="$style.root"> - <MkSelect v-model="src" style="margin: 0 0 12px 0;" small> - <option value="active-users">Active users</option> - <option value="notes">Notes</option> - <option value="ap-requests-inbox-received">AP Requests: inboxReceived</option> - <option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> - <option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> + <MkSelect v-model="src" :items="srcDef" style="margin: 0 0 12px 0;" small> </MkSelect> <MkHeatmap :src="src"/> </div> </template> <script lang="ts" setup> -import { ref } from 'vue'; import MkHeatmap from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; -const src = ref('active-users'); +const { + model: src, + def: srcDef, +} = useMkSelect({ + items: [ + { label: 'Active users', value: 'active-users' }, + { label: 'Notes', value: 'notes' }, + { label: 'AP Requests: inboxReceived', value: 'ap-requests-inbox-received' }, + { label: 'AP Requests: deliverSucceeded', value: 'ap-requests-deliver-succeeded' }, + { label: 'AP Requests: deliverFailed', value: 'ap-requests-deliver-failed' }, + ], + initialValue: 'active-users', +}); </script> <style lang="scss" module> diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index ec2b558cee..2e874b3505 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -32,15 +32,17 @@ const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', }); -let chartInstance: Chart; +let chartInstance: Chart | null = null; onMounted(() => { + if (chartEl.value == null) return; + chartInstance = new Chart(chartEl.value, { type: 'doughnut', data: { labels: props.data.map(x => x.name), datasets: [{ - backgroundColor: props.data.map(x => x.color), + backgroundColor: props.data.map(x => x.color ?? '#000'), borderColor: getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-panel'), borderWidth: 2, hoverOffset: 0, @@ -57,9 +59,10 @@ onMounted(() => { }, }, onClick: (ev) => { - const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; - if (hit && props.data[hit.index].onClick) { - props.data[hit.index].onClick(); + if (ev.native == null) return; + const hit = chartInstance!.getElementsAtEventForMode(ev.native, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick != null) { + props.data[hit.index].onClick!(); } }, plugins: { diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index 9b9618c4ac..771b35c09f 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -26,10 +26,10 @@ const chartEl = useTemplateRef('chartEl'); const { handler: externalTooltipHandler } = useChartTooltip(); -let chartInstance: Chart; +let chartInstance: Chart | null = null; -function setData(values) { - if (chartInstance == null) return; +function setData(values: number[]) { + if (chartInstance == null || chartInstance.data.labels == null) return; for (const value of values) { chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); @@ -41,8 +41,8 @@ function setData(values) { chartInstance.update(); } -function pushData(value) { - if (chartInstance == null) return; +function pushData(value: number) { + if (chartInstance == null || chartInstance.data.labels == null) return; chartInstance.data.labels.push(''); chartInstance.data.datasets[0].data.push(value); if (chartInstance.data.datasets[0].data.length > 100) { @@ -67,6 +67,8 @@ const color = '?' as never; onMounted(() => { + if (chartEl.value == null) return; + const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; chartInstance = new Chart(chartEl.value, { diff --git a/packages/frontend/src/pages/admin/overview.queue.vue b/packages/frontend/src/pages/admin/overview.queue.vue index e7e139b74d..e57df3744a 100644 --- a/packages/frontend/src/pages/admin/overview.queue.vue +++ b/packages/frontend/src/pages/admin/overview.queue.vue @@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { markRaw, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; import XChart from './overview.queue.chart.vue'; -import type { ApQueueDomain } from '@/pages/admin/queue.vue'; +import type { ApQueueDomain } from '@/pages/admin/federation-job-queue.vue'; import number from '@/filters/number.js'; import { useStream } from '@/stream.js'; import { genId } from '@/utility/id.js'; @@ -64,10 +64,10 @@ function onStats(stats: Misskey.entities.QueueStats) { delayed.value = stats[props.domain].delayed; waiting.value = stats[props.domain].waiting; - chartProcess.value.pushData(stats[props.domain].activeSincePrevTick); - chartActive.value.pushData(stats[props.domain].active); - chartDelayed.value.pushData(stats[props.domain].delayed); - chartWaiting.value.pushData(stats[props.domain].waiting); + chartProcess.value?.pushData(stats[props.domain].activeSincePrevTick); + chartActive.value?.pushData(stats[props.domain].active); + chartDelayed.value?.pushData(stats[props.domain].delayed); + chartWaiting.value?.pushData(stats[props.domain].waiting); } function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) { @@ -83,10 +83,10 @@ function onStatsLog(statsLog: Misskey.entities.QueueStatsLog) { dataWaiting.push(stats[props.domain].waiting); } - chartProcess.value.setData(dataProcess); - chartActive.value.setData(dataActive); - chartDelayed.value.setData(dataDelayed); - chartWaiting.value.setData(dataWaiting); + chartProcess.value?.setData(dataProcess); + chartActive.value?.setData(dataActive); + chartDelayed.value?.setData(dataDelayed); + chartWaiting.value?.setData(dataWaiting); } onMounted(() => { diff --git a/packages/frontend/src/pages/admin/overview.stats.vue b/packages/frontend/src/pages/admin/overview.stats.vue index fd8145b308..b0669bc557 100644 --- a/packages/frontend/src/pages/admin/overview.stats.vue +++ b/packages/frontend/src/pages/admin/overview.stats.vue @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <Transition :name="prefer.s.animation ? '_transition_zoom' : ''" mode="out-in"> <MkLoading v-if="fetching"/> - <div v-else :class="$style.root"> + <div v-else-if="stats != null" :class="$style.root"> <div class="item _panel users"> <div class="icon"><i class="ti ti-users"></i></div> <div class="body"> <div class="value"> <MkNumber :value="stats.originalUsersCount" style="margin-right: 0.5em;"/> - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> + <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"></MkNumberDiff> </div> <div class="label">Users</div> </div> @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="body"> <div class="value"> <MkNumber :value="stats.originalNotesCount" style="margin-right: 0.5em;"/> - <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> + <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"></MkNumberDiff> </div> <div class="label">Notes</div> </div> @@ -56,6 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> + <MkError v-else/> </Transition> </div> </template> @@ -71,8 +72,8 @@ import { customEmojis } from '@/custom-emojis.js'; import { prefer } from '@/preferences.js'; const stats = ref<Misskey.entities.StatsResponse | null>(null); -const usersComparedToThePrevDay = ref<number>(); -const notesComparedToThePrevDay = ref<number>(); +const usersComparedToThePrevDay = ref<number | null>(null); +const notesComparedToThePrevDay = ref<number | null>(null); const onlineUsersCount = ref(0); const fetching = ref(true); @@ -85,11 +86,11 @@ onMounted(async () => { onlineUsersCount.value = _onlineUsersCount; misskeyApiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { - usersComparedToThePrevDay.value = stats.value.originalUsersCount - chart.local.total[1]; + usersComparedToThePrevDay.value = _stats.originalUsersCount - chart.local.total[1]; }); misskeyApiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { - notesComparedToThePrevDay.value = stats.value.originalNotesCount - chart.local.total[1]; + notesComparedToThePrevDay.value = _stats.originalNotesCount - chart.local.total[1]; }); fetching.value = false; diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index 2ad5173618..2c550bd9c3 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -95,7 +95,7 @@ const federationPubActiveDiff = ref<number | null>(null); const federationSubActive = ref<number | null>(null); const federationSubActiveDiff = ref<number | null>(null); const newUsers = ref<Misskey.entities.UserDetailed[] | null>(null); -const activeInstances = shallowRef<Misskey.entities.FederationInstance | null>(null); +const activeInstances = shallowRef<Misskey.entities.FederationInstancesResponse | null>(null); const queueStatsConnection = markRaw(useStream().useChannel('queueStats')); const now = new Date(); const filesPagination = { diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index e98b4f0129..5f8950f07e 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -30,19 +30,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template #caption>{{ i18n.ts._role.descriptionOfDisplayOrder }}</template> </MkInput> - <MkSelect v-model="rolePermission" :readonly="readonly"> + <MkSelect v-model="rolePermission" :items="rolePermissionDef" :readonly="readonly"> <template #label><i class="ti ti-shield-lock"></i> {{ i18n.ts._role.permission }}</template> <template #caption><div v-html="i18n.ts._role.descriptionOfPermission.replaceAll('\n', '<br>')"></div></template> - <option value="normal">{{ i18n.ts.normalUser }}</option> - <option value="moderator">{{ i18n.ts.moderator }}</option> - <option value="administrator">{{ i18n.ts.administrator }}</option> </MkSelect> - <MkSelect v-model="role.target" :readonly="readonly"> + <MkSelect v-model="role.target" :items="[{ label: i18n.ts._role.manual, value: 'manual' }, { label: i18n.ts._role.conditional, value: 'conditional' }]" :readonly="readonly"> <template #label><i class="ti ti-users"></i> {{ i18n.ts._role.assignTarget }}</template> <template #caption><div v-html="i18n.ts._role.descriptionOfAssignTarget.replaceAll('\n', '<br>')"></div></template> - <option value="manual">{{ i18n.ts._role.manual }}</option> - <option value="conditional">{{ i18n.ts._role.conditional }}</option> </MkSelect> <MkFolder v-if="role.target === 'conditional'" defaultOpen> @@ -176,11 +171,17 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="role.policies.chatAvailability.useDefault" :readonly="readonly"> <template #label>{{ i18n.ts._role.useBaseValue }}</template> </MkSwitch> - <MkSelect v-model="role.policies.chatAvailability.value" :disabled="role.policies.chatAvailability.useDefault" :readonly="readonly"> + <MkSelect + v-model="role.policies.chatAvailability.value" + :items="[ + { label: i18n.ts.enabled, value: 'available' }, + { label: i18n.ts.readonly, value: 'readonly' }, + { label: i18n.ts.disabled, value: 'unavailable' }, + ]" + :disabled="role.policies.chatAvailability.useDefault" + :readonly="readonly" + > <template #label>{{ i18n.ts.enable }}</template> - <option value="available">{{ i18n.ts.enabled }}</option> - <option value="readonly">{{ i18n.ts.readonly }}</option> - <option value="unavailable">{{ i18n.ts.disabled }}</option> </MkSelect> <MkRange v-model="role.policies.chatAvailability.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> @@ -419,6 +420,9 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> <MkInput v-model="role.policies.maxFileSizeMb.value" :disabled="role.policies.maxFileSizeMb.useDefault" type="number" :readonly="readonly"> <template #suffix>MB</template> + <template #caption> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._role._options.maxFileSize_caption }}</div> + </template> </MkInput> <MkRange v-model="role.policies.maxFileSizeMb.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> <template #label>{{ i18n.ts._role.priority }}</template> @@ -801,6 +805,25 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])"> + <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template> + <template #suffix> + <span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.scheduledNoteLimit.value }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly"> + </MkInput> + <MkRange v-model="role.policies.scheduledNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #suffix> @@ -830,7 +853,7 @@ import { watch, ref, computed } from 'vue'; import { throttle } from 'throttle-debounce'; import * as Misskey from 'misskey-js'; import RolesEditorFormula from './RolesEditorFormula.vue'; -import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue'; +import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import MkColorInput from '@/components/MkColorInput.vue'; import MkSelect from '@/components/MkSelect.vue'; @@ -871,11 +894,17 @@ function updateAvatarDecorationLimit(value: string | number) { role.value.policies.avatarDecorationLimit.value = limited; } -const rolePermission = computed({ +const rolePermissionDef = [ + { label: i18n.ts.normalUser, value: 'normal' }, + { label: i18n.ts.moderator, value: 'moderator' }, + { label: i18n.ts.administrator, value: 'administrator' }, +] as const satisfies MkSelectItem[]; + +const rolePermission = computed<GetMkSelectValueTypesFromDef<typeof rolePermissionDef>>({ get: () => role.value.isAdministrator ? 'administrator' : role.value.isModerator ? 'moderator' : 'normal', set: (val) => { - role.value.isAdministrator = val === 'administrator'; - role.value.isModerator = val === 'moderator'; + role.value.isAdministrator = (val === 'administrator'); + role.value.isModerator = (val === 'moderator'); }, }); diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index c6c3165828..2e249eee50 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -71,7 +71,7 @@ import { Paginator } from '@/utility/paginator.js'; const router = useRouter(); const props = defineProps<{ - id?: string; + id: string; }>(); const usersPaginator = markRaw(new Paginator('admin/roles/users', { @@ -115,15 +115,15 @@ async function assign() { const { canceled: canceled2, result: period } = await os.select({ title: i18n.ts.period + ': ' + role.name, items: [{ - value: 'indefinitely', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'oneHour', text: i18n.ts.oneHour, + value: 'oneHour', label: i18n.ts.oneHour, }, { - value: 'oneDay', text: i18n.ts.oneDay, + value: 'oneDay', label: i18n.ts.oneDay, }, { - value: 'oneWeek', text: i18n.ts.oneWeek, + value: 'oneWeek', label: i18n.ts.oneWeek, }, { - value: 'oneMonth', text: i18n.ts.oneMonth, + value: 'oneMonth', label: i18n.ts.oneMonth, }], default: 'indefinitely', }); diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 5323d042cf..e65a3c5ba8 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -52,11 +52,15 @@ SPDX-License-Identifier: AGPL-3.0-only <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"> + <MkSelect + v-model="policies.chatAvailability" + :items="[ + { label: i18n.ts.enabled, value: 'available' }, + { label: i18n.ts.readonly, value: 'readonly' }, + { label: i18n.ts.disabled, value: 'unavailable' }, + ]" + > <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> @@ -151,6 +155,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>{{ policies.maxFileSizeMb }}MB</template> <MkInput v-model="policies.maxFileSizeMb" type="number"> <template #suffix>MB</template> + <template #caption> + <div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._role._options.maxFileSize_caption }}</div> + </template> </MkInput> </MkFolder> @@ -300,6 +307,13 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])"> + <template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template> + <template #suffix>{{ policies.scheduledNoteLimit }}</template> + <MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0"> + </MkInput> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])"> <template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template> <template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template> @@ -346,6 +360,7 @@ import { definePage } from '@/page.js'; import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { useRouter } from '@/router.js'; +import { deepClone } from '@/utility/clone.js'; import MkTextarea from '@/components/MkTextarea.vue'; const router = useRouter(); @@ -353,10 +368,7 @@ const baseRoleQ = ref(''); const roles = await misskeyApi('admin/roles/list'); -const policies = reactive<Record<typeof Misskey.rolePolicies[number], any>>({}); -for (const ROLE_POLICY of Misskey.rolePolicies) { - policies[ROLE_POLICY] = instance.policies[ROLE_POLICY]; -} +const policies = reactive(deepClone(instance.policies)); const avatarDecorationLimit = computed({ get: () => Math.min(16, Math.max(0, policies.avatarDecorationLimit)), @@ -376,6 +388,7 @@ function matchQuery(keywords: string[]): boolean { async function updateBaseRole() { await os.apiWithDialog('admin/roles/update-default-policies', { + //@ts-expect-error misskey-js側の型定義が不十分 policies, }); fetchInstance(true); diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index 7cbaeba8c7..2f7ecca521 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -11,26 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton style="margin-left: auto" @click="resetQuery">{{ i18n.ts.reset }}</MkButton> </div> <div :class="$style.inputs"> - <MkSelect v-model="sort" style="flex: 1;"> + <MkSelect v-model="sort" :items="sortDef" 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;"> + <MkSelect v-model="state" :items="stateDef" 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="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;"> + <MkSelect v-model="origin" :items="originDef" 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"> @@ -67,23 +55,57 @@ import * as os from '@/os.js'; import { lookupUser } from '@/utility/admin-lookup.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; import { dateString } from '@/filters/date.js'; import { Paginator } from '@/utility/paginator.js'; type SearchQuery = { - sort?: string; - state?: string; - origin?: string; + sort?: '-createdAt' | '+createdAt' | '-updatedAt' | '+updatedAt'; + state?: 'all' | 'available' | 'admin' | 'moderator' | 'suspended'; + origin?: 'combined' | 'local' | 'remote'; username?: string; hostname?: string; }; const storedQuery = JSON.parse(defaultMemoryStorage.getItem('admin-users-query') ?? '{}') as SearchQuery; -const sort = ref(storedQuery.sort ?? '+createdAt'); -const state = ref(storedQuery.state ?? 'all'); -const origin = ref(storedQuery.origin ?? 'local'); +const { + model: sort, + def: sortDef, +} = useMkSelect({ + items: [ + { label: `${i18n.ts.registeredDate} (${i18n.ts.ascendingOrder})`, value: '-createdAt' }, + { label: `${i18n.ts.registeredDate} (${i18n.ts.descendingOrder})`, value: '+createdAt' }, + { label: `${i18n.ts.lastUsed} (${i18n.ts.ascendingOrder})`, value: '-updatedAt' }, + { label: `${i18n.ts.lastUsed} (${i18n.ts.descendingOrder})`, value: '+updatedAt' }, + ], + initialValue: storedQuery.sort ?? '+createdAt', +}); +const { + model: state, + def: stateDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'all' }, + { label: i18n.ts.normal, value: 'available' }, + { label: i18n.ts.administrator, value: 'admin' }, + { label: i18n.ts.moderator, value: 'moderator' }, + { label: i18n.ts.suspend, value: 'suspended' }, + ], + initialValue: storedQuery.state ?? 'all', +}); +const { + model: origin, + def: originDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.all, value: 'combined' }, + { label: i18n.ts.local, value: 'local' }, + { label: i18n.ts.remote, value: 'remote' }, + ], + initialValue: storedQuery.origin ?? 'local', +}); const searchUsername = ref(storedQuery.username ?? ''); const searchHost = ref(storedQuery.hostname ?? ''); const paginator = markRaw(new Paginator('admin/show-users', { diff --git a/packages/frontend/src/pages/auth.vue b/packages/frontend/src/pages/auth.vue index 7e13d0ab36..83bf7221d0 100644 --- a/packages/frontend/src/pages/auth.vue +++ b/packages/frontend/src/pages/auth.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only <h1>{{ i18n.ts._auth.denied }}</h1> </div> <div v-if="state == 'accepted' && session"> - <h1>{{ session.app.isAuthorized ? i18n.ts['already-authorized'] : i18n.ts.allowed }}</h1> + <h1>{{ session.app.isAuthorized ? i18n.ts._auth.alreadyAuthorized : i18n.ts._auth.accepted }}</h1> <p v-if="session.app.callbackUrl"> {{ i18n.ts._auth.callback }} <MkEllipsis/> diff --git a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue index ddc4e89ef1..a8ce527523 100644 --- a/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue +++ b/packages/frontend/src/pages/avatar-decoration-edit-dialog.vue @@ -101,12 +101,12 @@ async function addRole() { const roles = await misskeyApi('admin/roles/list'); const currentRoleIds = rolesThatCanBeUsedThisDecoration.value.map(x => x.id); - const { canceled, result: role } = await os.select({ - items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), + const { canceled, result: roleId } = await os.select({ + items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ label: r.name, value: r.id })), }); - if (canceled || role == null) return; + if (canceled || roleId == null) return; - rolesThatCanBeUsedThisDecoration.value.push(role); + rolesThatCanBeUsedThisDecoration.value.push(roles.find(r => r.id === roleId)!); } async function removeRole(role, ev) { diff --git a/packages/frontend/src/pages/chat/home.vue b/packages/frontend/src/pages/chat/home.vue index 652ab04be6..5c773a241b 100644 --- a/packages/frontend/src/pages/chat/home.vue +++ b/packages/frontend/src/pages/chat/home.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true"> - <MkPolkadots v-if="tab === 'home'" accented/> + <MkPolkadots v-if="tab === 'home'" accented :height="200" style="margin-bottom: -200px;"/> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <XHome v-if="tab === 'home'"/> <XInvitations v-else-if="tab === 'invitations'"/> @@ -48,7 +48,7 @@ const headerTabs = computed(() => [{ }]); definePage(() => ({ - title: i18n.ts.chat + ' (beta)', + title: i18n.ts.directMessage, icon: 'ti ti-messages', })); </script> diff --git a/packages/frontend/src/pages/chat/message.vue b/packages/frontend/src/pages/chat/message.vue index 834aa9e033..9accea185e 100644 --- a/packages/frontend/src/pages/chat/message.vue +++ b/packages/frontend/src/pages/chat/message.vue @@ -46,6 +46,6 @@ onMounted(() => { }); definePage({ - title: i18n.ts.chat, + title: i18n.ts.directMessage, }); </script> diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index 6443616fe3..ef9205d86e 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -421,7 +421,7 @@ const tab = ref('chat'); const headerTabs = computed(() => room.value ? [{ key: 'chat', - title: i18n.ts.chat, + title: i18n.ts._chat.messages, icon: 'ti ti-messages', }, { key: 'members', @@ -437,7 +437,7 @@ const headerTabs = computed(() => room.value ? [{ icon: 'ti ti-info-circle', }] : [{ key: 'chat', - title: i18n.ts.chat, + title: i18n.ts._chat.messages, icon: 'ti ti-messages', }, { key: 'search', @@ -466,12 +466,12 @@ definePage(computed(() => { }; } else { return { - title: i18n.ts.chat, + title: i18n.ts.directMessage, }; } } else { return { - title: i18n.ts.chat, + title: i18n.ts.directMessage, }; } })); diff --git a/packages/frontend/src/pages/contact.vue b/packages/frontend/src/pages/contact.vue index eb94f23ac9..91d3e0e537 100644 --- a/packages/frontend/src/pages/contact.vue +++ b/packages/frontend/src/pages/contact.vue @@ -28,17 +28,37 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else style="opacity: 0.7;">({{ i18n.ts.none }})</span> </template> </MkKeyValue> + <MkFolder @opened="onOpened"> + <template #icon><i class="ti ti-report-search"></i></template> + <template #label>{{ i18n.ts.deviceInfo }}</template> + <template #caption>{{ i18n.ts.deviceInfoDescription }}</template> + <MkLoading v-if="userEnv == null" /> + <MkCode v-else lang="json" :code="JSON.stringify(userEnv, null, 2)" style="max-height: 300px; overflow: auto;"/> + </MkFolder> </div> </div> </PageWithHeader> </template> <script lang="ts" setup> +import { ref } from 'vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { definePage } from '@/page.js'; +import { getUserEnvironment } from '@/utility/get-user-environment.js'; +import type { UserEnvironment } from '@/utility/get-user-environment.js'; import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkLink from '@/components/MkLink.vue'; +import MkCode from '@/components/MkCode.vue'; + +const userEnv = ref<UserEnvironment | null>(null); + +async function onOpened() { + if (userEnv.value == null) { + userEnv.value = await getUserEnvironment(); + } +} definePage(() => ({ title: i18n.ts.inquiry, diff --git a/packages/frontend/src/pages/debug.vue b/packages/frontend/src/pages/debug.vue index 5cd68c2c3a..9c0761f0b1 100644 --- a/packages/frontend/src/pages/debug.vue +++ b/packages/frontend/src/pages/debug.vue @@ -11,11 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkResult v-if="resultType === 'notFound'" type="notFound"/> <MkResult v-if="resultType === 'error'" type="error"/> <MkSelect - v-model="resultType" :items="[ - { label: 'empty', value: 'empty' }, - { label: 'notFound', value: 'notFound' }, - { label: 'error', value: 'error' }, - ]" + v-model="resultType" :items="resultTypeDef" ></MkSelect> <MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 150px;"/> @@ -25,14 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/> <MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/> <MkSelect - v-model="iconType" :items="[ - { label: 'info', value: 'info' }, - { label: 'question', value: 'question' }, - { label: 'success', value: 'success' }, - { label: 'warn', value: 'warn' }, - { label: 'error', value: 'error' }, - { label: 'waiting', value: 'waiting' }, - ]" + v-model="iconType" :items="iconTypeDef" ></MkSelect> <div class="_buttons"> @@ -56,10 +45,34 @@ import MkKeyValue from '@/components/MkKeyValue.vue'; import MkLink from '@/components/MkLink.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import * as os from '@/os.js'; -const resultType = ref('empty'); -const iconType = ref('info'); +const { + model: resultType, + def: resultTypeDef, +} = useMkSelect({ + items: [ + { label: 'empty', value: 'empty' }, + { label: 'notFound', value: 'notFound' }, + { label: 'error', value: 'error' }, + ], + initialValue: 'empty', +}); +const { + model: iconType, + def: iconTypeDef, +} = useMkSelect({ + items: [ + { label: 'info', value: 'info' }, + { label: 'question', value: 'question' }, + { label: 'success', value: 'success' }, + { label: 'warn', value: 'warn' }, + { label: 'error', value: 'error' }, + { label: 'waiting', value: 'waiting' }, + ], + initialValue: 'info', +}); definePage(() => ({ title: 'DEBUG ROOM', diff --git a/packages/frontend/src/pages/drop-and-fusion.vue b/packages/frontend/src/pages/drop-and-fusion.vue index c1a8b992b7..0a69dbdd70 100644 --- a/packages/frontend/src/pages/drop-and-fusion.vue +++ b/packages/frontend/src/pages/drop-and-fusion.vue @@ -23,12 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_woodenFrame" style="text-align: center;"> <div class="_woodenFrameInner"> <div class="_gaps" style="padding: 16px;"> - <MkSelect v-model="gameMode"> - <option value="normal">NORMAL</option> - <option value="square">SQUARE</option> - <option value="yen">YEN</option> - <option value="sweets">SWEETS</option> - <!--<option value="space">SPACE</option>--> + <MkSelect v-model="gameMode" :items="gameModeDef"> </MkSelect> <MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton> </div> @@ -92,11 +87,24 @@ import XGame from './drop-and-fusion.game.vue'; import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import { misskeyApiGet } from '@/utility/misskey-api.js'; -const gameMode = ref<'normal' | 'square' | 'yen' | 'sweets' | 'space'>('normal'); +const { + model: gameMode, + def: gameModeDef, +} = useMkSelect({ + items: [ + { label: 'NORMAL', value: 'normal' }, + { label: 'SQUARE', value: 'square' }, + { label: 'YEN', value: 'yen' }, + { label: 'SWEETS', value: 'sweets' }, + //{ label: 'SPACE', value: 'space' }, + ], + initialValue: 'normal', +}); const gameStarted = ref(false); const mute = ref(false); const ranking = ref<Misskey.entities.BubbleGameRankingResponse | null>(null); diff --git a/packages/frontend/src/pages/emoji-edit-dialog.vue b/packages/frontend/src/pages/emoji-edit-dialog.vue index 033e3376a5..ea4863950d 100644 --- a/packages/frontend/src/pages/emoji-edit-dialog.vue +++ b/packages/frontend/src/pages/emoji-edit-dialog.vue @@ -135,12 +135,12 @@ async function addRole() { const roles = await misskeyApi('admin/roles/list'); const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id); - const { canceled, result: role } = await os.select({ - items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })), + const { canceled, result: roleId } = await os.select({ + items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ label: r.name, value: r.id })), }); - if (canceled || role == null) return; + if (canceled || roleId == null) return; - rolesThatCanBeUsedThisEmojiAsReaction.value.push(role); + rolesThatCanBeUsedThisEmojiAsReaction.value.push(roles.find(r => r.id === roleId)!); } async function removeRole(role: Misskey.entities.RoleLite, ev: Event) { diff --git a/packages/frontend/src/pages/explore.featured.vue b/packages/frontend/src/pages/explore.featured.vue index abb816a956..3158b384d2 100644 --- a/packages/frontend/src/pages/explore.featured.vue +++ b/packages/frontend/src/pages/explore.featured.vue @@ -5,9 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_spacer" style="--MI_SPACER-w: 800px;"> - <MkTab v-model="tab" style="margin-bottom: var(--MI-margin);"> - <option value="notes">{{ i18n.ts.notes }}</option> - <option value="polls">{{ i18n.ts.poll }}</option> + <MkTab + v-model="tab" + :tabs="[ + { key: 'notes', label: i18n.ts.notes }, + { key: 'polls', label: i18n.ts.poll }, + ]" + style="margin-bottom: var(--MI-margin);" + > </MkTab> <MkNotesTimeline v-if="tab === 'notes'" :paginator="paginatorForNotes"/> <MkNotesTimeline v-else-if="tab === 'polls'" :paginator="paginatorForPolls"/> @@ -33,5 +38,5 @@ const paginatorForPolls = markRaw(new Paginator('notes/polls/recommendation', { }, })); -const tab = ref('notes'); +const tab = ref<'notes' | 'polls'>('notes'); </script> diff --git a/packages/frontend/src/pages/explore.users.vue b/packages/frontend/src/pages/explore.users.vue index 08f9f5e582..4e3fb16b5a 100644 --- a/packages/frontend/src/pages/explore.users.vue +++ b/packages/frontend/src/pages/explore.users.vue @@ -5,9 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_spacer" style="--MI_SPACER-w: 1200px;"> - <MkTab v-if="instance.federation !== 'none'" v-model="origin" style="margin-bottom: var(--MI-margin);"> - <option value="local">{{ i18n.ts.local }}</option> - <option value="remote">{{ i18n.ts.remote }}</option> + <MkTab + v-if="instance.federation !== 'none'" + v-model="origin" + :tabs="[ + { key: 'local', label: i18n.ts.local }, + { key: 'remote', label: i18n.ts.remote }, + ]" + style="margin-bottom: var(--MI-margin);" + > </MkTab> <div v-if="origin === 'local'"> <template v-if="tag == null"> @@ -77,7 +83,7 @@ const props = defineProps<{ tag?: string; }>(); -const origin = ref('local'); +const origin = ref<'local' | 'remote'>('local'); const tagsLocal = ref<Misskey.entities.Hashtag[]>([]); const tagsRemote = ref<Misskey.entities.Hashtag[]>([]); diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 81b9d1cead..b3e8e88c23 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -10,11 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="title"> <template #label>{{ i18n.ts._play.title }}</template> </MkInput> - <MkSelect v-model="visibility"> + <MkSelect v-model="visibility" :items="visibilityDef"> <template #label>{{ i18n.ts.visibility }}</template> <template #caption>{{ i18n.ts._play.visibilityDescription }}</template> - <option :key="'public'" :value="'public'">{{ i18n.ts.public }}</option> - <option :key="'private'" :value="'private'">{{ i18n.ts.private }}</option> </MkSelect> <MkTextarea v-model="summary" :mfmAutocomplete="true" :mfmPreview="true"> <template #label>{{ i18n.ts._play.summary }}</template> @@ -52,6 +50,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkCodeEditor from '@/components/MkCodeEditor.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { useRouter } from '@/router.js'; const PRESET_DEFAULT = `/// @ ${AISCRIPT_VERSION} @@ -384,7 +383,16 @@ if (props.id) { const title = ref(flash.value?.title ?? 'New Play'); const summary = ref(flash.value?.summary ?? ''); const permissions = ref([]); // not implemented yet -const visibility = ref<'private' | 'public'>(flash.value?.visibility ?? 'public'); +const { + model: visibility, + def: visibilityDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.public, value: 'public' }, + { label: i18n.ts.private, value: 'private' }, + ], + initialValue: flash.value?.visibility ?? 'public', +}); const script = ref(flash.value?.script ?? PRESET_DEFAULT); function selectPreset(ev: MouseEvent) { diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index eab435c002..31a716fb0e 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div class="body"> <div class="title">{{ post.title }}</div> - <div class="description"><Mfm :text="post.description"/></div> + <div class="description"><Mfm v-if="post.description != null" :text="post.description"/></div> <div class="info"> <i class="ti ti-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> </div> @@ -93,7 +93,7 @@ const error = ref<any>(null); const otherPostsPaginator = markRaw(new Paginator('users/gallery/posts', { limit: 6, computedParams: computed(() => ({ - userId: post.value.user.id, + userId: post.value!.user.id, })), })); @@ -109,33 +109,38 @@ function fetchPost() { } function copyLink() { + if (!post.value) return; copyToClipboard(`${url}/gallery/${post.value.id}`); } function share() { + if (!post.value) return; navigator.share({ title: post.value.title, - text: post.value.description, + text: post.value.description ?? undefined, url: `${url}/gallery/${post.value.id}`, }); } function shareWithNote() { + if (!post.value) return; os.post({ initialText: `${post.value.title} ${url}/gallery/${post.value.id}`, }); } function like() { + if (!post.value) return; os.apiWithDialog('gallery/posts/like', { postId: props.postId, }).then(() => { - post.value.isLiked = true; - post.value.likedCount++; + post.value!.isLiked = true; + post.value!.likedCount++; }); } async function unlike() { + if (!post.value) return; const confirm = await os.confirm({ type: 'warning', text: i18n.ts.unlikeConfirm, @@ -144,8 +149,8 @@ async function unlike() { os.apiWithDialog('gallery/posts/unlike', { postId: props.postId, }).then(() => { - post.value.isLiked = false; - post.value.likedCount--; + post.value!.isLiked = false; + post.value!.likedCount--; }); } diff --git a/packages/frontend/src/pages/instance-info.vue b/packages/frontend/src/pages/instance-info.vue index 473207fe6e..61a40202c0 100644 --- a/packages/frontend/src/pages/instance-info.vue +++ b/packages/frontend/src/pages/instance-info.vue @@ -92,18 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'chart'" class="_gaps_m"> <div> <div :class="$style.selects"> - <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> - <option value="instance-requests">{{ i18n.ts._instanceCharts.requests }}</option> - <option value="instance-users">{{ i18n.ts._instanceCharts.users }}</option> - <option value="instance-users-total">{{ i18n.ts._instanceCharts.usersTotal }}</option> - <option value="instance-notes">{{ i18n.ts._instanceCharts.notes }}</option> - <option value="instance-notes-total">{{ i18n.ts._instanceCharts.notesTotal }}</option> - <option value="instance-ff">{{ i18n.ts._instanceCharts.ff }}</option> - <option value="instance-ff-total">{{ i18n.ts._instanceCharts.ffTotal }}</option> - <option value="instance-drive-usage">{{ i18n.ts._instanceCharts.cacheSize }}</option> - <option value="instance-drive-usage-total">{{ i18n.ts._instanceCharts.cacheSizeTotal }}</option> - <option value="instance-drive-files">{{ i18n.ts._instanceCharts.files }}</option> - <option value="instance-drive-files-total">{{ i18n.ts._instanceCharts.filesTotal }}</option> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;"> </MkSelect> </div> <div> @@ -154,6 +143,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkPagination from '@/components/MkPagination.vue'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { dateString } from '@/filters/date.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkTextarea from '@/components/MkTextarea.vue'; import { Paginator } from '@/utility/paginator.js'; @@ -163,7 +153,25 @@ const props = defineProps<{ const tab = ref('overview'); -const chartSrc = ref<ChartSrc>('instance-requests'); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._instanceCharts.requests, value: 'instance-requests' }, + { label: i18n.ts._instanceCharts.users, value: 'instance-users' }, + { label: i18n.ts._instanceCharts.usersTotal, value: 'instance-users-total' }, + { label: i18n.ts._instanceCharts.notes, value: 'instance-notes' }, + { label: i18n.ts._instanceCharts.notesTotal, value: 'instance-notes-total' }, + { label: i18n.ts._instanceCharts.ff, value: 'instance-ff' }, + { label: i18n.ts._instanceCharts.ffTotal, value: 'instance-ff-total' }, + { label: i18n.ts._instanceCharts.cacheSize, value: 'instance-drive-usage' }, + { label: i18n.ts._instanceCharts.cacheSizeTotal, value: 'instance-drive-usage-total' }, + { label: i18n.ts._instanceCharts.files, value: 'instance-drive-files' }, + { label: i18n.ts._instanceCharts.filesTotal, value: 'instance-drive-files-total' }, + ], + initialValue: 'instance-requests', +}); const meta = ref<Misskey.entities.AdminMetaResponse | null>(null); const instance = ref<Misskey.entities.FederationInstance | null>(null); const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none'); diff --git a/packages/frontend/src/pages/list.vue b/packages/frontend/src/pages/list.vue index a52b562c7f..efb1186fe5 100644 --- a/packages/frontend/src/pages/list.vue +++ b/packages/frontend/src/pages/list.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton> + <MkButton v-if="list.isLiked" v-tooltip="i18n.ts.unlike" inline :class="$style.button" asLike primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="list.likedCount != null && list.likedCount > 0" class="count">{{ list.likedCount }}</span></MkButton> <MkButton v-if="!list.isLiked" v-tooltip="i18n.ts.like" inline :class="$style.button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="1 > 0" class="count">{{ list.likedCount }}</span></MkButton> <MkButton inline @click="create()"><i class="ti ti-download" :class="$style.import"></i>{{ i18n.ts.import }}</MkButton> </div> @@ -41,7 +41,7 @@ const props = defineProps<{ listId: string; }>(); -const list = ref<Misskey.entities.UserList | null>(null); +const list = ref<Misskey.entities.UsersListsShowResponse | null>(null); const error = ref<unknown | null>(null); const users = ref<Misskey.entities.UserDetailed[]>([]); @@ -51,8 +51,9 @@ function fetchList(): void { forPublic: true, }).then(_list => { list.value = _list; + if (_list.userIds == null || _list.userIds.length === 0) return; misskeyApi('users/show', { - userIds: list.value.userIds, + userIds: _list.userIds, }).then(_users => { users.value = _users; }); @@ -68,7 +69,7 @@ function like() { }).then(() => { if (list.value == null) return; list.value.isLiked = true; - list.value.likedCount++; + list.value.likedCount = (list.value.likedCount != null ? list.value.likedCount + 1 : 1); }); } @@ -79,7 +80,7 @@ function unlike() { }).then(() => { if (list.value == null) return; list.value.isLiked = false; - list.value.likedCount--; + list.value.likedCount = (list.value.likedCount != null ? Math.max(0, list.value.likedCount - 1) : 0); }); } @@ -88,7 +89,7 @@ async function create() { const { canceled, result: name } = await os.inputText({ title: i18n.ts.enterListName, }); - if (canceled) return; + if (canceled || name == null) return; await os.apiWithDialog('users/lists/create-from-public', { name: name, listId: list.value.id }); } diff --git a/packages/frontend/src/pages/note.vue b/packages/frontend/src/pages/note.vue index abd2a5d8a1..c93ec4272a 100644 --- a/packages/frontend/src/pages/note.vue +++ b/packages/frontend/src/pages/note.vue @@ -136,10 +136,10 @@ function fetchNote() { }); } }).catch(err => { - if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') { + if (['fbcc002d-37d9-4944-a6b0-d9e29f2d33ab', '145f88d2-b03d-4087-8143-a78928883c4b'].includes(err.id)) { pleaseLogin({ path: '/', - message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor, + message: err.id === 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab' ? i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor : i18n.ts.signinOrContinueOnRemote, openOnRemote: { type: 'lookup', url: `https://${host}/notes/${props.noteId}`, diff --git a/packages/frontend/src/pages/page-editor/common.ts b/packages/frontend/src/pages/page-editor/common.ts index 420c8fc967..64cd9cde7a 100644 --- a/packages/frontend/src/pages/page-editor/common.ts +++ b/packages/frontend/src/pages/page-editor/common.ts @@ -4,12 +4,13 @@ */ import { i18n } from '@/i18n.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; export function getPageBlockList() { return [ - { value: 'section', text: i18n.ts._pages.blocks.section }, - { value: 'text', text: i18n.ts._pages.blocks.text }, - { value: 'image', text: i18n.ts._pages.blocks.image }, - { value: 'note', text: i18n.ts._pages.blocks.note }, - ]; + { value: 'section', label: i18n.ts._pages.blocks.section }, + { value: 'text', label: i18n.ts._pages.blocks.text }, + { value: 'image', label: i18n.ts._pages.blocks.image }, + { value: 'note', label: i18n.ts._pages.blocks.note }, + ] as const satisfies MkSelectItem[]; } diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue index f275ec9517..e596b31b43 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue @@ -39,6 +39,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'note' }): void; + (ev: 'remove'): void; }>(); const id = ref(props.modelValue.note); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue index cf5712a8e5..bb0841965f 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -71,7 +71,7 @@ async function add() { title: i18n.ts._pages.chooseBlock, items: getPageBlockList(), }); - if (canceled) return; + if (canceled || type == null) return; const id = genId(); children.value.push({ id, type }); diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue index 4a980ce472..079a28491b 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue @@ -27,6 +27,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'update:modelValue', value: Misskey.entities.PageBlock & { type: 'text' }): void; + (ev: 'remove'): void; }>(); let autocomplete: Autocomplete; @@ -42,6 +43,7 @@ watch(text, () => { }); onMounted(() => { + if (inputEl.value == null) return; autocomplete = new Autocomplete(inputEl.value, text); }); diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 9fe03ae981..3dd83b25c5 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <div class="_spacer" style="--MI_SPACER-w: 700px;"> <div class="jqqmcavi"> - <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> + <MkButton v-if="pageId && author != null" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="ti ti-external-link"></i> {{ i18n.ts._pages.viewPage }}</MkButton> <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="ti ti-copy"></i> {{ i18n.ts.duplicate }}</MkButton> <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> @@ -24,16 +24,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput v-model="name"> - <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <template #prefix>{{ url }}/@{{ author?.username ?? '???' }}/pages/</template> <template #label>{{ i18n.ts._pages.url }}</template> </MkInput> <MkSwitch v-model="alignCenter">{{ i18n.ts._pages.alignCenter }}</MkSwitch> - <MkSelect v-model="font"> + <MkSelect v-model="font" :items="fontDef"> <template #label>{{ i18n.ts._pages.font }}</template> - <option value="serif">{{ i18n.ts._pages.fontSerif }}</option> - <option value="sans-serif">{{ i18n.ts._pages.fontSansSerif }}</option> </MkSelect> <MkSwitch v-model="hideTitleWhenPinned">{{ i18n.ts._pages.hideTitleWhenPinned }}</MkSwitch> @@ -76,6 +74,7 @@ import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; import { $i } from '@/i.js'; import { mainRouter } from '@/router.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { getPageBlockList } from '@/pages/page-editor/common.js'; const props = defineProps<{ @@ -85,7 +84,7 @@ const props = defineProps<{ }>(); const tab = ref('settings'); -const author = ref($i); +const author = ref<Misskey.entities.User | null>($i); const readonly = ref(false); const page = ref<Misskey.entities.Page | null>(null); const pageId = ref<string | null>(null); @@ -95,7 +94,16 @@ const summary = ref<string | null>(null); const name = ref(Date.now().toString()); const eyeCatchingImage = ref<Misskey.entities.DriveFile | null>(null); const eyeCatchingImageId = ref<string | null>(null); -const font = ref<'sans-serif' | 'serif'>('sans-serif'); +const { + model: font, + def: fontDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._pages.fontSansSerif, value: 'sans-serif' }, + { label: i18n.ts._pages.fontSerif, value: 'serif' }, + ], + initialValue: 'sans-serif', +}); const content = ref<Misskey.entities.Page['content']>([]); const alignCenter = ref(false); const hideTitleWhenPinned = ref(false); @@ -202,11 +210,10 @@ async function duplicate() { async function add() { const { canceled, result: type } = await os.select({ - type: null, title: i18n.ts._pages.chooseBlock, items: getPageBlockList(), }); - if (canceled) return; + if (canceled || type == null) return; const id = genId(); content.value.push({ id, type }); diff --git a/packages/frontend/src/pages/qr.read.raw-viewer.vue b/packages/frontend/src/pages/qr.read.raw-viewer.vue new file mode 100644 index 0000000000..5a23e2322d --- /dev/null +++ b/packages/frontend/src/pages/qr.read.raw-viewer.vue @@ -0,0 +1,54 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkFolder defaultOpen :withSpacer="false"> + <template #label>{{ data.split('\n')[0] }}</template> + <template #header> + <MkTabs + v-model:tab="tab" + :tabs="[ + { + key: 'mfm', + title: i18n.ts._qr.mfm, + icon: 'ti ti-align-left', + }, + { + key: 'raw', + title: i18n.ts._qr.raw, + icon: 'ti ti-code', + }, + ]" + /> + </template> + + <div v-show="tab === 'mfm'" class="_spacer _gaps"> + <Mfm :text="data" :nyaize="false"/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false"/> + </div> + <div v-show="tab === 'raw'" class="_spacer" style="--MI_SPACER-min: 10px; --MI_SPACER-max: 16px;"> + <MkCode :code="data" lang="text"/> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { ref, computed } from 'vue'; +import * as mfm from 'mfm-js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkTabs from '@/components/MkTabs.vue'; +import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm'; +import MkCode from '@/components/MkCode.vue'; +import MkUrlPreview from '@/components/MkUrlPreview.vue'; +import { i18n } from '@/i18n.js'; + +const props = defineProps<{ + data: string; +}>(); + +const parsed = computed(() => mfm.parse(props.data)); +const urls = computed(() => extractUrlFromMfm(parsed.value)); +const tab = ref<'mfm' | 'raw'>('mfm'); +</script> diff --git a/packages/frontend/src/pages/qr.read.vue b/packages/frontend/src/pages/qr.read.vue new file mode 100644 index 0000000000..251dccd0f0 --- /dev/null +++ b/packages/frontend/src/pages/qr.read.vue @@ -0,0 +1,402 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div + ref="rootEl" + :class="$style.root" + :style="{ + '--MI-QrReadViewHeight': 'calc(100cqh - var(--MI-stickyTop, 0px) - var(--MI-stickyBottom, 0px))', + '--MI-QrReadVideoHeight': 'min(calc(var(--MI-QrReadViewHeight) * 0.3), 512px)', + }" +> + <MkStickyContainer> + <template #header> + <div :class="$style.view"> + <video ref="videoEl" :class="$style.video" autoplay muted playsinline></video> + <div ref="overlayEl" :class="$style.overlay"></div> + <div :class="$style.controls"> + <MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton> + + <MkButton v-if="qrStarted" v-tooltip="i18n.ts._qr.stopQr" iconOnly @click="stopQr"><i class="ti ti-player-play"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts._qr.startQr" iconOnly danger @click="startQr"><i class="ti ti-player-pause"></i></MkButton> + + <MkButton v-tooltip="i18n.ts._qr.chooseCamera" iconOnly @click="chooseCamera"><i class="ti ti-camera-rotate"></i></MkButton> + + <MkButton v-if="!flashCanToggle" v-tooltip="i18n.ts._qr.cannotToggleFlash" iconOnly disabled><i class="ti ti-bolt"></i></MkButton> + <MkButton v-else-if="!flash" v-tooltip="i18n.ts._qr.turnOnFlash" iconOnly @click="toggleFlash(true)"><i class="ti ti-bolt-off"></i></MkButton> + <MkButton v-else v-tooltip="i18n.ts._qr.turnOffFlash" iconOnly @click="toggleFlash(false)"><i class="ti ti-bolt-filled"></i></MkButton> + </div> + </div> + </template> + <div + :class="['_spacer', $style.contents]" + :style="{ + '--MI_SPACER-w': '800px' + }" + > + <MkStickyContainer> + <template #header> + <MkTab + v-model="tab" + :tabs="[ + { key: 'users', label: i18n.ts.users }, + { key: 'notes', label: i18n.ts.notes }, + { key: 'all', label: i18n.ts.all }, + ]" + :class="$style.tab" + > + </MkTab> + </template> + <div v-if="tab === 'users'" :class="[$style.users, '_margin']" style="padding-bottom: var(--MI-margin);"> + <MkUserInfo v-for="user in users" :key="user.id" :user="user"/> + </div> + <div v-else-if="tab === 'notes'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);"> + <MkNote v-for="note in notes" :key="note.id" :note="note" :class="$style.note"/> + </div> + <div v-else-if="tab === 'all'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);"> + <MkQrReadRawViewer v-for="result in Array.from(results).reverse()" :key="result" :data="result"/> + </div> + </MkStickyContainer> + </div> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import QrScanner from 'qr-scanner'; +import { onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import { getScrollContainer } from '@@/js/scroll.js'; +import type { ApShowResponse } from 'misskey-js/entities.js'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import MkUserInfo from '@/components/MkUserInfo.vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import MkNote from '@/components/MkNote.vue'; +import MkTab from '@/components/MkTab.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkQrReadRawViewer from '@/pages/qr.read.raw-viewer.vue'; + +const LIST_RERENDER_INTERVAL = 1500; + +const rootEl = useTemplateRef('rootEl'); +const videoEl = useTemplateRef('videoEl'); +const overlayEl = useTemplateRef('overlayEl'); + +const scannerInstance = shallowRef<QrScanner | null>(null); + +const tab = ref<'users' | 'notes' | 'all'>('users'); + +// higher is recent +const results = ref(new Set<string>()); +// lower is recent +const uris = ref<string[]>([]); +const sources = new Map<string, ApShowResponse | null>(); +const users = ref<(misskey.entities.UserDetailed)[]>([]); +const usersCount = ref(0); +const notes = ref<misskey.entities.Note[]>([]); +const notesCount = ref(0); + +const timer = ref<number | null>(null); + +function updateLists() { + const responses = uris.value.map(uri => sources.get(uri)).filter((r): r is ApShowResponse => !!r); + users.value = responses.filter(r => r.type === 'User').map(r => r.object).filter((u): u is misskey.entities.UserDetailed => !!u); + usersCount.value = users.value.length; + notes.value = responses.filter(r => r.type === 'Note').map(r => r.object).filter((n): n is misskey.entities.Note => !!n); + notesCount.value = notes.value.length; + updateRequired.value = false; +} + +const updateRequired = ref(false); + +watch(uris, () => { + if (timer.value) { + updateRequired.value = true; + return; + } + + updateLists(); + + timer.value = window.setTimeout(() => { + timer.value = null; + if (updateRequired.value) { + updateLists(); + } + }, LIST_RERENDER_INTERVAL) as number; +}); + +watch(tab, () => { + if (timer.value) { + window.clearTimeout(timer.value); + timer.value = null; + } + updateLists(); +}); + +async function processResult(result: QrScanner.ScanResult) { + if (!result) return; + const trimmed = result.data.trim(); + + if (!trimmed) return; + + const haveExisted = results.value.has(trimmed); + results.value.add(trimmed); + + try { + new URL(trimmed); + } catch { + if (!haveExisted) { + tab.value = 'all'; + } + return; + } + + if (uris.value[0] !== trimmed) { + // 並べ替え + uris.value = [trimmed, ...uris.value.slice(0, 29).filter(u => u !== trimmed)]; + } + + if (sources.has(trimmed)) return; + // Start fetching user info + sources.set(trimmed, null); + + await misskeyApi('ap/show', { uri: trimmed }) + .then(data => { + if (data.type === 'User') { + sources.set(trimmed, data); + tab.value = 'users'; + } else if (data.type === 'Note') { + sources.set(trimmed, data); + tab.value = 'notes'; + } + updateLists(); + }) + .catch(err => { + tab.value = 'all'; + throw err; + }); +} + +const qrStarted = ref(true); +const flashCanToggle = ref(false); +const flash = ref(false); + +async function upload() { + os.chooseFileFromPc({ multiple: true }).then(files => { + if (files.length === 0) return; + for (const file of files) { + QrScanner.scanImage(file, { returnDetailedScanResult: true }) + .then(result => { + processResult(result); + }) + .catch(err => { + if (err.toString().includes('No QR code found')) { + os.alert({ + type: 'info', + text: i18n.ts._qr.noQrCodeFound, + }); + } else { + os.alert({ + type: 'error', + text: err.toString(), + }); + console.error(err); + } + }); + } + }); +} + +async function chooseCamera() { + if (!scannerInstance.value) return; + const cameras = await QrScanner.listCameras(true); + if (cameras.length === 0) { + os.alert({ + type: 'error', + }); + return; + } + + const select = await os.select({ + title: i18n.ts._qr.chooseCamera, + items: cameras.map(camera => ({ + label: camera.label, + value: camera.id, + })), + }); + if (select.canceled) return; + if (select.result == null) return; + + await scannerInstance.value.setCamera(select.result); + flashCanToggle.value = await scannerInstance.value.hasFlash(); + flash.value = scannerInstance.value.isFlashOn(); +} + +async function toggleFlash(to = false) { + if (!scannerInstance.value) return; + + flash.value = to; + if (flash.value) { + await scannerInstance.value.turnFlashOn(); + } else { + await scannerInstance.value.turnFlashOff(); + } +} + +async function startQr() { + if (!scannerInstance.value) return; + await scannerInstance.value.start(); + qrStarted.value = true; +} + +function stopQr() { + if (!scannerInstance.value) return; + scannerInstance.value.stop(); + qrStarted.value = false; +} + +onActivated(() => { + startQr; +}); + +onDeactivated(() => { + stopQr; +}); + +const alertLock = ref(false); + +onMounted(() => { + if (!videoEl.value || !overlayEl.value) { + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + return; + } + + scannerInstance.value = new QrScanner( + videoEl.value, + processResult, + { + highlightScanRegion: true, + highlightCodeOutline: true, + overlay: overlayEl.value, + calculateScanRegion(video: HTMLVideoElement): QrScanner.ScanRegion { + const aspectRatio = video.videoWidth / video.videoHeight; + const SHORT_SIDE_SIZE_DOWNSCALED = 360; + return { + x: 0, + y: 0, + width: video.videoWidth, + height: video.videoHeight, + downScaledWidth: aspectRatio > 1 ? Math.round(SHORT_SIDE_SIZE_DOWNSCALED * aspectRatio) : SHORT_SIDE_SIZE_DOWNSCALED, + downScaledHeight: aspectRatio > 1 ? SHORT_SIDE_SIZE_DOWNSCALED : Math.round(SHORT_SIDE_SIZE_DOWNSCALED / aspectRatio), + }; + }, + onDecodeError(err) { + if (err.toString().includes('No QR code found')) return; + if (alertLock.value) return; + alertLock.value = true; + os.alert({ + type: 'error', + text: err.toString(), + }).finally(() => { + alertLock.value = false; + }); + }, + }, + ); + + scannerInstance.value.start() + .then(async () => { + qrStarted.value = true; + if (!scannerInstance.value) return; + flashCanToggle.value = await scannerInstance.value.hasFlash(); + flash.value = scannerInstance.value.isFlashOn(); + }) + .catch(err => { + qrStarted.value = false; + os.alert({ + type: 'error', + text: err.toString(), + }); + console.error(err); + }); +}); + +onUnmounted(() => { + if (timer.value) { + window.clearTimeout(timer.value); + timer.value = null; + } + + scannerInstance.value?.destroy(); +}); +</script> + +<style lang="scss" module> +.root { + position: relative; +} + +.view { + position: sticky; + top: var(--MI-stickyTop, 0); + z-index: 1; + background: var(--MI_THEME-bg); + background-size: 16px 16px; + width: 100%; + height: var(--MI-QrReadVideoHeight); +} + +.video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.controls { + width: 100%; + position: absolute; + right: 10px; + bottom: 10px; + display: flex; + justify-content: end; + align-items: center; + gap: 10px; +} + +html[data-color-scheme=dark] .view { + --c: rgb(255 255 255 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +html[data-color-scheme=light] .view { + --c: rgb(0 0 0 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +.contents { + padding-top: calc(var(--MI-margin) / 2); +} + +.tab { + padding: calc(var(--MI-margin) / 2) 0; + background: var(--MI_THEME-bg); +} + +.users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-gap: var(--MI-margin); +} + +.note { + background: var(--MI_THEME-panel); + border-radius: var(--MI-radius); +} +</style> diff --git a/packages/frontend/src/pages/qr.show.vue b/packages/frontend/src/pages/qr.show.vue new file mode 100644 index 0000000000..28f80e0963 --- /dev/null +++ b/packages/frontend/src/pages/qr.show.vue @@ -0,0 +1,234 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root"> + <div :class="[$style.content]"> + <div + ref="qrCodeEl" v-flip :style="{ + 'cursor': canShare ? 'pointer' : 'default', + }" + :class="$style.qr" @click="share" + ></div> + <div v-flip :class="$style.user"> + <MkAvatar :class="$style.avatar" :user="$i" :indicator="false"/> + <div> + <div :class="$style.name"><MkCondensedLine :minScale="2 / 3"><MkUserName :user="$i" :nowrap="true"/></MkCondensedLine></div> + <div><MkCondensedLine :minScale="2 / 3">{{ acct }}</MkCondensedLine></div> + </div> + </div> + <img v-if="deviceMotionPermissionNeeded" v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo" @click="requestDeviceMotion"/> + <img v-else v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import tinycolor from 'tinycolor2'; +import QRCodeStyling from 'qr-code-styling'; +import { computed, ref, shallowRef, watch, onMounted, onUnmounted, useTemplateRef } from 'vue'; +import { url, host } from '@@/js/config.js'; +import type { Directive } from 'vue'; +import { instance } from '@/instance.js'; +import { ensureSignin } from '@/i.js'; +import { userPage, userName } from '@/filters/user.js'; +import misskeysvg from '/client-assets/misskey.svg'; +import { getStaticImageUrl } from '@/utility/media-proxy.js'; +import { i18n } from '@/i18n.js'; + +const $i = ensureSignin(); + +const acct = computed(() => `@${$i.username}@${host}`); +const userProfileUrl = computed(() => userPage($i, undefined, true)); +const shareData = computed(() => ({ + title: i18n.tsx._qr.shareTitle({ name: userName($i), acct: acct.value }), + text: i18n.ts._qr.shareText, + url: userProfileUrl.value, +})); +const canShare = computed(() => navigator.canShare && navigator.canShare(shareData.value)); + +const qrCodeEl = useTemplateRef('qrCodeEl'); + +const qrColor = computed(() => tinycolor(instance.themeColor ?? '#86b300')); +const qrHsl = computed(() => qrColor.value.toHsl()); + +function share() { + if (!canShare.value) return; + return navigator.share(shareData.value); +} + +const qrCodeInstance = new QRCodeStyling({ + width: 600, + height: 600, + margin: 42, + type: 'canvas', + data: `${url}/users/${$i.id}`, + image: instance.iconUrl ? getStaticImageUrl(instance.iconUrl) : '/favicon.ico', + qrOptions: { + typeNumber: 0, + mode: 'Byte', + errorCorrectionLevel: 'H', + }, + imageOptions: { + hideBackgroundDots: true, + imageSize: 0.3, + margin: 16, + crossOrigin: 'anonymous', + }, + dotsOptions: { + type: 'dots', + color: tinycolor(`hsl(${qrHsl.value.h}, 100, 18)`).toRgbString(), + }, + cornersDotOptions: { + type: 'dot', + }, + cornersSquareOptions: { + type: 'extra-rounded', + }, + backgroundOptions: { + color: tinycolor(`hsl(${qrHsl.value.h}, 100, 97)`).toRgbString(), + }, +}); + +onMounted(() => { + if (qrCodeEl.value != null) { + qrCodeInstance.append(qrCodeEl.value); + } +}); + +//#region flip +const THRESHOLD = -3; +// @ts-expect-error TS(2339) +const deviceMotionPermissionNeeded = window.DeviceMotionEvent && typeof window.DeviceMotionEvent.requestPermission === 'function'; +const flipEls: Set<Element> = new Set(); +const flip = ref(false); + +function handleOrientationChange(event: DeviceOrientationEvent) { + const isUpsideDown = event.beta ? event.beta < THRESHOLD : false; + flip.value = isUpsideDown; +} + +watch(flip, (newState) => { + flipEls.forEach(el => { + el.classList.toggle('_qrShowFlipFliped', newState); + }); +}); + +function requestDeviceMotion() { + if (!deviceMotionPermissionNeeded) return; + // @ts-expect-error TS(2339) + window.DeviceMotionEvent.requestPermission() + .then((response: string) => { + if (response === 'granted') { + window.addEventListener('deviceorientation', handleOrientationChange); + } + }) + .catch(console.error); +} + +onMounted(() => { + window.addEventListener('deviceorientation', handleOrientationChange); +}); + +onUnmounted(() => { + window.removeEventListener('deviceorientation', handleOrientationChange); +}); + +const vFlip = { + mounted(el: Element) { + flipEls.add(el); + el.classList.add('_qrShowFlip'); + }, + unmounted(el: Element) { + el.classList.remove('_qrShowFlip'); + flipEls.delete(el); + }, +} as Directive; +//#endregion +</script> + +<style lang="scss" module> +$s1: 14px; +$s2: 21px; +$s3: 28px; +$avatarSize: 58px; + +.root { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; +} + +.qr { + position: relative; + margin: 0 auto; + width: 100%; + max-width: 230px; + border-radius: 12px; + overflow: clip; + aspect-ratio: 1; + + > svg, + > canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } +} + +.user { + display: flex; + flex-direction: column; + margin: $s3 auto; + justify-content: center; + align-items: center; + text-align: center; + overflow: visible; + width: fit-content; + max-width: 100%; +} + +.avatar { + width: $avatarSize; + height: $avatarSize; + margin-bottom: 16px; +} + +.name { + font-weight: bold; + font-size: 110%; +} + +.logo { + width: 100px; + margin: $s3 auto 0; + filter: drop-shadow(0 0 6px #0007); +} +</style> + +<style lang="scss"> +/* + * useCssModuleで$styleを読み込みたかったが、rollupでのunwindが壊れてしまうらしく失敗。 + * グローバルにクラスを定義することでお茶を濁す。 + */ +._qrShowFlip { + transition: rotate .3s linear, scale .3s .15s step-start; +} + +._qrShowFlipFliped { + scale: -1 1; + rotate: x 180deg; +} +</style> diff --git a/packages/frontend/src/pages/qr.vue b/packages/frontend/src/pages/qr.vue new file mode 100644 index 0000000000..2e5629f232 --- /dev/null +++ b/packages/frontend/src/pages/qr.vue @@ -0,0 +1,57 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.root" class="_pageScrollable"> + <div class="_spacer" :class="$style.main"> + <MkButton v-if="read" :class="$style.button" rounded @click="read = false"><i class="ti ti-qrcode"></i> {{ i18n.ts._qr.showTabTitle }}</MkButton> + <MkButton v-else :class="$style.button" rounded @click="read = true"><i class="ti ti-scan"></i> {{ i18n.ts._qr.readTabTitle }}</MkButton> + + <MkQrRead v-if="read"/> + <MkQrShow v-else/> + </div> + <MkPolkadots v-if="!read" accented revered :height="200" style="position: sticky; bottom: 0; margin-top: -200px;"/> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, ref, shallowRef } from 'vue'; +import MkQrShow from './qr.show.vue'; +import { definePage } from '@/page.js'; +import { i18n } from '@/i18n.js'; +import { ensureSignin } from '@/i'; +import MkButton from '@/components/MkButton.vue'; +import MkPolkadots from '@/components/MkPolkadots.vue'; + +// router definitionでloginRequiredが設定されているためエラーハンドリングしない +const $i = ensureSignin(); + +const read = ref(false); + +const MkQrRead = defineAsyncComponent(() => import('./qr.read.vue')); + +definePage(() => ({ + title: i18n.ts.qr, + icon: 'ti ti-qrcode', +})); +</script> + +<style lang="scss" module> +.root { + height: 100%; +} + +.main { + min-height: 100%; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +} + +.button { + margin: 0 auto 16px auto; +} +</style> diff --git a/packages/frontend/src/pages/registry.keys.vue b/packages/frontend/src/pages/registry.keys.vue index 8eb2ab9fd0..a352fe4c00 100644 --- a/packages/frontend/src/pages/registry.keys.vue +++ b/packages/frontend/src/pages/registry.keys.vue @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton> <FormSection v-if="keys"> - <template #label>{{ i18n.ts.keys }}</template> + <template #label>{{ i18n.ts._registry.keys }}</template> <div class="_gaps_s"> <FormLink v-for="key in keys" :to="`/registry/value/${props.domain}/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink> </div> diff --git a/packages/frontend/src/pages/reversi/game.board.vue b/packages/frontend/src/pages/reversi/game.board.vue index 69429728d0..aae638641a 100644 --- a/packages/frontend/src/pages/reversi/game.board.vue +++ b/packages/frontend/src/pages/reversi/game.board.vue @@ -164,7 +164,7 @@ const $i = ensureSignin(); const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; - connection?: Misskey.ChannelConnection<Misskey.Channels['reversiGame']> | null; + connection?: Misskey.IChannelConnection<Misskey.Channels['reversiGame']> | null; }>(); const showBoardLabels = ref<boolean>(false); diff --git a/packages/frontend/src/pages/reversi/game.setting.vue b/packages/frontend/src/pages/reversi/game.setting.vue index 8392384963..1e01496bbb 100644 --- a/packages/frontend/src/pages/reversi/game.setting.vue +++ b/packages/frontend/src/pages/reversi/game.setting.vue @@ -132,7 +132,7 @@ const mapCategories = Array.from(new Set(Object.values(Reversi.maps).map(x => x. const props = defineProps<{ game: Misskey.entities.ReversiGameDetailed; - connection: Misskey.ChannelConnection<Misskey.Channels['reversiGame']>; + connection: Misskey.IChannelConnection<Misskey.Channels['reversiGame']>; }>(); const shareWhenStart = defineModel<boolean>('shareWhenStart', { default: false }); diff --git a/packages/frontend/src/pages/reversi/game.vue b/packages/frontend/src/pages/reversi/game.vue index a447572cc0..b1ba4da247 100644 --- a/packages/frontend/src/pages/reversi/game.vue +++ b/packages/frontend/src/pages/reversi/game.vue @@ -33,7 +33,7 @@ const props = defineProps<{ }>(); const game = shallowRef<Misskey.entities.ReversiGameDetailed | null>(null); -const connection = shallowRef<Misskey.ChannelConnection | null>(null); +const connection = shallowRef<Misskey.IChannelConnection<Misskey.Channels['reversiGame']> | null>(null); const shareWhenStart = ref(false); watch(() => props.gameId, () => { diff --git a/packages/frontend/src/pages/settings/2fa.vue b/packages/frontend/src/pages/settings/2fa.vue index ca404b43c4..2cc13744b1 100644 --- a/packages/frontend/src/pages/settings/2fa.vue +++ b/packages/frontend/src/pages/settings/2fa.vue @@ -196,6 +196,7 @@ async function addSecurityKey() { if (auth.canceled) return; const registrationOptions = parseCreationOptionsFromJSON({ + // @ts-expect-error misskey-js側に型がない publicKey: await os.apiWithDialog('i/2fa/register-key', { password: auth.result.password, token: auth.result.token, @@ -226,6 +227,7 @@ async function addSecurityKey() { password: auth.result.password, token: auth.result.token, name: name.result, + // @ts-expect-error misskey-js側に型がない credential: credential.toJSON(), }); } diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue index 63b3c95233..57192c0fb7 100644 --- a/packages/frontend/src/pages/settings/drive-cleaner.vue +++ b/packages/frontend/src/pages/settings/drive-cleaner.vue @@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps"> - <MkSelect v-model="sortModeSelect"> + <MkSelect v-model="sortModeSelect" :items="sortModeSelectDef"> <template #label>{{ i18n.ts.sort }}</template> - <option v-for="x in sortOptions" :key="x.value" :value="x.value">{{ x.displayName }}</option> </MkSelect> <div v-if="!fetching"> <MkPagination v-slot="{items}" :paginator="paginator"> @@ -60,6 +59,7 @@ import { i18n } from '@/i18n.js'; import bytes from '@/filters/bytes.js'; import { definePage } from '@/page.js'; import MkSelect from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; @@ -69,15 +69,19 @@ const paginator = markRaw(new Paginator('drive/files', { computedParams: computed(() => ({ sort: sortMode.value })), })); -const sortOptions = [ - { value: 'sizeDesc', displayName: i18n.ts._drivecleaner.orderBySizeDesc }, - { value: 'createdAtAsc', displayName: i18n.ts._drivecleaner.orderByCreatedAtAsc }, -]; - const capacity = ref<number>(0); const usage = ref<number>(0); const fetching = ref(true); -const sortModeSelect = ref('sizeDesc'); +const { + model: sortModeSelect, + def: sortModeSelectDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._drivecleaner.orderBySizeDesc, value: 'sizeDesc' }, + { label: i18n.ts._drivecleaner.orderByCreatedAtAsc, value: 'createdAtAsc' }, + ], + initialValue: 'sizeDesc', +}); fetchDriveInfo(); diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index cfa4df18fc..f58ff4c78c 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only <FormLink @click="chooseUploadFolder()"> <SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel> <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> - <template #suffixIcon><i class="ti ti-folder"></i></template> + <template #icon><i class="ti ti-folder"></i></template> </FormLink> </SearchMarker> @@ -129,13 +129,37 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSelect v-model="defaultImageCompressionLevel" :items="[ { label: i18n.ts.none, value: 0 }, - { label: i18n.ts.low, value: 1 }, - { label: i18n.ts.medium, value: 2 }, - { label: i18n.ts.high, value: 3 }, + { label: `${i18n.ts.low} (${i18n.ts._compression._quality.high}; ${i18n.ts._compression._size.large})`, value: 1 }, + { label: `${i18n.ts.medium} (${i18n.ts._compression._quality.medium}; ${i18n.ts._compression._size.medium})`, value: 2 }, + { label: `${i18n.ts.high} (${i18n.ts._compression._quality.low}; ${i18n.ts._compression._size.small})`, value: 3 }, ]" > - <template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template> - <template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template> + <template #label><SearchLabel>{{ i18n.ts.defaultCompressionLevel }}</SearchLabel></template> + <template #caption><div v-html="i18n.ts.defaultCompressionLevel_description"></div></template> + </MkSelect> + </MkPreferenceContainer> + </SearchMarker> + </div> + </FormSection> + </SearchMarker> + + <SearchMarker :keywords="['video']"> + <FormSection> + <template #label><SearchLabel>{{ i18n.ts.video }}</SearchLabel></template> + + <div class="_gaps_m"> + <SearchMarker :keywords="['default', 'video', 'compression']"> + <MkPreferenceContainer k="defaultVideoCompressionLevel"> + <MkSelect + v-model="defaultVideoCompressionLevel" :items="[ + { label: i18n.ts.none, value: 0 }, + { label: `${i18n.ts.low} (${i18n.ts._compression._quality.high}; ${i18n.ts._compression._size.large})`, value: 1 }, + { label: `${i18n.ts.medium} (${i18n.ts._compression._quality.medium}; ${i18n.ts._compression._size.medium})`, value: 2 }, + { label: `${i18n.ts.high} (${i18n.ts._compression._quality.low}; ${i18n.ts._compression._size.small})`, value: 3 }, + ]" + > + <template #label><SearchLabel>{{ i18n.ts.defaultCompressionLevel }}</SearchLabel></template> + <template #caption><div v-html="i18n.ts.defaultCompressionLevel_description"></div></template> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -196,6 +220,7 @@ const meterStyle = computed(() => { const keepOriginalFilename = prefer.model('keepOriginalFilename'); const defaultWatermarkPresetId = prefer.model('defaultWatermarkPresetId'); const defaultImageCompressionLevel = prefer.model('defaultImageCompressionLevel'); +const defaultVideoCompressionLevel = prefer.model('defaultVideoCompressionLevel'); const watermarkPresetsSyncEnabled = ref(prefer.isSyncEnabled('watermarkPresets')); diff --git a/packages/frontend/src/pages/settings/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue index 5ff5f45a2f..9c70461847 100644 --- a/packages/frontend/src/pages/settings/emoji-palette.vue +++ b/packages/frontend/src/pages/settings/emoji-palette.vue @@ -36,20 +36,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <SearchMarker :keywords="['main', 'palette']"> <MkPreferenceContainer k="emojiPaletteForMain"> - <MkSelect v-model="emojiPaletteForMain"> + <MkSelect v-model="emojiPaletteForMain" :items="emojiPaletteForMainDef"> <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForMain }}</SearchLabel></template> - <option key="-" :value="null">({{ i18n.ts.auto }})</option> - <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['reaction', 'palette']"> <MkPreferenceContainer k="emojiPaletteForReaction"> - <MkSelect v-model="emojiPaletteForReaction"> + <MkSelect v-model="emojiPaletteForReaction" :items="emojiPaletteForReactionDef"> <template #label><SearchLabel>{{ i18n.ts._emojiPalette.paletteForReaction }}</SearchLabel></template> - <option key="-" :value="null">({{ i18n.ts.auto }})</option> - <option v-for="palette in prefer.r.emojiPalettes.value" :key="palette.id" :value="palette.id">{{ palette.name === '' ? '(' + i18n.ts.noName + ')' : palette.name }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -68,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only <option :value="1">{{ i18n.ts.small }}</option> <option :value="2">{{ i18n.ts.medium }}</option> <option :value="3">{{ i18n.ts.large }}</option> + <option :value="4">{{ i18n.ts.large }}+</option> + <option :value="5">{{ i18n.ts.large }}++</option> </MkRadios> </MkPreferenceContainer> </SearchMarker> @@ -99,12 +97,15 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['emoji', 'picker', 'style']"> <MkPreferenceContainer k="emojiPickerStyle"> - <MkSelect v-model="emojiPickerStyle"> + <MkSelect + v-model="emojiPickerStyle" :items="[ + { label: i18n.ts.auto, value: 'auto' }, + { label: i18n.ts.popup, value: 'popup' }, + { label: i18n.ts.drawer, value: 'drawer' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template> <template #caption>{{ i18n.ts.needReloadToApply }}</template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -119,8 +120,9 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { computed, ref, watch } from 'vue'; -import { genId } from '@/utility/id.js'; import XPalette from './emoji-palette.palette.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import { genId } from '@/utility/id.js'; import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; @@ -135,7 +137,21 @@ import MkSwitch from '@/components/MkSwitch.vue'; import { emojiPicker } from '@/utility/emoji-picker.js'; const emojiPaletteForReaction = prefer.model('emojiPaletteForReaction'); +const emojiPaletteForReactionDef = computed<MkSelectItem[]>(() => [ + { label: `(${i18n.ts.auto})`, value: null }, + ...prefer.s.emojiPalettes.map(palette => ({ + label: palette.name === '' ? `(${i18n.ts.noName})` : palette.name, + value: palette.id, + })), +]); const emojiPaletteForMain = prefer.model('emojiPaletteForMain'); +const emojiPaletteForMainDef = computed<MkSelectItem[]>(() => [ + { label: `(${i18n.ts.auto})`, value: null }, + ...prefer.s.emojiPalettes.map(palette => ({ + label: palette.name === '' ? `(${i18n.ts.noName})` : palette.name, + value: palette.id, + })), +]); const emojiPickerScale = prefer.model('emojiPickerScale'); const emojiPickerWidth = prefer.model('emojiPickerWidth'); const emojiPickerHeight = prefer.model('emojiPickerHeight'); diff --git a/packages/frontend/src/pages/settings/navbar.vue b/packages/frontend/src/pages/settings/navbar.vue index f7c634b42e..c8cbc0977f 100644 --- a/packages/frontend/src/pages/settings/navbar.vue +++ b/packages/frontend/src/pages/settings/navbar.vue @@ -86,9 +86,9 @@ async function addItem() { const { canceled, result: item } = await os.select({ title: i18n.ts.addItem, items: [...menu.map(k => ({ - value: k, text: navbarItemDef[k].title, + value: k, label: navbarItemDef[k].title, })), { - value: '-', text: i18n.ts.divider, + value: '-', label: i18n.ts.divider, }], }); if (canceled || item == null) return; diff --git a/packages/frontend/src/pages/settings/notifications.notification-config.vue b/packages/frontend/src/pages/settings/notifications.notification-config.vue index 0ea415f673..78c3312c27 100644 --- a/packages/frontend/src/pages/settings/notifications.notification-config.vue +++ b/packages/frontend/src/pages/settings/notifications.notification-config.vue @@ -5,13 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="type"> - <option v-for="type in props.configurableTypes ?? notificationConfigTypes" :key="type" :value="type">{{ notificationConfigTypesI18nMap[type] }}</option> + <MkSelect v-model="type" :items="typeDef"> </MkSelect> - <MkSelect v-if="type === 'list'" v-model="userListId"> + <MkSelect v-if="type === 'list'" v-model="userListId" :items="userListIdDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in props.userLists" :key="list.id" :value="list.id">{{ list.name }}</option> </MkSelect> <div class="_buttons"> @@ -41,9 +39,10 @@ export type NotificationConfig = { <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { ref } from 'vue'; +import { ref, computed } from 'vue'; import MkSelect from '@/components/MkSelect.vue'; import MkButton from '@/components/MkButton.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -66,8 +65,26 @@ const notificationConfigTypesI18nMap: Record<typeof notificationConfigTypes[numb never: i18n.ts.none, }; -const type = ref(props.value.type); -const userListId = ref(props.value.type === 'list' ? props.value.userListId : null); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: computed(() => (props.configurableTypes ?? notificationConfigTypes).map((t: NotificationConfig['type']) => ({ + label: notificationConfigTypesI18nMap[t], + value: t, + }))), + initialValue: props.value.type, +}); +const { + model: userListId, + def: userListIdDef, +} = useMkSelect({ + items: computed(() => props.userLists.map(list => ({ + label: list.name, + value: list.id, + }))), + initialValue: props.value.type === 'list' ? props.value.userListId : null, +}); function save() { emit('update', type.value === 'list' ? { type: type.value, userListId: userListId.value! } : { type: type.value }); diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 64d61c0bee..2802d3263e 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -85,7 +85,7 @@ const $i = ensureSignin(); const nonConfigurableNotificationTypes = ['note', 'roleAssigned', 'followRequestAccepted', 'test', 'exportCompleted'] satisfies (typeof notificationTypes[number])[] as string[]; -const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken'] satisfies (typeof notificationTypes[number])[] as string[]; +const onlyOnOrOffNotificationTypes = ['app', 'achievementEarned', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'] satisfies (typeof notificationTypes[number])[] as string[]; const allowButton = useTemplateRef('allowButton'); const pushRegistrationInServer = computed(() => allowButton.value?.pushRegistrationInServer); diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 41b799bead..c4c76884e4 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -102,6 +102,9 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="enableHapticFeedback"> <template #label>Enable haptic feedback</template> </MkSwitch> + <MkSwitch v-model="enableWebTranslatorApi"> + <template #label>Enable in-browser translator API</template> + </MkSwitch> </div> </MkFolder> </SearchMarker> @@ -182,6 +185,7 @@ const devMode = prefer.model('devMode'); const stackingRouterView = prefer.model('experimental.stackingRouterView'); const enableFolderPageView = prefer.model('experimental.enableFolderPageView'); const enableHapticFeedback = prefer.model('experimental.enableHapticFeedback'); +const enableWebTranslatorApi = prefer.model('experimental.enableWebTranslatorApi'); watch(skipNoteRender, () => { suggestReload(); diff --git a/packages/frontend/src/pages/settings/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index ba35dd7f43..c622647b4f 100644 --- a/packages/frontend/src/pages/settings/preferences.vue +++ b/packages/frontend/src/pages/settings/preferences.vue @@ -18,9 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <SearchMarker :keywords="['language']"> - <MkSelect v-model="lang"> + <MkSelect v-model="lang" :items="langs.map(x => ({ label: x[1], value: x[0] }))"> <template #label><SearchLabel>{{ i18n.ts.uiLanguage }}</SearchLabel></template> - <option v-for="x in langs" :key="x[0]" :value="x[0]">{{ x[1] }}</option> <template #caption> <I18n :src="i18n.ts.i18nInfo" tag="span"> <template #link> @@ -272,22 +271,31 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation']"> <MkPreferenceContainer k="instanceTicker"> - <MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker"> + <MkSelect + v-if="instance.federation !== 'none'" + v-model="instanceTicker" + :items="[ + { label: i18n.ts._instanceTicker.none, value: 'none' }, + { label: i18n.ts._instanceTicker.remote, value: 'remote' }, + { label: i18n.ts._instanceTicker.always, value: 'always' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.instanceTicker }}</SearchLabel></template> - <option value="none">{{ i18n.ts._instanceTicker.none }}</option> - <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> - <option value="always">{{ i18n.ts._instanceTicker.always }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility']"> <MkPreferenceContainer k="nsfw"> - <MkSelect v-model="nsfw"> + <MkSelect + v-model="nsfw" + :items="[ + { label: i18n.ts._displayOfSensitiveMedia.respect, value: 'respect' }, + { label: i18n.ts._displayOfSensitiveMedia.ignore, value: 'ignore' }, + { label: i18n.ts._displayOfSensitiveMedia.force, value: 'force' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.displayOfSensitiveMedia }}</SearchLabel></template> - <option value="respect">{{ i18n.ts._displayOfSensitiveMedia.respect }}</option> - <option value="ignore">{{ i18n.ts._displayOfSensitiveMedia.ignore }}</option> - <option value="force">{{ i18n.ts._displayOfSensitiveMedia.force }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -339,11 +347,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_m"> <MkPreferenceContainer k="defaultNoteVisibility"> - <MkSelect v-model="defaultNoteVisibility"> - <option value="public">{{ i18n.ts._visibility.public }}</option> - <option value="home">{{ i18n.ts._visibility.home }}</option> - <option value="followers">{{ i18n.ts._visibility.followers }}</option> - <option value="specified">{{ i18n.ts._visibility.specified }}</option> + <MkSelect + v-model="defaultNoteVisibility" + :items="[ + { label: i18n.ts._visibility.public, value: 'public' }, + { label: i18n.ts._visibility.home, value: 'home' }, + { label: i18n.ts._visibility.followers, value: 'followers' }, + { label: i18n.ts._visibility.specified, value: 'specified' }, + ]" + > </MkSelect> </MkPreferenceContainer> @@ -402,7 +414,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="$i.policies.chatAvailability !== 'unavailable'"> <SearchMarker v-slot="slotProps" :keywords="['chat', 'messaging']"> <MkFolder :defaultOpen="slotProps.isParentOfTarget"> - <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + <template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template> <template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></template> <div class="_gaps_s"> @@ -528,22 +540,30 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['menu', 'style', 'popup', 'drawer']"> <MkPreferenceContainer k="menuStyle"> - <MkSelect v-model="menuStyle"> + <MkSelect + v-model="menuStyle" + :items="[ + { label: i18n.ts.auto, value: 'auto' }, + { label: i18n.ts.popup, value: 'popup' }, + { label: i18n.ts.drawer, value: 'drawer' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.menuStyle }}</SearchLabel></template> - <option value="auto">{{ i18n.ts.auto }}</option> - <option value="popup">{{ i18n.ts.popup }}</option> - <option value="drawer">{{ i18n.ts.drawer }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> <SearchMarker :keywords="['contextmenu', 'system', 'native']"> <MkPreferenceContainer k="contextMenu"> - <MkSelect v-model="contextMenu"> + <MkSelect + v-model="contextMenu" + :items="[ + { label: i18n.ts._contextMenu.app, value: 'app' }, + { label: i18n.ts._contextMenu.appWithShift, value: 'appWithShift' }, + { label: i18n.ts._contextMenu.native, value: 'native' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts._contextMenu.title }}</SearchLabel></template> - <option value="app">{{ i18n.ts._contextMenu.app }}</option> - <option value="appWithShift">{{ i18n.ts._contextMenu.appWithShift }}</option> - <option value="native">{{ i18n.ts._contextMenu.native }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -719,11 +739,15 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['server', 'disconnect', 'reconnect', 'reload', 'streaming']"> <MkPreferenceContainer k="serverDisconnectedBehavior"> - <MkSelect v-model="serverDisconnectedBehavior"> + <MkSelect + v-model="serverDisconnectedBehavior" + :items="[ + { label: i18n.ts._serverDisconnectedBehavior.reload, value: 'reload' }, + { label: i18n.ts._serverDisconnectedBehavior.dialog, value: 'dialog' }, + { label: i18n.ts._serverDisconnectedBehavior.quiet, value: 'quiet' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.whenServerDisconnected }}</SearchLabel></template> - <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> - <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> - <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> </MkSelect> </MkPreferenceContainer> </SearchMarker> @@ -924,6 +948,7 @@ watch([ chatShowSenderName, useStickyIcons, enableHighQualityImagePlaceholders, + disableShowingAnimatedImages, keepScreenOn, contextMenu, fontSize, @@ -934,6 +959,8 @@ watch([ enablePullToRefresh, reduceAnimation, showAvailableReactionsFirstInNote, + animatedMfm, + advancedMfm, ], () => { suggestReload(); }); @@ -984,16 +1011,15 @@ function removeEmojiIndex(lang: string) { async function setPinnedList() { const lists = await misskeyApi('users/lists/list'); - const { canceled, result: list } = await os.select({ + const { canceled, result: listId } = await os.select({ title: i18n.ts.selectList, items: lists.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), }); - if (canceled) return; - if (list == null) return; + if (canceled || listId == null) return; - prefer.commit('pinnedUserLists', [list]); + prefer.commit('pinnedUserLists', [lists.find((x) => x.id === listId)!]); } function removePinnedList() { diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue index 54a6c0af82..c2e0b3fe41 100644 --- a/packages/frontend/src/pages/settings/privacy.vue +++ b/packages/frontend/src/pages/settings/privacy.vue @@ -33,20 +33,14 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['following', 'visibility']"> - <MkSelect v-model="followingVisibility" @update:modelValue="save()"> + <MkSelect v-model="followingVisibility" :items="followingVisibilityDef" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.followingVisibility }}</SearchLabel></template> - <option value="public">{{ i18n.ts._ffVisibility.public }}</option> - <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> - <option value="private">{{ i18n.ts._ffVisibility.private }}</option> </MkSelect> </SearchMarker> <SearchMarker :keywords="['follower', 'visibility']"> - <MkSelect v-model="followersVisibility" @update:modelValue="save()"> + <MkSelect v-model="followersVisibility" :items="followersVisibilityDef" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts.followersVisibility }}</SearchLabel></template> - <option value="public">{{ i18n.ts._ffVisibility.public }}</option> - <option value="followers">{{ i18n.ts._ffVisibility.followers }}</option> - <option value="private">{{ i18n.ts._ffVisibility.private }}</option> </MkSelect> </SearchMarker> @@ -80,18 +74,13 @@ SPDX-License-Identifier: AGPL-3.0-only <SearchMarker :keywords="['chat']"> <FormSection> - <template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template> + <template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template> <div class="_gaps_m"> <MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo> <SearchMarker :keywords="['chat']"> - <MkSelect v-model="chatScope" @update:modelValue="save()"> + <MkSelect v-model="chatScope" :items="chatScopeDef" @update:modelValue="save()"> <template #label><SearchLabel>{{ i18n.ts._chat.chatAllowedUsers }}</SearchLabel></template> - <option value="everyone">{{ i18n.ts._chat._chatAllowedUsers.everyone }}</option> - <option value="followers">{{ i18n.ts._chat._chatAllowedUsers.followers }}</option> - <option value="following">{{ i18n.ts._chat._chatAllowedUsers.following }}</option> - <option value="mutual">{{ i18n.ts._chat._chatAllowedUsers.mutual }}</option> - <option value="none">{{ i18n.ts._chat._chatAllowedUsers.none }}</option> <template #caption>{{ i18n.ts._chat.chatAllowedUsers_note }}</template> </MkSelect> </SearchMarker> @@ -119,15 +108,24 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><SearchLabel>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBefore }}</SearchLabel></template> <div class="_gaps_s"> - <MkSelect :modelValue="makeNotesFollowersOnlyBefore_type" @update:modelValue="makeNotesFollowersOnlyBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null"> - <option :value="null">{{ i18n.ts.none }}</option> - <option value="relative">{{ i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod }}</option> - <option value="absolute">{{ i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime }}</option> + <MkSelect + v-model="makeNotesFollowersOnlyBefore_type" + :items="[ + { label: i18n.ts.none, value: null }, + { label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod, value: 'relative' }, + { label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime, value: 'absolute' }, + ]" + > </MkSelect> - <MkSelect v-if="makeNotesFollowersOnlyBefore_type === 'relative'" v-model="makeNotesFollowersOnlyBefore_selection"> - <option v-for="preset in makeNotesFollowersOnlyBefore_presets" :value="preset.value">{{ preset.label }}</option> - <option value="custom">{{ i18n.ts.custom }}</option> + <MkSelect + v-if="makeNotesFollowersOnlyBefore_type === 'relative'" + v-model="makeNotesFollowersOnlyBefore_selection" + :items="[ + ...makeNotesFollowersOnlyBefore_presets, + { label: i18n.ts.custom, value: 'custom' }, + ]" + > </MkSelect> <MkInput @@ -140,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput - v-if="makeNotesFollowersOnlyBefore_type === 'absolute'" + v-if="makeNotesFollowersOnlyBefore_type === 'absolute' && makeNotesFollowersOnlyBefore != null" :modelValue="formatDateTimeString(new Date(makeNotesFollowersOnlyBefore * 1000), 'yyyy-MM-dd')" type="date" :manualSave="true" @@ -161,22 +159,23 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <MkSelect - :items="[{ - value: null, - label: i18n.ts.none - }, { - value: 'relative', - label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod - }, { - value: 'absolute', - label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime - }] as const" :modelValue="makeNotesHiddenBefore_type" @update:modelValue="makeNotesHiddenBefore = $event === 'relative' ? -604800 : $event === 'absolute' ? Math.floor(Date.now() / 1000) : null" + v-model="makeNotesHiddenBefore_type" + :items="[ + { label: i18n.ts.none, value: null }, + { label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod, value: 'relative' }, + { label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime, value: 'absolute' }, + ]" > </MkSelect> - <MkSelect v-if="makeNotesHiddenBefore_type === 'relative'" v-model="makeNotesHiddenBefore_selection"> - <option v-for="preset in makeNotesHiddenBefore_presets" :value="preset.value">{{ preset.label }}</option> - <option value="custom">{{ i18n.ts.custom }}</option> + <MkSelect + v-if="makeNotesHiddenBefore_type === 'relative'" + v-model="makeNotesHiddenBefore_selection" + :items="[ + ...makeNotesHiddenBefore_presets, + { label: i18n.ts.custom, value: 'custom' }, + ]" + > </MkSelect> <MkInput @@ -189,7 +188,7 @@ SPDX-License-Identifier: AGPL-3.0-only </MkInput> <MkInput - v-if="makeNotesHiddenBefore_type === 'absolute'" + v-if="makeNotesHiddenBefore_type === 'absolute' && makeNotesHiddenBefore != null" :modelValue="formatDateTimeString(new Date(makeNotesHiddenBefore * 1000), 'yyyy-MM-dd')" type="date" :manualSave="true" @@ -216,8 +215,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed, watch } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; -import MkFolder from '@/components/MkFolder.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; @@ -225,6 +224,7 @@ import { ensureSignin } from '@/i.js'; import { definePage } from '@/page.js'; import FormSlot from '@/components/form/slot.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import MkInput from '@/components/MkInput.vue'; import * as os from '@/os.js'; import MkDisableSection from '@/components/MkDisableSection.vue'; @@ -243,18 +243,61 @@ const makeNotesFollowersOnlyBefore = ref($i.makeNotesFollowersOnlyBefore ?? null const makeNotesHiddenBefore = ref($i.makeNotesHiddenBefore ?? null); const hideOnlineStatus = ref($i.hideOnlineStatus); const publicReactions = ref($i.publicReactions); -const followingVisibility = ref($i.followingVisibility); -const followersVisibility = ref($i.followersVisibility); -const chatScope = ref($i.chatScope); +const { + model: followingVisibility, + def: followingVisibilityDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.public, value: 'public' }, + { label: i18n.ts.followers, value: 'followers' }, + { label: i18n.ts.private, value: 'private' }, + ], + initialValue: $i.followingVisibility, +}); +const { + model: followersVisibility, + def: followersVisibilityDef, +} = useMkSelect({ + items: [ + { label: i18n.ts.public, value: 'public' }, + { label: i18n.ts.followers, value: 'followers' }, + { label: i18n.ts.private, value: 'private' }, + ], + initialValue: $i.followersVisibility, +}); +const { + model: chatScope, + def: chatScopeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._chat._chatAllowedUsers.everyone, value: 'everyone' }, + { label: i18n.ts._chat._chatAllowedUsers.followers, value: 'followers' }, + { label: i18n.ts._chat._chatAllowedUsers.following, value: 'following' }, + { label: i18n.ts._chat._chatAllowedUsers.mutual, value: 'mutual' }, + { label: i18n.ts._chat._chatAllowedUsers.none, value: 'none' }, + ], + initialValue: $i.chatScope, +}); -const makeNotesFollowersOnlyBefore_type = computed(() => { - if (makeNotesFollowersOnlyBefore.value == null) { - return null; - } else if (makeNotesFollowersOnlyBefore.value >= 0) { - return 'absolute'; - } else { - return 'relative'; - } +const makeNotesFollowersOnlyBefore_type = computed({ + get: () => { + if (makeNotesFollowersOnlyBefore.value == null) { + return null; + } else if (makeNotesFollowersOnlyBefore.value >= 0) { + return 'absolute'; + } else { + return 'relative'; + } + }, + set(value) { + if (value === 'relative') { + makeNotesFollowersOnlyBefore.value = -604800; + } else if (value === 'absolute') { + makeNotesFollowersOnlyBefore.value = Math.floor(Date.now() / 1000); + } else { + makeNotesFollowersOnlyBefore.value = null; + } + }, }); const makeNotesFollowersOnlyBefore_presets = [ @@ -265,7 +308,7 @@ const makeNotesFollowersOnlyBefore_presets = [ { label: i18n.ts.oneMonth, value: -2592000 }, { label: i18n.ts.threeMonths, value: -7776000 }, { label: i18n.ts.oneYear, value: -31104000 }, -]; +] satisfies MkSelectItem[]; const makeNotesFollowersOnlyBefore_isCustomMode = ref( makeNotesFollowersOnlyBefore.value != null && @@ -288,14 +331,25 @@ const makeNotesFollowersOnlyBefore_customMonths = computed({ }, }); -const makeNotesHiddenBefore_type = computed(() => { - if (makeNotesHiddenBefore.value == null) { - return null; - } else if (makeNotesHiddenBefore.value >= 0) { - return 'absolute'; - } else { - return 'relative'; - } +const makeNotesHiddenBefore_type = computed({ + get: () => { + if (makeNotesHiddenBefore.value == null) { + return null; + } else if (makeNotesHiddenBefore.value >= 0) { + return 'absolute'; + } else { + return 'relative'; + } + }, + set(value) { + if (value === 'relative') { + makeNotesHiddenBefore.value = -604800; + } else if (value === 'absolute') { + makeNotesHiddenBefore.value = Math.floor(Date.now() / 1000); + } else { + makeNotesHiddenBefore.value = null; + } + }, }); const makeNotesHiddenBefore_presets = [ @@ -306,7 +360,7 @@ const makeNotesHiddenBefore_presets = [ { label: i18n.ts.oneMonth, value: -2592000 }, { label: i18n.ts.threeMonths, value: -7776000 }, { label: i18n.ts.oneYear, value: -31104000 }, -]; +] satisfies MkSelectItem[]; const makeNotesHiddenBefore_isCustomMode = ref( makeNotesHiddenBefore.value != null && diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index 4816a6e33b..89325dee63 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -53,9 +53,8 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['language', 'locale']"> - <MkSelect v-model="profile.lang"> + <MkSelect v-model="profile.lang" :items="Object.entries(langmap).map(([code, def]) => ({ label: def.nativeName, value: code }))"> <template #label><SearchLabel>{{ i18n.ts.language }}</SearchLabel></template> - <option v-for="x in Object.keys(langmap)" :key="x" :value="x">{{ langmap[x].nativeName }}</option> </MkSelect> </SearchMarker> @@ -117,13 +116,17 @@ SPDX-License-Identifier: AGPL-3.0-only </SearchMarker> <SearchMarker :keywords="['reaction']"> - <MkSelect v-model="reactionAcceptance"> + <MkSelect + v-model="reactionAcceptance" + :items="[ + { label: i18n.ts.all, value: null }, + { label: i18n.ts.likeOnlyForRemote, value: 'likeOnlyForRemote' }, + { label: i18n.ts.nonSensitiveOnly, value: 'nonSensitiveOnly' }, + { label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote, value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' }, + { label: i18n.ts.likeOnly, value: 'likeOnly' }, + ]" + > <template #label><SearchLabel>{{ i18n.ts.reactionAcceptance }}</SearchLabel></template> - <option :value="null">{{ i18n.ts.all }}</option> - <option value="likeOnlyForRemote">{{ i18n.ts.likeOnlyForRemote }}</option> - <option value="nonSensitiveOnly">{{ i18n.ts.nonSensitiveOnly }}</option> - <option value="nonSensitiveOnlyForLocalLikeOnlyForRemote">{{ i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }}</option> - <option value="likeOnly">{{ i18n.ts.likeOnly }}</option> </MkSelect> </SearchMarker> @@ -148,6 +151,15 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> </SearchMarker> + + <hr> + + <SearchMarker :keywords="['qrcode']"> + <FormLink to="/qr"> + <template #icon><i class="ti ti-qrcode"></i></template> + <SearchLabel>{{ i18n.ts.qr }}</SearchLabel> + </FormLink> + </SearchMarker> </div> </SearchMarker> </template> @@ -161,6 +173,7 @@ import MkSelect from '@/components/MkSelect.vue'; import FormSplit from '@/components/form/split.vue'; import MkFolder from '@/components/MkFolder.vue'; import FormSlot from '@/components/form/slot.vue'; +import FormLink from '@/components/form/link.vue'; import { chooseDriveFile } from '@/utility/drive.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 7aad43b1d0..31fe9a64db 100644 --- a/packages/frontend/src/pages/settings/sounds.sound.vue +++ b/packages/frontend/src/pages/settings/sounds.sound.vue @@ -5,9 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="type"> + <MkSelect v-model="type" :items="typeDef"> <template #label>{{ i18n.ts.sound }}</template> - <option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option> </MkSelect> <div v-if="type === '_driveFile_' && driveFileError === true" :class="$style.fileSelectorRoot"> <MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton> @@ -38,28 +37,36 @@ import MkButton from '@/components/MkButton.vue'; import MkRange from '@/components/MkRange.vue'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { playMisskeySfxFile, soundsTypes, getSoundDuration } from '@/utility/sound.js'; import { selectFile } from '@/utility/drive.js'; +import type { SoundStore } from '@/preferences/def.js'; const props = defineProps<{ - type: SoundType; - fileId?: string; - fileUrl?: string; - volume: number; + def: SoundStore; }>(); const emit = defineEmits<{ (ev: 'update', result: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }): void; }>(); -const type = ref<SoundType>(props.type); -const fileId = ref(props.fileId); -const fileUrl = ref(props.fileUrl); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: soundsTypes.map((x) => ({ + label: getSoundTypeName(x), + value: x, + })), + initialValue: props.def.type, +}); +const fileId = ref('fileId' in props.def ? props.def.fileId : undefined); +const fileUrl = ref('fileUrl' in props.def ? props.def.fileUrl : undefined); const fileName = ref<string>(''); const driveFileError = ref(false); const hasChanged = ref(false); -const volume = ref(props.volume); +const volume = ref(props.def.volume); if (type.value === '_driveFile_' && fileId.value) { await misskeyApi('drive/files/show', { diff --git a/packages/frontend/src/pages/settings/sounds.vue b/packages/frontend/src/pages/settings/sounds.vue index ea5b347525..1b851825d6 100644 --- a/packages/frontend/src/pages/settings/sounds.vue +++ b/packages/frontend/src/pages/settings/sounds.vue @@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template> <Suspense> <template #default> - <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/> + <XSound :def="sounds[type]" @update="(res) => updated(type, res)"/> </template> <template #fallback> <MkLoading/> diff --git a/packages/frontend/src/pages/settings/statusbar.statusbar.vue b/packages/frontend/src/pages/settings/statusbar.statusbar.vue index 561d31148f..b69fd2596d 100644 --- a/packages/frontend/src/pages/settings/statusbar.statusbar.vue +++ b/packages/frontend/src/pages/settings/statusbar.statusbar.vue @@ -5,11 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="statusbar.type" placeholder="Please select"> + <MkSelect v-model="statusbar.type" :items="statusbarTypeDef"> <template #label>{{ i18n.ts.type }}</template> - <option value="rss">RSS</option> - <option v-if="instance.federation !== 'none'" value="federation">Federation</option> - <option value="userList">User list timeline</option> </MkSelect> <MkInput v-model="statusbar.name" manualSave> @@ -63,9 +60,8 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </template> <template v-else-if="statusbar.type === 'userList' && userLists != null"> - <MkSelect v-model="statusbar.props.userListId"> + <MkSelect v-model="statusbar.props.userListId" :items="userListsDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> </MkSelect> <MkInput v-model="statusbar.props.refreshIntervalSec" manualSave type="number"> <template #label>{{ i18n.ts.refreshInterval }}</template> @@ -86,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { reactive, watch } from 'vue'; +import { reactive, computed, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; @@ -98,13 +94,32 @@ import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; import { prefer } from '@/preferences.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { StatusbarStore } from '@/preferences/def.js'; const props = defineProps<{ _id: string; userLists: Misskey.entities.UserList[] | null; }>(); -const statusbar = reactive(deepClone(prefer.s.statusbars.find(x => x.id === props._id))!); +const statusbar = reactive<StatusbarStore>(deepClone(prefer.s.statusbars.find(x => x.id === props._id)!)); + +const statusbarTypeDef = computed(() => { + const items = [ + { label: 'RSS', value: 'rss' }, + ] satisfies MkSelectItem[]; + if (instance.federation !== 'none') { + items.push({ label: 'Federation', value: 'federation' }); + } + if (props.userLists != null) { + items.push({ label: i18n.ts.userList, value: 'userList' }); + } + return items; +}); + +const userListsDef = computed(() => { + return (props.userLists ?? []).map(x => ({ label: x.name, value: x.id })) satisfies MkSelectItem[]; +}); watch(() => statusbar.type, () => { if (statusbar.type === 'rss') { diff --git a/packages/frontend/src/pages/settings/theme.manage.vue b/packages/frontend/src/pages/settings/theme.manage.vue index e972184278..7bb877ec39 100644 --- a/packages/frontend/src/pages/settings/theme.manage.vue +++ b/packages/frontend/src/pages/settings/theme.manage.vue @@ -5,16 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div class="_gaps_m"> - <MkSelect v-model="selectedThemeId"> + <MkSelect v-model="selectedThemeId" :items="selectedThemeIdDef"> <template #label>{{ i18n.ts.theme }}</template> - <optgroup :label="i18n.ts._theme.installedThemes"> - <option v-for="x in installedThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="i18n.ts._theme.builtinThemes"> - <option v-for="x in builtinThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> </MkSelect> - <template v-if="selectedTheme"> + <template v-if="selectedTheme != null"> <MkInput readonly :modelValue="selectedTheme.author"> <template #label>{{ i18n.ts.author }}</template> </MkInput> @@ -43,10 +37,26 @@ import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; const installedThemes = getThemesRef(); const builtinThemes = getBuiltinThemesRef(); -const selectedThemeId = ref<string | null>(null); +const { + model: selectedThemeId, + def: selectedThemeIdDef, +} = useMkSelect({ + items: computed<MkSelectItem<string | null>[]>(() => [{ + type: 'group', + label: i18n.ts._theme.installedThemes, + items: installedThemes.value.map(x => ({ label: x.name, value: x.id })), + }, { + type: 'group', + label: i18n.ts._theme.builtinThemes, + items: builtinThemes.value.map(x => ({ label: x.name, value: x.id })), + }]), + initialValue: null, +}); const themes = computed(() => [...installedThemes.value, ...builtinThemes.value]); diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index beae1224e4..0129aebe94 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -5,7 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/theme" :label="i18n.ts.theme" :keywords="['theme']" icon="ti ti-palette"> - <div class="_gaps_m"> + <div + class="_gaps_m" + @dragover.prevent.stop="onDragover" + @drop.prevent.stop="onDrop" + > <div v-adaptive-border class="rfqxtzch _panel"> <div class="toggle"> <div class="toggleWrapper"> @@ -58,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="instanceLightTheme.id" /> - <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)"> + <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, instanceLightTheme)" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)"> <MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div> </label> @@ -78,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -98,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -129,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="instanceDarkTheme.id" /> - <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)"> + <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, instanceDarkTheme)" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)"> <MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div> </label> @@ -149,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -169,7 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.themeRadio" :value="theme.id" /> - <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)"> <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> <div :class="$style.themeItemCaption">{{ theme.name }}</div> </label> @@ -214,7 +218,7 @@ import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; import MkThemePreview from '@/components/MkThemePreview.vue'; import MkInfo from '@/components/MkInfo.vue'; -import { getBuiltinThemesRef, getThemesRef, removeTheme } from '@/theme.js'; +import { getBuiltinThemesRef, getThemesRef, installTheme, parseThemeCode, removeTheme } from '@/theme.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; import { store } from '@/store.js'; import { i18n } from '@/i18n.js'; @@ -223,6 +227,7 @@ import { uniqueBy } from '@/utility/array.js'; import { definePage } from '@/page.js'; import { prefer } from '@/preferences.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { checkDragDataType, getDragData, getPlainDragData, setDragData, setPlainDragData } from '@/drag-and-drop.js'; const installedThemes = getThemesRef(); const builtinThemes = getBuiltinThemesRef(); @@ -321,6 +326,38 @@ function onThemeContextmenu(theme: Theme, ev: MouseEvent) { }], ev); } +function onThemeDragstart(ev: DragEvent, theme: Theme) { + if (!ev.dataTransfer) return; + + ev.dataTransfer.effectAllowed = 'copy'; + setPlainDragData(ev, JSON5.stringify(theme, null, '\t')); +} + +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; + + if (ev.dataTransfer.types[0] === 'text/plain') { + ev.dataTransfer.dropEffect = 'copy'; + } else { + ev.dataTransfer.dropEffect = 'none'; + } + + return false; +} + +async function onDrop(ev: DragEvent) { + if (!ev.dataTransfer) return; + + const code = getPlainDragData(ev); + if (code != null) { + try { + await installTheme(code); + } catch (err) { + // nop + } + } +} + const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index 51ac9d66f0..368537ec91 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -112,8 +112,7 @@ async function init() { ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), ...(visibleAccts ? visibleAccts.split(',').map(Misskey.acct.parse) : []), ] - // TypeScriptの指示通りに変換する - .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + // @ts-expect-error payloadの引数側の型が正常に解決されない .map(q => misskeyApi('users/show', q) .then(user => { visibleUsers.value.push(user); diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 42455bd18e..7094aca7c0 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> <div :key="user.id" class="main _panel"> - <div class="banner-container" :style="style"> - <div ref="bannerEl" class="banner" :style="style"></div> + <div ref="bannerEl" class="banner-container"> + <div class="banner" :style="style"></div> <div class="fade"></div> <div class="title"> <MkUserName class="name" :user="user" :nowrap="true"/> @@ -159,9 +159,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch, ref } from 'vue'; +import { defineAsyncComponent, computed, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch, ref, useTemplateRef } from 'vue'; import * as Misskey from 'misskey-js'; -import { getScrollPosition } from '@@/js/scroll.js'; +import { getScrollContainer } from '@@/js/scroll.js'; import MkNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkAccountMoved from '@/components/MkAccountMoved.vue'; @@ -221,11 +221,10 @@ const emit = defineEmits<{ const router = useRouter(); const user = ref(props.user); -const parallaxAnimationId = ref<null | number>(null); const narrow = ref<null | boolean>(null); -const rootEl = ref<null | HTMLElement>(null); -const bannerEl = ref<null | HTMLElement>(null); -const memoTextareaEl = ref<null | HTMLElement>(null); +const rootEl = useTemplateRef('rootEl'); +const bannerEl = useTemplateRef('bannerEl'); +const memoTextareaEl = useTemplateRef('memoTextareaEl'); const memoDraft = ref(props.user.memo); const isEditingMemo = ref(false); const moderationNote = ref(props.user.moderationNote ?? ''); @@ -257,24 +256,6 @@ function menu(ev: MouseEvent) { os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } -function parallaxLoop() { - parallaxAnimationId.value = window.requestAnimationFrame(parallaxLoop); - parallax(); -} - -function parallax() { - const banner = bannerEl.value; - if (banner == null) return; - - const top = getScrollPosition(rootEl.value); - - if (top < 0) return; - - const z = 1.75; // 奥行き(小さいほど奥) - const pos = -(top / z); - banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; -} - function showMemoTextarea() { isEditingMemo.value = true; nextTick(() => { @@ -304,8 +285,38 @@ async function reload() { // TODO } +let bannerParallaxResizeObserver: ResizeObserver | null = null; + +function calcBannerParallax() { + if (!bannerEl.value || !CSS.supports('view-timeline-inset', 'auto 100px')) return; + const elRect = bannerEl.value.getBoundingClientRect(); + const scrollEl = getScrollContainer(bannerEl.value); + const scrollPosition = scrollEl?.scrollTop ?? window.scrollY; + const scrollContainerHeight = scrollEl?.clientHeight ?? window.innerHeight; + const scrollContainerTop = scrollEl?.getBoundingClientRect().top ?? 0; + const top = scrollPosition + elRect.top - scrollContainerTop; + const bottom = scrollContainerHeight - top; + bannerEl.value.style.setProperty('--bannerParallaxInset', `auto ${bottom}px`); +} + +function initCalcBannerParallax() { + const scrollEl = bannerEl.value ? getScrollContainer(bannerEl.value) : null; + if (scrollEl != null && CSS.supports('view-timeline-inset', 'auto 100px')) { + bannerParallaxResizeObserver = new ResizeObserver(() => { + calcBannerParallax(); + }); + bannerParallaxResizeObserver.observe(scrollEl); + } +} + +function disposeBannerParallaxResizeObserver() { + if (bannerParallaxResizeObserver) { + bannerParallaxResizeObserver.disconnect(); + bannerParallaxResizeObserver = null; + } +} + onMounted(() => { - window.requestAnimationFrame(parallaxLoop); narrow.value = rootEl.value!.clientWidth < 1000; if (props.user.birthday) { @@ -319,16 +330,24 @@ onMounted(() => { }); } } + nextTick(() => { + calcBannerParallax(); adjustMemoTextarea(); }); + + initCalcBannerParallax(); }); -onUnmounted(() => { - if (parallaxAnimationId.value) { - window.cancelAnimationFrame(parallaxAnimationId.value); +onActivated(() => { + if (bannerEl.value) { + calcBannerParallax(); + initCalcBannerParallax(); } }); + +onUnmounted(disposeBannerParallaxResizeObserver); +onDeactivated(disposeBannerParallaxResizeObserver); </script> <style lang="scss" scoped> @@ -353,14 +372,23 @@ onUnmounted(() => { overflow: clip; background-size: cover; background-position: center; + view-timeline-name: --bannerParallax; + view-timeline-inset: var(--bannerParallaxInset, auto); + view-timeline-axis: block; > .banner { - height: 100%; + position: absolute; + top: 50%; + left: 0; + width: 100%; + height: 300%; background-color: #4c5e6d; - background-size: cover; + background-repeat: repeat-y; background-position: center; - box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; - will-change: background-position; + will-change: transform; + animation: bannerParallaxKeyframes linear both; + animation-timeline: --bannerParallax; + animation-range: cover; } > .fade { @@ -716,6 +744,15 @@ onUnmounted(() => { } } } + +@keyframes bannerParallaxKeyframes { + from { + transform: translateY(-50%); + } + to { + transform: translateY(-30%); + } +} </style> <style lang="scss" module> diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 5e9e671252..6d74de14a0 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -6,11 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <MkStickyContainer> <template #header> - <MkTab v-model="tab" :class="$style.tab"> - <option value="featured">{{ i18n.ts.featured }}</option> - <option value="notes">{{ i18n.ts.notes }}</option> - <option value="all">{{ i18n.ts.all }}</option> - <option value="files">{{ i18n.ts.withFiles }}</option> + <MkTab + v-model="tab" + :tabs="[ + { key: 'featured', label: i18n.ts.featured }, + { key: 'notes', label: i18n.ts.notes }, + { key: 'all', label: i18n.ts.all }, + { key: 'files', label: i18n.ts.withFiles }, + ]" + :class="$style.tab" + > </MkTab> </template> <MkNotesTimeline v-if="tab === 'featured'" :noGap="true" :paginator="featuredPaginator" :pullToRefresh="false" :class="$style.tl"/> @@ -30,7 +35,7 @@ const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); -const tab = ref<string>('all'); +const tab = ref<'featured' | 'notes' | 'all' | 'files'>('all'); const featuredPaginator = markRaw(new Paginator('users/featured-notes', { limit: 10, diff --git a/packages/frontend/src/pages/user/lists.vue b/packages/frontend/src/pages/user/lists.vue index 6c9204ae22..8824acb33e 100644 --- a/packages/frontend/src/pages/user/lists.vue +++ b/packages/frontend/src/pages/user/lists.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkPagination v-slot="{items}" :paginator="paginator" withControl> <MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/list/${ list.id }`"> <div>{{ list.name }}</div> - <MkAvatars :userIds="list.userIds"/> + <MkAvatars v-if="list.userIds != null" :userIds="list.userIds"/> </MkA> </MkPagination> </div> diff --git a/packages/frontend/src/pages/user/notes.vue b/packages/frontend/src/pages/user/notes.vue index b5e600da92..1e6dba73bd 100644 --- a/packages/frontend/src/pages/user/notes.vue +++ b/packages/frontend/src/pages/user/notes.vue @@ -8,11 +8,16 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <MkStickyContainer> <template #header> - <MkTab v-model="tab" :class="$style.tab"> - <option value="featured">{{ i18n.ts.featured }}</option> - <option value="notes">{{ i18n.ts.notes }}</option> - <option value="all">{{ i18n.ts.all }}</option> - <option value="files">{{ i18n.ts.withFiles }}</option> + <MkTab + v-model="tab" + :tabs="[ + { key: 'featured', label: i18n.ts.featured }, + { key: 'notes', label: i18n.ts.notes }, + { key: 'all', label: i18n.ts.all }, + { key: 'files', label: i18n.ts.withFiles }, + ]" + :class="$style.tab" + > </MkTab> </template> <MkNotesTimeline v-if="tab === 'featured'" :noGap="true" :paginator="featuredPaginator" :class="$style.tl"/> @@ -34,7 +39,7 @@ const props = defineProps<{ user: Misskey.entities.UserDetailed; }>(); -const tab = ref<string>('all'); +const tab = ref<'featured' | 'notes' | 'all' | 'files'>('all'); const featuredPaginator = markRaw(new Paginator('users/featured-notes', { limit: 10, |