All checks were successful
Gitea Actions Demo / build-and-push (push) Successful in 13s
172 lines
4.9 KiB
Vue
172 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
|
import { defineProps, defineEmits } from 'vue'
|
|
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
|
import type { Task } from '@/common/models'
|
|
|
|
const imageCacheName = 'images-v1'
|
|
|
|
const props = defineProps<{
|
|
title: string
|
|
taskIds: string[]
|
|
childId: string | number | null
|
|
isParentAuthenticated: boolean
|
|
filterType?: number | null
|
|
}>()
|
|
const emit = defineEmits<{
|
|
(e: 'trigger-task', task: Task): void
|
|
}>()
|
|
|
|
const tasks = ref<Task[]>([])
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
|
const taskRefs = ref<Record<string, HTMLElement | null>>({})
|
|
|
|
const lastCenteredTaskId = ref<string | null>(null)
|
|
const readyTaskId = ref<string | null>(null)
|
|
|
|
const fetchTasks = async () => {
|
|
const taskPromises = props.taskIds.map((id) =>
|
|
fetch(`/api/task/${id}`).then((res) => {
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
return res.json()
|
|
}),
|
|
)
|
|
|
|
try {
|
|
const results = await Promise.all(taskPromises)
|
|
tasks.value = results
|
|
|
|
// Fetch images for each task (uses shared imageCache)
|
|
await Promise.all(tasks.value.map(fetchImage))
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
|
console.error('Error fetching tasks:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const fetchImage = async (task: Task) => {
|
|
if (!task.image_id) {
|
|
console.log(`No image ID for task: ${task.id}`)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const url = await getCachedImageUrl(task.image_id, imageCacheName)
|
|
task.image_url = url
|
|
} catch (err) {
|
|
console.error('Error fetching image for task', task.id, err)
|
|
}
|
|
}
|
|
|
|
const centerTask = async (taskId: string) => {
|
|
await nextTick()
|
|
const wrapper = scrollWrapper.value
|
|
const card = taskRefs.value[taskId]
|
|
if (wrapper && card) {
|
|
const wrapperRect = wrapper.getBoundingClientRect()
|
|
const cardRect = card.getBoundingClientRect()
|
|
const wrapperScrollLeft = wrapper.scrollLeft
|
|
const cardCenter = cardRect.left + cardRect.width / 2
|
|
const wrapperCenter = wrapperRect.left + wrapperRect.width / 2
|
|
const scrollOffset = cardCenter - wrapperCenter
|
|
wrapper.scrollTo({
|
|
left: wrapperScrollLeft + scrollOffset,
|
|
behavior: 'smooth',
|
|
})
|
|
}
|
|
}
|
|
|
|
const triggerTask = (taskId: string) => {
|
|
const task = tasks.value.find((t) => t.id === taskId)
|
|
if (task) emit('trigger-task', task)
|
|
}
|
|
|
|
const handleTaskClick = async (taskId: string) => {
|
|
await nextTick()
|
|
const wrapper = scrollWrapper.value
|
|
const card = taskRefs.value[taskId]
|
|
if (!wrapper || !card) return
|
|
|
|
const wrapperRect = wrapper.getBoundingClientRect()
|
|
const cardRect = card.getBoundingClientRect()
|
|
const cardCenter = cardRect.left + cardRect.width / 2
|
|
const cardFullyVisible = cardCenter >= wrapperRect.left && cardCenter <= wrapperRect.right
|
|
|
|
if (!cardFullyVisible || lastCenteredTaskId.value !== taskId) {
|
|
// Center the task, but don't trigger
|
|
await centerTask(taskId)
|
|
lastCenteredTaskId.value = taskId
|
|
readyTaskId.value = taskId
|
|
return
|
|
}
|
|
|
|
// If already centered and visible, emit to parent
|
|
triggerTask(taskId)
|
|
readyTaskId.value = null
|
|
}
|
|
|
|
const filteredTasks = computed(() => {
|
|
if (props.filterType == 1) {
|
|
return tasks.value.filter((t) => t.is_good)
|
|
} else if (props.filterType == 2) {
|
|
return tasks.value.filter((t) => !t.is_good)
|
|
}
|
|
return tasks.value
|
|
})
|
|
|
|
watch(
|
|
() => props.taskIds,
|
|
(newTaskIds) => {
|
|
if (newTaskIds && newTaskIds.length > 0) {
|
|
fetchTasks()
|
|
} else {
|
|
tasks.value = []
|
|
loading.value = false
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
// revoke all created object URLs when component unmounts
|
|
onBeforeUnmount(() => {
|
|
revokeAllImageUrls()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="child-list-container">
|
|
<h3>{{ title }}</h3>
|
|
|
|
<div v-if="loading" class="loading">Loading tasks...</div>
|
|
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
|
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
|
|
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
|
<div class="item-scroll">
|
|
<div
|
|
v-for="task in filteredTasks"
|
|
:key="task.id"
|
|
class="item-card"
|
|
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
|
|
:ref="(el) => (taskRefs[task.id] = el)"
|
|
@click="() => handleTaskClick(task.id)"
|
|
>
|
|
<div class="item-name">{{ task.name }}</div>
|
|
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
|
|
<div
|
|
class="item-points"
|
|
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
|
|
>
|
|
{{ task.is_good ? task.points : -task.points }} Points
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped></style>
|