summaryrefslogtreecommitdiff
path: root/packages/frontend/src/widgets
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/widgets')
-rw-r--r--packages/frontend/src/widgets/activity.calendar.vue81
-rw-r--r--packages/frontend/src/widgets/activity.chart.vue92
-rw-r--r--packages/frontend/src/widgets/activity.vue90
-rw-r--r--packages/frontend/src/widgets/aichan.vue74
-rw-r--r--packages/frontend/src/widgets/aiscript.vue175
-rw-r--r--packages/frontend/src/widgets/button.vue103
-rw-r--r--packages/frontend/src/widgets/calendar.vue213
-rw-r--r--packages/frontend/src/widgets/clock.vue203
-rw-r--r--packages/frontend/src/widgets/digital-clock.vue92
-rw-r--r--packages/frontend/src/widgets/federation.vue147
-rw-r--r--packages/frontend/src/widgets/index.ts53
-rw-r--r--packages/frontend/src/widgets/instance-cloud.vue81
-rw-r--r--packages/frontend/src/widgets/job-queue.vue197
-rw-r--r--packages/frontend/src/widgets/memo.vue111
-rw-r--r--packages/frontend/src/widgets/notifications.vue70
-rw-r--r--packages/frontend/src/widgets/online-users.vue78
-rw-r--r--packages/frontend/src/widgets/photos.vue123
-rw-r--r--packages/frontend/src/widgets/post-form.vue35
-rw-r--r--packages/frontend/src/widgets/rss-ticker.vue152
-rw-r--r--packages/frontend/src/widgets/rss.vue96
-rw-r--r--packages/frontend/src/widgets/server-metric/cpu-mem.vue167
-rw-r--r--packages/frontend/src/widgets/server-metric/cpu.vue65
-rw-r--r--packages/frontend/src/widgets/server-metric/disk.vue57
-rw-r--r--packages/frontend/src/widgets/server-metric/index.vue87
-rw-r--r--packages/frontend/src/widgets/server-metric/mem.vue73
-rw-r--r--packages/frontend/src/widgets/server-metric/net.vue140
-rw-r--r--packages/frontend/src/widgets/server-metric/pie.vue52
-rw-r--r--packages/frontend/src/widgets/slideshow.vue159
-rw-r--r--packages/frontend/src/widgets/timeline.vue129
-rw-r--r--packages/frontend/src/widgets/trends.vue120
-rw-r--r--packages/frontend/src/widgets/unix-clock.vue116
-rw-r--r--packages/frontend/src/widgets/user-list.vue136
-rw-r--r--packages/frontend/src/widgets/widget.ts73
33 files changed, 3640 insertions, 0 deletions
diff --git a/packages/frontend/src/widgets/activity.calendar.vue b/packages/frontend/src/widgets/activity.calendar.vue
new file mode 100644
index 0000000000..84f6af1c13
--- /dev/null
+++ b/packages/frontend/src/widgets/activity.calendar.vue
@@ -0,0 +1,81 @@
+<template>
+<svg viewBox="0 0 21 7">
+ <rect v-for="record in activity" class="day"
+ width="1" height="1"
+ :x="record.x" :y="record.date.weekday"
+ rx="1" ry="1"
+ fill="transparent">
+ <title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
+ </rect>
+ <rect v-for="record in activity" class="day"
+ :width="record.v" :height="record.v"
+ :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
+ rx="1" ry="1"
+ :fill="record.color"
+ style="pointer-events: none;"/>
+ <rect class="today"
+ width="1" height="1"
+ :x="activity[0].x" :y="activity[0].date.weekday"
+ rx="1" ry="1"
+ fill="none"
+ stroke-width="0.1"
+ stroke="#f73520"/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+const props = defineProps<{
+ activity: any[]
+}>();
+
+for (const d of props.activity) {
+ d.total = d.notes + d.replies + d.renotes;
+}
+const peak = Math.max(...props.activity.map(d => d.total));
+
+const now = new Date();
+const year = now.getFullYear();
+const month = now.getMonth();
+const day = now.getDate();
+
+let x = 20;
+props.activity.slice().forEach((d, i) => {
+ d.x = x;
+
+ const date = new Date(year, month, day - i);
+ d.date = {
+ year: date.getFullYear(),
+ month: date.getMonth(),
+ day: date.getDate(),
+ weekday: date.getDay(),
+ };
+
+ d.v = peak === 0 ? 0 : d.total / (peak / 2);
+ if (d.v > 1) d.v = 1;
+ const ch = d.date.weekday === 0 || d.date.weekday === 6 ? 275 : 170;
+ const cs = d.v * 100;
+ const cl = 15 + ((1 - d.v) * 80);
+ d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
+
+ if (d.date.weekday === 0) x--;
+});
+</script>
+
+<style lang="scss" scoped>
+svg {
+ display: block;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+
+ > rect {
+ transform-origin: center;
+
+ &.day {
+ &:hover {
+ fill: rgba(#000, 0.05);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/activity.chart.vue b/packages/frontend/src/widgets/activity.chart.vue
new file mode 100644
index 0000000000..b61e419f94
--- /dev/null
+++ b/packages/frontend/src/widgets/activity.chart.vue
@@ -0,0 +1,92 @@
+<template>
+<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
+ <polyline
+ :points="pointsNote"
+ fill="none"
+ stroke-width="1"
+ stroke="#41ddde"/>
+ <polyline
+ :points="pointsReply"
+ fill="none"
+ stroke-width="1"
+ stroke="#f7796c"/>
+ <polyline
+ :points="pointsRenote"
+ fill="none"
+ stroke-width="1"
+ stroke="#a1de41"/>
+ <polyline
+ :points="pointsTotal"
+ fill="none"
+ stroke-width="1"
+ stroke="#555"
+ stroke-dasharray="2 2"/>
+</svg>
+</template>
+
+<script lang="ts" setup>
+const props = defineProps<{
+ activity: any[]
+}>();
+
+let viewBoxX: number = $ref(147);
+let viewBoxY: number = $ref(60);
+let zoom: number = $ref(1);
+let pos: number = $ref(0);
+let pointsNote: any = $ref(null);
+let pointsReply: any = $ref(null);
+let pointsRenote: any = $ref(null);
+let pointsTotal: any = $ref(null);
+
+function dragListen(fn) {
+ window.addEventListener('mousemove', fn);
+ window.addEventListener('mouseleave', dragClear.bind(null, fn));
+ window.addEventListener('mouseup', dragClear.bind(null, fn));
+}
+
+function dragClear(fn) {
+ window.removeEventListener('mousemove', fn);
+ window.removeEventListener('mouseleave', dragClear);
+ window.removeEventListener('mouseup', dragClear);
+}
+
+function onMousedown(ev) {
+ const clickX = ev.clientX;
+ const clickY = ev.clientY;
+ const baseZoom = zoom;
+ const basePos = pos;
+
+ // 動かした時
+ dragListen(me => {
+ let moveLeft = me.clientX - clickX;
+ let moveTop = me.clientY - clickY;
+
+ zoom = Math.max(1, baseZoom + (-moveTop / 20));
+ pos = Math.min(0, basePos + moveLeft);
+ if (pos < -(((props.activity.length - 1) * zoom) - viewBoxX)) pos = -(((props.activity.length - 1) * zoom) - viewBoxX);
+
+ render();
+ });
+}
+
+function render() {
+ const peak = Math.max(...props.activity.map(d => d.total));
+ if (peak !== 0) {
+ const activity = props.activity.slice().reverse();
+ pointsNote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.notes / peak)) * viewBoxY}`).join(' ');
+ pointsReply = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.replies / peak)) * viewBoxY}`).join(' ');
+ pointsRenote = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.renotes / peak)) * viewBoxY}`).join(' ');
+ pointsTotal = activity.map((d, i) => `${(i * zoom) + pos},${(1 - (d.total / peak)) * viewBoxY}`).join(' ');
+ }
+}
+</script>
+
+<style lang="scss" scoped>
+svg {
+ display: block;
+ padding: 16px;
+ width: 100%;
+ box-sizing: border-box;
+ cursor: all-scroll;
+}
+</style>
diff --git a/packages/frontend/src/widgets/activity.vue b/packages/frontend/src/widgets/activity.vue
new file mode 100644
index 0000000000..238a05ca09
--- /dev/null
+++ b/packages/frontend/src/widgets/activity.vue
@@ -0,0 +1,90 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity">
+ <template #header><i class="ti ti-chart-line"></i>{{ i18n.ts._widgets.activity }}</template>
+ <template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
+
+ <div>
+ <MkLoading v-if="fetching"/>
+ <template v-else>
+ <XCalendar v-show="widgetProps.view === 0" :activity="[].concat(activity)"/>
+ <XChart v-show="widgetProps.view === 1" :activity="[].concat(activity)"/>
+ </template>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import XCalendar from './activity.calendar.vue';
+import XChart from './activity.chart.vue';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const name = 'activity';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ view: {
+ type: 'number' as const,
+ default: 0,
+ hidden: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const activity = ref(null);
+const fetching = ref(true);
+
+const toggleView = () => {
+ if (widgetProps.view === 1) {
+ widgetProps.view = 0;
+ } else {
+ widgetProps.view++;
+ }
+ save();
+};
+
+os.apiGet('charts/user/notes', {
+ userId: $i.id,
+ span: 'day',
+ limit: 7 * 21,
+}).then(res => {
+ activity.value = res.diffs.normal.map((_, i) => ({
+ total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i],
+ notes: res.diffs.normal[i],
+ replies: res.diffs.reply[i],
+ renotes: res.diffs.renote[i],
+ }));
+ fetching.value = false;
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/aichan.vue b/packages/frontend/src/widgets/aichan.vue
new file mode 100644
index 0000000000..828490fd9c
--- /dev/null
+++ b/packages/frontend/src/widgets/aichan.vue
@@ -0,0 +1,74 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-aichan">
+ <iframe ref="live2d" class="dedjhjmo" src="https://misskey-dev.github.io/mascot-web/?scale=1.5&y=1.1&eyeY=100" @click="touched"></iframe>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+
+const name = 'ai';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const live2d = ref<HTMLIFrameElement>();
+
+const touched = () => {
+ //if (this.live2d) this.live2d.changeExpression('gurugurume');
+};
+
+const onMousemove = (ev: MouseEvent) => {
+ const iframeRect = live2d.value.getBoundingClientRect();
+ live2d.value.contentWindow.postMessage({
+ type: 'moveCursor',
+ body: {
+ x: ev.clientX - iframeRect.left,
+ y: ev.clientY - iframeRect.top,
+ },
+ }, '*');
+};
+
+onMounted(() => {
+ window.addEventListener('mousemove', onMousemove, { passive: true });
+});
+
+onUnmounted(() => {
+ window.removeEventListener('mousemove', onMousemove);
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.dedjhjmo {
+ width: 100%;
+ height: 350px;
+ border: none;
+ pointer-events: none;
+}
+</style>
diff --git a/packages/frontend/src/widgets/aiscript.vue b/packages/frontend/src/widgets/aiscript.vue
new file mode 100644
index 0000000000..4009edb8b8
--- /dev/null
+++ b/packages/frontend/src/widgets/aiscript.vue
@@ -0,0 +1,175 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscript">
+ <template #header><i class="ti ti-terminal-2"></i>{{ i18n.ts._widgets.aiscript }}</template>
+
+ <div class="uylguesu _monospace">
+ <textarea v-model="widgetProps.script" placeholder="(1 + 1)"></textarea>
+ <button class="_buttonPrimary" @click="run">RUN</button>
+ <div class="logs">
+ <div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const name = 'aiscript';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ script: {
+ type: 'string' as const,
+ multiline: true,
+ default: '(1 + 1)',
+ hidden: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const logs = ref<{
+ id: string;
+ text: string;
+ print: boolean;
+}[]>([]);
+
+const run = async () => {
+ logs.value = [];
+ const aiscript = new AiScript(createAiScriptEnv({
+ storageKey: 'widget',
+ token: $i?.token,
+ }), {
+ in: (q) => {
+ return new Promise(ok => {
+ os.inputText({
+ title: q,
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ logs.value.push({
+ id: Math.random().toString(),
+ text: value.type === 'str' ? value.value : utils.valToString(value),
+ print: true,
+ });
+ },
+ log: (type, params) => {
+ switch (type) {
+ case 'end': logs.value.push({
+ id: Math.random().toString(),
+ text: utils.valToString(params.val, true),
+ print: false,
+ }); break;
+ default: break;
+ }
+ },
+ });
+
+ let ast;
+ try {
+ ast = parse(widgetProps.script);
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: 'Syntax error :(',
+ });
+ return;
+ }
+ try {
+ await aiscript.exec(ast);
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: err,
+ });
+ }
+};
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.uylguesu {
+ text-align: right;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ padding: 16px;
+ color: var(--fg);
+ background: transparent;
+ border: none;
+ border-bottom: solid 0.5px var(--divider);
+ border-radius: 0;
+ box-sizing: border-box;
+ font: inherit;
+
+ &:focus-visible {
+ outline: none;
+ }
+ }
+
+ > button {
+ display: inline-block;
+ margin: 8px;
+ padding: 0 10px;
+ height: 28px;
+ outline: none;
+ border-radius: 4px;
+
+ &:disabled {
+ opacity: 0.7;
+ cursor: default;
+ }
+ }
+
+ > .logs {
+ border-top: solid 0.5px var(--divider);
+ text-align: left;
+ padding: 16px;
+
+ &:empty {
+ display: none;
+ }
+
+ > .log {
+ &:not(.print) {
+ opacity: 0.7;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/button.vue b/packages/frontend/src/widgets/button.vue
new file mode 100644
index 0000000000..f0148d7f4e
--- /dev/null
+++ b/packages/frontend/src/widgets/button.vue
@@ -0,0 +1,103 @@
+<template>
+<div class="mkw-button">
+ <MkButton :primary="widgetProps.colored" full @click="run">
+ {{ widgetProps.label }}
+ </MkButton>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { createAiScriptEnv } from '@/scripts/aiscript/api';
+import { $i } from '@/account';
+import MkButton from '@/components/MkButton.vue';
+
+const name = 'button';
+
+const widgetPropsDef = {
+ label: {
+ type: 'string' as const,
+ default: 'BUTTON',
+ },
+ colored: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ script: {
+ type: 'string' as const,
+ multiline: true,
+ default: 'Mk:dialog("hello" "world")',
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const run = async () => {
+ const aiscript = new AiScript(createAiScriptEnv({
+ storageKey: 'widget',
+ token: $i?.token,
+ }), {
+ in: (q) => {
+ return new Promise(ok => {
+ os.inputText({
+ title: q,
+ }).then(({ canceled, result: a }) => {
+ ok(a);
+ });
+ });
+ },
+ out: (value) => {
+ // nop
+ },
+ log: (type, params) => {
+ // nop
+ },
+ });
+
+ let ast;
+ try {
+ ast = parse(widgetProps.script);
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: 'Syntax error :(',
+ });
+ return;
+ }
+ try {
+ await aiscript.exec(ast);
+ } catch (err) {
+ os.alert({
+ type: 'error',
+ text: err,
+ });
+ }
+};
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-button {
+}
+</style>
diff --git a/packages/frontend/src/widgets/calendar.vue b/packages/frontend/src/widgets/calendar.vue
new file mode 100644
index 0000000000..99bd36e2fc
--- /dev/null
+++ b/packages/frontend/src/widgets/calendar.vue
@@ -0,0 +1,213 @@
+<template>
+<div class="mkw-calendar" :class="{ _panel: !widgetProps.transparent }">
+ <div class="calendar" :class="{ isHoliday }">
+ <p class="month-and-year">
+ <span class="year">{{ $t('yearX', { year }) }}</span>
+ <span class="month">{{ $t('monthX', { month }) }}</span>
+ </p>
+ <p v-if="month === 1 && day === 1" class="day">🎉{{ $t('dayX', { day }) }}<span style="display: inline-block; transform: scaleX(-1);">🎉</span></p>
+ <p v-else class="day">{{ $t('dayX', { day }) }}</p>
+ <p class="week-day">{{ weekDay }}</p>
+ </div>
+ <div class="info">
+ <div>
+ <p>{{ i18n.ts.today }}: <b>{{ dayP.toFixed(1) }}%</b></p>
+ <div class="meter">
+ <div class="val" :style="{ width: `${dayP}%` }"></div>
+ </div>
+ </div>
+ <div>
+ <p>{{ i18n.ts.thisMonth }}: <b>{{ monthP.toFixed(1) }}%</b></p>
+ <div class="meter">
+ <div class="val" :style="{ width: `${monthP}%` }"></div>
+ </div>
+ </div>
+ <div>
+ <p>{{ i18n.ts.thisYear }}: <b>{{ yearP.toFixed(1) }}%</b></p>
+ <div class="meter">
+ <div class="val" :style="{ width: `${yearP}%` }"></div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { i18n } from '@/i18n';
+import { useInterval } from '@/scripts/use-interval';
+
+const name = 'calendar';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const year = ref(0);
+const month = ref(0);
+const day = ref(0);
+const weekDay = ref('');
+const yearP = ref(0);
+const monthP = ref(0);
+const dayP = ref(0);
+const isHoliday = ref(false);
+const tick = () => {
+ const now = new Date();
+ const nd = now.getDate();
+ const nm = now.getMonth();
+ const ny = now.getFullYear();
+
+ year.value = ny;
+ month.value = nm + 1;
+ day.value = nd;
+ weekDay.value = [
+ 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,
+ ][now.getDay()];
+
+ const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime();
+ const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/;
+ const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime();
+ const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime();
+ const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime();
+ const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime();
+
+ dayP.value = dayNumer / dayDenom * 100;
+ monthP.value = monthNumer / monthDenom * 100;
+ yearP.value = yearNumer / yearDenom * 100;
+
+ isHoliday.value = now.getDay() === 0 || now.getDay() === 6;
+};
+
+useInterval(tick, 1000, {
+ immediate: true,
+ afterMounted: false,
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-calendar {
+ padding: 16px 0;
+
+ &:after {
+ content: "";
+ display: block;
+ clear: both;
+ }
+
+ > .calendar {
+ float: left;
+ width: 60%;
+ text-align: center;
+
+ &.isHoliday {
+ > .day {
+ color: #ef95a0;
+ }
+ }
+
+ > .month-and-year, > .week-day {
+ margin: 0;
+ line-height: 18px;
+ font-size: 0.9em;
+
+ > .year, > .month {
+ margin: 0 4px;
+ }
+ }
+
+ > .day {
+ margin: 10px 0;
+ line-height: 32px;
+ font-size: 1.75em;
+ }
+ }
+
+ > .info {
+ display: block;
+ float: left;
+ width: 40%;
+ padding: 0 16px 0 0;
+ box-sizing: border-box;
+
+ > div {
+ margin-bottom: 8px;
+
+ &:last-child {
+ margin-bottom: 4px;
+ }
+
+ > p {
+ margin: 0 0 2px 0;
+ font-size: 0.75em;
+ line-height: 18px;
+ opacity: 0.8;
+
+ > b {
+ margin-left: 2px;
+ }
+ }
+
+ > .meter {
+ width: 100%;
+ overflow: hidden;
+ background: var(--X11);
+ border-radius: 8px;
+
+ > .val {
+ height: 4px;
+ transition: width .3s cubic-bezier(0.23, 1, 0.32, 1);
+ }
+ }
+
+ &:nth-child(1) {
+ > .meter > .val {
+ background: #f7796c;
+ }
+ }
+
+ &:nth-child(2) {
+ > .meter > .val {
+ background: #a1de41;
+ }
+ }
+
+ &:nth-child(3) {
+ > .meter > .val {
+ background: #41ddde;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/clock.vue b/packages/frontend/src/widgets/clock.vue
new file mode 100644
index 0000000000..dc99b6631e
--- /dev/null
+++ b/packages/frontend/src/widgets/clock.vue
@@ -0,0 +1,203 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-clock">
+ <div class="vubelbmv" :class="widgetProps.size">
+ <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label a abbrev">{{ tzAbbrev }}</div>
+ <MkAnalogClock
+ class="clock"
+ :thickness="widgetProps.thickness"
+ :offset="tzOffset"
+ :graduations="widgetProps.graduations"
+ :fade-graduations="widgetProps.fadeGraduations"
+ :twentyfour="widgetProps.twentyFour"
+ :s-animation="widgetProps.sAnimation"
+ />
+ <MkDigitalClock v-if="widgetProps.label === 'time' || widgetProps.label === 'timeAndTz'" class="_monospace label c time" :show-s="false" :offset="tzOffset"/>
+ <div v-if="widgetProps.label === 'tz' || widgetProps.label === 'timeAndTz'" class="_monospace label d offset">{{ tzOffsetLabel }}</div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkAnalogClock from '@/components/MkAnalogClock.vue';
+import MkDigitalClock from '@/components/MkDigitalClock.vue';
+import { timezones } from '@/scripts/timezones';
+import { i18n } from '@/i18n';
+
+const name = 'clock';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ size: {
+ type: 'radio' as const,
+ default: 'medium',
+ options: [{
+ value: 'small', label: i18n.ts.small,
+ }, {
+ value: 'medium', label: i18n.ts.medium,
+ }, {
+ value: 'large', label: i18n.ts.large,
+ }],
+ },
+ thickness: {
+ type: 'radio' as const,
+ default: 0.2,
+ options: [{
+ value: 0.1, label: 'thin',
+ }, {
+ value: 0.2, label: 'medium',
+ }, {
+ value: 0.3, label: 'thick',
+ }],
+ },
+ graduations: {
+ type: 'radio' as const,
+ default: 'numbers',
+ options: [{
+ value: 'none', label: 'None',
+ }, {
+ value: 'dots', label: 'Dots',
+ }, {
+ value: 'numbers', label: 'Numbers',
+ }],
+ },
+ fadeGraduations: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ sAnimation: {
+ type: 'radio' as const,
+ default: 'elastic',
+ options: [{
+ value: 'none', label: 'None',
+ }, {
+ value: 'elastic', label: 'Elastic',
+ }, {
+ value: 'easeOut', label: 'Ease out',
+ }],
+ },
+ twentyFour: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ label: {
+ type: 'radio' as const,
+ default: 'none',
+ options: [{
+ value: 'none', label: 'None',
+ }, {
+ value: 'time', label: 'Time',
+ }, {
+ value: 'tz', label: 'TZ',
+ }, {
+ value: 'timeAndTz', label: 'Time + TZ',
+ }],
+ },
+ timezone: {
+ type: 'enum' as const,
+ default: null,
+ enum: [...timezones.map((tz) => ({
+ label: tz.name,
+ value: tz.name.toLowerCase(),
+ })), {
+ label: '(auto)',
+ value: null,
+ }],
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const tzAbbrev = $computed(() => (widgetProps.timezone === null
+ ? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
+ : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
+
+const tzOffset = $computed(() => widgetProps.timezone === null
+ ? 0 - new Date().getTimezoneOffset()
+ : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
+
+const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.vubelbmv {
+ position: relative;
+
+ > .label {
+ position: absolute;
+ opacity: 0.7;
+
+ &.a {
+ top: 14px;
+ left: 14px;
+ }
+
+ &.b {
+ top: 14px;
+ right: 14px;
+ }
+
+ &.c {
+ bottom: 14px;
+ left: 14px;
+ }
+
+ &.d {
+ bottom: 14px;
+ right: 14px;
+ }
+ }
+
+ > .clock {
+ margin: auto;
+ }
+
+ &.small {
+ padding: 12px;
+
+ > .clock {
+ height: 100px;
+ }
+ }
+
+ &.medium {
+ padding: 14px;
+
+ > .clock {
+ height: 150px;
+ }
+ }
+
+ &.large {
+ padding: 16px;
+
+ > .clock {
+ height: 200px;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/digital-clock.vue b/packages/frontend/src/widgets/digital-clock.vue
new file mode 100644
index 0000000000..d2bfd523f3
--- /dev/null
+++ b/packages/frontend/src/widgets/digital-clock.vue
@@ -0,0 +1,92 @@
+<template>
+<div class="mkw-digitalClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
+ <div v-if="widgetProps.showLabel" class="label">{{ tzAbbrev }}</div>
+ <div class="time">
+ <MkDigitalClock :show-ms="widgetProps.showMs" :offset="tzOffset"/>
+ </div>
+ <div v-if="widgetProps.showLabel" class="label">{{ tzOffsetLabel }}</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { timezones } from '@/scripts/timezones';
+import MkDigitalClock from '@/components/MkDigitalClock.vue';
+
+const name = 'digitalClock';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ fontSize: {
+ type: 'number' as const,
+ default: 1.5,
+ step: 0.1,
+ },
+ showMs: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ showLabel: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ timezone: {
+ type: 'enum' as const,
+ default: null,
+ enum: [...timezones.map((tz) => ({
+ label: tz.name,
+ value: tz.name.toLowerCase(),
+ })), {
+ label: '(auto)',
+ value: null,
+ }],
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const tzAbbrev = $computed(() => (widgetProps.timezone === null
+ ? timezones.find((tz) => tz.name.toLowerCase() === Intl.DateTimeFormat().resolvedOptions().timeZone.toLowerCase())?.abbrev
+ : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.abbrev) ?? '?');
+
+const tzOffset = $computed(() => widgetProps.timezone === null
+ ? 0 - new Date().getTimezoneOffset()
+ : timezones.find((tz) => tz.name.toLowerCase() === widgetProps.timezone)?.offset ?? 0);
+
+const tzOffsetLabel = $computed(() => (tzOffset >= 0 ? '+' : '-') + Math.floor(tzOffset / 60).toString().padStart(2, '0') + ':' + (tzOffset % 60).toString().padStart(2, '0'));
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-digitalClock {
+ padding: 16px 0;
+ text-align: center;
+
+ > .label {
+ font-size: 65%;
+ opacity: 0.7;
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/federation.vue b/packages/frontend/src/widgets/federation.vue
new file mode 100644
index 0000000000..3374783b0c
--- /dev/null
+++ b/packages/frontend/src/widgets/federation.vue
@@ -0,0 +1,147 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :foldable="foldable" :scrollable="scrollable" class="mkw-federation">
+ <template #header><i class="ti ti-whirl"></i>{{ i18n.ts._widgets.federation }}</template>
+
+ <div class="wbrkwalb">
+ <MkLoading v-if="fetching"/>
+ <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
+ <div v-for="(instance, i) in instances" :key="instance.id" class="instance">
+ <img :src="getInstanceIcon(instance)" alt=""/>
+ <div class="body">
+ <a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a>
+ <p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p>
+ </div>
+ <MkMiniChart class="chart" :src="charts[i].requests.received"/>
+ </div>
+ </transition-group>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const name = 'federation';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps> & { foldable?: boolean; scrollable?: boolean; }>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; foldable?: boolean; scrollable?: boolean; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const instances = ref([]);
+const charts = ref([]);
+const fetching = ref(true);
+
+const fetch = async () => {
+ const fetchedInstances = await os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 5,
+ });
+ const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' })));
+ instances.value = fetchedInstances;
+ charts.value = fetchedCharts;
+ fetching.value = false;
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+
+function getInstanceIcon(instance): string {
+ return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
+}
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwalb {
+ $bodyTitleHieght: 18px;
+ $bodyInfoHieght: 16px;
+
+ height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
+ overflow: hidden;
+
+ > .instances {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ > .instance {
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ border-bottom: solid 0.5px var(--divider);
+
+ > img {
+ display: block;
+ width: ($bodyTitleHieght + $bodyInfoHieght);
+ height: ($bodyTitleHieght + $bodyInfoHieght);
+ object-fit: cover;
+ border-radius: 4px;
+ margin-right: 8px;
+ }
+
+ > .body {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+ padding-right: 8px;
+
+ > .a {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: $bodyTitleHieght;
+ }
+
+ > p {
+ margin: 0;
+ font-size: 75%;
+ opacity: 0.7;
+ line-height: $bodyInfoHieght;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > .chart {
+ height: 30px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts
new file mode 100644
index 0000000000..39826f13c8
--- /dev/null
+++ b/packages/frontend/src/widgets/index.ts
@@ -0,0 +1,53 @@
+import { App, defineAsyncComponent } from 'vue';
+
+export default function(app: App) {
+ app.component('MkwMemo', defineAsyncComponent(() => import('./memo.vue')));
+ app.component('MkwNotifications', defineAsyncComponent(() => import('./notifications.vue')));
+ app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue')));
+ app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue')));
+ app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue')));
+ app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue')));
+ app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue')));
+ app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue')));
+ app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue')));
+ app.component('MkwPhotos', defineAsyncComponent(() => import('./photos.vue')));
+ app.component('MkwDigitalClock', defineAsyncComponent(() => import('./digital-clock.vue')));
+ app.component('MkwUnixClock', defineAsyncComponent(() => import('./unix-clock.vue')));
+ app.component('MkwFederation', defineAsyncComponent(() => import('./federation.vue')));
+ app.component('MkwPostForm', defineAsyncComponent(() => import('./post-form.vue')));
+ app.component('MkwSlideshow', defineAsyncComponent(() => import('./slideshow.vue')));
+ app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue')));
+ app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue')));
+ app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue')));
+ app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue')));
+ app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
+ app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
+ app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
+ app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue')));
+}
+
+export const widgets = [
+ 'memo',
+ 'notifications',
+ 'timeline',
+ 'calendar',
+ 'rss',
+ 'rssTicker',
+ 'trends',
+ 'clock',
+ 'activity',
+ 'photos',
+ 'digitalClock',
+ 'unixClock',
+ 'federation',
+ 'instanceCloud',
+ 'postForm',
+ 'slideshow',
+ 'serverMetric',
+ 'onlineUsers',
+ 'jobQueue',
+ 'button',
+ 'aiscript',
+ 'aichan',
+ 'userList',
+];
diff --git a/packages/frontend/src/widgets/instance-cloud.vue b/packages/frontend/src/widgets/instance-cloud.vue
new file mode 100644
index 0000000000..4965616995
--- /dev/null
+++ b/packages/frontend/src/widgets/instance-cloud.vue
@@ -0,0 +1,81 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud">
+ <div class="">
+ <MkTagCloud v-if="activeInstances">
+ <li v-for="instance in activeInstances" :key="instance.id">
+ <a @click.prevent="onInstanceClick(instance)">
+ <img style="width: 32px;" :src="getInstanceIcon(instance)">
+ </a>
+ </li>
+ </MkTagCloud>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkTagCloud from '@/components/MkTagCloud.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
+
+const name = 'instanceCloud';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+let cloud = $ref<InstanceType<typeof MkTagCloud> | null>();
+let activeInstances = $shallowRef(null);
+
+function onInstanceClick(i) {
+ os.pageWindow(`/instance-info/${i.host}`);
+}
+
+useInterval(() => {
+ os.api('federation/instances', {
+ sort: '+lastCommunicatedAt',
+ limit: 25,
+ }).then(res => {
+ activeInstances = res;
+ if (cloud) cloud.update();
+ });
+}, 1000 * 60 * 3, {
+ immediate: true,
+ afterMounted: true,
+});
+
+function getInstanceIcon(instance): string {
+ return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
+}
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+
+</style>
diff --git a/packages/frontend/src/widgets/job-queue.vue b/packages/frontend/src/widgets/job-queue.vue
new file mode 100644
index 0000000000..9f19c51825
--- /dev/null
+++ b/packages/frontend/src/widgets/job-queue.vue
@@ -0,0 +1,197 @@
+<template>
+<div class="mkw-jobQueue _monospace" :class="{ _panel: !widgetProps.transparent }">
+ <div class="inbox">
+ <div class="label">Inbox queue<i v-if="current.inbox.waiting > 0" class="ti ti-alert-triangle icon"></i></div>
+ <div class="values">
+ <div>
+ <div>Process</div>
+ <div :class="{ inc: current.inbox.activeSincePrevTick > prev.inbox.activeSincePrevTick, dec: current.inbox.activeSincePrevTick < prev.inbox.activeSincePrevTick }">{{ number(current.inbox.activeSincePrevTick) }}</div>
+ </div>
+ <div>
+ <div>Active</div>
+ <div :class="{ inc: current.inbox.active > prev.inbox.active, dec: current.inbox.active < prev.inbox.active }">{{ number(current.inbox.active) }}</div>
+ </div>
+ <div>
+ <div>Delayed</div>
+ <div :class="{ inc: current.inbox.delayed > prev.inbox.delayed, dec: current.inbox.delayed < prev.inbox.delayed }">{{ number(current.inbox.delayed) }}</div>
+ </div>
+ <div>
+ <div>Waiting</div>
+ <div :class="{ inc: current.inbox.waiting > prev.inbox.waiting, dec: current.inbox.waiting < prev.inbox.waiting }">{{ number(current.inbox.waiting) }}</div>
+ </div>
+ </div>
+ </div>
+ <div class="deliver">
+ <div class="label">Deliver queue<i v-if="current.deliver.waiting > 0" class="ti ti-alert-triangle icon"></i></div>
+ <div class="values">
+ <div>
+ <div>Process</div>
+ <div :class="{ inc: current.deliver.activeSincePrevTick > prev.deliver.activeSincePrevTick, dec: current.deliver.activeSincePrevTick < prev.deliver.activeSincePrevTick }">{{ number(current.deliver.activeSincePrevTick) }}</div>
+ </div>
+ <div>
+ <div>Active</div>
+ <div :class="{ inc: current.deliver.active > prev.deliver.active, dec: current.deliver.active < prev.deliver.active }">{{ number(current.deliver.active) }}</div>
+ </div>
+ <div>
+ <div>Delayed</div>
+ <div :class="{ inc: current.deliver.delayed > prev.deliver.delayed, dec: current.deliver.delayed < prev.deliver.delayed }">{{ number(current.deliver.delayed) }}</div>
+ </div>
+ <div>
+ <div>Waiting</div>
+ <div :class="{ inc: current.deliver.waiting > prev.deliver.waiting, dec: current.deliver.waiting < prev.deliver.waiting }">{{ number(current.deliver.waiting) }}</div>
+ </div>
+ </div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { stream } from '@/stream';
+import number from '@/filters/number';
+import * as sound from '@/scripts/sound';
+import * as os from '@/os';
+import { deepClone } from '@/scripts/clone';
+
+const name = 'jobQueue';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ sound: {
+ type: 'boolean' as const,
+ default: false,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const connection = stream.useChannel('queueStats');
+const current = reactive({
+ inbox: {
+ activeSincePrevTick: 0,
+ active: 0,
+ waiting: 0,
+ delayed: 0,
+ },
+ deliver: {
+ activeSincePrevTick: 0,
+ active: 0,
+ waiting: 0,
+ delayed: 0,
+ },
+});
+const prev = reactive({} as typeof current);
+const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1);
+
+for (const domain of ['inbox', 'deliver']) {
+ prev[domain] = deepClone(current[domain]);
+}
+
+const onStats = (stats) => {
+ 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;
+
+ if (current[domain].waiting > 0 && widgetProps.sound && jammedSound.paused) {
+ jammedSound.play();
+ }
+ }
+};
+
+const onStatsLog = (statsLog) => {
+ for (const stats of [...statsLog].reverse()) {
+ onStats(stats);
+ }
+};
+
+connection.on('stats', onStats);
+connection.on('statsLog', onStatsLog);
+
+connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ length: 1,
+});
+
+onUnmounted(() => {
+ connection.off('stats', onStats);
+ connection.off('statsLog', onStatsLog);
+ connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+@keyframes warnBlink {
+ 0% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+.mkw-jobQueue {
+ font-size: 0.9em;
+
+ > div {
+ padding: 16px;
+
+ &:not(:first-child) {
+ border-top: solid 0.5px var(--divider);
+ }
+
+ > .label {
+ display: flex;
+
+ > .icon {
+ color: var(--warn);
+ margin-left: auto;
+ animation: warnBlink 1s infinite;
+ }
+ }
+
+ > .values {
+ display: flex;
+
+ > div {
+ flex: 1;
+
+ > div:first-child {
+ opacity: 0.7;
+ }
+
+ > div:last-child {
+ &.inc {
+ color: var(--warn);
+ }
+
+ &.dec {
+ color: var(--success);
+ }
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/memo.vue b/packages/frontend/src/widgets/memo.vue
new file mode 100644
index 0000000000..1cc0e10bba
--- /dev/null
+++ b/packages/frontend/src/widgets/memo.vue
@@ -0,0 +1,111 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-memo">
+ <template #header><i class="ti ti-note"></i>{{ i18n.ts._widgets.memo }}</template>
+
+ <div class="otgbylcu">
+ <textarea v-model="text" :placeholder="i18n.ts.placeholder" @input="onChange"></textarea>
+ <button :disabled="!changed" class="_buttonPrimary" @click="saveMemo">{{ i18n.ts.save }}</button>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const name = 'memo';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const text = ref<string | null>(defaultStore.state.memo);
+const changed = ref(false);
+let timeoutId;
+
+const saveMemo = () => {
+ defaultStore.set('memo', text.value);
+ changed.value = false;
+};
+
+const onChange = () => {
+ changed.value = true;
+ window.clearTimeout(timeoutId);
+ timeoutId = window.setTimeout(saveMemo, 1000);
+};
+
+watch(() => defaultStore.reactiveState.memo, newText => {
+ text.value = newText.value;
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.otgbylcu {
+ padding-bottom: 28px + 16px;
+
+ > textarea {
+ display: block;
+ width: 100%;
+ max-width: 100%;
+ min-width: 100%;
+ padding: 16px;
+ color: var(--fg);
+ background: transparent;
+ border: none;
+ border-bottom: solid 0.5px var(--divider);
+ border-radius: 0;
+ box-sizing: border-box;
+ font: inherit;
+ font-size: 0.9em;
+
+ &:focus-visible {
+ outline: none;
+ }
+ }
+
+ > button {
+ display: block;
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ margin: 0;
+ padding: 0 10px;
+ height: 28px;
+ outline: none;
+ border-radius: 4px;
+
+ &:disabled {
+ opacity: 0.7;
+ cursor: default;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/notifications.vue b/packages/frontend/src/widgets/notifications.vue
new file mode 100644
index 0000000000..e697209444
--- /dev/null
+++ b/packages/frontend/src/widgets/notifications.vue
@@ -0,0 +1,70 @@
+<template>
+<MkContainer :style="`height: ${widgetProps.height}px;`" :show-header="widgetProps.showHeader" :scrollable="true" class="mkw-notifications">
+ <template #header><i class="ti ti-bell"></i>{{ i18n.ts.notifications }}</template>
+ <template #func><button class="_button" @click="configureNotification()"><i class="ti ti-settings"></i></button></template>
+
+ <div>
+ <XNotifications :include-types="widgetProps.includingTypes"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { defineAsyncComponent } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import XNotifications from '@/components/MkNotifications.vue';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+
+const name = 'notifications';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ height: {
+ type: 'number' as const,
+ default: 300,
+ },
+ includingTypes: {
+ type: 'array' as const,
+ hidden: true,
+ default: null,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const configureNotification = () => {
+ os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), {
+ includingTypes: widgetProps.includingTypes,
+ }, {
+ done: async (res) => {
+ const { includingTypes } = res;
+ widgetProps.includingTypes = includingTypes;
+ save();
+ },
+ }, 'closed');
+};
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/online-users.vue b/packages/frontend/src/widgets/online-users.vue
new file mode 100644
index 0000000000..e9ab79b111
--- /dev/null
+++ b/packages/frontend/src/widgets/online-users.vue
@@ -0,0 +1,78 @@
+<template>
+<div class="mkw-onlineUsers" :class="{ _panel: !widgetProps.transparent, pad: !widgetProps.transparent }">
+ <I18n v-if="onlineUsersCount" :src="i18n.ts.onlineUsersCount" text-tag="span" class="text">
+ <template #n><b>{{ onlineUsersCount }}</b></template>
+ </I18n>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const name = 'onlineUsers';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const onlineUsersCount = ref(0);
+
+const tick = () => {
+ os.api('get-online-users-count').then(res => {
+ onlineUsersCount.value = res.count;
+ });
+};
+
+useInterval(tick, 1000 * 15, {
+ immediate: true,
+ afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-onlineUsers {
+ text-align: center;
+
+ &.pad {
+ padding: 16px 0;
+ }
+
+ > .text {
+ ::v-deep(b) {
+ color: #41b781;
+ }
+
+ ::v-deep(span) {
+ opacity: 0.7;
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/photos.vue b/packages/frontend/src/widgets/photos.vue
new file mode 100644
index 0000000000..4ad5324053
--- /dev/null
+++ b/packages/frontend/src/widgets/photos.vue
@@ -0,0 +1,123 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" :class="$style.root" :data-transparent="widgetProps.transparent ? true : null" class="mkw-photos">
+ <template #header><i class="ti ti-camera"></i>{{ i18n.ts._widgets.photos }}</template>
+
+ <div class="">
+ <MkLoading v-if="fetching"/>
+ <div v-else :class="$style.stream">
+ <div
+ v-for="(image, i) in images" :key="i"
+ :class="$style.img"
+ :style="`background-image: url(${thumbnail(image)})`"
+ ></div>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import { stream } from '@/stream';
+import { getStaticImageUrl } from '@/scripts/get-static-image-url';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { defaultStore } from '@/store';
+import { i18n } from '@/i18n';
+
+const name = 'photos';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const connection = stream.useChannel('main');
+const images = ref([]);
+const fetching = ref(true);
+
+const onDriveFileCreated = (file) => {
+ if (/^image\/.+$/.test(file.type)) {
+ images.value.unshift(file);
+ if (images.value.length > 9) images.value.pop();
+ }
+};
+
+const thumbnail = (image: any): string => {
+ return defaultStore.state.disableShowingAnimatedImages
+ ? getStaticImageUrl(image.thumbnailUrl)
+ : image.thumbnailUrl;
+};
+
+os.api('drive/stream', {
+ type: 'image/*',
+ limit: 9,
+}).then(res => {
+ images.value = res;
+ fetching.value = false;
+});
+
+connection.on('driveFileCreated', onDriveFileCreated);
+onUnmounted(() => {
+ connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" module>
+.root[data-transparent] {
+ .stream {
+ padding: 0;
+ }
+
+ .img {
+ border: solid 4px transparent;
+ border-radius: 8px;
+ }
+}
+
+.stream {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ padding: 8px;
+
+ .img {
+ flex: 1 1 33%;
+ width: 33%;
+ height: 80px;
+ box-sizing: border-box;
+ background-position: center center;
+ background-size: cover;
+ background-clip: content-box;
+ border: solid 2px transparent;
+ border-radius: 4px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/post-form.vue b/packages/frontend/src/widgets/post-form.vue
new file mode 100644
index 0000000000..f1708775ba
--- /dev/null
+++ b/packages/frontend/src/widgets/post-form.vue
@@ -0,0 +1,35 @@
+<template>
+<XPostForm class="_panel mkw-postForm" :fixed="true" :autofocus="false"/>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import XPostForm from '@/components/MkPostForm.vue';
+
+const name = 'postForm';
+
+const widgetPropsDef = {
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/rss-ticker.vue b/packages/frontend/src/widgets/rss-ticker.vue
new file mode 100644
index 0000000000..44c21d1836
--- /dev/null
+++ b/packages/frontend/src/widgets/rss-ticker.vue
@@ -0,0 +1,152 @@
+<template>
+<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker">
+ <template #header><i class="ti ti-rss"></i>RSS</template>
+ <template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template>
+
+ <div class="ekmkgxbk">
+ <MkLoading v-if="fetching"/>
+ <div v-else class="feed">
+ <transition name="change" mode="default">
+ <MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse">
+ <span v-for="item in items" class="item">
+ <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span>
+ </span>
+ </MarqueeText>
+ </transition>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import MarqueeText from '@/components/MkMarquee.vue';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { useInterval } from '@/scripts/use-interval';
+import { shuffle } from '@/scripts/shuffle';
+
+const name = 'rssTicker';
+
+const widgetPropsDef = {
+ url: {
+ type: 'string' as const,
+ default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+ },
+ shuffle: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ refreshIntervalSec: {
+ type: 'number' as const,
+ default: 60,
+ },
+ duration: {
+ type: 'range' as const,
+ default: 70,
+ step: 1,
+ min: 5,
+ max: 200,
+ },
+ reverse: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ showHeader: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const items = ref([]);
+const fetching = ref(true);
+let key = $ref(0);
+
+const tick = () => {
+ window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
+ res.json().then(feed => {
+ if (widgetProps.shuffle) {
+ shuffle(feed.items);
+ }
+ items.value = feed.items;
+ fetching.value = false;
+ key++;
+ });
+ });
+};
+
+watch(() => widgetProps.url, tick);
+
+useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), {
+ immediate: true,
+ afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.change-enter-active, .change-leave-active {
+ position: absolute;
+ top: 0;
+ transition: all 1s ease;
+}
+.change-enter-from {
+ opacity: 0;
+ transform: translateY(-100%);
+}
+.change-leave-to {
+ opacity: 0;
+ transform: translateY(100%);
+}
+
+.ekmkgxbk {
+ > .feed {
+ --height: 42px;
+ padding: 0;
+ font-size: 0.9em;
+ line-height: var(--height);
+ height: var(--height);
+ contain: strict;
+
+ ::v-deep(.item) {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: bottom;
+ color: var(--fg);
+
+ > .divider {
+ display: inline-block;
+ width: 0.5px;
+ height: 16px;
+ margin: 0 1em;
+ background: var(--divider);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/rss.vue b/packages/frontend/src/widgets/rss.vue
new file mode 100644
index 0000000000..c0338c8e47
--- /dev/null
+++ b/packages/frontend/src/widgets/rss.vue
@@ -0,0 +1,96 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-rss">
+ <template #header><i class="ti ti-rss"></i>RSS</template>
+ <template #func><button class="_button" @click="configure"><i class="ti ti-settings"></i></button></template>
+
+ <div class="ekmkgxbj">
+ <MkLoading v-if="fetching"/>
+ <div v-else class="feed">
+ <a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import { useInterval } from '@/scripts/use-interval';
+
+const name = 'rss';
+
+const widgetPropsDef = {
+ url: {
+ type: 'string' as const,
+ default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
+ },
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const items = ref([]);
+const fetching = ref(true);
+
+const tick = () => {
+ window.fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => {
+ res.json().then(feed => {
+ items.value = feed.items;
+ fetching.value = false;
+ });
+ });
+};
+
+watch(() => widgetProps.url, tick);
+
+useInterval(tick, 60000, {
+ immediate: true,
+ afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.ekmkgxbj {
+ > .feed {
+ padding: 0;
+ font-size: 0.9em;
+
+ > .item {
+ display: block;
+ padding: 8px 16px;
+ color: var(--fg);
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+
+ &:nth-child(even) {
+ background: rgba(#000, 0.05);
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/cpu-mem.vue b/packages/frontend/src/widgets/server-metric/cpu-mem.vue
new file mode 100644
index 0000000000..80a8e427e1
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/cpu-mem.vue
@@ -0,0 +1,167 @@
+<template>
+<div class="lcfyofjk">
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <defs>
+ <linearGradient :id="cpuGradientId" x1="0" x2="0" y1="1" y2="0">
+ <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
+ <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
+ </linearGradient>
+ <mask :id="cpuMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+ <polygon
+ :points="cpuPolygonPoints"
+ fill="#fff"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="cpuPolylinePoints"
+ fill="none"
+ stroke="#fff"
+ stroke-width="1"
+ />
+ <circle
+ :cx="cpuHeadX"
+ :cy="cpuHeadY"
+ r="1.5"
+ fill="#fff"
+ />
+ </mask>
+ </defs>
+ <rect
+ x="-2" y="-2"
+ :width="viewBoxX + 4" :height="viewBoxY + 4"
+ :style="`stroke: none; fill: url(#${ cpuGradientId }); mask: url(#${ cpuMaskId })`"
+ />
+ <text x="1" y="5">CPU <tspan>{{ cpuP }}%</tspan></text>
+ </svg>
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <defs>
+ <linearGradient :id="memGradientId" x1="0" x2="0" y1="1" y2="0">
+ <stop offset="0%" stop-color="hsl(180, 80%, 70%)"></stop>
+ <stop offset="100%" stop-color="hsl(0, 80%, 70%)"></stop>
+ </linearGradient>
+ <mask :id="memMaskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY">
+ <polygon
+ :points="memPolygonPoints"
+ fill="#fff"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="memPolylinePoints"
+ fill="none"
+ stroke="#fff"
+ stroke-width="1"
+ />
+ <circle
+ :cx="memHeadX"
+ :cy="memHeadY"
+ r="1.5"
+ fill="#fff"
+ />
+ </mask>
+ </defs>
+ <rect
+ x="-2" y="-2"
+ :width="viewBoxX + 4" :height="viewBoxY + 4"
+ :style="`stroke: none; fill: url(#${ memGradientId }); mask: url(#${ memMaskId })`"
+ />
+ <text x="1" y="5">MEM <tspan>{{ memP }}%</tspan></text>
+ </svg>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import { v4 as uuid } from 'uuid';
+
+const props = defineProps<{
+ connection: any,
+ meta: any
+}>();
+
+let viewBoxX: number = $ref(50);
+let viewBoxY: number = $ref(30);
+let stats: any[] = $ref([]);
+const cpuGradientId = uuid();
+const cpuMaskId = uuid();
+const memGradientId = uuid();
+const memMaskId = uuid();
+let cpuPolylinePoints: string = $ref('');
+let memPolylinePoints: string = $ref('');
+let cpuPolygonPoints: string = $ref('');
+let memPolygonPoints: string = $ref('');
+let cpuHeadX: any = $ref(null);
+let cpuHeadY: any = $ref(null);
+let memHeadX: any = $ref(null);
+let memHeadY: any = $ref(null);
+let cpuP: string = $ref('');
+let memP: string = $ref('');
+
+onMounted(() => {
+ props.connection.on('stats', onStats);
+ props.connection.on('statsLog', onStatsLog);
+ props.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ });
+});
+
+onBeforeUnmount(() => {
+ props.connection.off('stats', onStats);
+ props.connection.off('statsLog', onStatsLog);
+});
+
+function onStats(connStats) {
+ stats.push(connStats);
+ if (stats.length > 50) stats.shift();
+
+ let cpuPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - s.cpu) * viewBoxY]);
+ let memPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.mem.active / props.meta.mem.total)) * viewBoxY]);
+ cpuPolylinePoints = cpuPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+ memPolylinePoints = memPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+ cpuPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${cpuPolylinePoints} ${viewBoxX},${viewBoxY}`;
+ memPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${memPolylinePoints} ${viewBoxX},${viewBoxY}`;
+
+ cpuHeadX = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][0];
+ cpuHeadY = cpuPolylinePointsStats[cpuPolylinePointsStats.length - 1][1];
+ memHeadX = memPolylinePointsStats[memPolylinePointsStats.length - 1][0];
+ memHeadY = memPolylinePointsStats[memPolylinePointsStats.length - 1][1];
+
+ cpuP = (connStats.cpu * 100).toFixed(0);
+ memP = (connStats.mem.active / props.meta.mem.total * 100).toFixed(0);
+}
+
+function onStatsLog(statsLog) {
+ for (const revStats of [...statsLog].reverse()) {
+ onStats(revStats);
+ }
+}
+</script>
+
+<style lang="scss" scoped>
+.lcfyofjk {
+ display: flex;
+
+ > svg {
+ display: block;
+ padding: 10px;
+ width: 50%;
+
+ &:first-child {
+ padding-right: 5px;
+ }
+
+ &:last-child {
+ padding-left: 5px;
+ }
+
+ > text {
+ font-size: 4.5px;
+ fill: currentColor;
+
+ > tspan {
+ opacity: 0.5;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/cpu.vue b/packages/frontend/src/widgets/server-metric/cpu.vue
new file mode 100644
index 0000000000..e7b2226d1f
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/cpu.vue
@@ -0,0 +1,65 @@
+<template>
+<div class="vrvdvrys">
+ <XPie class="pie" :value="usage"/>
+ <div>
+ <p><i class="ti ti-cpu"></i>CPU</p>
+ <p>{{ meta.cpu.cores }} Logical cores</p>
+ <p>{{ meta.cpu.model }}</p>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import XPie from './pie.vue';
+
+const props = defineProps<{
+ connection: any,
+ meta: any
+}>();
+
+let usage: number = $ref(0);
+
+function onStats(stats) {
+ usage = stats.cpu;
+}
+
+onMounted(() => {
+ props.connection.on('stats', onStats);
+});
+
+onBeforeUnmount(() => {
+ props.connection.off('stats', onStats);
+});
+</script>
+
+<style lang="scss" scoped>
+.vrvdvrys {
+ display: flex;
+ padding: 16px;
+
+ > .pie {
+ height: 82px;
+ flex-shrink: 0;
+ margin-right: 16px;
+ }
+
+ > div {
+ flex: 1;
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+
+ &:first-child {
+ font-weight: bold;
+ margin-bottom: 4px;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/disk.vue b/packages/frontend/src/widgets/server-metric/disk.vue
new file mode 100644
index 0000000000..3d22d05383
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/disk.vue
@@ -0,0 +1,57 @@
+<template>
+<div class="zbwaqsat">
+ <XPie class="pie" :value="usage"/>
+ <div>
+ <p><i class="ti ti-database"></i>Disk</p>
+ <p>Total: {{ bytes(total, 1) }}</p>
+ <p>Free: {{ bytes(available, 1) }}</p>
+ <p>Used: {{ bytes(used, 1) }}</p>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+import XPie from './pie.vue';
+import bytes from '@/filters/bytes';
+
+const props = defineProps<{
+ meta: any; // TODO
+}>();
+
+const usage = $computed(() => props.meta.fs.used / props.meta.fs.total);
+const total = $computed(() => props.meta.fs.total);
+const used = $computed(() => props.meta.fs.used);
+const available = $computed(() => props.meta.fs.total - props.meta.fs.used);
+</script>
+
+<style lang="scss" scoped>
+.zbwaqsat {
+ display: flex;
+ padding: 16px;
+
+ > .pie {
+ height: 82px;
+ flex-shrink: 0;
+ margin-right: 16px;
+ }
+
+ > div {
+ flex: 1;
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+
+ &:first-child {
+ font-weight: bold;
+ margin-bottom: 4px;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/index.vue b/packages/frontend/src/widgets/server-metric/index.vue
new file mode 100644
index 0000000000..bc3fca6fc1
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/index.vue
@@ -0,0 +1,87 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent">
+ <template #header><i class="ti ti-server"></i>{{ i18n.ts._widgets.serverMetric }}</template>
+ <template #func><button class="_button" @click="toggleView()"><i class="ti ti-selector"></i></button></template>
+
+ <div v-if="meta" class="mkw-serverMetric">
+ <XCpuMemory v-if="widgetProps.view === 0" :connection="connection" :meta="meta"/>
+ <XNet v-else-if="widgetProps.view === 1" :connection="connection" :meta="meta"/>
+ <XCpu v-else-if="widgetProps.view === 2" :connection="connection" :meta="meta"/>
+ <XMemory v-else-if="widgetProps.view === 3" :connection="connection" :meta="meta"/>
+ <XDisk v-else-if="widgetProps.view === 4" :connection="connection" :meta="meta"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from '../widget';
+import XCpuMemory from './cpu-mem.vue';
+import XNet from './net.vue';
+import XCpu from './cpu.vue';
+import XMemory from './mem.vue';
+import XDisk from './disk.vue';
+import MkContainer from '@/components/MkContainer.vue';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { stream } from '@/stream';
+import { i18n } from '@/i18n';
+
+const name = 'serverMetric';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ view: {
+ type: 'number' as const,
+ default: 0,
+ hidden: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const meta = ref(null);
+
+os.api('server-info', {}).then(res => {
+ meta.value = res;
+});
+
+const toggleView = () => {
+ if (widgetProps.view === 4) {
+ widgetProps.view = 0;
+ } else {
+ widgetProps.view++;
+ }
+ save();
+};
+
+const connection = stream.useChannel('serverStats');
+onUnmounted(() => {
+ connection.dispose();
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/server-metric/mem.vue b/packages/frontend/src/widgets/server-metric/mem.vue
new file mode 100644
index 0000000000..6018eb4265
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/mem.vue
@@ -0,0 +1,73 @@
+<template>
+<div class="zlxnikvl">
+ <XPie class="pie" :value="usage"/>
+ <div>
+ <p><i class="fas fa-memory"></i>RAM</p>
+ <p>Total: {{ bytes(total, 1) }}</p>
+ <p>Used: {{ bytes(used, 1) }}</p>
+ <p>Free: {{ bytes(free, 1) }}</p>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import XPie from './pie.vue';
+import bytes from '@/filters/bytes';
+
+const props = defineProps<{
+ connection: any,
+ meta: any
+}>();
+
+let usage: number = $ref(0);
+let total: number = $ref(0);
+let used: number = $ref(0);
+let free: number = $ref(0);
+
+function onStats(stats) {
+ usage = stats.mem.active / props.meta.mem.total;
+ total = props.meta.mem.total;
+ used = stats.mem.active;
+ free = total - used;
+}
+
+onMounted(() => {
+ props.connection.on('stats', onStats);
+});
+
+onBeforeUnmount(() => {
+ props.connection.off('stats', onStats);
+});
+</script>
+
+<style lang="scss" scoped>
+.zlxnikvl {
+ display: flex;
+ padding: 16px;
+
+ > .pie {
+ height: 82px;
+ flex-shrink: 0;
+ margin-right: 16px;
+ }
+
+ > div {
+ flex: 1;
+
+ > p {
+ margin: 0;
+ font-size: 0.8em;
+
+ &:first-child {
+ font-weight: bold;
+ margin-bottom: 4px;
+
+ > i {
+ margin-right: 4px;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/net.vue b/packages/frontend/src/widgets/server-metric/net.vue
new file mode 100644
index 0000000000..ab8b0fe471
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/net.vue
@@ -0,0 +1,140 @@
+<template>
+<div class="oxxrhrto">
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <polygon
+ :points="inPolygonPoints"
+ fill="#94a029"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="inPolylinePoints"
+ fill="none"
+ stroke="#94a029"
+ stroke-width="1"
+ />
+ <circle
+ :cx="inHeadX"
+ :cy="inHeadY"
+ r="1.5"
+ fill="#94a029"
+ />
+ <text x="1" y="5">NET rx <tspan>{{ bytes(inRecent) }}</tspan></text>
+ </svg>
+ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`">
+ <polygon
+ :points="outPolygonPoints"
+ fill="#ff9156"
+ fill-opacity="0.5"
+ />
+ <polyline
+ :points="outPolylinePoints"
+ fill="none"
+ stroke="#ff9156"
+ stroke-width="1"
+ />
+ <circle
+ :cx="outHeadX"
+ :cy="outHeadY"
+ r="1.5"
+ fill="#ff9156"
+ />
+ <text x="1" y="5">NET tx <tspan>{{ bytes(outRecent) }}</tspan></text>
+ </svg>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onBeforeUnmount } from 'vue';
+import bytes from '@/filters/bytes';
+
+const props = defineProps<{
+ connection: any,
+ meta: any
+}>();
+
+let viewBoxX: number = $ref(50);
+let viewBoxY: number = $ref(30);
+let stats: any[] = $ref([]);
+let inPolylinePoints: string = $ref('');
+let outPolylinePoints: string = $ref('');
+let inPolygonPoints: string = $ref('');
+let outPolygonPoints: string = $ref('');
+let inHeadX: any = $ref(null);
+let inHeadY: any = $ref(null);
+let outHeadX: any = $ref(null);
+let outHeadY: any = $ref(null);
+let inRecent: number = $ref(0);
+let outRecent: number = $ref(0);
+
+onMounted(() => {
+ props.connection.on('stats', onStats);
+ props.connection.on('statsLog', onStatsLog);
+ props.connection.send('requestLog', {
+ id: Math.random().toString().substr(2, 8),
+ });
+});
+
+onBeforeUnmount(() => {
+ props.connection.off('stats', onStats);
+ props.connection.off('statsLog', onStatsLog);
+});
+
+function onStats(connStats) {
+ stats.push(connStats);
+ if (stats.length > 50) stats.shift();
+
+ const inPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.rx)));
+ const outPeak = Math.max(1024 * 64, Math.max(...stats.map(s => s.net.tx)));
+
+ let inPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.rx / inPeak)) * viewBoxY]);
+ let outPolylinePointsStats = stats.map((s, i) => [viewBoxX - ((stats.length - 1) - i), (1 - (s.net.tx / outPeak)) * viewBoxY]);
+ inPolylinePoints = inPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+ outPolylinePoints = outPolylinePointsStats.map(xy => `${xy[0]},${xy[1]}`).join(' ');
+
+ inPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${inPolylinePoints} ${viewBoxX},${viewBoxY}`;
+ outPolygonPoints = `${viewBoxX - (stats.length - 1)},${viewBoxY} ${outPolylinePoints} ${viewBoxX},${viewBoxY}`;
+
+ inHeadX = inPolylinePointsStats[inPolylinePointsStats.length - 1][0];
+ inHeadY = inPolylinePointsStats[inPolylinePointsStats.length - 1][1];
+ outHeadX = outPolylinePointsStats[outPolylinePointsStats.length - 1][0];
+ outHeadY = outPolylinePointsStats[outPolylinePointsStats.length - 1][1];
+
+ inRecent = connStats.net.rx;
+ outRecent = connStats.net.tx;
+}
+
+function onStatsLog(statsLog) {
+ for (const revStats of [...statsLog].reverse()) {
+ onStats(revStats);
+ }
+}
+</script>
+
+<style lang="scss" scoped>
+.oxxrhrto {
+ display: flex;
+
+ > svg {
+ display: block;
+ padding: 10px;
+ width: 50%;
+
+ &:first-child {
+ padding-right: 5px;
+ }
+
+ &:last-child {
+ padding-left: 5px;
+ }
+
+ > text {
+ font-size: 4.5px;
+ fill: currentColor;
+
+ > tspan {
+ opacity: 0.5;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/server-metric/pie.vue b/packages/frontend/src/widgets/server-metric/pie.vue
new file mode 100644
index 0000000000..868dbc0484
--- /dev/null
+++ b/packages/frontend/src/widgets/server-metric/pie.vue
@@ -0,0 +1,52 @@
+<template>
+<svg class="hsalcinq" viewBox="0 0 1 1" preserveAspectRatio="none">
+ <circle
+ :r="r"
+ cx="50%" cy="50%"
+ fill="none"
+ stroke-width="0.1"
+ stroke="rgba(0, 0, 0, 0.05)"
+ />
+ <circle
+ :r="r"
+ cx="50%" cy="50%"
+ :stroke-dasharray="Math.PI * (r * 2)"
+ :stroke-dashoffset="strokeDashoffset"
+ fill="none"
+ stroke-width="0.1"
+ :stroke="color"
+ />
+ <text x="50%" y="50%" dy="0.05" text-anchor="middle">{{ (value * 100).toFixed(0) }}%</text>
+</svg>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+
+const props = defineProps<{
+ value: number;
+}>();
+
+const r = 0.45;
+
+const color = $computed(() => `hsl(${180 - (props.value * 180)}, 80%, 70%)`);
+const strokeDashoffset = $computed(() => (1 - props.value) * (Math.PI * (r * 2)));
+</script>
+
+<style lang="scss" scoped>
+.hsalcinq {
+ display: block;
+ height: 100%;
+
+ > circle {
+ transform-origin: center;
+ transform: rotate(-90deg);
+ transition: stroke-dashoffset 0.5s ease;
+ }
+
+ > text {
+ font-size: 0.15px;
+ fill: currentColor;
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/slideshow.vue b/packages/frontend/src/widgets/slideshow.vue
new file mode 100644
index 0000000000..e317b8ab94
--- /dev/null
+++ b/packages/frontend/src/widgets/slideshow.vue
@@ -0,0 +1,159 @@
+<template>
+<div class="kvausudm _panel mkw-slideshow" :style="{ height: widgetProps.height + 'px' }">
+ <div @click="choose">
+ <p v-if="widgetProps.folderId == null">
+ {{ i18n.ts.folder }}
+ </p>
+ <p v-if="widgetProps.folderId != null && images.length === 0 && !fetching">{{ $t('no-image') }}</p>
+ <div ref="slideA" class="slide a"></div>
+ <div ref="slideB" class="slide b"></div>
+ </div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const name = 'slideshow';
+
+const widgetPropsDef = {
+ height: {
+ type: 'number' as const,
+ default: 300,
+ },
+ folderId: {
+ type: 'string' as const,
+ default: null,
+ hidden: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const images = ref([]);
+const fetching = ref(true);
+const slideA = ref<HTMLElement>();
+const slideB = ref<HTMLElement>();
+
+const change = () => {
+ if (images.value.length === 0) return;
+
+ const index = Math.floor(Math.random() * images.value.length);
+ const img = `url(${ images.value[index].url })`;
+
+ slideB.value.style.backgroundImage = img;
+
+ slideB.value.classList.add('anime');
+ window.setTimeout(() => {
+ // 既にこのウィジェットがunmountされていたら要素がない
+ if (slideA.value == null) return;
+
+ slideA.value.style.backgroundImage = img;
+
+ slideB.value.classList.remove('anime');
+ }, 1000);
+};
+
+const fetch = () => {
+ fetching.value = true;
+
+ os.api('drive/files', {
+ folderId: widgetProps.folderId,
+ type: 'image/*',
+ limit: 100,
+ }).then(res => {
+ images.value = res;
+ fetching.value = false;
+ slideA.value.style.backgroundImage = '';
+ slideB.value.style.backgroundImage = '';
+ change();
+ });
+};
+
+const choose = () => {
+ os.selectDriveFolder(false).then(folder => {
+ if (folder == null) {
+ return;
+ }
+ widgetProps.folderId = folder.id;
+ save();
+ fetch();
+ });
+};
+
+useInterval(change, 10000, {
+ immediate: false,
+ afterMounted: true,
+});
+
+onMounted(() => {
+ if (widgetProps.folderId != null) {
+ fetch();
+ }
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.kvausudm {
+ position: relative;
+
+ > div {
+ width: 100%;
+ height: 100%;
+ cursor: pointer;
+
+ > p {
+ display: block;
+ margin: 1em;
+ text-align: center;
+ color: #888;
+ }
+
+ > * {
+ pointer-events: none;
+ }
+
+ > .slide {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-size: cover;
+ background-position: center;
+
+ &.b {
+ opacity: 0;
+ }
+
+ &.anime {
+ transition: opacity 1s;
+ opacity: 1;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/timeline.vue b/packages/frontend/src/widgets/timeline.vue
new file mode 100644
index 0000000000..e48444d33f
--- /dev/null
+++ b/packages/frontend/src/widgets/timeline.vue
@@ -0,0 +1,129 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" :style="`height: ${widgetProps.height}px;`" :scrollable="true" class="mkw-timeline">
+ <template #header>
+ <button class="_button" @click="choose">
+ <i v-if="widgetProps.src === 'home'" class="ti ti-home"></i>
+ <i v-else-if="widgetProps.src === 'local'" class="ti ti-messages"></i>
+ <i v-else-if="widgetProps.src === 'social'" class="ti ti-share"></i>
+ <i v-else-if="widgetProps.src === 'global'" class="ti ti-world"></i>
+ <i v-else-if="widgetProps.src === 'list'" class="ti ti-list"></i>
+ <i v-else-if="widgetProps.src === 'antenna'" class="ti ti-antenna"></i>
+ <span style="margin-left: 8px;">{{ widgetProps.src === 'list' ? widgetProps.list.name : widgetProps.src === 'antenna' ? widgetProps.antenna.name : $t('_timelines.' + widgetProps.src) }}</span>
+ <i :class="menuOpened ? 'ti ti-chevron-up' : 'ti ti-chevron-down'" style="margin-left: 8px;"></i>
+ </button>
+ </template>
+
+ <div>
+ <XTimeline :key="widgetProps.src === 'list' ? `list:${widgetProps.list.id}` : widgetProps.src === 'antenna' ? `antenna:${widgetProps.antenna.id}` : widgetProps.src" :src="widgetProps.src" :list="widgetProps.list ? widgetProps.list.id : null" :antenna="widgetProps.antenna ? widgetProps.antenna.id : null"/>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, reactive, ref, watch } from 'vue';
+import { GetFormResultType } from '@/scripts/form';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import * as os from '@/os';
+import MkContainer from '@/components/MkContainer.vue';
+import XTimeline from '@/components/MkTimeline.vue';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
+
+const name = 'timeline';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ height: {
+ type: 'number' as const,
+ default: 300,
+ },
+ src: {
+ type: 'string' as const,
+ default: 'home',
+ hidden: true,
+ },
+ antenna: {
+ type: 'object' as const,
+ default: null,
+ hidden: true,
+ },
+ list: {
+ type: 'object' as const,
+ default: null,
+ hidden: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const menuOpened = ref(false);
+
+const setSrc = (src) => {
+ widgetProps.src = src;
+ save();
+};
+
+const choose = async (ev) => {
+ menuOpened.value = true;
+ const [antennas, lists] = await Promise.all([
+ os.api('antennas/list'),
+ os.api('users/lists/list'),
+ ]);
+ const antennaItems = antennas.map(antenna => ({
+ text: antenna.name,
+ icon: 'ti ti-antenna',
+ action: () => {
+ widgetProps.antenna = antenna;
+ setSrc('antenna');
+ },
+ }));
+ const listItems = lists.map(list => ({
+ text: list.name,
+ icon: 'ti ti-list',
+ action: () => {
+ widgetProps.list = list;
+ setSrc('list');
+ },
+ }));
+ os.popupMenu([{
+ text: i18n.ts._timelines.home,
+ icon: 'ti ti-home',
+ action: () => { setSrc('home'); },
+ }, {
+ text: i18n.ts._timelines.local,
+ icon: 'ti ti-messages',
+ action: () => { setSrc('local'); },
+ }, {
+ text: i18n.ts._timelines.social,
+ icon: 'ti ti-share',
+ action: () => { setSrc('social'); },
+ }, {
+ text: i18n.ts._timelines.global,
+ icon: 'ti ti-world',
+ action: () => { setSrc('global'); },
+ }, antennaItems.length > 0 ? null : undefined, ...antennaItems, listItems.length > 0 ? null : undefined, ...listItems], ev.currentTarget ?? ev.target).then(() => {
+ menuOpened.value = false;
+ });
+};
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
diff --git a/packages/frontend/src/widgets/trends.vue b/packages/frontend/src/widgets/trends.vue
new file mode 100644
index 0000000000..02eec0431e
--- /dev/null
+++ b/packages/frontend/src/widgets/trends.vue
@@ -0,0 +1,120 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-trends">
+ <template #header><i class="ti ti-hash"></i>{{ i18n.ts._widgets.trends }}</template>
+
+ <div class="wbrkwala">
+ <MkLoading v-if="fetching"/>
+ <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="tags">
+ <div v-for="stat in stats" :key="stat.tag">
+ <div class="tag">
+ <MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
+ <p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
+ </div>
+ <MkMiniChart class="chart" :src="stat.chart"/>
+ </div>
+ </transition-group>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import MkMiniChart from '@/components/MkMiniChart.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+
+const name = 'hashtags';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+const stats = ref([]);
+const fetching = ref(true);
+
+const fetch = () => {
+ os.api('hashtags/trend').then(res => {
+ stats.value = res;
+ fetching.value = false;
+ });
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.wbrkwala {
+ height: (62px + 1px) + (62px + 1px) + (62px + 1px) + (62px + 1px) + 62px;
+ overflow: hidden;
+
+ > .tags {
+ .chart-move {
+ transition: transform 1s ease;
+ }
+
+ > div {
+ display: flex;
+ align-items: center;
+ padding: 14px 16px;
+ border-bottom: solid 0.5px var(--divider);
+
+ > .tag {
+ flex: 1;
+ overflow: hidden;
+ font-size: 0.9em;
+ color: var(--fg);
+
+ > .a {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 18px;
+ }
+
+ > p {
+ margin: 0;
+ font-size: 75%;
+ opacity: 0.7;
+ line-height: 16px;
+ }
+ }
+
+ > .chart {
+ height: 30px;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/unix-clock.vue b/packages/frontend/src/widgets/unix-clock.vue
new file mode 100644
index 0000000000..cf85ac782c
--- /dev/null
+++ b/packages/frontend/src/widgets/unix-clock.vue
@@ -0,0 +1,116 @@
+<template>
+<div class="mkw-unixClock _monospace" :class="{ _panel: !widgetProps.transparent }" :style="{ fontSize: `${widgetProps.fontSize}em` }">
+ <div v-if="widgetProps.showLabel" class="label">UNIX Epoch</div>
+ <div class="time">
+ <span v-text="ss"></span>
+ <span v-if="widgetProps.showMs" class="colon" :class="{ showColon }">:</span>
+ <span v-if="widgetProps.showMs" v-text="ms"></span>
+ </div>
+ <div v-if="widgetProps.showLabel" class="label">UTC</div>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { onUnmounted, ref, watch } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+
+const name = 'unixClock';
+
+const widgetPropsDef = {
+ transparent: {
+ type: 'boolean' as const,
+ default: false,
+ },
+ fontSize: {
+ type: 'number' as const,
+ default: 1.5,
+ step: 0.1,
+ },
+ showMs: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ showLabel: {
+ type: 'boolean' as const,
+ default: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+let intervalId;
+const ss = ref('');
+const ms = ref('');
+const showColon = ref(false);
+let prevSec: string | null = null;
+
+watch(showColon, (v) => {
+ if (v) {
+ window.setTimeout(() => {
+ showColon.value = false;
+ }, 30);
+ }
+});
+
+const tick = () => {
+ const now = new Date();
+ ss.value = Math.floor(now.getTime() / 1000).toString();
+ ms.value = Math.floor(now.getTime() % 1000 / 10).toString().padStart(2, '0');
+ if (ss.value !== prevSec) showColon.value = true;
+ prevSec = ss.value;
+};
+
+tick();
+
+watch(() => widgetProps.showMs, () => {
+ if (intervalId) window.clearInterval(intervalId);
+ intervalId = window.setInterval(tick, widgetProps.showMs ? 10 : 1000);
+}, { immediate: true });
+
+onUnmounted(() => {
+ window.clearInterval(intervalId);
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" scoped>
+.mkw-unixClock {
+ padding: 16px 0;
+ text-align: center;
+
+ > .label {
+ font-size: 65%;
+ opacity: 0.7;
+ }
+
+ > .time {
+ > .colon {
+ opacity: 0;
+ transition: opacity 1s ease;
+
+ &.showColon {
+ opacity: 1;
+ transition: opacity 0s;
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/user-list.vue b/packages/frontend/src/widgets/user-list.vue
new file mode 100644
index 0000000000..9ffbf0d8e3
--- /dev/null
+++ b/packages/frontend/src/widgets/user-list.vue
@@ -0,0 +1,136 @@
+<template>
+<MkContainer :show-header="widgetProps.showHeader" class="mkw-userList">
+ <template #header><i class="ti ti-users"></i>{{ list ? list.name : i18n.ts._widgets.userList }}</template>
+ <template #func><button class="_button" @click="configure()"><i class="ti ti-settings"></i></button></template>
+
+ <div :class="$style.root">
+ <div v-if="widgetProps.listId == null" class="init">
+ <MkButton primary @click="chooseList">{{ i18n.ts._widgets._userList.chooseList }}</MkButton>
+ </div>
+ <MkLoading v-else-if="fetching"/>
+ <div v-else class="users">
+ <MkA v-for="user in users" :key="user.id" class="user">
+ <MkAvatar :user="user" class="avatar" :show-indicator="true"/>
+ </MkA>
+ </div>
+ </div>
+</MkContainer>
+</template>
+
+<script lang="ts" setup>
+import { onMounted, onUnmounted, ref } from 'vue';
+import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
+import { GetFormResultType } from '@/scripts/form';
+import MkContainer from '@/components/MkContainer.vue';
+import * as os from '@/os';
+import { useInterval } from '@/scripts/use-interval';
+import { i18n } from '@/i18n';
+import MkButton from '@/components/MkButton.vue';
+
+const name = 'userList';
+
+const widgetPropsDef = {
+ showHeader: {
+ type: 'boolean' as const,
+ default: true,
+ },
+ listId: {
+ type: 'string' as const,
+ default: null,
+ hidden: true,
+ },
+};
+
+type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
+
+// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
+//const props = defineProps<WidgetComponentProps<WidgetProps>>();
+//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
+const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
+const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
+
+const { widgetProps, configure, save } = useWidgetPropsManager(name,
+ widgetPropsDef,
+ props,
+ emit,
+);
+
+let list = $ref();
+let users = $ref([]);
+let fetching = $ref(true);
+
+async function chooseList() {
+ const lists = await os.api('users/lists/list');
+ const { canceled, result: list } = await os.select({
+ title: i18n.ts.selectList,
+ items: lists.map(x => ({
+ value: x, text: x.name,
+ })),
+ default: widgetProps.listId,
+ });
+ if (canceled) return;
+
+ widgetProps.listId = list.id;
+ save();
+ fetch();
+}
+
+const fetch = () => {
+ if (widgetProps.listId == null) {
+ fetching = false;
+ return;
+ }
+
+ os.api('users/lists/show', {
+ listId: widgetProps.listId,
+ }).then(_list => {
+ list = _list;
+ os.api('users/show', {
+ userIds: list.userIds,
+ }).then(_users => {
+ users = _users;
+ fetching = false;
+ });
+ });
+};
+
+useInterval(fetch, 1000 * 60, {
+ immediate: true,
+ afterMounted: true,
+});
+
+defineExpose<WidgetComponentExpose>({
+ name,
+ configure,
+ id: props.widget ? props.widget.id : null,
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ &:global {
+ > .init {
+ padding: 16px;
+ }
+
+ > .users {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(30px, 40px));
+ grid-gap: 12px;
+ place-content: center;
+ padding: 16px;
+
+ > .user {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 1;
+
+ > .avatar {
+ width: 100%;
+ height: 100%;
+ }
+ }
+ }
+ }
+}
+</style>
diff --git a/packages/frontend/src/widgets/widget.ts b/packages/frontend/src/widgets/widget.ts
new file mode 100644
index 0000000000..8bd56a5966
--- /dev/null
+++ b/packages/frontend/src/widgets/widget.ts
@@ -0,0 +1,73 @@
+import { reactive, watch } from 'vue';
+import { throttle } from 'throttle-debounce';
+import { Form, GetFormResultType } from '@/scripts/form';
+import * as os from '@/os';
+import { deepClone } from '@/scripts/clone';
+
+export type Widget<P extends Record<string, unknown>> = {
+ id: string;
+ data: Partial<P>;
+};
+
+export type WidgetComponentProps<P extends Record<string, unknown>> = {
+ widget?: Widget<P>;
+};
+
+export type WidgetComponentEmits<P extends Record<string, unknown>> = {
+ (ev: 'updateProps', props: P);
+};
+
+export type WidgetComponentExpose = {
+ name: string;
+ id: string | null;
+ configure: () => void;
+};
+
+export const useWidgetPropsManager = <F extends Form & Record<string, { default: any; }>>(
+ name: string,
+ propsDef: F,
+ props: Readonly<WidgetComponentProps<GetFormResultType<F>>>,
+ emit: WidgetComponentEmits<GetFormResultType<F>>,
+): {
+ widgetProps: GetFormResultType<F>;
+ save: () => void;
+ configure: () => void;
+} => {
+ const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {});
+
+ const mergeProps = () => {
+ for (const prop of Object.keys(propsDef)) {
+ if (typeof widgetProps[prop] === 'undefined') {
+ widgetProps[prop] = propsDef[prop].default;
+ }
+ }
+ };
+ watch(widgetProps, () => {
+ mergeProps();
+ }, { deep: true, immediate: true });
+
+ const save = throttle(3000, () => {
+ emit('updateProps', widgetProps);
+ });
+
+ const configure = async () => {
+ const form = deepClone(propsDef);
+ for (const item of Object.keys(form)) {
+ form[item].default = widgetProps[item];
+ }
+ const { canceled, result } = await os.form(name, form);
+ if (canceled) return;
+
+ for (const key of Object.keys(result)) {
+ widgetProps[key] = result[key];
+ }
+
+ save();
+ };
+
+ return {
+ widgetProps,
+ save,
+ configure,
+ };
+};