This commit is contained in:
@@ -148,9 +148,6 @@ def set_child_tasks(id):
|
||||
if not isinstance(task_ids, list):
|
||||
return jsonify({'error': 'task_ids must be a list'}), 400
|
||||
|
||||
# Deduplicate and drop falsy values
|
||||
new_task_ids = [tid for tid in dict.fromkeys(task_ids) if tid]
|
||||
|
||||
ChildQuery = Query()
|
||||
result = child_db.search(ChildQuery.id == id)
|
||||
if not result:
|
||||
|
||||
@@ -38,7 +38,11 @@ def get_task(id):
|
||||
|
||||
@task_api.route('/task/list', methods=['GET'])
|
||||
def list_tasks():
|
||||
ids_param = request.args.get('ids')
|
||||
tasks = task_db.all()
|
||||
if ids_param:
|
||||
ids = set(ids_param.split(','))
|
||||
tasks = [task for task in tasks if task.get('id') in ids]
|
||||
return jsonify({'tasks': tasks}), 200
|
||||
|
||||
@task_api.route('/task/<id>', methods=['DELETE'])
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from '../task/ChildTaskList.vue'
|
||||
import ChildRewardList from '../reward/ChildRewardList.vue'
|
||||
import ScrollingList from '../shared/ScrollingList.vue'
|
||||
import { eventBus } from '@/common/eventBus'
|
||||
import '@/assets/view-shared.css'
|
||||
import type {
|
||||
@@ -34,7 +34,7 @@ const showCancelDialog = ref(false)
|
||||
const dialogReward = ref<Reward | null>(null)
|
||||
const childRewardListRef = ref()
|
||||
const childChoreListRef = ref()
|
||||
const childHabitListRef = ref()
|
||||
const childPenaltyListRef = ref()
|
||||
|
||||
function handleTaskTriggered(event: Event) {
|
||||
const payload = event.payload as ChildTaskTriggeredEventPayload
|
||||
@@ -99,6 +99,7 @@ function handleChildModified(event: Event) {
|
||||
if (data) {
|
||||
child.value = data
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
@@ -137,6 +138,8 @@ function handleTaskModified(event: Event) {
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch child after EDIT operation:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
break
|
||||
|
||||
@@ -168,6 +171,9 @@ const triggerTask = (task: Task) => {
|
||||
}
|
||||
}
|
||||
|
||||
const triggerReward = (reward: Reward) => {}
|
||||
/*
|
||||
|
||||
const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) => {
|
||||
if ('speechSynthesis' in window && reward.name) {
|
||||
const utterString =
|
||||
@@ -184,7 +190,7 @@ const triggerReward = (reward: Reward, redeemable: boolean, pending: boolean) =>
|
||||
dialogReward.value = reward
|
||||
showRewardDialog.value = true
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
async function cancelPendingReward() {
|
||||
if (!child.value?.id || !dialogReward.value) return
|
||||
@@ -243,7 +249,6 @@ async function fetchChildData(id: string | number) {
|
||||
console.error(err)
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +258,7 @@ function resetInactivityTimer() {
|
||||
if (inactivityTimer) clearTimeout(inactivityTimer)
|
||||
inactivityTimer = setTimeout(() => {
|
||||
router.push({ name: 'ChildrenListView' })
|
||||
}, 60000) // 60 seconds
|
||||
}, 6000000) // 60 seconds
|
||||
}
|
||||
|
||||
function setupInactivityListeners() {
|
||||
@@ -287,6 +292,7 @@ onMounted(async () => {
|
||||
tasks.value = data.tasks || []
|
||||
rewards.value = data.rewards || []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -318,24 +324,89 @@ onUnmounted(() => {
|
||||
<div v-else class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
<ScrollingList
|
||||
title="Chores"
|
||||
ref="childChoreListRef"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
:filter-type="1"
|
||||
@trigger-task="triggerTask"
|
||||
/>
|
||||
<ChildTaskList
|
||||
title="Bad Habits"
|
||||
ref="childHabitListRef"
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
:filter-type="2"
|
||||
@trigger-task="triggerTask"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Penalties"
|
||||
ref="childPenaltyListRef"
|
||||
:fetchBaseUrl="`/api/task/list?ids=${tasks.join(',')}`"
|
||||
:ids="tasks"
|
||||
itemKey="tasks"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerTask"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img v-if="item.image_url" :src="item.image_url" alt="Task Image" class="item-image" />
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ScrollingList
|
||||
title="Rewards"
|
||||
ref="childRewardListRef"
|
||||
:fetchBaseUrl="`/api/reward/list?ids=${rewards.join(',')}`"
|
||||
:ids="rewards"
|
||||
itemKey="rewards"
|
||||
imageField="image_id"
|
||||
@trigger-item="triggerReward"
|
||||
:getItemClass="(item) => ({ bad: !item.is_good, good: item.is_good })"
|
||||
:filter-fn="
|
||||
(item) => {
|
||||
return !item.is_good
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
<img
|
||||
v-if="item.image_url"
|
||||
:src="item.image_url"
|
||||
alt="Reward Image"
|
||||
class="item-image"
|
||||
/>
|
||||
<div
|
||||
class="item-points"
|
||||
:class="{ 'good-points': item.is_good, 'bad-points': !item.is_good }"
|
||||
>
|
||||
{{ item.is_good ? item.points : -item.points }} Points
|
||||
</div>
|
||||
</template>
|
||||
</ScrollingList>
|
||||
<ChildRewardList
|
||||
ref="childRewardListRef"
|
||||
:child-id="child ? child.id : null"
|
||||
@@ -415,4 +486,27 @@ onUnmounted(() => {
|
||||
justify-content: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.item-points {
|
||||
color: var(--item-points-color, #ffd166);
|
||||
font-size: 1rem;
|
||||
font-weight: 900;
|
||||
text-shadow: var(--item-points-shadow);
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.good) {
|
||||
border-color: var(--list-item-border-good);
|
||||
background: var(--list-item-bg-good);
|
||||
}
|
||||
:deep(.bad) {
|
||||
border-color: var(--list-item-border-bad);
|
||||
background: var(--list-item-bg-bad);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -58,6 +58,7 @@ function goToCreateTask() {
|
||||
async function onSubmit() {
|
||||
const selectedIds = taskListRef.value?.selectedItems ?? []
|
||||
try {
|
||||
console.log('selectedIds:', selectedIds)
|
||||
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -40,6 +40,7 @@ const fetchItems = async () => {
|
||||
console.log('Fetched data:', data)
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
const initiallySelected: string[] = []
|
||||
await Promise.all(
|
||||
itemList.map(async (item: any) => {
|
||||
if (props.imageFields) {
|
||||
@@ -48,6 +49,7 @@ const fetchItems = async () => {
|
||||
try {
|
||||
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
|
||||
} catch {
|
||||
console.error('Error fetching image for item', item.id)
|
||||
item[`${field.replace('_id', '_url')}`] = null
|
||||
}
|
||||
}
|
||||
@@ -61,11 +63,14 @@ const fetchItems = async () => {
|
||||
}
|
||||
//for each item see it there is an 'assigned' field that is true. if so check the item's selectable checkbox
|
||||
if (props.selectable && item.assigned === true) {
|
||||
selectedItems.value.push(item.id)
|
||||
initiallySelected.push(item.id)
|
||||
}
|
||||
}),
|
||||
)
|
||||
items.value = itemList
|
||||
if (props.selectable) {
|
||||
selectedItems.value = initiallySelected
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
@@ -109,7 +114,7 @@ const handleDelete = (item: any) => {
|
||||
type="checkbox"
|
||||
class="list-checkbox"
|
||||
v-model="selectedItems"
|
||||
:value="item"
|
||||
:value="item.id"
|
||||
@click.stop
|
||||
/>
|
||||
<button
|
||||
|
||||
301
web/vue-app/src/components/shared/ScrollingList.vue
Normal file
301
web/vue-app/src/components/shared/ScrollingList.vue
Normal file
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onBeforeUnmount, nextTick, computed } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '@/common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
fetchBaseUrl: string
|
||||
ids: readonly string[]
|
||||
itemKey: string
|
||||
imageFields?: readonly string[]
|
||||
isParentAuthenticated?: boolean
|
||||
filterFn?: (item: any) => boolean
|
||||
getItemClass?: (item: any) => string | string[] | Record<string, boolean>
|
||||
}>()
|
||||
|
||||
// Compute the fetch URL with ids if present
|
||||
const fetchUrl = computed(() => {
|
||||
if (props.ids && props.ids.length > 0) {
|
||||
const separator = props.fetchBaseUrl.includes('?') ? '&' : '?'
|
||||
return `${props.fetchBaseUrl}${separator}ids=${props.ids.join(',')}`
|
||||
}
|
||||
return props.fetchBaseUrl
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'trigger-item', item: any): void
|
||||
}>()
|
||||
|
||||
const items = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
|
||||
const lastCenteredItemId = ref<string | null>(null)
|
||||
const readyItemId = ref<string | null>(null)
|
||||
|
||||
const fetchItems = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(fetchUrl.value)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
// Try to use 'tasks', 'reward_status', or 'items' as fallback
|
||||
let itemList = data[props.itemKey || 'items'] || []
|
||||
if (props.filterFn) itemList = itemList.filter(props.filterFn)
|
||||
items.value = itemList
|
||||
// Fetch images for each item
|
||||
await Promise.all(
|
||||
itemList.map(async (item: any) => {
|
||||
if (props.imageFields) {
|
||||
for (const field of props.imageFields) {
|
||||
if (item[field]) {
|
||||
try {
|
||||
item[`${field.replace('_id', '_url')}`] = await getCachedImageUrl(item[field])
|
||||
} catch {
|
||||
item[`${field.replace('_id', '_url')}`] = null
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (item.image_id) {
|
||||
try {
|
||||
item.image_url = await getCachedImageUrl(item.image_id)
|
||||
} catch {
|
||||
console.error('Error fetching image for item', item.id)
|
||||
item.image_url = null
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
|
||||
items.value = []
|
||||
console.error('Error fetching items:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const centerItem = async (itemId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = itemRefs.value[itemId]
|
||||
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 handleClicked = async (item: any) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = itemRefs.value[item.id]
|
||||
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 || lastCenteredItemId.value !== item.id) {
|
||||
// Center the item, but don't trigger
|
||||
await centerItem(item.id)
|
||||
lastCenteredItemId.value = item.id
|
||||
readyItemId.value = item.id
|
||||
return
|
||||
}
|
||||
emit(
|
||||
'trigger-item',
|
||||
items.value.find((i) => i.id === item.id),
|
||||
)
|
||||
readyItemId.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.ids],
|
||||
() => {
|
||||
fetchItems()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="child-list-container">
|
||||
<h3>{{ title }}</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="items.length === 0" class="empty">No {{ title }}</div>
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="item-scroll">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:class="['item-card', props.getItemClass?.(item)]"
|
||||
:ref="(el) => (itemRefs[item.id] = el)"
|
||||
@click.stop="handleClicked(item)"
|
||||
>
|
||||
<slot name="item" :item="item">
|
||||
<div class="item-name">{{ item.name }}</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.child-list-container {
|
||||
background: var(--child-list-bg, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
color: var(--child-list-title-color, #fff);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: var(--child-list-scrollbar-track, rgba(255, 255, 255, 0.05));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8))
|
||||
);
|
||||
border-radius: 10px;
|
||||
border: var(--child-list-scrollbar-thumb-border, 2px solid rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: var(
|
||||
--child-list-scrollbar-thumb-hover,
|
||||
linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1))
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.item-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Fallback for browsers that don't support flex gap */
|
||||
.item-card + .item-card {
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
background: var(--item-card-bg, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 140px;
|
||||
max-width: 220px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
border: var(--item-card-border, 1px solid rgba(255, 255, 255, 0.08));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--item-card-hover-shadow, 0 8px 20px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #667eea00;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow: var(--item-card-ready-shadow, 0 0 0 3px #667eea88, 0 0 12px #667eea44);
|
||||
border-color: var(--item-card-ready-border, #667eea);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.item-name) {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--item-name-color, #fff);
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
:deep(.item-image) {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 0.4rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.item-card {
|
||||
min-width: 110px;
|
||||
max-width: 150px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
:deep(.item-name) {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
:deep(.item-image) {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user