summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2025-10-08 13:18:08 +0000
committerGitHub <noreply@github.com>2025-10-08 13:18:08 +0000
commit56cc89b521e8ca0d302230d123c3924e4461556d (patch)
tree242411d50ffd1ed7096f95ecdafe91b482628a46 /packages/frontend/src/pages
parentMerge pull request #16521 from misskey-dev/develop (diff)
parentRelease: 2025.10.0 (diff)
downloadmisskey-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')
-rw-r--r--packages/frontend/src/pages/about.emojis.vue42
-rw-r--r--packages/frontend/src/pages/about.federation.vue89
-rw-r--r--packages/frontend/src/pages/admin-user.vue48
-rw-r--r--packages/frontend/src/pages/admin/RolesEditorFormula.vue51
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue42
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue18
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue52
-rw-r--r--packages/frontend/src/pages/admin/ads.vue42
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue16
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.local.list.search.vue16
-rw-r--r--packages/frontend/src/pages/admin/custom-emojis-manager.register.vue20
-rw-r--r--packages/frontend/src/pages/admin/federation-job-queue.chart.chart.vue8
-rw-r--r--packages/frontend/src/pages/admin/federation.vue63
-rw-r--r--packages/frontend/src/pages/admin/files.vue18
-rw-r--r--packages/frontend/src/pages/admin/invites.vue39
-rw-r--r--packages/frontend/src/pages/admin/job-queue.vue2
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue28
-rw-r--r--packages/frontend/src/pages/admin/modlog.vue16
-rw-r--r--packages/frontend/src/pages/admin/overview.active-users.vue2
-rw-r--r--packages/frontend/src/pages/admin/overview.federation.vue8
-rw-r--r--packages/frontend/src/pages/admin/overview.heatmap.vue23
-rw-r--r--packages/frontend/src/pages/admin/overview.pie.vue13
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.chart.vue12
-rw-r--r--packages/frontend/src/pages/admin/overview.queue.vue18
-rw-r--r--packages/frontend/src/pages/admin/overview.stats.vue15
-rw-r--r--packages/frontend/src/pages/admin/overview.vue2
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue59
-rw-r--r--packages/frontend/src/pages/admin/roles.role.vue12
-rw-r--r--packages/frontend/src/pages/admin/roles.vue29
-rw-r--r--packages/frontend/src/pages/admin/users.vue64
-rw-r--r--packages/frontend/src/pages/auth.vue2
-rw-r--r--packages/frontend/src/pages/avatar-decoration-edit-dialog.vue8
-rw-r--r--packages/frontend/src/pages/chat/home.vue4
-rw-r--r--packages/frontend/src/pages/chat/message.vue2
-rw-r--r--packages/frontend/src/pages/chat/room.vue8
-rw-r--r--packages/frontend/src/pages/contact.vue20
-rw-r--r--packages/frontend/src/pages/debug.vue43
-rw-r--r--packages/frontend/src/pages/drop-and-fusion.vue22
-rw-r--r--packages/frontend/src/pages/emoji-edit-dialog.vue8
-rw-r--r--packages/frontend/src/pages/explore.featured.vue13
-rw-r--r--packages/frontend/src/pages/explore.users.vue14
-rw-r--r--packages/frontend/src/pages/flash/flash-edit.vue16
-rw-r--r--packages/frontend/src/pages/gallery/post.vue19
-rw-r--r--packages/frontend/src/pages/instance-info.vue34
-rw-r--r--packages/frontend/src/pages/list.vue13
-rw-r--r--packages/frontend/src/pages/note.vue4
-rw-r--r--packages/frontend/src/pages/page-editor/common.ts11
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.note.vue1
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue2
-rw-r--r--packages/frontend/src/pages/page-editor/els/page-editor.el.text.vue2
-rw-r--r--packages/frontend/src/pages/page-editor/page-editor.vue25
-rw-r--r--packages/frontend/src/pages/qr.read.raw-viewer.vue54
-rw-r--r--packages/frontend/src/pages/qr.read.vue402
-rw-r--r--packages/frontend/src/pages/qr.show.vue234
-rw-r--r--packages/frontend/src/pages/qr.vue57
-rw-r--r--packages/frontend/src/pages/registry.keys.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.board.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.setting.vue2
-rw-r--r--packages/frontend/src/pages/reversi/game.vue2
-rw-r--r--packages/frontend/src/pages/settings/2fa.vue2
-rw-r--r--packages/frontend/src/pages/settings/drive-cleaner.vue20
-rw-r--r--packages/frontend/src/pages/settings/drive.vue37
-rw-r--r--packages/frontend/src/pages/settings/emoji-palette.vue38
-rw-r--r--packages/frontend/src/pages/settings/navbar.vue4
-rw-r--r--packages/frontend/src/pages/settings/notifications.notification-config.vue31
-rw-r--r--packages/frontend/src/pages/settings/notifications.vue2
-rw-r--r--packages/frontend/src/pages/settings/other.vue4
-rw-r--r--packages/frontend/src/pages/settings/preferences.vue92
-rw-r--r--packages/frontend/src/pages/settings/privacy.vue172
-rw-r--r--packages/frontend/src/pages/settings/profile.vue29
-rw-r--r--packages/frontend/src/pages/settings/sounds.sound.vue27
-rw-r--r--packages/frontend/src/pages/settings/sounds.vue2
-rw-r--r--packages/frontend/src/pages/settings/statusbar.statusbar.vue31
-rw-r--r--packages/frontend/src/pages/settings/theme.manage.vue28
-rw-r--r--packages/frontend/src/pages/settings/theme.vue53
-rw-r--r--packages/frontend/src/pages/share.vue3
-rw-r--r--packages/frontend/src/pages/user/home.vue105
-rw-r--r--packages/frontend/src/pages/user/index.timeline.vue17
-rw-r--r--packages/frontend/src/pages/user/lists.vue2
-rw-r--r--packages/frontend/src/pages/user/notes.vue17
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,