round 3
This commit is contained in:
@@ -1,24 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
points: number
|
||||
is_good: boolean
|
||||
image_id: string | null // Ensure image can be null or hold an object URL
|
||||
}
|
||||
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: 'points-updated', payload: { id: string; points: number }): void
|
||||
(e: 'trigger-task', task: Task): void
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
@@ -27,7 +25,6 @@ const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const taskRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
|
||||
const lastCenteredTaskId = ref<string | null>(null)
|
||||
const lastCenterTime = ref<number>(0)
|
||||
const readyTaskId = ref<string | null>(null)
|
||||
|
||||
const fetchTasks = async () => {
|
||||
@@ -60,7 +57,7 @@ const fetchImage = async (task: Task) => {
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(task.image_id, imageCacheName)
|
||||
task.image_id = url
|
||||
task.image_url = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for task', task.id, err)
|
||||
}
|
||||
@@ -84,20 +81,9 @@ const centerTask = async (taskId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerTask = async (taskId: string) => {
|
||||
if (!props.isParentAuthenticated || !props.childId) return
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/trigger-task`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
if (!resp.ok) return
|
||||
const data = await resp.json()
|
||||
emit('points-updated', { id: data.id, points: data.points })
|
||||
} catch (err) {
|
||||
console.error('Failed to trigger task:', err)
|
||||
}
|
||||
const triggerTask = (taskId: string) => {
|
||||
const task = tasks.value.find((t) => t.id === taskId)
|
||||
if (task) emit('trigger-task', task)
|
||||
}
|
||||
|
||||
const handleTaskClick = async (taskId: string) => {
|
||||
@@ -115,16 +101,24 @@ const handleTaskClick = async (taskId: string) => {
|
||||
// Center the task, but don't trigger
|
||||
await centerTask(taskId)
|
||||
lastCenteredTaskId.value = taskId
|
||||
lastCenterTime.value = Date.now()
|
||||
readyTaskId.value = taskId // <-- Add this line
|
||||
readyTaskId.value = taskId
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the task
|
||||
// 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
|
||||
})
|
||||
|
||||
onMounted(fetchTasks)
|
||||
|
||||
// revoke all created object URLs when component unmounts
|
||||
@@ -135,16 +129,15 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="task-list-container">
|
||||
<h3>Tasks</h3>
|
||||
<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="tasks.length === 0" class="empty">No tasks</div>
|
||||
|
||||
<div v-else-if="filteredTasks.length === 0" class="empty">No {{ title }}</div>
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="task-scroll">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
|
||||
@@ -152,7 +145,7 @@ onBeforeUnmount(() => {
|
||||
@click="() => handleTaskClick(task.id)"
|
||||
>
|
||||
<div class="task-name">{{ task.name }}</div>
|
||||
<img v-if="task.image_id" :src="task.image_id" alt="Task Image" class="task-image" />
|
||||
<img v-if="task.image_url" :src="task.image_url" alt="Task Image" class="task-image" />
|
||||
<div
|
||||
class="task-points"
|
||||
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
|
||||
@@ -177,7 +170,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.task-list-container h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { getCachedImageUrl } from '../../common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
childId?: string | number
|
||||
assignable?: boolean
|
||||
assignFilter?: 'assignable' | 'assigned' | 'none'
|
||||
typeFilter?: 'good' | 'bad' | 'all'
|
||||
deletable?: boolean
|
||||
selectable?: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['edit-task', 'delete-task'])
|
||||
|
||||
@@ -16,53 +19,95 @@ const tasks = ref<
|
||||
is_good: boolean
|
||||
image_id?: string | null
|
||||
image_url?: string | null
|
||||
assigned?: boolean
|
||||
}[]
|
||||
>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const selectedTasks = ref<string[]>([])
|
||||
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
let url = ''
|
||||
if (props.childId) {
|
||||
if (props.assignable) {
|
||||
url = `/api/child/${props.childId}/list-assignable-tasks`
|
||||
} else {
|
||||
url = `/api/child/${props.childId}/list-tasks`
|
||||
url = `/api/child/${props.childId}/list-all-tasks`
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const assigned = (data.assigned_tasks || []).map((task: any) => ({ ...task, assigned: true }))
|
||||
const assignable = (data.assignable_tasks || []).map((task: any) => ({
|
||||
...task,
|
||||
assigned: false,
|
||||
}))
|
||||
let taskList: any[] = []
|
||||
if (props.assignFilter === 'assignable') {
|
||||
taskList = assignable
|
||||
} else if (props.assignFilter === 'assigned') {
|
||||
taskList = assigned
|
||||
} else if (props.assignFilter === 'none') {
|
||||
taskList = []
|
||||
} else {
|
||||
taskList = [...assigned, ...assignable]
|
||||
}
|
||||
// Fetch images for each task if image_id is present
|
||||
await Promise.all(
|
||||
taskList.map(async (task: any) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
tasks.value = taskList
|
||||
|
||||
// If selectable, pre-select assigned tasks
|
||||
if (props.selectable) {
|
||||
selectedTasks.value = assigned.map((task: any) => String(task.id))
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
tasks.value = []
|
||||
if (props.selectable) selectedTasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
url = '/api/task/list'
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const taskList = data.tasks || []
|
||||
// Fetch images for each task if image_id is present
|
||||
await Promise.all(
|
||||
taskList.map(async (task: any) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const taskList = data.tasks || []
|
||||
await Promise.all(
|
||||
taskList.map(async (task: any) => {
|
||||
if (task.image_id) {
|
||||
try {
|
||||
task.image_url = await getCachedImageUrl(task.image_id)
|
||||
} catch (e) {
|
||||
task.image_url = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
tasks.value = taskList
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
tasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}),
|
||||
)
|
||||
tasks.value = taskList
|
||||
if (props.selectable) selectedTasks.value = []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
tasks.value = []
|
||||
if (props.selectable) selectedTasks.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
watch(() => [props.childId, props.assignable], fetchTasks)
|
||||
watch(() => [props.childId, props.assignFilter], fetchTasks)
|
||||
|
||||
const handleEdit = (taskId: string) => {
|
||||
emit('edit-task', taskId)
|
||||
@@ -72,7 +117,7 @@ const handleDelete = (taskId: string) => {
|
||||
emit('delete-task', taskId)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: fetchTasks })
|
||||
defineExpose({ refresh: fetchTasks, selectedTasks })
|
||||
|
||||
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
|
||||
const listHeight = computed(() => {
|
||||
@@ -80,6 +125,15 @@ const listHeight = computed(() => {
|
||||
const n = tasks.value.length
|
||||
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
|
||||
})
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
if (props.typeFilter === 'good') {
|
||||
return tasks.value.filter((t) => t.is_good)
|
||||
} else if (props.typeFilter === 'bad') {
|
||||
return tasks.value.filter((t) => !t.is_good)
|
||||
}
|
||||
return tasks.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -88,7 +142,7 @@ const listHeight = computed(() => {
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
|
||||
<div v-else>
|
||||
<div v-for="(task, idx) in tasks" :key="task.id">
|
||||
<div v-for="(task, idx) in filteredTasks" :key="task.id">
|
||||
<div
|
||||
class="task-list-item"
|
||||
:class="{ good: task.is_good, bad: !task.is_good }"
|
||||
@@ -99,6 +153,15 @@ const listHeight = computed(() => {
|
||||
<span class="task-points">
|
||||
{{ task.is_good ? task.points : '-' + task.points }} pts
|
||||
</span>
|
||||
<!-- Add checkbox if selectable -->
|
||||
<input
|
||||
v-if="props.selectable"
|
||||
type="checkbox"
|
||||
class="task-checkbox"
|
||||
v-model="selectedTasks"
|
||||
:value="task.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
v-if="props.deletable"
|
||||
class="delete-btn"
|
||||
@@ -126,7 +189,7 @@ const listHeight = computed(() => {
|
||||
<style scoped>
|
||||
.task-listbox {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
width: auto;
|
||||
max-width: 480px;
|
||||
/* Subtract any header/nav height if needed, e.g. 4.5rem */
|
||||
max-height: calc(100vh - 4.5rem);
|
||||
@@ -223,4 +286,11 @@ const listHeight = computed(() => {
|
||||
.delete-btn svg {
|
||||
display: block;
|
||||
}
|
||||
.task-checkbox {
|
||||
margin-left: 1rem;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
accent-color: #667eea;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user