mirror of
https://github.com/sim1222/misskey.git
synced 2025-08-03 23:16:28 +09:00
rename: client -> frontend
This commit is contained in:
81
packages/frontend/src/widgets/activity.calendar.vue
Normal file
81
packages/frontend/src/widgets/activity.calendar.vue
Normal file
@ -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>
|
92
packages/frontend/src/widgets/activity.chart.vue
Normal file
92
packages/frontend/src/widgets/activity.chart.vue
Normal file
@ -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>
|
90
packages/frontend/src/widgets/activity.vue
Normal file
90
packages/frontend/src/widgets/activity.vue
Normal file
@ -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>
|
74
packages/frontend/src/widgets/aichan.vue
Normal file
74
packages/frontend/src/widgets/aichan.vue
Normal file
@ -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>
|
175
packages/frontend/src/widgets/aiscript.vue
Normal file
175
packages/frontend/src/widgets/aiscript.vue
Normal file
@ -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>
|
103
packages/frontend/src/widgets/button.vue
Normal file
103
packages/frontend/src/widgets/button.vue
Normal file
@ -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>
|
213
packages/frontend/src/widgets/calendar.vue
Normal file
213
packages/frontend/src/widgets/calendar.vue
Normal file
@ -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>
|
203
packages/frontend/src/widgets/clock.vue
Normal file
203
packages/frontend/src/widgets/clock.vue
Normal file
@ -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>
|
92
packages/frontend/src/widgets/digital-clock.vue
Normal file
92
packages/frontend/src/widgets/digital-clock.vue
Normal file
@ -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>
|
147
packages/frontend/src/widgets/federation.vue
Normal file
147
packages/frontend/src/widgets/federation.vue
Normal file
@ -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>
|
53
packages/frontend/src/widgets/index.ts
Normal file
53
packages/frontend/src/widgets/index.ts
Normal file
@ -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',
|
||||
];
|
81
packages/frontend/src/widgets/instance-cloud.vue
Normal file
81
packages/frontend/src/widgets/instance-cloud.vue
Normal file
@ -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>
|
197
packages/frontend/src/widgets/job-queue.vue
Normal file
197
packages/frontend/src/widgets/job-queue.vue
Normal file
@ -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>
|
111
packages/frontend/src/widgets/memo.vue
Normal file
111
packages/frontend/src/widgets/memo.vue
Normal file
@ -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>
|
70
packages/frontend/src/widgets/notifications.vue
Normal file
70
packages/frontend/src/widgets/notifications.vue
Normal file
@ -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>
|
78
packages/frontend/src/widgets/online-users.vue
Normal file
78
packages/frontend/src/widgets/online-users.vue
Normal file
@ -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>
|
123
packages/frontend/src/widgets/photos.vue
Normal file
123
packages/frontend/src/widgets/photos.vue
Normal file
@ -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>
|
35
packages/frontend/src/widgets/post-form.vue
Normal file
35
packages/frontend/src/widgets/post-form.vue
Normal file
@ -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>
|
152
packages/frontend/src/widgets/rss-ticker.vue
Normal file
152
packages/frontend/src/widgets/rss-ticker.vue
Normal file
@ -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>
|
96
packages/frontend/src/widgets/rss.vue
Normal file
96
packages/frontend/src/widgets/rss.vue
Normal file
@ -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>
|
167
packages/frontend/src/widgets/server-metric/cpu-mem.vue
Normal file
167
packages/frontend/src/widgets/server-metric/cpu-mem.vue
Normal file
@ -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>
|
65
packages/frontend/src/widgets/server-metric/cpu.vue
Normal file
65
packages/frontend/src/widgets/server-metric/cpu.vue
Normal file
@ -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>
|
57
packages/frontend/src/widgets/server-metric/disk.vue
Normal file
57
packages/frontend/src/widgets/server-metric/disk.vue
Normal file
@ -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>
|
87
packages/frontend/src/widgets/server-metric/index.vue
Normal file
87
packages/frontend/src/widgets/server-metric/index.vue
Normal file
@ -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>
|
73
packages/frontend/src/widgets/server-metric/mem.vue
Normal file
73
packages/frontend/src/widgets/server-metric/mem.vue
Normal file
@ -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>
|
140
packages/frontend/src/widgets/server-metric/net.vue
Normal file
140
packages/frontend/src/widgets/server-metric/net.vue
Normal file
@ -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>
|
52
packages/frontend/src/widgets/server-metric/pie.vue
Normal file
52
packages/frontend/src/widgets/server-metric/pie.vue
Normal file
@ -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>
|
159
packages/frontend/src/widgets/slideshow.vue
Normal file
159
packages/frontend/src/widgets/slideshow.vue
Normal file
@ -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>
|
129
packages/frontend/src/widgets/timeline.vue
Normal file
129
packages/frontend/src/widgets/timeline.vue
Normal file
@ -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>
|
120
packages/frontend/src/widgets/trends.vue
Normal file
120
packages/frontend/src/widgets/trends.vue
Normal file
@ -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>
|
116
packages/frontend/src/widgets/unix-clock.vue
Normal file
116
packages/frontend/src/widgets/unix-clock.vue
Normal file
@ -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>
|
136
packages/frontend/src/widgets/user-list.vue
Normal file
136
packages/frontend/src/widgets/user-list.vue
Normal file
@ -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>
|
73
packages/frontend/src/widgets/widget.ts
Normal file
73
packages/frontend/src/widgets/widget.ts
Normal file
@ -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,
|
||||
};
|
||||
};
|
Reference in New Issue
Block a user