diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-09-13 21:00:33 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-09-13 21:00:33 +0900 |
| commit | d4654dd7bd5bf1c7faa74ed89f592448c0076be8 (patch) | |
| tree | b4f51e86f174717fef469fbedca48faa2a55e841 /packages/frontend/src | |
| parent | fix(deps): update dependency vite [security] (#16535) (diff) | |
| download | misskey-d4654dd7bd5bf1c7faa74ed89f592448c0076be8.tar.gz misskey-d4654dd7bd5bf1c7faa74ed89f592448c0076be8.tar.bz2 misskey-d4654dd7bd5bf1c7faa74ed89f592448c0076be8.zip | |
refactor(frontend): os.select, MkSelectのitem指定をオブジェクトによる定義に統一し、型を狭める (#16475)
* refactor(frontend): MkSelectのitem指定をオブジェクトによる定義に統一
* fix
* spdx
* fix
* fix os.select
* fix lint
* add comment
* fix
* fix: os.select対応漏れを修正
* fix
* fix
* fix: MkSelectのmodelに対する型チェックを厳格化
* fix
* fix
* fix
* Update packages/frontend/src/components/MkEmbedCodeGenDialog.vue
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
* fix
* fix types
* fix
* fix
* Update packages/frontend/src/pages/admin/roles.editor.vue
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
* fix: MkSelectに直接配列を指定している場合に正常に型が解決されるように
---------
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src')
64 files changed, 1171 insertions, 765 deletions
diff --git a/packages/frontend/src/components/MkAntennaEditor.vue b/packages/frontend/src/components/MkAntennaEditor.vue index e2febf7225..a41fdbc45d 100644 --- a/packages/frontend/src/components/MkAntennaEditor.vue +++ b/packages/frontend/src/components/MkAntennaEditor.vue @@ -10,17 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="name"> <template #label>{{ i18n.ts.name }}</template> </MkInput> - <MkSelect v-model="src"> + <MkSelect v-model="src" :items="antennaSourcesSelectDef"> <template #label>{{ i18n.ts.antennaSource }}</template> - <option value="all">{{ i18n.ts._antennaSources.all }}</option> - <!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>--> - <option value="users">{{ i18n.ts._antennaSources.users }}</option> - <!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>--> - <option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option> </MkSelect> - <MkSelect v-if="src === 'list'" v-model="userListId"> + <MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef"> <template #label>{{ i18n.ts.userList }}</template> - <option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option> </MkSelect> <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users"> <template #label>{{ i18n.ts.users }}</template> @@ -52,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch, ref } from 'vue'; +import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import type { DeepPartial } from '@/utility/merge.js'; import MkButton from '@/components/MkButton.vue'; @@ -64,6 +58,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; import { deepMerge } from '@/utility/merge.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { id?: string; @@ -99,9 +94,35 @@ const emit = defineEmits<{ (ev: 'deleted'): void, }>(); +const { + model: src, + def: antennaSourcesSelectDef, +} = useMkSelect({ + items: [ + { value: 'all', label: i18n.ts._antennaSources.all }, + //{ value: 'home', label: i18n.ts._antennaSources.homeTimeline }, + { value: 'users', label: i18n.ts._antennaSources.users }, + //{ value: 'list', label: i18n.ts._antennaSources.userList }, + { value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist }, + ], + initialValue: initialAntenna.src, +}); + +const { + model: userListId, + def: userListsSelectDef, +} = useMkSelect({ + items: computed(() => { + if (userLists.value == null) return []; + return userLists.value.map(list => ({ + value: list.id, + label: list.name, + })); + }), + initialValue: initialAntenna.userListId, +}); + const name = ref<string>(initialAntenna.name); -const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src); -const userListId = ref<string | null>(initialAntenna.userListId); const users = ref<string>(initialAntenna.users.join('\n')); const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n')); const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n')); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 20a953c72c..a3b6112629 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> </MkInput> - <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate"> + <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template> - <option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option> </MkSelect> <MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton> <div v-else-if="c.type === 'postForm'" :class="$style.postForm"> @@ -74,6 +73,7 @@ import MkSelect from '@/components/MkSelect.vue'; import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import MkFolder from '@/components/MkFolder.vue'; import MkPostForm from '@/components/MkPostForm.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const props = withDefaults(defineProps<{ component: AsUiComponent; @@ -130,7 +130,19 @@ function onSwitchUpdate(v: boolean) { } } -const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null); +const { + model: valueForSelect, + def: selectDef, +} = useMkSelect({ + items: computed(() => { + if (c.type !== 'select') return []; + return (c.items ?? []).map(item => ({ + value: item.value, + label: item.text, + })); + }), + initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null, +}); function onSelectUpdate(v) { valueForSelect.value = v; diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 3f7519a43f..705301a6a6 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -29,16 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> - <MkSelect v-if="select" v-model="selectedValue" autofocus> - <template v-if="select.items"> - <template v-for="item in select.items"> - <optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle"> - <option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option> - </optgroup> - <option v-else :value="item.value">{{ item.text }}</option> - </template> - </template> - </MkSelect> + <MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> @@ -56,6 +47,8 @@ import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { i18n } from '@/i18n.js'; type Input = { @@ -67,17 +60,9 @@ type Input = { maxLength?: number; }; -type SelectItem = { - value: any; - text: string; -}; - type Select = { - items: (SelectItem | { - sectionTitle: string; - items: SelectItem[]; - })[]; - default: string | null; + items: MkSelectItem[]; + default: OptionValue | null; }; type Result = string | number | true | null; @@ -115,7 +100,6 @@ const emit = defineEmits<{ const modal = useTemplateRef('modal'); const inputValue = ref<string | number | null>(props.input?.default ?? null); -const selectedValue = ref(props.select?.default ?? null); const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { @@ -134,6 +118,14 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character return null; }); +const { + def: selectDef, + model: selectedValue, +} = useMkSelect({ + items: computed(() => props.select?.items ?? []), + initialValue: props.select?.default ?? null, +}); + // overload function を使いたいので lint エラーを無視する function done(canceled: true): void; function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index 17823deb85..0cb8499699 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -52,11 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #suffix>px</template> <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> </MkInput> - <MkSelect v-model="colorMode"> + <MkSelect v-model="colorMode" :items="colorModeDef"> <template #label>{{ i18n.ts.theme }}</template> - <option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option> - <option value="light">{{ i18n.ts.light }}</option> - <option value="dark">{{ i18n.ts.dark }}</option> </MkSelect> <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> @@ -105,6 +102,7 @@ import MkInfo from '@/components/MkInfo.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; @@ -162,7 +160,18 @@ const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(pro const header = ref(props.params?.header ?? true); const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500); -const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); +const { + model: colorMode, + def: colorModeDef, +} = useMkSelect({ + items: [ + { value: 'auto', label: i18n.ts.syncDeviceDarkMode }, + { value: 'light', label: i18n.ts.light }, + { value: 'dark', label: i18n.ts.dark }, + ], + initialValue: props.params?.colorMode ?? 'auto', +}); + const rounded = ref(props.params?.rounded ?? true); const border = ref(props.params?.border ?? true); diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 8d697499a5..142ccb12a3 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -39,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-text="v.label || k"></span> <template v-if="v.description" #caption>{{ v.description }}</template> </MkSwitch> - <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]"> + <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> - <option v-for="option in v.enum" :key="getEnumKey(option)" :value="getEnumValue(option)">{{ getEnumLabel(option) }}</option> </MkSelect> <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> @@ -77,7 +76,8 @@ import MkRange from './MkRange.vue'; import MkButton from './MkButton.vue'; import MkRadios from './MkRadios.vue'; import XFile from './MkFormDialog.file.vue'; -import type { EnumItem, Form, RadioFormItem } from '@/utility/form.js'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; @@ -120,16 +120,14 @@ function cancel() { dialog.value?.close(); } -function getEnumLabel(e: EnumItem) { - return typeof e === 'string' ? e : e.label; -} - -function getEnumValue(e: EnumItem) { - return typeof e === 'string' ? e : e.value; -} - -function getEnumKey(e: EnumItem) { - return typeof e === 'string' ? e : typeof e.value === 'string' ? e.value : JSON.stringify(e.value); +function getMkSelectDef(def: EnumFormItem): MkSelectItem[] { + return def.enum.map((v) => { + if (typeof v === 'string') { + return { value: v, label: v }; + } else { + return { value: v.value, label: v.label }; + } + }); } function getRadioKey(e: RadioFormItem['options'][number]) { diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 15578ca1c9..13048a2e1b 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header>Chart</template> <div :class="$style.chart"> <div class="selects"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup v-if="shouldShowFederation" :label="i18n.ts.federation"> - <option value="federation">{{ i18n.ts._charts.federation }}</option> - <option value="ap-request">{{ i18n.ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="i18n.ts.users"> - <option value="users">{{ i18n.ts._charts.usersIncDec }}</option> - <option value="users-total">{{ i18n.ts._charts.usersTotal }}</option> - <option value="active-users">{{ i18n.ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="i18n.ts.notes"> - <option value="notes">{{ i18n.ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option> - <option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="i18n.ts.drive"> - <option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option> - <option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ i18n.ts.perHour }}</option> - <option value="day">{{ i18n.ts.perDay }}</option> - </MkSelect> + <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0; flex: 1;"></MkSelect> + <MkSelect v-model="chartSpan" :items="chartSpanDef" style="margin: 0 0 0 10px;"></MkSelect> </div> <div class="chart _panel"> <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart> @@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkFoldableSection class="item"> <template #header>Active users heatmap</template> - <MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;"> - <option value="active-users">Active users</option> - <option value="notes">Notes</option> - <option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option> - <option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option> - <option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option> - </MkSelect> + <MkSelect v-model="heatmapSrc" :items="heatmapSrcDef" style="margin: 0 0 12px 0;"></MkSelect> <div class="_panel" :class="$style.heatmap"> <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> </div> @@ -84,10 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, ref, computed, useTemplateRef } from 'vue'; +import { onMounted, computed, useTemplateRef } from 'vue'; import { Chart } from 'chart.js'; -import type { HeatmapSource } from '@/components/MkHeatmap.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; @@ -101,15 +72,96 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; import { initChart } from '@/utility/init-chart.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; initChart(); const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator); const chartLimit = 500; -const chartSpan = ref<'hour' | 'day'>('hour'); -const chartSrc = ref<ChartSrc>('active-users'); -const heatmapSrc = ref<HeatmapSource>('active-users'); +const { + model: chartSpan, + def: chartSpanDef, +} = useMkSelect({ + items: [ + { value: 'hour', label: i18n.ts.perHour }, + { value: 'day', label: i18n.ts.perDay }, + ], + initialValue: 'hour', +}); +const { + model: chartSrc, + def: chartSrcDef, +} = useMkSelect({ + items: computed<MkSelectItem<ChartSrc>[]>(() => { + const items: MkSelectItem<ChartSrc>[] = []; + + if (shouldShowFederation.value) { + items.push({ + type: 'group', + label: i18n.ts.federation, + items: [ + { value: 'federation', label: i18n.ts._charts.federation }, + { value: 'ap-request', label: i18n.ts._charts.apRequest }, + ], + }); + } + + items.push({ + type: 'group', + label: i18n.ts.users, + items: [ + { value: 'users', label: i18n.ts._charts.usersIncDec }, + { value: 'users-total', label: i18n.ts._charts.usersTotal }, + { value: 'active-users', label: i18n.ts._charts.activeUsers }, + ], + }); + + const notesItems: ItemOption<ChartSrc>[] = [ + { value: 'notes', label: i18n.ts._charts.notesIncDec }, + { value: 'local-notes', label: i18n.ts._charts.localNotesIncDec }, + ]; + + if (shouldShowFederation.value) notesItems.push({ value: 'remote-notes', label: i18n.ts._charts.remoteNotesIncDec }); + + notesItems.push( + { value: 'notes-total', label: i18n.ts._charts.notesTotal }, + ); + + items.push({ + type: 'group', + label: i18n.ts.notes, + items: notesItems, + }); + + items.push({ + type: 'group', + label: i18n.ts.drive, + items: [ + { value: 'drive-files', label: i18n.ts._charts.filesIncDec }, + { value: 'drive', label: i18n.ts._charts.storageUsageIncDec }, + ], + }); + + return items; + }), + initialValue: 'active-users', +}); +const { + model: heatmapSrc, + def: heatmapSrcDef, +} = useMkSelect({ + items: computed(() => [ + { value: 'active-users' as const, label: 'Active Users' }, + { value: 'notes' as const, label: 'Notes' }, + ...(shouldShowFederation.value ? [ + { value: 'ap-requests-inbox-received' as const, label: 'AP Requests: inboxReceived' }, + { value: 'ap-requests-deliver-succeeded' as const, label: 'AP Requests: deliverSucceeded' }, + { value: 'ap-requests-deliver-failed' as const, label: 'AP Requests: deliverFailed' }, + ] : []), + ]), + initialValue: 'active-users', +}); const subDoughnutEl = useTemplateRef('subDoughnutEl'); const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); diff --git a/packages/frontend/src/components/MkPaginationControl.vue b/packages/frontend/src/components/MkPaginationControl.vue index 10bed575a4..55aa3f2dc2 100644 --- a/packages/frontend/src/components/MkPaginationControl.vue +++ b/packages/frontend/src/components/MkPaginationControl.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.root"> <div :class="$style.control"> - <MkSelect v-model="order" :class="$style.order" :items="[{ label: i18n.ts._order.newest, value: 'newest' }, { label: i18n.ts._order.oldest, value: 'oldest' }]"> + <MkSelect v-model="order" :class="$style.order" :items="orderDef"> <template #prefix><i class="ti ti-arrows-sort"></i></template> </MkSelect> <MkButton v-if="paginator.canSearch" v-tooltip="i18n.ts.search" iconOnly transparent rounded :active="searchOpened" @click="searchOpened = !searchOpened"><i class="ti ti-search"></i></MkButton> @@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js'; import MkSelect from '@/components/MkSelect.vue'; import MkInput from '@/components/MkInput.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const props = withDefaults(defineProps<{ paginator: T; @@ -58,7 +59,16 @@ const props = withDefaults(defineProps<{ const searchOpened = ref(false); const filterOpened = ref(props.filterOpened); -const order = ref<'newest' | 'oldest'>('newest'); +const { + model: order, + def: orderDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._order.newest, value: 'newest' }, + { label: i18n.ts._order.oldest, value: 'oldest' }, + ], + initialValue: 'newest', +}); const date = ref<number | null>(null); const q = ref<string | null>(null); diff --git a/packages/frontend/src/components/MkPollEditor.vue b/packages/frontend/src/components/MkPollEditor.vue index 174c923bcf..b7c3d1f42d 100644 --- a/packages/frontend/src/components/MkPollEditor.vue +++ b/packages/frontend/src/components/MkPollEditor.vue @@ -22,11 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch> <section> <div> - <MkSelect v-model="expiration" small> + <MkSelect v-model="expiration" :items="expirationDef" small> <template #label>{{ i18n.ts._poll.expiration }}</template> - <option value="infinite">{{ i18n.ts._poll.infinite }}</option> - <option value="at">{{ i18n.ts._poll.at }}</option> - <option value="after">{{ i18n.ts._poll.after }}</option> </MkSelect> <section v-if="expiration === 'at'"> <MkInput v-model="atDate" small type="date" class="input"> @@ -40,12 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-model="after" small type="number" :min="1" class="input"> <template #label>{{ i18n.ts._poll.duration }}</template> </MkInput> - <MkSelect v-model="unit" small> - <option value="second">{{ i18n.ts._time.second }}</option> - <option value="minute">{{ i18n.ts._time.minute }}</option> - <option value="hour">{{ i18n.ts._time.hour }}</option> - <option value="day">{{ i18n.ts._time.day }}</option> - </MkSelect> + <MkSelect v-model="unit" :items="unitDef" small></MkSelect> </section> </div> </section> @@ -61,6 +53,7 @@ import MkButton from './MkButton.vue'; import { formatDateTimeString } from '@/utility/format-time-string.js'; import { addTime } from '@/utility/time.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; export type PollEditorModelValue = { expiresAt: number | null; @@ -78,11 +71,32 @@ const emit = defineEmits<{ const choices = ref(props.modelValue.choices); const multiple = ref(props.modelValue.multiple); -const expiration = ref('infinite'); +const { + model: expiration, + def: expirationDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._poll.infinite, value: 'infinite' }, + { label: i18n.ts._poll.at, value: 'at' }, + { label: i18n.ts._poll.after, value: 'after' }, + ], + initialValue: 'infinite', +}); const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); const atTime = ref('00:00'); const after = ref(0); -const unit = ref('second'); +const { + model: unit, + def: unitDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._time.second, value: 'second' }, + { label: i18n.ts._time.minute, value: 'minute' }, + { label: i18n.ts._time.hour, value: 'hour' }, + { label: i18n.ts._time.day, value: 'day' }, + ], + initialValue: 'second', +}); if (props.modelValue.expiresAt) { expiration.value = 'at'; diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index a3ff89fc4d..9fec7ea4da 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -567,11 +567,11 @@ async function toggleReactionAcceptance() { const select = await os.select({ title: i18n.ts.reactionAcceptance, items: [ - { value: null, text: i18n.ts.all }, - { value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote }, - { value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly }, - { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, - { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, + { value: null, label: i18n.ts.all }, + { value: 'likeOnlyForRemote' as const, label: i18n.ts.likeOnlyForRemote }, + { value: 'nonSensitiveOnly' as const, label: i18n.ts.nonSensitiveOnly }, + { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, + { value: 'likeOnly' as const, label: i18n.ts.likeOnly }, ], default: reactionAcceptance.value, }); diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index f1cc98def4..937804703d 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -102,12 +102,12 @@ async function addRole() { const items = roles.value .filter(r => r.isPublic) .filter(r => !selectedRoleIds.value.includes(r.id)) - .map(r => ({ text: r.name, value: r })); + .map(r => ({ label: r.name, value: r.id })); - const { canceled, result: role } = await os.select({ items }); - if (canceled || role == null) return; + const { canceled, result: roleId } = await os.select({ items }); + if (canceled || roleId == null) return; - selectedRoleIds.value.push(role.id); + selectedRoleIds.value.push(roleId); } async function removeRole(roleId: string) { diff --git a/packages/frontend/src/components/MkSelect.vue b/packages/frontend/src/components/MkSelect.vue index 9cbaf676c7..e79236fe54 100644 --- a/packages/frontend/src/components/MkSelect.vue +++ b/packages/frontend/src/components/MkSelect.vue @@ -40,46 +40,41 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -type ItemOption = { +export type OptionValue = string | number | null; + +export type ItemOption<T extends OptionValue = OptionValue> = { type?: 'option'; - value: string | number | null; + value: T; label: string; }; -type ItemGroup = { +export type ItemGroup<T extends OptionValue = OptionValue> = { type: 'group'; - label: string; - items: ItemOption[]; + label?: string; + items: ItemOption<T>[]; }; -export type MkSelectItem = ItemOption | ItemGroup; +export type MkSelectItem<T extends OptionValue = OptionValue> = ItemOption<T> | ItemGroup<T>; -type ValuesOfItems<T> = T extends (infer U)[] - ? U extends { type: 'group'; items: infer V } - ? V extends (infer W)[] - ? W extends { value: infer X } - ? X - : never - : never - : U extends { value: infer Y } - ? Y - : never +export type GetMkSelectValueType<T extends MkSelectItem> = T extends ItemGroup + ? T['items'][number]['value'] + : T extends ItemOption + ? T['value'] + : never; + +export type GetMkSelectValueTypesFromDef<T extends MkSelectItem[]> = T[number] extends MkSelectItem + ? GetMkSelectValueType<T[number]> : never; </script> -<script lang="ts" setup generic="T extends MkSelectItem[]"> -import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; +<script lang="ts" setup generic="const ITEMS extends MkSelectItem[], MODELT extends OptionValue"> +import { onMounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import { useInterval } from '@@/js/use-interval.js'; -import type { VNode, VNodeChild } from 'vue'; import type { MenuItem } from '@/types/menu.js'; import * as os from '@/os.js'; -// TODO: itemsをslot内のoptionで指定する用法は廃止する(props.itemsを必須化する) -// see: https://github.com/misskey-dev/misskey/issues/15558 -// あと型推論と相性が良くない - const props = defineProps<{ - modelValue: ValuesOfItems<T>; + items: ITEMS; required?: boolean; readonly?: boolean; disabled?: boolean; @@ -88,16 +83,17 @@ const props = defineProps<{ inline?: boolean; small?: boolean; large?: boolean; - items?: T; }>(); -const emit = defineEmits<{ - (ev: 'update:modelValue', value: ValuesOfItems<T>): void; -}>(); +type ModelTChecked = MODELT & ( + MODELT extends GetMkSelectValueTypesFromDef<ITEMS> + ? unknown + : 'Error: The type of model does not match the type of items.' +); -const slots = useSlots(); +const model = defineModel<ModelTChecked>({ required: true }); -const { modelValue, autofocus } = toRefs(props); +const { autofocus } = toRefs(props); const focused = ref(false); const opening = ref(false); const currentValueText = ref<string | null>(null); @@ -140,52 +136,26 @@ onMounted(() => { }); }); -watch([modelValue, () => props.items], () => { - if (props.items) { - let found: ItemOption | null = null; - for (const item of props.items) { - if (item.type === 'group') { - for (const option of item.items) { - if (option.value === modelValue.value) { - found = option; - break; - } - } - } else { - if (item.value === modelValue.value) { - found = item; +watch([model, () => props.items], () => { + let found: ItemOption | null = null; + for (const item of props.items) { + if (item.type === 'group') { + for (const option of item.items) { + if (option.value === model.value) { + found = option; break; } } - } - if (found) { - currentValueText.value = found.label; - } - return; - } - - const scanOptions = (options: VNodeChild[]) => { - for (const vnode of options) { - if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; - if (vnode.type === 'optgroup') { - const optgroup = vnode; - if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - if (Array.isArray(fragment.children)) scanOptions(fragment.children); - } else if (vnode.props == null) { // v-if で条件が false のときにこうなる - // nop? - } else { - const option = vnode; - if (option.props?.value === modelValue.value) { - currentValueText.value = option.children as string; - break; - } + } else { + if (item.value === model.value) { + found = item; + break; } } - }; - - scanOptions(slots.default!()); + } + if (found) { + currentValueText.value = found.label; + } }, { immediate: true, deep: true }); function show() { @@ -196,68 +166,32 @@ function show() { const menu: MenuItem[] = []; - if (props.items) { - for (const item of props.items) { - if (item.type === 'group') { + for (const item of props.items) { + if (item.type === 'group') { + if (item.label != null) { menu.push({ type: 'label', text: item.label, }); - for (const option of item.items) { - menu.push({ - text: option.label, - active: computed(() => modelValue.value === option.value), - action: () => { - emit('update:modelValue', option.value); - }, - }); - } - } else { + } + for (const option of item.items) { menu.push({ - text: item.label, - active: computed(() => modelValue.value === item.value), + text: option.label, + active: computed(() => model.value === option.value), action: () => { - emit('update:modelValue', item.value); + model.value = option.value as ModelTChecked; }, }); } - } - } else { - let options = slots.default!(); - - const pushOption = (option: VNode) => { + } else { menu.push({ - text: option.children as string, - active: computed(() => modelValue.value === option.props?.value), + text: item.label, + active: computed(() => model.value === item.value), action: () => { - emit('update:modelValue', option.props?.value); + model.value = item.value as ModelTChecked; }, }); - }; - - const scanOptions = (options: VNodeChild[]) => { - for (const vnode of options) { - if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue; - if (vnode.type === 'optgroup') { - const optgroup = vnode; - menu.push({ - type: 'label', - text: optgroup.props?.label, - }); - if (Array.isArray(optgroup.children)) scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - if (Array.isArray(fragment.children)) scanOptions(fragment.children); - } else if (vnode.props == null) { // v-if で条件が false のときにこうなる - // nop? - } else { - const option = vnode; - pushOption(option); - } - } - }; - - scanOptions(options); + } } os.popupMenu(menu, container.value, { diff --git a/packages/frontend/src/components/MkWatermarkEditorDialog.vue b/packages/frontend/src/components/MkWatermarkEditorDialog.vue index 206298b194..75a45548fd 100644 --- a/packages/frontend/src/components/MkWatermarkEditorDialog.vue +++ b/packages/frontend/src/components/MkWatermarkEditorDialog.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div :class="$style.controls"> <div class="_spacer _gaps"> - <MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> + <MkSelect v-model="type" :items="typeDef"> <template #label>{{ i18n.ts._watermarkEditor.type }}</template> </MkSelect> @@ -86,6 +86,7 @@ import * as os from '@/os.js'; import { deepClone } from '@/utility/clone.js'; import { ensureSignin } from '@/i.js'; import { genId } from '@/utility/id.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const $i = ensureSignin(); @@ -186,7 +187,18 @@ async function cancel() { dialog.value?.close(); } -const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type); +const { + model: type, + def: typeDef, +} = useMkSelect({ + items: [ + { label: i18n.ts._watermarkEditor.text, value: 'text' }, + { label: i18n.ts._watermarkEditor.image, value: 'image' }, + { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }, + ], + initialValue: preset.layers.length > 1 ? 'advanced' : preset.layers[0].type, +}); + watch(type, () => { if (type.value === 'text') { preset.layers = [createTextLayer()]; diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index 08a018ea9b..cf7c2cda80 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.root"> <template v-if="edit"> <header :class="$style.editHeader"> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> + <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> - <option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option> </MkSelect> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> @@ -59,6 +58,7 @@ import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); @@ -89,7 +89,15 @@ const widgetRefs = {}; const configWidget = (id: string) => { widgetRefs[id].configure(); }; -const widgetAdderSelected = ref<string | null>(null); + +const { + model: widgetAdderSelected, + def: widgetAdderSelectedDef, +} = useMkSelect({ + items: computed(() => [{ label: i18n.ts.none, value: null }, ..._widgetDefs.value.map(x => ({ label: i18n.ts._widgets[x], value: x }))]), + initialValue: null, +}); + const addWidget = () => { if (widgetAdderSelected.value == null) return; diff --git a/packages/frontend/src/composables/use-mkselect.ts b/packages/frontend/src/composables/use-mkselect.ts new file mode 100644 index 0000000000..7cb470d169 --- /dev/null +++ b/packages/frontend/src/composables/use-mkselect.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; +import type { Ref, MaybeRefOrGetter } from 'vue'; +import type { MkSelectItem, OptionValue, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; + +type UnwrapReadonlyItems<T> = T extends readonly (infer U)[] ? U[] : T; + +/** 指定したオプション定義をもとに型を狭めたrefを生成するコンポーサブル */ +export function useMkSelect< + const TItemsInput extends MaybeRefOrGetter<MkSelectItem[]>, + const TItems extends TItemsInput extends MaybeRefOrGetter<infer U> ? U : never, + TInitialValue extends OptionValue | void = void, + TItemsValue = GetMkSelectValueTypesFromDef<UnwrapReadonlyItems<TItems>>, + ModelType = TInitialValue extends void + ? TItemsValue + : (TItemsValue | TInitialValue) +>(opts: { + items: TItemsInput; + initialValue?: (TInitialValue | (OptionValue extends TItemsValue ? OptionValue : TInitialValue)) & ( + TItemsValue extends TInitialValue + ? unknown + : { 'Error: Type of initialValue must include all types of items': TItemsValue } + ); +}): { + def: TItemsInput; + model: Ref<ModelType>; +} { + const model = ref(opts.initialValue ?? null); + + return { + def: opts.items, + model: model as Ref<ModelType>, + }; +} diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 504ae19f1d..6c5f04c6b5 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -14,6 +14,7 @@ import type { Form, GetFormResultType } from '@/utility/form.js'; import type { MenuItem } from '@/types/menu.js'; import type { PostFormProps } from '@/types/post-form.js'; import type { UploaderFeatures } from '@/composables/use-uploader.js'; +import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue'; import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import { misskeyApi } from '@/utility/misskey-api.js'; @@ -502,50 +503,15 @@ export function authenticateDialog(): Promise<{ }); } -type SelectItem<C> = { - value: C; - text: string; -}; - -// default が指定されていたら result は null になり得ないことを保証する overload function -export function select<C = unknown>(props: { +export function select<C extends OptionValue, D extends C | null = null>(props: { title?: string; text?: string; - default: string; - items: (SelectItem<C> | { - sectionTitle: string; - items: SelectItem<C>[]; - } | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: C; -}>; -export function select<C = unknown>(props: { - title?: string; - text?: string; - default?: string | null; - items: (SelectItem<C> | { - sectionTitle: string; - items: SelectItem<C>[]; - } | undefined)[]; -}): Promise<{ - canceled: true; result: undefined; -} | { - canceled: false; result: C | null; -}>; -export function select<C = unknown>(props: { - title?: string; - text?: string; - default?: string | null; - items: (SelectItem<C> | { - sectionTitle: string; - items: SelectItem<C>[]; - } | undefined)[]; + default?: D; + items: (MkSelectItem<C> | undefined)[]; }): Promise<{ canceled: true; result: undefined; } | { - canceled: false; result: C | null; + canceled: false; result: Exclude<D, undefined> extends null ? C | null : C; }> { return new Promise(resolve => { const { dispose } = popup(MkDialog, { diff --git a/packages/frontend/src/pages/about.federation.vue b/packages/frontend/src/pages/about.federation.vue index 568bf9fd1a..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', - }]" - > + <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 86dfd38d81..24f2d0d5eb 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -153,17 +153,15 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-else-if="tab === 'announcements'" class="_gaps"> <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> - <MkSelect v-model="announcementsStatus"> + <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,6 +226,7 @@ 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'; @@ -247,7 +245,15 @@ const props = withDefaults(defineProps<{ const result = await _fetch_(); const tab = ref(props.initialTab); -const chartSrc = ref<ChartSrc>('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); @@ -264,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, @@ -428,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 || 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..17008e0c13 100644 --- a/packages/frontend/src/pages/admin/ads.vue +++ b/packages/frontend/src/pages/admin/ads.vue @@ -6,11 +6,8 @@ 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"> @@ -95,6 +92,7 @@ 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 +100,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 +129,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 +142,7 @@ const filterItems = (v) => { }; // 選択された曜日(index)のビットフラグを操作する -function toggleDayOfWeek(ad, index) { +function toggleDayOfWeek(ad: Misskey.entities.Ad, index: number) { ad.dayOfWeek ^= 1 << index; } @@ -153,7 +161,7 @@ function add() { }); } -function remove(ad) { +function remove(ad: Misskey.entities.Ad) { os.confirm({ type: 'warning', text: i18n.tsx.removeAreYouSure({ x: ad.url }), @@ -169,7 +177,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 176d1121c5..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[]>([]); 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/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.heatmap.vue b/packages/frontend/src/pages/admin/overview.heatmap.vue index 6192d6eb0f..5edc01404c 100644 --- a/packages/frontend/src/pages/admin/overview.heatmap.vue +++ b/packages/frontend/src/pages/admin/overview.heatmap.vue @@ -5,24 +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 type { HeatmapSource } from '@/components/MkHeatmap.vue'; +import { useMkSelect } from '@/composables/use-mkselect.js'; -const src = ref<HeatmapSource>('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/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 66004a44bb..e10eb1163e 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> @@ -841,6 +842,7 @@ import FormSlot from '@/components/form/slot.vue'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; import { deepClone } from '@/utility/clone.js'; +import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue'; const emit = defineEmits<{ (ev: 'update:modelValue', v: any): void; @@ -870,11 +872,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 8e83cdb667..2e249eee50 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -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 6483bf16a8..7a49860b2d 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> 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/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/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/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/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/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/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 3c51f25676..3dd83b25c5 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -30,10 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only <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<{ @@ -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); 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/emoji-palette.vue b/packages/frontend/src/pages/settings/emoji-palette.vue index 5ff5f45a2f..34bf1c14af 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> @@ -99,12 +95,13 @@ 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> @@ -125,6 +122,7 @@ import MkRadios from '@/components/MkRadios.vue'; import MkButton from '@/components/MkButton.vue'; import FormSection from '@/components/form/section.vue'; import MkSelect from '@/components/MkSelect.vue'; +import type { MkSelectItem } from '@/components/MkSelect.vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { definePage } from '@/page.js'; @@ -135,7 +133,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/preferences.vue b/packages/frontend/src/pages/settings/preferences.vue index ba35dd7f43..78f0891a58 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> @@ -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> @@ -984,16 +1008,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 8ae8c79ebd..d3020f068b 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> @@ -85,13 +79,8 @@ SPDX-License-Identifier: AGPL-3.0-only <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 v-model="makeNotesFollowersOnlyBefore_type"> - <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 @@ -162,22 +160,22 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="_gaps_s"> <MkSelect v-model="makeNotesHiddenBefore_type" - :items="[{ - value: null, - label: i18n.ts.none - }, { - value: 'relative', - label: i18n.ts._accountSettings.notesHavePassedSpecifiedPeriod - }, { - value: 'absolute', - label: i18n.ts._accountSettings.notesOlderThanSpecifiedDateAndTime - }]" + :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 @@ -217,6 +215,7 @@ 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 { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.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,9 +243,41 @@ 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({ get: () => { @@ -276,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 && @@ -328,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..17e8505474 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> diff --git a/packages/frontend/src/pages/settings/sounds.sound.vue b/packages/frontend/src/pages/settings/sounds.sound.vue index 9e9671487e..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,6 +37,7 @@ 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'; @@ -51,7 +51,16 @@ const emit = defineEmits<{ (ev: 'update', result: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }): void; }>(); -const type = ref<SoundType>(props.def.type); +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>(''); 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/preferences/manager.ts b/packages/frontend/src/preferences/manager.ts index d26d590851..b6d3d55a5f 100644 --- a/packages/frontend/src/preferences/manager.ts +++ b/packages/frontend/src/preferences/manager.ts @@ -447,16 +447,16 @@ export class PreferencesManager { title: i18n.ts.preferenceSyncConflictTitle, text: i18n.ts.preferenceSyncConflictText, items: [...(mergedValue !== undefined ? [{ - text: i18n.ts.preferenceSyncConflictChoiceMerge, - value: 'merge', + label: i18n.ts.preferenceSyncConflictChoiceMerge, + value: 'merge' as const, }] : []), { - text: i18n.ts.preferenceSyncConflictChoiceServer, - value: 'remote', + label: i18n.ts.preferenceSyncConflictChoiceServer, + value: 'remote' as const, }, { - text: i18n.ts.preferenceSyncConflictChoiceDevice, - value: 'local', + label: i18n.ts.preferenceSyncConflictChoiceDevice, + value: 'local' as const, }, { - text: i18n.ts.preferenceSyncConflictChoiceCancel, + label: i18n.ts.preferenceSyncConflictChoiceCancel, value: null, }], default: mergedValue !== undefined ? 'merge' : 'remote', diff --git a/packages/frontend/src/preferences/utility.ts b/packages/frontend/src/preferences/utility.ts index 80949f4971..33d379509a 100644 --- a/packages/frontend/src/preferences/utility.ts +++ b/packages/frontend/src/preferences/utility.ts @@ -187,7 +187,7 @@ export async function restoreFromCloudBackup() { const select = await os.select({ title: i18n.ts._preferencesBackup.selectBackupToRestore, items: backups.map(backup => ({ - text: backup.name, + label: backup.name, value: backup.name, })), }); diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue index 9f6d8267f7..e2ee4b658e 100644 --- a/packages/frontend/src/ui/deck.vue +++ b/packages/frontend/src/ui/deck.vue @@ -168,7 +168,7 @@ const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ title: i18n.ts._deck.addColumn, items: columnTypes.map(column => ({ - value: column, text: i18n.ts._deck._columns[column], + value: column, label: i18n.ts._deck._columns[column], })), }); if (canceled || column == null) return; diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue index 0042882728..0423a22ce1 100644 --- a/packages/frontend/src/ui/deck/antenna-column.vue +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -51,22 +51,24 @@ watch(soundSetting, v => { async function setAntenna() { const antennas = await misskeyApi('antennas/list'); - const { canceled, result: antenna } = await os.select<MisskeyEntities.Antenna | '_CREATE_'>({ + const { canceled, result: antennaIdOrOperation } = await os.select({ title: i18n.ts.selectAntenna, items: [ - { value: '_CREATE_', text: i18n.ts.createNew }, + { value: '_CREATE_', label: i18n.ts.createNew }, (antennas.length > 0 ? { - sectionTitle: i18n.ts.createdAntennas, + type: 'group' as const, + label: i18n.ts.createdAntennas, items: antennas.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), } : undefined), ], default: props.column.antennaId, }); - if (canceled || antenna == null) return; - if (antenna === '_CREATE_') { + if (canceled || antennaIdOrOperation == null) return; + + if (antennaIdOrOperation === '_CREATE_') { const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkAntennaEditorDialog.vue').then(x => x.default), {}, { created: (newAntenna: MisskeyEntities.Antenna) => { antennasCache.delete(); @@ -82,6 +84,8 @@ async function setAntenna() { return; } + const antenna = antennas.find(x => x.id === antennaIdOrOperation)!; + updateColumn(props.column.id, { antennaId: antenna.id, timelineNameCache: antenna.name, diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index c02499e2d7..35ca9f5cc6 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -58,14 +58,15 @@ watch(soundSetting, v => { async function setChannel() { const channels = await favoritedChannelsCache.fetch(); - const { canceled, result: chosenChannel } = await os.select({ + const { canceled, result: chosenChannelId } = await os.select({ title: i18n.ts.selectChannel, items: channels.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), default: props.column.channelId, }); - if (canceled || chosenChannel == null) return; + if (canceled || chosenChannelId == null) return; + const chosenChannel = channels.find(x => x.id === chosenChannelId)!; updateColumn(props.column.id, { channelId: chosenChannel.id, timelineNameCache: chosenChannel.name, diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 5c5891ece8..7fb0aba1e1 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -58,22 +58,23 @@ watch(soundSetting, v => { async function setList() { const lists = await misskeyApi('users/lists/list'); - const { canceled, result: list } = await os.select<MisskeyEntities.UserList | '_CREATE_'>({ + const { canceled, result: listIdOrOperation } = await os.select({ title: i18n.ts.selectList, items: [ - { value: '_CREATE_', text: i18n.ts.createNew }, + { value: '_CREATE_', label: i18n.ts.createNew }, (lists.length > 0 ? { - sectionTitle: i18n.ts.createdLists, + type: 'group' as const, + label: i18n.ts.createdLists, items: lists.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), } : undefined), ], default: props.column.listId, }); - if (canceled || list == null) return; + if (canceled || listIdOrOperation == null) return; - if (list === '_CREATE_') { + if (listIdOrOperation === '_CREATE_') { const { canceled, result: name } = await os.inputText({ title: i18n.ts.enterListName, }); @@ -87,6 +88,8 @@ async function setList() { timelineNameCache: res.name, }); } else { + const list = lists.find(x => x.id === listIdOrOperation)!; + updateColumn(props.column.id, { listId: list.id, timelineNameCache: list.name, diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index 0aafeb56d7..beb679169c 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -49,14 +49,15 @@ watch(soundSetting, v => { async function setRole() { const roles = (await misskeyApi('roles/list')).filter(x => x.isExplorable); - const { canceled, result: role } = await os.select({ + const { canceled, result: roleId } = await os.select({ title: i18n.ts.role, items: roles.map(x => ({ - value: x, text: x.name, + value: x.id, label: x.name, })), default: props.column.roleId, }); - if (canceled || role == null) return; + if (canceled || roleId == null) return; + const role = roles.find(x => x.id === roleId)!; updateColumn(props.column.id, { roleId: role.id, timelineNameCache: role.name, diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index 37814f0914..afaa08e6d0 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -96,13 +96,13 @@ async function setType() { const { canceled, result: src } = await os.select({ title: i18n.ts.timeline, items: [{ - value: 'home' as const, text: i18n.ts._timelines.home, + value: 'home', label: i18n.ts._timelines.home, }, { - value: 'local' as const, text: i18n.ts._timelines.local, + value: 'local', label: i18n.ts._timelines.local, }, { - value: 'social' as const, text: i18n.ts._timelines.social, + value: 'social', label: i18n.ts._timelines.social, }, { - value: 'global' as const, text: i18n.ts._timelines.global, + value: 'global', label: i18n.ts._timelines.global, }], }); if (canceled) { diff --git a/packages/frontend/src/utility/form.ts b/packages/frontend/src/utility/form.ts index b0b09f0f6b..cb4a227f67 100644 --- a/packages/frontend/src/utility/form.ts +++ b/packages/frontend/src/utility/form.ts @@ -4,10 +4,11 @@ */ import * as Misskey from 'misskey-js'; +import type { OptionValue } from '@/components/MkSelect.vue'; export type EnumItem = string | { label: string; - value: unknown; + value: OptionValue; }; type Hidden = boolean | ((v: any) => boolean); diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts index d6ddebc868..89dcae28e1 100644 --- a/packages/frontend/src/utility/get-user-menu.ts +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -37,15 +37,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router const { canceled, result: period } = await os.select({ title: i18n.ts.mutePeriod, items: [{ - value: 'indefinitely', text: i18n.ts.indefinitely, + value: 'indefinitely', label: i18n.ts.indefinitely, }, { - value: 'tenMinutes', text: i18n.ts.tenMinutes, + value: 'tenMinutes', label: i18n.ts.tenMinutes, }, { - 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, }], default: 'indefinitely', }); @@ -312,15 +312,15 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router const { canceled, result: period } = await os.select({ title: i18n.ts.period + ': ' + r.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/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue index d87ea5ade2..9e914fa648 100644 --- a/packages/frontend/src/widgets/WidgetUserList.vue +++ b/packages/frontend/src/widgets/WidgetUserList.vue @@ -67,15 +67,15 @@ const fetching = ref(true); async function chooseList() { 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, })), default: widgetProps.listId, }); - if (canceled || list == null) return; - + if (canceled || listId == null) return; + const list = lists.find(x => x.id === listId)!; widgetProps.listId = list.id; save(); fetch(); |