This commit is contained in:
2025-12-02 17:02:20 -05:00
parent f82ba25160
commit 6423d1c1a2
49 changed files with 2320 additions and 349 deletions

View File

@@ -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;
}

View File

@@ -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>