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