diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-04-19 14:00:38 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-19 14:00:38 +0900 |
| commit | 7b38806413b84bd20070f3c81b899e5b890b1a8b (patch) | |
| tree | 2385b32546d410ff2e5e208793f7ad82ad6623ab /packages/frontend/src/components | |
| parent | fix(storybook): implement missing stories (#15862) (diff) | |
| download | misskey-7b38806413b84bd20070f3c81b899e5b890b1a8b.tar.gz misskey-7b38806413b84bd20070f3c81b899e5b890b1a8b.tar.bz2 misskey-7b38806413b84bd20070f3c81b899e5b890b1a8b.zip | |
feat: Job queue inspector (#15856)
* wip
* wip
* Update job-queue.vue
* wip
* wip
* Update job-queue.vue
* wip
* Update job-queue.vue
* wip
* Update QueueService.ts
* Update QueueService.ts
* Update QueueService.ts
* Update job-queue.vue
* wip
* wip
* wip
* Update job-queue.vue
* wip
* Update MkTl.vue
* wip
* Update index.vue
* wip
* wip
* Update MkTl.vue
* 🎨
* jobs search
* wip
* Update job-queue.vue
* wip
* wip
* Update job-queue.vue
* Update job-queue.vue
* Update job-queue.vue
* Update job-queue.vue
* wip
* Update job-queue.job.vue
* wip
* wip
* wip
* Update MkCode.vue
* wip
* Update job-queue.job.vue
* wip
* Update job-queue.job.vue
* Update misskey-js.api.md
* Update CHANGELOG.md
* Update job-queue.job.vue
Diffstat (limited to 'packages/frontend/src/components')
| -rw-r--r-- | packages/frontend/src/components/MkCode.vue | 8 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkFolder.vue | 46 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkTabs.vue | 235 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkTl.vue | 173 |
4 files changed, 443 insertions, 19 deletions
diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index d2d9f320ee..f41cb0d00b 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template #fallback> <MkLoading/> </template> - <XCode v-if="show && lang" :code="code" :lang="lang"/> - <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/> + <pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> <div :class="$style.codePlaceholderContainer"> <div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div> @@ -70,11 +70,9 @@ function copy() { .codeBlockFallbackRoot { display: block; overflow-wrap: anywhere; - background: var(--MI_THEME-bg); padding: 1em; - margin: .5em 0; + margin: 0; overflow: auto; - border-radius: 8px; } .codeBlockFallbackCode { diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 8b4bacba69..81689397cc 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -38,15 +38,26 @@ SPDX-License-Identifier: AGPL-3.0-only > <KeepAlive> <div v-show="opened"> - <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> - <slot></slot> - </MkSpacer> - <div v-else> - <slot></slot> - </div> - <div v-if="$slots.footer" :class="$style.footer"> - <slot name="footer"></slot> - </div> + <MkStickyContainer> + <template #header> + <div v-if="$slots.header" :class="$style.inBodyHeader"> + <slot name="header"></slot> + </div> + </template> + + <MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax"> + <slot></slot> + </MkSpacer> + <div v-else> + <slot></slot> + </div> + + <template #footer> + <div v-if="$slots.footer" :class="$style.inBodyFooter"> + <slot name="footer"></slot> + </div> + </template> + </MkStickyContainer> </div> </KeepAlive> </Transition> @@ -230,14 +241,21 @@ onMounted(() => { &.bgSame { background: var(--MI_THEME-bg); + + .inBodyHeader { + background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); + } } } -.footer { - position: sticky !important; - z-index: 1; - bottom: var(--MI-stickyBottom, 0px); - left: 0; +.inBodyHeader { + background: color(from var(--MI_THEME-panel) srgb r g b / 0.75); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); + border-bottom: solid 0.5px var(--MI_THEME-divider); +} + +.inBodyFooter { padding: 12px; background: color(from var(--MI_THEME-bg) srgb r g b / 0.5); -webkit-backdrop-filter: var(--MI-blur, blur(15px)); diff --git a/packages/frontend/src/components/MkTabs.vue b/packages/frontend/src/components/MkTabs.vue new file mode 100644 index 0000000000..a1f30100d0 --- /dev/null +++ b/packages/frontend/src/components/MkTabs.vue @@ -0,0 +1,235 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.tabs"> + <div :class="$style.tabsInner"> + <button + v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" + class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]" + @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)" + > + <div :class="$style.tabInner"> + <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> + <div + v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)" + :class="$style.tabTitle" + > + {{ t.title }} + </div> + <Transition + v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave" + @afterLeave="afterLeave" + > + <div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div> + </Transition> + </div> + </button> + </div> + <div + ref="tabHighlightEl" + :class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]" + ></div> +</div> +</template> + +<script lang="ts"> +export type Tab = { + key: string; + onClick?: (ev: MouseEvent) => void; +} & ( + | { + iconOnly?: false; + title: string; + icon?: string; + } + | { + iconOnly: true; + icon: string; + } +); +</script> + +<script lang="ts" setup> +import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue'; +import { prefer } from '@/preferences.js'; + +const props = withDefaults(defineProps<{ + tabs?: Tab[]; + tab?: string; +}>(), { + tabs: () => ([] as Tab[]), +}); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); + (ev: 'tabClick', key: string); +}>(); + +const tabHighlightEl = useTemplateRef('tabHighlightEl'); +const tabRefs: Record<string, HTMLElement | null> = {}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(t: Tab, ev: MouseEvent): void { + emit('tabClick', t.key); + + if (t.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + t.onClick(ev); + } + + if (t.key) { + emit('update:tab', t.key); + } +} + +function renderTab() { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.value.style.width = rect.width + 'px'; + tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px'; + } +} + +let entering = false; + +async function enter(el: Element) { + if (!(el instanceof HTMLElement)) return; + entering = true; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = '0'; + el.style.paddingLeft = '0'; + el.offsetWidth; // reflow + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + nextTick(() => { + entering = false; + }); + + window.setTimeout(renderTab, 170); +} + +function afterEnter(el: Element) { + if (!(el instanceof HTMLElement)) return; + // element.style.width = ''; +} + +async function leave(el: Element) { + if (!(el instanceof HTMLElement)) return; + const elementWidth = el.getBoundingClientRect().width; + el.style.width = `${elementWidth}px`; + el.style.paddingLeft = ''; + el.offsetWidth; // reflow + el.style.width = '0'; + el.style.paddingLeft = '0'; +} + +function afterLeave(el: Element) { + if (!(el instanceof HTMLElement)) return; + el.style.width = ''; +} + +onMounted(() => { + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => { + if (entering) return; + renderTab(); + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { +}); +</script> + +<style lang="scss" module> +.tabs { + --height: 40px; + + display: block; + position: relative; + margin: 0; + height: var(--height); + font-size: 85%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tabsInner { + display: inline-block; + height: var(--height); + white-space: nowrap; +} + +.tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + &.animate { + transition: opacity 0.2s ease; + } +} + +.tabInner { + display: flex; + align-items: center; +} + +.tabIcon + .tabTitle { + padding-left: 4px; +} + +.tabTitle { + overflow: hidden; + + &.animate { + transition: width .15s linear, padding-left .15s linear; + } +} + +.tabHighlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--MI_THEME-accent); + border-radius: 999px; + transition: none; + pointer-events: none; + + &.animate { + transition: width 0.15s ease, left 0.15s ease; + } +} +</style> diff --git a/packages/frontend/src/components/MkTl.vue b/packages/frontend/src/components/MkTl.vue new file mode 100644 index 0000000000..95cc4d2a2a --- /dev/null +++ b/packages/frontend/src/components/MkTl.vue @@ -0,0 +1,173 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.items"> + <template v-for="(item, i) in items" :key="item.id"> + <div :class="$style.left"> + <slot v-if="item.type === 'event'" name="left" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + </div> + <div :class="[$style.center, item.type === 'date' ? $style.date : '']"> + <div :class="$style.centerLine"></div> + <div :class="$style.centerPoint"></div> + </div> + <div :class="$style.right"> + <slot v-if="item.type === 'event'" name="right" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot> + <div v-else :class="$style.dateLabel"><i class="ti ti-chevron-up"></i> {{ item.prevText }}</div> + </div> + </template> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; + +const props = defineProps<{ + events: { + id: string; + timestamp: number; + data: any; + }[]; +}>(); + +const events = computed(() => { + return props.events.toSorted((a, b) => b.timestamp - a.timestamp); +}); + +function getDateText(dateInstance: Date) { + const year = dateInstance.getFullYear(); + const month = dateInstance.getMonth() + 1; + const date = dateInstance.getDate(); + const hour = dateInstance.getHours(); + return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`; +} + +const items = computed<({ + id: string; + type: 'event'; + timestamp: number; + delta: number; + data: any; +} | { + id: string; + type: 'date'; + prev: Date; + prevText: string; + next: Date | null; + nextText: string; +})[]>(() => { + const results = []; + for (let i = 0; i < events.value.length; i++) { + const item = events.value[i]; + + const date = new Date(item.timestamp); + const nextDate = events.value[i + 1] ? new Date(events.value[i + 1].timestamp) : null; + + results.push({ + id: item.id, + type: 'event', + timestamp: item.timestamp, + delta: i === events.value.length - 1 ? 0 : item.timestamp - events.value[i + 1].timestamp, + data: item.data, + }); + + if ( + i !== events.value.length - 1 && + nextDate != null && ( + date.getFullYear() !== nextDate.getFullYear() || + date.getMonth() !== nextDate.getMonth() || + date.getDate() !== nextDate.getDate() || + date.getHours() !== nextDate.getHours() + ) + ) { + results.push({ + id: `date-${item.id}`, + type: 'date', + prev: date, + prevText: getDateText(date), + next: nextDate, + nextText: getDateText(nextDate), + }); + } + } + return results; + }); +</script> + +<style lang="scss" module> +.root { + +} + +.items { + display: grid; + grid-template-columns: max-content 18px 1fr; + gap: 0 8px; +} + +.item { +} + +.center { + position: relative; + + &.date { + .centerPoint::before { + position: absolute; + content: ""; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 7px; + height: 7px; + background: var(--MI_THEME-bg); + border-radius: 50%; + } + } +} + +.centerLine { + position: absolute; + top: 0; + left: 0; + right: 0; + margin: auto; + width: 3px; + height: 100%; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); +} +.centerPoint { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; + width: 13px; + height: 13px; + background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%); + border-radius: 50%; +} + +.left { + min-width: 0; + align-self: center; + justify-self: right; +} + +.right { + min-width: 0; + align-self: center; +} + +.dateLabel { + opacity: 0.7; + font-size: 90%; + padding: 4px; + margin: 8px 0; +} +</style> |