Queue media preview (#449)

* output url

* Basic image previews

* Split out task item component

* Move task actions to context menu

* simplify

* Move spinner

* Lift context menu to tab scope

* Better tag

* Fix placeholder style

* nit

* Correctly handle cancelled

* nit

* Split out result item as separate component

* nit

* Fix center crop

* nit

* Simplify task item

* Flat list

* Show prompt id

* Make image draggable

* Disable preview for dragging

* Fix key

* Correctly handle task in expanded view

* Add preview
This commit is contained in:
Chenlei Hu
2024-08-15 23:26:38 -04:00
committed by GitHub
parent 9e3dffd7fd
commit e6d29656fa
8 changed files with 397 additions and 125 deletions

View File

@@ -0,0 +1,81 @@
<template>
<div class="result-container" ref="resultContainer">
<Image
v-if="result.mediaType === 'images'"
:src="result.url"
alt="Task Output"
width="100%"
height="100%"
preview
:pt="{ previewMask: { class: 'image-preview-mask' } }"
/>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview">
<i class="pi pi-file"></i>
<span>{{ result.mediaType }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import Image from 'primevue/image'
import { onMounted, ref } from 'vue'
const props = defineProps<{
result: ResultItemImpl
}>()
const resultContainer = ref<HTMLElement | null>(null)
onMounted(() => {
if (props.result.mediaType === 'images') {
resultContainer.value.querySelectorAll('img').forEach((img) => {
img.draggable = true
})
}
})
</script>
<style scoped>
.result-container {
aspect-ratio: 1 / 1;
overflow: hidden;
}
:deep(img) {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
}
.p-image-preview {
position: static;
display: contents;
}
:deep(.image-preview-mask) {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
padding: 10px;
cursor: pointer;
background: rgba(0, 0, 0, 0.5);
color: var(--p-image-preview-mask-color);
transition:
opacity var(--p-image-transition-duration),
background var(--p-image-transition-duration);
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<!-- @contextmenu="handleContextMenu" -->
<div class="task-item">
<div class="task-result-preview">
<div v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
<ResultItem v-if="flatOutputs.length" :result="flatOutputs[0]" />
</div>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Running"
class="pi pi-spin pi-spinner"
></i>
<span v-else-if="task.displayStatus === TaskItemDisplayStatus.Pending"
>...</span
>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Cancelled"
class="pi pi-exclamation-triangle"
></i>
<i
v-else-if="task.displayStatus === TaskItemDisplayStatus.Failed"
class="pi pi-exclamation-circle"
></i>
</div>
<div class="task-item-details">
<div class="tag-wrapper">
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)"></span>
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
</span>
<span v-if="isFlatTask" class="task-prompt-id">
{{ task.promptId.split('-')[0] }}
</span>
</Tag>
</div>
<div class="tag-wrapper">
<Tag v-if="task.isHistory && flatOutputs.length > 1">
<span>{{ flatOutputs.length }}</span>
</Tag>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import ResultItem from './ResultItem.vue'
import { TaskItemDisplayStatus, type TaskItemImpl } from '@/stores/queueStore'
const props = defineProps<{
task: TaskItemImpl
isFlatTask: boolean
}>()
const flatOutputs = props.task.flatOutputs
const emit = defineEmits<{
(e: 'contextmenu', { task: TaskItemImpl, event: MouseEvent }): void
}>()
const handleContextMenu = (e: MouseEvent) => {
emit('contextmenu', { task: props.task, event: e })
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'secondary'
case TaskItemDisplayStatus.Running:
return 'info'
case TaskItemDisplayStatus.Completed:
return 'success'
case TaskItemDisplayStatus.Failed:
return 'danger'
case TaskItemDisplayStatus.Cancelled:
return 'warn'
}
}
const taskStatusText = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
return 'Pending'
case TaskItemDisplayStatus.Running:
return 'Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
return 'Cancelled'
}
}
const formatTime = (time?: number) => {
if (time === undefined) {
return ''
}
return `${time.toFixed(2)}s`
}
</script>
<style scoped>
.task-result-preview {
aspect-ratio: 1 / 1;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.task-result-preview i,
.task-result-preview span {
font-size: 2rem;
}
.task-item {
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
position: relative;
}
.task-item-details {
position: absolute;
bottom: 0;
padding: 0.6rem;
display: flex;
justify-content: space-between;
width: 100%;
}
/* In dark mode, transparent background color for tags is not ideal for tags that
are floating on top of images. */
.tag-wrapper {
background-color: var(--p-primary-contrast-color);
border-radius: 6px;
display: inline-flex;
}
</style>