summaryrefslogtreecommitdiff
path: root/packages/frontend/src/widgets
diff options
context:
space:
mode:
authormisskey-release-bot[bot] <157398866+misskey-release-bot[bot]@users.noreply.github.com>2026-03-05 10:56:50 +0000
committerGitHub <noreply@github.com>2026-03-05 10:56:50 +0000
commitfe3dd8edb5f30104cd0a7ed755eb254feda2922d (patch)
treeaf6cf5fa4ca75302ac2de5db742cead00bc13d21 /packages/frontend/src/widgets
parentMerge pull request #16998 from misskey-dev/develop (diff)
parentRelease: 2026.3.0 (diff)
downloadmisskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.gz
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.tar.bz2
misskey-fe3dd8edb5f30104cd0a7ed755eb254feda2922d.zip
Merge pull request #17217 from misskey-dev/develop
Release: 2026.3.0
Diffstat (limited to 'packages/frontend/src/widgets')
-rw-r--r--packages/frontend/src/widgets/WidgetActivity.chart.vue25
-rw-r--r--packages/frontend/src/widgets/WidgetActivity.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetAichan.vue4
-rw-r--r--packages/frontend/src/widgets/WidgetAiscript.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetAiscriptApp.vue4
-rw-r--r--packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue86
-rw-r--r--packages/frontend/src/widgets/WidgetBirthdayFollowings.vue201
-rw-r--r--packages/frontend/src/widgets/WidgetButton.vue6
-rw-r--r--packages/frontend/src/widgets/WidgetCalendar.vue1
-rw-r--r--packages/frontend/src/widgets/WidgetChat.vue1
-rw-r--r--packages/frontend/src/widgets/WidgetClicker.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetClock.vue42
-rw-r--r--packages/frontend/src/widgets/WidgetDigitalClock.vue8
-rw-r--r--packages/frontend/src/widgets/WidgetFederation.vue9
-rw-r--r--packages/frontend/src/widgets/WidgetInstanceCloud.vue6
-rw-r--r--packages/frontend/src/widgets/WidgetJobQueue.vue26
-rw-r--r--packages/frontend/src/widgets/WidgetMemo.vue6
-rw-r--r--packages/frontend/src/widgets/WidgetNotifications.vue2
-rw-r--r--packages/frontend/src/widgets/WidgetOnlineUsers.vue1
-rw-r--r--packages/frontend/src/widgets/WidgetPhotos.vue6
-rw-r--r--packages/frontend/src/widgets/WidgetRss.vue11
-rw-r--r--packages/frontend/src/widgets/WidgetRssTicker.vue12
-rw-r--r--packages/frontend/src/widgets/WidgetSlideshow.vue7
-rw-r--r--packages/frontend/src/widgets/WidgetTimeline.vue14
-rw-r--r--packages/frontend/src/widgets/WidgetTrends.vue3
-rw-r--r--packages/frontend/src/widgets/WidgetUnixClock.vue5
-rw-r--r--packages/frontend/src/widgets/WidgetUserList.vue1
-rw-r--r--packages/frontend/src/widgets/index.ts6
-rw-r--r--packages/frontend/src/widgets/server-metric/index.vue2
-rw-r--r--packages/frontend/src/widgets/widget.ts69
30 files changed, 416 insertions, 154 deletions
diff --git a/packages/frontend/src/widgets/WidgetActivity.chart.vue b/packages/frontend/src/widgets/WidgetActivity.chart.vue
index e708343b3a..bab688f851 100644
--- a/packages/frontend/src/widgets/WidgetActivity.chart.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.chart.vue
@@ -53,19 +53,27 @@ const pointsReply = ref<string>();
const pointsRenote = ref<string>();
const pointsTotal = ref<string>();
-function dragListen(fn) {
+function dragListen(fn: (ev: MouseEvent | TouchEvent) => void) {
window.addEventListener('mousemove', fn);
window.addEventListener('mouseleave', dragClear.bind(null, fn));
window.addEventListener('mouseup', dragClear.bind(null, fn));
}
-function dragClear(fn) {
+function dragClear(fn: (ev: MouseEvent | TouchEvent) => void) {
window.removeEventListener('mousemove', fn);
- window.removeEventListener('mouseleave', dragClear);
- window.removeEventListener('mouseup', dragClear);
+ window.removeEventListener('mouseleave', dragClear as any);
+ window.removeEventListener('mouseup', dragClear as any);
}
-function onMousedown(ev) {
+function getPositionX(event: MouseEvent | TouchEvent) {
+ return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientX : 'clientX' in event ? event.clientX : 0;
+}
+
+function getPositionY(event: MouseEvent | TouchEvent) {
+ return 'touches' in event && event.touches.length > 0 ? event.touches[0].clientY : 'clientY' in event ? event.clientY : 0;
+}
+
+function onMousedown(ev: MouseEvent) {
const clickX = ev.clientX;
const clickY = ev.clientY;
const baseZoom = zoom.value;
@@ -73,8 +81,11 @@ function onMousedown(ev) {
// 動かした時
dragListen(me => {
- let moveLeft = me.clientX - clickX;
- let moveTop = me.clientY - clickY;
+ const x = getPositionX(me);
+ const y = getPositionY(me);
+
+ let moveLeft = x - clickX;
+ let moveTop = y - clickY;
zoom.value = Math.max(1, baseZoom + (-moveTop / 20));
pos.value = Math.min(0, basePos + moveLeft);
diff --git a/packages/frontend/src/widgets/WidgetActivity.vue b/packages/frontend/src/widgets/WidgetActivity.vue
index 9625abb4d1..3d0f4657b1 100644
--- a/packages/frontend/src/widgets/WidgetActivity.vue
+++ b/packages/frontend/src/widgets/WidgetActivity.vue
@@ -38,10 +38,12 @@ const name = 'activity';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
view: {
diff --git a/packages/frontend/src/widgets/WidgetAichan.vue b/packages/frontend/src/widgets/WidgetAichan.vue
index 3951de1d84..c2a41b6257 100644
--- a/packages/frontend/src/widgets/WidgetAichan.vue
+++ b/packages/frontend/src/widgets/WidgetAichan.vue
@@ -12,14 +12,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
import { useWidgetPropsManager } from './widget.js';
+import { i18n } from '@/i18n.js';
import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
-const name = 'ai';
+const name = 'aichan';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetAiscript.vue b/packages/frontend/src/widgets/WidgetAiscript.vue
index 795c5a2cfa..c6cafb270f 100644
--- a/packages/frontend/src/widgets/WidgetAiscript.vue
+++ b/packages/frontend/src/widgets/WidgetAiscript.vue
@@ -37,10 +37,12 @@ const name = 'aiscript';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
script: {
type: 'string',
+ label: i18n.ts.script,
multiline: true,
default: '(1 + 1)',
hidden: true,
diff --git a/packages/frontend/src/widgets/WidgetAiscriptApp.vue b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
index 18acd966fd..9ed441b77c 100644
--- a/packages/frontend/src/widgets/WidgetAiscriptApp.vue
+++ b/packages/frontend/src/widgets/WidgetAiscriptApp.vue
@@ -24,6 +24,7 @@ import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
import * as os from '@/os.js';
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
import { $i } from '@/i.js';
+import { i18n } from '@/i18n.js';
import MkAsUi from '@/components/MkAsUi.vue';
import MkContainer from '@/components/MkContainer.vue';
import { registerAsUiLib } from '@/aiscript/ui.js';
@@ -33,11 +34,14 @@ const name = 'aiscriptApp';
const widgetPropsDef = {
script: {
type: 'string',
+ label: i18n.ts.script,
multiline: true,
+ manualSave: true,
default: '',
},
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue
new file mode 100644
index 0000000000..2b714c2f6c
--- /dev/null
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.user.vue
@@ -0,0 +1,86 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root">
+ <MkA :to="userPage(item.user)" style="overflow: clip;">
+ <MkUserCardMini :user="item.user" :withChart="false" style="text-overflow: ellipsis; background: inherit; border-radius: unset;">
+ <template #sub>
+ <span>{{ countdownDate }}</span>
+ <span> / </span>
+ <span class="_monospace">@{{ acct(item.user) }}</span>
+ </template>
+ </MkUserCardMini>
+ </MkA>
+ <button v-tooltip.noDelay="i18n.ts.note" class="_button" :class="$style.post" @click="os.post({initialText: `@${item.user.username}${item.user.host ? `@${item.user.host}` : ''} `})">
+ <i class="ti-fw ti ti-confetti" :class="$style.postIcon"></i>
+ </button>
+</div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkUserCardMini from '@/components/MkUserCardMini.vue';
+import * as os from '@/os.js';
+import { i18n } from '@/i18n.js';
+import { useLowresTime } from '@/composables/use-lowres-time.js';
+import { userPage, acct } from '@/filters/user.js';
+
+const props = defineProps<{
+ item: Misskey.entities.UsersGetFollowingUsersByBirthdayResponse[number];
+}>();
+
+const now = useLowresTime();
+const nowDate = computed(() => {
+ const date = new Date(now.value);
+ date.setHours(0, 0, 0, 0);
+ return date;
+});
+const birthdayDate = computed(() => {
+ const [year, month, day] = props.item.birthday.split('-').map((v) => parseInt(v, 10));
+ return new Date(year, month - 1, day, 0, 0, 0, 0);
+});
+
+const countdownDate = computed(() => {
+ const days = Math.floor((birthdayDate.value.getTime() - nowDate.value.getTime()) / (1000 * 60 * 60 * 24));
+ if (days === 0) {
+ return i18n.ts.today;
+ } else if (days > 0) {
+ return i18n.tsx._timeIn.days({ n: days });
+ } else {
+ return i18n.tsx._ago.daysAgo({ n: Math.abs(days) });
+ }
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ box-sizing: border-box;
+ display: grid;
+ align-items: center;
+ grid-template-columns: auto 56px;
+}
+
+.post {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 40px;
+ width: 40px;
+ margin-right: 16px;
+ aspect-ratio: 1/1;
+ border-radius: 100%;
+ background: linear-gradient(90deg, var(--MI_THEME-buttonGradateA), var(--MI_THEME-buttonGradateB));
+
+ &:hover {
+ background: linear-gradient(90deg, hsl(from var(--MI_THEME-accent) h s calc(l + 5)), hsl(from var(--MI_THEME-accent) h s calc(l + 5)));
+ }
+}
+
+.postIcon {
+ color: var(--MI_THEME-fgOnAccent);
+}
+</style>
diff --git a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
index d1991cd70a..cf9c5a3d35 100644
--- a/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
+++ b/packages/frontend/src/widgets/WidgetBirthdayFollowings.vue
@@ -4,42 +4,75 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
+<MkContainer :style="`height: ${widgetProps.height}px;`" :showHeader="widgetProps.showHeader" :scrollable="true" class="mkw-bdayfollowings">
<template #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
- <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="actualFetch()"><i class="ti ti-refresh"></i></button></template>
+ <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="fetch"><i class="ti ti-refresh"></i></button></template>
- <div :class="$style.bdayFRoot">
- <MkLoading v-if="fetching"/>
- <div v-else-if="users.length > 0" :class="$style.bdayFGrid">
- <MkAvatar v-for="user in users" :key="user.id" :user="user.followee!" link preview></MkAvatar>
+ <MkPagination v-slot="{ items }" :paginator="birthdayUsersPaginator">
+ <div>
+ <template v-for="(user, i) in items" :key="user.id">
+ <div
+ v-if="i > 0 && isSeparatorNeeded(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)"
+ >
+ <div :class="$style.date">
+ <span><i class="ti ti-chevron-up"></i> {{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.prevText }}</span>
+ <span style="height: 1em; width: 1px; background: var(--MI_THEME-divider);"></span>
+ <span>{{ getSeparatorInfo(birthdayUsersPaginator.items.value[i - 1].birthday, user.birthday)?.nextText }} <i class="ti ti-chevron-down"></i></span>
+ </div>
+ <XUser :class="$style.user" :item="user" />
+ </div>
+ <XUser v-else :class="$style.user" :item="user" />
+ </template>
</div>
- <div v-else :class="$style.bdayFFallback">
- <MkResult type="empty"/>
- </div>
- </div>
+ </MkPagination>
</MkContainer>
</template>
<script lang="ts" setup>
-import { ref } from 'vue';
-import * as Misskey from 'misskey-js';
-import { useInterval } from '@@/js/use-interval.js';
+import { computed, markRaw, ref, watch } from 'vue';
+import { useLowresTime } from '@/composables/use-lowres-time.js';
+import { isSeparatorNeeded, getSeparatorInfo } from '@/utility/timeline-date-separate.js';
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
-import { misskeyApi } from '@/utility/misskey-api.js';
+import MkPagination from '@/components/MkPagination.vue';
+import XUser from './WidgetBirthdayFollowings.user.vue';
import { i18n } from '@/i18n.js';
-import { $i } from '@/i.js';
+import { Paginator } from '@/utility/paginator.js';
-const name = i18n.ts._widgets.birthdayFollowings;
+const name = 'birthdayFollowings';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
+ height: {
+ type: 'number' as const,
+ label: i18n.ts._widgetOptions.height,
+ default: 300,
+ },
+ period: {
+ type: 'radio' as const,
+ label: i18n.ts._widgetOptions._birthdayFollowings.period,
+ default: '3day',
+ options: [{
+ value: 'today' as const,
+ label: i18n.ts.today,
+ }, {
+ value: '3day' as const,
+ label: i18n.tsx.dayX({ day: 3 }),
+ }, {
+ value: 'week' as const,
+ label: i18n.ts.oneWeek,
+ }, {
+ value: 'month' as const,
+ label: i18n.ts.oneMonth,
+ }],
+ },
} satisfies FormWithDefault;
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
@@ -47,62 +80,84 @@ type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
-const { widgetProps, configure } = useWidgetPropsManager(name,
+const { widgetProps, configure } = useWidgetPropsManager(
+ name,
widgetPropsDef,
props,
emit,
);
-const users = ref<Misskey.Endpoints['users/following']['res']>([]);
-const fetching = ref(true);
-let lastFetchedAt = '1970-01-01';
+const now = useLowresTime();
+const nextDay = new Date();
+nextDay.setHours(24, 0, 0, 0);
+let nextDayMidnightTime = nextDay.getTime();
-const fetch = () => {
- if (!$i) {
- users.value = [];
- fetching.value = false;
- return;
+const begin = ref<Date>(new Date());
+const end = computed(() => {
+ switch (widgetProps.period) {
+ case '3day':
+ return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 3);
+ case 'week':
+ return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 7);
+ case 'month':
+ return new Date(begin.value.getTime() + 1000 * 60 * 60 * 24 * 30);
+ default:
+ return begin.value;
}
+});
+
+const birthdayUsersPaginator = markRaw(new Paginator('users/get-following-users-by-birthday', {
+ limit: 18,
+ offsetMode: true,
+ computedParams: computed(() => {
+ if (widgetProps.period === 'today') {
+ return {
+ birthday: {
+ month: begin.value.getMonth() + 1,
+ day: begin.value.getDate(),
+ },
+ };
+ } else {
+ return {
+ birthday: {
+ begin: {
+ month: begin.value.getMonth() + 1,
+ day: begin.value.getDate(),
+ },
+ end: {
+ month: end.value.getMonth() + 1,
+ day: end.value.getDate(),
+ },
+ },
+ };
+ }
+ }),
+}));
- const lfAtD = new Date(lastFetchedAt);
- lfAtD.setHours(0, 0, 0, 0);
+function fetch() {
const now = new Date();
- now.setHours(0, 0, 0, 0);
+ begin.value = now;
+}
- if (now > lfAtD) {
- actualFetch();
+const UPDATE_INTERVAL = 1000 * 60;
+let nextDayTimer: number | null = null;
- lastFetchedAt = now.toISOString();
- }
-};
+watch(now, (to) => {
+ // 次回更新までに日付が変わる場合、日付が変わった直後に強制的に更新するタイマーをセットする
+ if (nextDayMidnightTime - to <= UPDATE_INTERVAL) {
+ if (nextDayTimer != null) {
+ window.clearTimeout(nextDayTimer);
+ nextDayTimer = null;
+ }
-function actualFetch() {
- if ($i == null) {
- users.value = [];
- fetching.value = false;
- return;
+ nextDayTimer = window.setTimeout(() => {
+ fetch();
+ nextDay.setHours(24, 0, 0, 0);
+ nextDayMidnightTime = nextDay.getTime();
+ nextDayTimer = null;
+ }, nextDayMidnightTime - to);
}
-
- const now = new Date();
- now.setHours(0, 0, 0, 0);
- fetching.value = true;
- misskeyApi('users/following', {
- limit: 18,
- birthday: `${now.getFullYear().toString().padStart(4, '0')}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`,
- userId: $i.id,
- }).then(res => {
- users.value = res;
- window.setTimeout(() => {
- // 早すぎるとチカチカする
- fetching.value = false;
- }, 100);
- });
-}
-
-useInterval(fetch, 1000 * 60, {
- immediate: true,
- afterMounted: true,
-});
+}, { immediate: true });
defineExpose<WidgetComponentExpose>({
name,
@@ -112,24 +167,24 @@ defineExpose<WidgetComponentExpose>({
</script>
<style lang="scss" module>
-.bdayFRoot {
- overflow: hidden;
- min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--MI-margin) * 2));
+.root {
+ container-type: inline-size;
+ background: var(--MI_THEME-panel);
}
-.bdayFGrid {
- display: grid;
- grid-template-columns: repeat(6, 42px);
- grid-template-rows: repeat(3, 42px);
- place-content: center;
- gap: 8px;
- margin: var(--MI-margin) auto;
+
+.user {
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
}
-.bdayFFallback {
- height: 100%;
+.date {
display: flex;
- flex-direction: column;
- justify-content: center;
+ font-size: 85%;
align-items: center;
+ justify-content: center;
+ gap: 1em;
+ opacity: 0.75;
+ padding: 8px 8px;
+ margin: 0 auto;
+ border-bottom: solid 0.5px var(--MI_THEME-divider);
}
</style>
diff --git a/packages/frontend/src/widgets/WidgetButton.vue b/packages/frontend/src/widgets/WidgetButton.vue
index f8ae03c5fd..223901390e 100644
--- a/packages/frontend/src/widgets/WidgetButton.vue
+++ b/packages/frontend/src/widgets/WidgetButton.vue
@@ -20,22 +20,26 @@ import * as os from '@/os.js';
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
import { $i } from '@/i.js';
import MkButton from '@/components/MkButton.vue';
+import { i18n } from '@/i18n.js';
const name = 'button';
const widgetPropsDef = {
label: {
type: 'string',
+ label: i18n.ts.label,
default: 'BUTTON',
},
colored: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._button.colored,
default: true,
},
script: {
type: 'string',
+ label: i18n.ts.script,
multiline: true,
- default: 'Mk:dialog("hello" "world")',
+ default: 'Mk:dialog("hello", "world")',
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetCalendar.vue b/packages/frontend/src/widgets/WidgetCalendar.vue
index f2321ca9fa..28336cbe09 100644
--- a/packages/frontend/src/widgets/WidgetCalendar.vue
+++ b/packages/frontend/src/widgets/WidgetCalendar.vue
@@ -50,6 +50,7 @@ const name = 'calendar';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetChat.vue b/packages/frontend/src/widgets/WidgetChat.vue
index 8fee7f00f6..06d8f741f4 100644
--- a/packages/frontend/src/widgets/WidgetChat.vue
+++ b/packages/frontend/src/widgets/WidgetChat.vue
@@ -29,6 +29,7 @@ const name = 'chat';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetClicker.vue b/packages/frontend/src/widgets/WidgetClicker.vue
index 282a1a6d93..614e7c7fe5 100644
--- a/packages/frontend/src/widgets/WidgetClicker.vue
+++ b/packages/frontend/src/widgets/WidgetClicker.vue
@@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
+import { i18n } from '@/i18n.js';
import MkContainer from '@/components/MkContainer.vue';
import MkClickerGame from '@/components/MkClickerGame.vue';
@@ -23,6 +24,7 @@ const name = 'clicker';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: false,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetClock.vue b/packages/frontend/src/widgets/WidgetClock.vue
index 7aa69a39b5..80f312e7c4 100644
--- a/packages/frontend/src/widgets/WidgetClock.vue
+++ b/packages/frontend/src/widgets/WidgetClock.vue
@@ -44,10 +44,12 @@ const name = 'clock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
size: {
type: 'radio',
+ label: i18n.ts._widgetOptions._clock.size,
default: 'medium',
options: [{
value: 'small' as const,
@@ -62,79 +64,89 @@ const widgetPropsDef = {
},
thickness: {
type: 'radio',
+ label: i18n.ts._widgetOptions._clock.thickness,
default: 0.2,
options: [{
value: 0.1 as const,
- label: 'thin',
+ label: i18n.ts._widgetOptions._clock.thicknessThin,
}, {
value: 0.2 as const,
- label: 'medium',
+ label: i18n.ts._widgetOptions._clock.thicknessMedium,
}, {
value: 0.3 as const,
- label: 'thick',
+ label: i18n.ts._widgetOptions._clock.thicknessThick,
}],
},
graduations: {
type: 'radio',
+ label: i18n.ts._widgetOptions._clock.graduations,
default: 'numbers',
options: [{
value: 'none' as const,
- label: 'None',
+ label: i18n.ts.none,
}, {
value: 'dots' as const,
- label: 'Dots',
+ label: i18n.ts._widgetOptions._clock.graduationDots,
}, {
value: 'numbers' as const,
- label: 'Numbers',
- }],
+ label: i18n.ts._widgetOptions._clock.graduationArabic,
+ }, /*, {
+ value: 'roman' as const,
+ label: i18n.ts._widgetOptions._clock.graduationRoman,
+ }*/],
},
fadeGraduations: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._clock.fadeGraduations,
default: true,
},
sAnimation: {
type: 'radio',
+ label: i18n.ts._widgetOptions._clock.sAnimation,
default: 'elastic',
options: [{
value: 'none' as const,
- label: 'None',
+ label: i18n.ts.none,
}, {
value: 'elastic' as const,
- label: 'Elastic',
+ label: i18n.ts._widgetOptions._clock.sAnimationElastic,
}, {
value: 'easeOut' as const,
- label: 'Ease out',
+ label: i18n.ts._widgetOptions._clock.sAnimationEaseOut,
}],
},
twentyFour: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._clock.twentyFour,
default: false,
},
label: {
type: 'radio',
+ label: i18n.ts.label,
default: 'none',
options: [{
value: 'none' as const,
- label: 'None',
+ label: i18n.ts.none,
}, {
value: 'time' as const,
- label: 'Time',
+ label: i18n.ts._widgetOptions._clock.labelTime,
}, {
value: 'tz' as const,
- label: 'TZ',
+ label: i18n.ts._widgetOptions._clock.labelTz,
}, {
value: 'timeAndTz' as const,
- label: 'Time + TZ',
+ label: i18n.ts._widgetOptions._clock.labelTimeAndTz,
}],
},
timezone: {
type: 'enum',
+ label: i18n.ts._widgetOptions._clock.timezone,
default: null,
enum: [...timezones.map((tz) => ({
label: tz.name,
value: tz.name.toLowerCase(),
})), {
- label: '(auto)',
+ label: i18n.ts.auto,
value: null,
}],
},
diff --git a/packages/frontend/src/widgets/WidgetDigitalClock.vue b/packages/frontend/src/widgets/WidgetDigitalClock.vue
index b8cbc6429c..d50d4aef62 100644
--- a/packages/frontend/src/widgets/WidgetDigitalClock.vue
+++ b/packages/frontend/src/widgets/WidgetDigitalClock.vue
@@ -19,6 +19,7 @@ import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import { timezones } from '@/utility/timezones.js';
+import { i18n } from '@/i18n.js';
import MkDigitalClock from '@/components/MkDigitalClock.vue';
const name = 'digitalClock';
@@ -26,29 +27,34 @@ const name = 'digitalClock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
fontSize: {
type: 'number',
+ label: i18n.ts.fontSize,
default: 1.5,
step: 0.1,
},
showMs: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._clock.showMs,
default: true,
},
showLabel: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._clock.showLabel,
default: true,
},
timezone: {
type: 'enum',
+ label: i18n.ts._widgetOptions._clock.timezone,
default: null,
enum: [...timezones.map((tz) => ({
label: tz.name,
value: tz.name.toLowerCase(),
})), {
- label: '(auto)',
+ label: i18n.ts.auto,
value: null,
}],
},
diff --git a/packages/frontend/src/widgets/WidgetFederation.vue b/packages/frontend/src/widgets/WidgetFederation.vue
index 3e880af03b..cf2c2f0ab7 100644
--- a/packages/frontend/src/widgets/WidgetFederation.vue
+++ b/packages/frontend/src/widgets/WidgetFederation.vue
@@ -43,6 +43,7 @@ const name = 'federation';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;
@@ -62,7 +63,7 @@ const instances = ref<Misskey.entities.FederationInstance[]>([]);
const charts = ref<Misskey.entities.ChartsInstanceResponse[]>([]);
const fetching = ref(true);
-const fetch = async () => {
+async function fetchInstances() {
const fetchedInstances = await misskeyApi('federation/instances', {
sort: '+latestRequestReceivedAt',
limit: 5,
@@ -71,14 +72,14 @@ const fetch = async () => {
instances.value = fetchedInstances;
charts.value = fetchedCharts;
fetching.value = false;
-};
+}
-useInterval(fetch, 1000 * 60, {
+useInterval(fetchInstances, 1000 * 60, {
immediate: true,
afterMounted: true,
});
-function getInstanceIcon(instance): string {
+function getInstanceIcon(instance: Misskey.entities.FederationInstance): string {
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
}
diff --git a/packages/frontend/src/widgets/WidgetInstanceCloud.vue b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
index 8053dd43cf..c1e864bdb3 100644
--- a/packages/frontend/src/widgets/WidgetInstanceCloud.vue
+++ b/packages/frontend/src/widgets/WidgetInstanceCloud.vue
@@ -29,12 +29,14 @@ import MkTagCloud from '@/components/MkTagCloud.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
+import { i18n } from '@/i18n.js';
const name = 'instanceCloud';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;
@@ -53,7 +55,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
const cloud = useTemplateRef('cloud');
const activeInstances = shallowRef<Misskey.entities.FederationInstance[] | null>(null);
-function onInstanceClick(i) {
+function onInstanceClick(i: Misskey.entities.FederationInstance) {
os.pageWindow(`/instance-info/${i.host}`);
}
@@ -70,7 +72,7 @@ useInterval(() => {
afterMounted: true,
});
-function getInstanceIcon(instance): string {
+function getInstanceIcon(instance: Misskey.entities.FederationInstance): string {
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
}
diff --git a/packages/frontend/src/widgets/WidgetJobQueue.vue b/packages/frontend/src/widgets/WidgetJobQueue.vue
index fba7d82062..1727ea9b74 100644
--- a/packages/frontend/src/widgets/WidgetJobQueue.vue
+++ b/packages/frontend/src/widgets/WidgetJobQueue.vue
@@ -52,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted, reactive, ref } from 'vue';
+import * as Misskey from 'misskey-js';
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
@@ -61,16 +62,19 @@ import * as sound from '@/utility/sound.js';
import { deepClone } from '@/utility/clone.js';
import { prefer } from '@/preferences.js';
import { genId } from '@/utility/id.js';
+import { i18n } from '@/i18n.js';
const name = 'jobQueue';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
sound: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._jobQueue.sound,
default: false,
},
} satisfies FormWithDefault;
@@ -113,20 +117,22 @@ if (prefer.s['sound.masterVolume']) {
}
for (const domain of ['inbox', 'deliver']) {
- prev[domain] = deepClone(current[domain]);
+ const d = domain as 'inbox' | 'deliver';
+ prev[d] = deepClone(current[d]);
}
-const onStats = (stats) => {
+const onStats = (stats: Misskey.entities.QueueStats) => {
for (const domain of ['inbox', 'deliver']) {
- prev[domain] = deepClone(current[domain]);
- current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
- current[domain].active = stats[domain].active;
- current[domain].waiting = stats[domain].waiting;
- current[domain].delayed = stats[domain].delayed;
+ const d = domain as 'inbox' | 'deliver';
+ prev[d] = deepClone(current[d]);
+ current[d].activeSincePrevTick = stats[d].activeSincePrevTick;
+ current[d].active = stats[d].active;
+ current[d].waiting = stats[d].waiting;
+ current[d].delayed = stats[d].delayed;
- if (current[domain].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) {
+ if (current[d].waiting > 0 && widgetProps.sound && jammedAudioBuffer.value && !jammedSoundNodePlaying.value) {
const soundNode = sound.createSourceNode(jammedAudioBuffer.value, {}).soundSource;
- if (soundNode) {
+ if (soundNode != null) {
jammedSoundNodePlaying.value = true;
soundNode.onended = () => jammedSoundNodePlaying.value = false;
soundNode.start();
@@ -135,7 +141,7 @@ const onStats = (stats) => {
}
};
-const onStatsLog = (statsLog) => {
+const onStatsLog = (statsLog: Misskey.entities.QueueStatsLog) => {
for (const stats of [...statsLog].reverse()) {
onStats(stats);
}
diff --git a/packages/frontend/src/widgets/WidgetMemo.vue b/packages/frontend/src/widgets/WidgetMemo.vue
index 2beca8c43a..fd5b56991e 100644
--- a/packages/frontend/src/widgets/WidgetMemo.vue
+++ b/packages/frontend/src/widgets/WidgetMemo.vue
@@ -29,10 +29,12 @@ const name = 'memo';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
height: {
type: 'number',
+ label: i18n.ts.height,
default: 100,
},
} satisfies FormWithDefault;
@@ -50,7 +52,7 @@ const { widgetProps, configure } = useWidgetPropsManager(name,
const text = ref<string | null>(store.s.memo);
const changed = ref(false);
-let timeoutId;
+let timeoutId: number | null = null;
const saveMemo = () => {
store.set('memo', text.value);
@@ -59,7 +61,7 @@ const saveMemo = () => {
const onChange = () => {
changed.value = true;
- window.clearTimeout(timeoutId);
+ if (timeoutId != null) window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(saveMemo, 1000);
};
diff --git a/packages/frontend/src/widgets/WidgetNotifications.vue b/packages/frontend/src/widgets/WidgetNotifications.vue
index b588bcb029..48a29d6145 100644
--- a/packages/frontend/src/widgets/WidgetNotifications.vue
+++ b/packages/frontend/src/widgets/WidgetNotifications.vue
@@ -31,10 +31,12 @@ const name = 'notifications';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
height: {
type: 'number',
+ label: i18n.ts.height,
default: 300,
},
excludeTypes: {
diff --git a/packages/frontend/src/widgets/WidgetOnlineUsers.vue b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
index 9fd8c013d1..b0bb4b47b1 100644
--- a/packages/frontend/src/widgets/WidgetOnlineUsers.vue
+++ b/packages/frontend/src/widgets/WidgetOnlineUsers.vue
@@ -28,6 +28,7 @@ const name = 'onlineUsers';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: true,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetPhotos.vue b/packages/frontend/src/widgets/WidgetPhotos.vue
index e89a642b99..670e764c8c 100644
--- a/packages/frontend/src/widgets/WidgetPhotos.vue
+++ b/packages/frontend/src/widgets/WidgetPhotos.vue
@@ -39,10 +39,12 @@ const name = 'photos';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;
@@ -62,12 +64,12 @@ const connection = useStream().useChannel('main');
const images = ref<Misskey.entities.DriveFile[]>([]);
const fetching = ref(true);
-const onDriveFileCreated = (file) => {
+function onDriveFileCreated(file: Misskey.entities.DriveFile) {
if (/^image\/.+$/.test(file.type)) {
images.value.unshift(file);
if (images.value.length > 9) images.value.pop();
}
-};
+}
const thumbnail = (image: Misskey.entities.DriveFile): string => {
return prefer.s.disableShowingAnimatedImages
diff --git a/packages/frontend/src/widgets/WidgetRss.vue b/packages/frontend/src/widgets/WidgetRss.vue
index e5499aa0da..2495c5a6e9 100644
--- a/packages/frontend/src/widgets/WidgetRss.vue
+++ b/packages/frontend/src/widgets/WidgetRss.vue
@@ -25,28 +25,33 @@ import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import { useWidgetPropsManager } from './widget.js';
+import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
-import { i18n } from '@/i18n.js';
const name = 'rss';
const widgetPropsDef = {
url: {
type: 'string',
+ label: i18n.ts._widgetOptions._rss.url,
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+ manualSave: true,
},
refreshIntervalSec: {
type: 'number',
+ label: i18n.ts._widgetOptions._rss.refreshIntervalSec,
default: 60,
},
maxEntries: {
type: 'number',
+ label: i18n.ts._widgetOptions._rss.maxEntries,
default: 15,
},
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;
@@ -68,7 +73,7 @@ const fetching = ref(true);
const fetchEndpoint = computed(() => {
const url = new URL('/api/fetch-rss', base);
url.searchParams.set('url', widgetProps.url);
- return url;
+ return url.toString();
});
const intervalClear = ref<(() => void) | undefined>();
@@ -83,7 +88,7 @@ const tick = () => {
});
};
-watch(() => fetchEndpoint, tick);
+watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();
diff --git a/packages/frontend/src/widgets/WidgetRssTicker.vue b/packages/frontend/src/widgets/WidgetRssTicker.vue
index 95f82f7d7b..9ed21e6d00 100644
--- a/packages/frontend/src/widgets/WidgetRssTicker.vue
+++ b/packages/frontend/src/widgets/WidgetRssTicker.vue
@@ -35,6 +35,7 @@ import MkMarqueeText from '@/components/MkMarqueeText.vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { shuffle } from '@/utility/shuffle.js';
+import { i18n } from '@/i18n.js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
@@ -43,22 +44,28 @@ const name = 'rssTicker';
const widgetPropsDef = {
url: {
type: 'string',
+ label: i18n.ts._widgetOptions._rss.url,
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+ manualSave: true,
},
shuffle: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._rssTicker.shuffle,
default: true,
},
refreshIntervalSec: {
type: 'number',
+ label: i18n.ts._widgetOptions._rss.refreshIntervalSec,
default: 60,
},
maxEntries: {
type: 'number',
+ label: i18n.ts._widgetOptions._rss.maxEntries,
default: 15,
},
duration: {
type: 'range',
+ label: i18n.ts._widgetOptions._rssTicker.duration,
default: 70,
step: 1,
min: 5,
@@ -66,14 +73,17 @@ const widgetPropsDef = {
},
reverse: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._rssTicker.reverse,
default: false,
},
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: false,
},
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;
@@ -119,7 +129,7 @@ const tick = () => {
});
};
-watch(() => fetchEndpoint, tick);
+watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();
diff --git a/packages/frontend/src/widgets/WidgetSlideshow.vue b/packages/frontend/src/widgets/WidgetSlideshow.vue
index 240210c1fb..b812c89e08 100644
--- a/packages/frontend/src/widgets/WidgetSlideshow.vue
+++ b/packages/frontend/src/widgets/WidgetSlideshow.vue
@@ -33,6 +33,7 @@ const name = 'slideshow';
const widgetPropsDef = {
height: {
type: 'number',
+ label: i18n.ts._widgetOptions.height,
default: 300,
},
folderId: {
@@ -95,11 +96,11 @@ const fetch = () => {
};
const choose = () => {
- selectDriveFolder(null).then(folder => {
- if (folder[0] == null) {
+ selectDriveFolder(null).then(({ folders, canceled }) => {
+ if (canceled || folders[0] == null) {
return;
}
- widgetProps.folderId = folder[0].id;
+ widgetProps.folderId = folders[0].id;
save();
fetch();
});
diff --git a/packages/frontend/src/widgets/WidgetTimeline.vue b/packages/frontend/src/widgets/WidgetTimeline.vue
index 6c775fd98c..83b8e7ccbc 100644
--- a/packages/frontend/src/widgets/WidgetTimeline.vue
+++ b/packages/frontend/src/widgets/WidgetTimeline.vue
@@ -41,13 +41,13 @@ import * as Misskey from 'misskey-js';
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
+import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import MkContainer from '@/components/MkContainer.vue';
import MkStreamingNotesTimeline from '@/components/MkStreamingNotesTimeline.vue';
import { i18n } from '@/i18n.js';
import { availableBasicTimelines, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass, basicTimelineTypes } from '@/timelines.js';
-import type { MenuItem } from '@/types/menu.js';
const name = 'timeline';
@@ -93,12 +93,12 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name,
const menuOpened = ref(false);
const headerTitle = computed<string>(() => {
- if (widgetProps.src === 'list' && widgetProps.list != null) {
- return widgetProps.list.name;
- } else if (widgetProps.src === 'antenna' && widgetProps.antenna != null) {
- return widgetProps.antenna.name;
+ if (widgetProps.src === 'list') {
+ return widgetProps.list != null ? widgetProps.list.name : '?';
+ } else if (widgetProps.src === 'antenna') {
+ return widgetProps.antenna != null ? widgetProps.antenna.name : '?';
} else {
- return i18n.ts._timelines[widgetProps.src];
+ return i18n.ts._timelines[widgetProps.src] ?? '?';
}
});
@@ -107,7 +107,7 @@ const setSrc = (src: TlSrc) => {
save();
};
-const choose = async (ev: MouseEvent) => {
+const choose = async (ev: PointerEvent) => {
menuOpened.value = true;
const [antennas, lists] = await Promise.all([
misskeyApi('antennas/list'),
diff --git a/packages/frontend/src/widgets/WidgetTrends.vue b/packages/frontend/src/widgets/WidgetTrends.vue
index dcb900b0c9..498129305b 100644
--- a/packages/frontend/src/widgets/WidgetTrends.vue
+++ b/packages/frontend/src/widgets/WidgetTrends.vue
@@ -36,11 +36,12 @@ import { misskeyApiGet } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
-const name = 'hashtags';
+const name = 'trends';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetUnixClock.vue b/packages/frontend/src/widgets/WidgetUnixClock.vue
index 226a4c73aa..1bb361664f 100644
--- a/packages/frontend/src/widgets/WidgetUnixClock.vue
+++ b/packages/frontend/src/widgets/WidgetUnixClock.vue
@@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import { useWidgetPropsManager } from './widget.js';
+import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
@@ -26,19 +27,23 @@ const name = 'unixClock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
fontSize: {
type: 'number',
+ label: i18n.ts.fontSize,
default: 1.5,
step: 0.1,
},
showMs: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._clock.showMs,
default: true,
},
showLabel: {
type: 'boolean',
+ label: i18n.ts._widgetOptions._clock.showLabel,
default: true,
},
} satisfies FormWithDefault;
diff --git a/packages/frontend/src/widgets/WidgetUserList.vue b/packages/frontend/src/widgets/WidgetUserList.vue
index 9e914fa648..3fc46f303f 100644
--- a/packages/frontend/src/widgets/WidgetUserList.vue
+++ b/packages/frontend/src/widgets/WidgetUserList.vue
@@ -41,6 +41,7 @@ const name = 'userList';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
listId: {
diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts
index aea810d1ea..79bae68d71 100644
--- a/packages/frontend/src/widgets/index.ts
+++ b/packages/frontend/src/widgets/index.ts
@@ -42,7 +42,7 @@ export default function(app: App) {
export const federationWidgets = [
'federation',
'instanceCloud',
-];
+] as const;
export const widgets = [
'profile',
@@ -74,4 +74,6 @@ export const widgets = [
'chat',
...federationWidgets,
-];
+] as const;
+
+export type WidgetName = typeof widgets[number];
diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue
index f52b6fd12e..5d93c6a982 100644
--- a/packages/frontend/src/widgets/server-metric/index.vue
+++ b/packages/frontend/src/widgets/server-metric/index.vue
@@ -40,10 +40,12 @@ const name = 'serverMetric';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.showHeader,
default: true,
},
transparent: {
type: 'boolean',
+ label: i18n.ts._widgetOptions.transparent,
default: false,
},
view: {
diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts
index c5ca7ac26c..bfb724ff72 100644
--- a/packages/frontend/src/widgets/widget.ts
+++ b/packages/frontend/src/widgets/widget.ts
@@ -3,12 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { reactive, watch } from 'vue';
-import type { Reactive } from 'vue';
+import { defineAsyncComponent, reactive, watch } from 'vue';
import { throttle } from 'throttle-debounce';
+import type { Reactive } from 'vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
+import { getDefaultFormValues } from '@/utility/form.js';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
+import type { WidgetName } from './index.js';
export type Widget<P extends Record<string, unknown>> = {
id: string;
@@ -20,7 +22,7 @@ export type WidgetComponentProps<P extends Record<string, unknown>> = {
};
export type WidgetComponentEmits<P extends Record<string, unknown>> = {
- (ev: 'updateProps', props: P);
+ (ev: 'updateProps', props: P): void;
};
export type WidgetComponentExpose = {
@@ -30,7 +32,7 @@ export type WidgetComponentExpose = {
};
export const useWidgetPropsManager = <F extends FormWithDefault>(
- name: string,
+ name: WidgetName,
propsDef: F,
props: Readonly<WidgetComponentProps<GetFormResultType<F>>>,
emit: WidgetComponentEmits<GetFormResultType<F>>,
@@ -39,19 +41,23 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
save: () => void;
configure: () => void;
} => {
- const widgetProps = reactive<GetFormResultType<F>>((props.widget ? deepClone(props.widget.data) : {}) as GetFormResultType<F>);
-
- const mergeProps = () => {
- for (const prop of Object.keys(propsDef)) {
- if (typeof widgetProps[prop] === 'undefined') {
- widgetProps[prop] = propsDef[prop].default;
+ const widgetProps = reactive((() => {
+ const np = getDefaultFormValues(propsDef);
+ if (props.widget?.data != null) {
+ for (const key of Object.keys(props.widget.data) as (keyof F)[]) {
+ np[key] = props.widget.data[key] as GetFormResultType<F>[typeof key];
}
}
- };
+ return np;
+ })());
- watch(widgetProps, () => {
- mergeProps();
- }, { deep: true, immediate: true });
+ watch(() => props.widget?.data, (to) => {
+ if (to != null) {
+ for (const key of Object.keys(propsDef)) {
+ (widgetProps as any)[key] = to[key];
+ }
+ }
+ }, { deep: true });
const save = throttle(3000, () => {
emit('updateProps', widgetProps as GetFormResultType<F>);
@@ -60,13 +66,38 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
const configure = async () => {
const form = deepClone(propsDef);
for (const item of Object.keys(form)) {
- form[item].default = widgetProps[item];
+ form[item].default = (widgetProps as any)[item];
+ }
+
+ const res = await new Promise<{
+ canceled: false;
+ result: GetFormResultType<F>;
+ } | {
+ canceled: true;
+ }>((resolve) => {
+ const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWidgetSettingsDialog.vue')), {
+ widgetName: name,
+ form: form,
+ currentSettings: widgetProps,
+ }, {
+ saved: (newProps) => {
+ resolve({ canceled: false, result: newProps as GetFormResultType<F> });
+ },
+ canceled: () => {
+ resolve({ canceled: true });
+ },
+ closed: () => {
+ dispose();
+ },
+ });
+ });
+
+ if (res.canceled) {
+ return;
}
- const { canceled, result } = await os.form(name, form);
- if (canceled) return;
- for (const key of Object.keys(result)) {
- widgetProps[key] = result[key];
+ for (const key of Object.keys(res.result)) {
+ (widgetProps as any)[key] = res.result[key];
}
save();