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,17 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
import { isParentAuthenticated } from '../stores/auth'
import ChildForm from './child/ChildForm.vue'
interface Child {
id: string | number
name: string
age: number
points?: number
image_id: string | null
}
import { eventBus } from '@/common/eventBus'
import type { Child, Event } from '@/common/models'
const router = useRouter()
const children = ref<Child[]>([])
@@ -27,19 +20,13 @@ const confirmDeleteVisible = ref(false)
const deletingChildId = ref<string | number | null>(null)
const deleting = ref(false)
const showEditDialog = ref(false)
const editingChild = ref<Child | null>(null)
const openEditDialog = (child: Child, evt?: Event) => {
const openChildEditor = (child: Child, evt?: Event) => {
evt?.stopPropagation()
editingChild.value = { ...child } // shallow copy for editing
showEditDialog.value = true
closeMenu()
router.push({ name: 'ChildEditView', params: { id: child.id } })
}
const closeEditDialog = () => {
showEditDialog.value = false
editingChild.value = null
function handleServerChange(event: Event) {
fetchChildren()
}
// points update state
@@ -85,12 +72,28 @@ const fetchChildren = async () => {
}
}
const createChild = () => {
router.push({ name: 'CreateChild' })
}
onMounted(async () => {
eventBus.on('child_update', handleServerChange)
eventBus.on('task_update', handleServerChange)
eventBus.on('reward_update', handleServerChange)
eventBus.on('child_delete', handleServerChange)
await fetchChildren()
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
})
onBeforeUnmount(() => {
eventBus.off('child_update', handleServerChange)
eventBus.off('task_update', handleServerChange)
eventBus.off('reward_update', handleServerChange)
eventBus.off('child_delete', handleServerChange)
})
const shouldIgnoreNextCardClick = ref(false)
const onDocClick = (e: MouseEvent) => {
@@ -194,11 +197,6 @@ const deletePoints = async (childId: string | number, evt?: Event) => {
}
}
const gridColumns = computed(() => {
const n = Math.min(children.value.length, 3)
return `repeat(${n || 1}, minmax(var(--card-width, 289px), 1fr))`
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick, true)
revokeAllImageUrls()
@@ -238,7 +236,7 @@ onBeforeUnmount(() => {
<button
class="menu-item"
@mousedown.stop.prevent
@click="openEditDialog(child, $event)"
@click="openChildEditor(child, $event)"
>
Edit Child
</button>
@@ -269,18 +267,6 @@ onBeforeUnmount(() => {
</div>
</div>
<ChildForm
v-if="showEditDialog"
:child="editingChild"
@close="closeEditDialog"
@updated="
async () => {
closeEditDialog()
await fetchChildren()
}
"
/>
<!-- confirmation modal -->
<div
v-if="confirmDeleteVisible"
@@ -308,6 +294,14 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<!-- Add Child button (FAB) -->
<button v-if="isParentAuthenticated" class="fab" @click="createChild" aria-label="Add Child">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<circle cx="14" cy="14" r="14" fill="#667eea" />
<path d="M14 8v12M8 14h12" stroke="#fff" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
</div>
</template>
@@ -525,4 +519,32 @@ h1 {
font-weight: 600;
text-align: center;
}
/* Floating Action Button (FAB) */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #667eea;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: #5a67d8;
}
.fab:active {
background: #4c51bf;
}
</style>

View File

@@ -41,83 +41,98 @@ onBeforeUnmount(() => {
</script>
<template>
<div v-if="child" class="detail-card">
<h1>{{ child.name }}</h1>
<div v-if="child" class="detail-card-horizontal">
<img v-if="imageUrl" :src="imageUrl" alt="Child Image" class="child-image" />
<div class="info">
<div class="info-item">
<span class="label">Age:</span>
<span class="value">{{ child.age }}</span>
</div>
<div class="info-item">
<span class="label">Points:</span>
<span class="value">{{ child.points ?? '—' }}</span>
</div>
<div class="main-info">
<div class="child-name">{{ child.name }}</div>
<div class="child-age">Age: {{ child.age }}</div>
</div>
<div class="points">
<span class="label">Points</span>
<span class="value">{{ child.points ?? '—' }}</span>
</div>
</div>
</template>
<style scoped>
.detail-card {
.detail-card-horizontal {
display: flex;
align-items: center;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
padding: 1.2rem 1rem;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.13);
padding: 0.7rem 1rem;
max-width: 420px;
width: 100%;
min-height: 64px;
box-sizing: border-box;
}
.detail-card h1 {
font-size: 1.3rem;
color: #333;
margin-bottom: 1rem;
text-align: center;
gap: 1rem;
}
.child-image {
width: 64px;
height: 64px;
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 0 auto 0.7rem auto;
flex-shrink: 0;
}
.info {
.main-info {
display: flex;
flex-direction: column;
gap: 0.6rem;
justify-content: center;
flex: 1 1 auto;
min-width: 0;
}
.info-item {
.child-name {
font-size: 1.08rem;
font-weight: 600;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.child-age {
font-size: 0.97rem;
color: #666;
margin-top: 2px;
}
.points {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.7rem;
background: #f5f5f5;
border-radius: 7px;
font-size: 0.98rem;
flex-direction: column;
align-items: flex-end;
min-width: 54px;
margin-left: 0.7rem;
}
.points .label {
font-size: 0.85rem;
color: #888;
margin-bottom: 1px;
}
.points .value {
font-size: 1.1rem;
font-weight: 700;
color: #667eea;
}
/* Even more compact on small screens */
@media (max-width: 480px) {
.detail-card {
padding: 0.7rem 0.4rem;
.detail-card-horizontal {
padding: 0.5rem 0.4rem;
max-width: 98vw;
}
.detail-card h1 {
font-size: 1.05rem;
margin-bottom: 0.7rem;
gap: 0.6rem;
}
.child-image {
width: 48px;
height: 48px;
margin-bottom: 0.5rem;
width: 38px;
height: 38px;
}
.info-item {
padding: 0.38rem 0.5rem;
font-size: 0.93rem;
.points {
min-width: 38px;
margin-left: 0.3rem;
}
}
</style>

View File

@@ -0,0 +1,247 @@
<template>
<div class="child-edit-view">
<h2>{{ isEdit ? 'Edit Child' : 'Create Child' }}</h2>
<div v-if="loading" class="loading-message">Loading child...</div>
<form v-else @submit.prevent="submit" class="form">
<div class="form-group">
<label for="child-name">Name</label>
<input id="child-name" ref="nameInput" v-model="name" required maxlength="64" />
</div>
<div class="form-group">
<label for="child-age">Age</label>
<input id="child-age" v-model.number="age" type="number" min="0" max="120" required />
</div>
<div class="form-group image-picker-group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
v-model="selectedImageId"
:image-type="1"
@add-image="onAddImage"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" class="btn cancel" @click="onCancel" :disabled="loading">
Cancel
</button>
<button type="submit" class="btn save" :disabled="loading">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '../ImagePicker.vue'
const route = useRoute()
const router = useRouter()
// Accept id as a prop for edit mode
const props = defineProps<{ id?: string }>()
const isEdit = computed(() => !!props.id)
const name = ref('')
const age = ref<number | null>(null)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
onMounted(async () => {
if (isEdit.value && props.id) {
loading.value = true
try {
const resp = await fetch(`/api/child/${props.id}`)
if (!resp.ok) throw new Error('Failed to load child')
const data = await resp.json()
name.value = data.name ?? ''
age.value = Number(data.age) ?? null
selectedImageId.value = data.image_id ?? null
} catch (e) {
error.value = 'Could not load child.'
} finally {
loading.value = false
await nextTick()
nameInput.value?.focus()
}
} else {
await nextTick()
nameInput.value?.focus()
}
})
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Child name is required.'
return
}
if (age.value === null || age.value < 0) {
error.value = 'Age must be a non-negative number.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the child
try {
let resp
if (isEdit.value && props.id) {
resp = await fetch(`/api/child/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
age: age.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/child/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
age: age.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save child')
await router.push({ name: 'ChildrenListView' })
} catch (err) {
alert('Failed to save child.')
}
loading.value = false
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.child-edit-view {
max-width: 400px;
margin: 2rem auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px #667eea22;
padding: 2rem 2.2rem 1.5rem 2.2rem;
}
h2 {
margin-bottom: 1.2rem;
font-size: 1.15rem;
color: #667eea;
font-weight: 700;
text-align: center;
}
.form {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.form-group {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
box-sizing: border-box;
}
label {
font-weight: 600;
color: #333;
font-size: 1rem;
}
input[type='text'],
input[type='number'] {
width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 0.5rem 0.7rem;
border-radius: 8px;
border: 1px solid #e6e6e6;
font-size: 1rem;
background: #fafbff;
color: #222;
transition: border 0.2s;
}
input:focus {
outline: none;
border: 1.5px solid #667eea;
}
.actions {
display: flex;
gap: 0.7rem;
justify-content: center;
margin-top: 0.5rem;
}
.btn {
padding: 0.5rem 1.1rem;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
transition: background 0.18s;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.save {
background: #667eea;
color: white;
}
.btn.save:hover {
background: #5a67d8;
}
.form-group.image-picker-group {
display: block;
text-align: left;
}
.error {
color: #e53e3e;
margin-top: 0.7rem;
text-align: center;
}
.loading-message {
text-align: center;
color: #666;
margin-bottom: 1.2rem;
}
</style>

View File

@@ -1,37 +1,161 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
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'
interface Child {
id: string | number
name: string
age: number
points?: number
}
import { eventBus } from '@/common/eventBus'
import type {
Child,
Event,
Task,
Reward,
TaskUpdateEventPayload,
RewardUpdateEventPayload,
ChildUpdateEventPayload,
ChildDeleteEventPayload,
TaskCreatedEventPayload,
TaskDeletedEventPayload,
TaskEditedEventPayload,
RewardCreatedEventPayload,
RewardDeletedEventPayload,
RewardEditedEventPayload,
} from '@/common/models'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const rewards = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
onMounted(async () => {
function handlePointsUpdate(event: Event) {
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleServerChange(event: Event) {
const payload = event.payload as
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
fetchChildData(child.value.id)
}
}
function handleChildDeletion(event: Event) {
const payload = event.payload as ChildDeleteEventPayload
if (child.value && payload.child_id == child.value.id) {
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
}
}
function handleTaskChanged(event: Event) {
const payload = event.payload as
| TaskCreatedEventPayload
| TaskDeletedEventPayload
| TaskEditedEventPayload
if (child.value) {
const task_id = payload.task_id
if (tasks.value.includes(task_id)) {
fetchChildData(child.value.id)
}
}
}
function handleRewardChanged(event: Event) {
const payload = event.payload as
| RewardCreatedEventPayload
| RewardDeletedEventPayload
| RewardEditedEventPayload
if (child.value) {
const reward_id = payload.reward_id
if (rewards.value.includes(reward_id)) {
fetchChildData(child.value.id)
}
}
}
const handleTriggerTask = (task: Task) => {
if ('speechSynthesis' in window && task.name) {
const utter = new window.SpeechSynthesisUtterance(task.name)
window.speechSynthesis.speak(utter)
}
}
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
if ('speechSynthesis' in window && reward.name) {
console.log('Handle trigger reward:', reward, redeemable)
const utterString =
reward.name + (redeemable ? '' : `, You still need ${reward.points_needed} points.`)
const utter = new window.SpeechSynthesisUtterance(utterString)
window.speechSynthesis.speak(utter)
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${route.params.id}`)
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
child.value = data.children ? data.children : data
tasks.value = data.tasks || []
rewards.value = data.rewards || []
error.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
eventBus.on('task_update', handlePointsUpdate)
eventBus.on('reward_update', handlePointsUpdate)
eventBus.on('task_set', handleServerChange)
eventBus.on('reward_set', handleServerChange)
eventBus.on('child_update', handleServerChange)
eventBus.on('child_delete', handleChildDeletion)
eventBus.on('task_created', handleTaskChanged)
eventBus.on('task_deleted', handleTaskChanged)
eventBus.on('task_edited', handleTaskChanged)
eventBus.on('reward_created', handleRewardChanged)
eventBus.on('reward_deleted', handleRewardChanged)
eventBus.on('reward_edited', handleRewardChanged)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
fetchChildData(idParam)
}
}
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('task_update', handlePointsUpdate)
eventBus.off('reward_update', handlePointsUpdate)
eventBus.off('task_set', handleServerChange)
eventBus.off('reward_set', handleServerChange)
eventBus.off('child_update', handleServerChange)
eventBus.off('child_delete', handleChildDeletion)
eventBus.off('task_created', handleTaskChanged)
eventBus.off('task_deleted', handleTaskChanged)
eventBus.off('task_edited', handleTaskChanged)
eventBus.off('reward_created', handleRewardChanged)
eventBus.off('reward_deleted', handleRewardChanged)
eventBus.off('reward_edited', handleRewardChanged)
})
</script>
@@ -44,26 +168,33 @@ onMounted(async () => {
<div class="main">
<ChildDetailCard :child="child" />
<ChildTaskList
title="Chores"
:task-ids="tasks"
:child-id="child ? child.id : null"
:is-parent-authenticated="false"
:filter-type="1"
@trigger-task="handleTriggerTask"
/>
<ChildTaskList
title="Bad Habits"
:task-ids="tasks"
:child-id="child ? child.id : null"
:is-parent-authenticated="false"
:filter-type="2"
@trigger-task="handleTriggerTask"
/>
<ChildRewardList
:child-id="child ? child.id : null"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@trigger-reward="handleTriggerReward"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
}
"
/>
<!-- removed placeholder -->
</div>
<!-- Remove this aside block:
<aside class="side">
<div class="placeholder">Additional components go here</div>
</aside>
-->
</div>
</div>
</template>
@@ -74,7 +205,7 @@ onMounted(async () => {
max-width: 1200px;
margin: 0 auto;
min-height: 100vh;
padding: 2rem;
padding: 0.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-sizing: border-box;
}

View File

@@ -1,45 +1,188 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, onMounted, computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { isParentAuthenticated } from '../../stores/auth'
import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from '../reward/ChildRewardList.vue'
import AssignTaskButton from '../AssignTaskButton.vue'
interface Child {
id: string | number
name: string
age: number
points?: number
}
import { eventBus } from '@/common/eventBus'
import type {
Task,
Child,
Event,
Reward,
TaskUpdateEventPayload,
RewardUpdateEventPayload,
ChildUpdateEventPayload,
ChildDeleteEventPayload,
} from '@/common/models'
const route = useRoute()
const router = useRouter()
const child = ref<Child | null>(null)
const tasks = ref<string[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const rewardListRef = ref()
const showConfirm = ref(false)
const selectedTask = ref<Task | null>(null)
const showRewardConfirm = ref(false)
const selectedReward = ref<Reward | null>(null)
onMounted(async () => {
function handlePointsUpdate(event: Event) {
const payload = event.payload as TaskUpdateEventPayload | RewardUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
child.value.points = payload.points
}
}
function handleServerChange(event: Event) {
const payload = event.payload as
| TaskUpdateEventPayload
| RewardUpdateEventPayload
| ChildUpdateEventPayload
if (child.value && payload.child_id == child.value.id) {
fetchChildData(child.value.id)
}
}
function handleChildDeletion(event: Event) {
const payload = event.payload as ChildDeleteEventPayload
if (child.value && payload.child_id == child.value.id) {
// Navigate away back to children list
router.push({ name: 'ChildrenListView' })
}
}
async function fetchChildData(id: string | number) {
loading.value = true
try {
const resp = await fetch(`/api/child/${route.params.id}`)
const resp = await fetch(`/api/child/${id}`)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
child.value = data.children ? data.children : data
tasks.value = data.tasks || []
error.value = null
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
console.error(err)
} finally {
loading.value = false
}
}
onMounted(async () => {
try {
eventBus.on('task_update', handlePointsUpdate)
eventBus.on('reward_update', handlePointsUpdate)
eventBus.on('task_set', handleServerChange)
eventBus.on('reward_set', handleServerChange)
eventBus.on('child_update', handleServerChange)
eventBus.on('child_delete', handleChildDeletion)
if (route.params.id) {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
if (idParam !== undefined) {
fetchChildData(idParam)
}
}
} catch (err) {
console.error('Error in onMounted:', err)
}
})
onUnmounted(() => {
eventBus.off('task_update', handlePointsUpdate)
eventBus.off('reward_update', handlePointsUpdate)
eventBus.off('task_set', handleServerChange)
eventBus.off('reward_set', handleServerChange)
eventBus.off('child_update', handleServerChange)
eventBus.off('child_delete', handleChildDeletion)
})
const refreshRewards = () => {
rewardListRef.value?.refresh()
}
const handleTriggerTask = (task: Task) => {
selectedTask.value = task
showConfirm.value = true
}
const confirmTriggerTask = async () => {
if (!child.value?.id || !selectedTask.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-task`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: selectedTask.value.id }),
})
if (!resp.ok) return
const data = await resp.json()
console.log('Trigger task response data:', child.value.id, data.id)
if (child.value && child.value.id === data.id) child.value.points = data.points
} catch (err) {
console.error('Failed to trigger task:', err)
} finally {
showConfirm.value = false
selectedTask.value = null
}
}
const handleTriggerReward = (reward: Reward, redeemable: boolean) => {
console.log('Handle trigger reward:', reward, redeemable)
if (!redeemable) return
selectedReward.value = reward
showRewardConfirm.value = true
}
const confirmTriggerReward = async () => {
if (!child.value?.id || !selectedReward.value) return
try {
const resp = await fetch(`/api/child/${child.value.id}/trigger-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: selectedReward.value.id }),
})
if (!resp.ok) return
const data = await resp.json()
if (child.value && child.value.id === data.id) child.value.points = data.points
} catch (err) {
console.error('Failed to trigger reward:', err)
} finally {
showRewardConfirm.value = false
selectedReward.value = null
}
}
function goToAssignTasks() {
if (child.value?.id) {
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'good' } })
}
}
function goToAssignBadHabits() {
if (child.value?.id) {
router.push({ name: 'TaskAssignView', params: { id: child.value.id, type: 'bad' } })
}
}
function goToAssignRewards() {
if (child.value?.id) {
router.push({ name: 'RewardAssignView', params: { id: child.value.id } })
}
}
const handleTaskPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
if (child.value && child.value.id === id) child.value.points = points
refreshRewards()
}
const handleRewardPointsUpdated = ({ id, points }: { id: string | number; points: number }) => {
if (child.value && child.value.id === id) child.value.points = points
}
const childId = computed(() => child.value?.id ?? null)
</script>
<template>
@@ -51,31 +194,118 @@ const refreshRewards = () => {
<div class="main">
<ChildDetailCard :child="child" />
<ChildTaskList
title="Chores"
:task-ids="tasks"
:child-id="child ? child.id : null"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
refreshRewards()
}
"
:filter-type="1"
@points-updated="handleTaskPointsUpdated"
@trigger-task="handleTriggerTask"
/>
<ChildTaskList
title="Bad Habits"
:task-ids="tasks"
:child-id="childId"
:is-parent-authenticated="isParentAuthenticated"
:filter-type="2"
@points-updated="handleTaskPointsUpdated"
@trigger-task="handleTriggerTask"
/>
<ChildRewardList
ref="rewardListRef"
:child-id="child ? child.id : null"
:is-parent-authenticated="isParentAuthenticated"
@points-updated="
({ id, points }) => {
if (child && child.id === id) child.points = points
refreshRewards()
}
"
:child-id="childId"
:child-points="child?.points ?? 0"
:is-parent-authenticated="false"
@points-updated="handleRewardPointsUpdated"
@trigger-reward="handleTriggerReward"
/>
</div>
</div>
<!-- Place the AssignTaskButton here, outside .main but inside .container -->
<AssignTaskButton :child-id="child ? child.id : null" />
<div class="assign-buttons">
<button v-if="child" class="assign-task-btn" @click="goToAssignTasks">Assign Tasks</button>
<button v-if="child" class="assign-bad-btn" @click="goToAssignBadHabits">
Assign Bad Habits
</button>
<button v-if="child" class="assign-reward-btn" @click="goToAssignRewards">
Assign Rewards
</button>
</div>
<div v-if="showConfirm && selectedTask" class="modal-backdrop">
<div class="modal">
<div class="task-info">
<img
v-if="selectedTask.image_url"
:src="selectedTask.image_url"
alt="Task Image"
class="task-image"
/>
<div class="task-details">
<div class="task-name">{{ selectedTask.name }}</div>
<div class="task-points" :class="selectedTask.is_good ? 'good' : 'bad'">
{{ selectedTask.points }} pts
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
{{ selectedTask.is_good ? 'Add' : 'Subtract' }} these points
{{ selectedTask.is_good ? 'to' : 'from' }}
<span class="child-name">{{ child?.name }}</span>
</div>
<div class="actions">
<button @click="confirmTriggerTask">Yes</button>
<button
@click="
() => {
showConfirm = false
selectedTask = null
}
"
>
Cancel
</button>
</div>
</div>
</div>
<div v-if="showRewardConfirm && selectedReward" class="modal-backdrop">
<div class="modal">
<div class="reward-info">
<img
v-if="selectedReward.image_id"
:src="selectedReward.image_id"
alt="Reward Image"
class="reward-image"
/>
<div class="reward-details">
<div class="reward-name">{{ selectedReward.name }}</div>
<div class="reward-points">
{{
selectedReward.points_needed === 0
? 'Reward Ready!'
: selectedReward.points_needed + ' pts needed'
}}
</div>
</div>
</div>
<div class="dialog-message" style="margin-bottom: 1.2rem">
Redeem this reward for <span class="child-name">{{ child?.name }}</span
>?
</div>
<div class="actions">
<button @click="confirmTriggerReward">Yes</button>
<button
@click="
() => {
showRewardConfirm = false
selectedReward = null
}
"
>
Cancel
</button>
</div>
</div>
</div>
</div>
</template>
@@ -89,16 +319,6 @@ const refreshRewards = () => {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-sizing: border-box;
}
.back-btn {
background: white;
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px;
cursor: pointer;
margin-bottom: 1.5rem;
color: #667eea;
font-weight: 600;
}
.loading,
.error {
color: white;
@@ -118,9 +338,6 @@ const refreshRewards = () => {
display: flex;
justify-content: center;
align-items: flex-start;
/* Remove grid styles */
/* grid-template-columns: 1fr 320px; */
/* gap: 1.5rem; */
}
.main {
display: flex;
@@ -130,22 +347,84 @@ const refreshRewards = () => {
width: 100%;
max-width: 600px; /* or whatever width fits your content best */
}
.side {
display: flex;
flex-direction: column;
gap: 1rem;
}
.placeholder {
background: rgba(255, 255, 255, 0.08);
color: white;
padding: 1rem;
border-radius: 8px;
min-height: 120px;
/* Modal styles */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.modal {
background: #fff;
color: #222;
padding: 1.5rem 2rem;
border-radius: 12px;
min-width: 240px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
text-align: center;
}
.task-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.task-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: #eee;
}
.task-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.task-name {
font-weight: 600;
font-size: 1.1rem;
}
.task-points,
.task-points.good,
.task-points.bad {
font-weight: 600;
font-size: 1.1rem;
}
.task-points.good {
color: #38c172;
}
.task-points.bad {
color: #ef4444;
}
.actions {
margin-top: 1.2rem;
display: flex;
gap: 1rem;
justify-content: center;
}
.actions button {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
}
.actions button:first-child {
background: #667eea;
color: #fff;
}
.actions button:last-child {
background: #f3f3f3;
color: #666;
}
.actions button:last-child:hover {
background: #e2e8f0;
}
/* Mobile adjustments */
@media (max-width: 900px) {
@@ -158,17 +437,83 @@ const refreshRewards = () => {
.container {
padding: 1rem;
}
.back-btn {
padding: 0.45rem 0.75rem;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.main {
gap: 1rem;
}
.placeholder {
padding: 0.75rem;
min-height: 80px;
}
}
.dialog-message {
font-size: 1.08rem;
color: #444;
font-weight: 500;
}
.dialog-message .child-name {
color: #667eea;
font-weight: 700;
margin-left: 2px;
}
.reward-info {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.reward-image {
width: 72px;
height: 72px;
object-fit: cover;
border-radius: 8px;
background: #eee;
}
.reward-details {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.reward-name {
font-weight: 600;
font-size: 1.1rem;
}
.reward-points {
color: #667eea;
font-weight: 500;
font-size: 1rem;
}
.assign-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin: 2rem 0;
}
.assign-task-btn,
.assign-bad-btn,
.assign-reward-btn {
font-weight: 600;
border: none;
border-radius: 8px;
padding: 0.7rem 1.5rem;
font-size: 1.1rem;
cursor: pointer;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
transition: background 0.18s;
color: #fff;
background: #667eea;
}
.assign-task-btn:hover,
.assign-bad-btn:hover,
.assign-reward-btn:hover {
background: #5a67d8;
}
.assign-bad-btn {
background: #ef4444;
}
.assign-bad-btn:hover {
background: #dc2626;
}
.assign-reward-btn {
background: #38c172;
}
.assign-reward-btn:hover {
background: #2f855a;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="reward-assign-view">
<h2>Assign Rewards</h2>
<div class="reward-list-scroll">
<RewardList ref="rewardListRef" :child-id="childId" :selectable="true" />
</div>
<div class="actions">
<button class="btn cancel" @click="onCancel">Cancel</button>
<button class="btn submit" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import RewardList from '../reward/RewardList.vue'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const rewardListRef = ref()
async function onSubmit() {
const selectedIds = rewardListRef.value?.selectedRewards ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-rewards`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update rewards')
router.back()
} catch (err) {
alert('Failed to update rewards.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.reward-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
h2 {
font-size: 1.15rem;
color: #ffffff;
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.reward-list-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-bottom: 2rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 0;
}
.btn {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.submit {
background: #667eea;
color: #fff;
}
.btn.submit:hover {
background: #5a67d8;
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<div class="task-assign-view">
<h2>Assign Tasks</h2>
<div class="task-list-scroll">
<TaskList
ref="taskListRef"
:child-id="childId"
:selectable="true"
:type-filter="typeFilter"
/>
</div>
<div class="actions">
<button class="btn cancel" @click="onCancel">Cancel</button>
<button class="btn submit" @click="onSubmit">Submit</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TaskList from '../task/TaskList.vue'
const route = useRoute()
const router = useRouter()
const childId = route.params.id
const taskListRef = ref()
const typeFilter = computed(() => {
if (route.params.type === 'good') return 'good'
if (route.params.type === 'bad') return 'bad'
return 'all'
})
async function onSubmit() {
const selectedIds = taskListRef.value?.selectedTasks ?? []
try {
const resp = await fetch(`/api/child/${childId}/set-tasks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_ids: selectedIds }),
})
if (!resp.ok) throw new Error('Failed to update tasks')
router.back()
} catch (err) {
alert('Failed to update tasks.')
}
}
function onCancel() {
router.back()
}
</script>
<style scoped>
.task-assign-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
h2 {
font-size: 1.15rem;
color: #ffffff;
font-weight: 700;
text-align: center;
margin: 0.2rem;
}
.task-list-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-bottom: 2rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 0;
}
.btn {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
font-size: 1rem;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.submit {
background: #667eea;
color: #fff;
}
.btn.submit:hover {
background: #5a67d8;
}
</style>

View File

@@ -2,23 +2,18 @@
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { defineProps, defineEmits, defineExpose } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Reward {
id: string
name: string
points_needed: number
image_id: string | null
}
import type { RewardStatus } from '@/common/models'
const imageCacheName = 'images-v1'
const props = defineProps<{
childId: string | number | null
childPoints: number
isParentAuthenticated: boolean
}>()
const emit = defineEmits(['points-updated'])
const emit = defineEmits(['points-updated', 'trigger-reward'])
const rewards = ref<Reward[]>([])
const rewards = ref<RewardStatus[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
@@ -53,7 +48,7 @@ const fetchRewards = async (id: string | number | null) => {
}
}
const fetchImage = async (reward: Reward) => {
const fetchImage = async (reward: RewardStatus) => {
if (!reward.image_id) {
console.log(`No image ID for reward: ${reward.id}`)
return
@@ -86,8 +81,6 @@ const centerReward = async (rewardId: string) => {
}
const handleRewardClick = async (rewardId: string) => {
if (!props.isParentAuthenticated) return // Only allow if logged in
await nextTick()
const wrapper = scrollWrapper.value
const card = rewardRefs.value[rewardId]
@@ -111,24 +104,10 @@ const handleRewardClick = async (rewardId: string) => {
readyRewardId.value = null
}
const triggerReward = async (rewardId: string) => {
if (!props.childId) return
const triggerReward = (rewardId: string) => {
const reward = rewards.value.find((rew) => rew.id === rewardId)
if (!reward || reward.points_needed > 0) return // Don't trigger if not allowed
if (!props.isParentAuthenticated) return // Only allow if logged in
try {
const resp = await fetch(`/api/child/${props.childId}/trigger-reward`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reward_id: rewardId }),
})
if (!resp.ok) return
const data = await resp.json()
// Emit the new points so the parent can update the child points
emit('points-updated', { id: props.childId, points: data.points })
} catch (err) {
console.error('Failed to trigger reward:', err)
}
if (!reward) return // Don't trigger if not allowed
emit('trigger-reward', reward, reward.points_needed <= 0)
}
onMounted(() => fetchRewards(props.childId))
@@ -136,6 +115,18 @@ watch(
() => props.childId,
(v) => fetchRewards(v),
)
watch(
() => props.childPoints,
() => {
// Option 1: If reward eligibility depends on points, recompute eligibility here
// Option 2: If you need to refetch from the backend, call fetchRewards(props.childId)
// For most cases, just recompute eligibility locally
// Example:
rewards.value.forEach((reward) => {
reward.points_needed = Math.max(0, reward.cost - props.childPoints)
})
},
)
// revoke created object URLs when component unmounts to avoid memory leaks
onBeforeUnmount(() => {
@@ -162,7 +153,6 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
class="reward-card"
:class="{
ready: readyRewardId === r.id,
disabled: r.points_needed > 0,
}"
:ref="(el) => (rewardRefs[r.id] = el)"
@click="() => handleRewardClick(r.id)"
@@ -191,7 +181,7 @@ defineExpose({ refresh: () => fetchRewards(props.childId) })
}
.reward-list-container h3 {
margin: 0 0 0.75rem 0;
margin: 0;
font-size: 1.05rem;
font-weight: 600;
}

View File

@@ -1,10 +1,12 @@
<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'
deletable?: boolean
selectable?: boolean
}>()
const emit = defineEmits(['edit-reward', 'delete-reward'])
@@ -21,49 +23,93 @@ const rewards = ref<
const loading = ref(true)
const error = ref<string | null>(null)
const selectedRewards = ref<string[]>([])
const fetchRewards = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
if (props.assignable) {
url = `/api/child/${props.childId}/list-assignable-rewards`
} else {
url = `/api/child/${props.childId}/list-rewards`
url = `/api/child/${props.childId}/list-all-rewards`
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const assigned = (data.assigned_rewards || []).map((reward: any) => ({
...reward,
assigned: true,
}))
const assignable = (data.assignable_rewards || []).map((reward: any) => ({
...reward,
assigned: false,
}))
let rewardList: any[] = []
if (props.assignFilter === 'assignable') {
rewardList = assignable
} else if (props.assignFilter === 'assigned') {
rewardList = assigned
} else if (props.assignFilter === 'none') {
rewardList = []
} else {
rewardList = [...assigned, ...assignable]
}
// Fetch images for each reward if image_id is present
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
}
}
}),
)
rewards.value = rewardList
// If selectable, pre-select assigned rewards
if (props.selectable) {
selectedRewards.value = assigned.map((reward: any) => String(reward.id))
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
loading.value = false
}
} else {
url = '/api/reward/list'
}
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const rewardList = data.rewards || []
// Fetch images for each reward if image_id is present
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const rewardList = data.rewards || []
await Promise.all(
rewardList.map(async (reward: any) => {
if (reward.image_id) {
try {
reward.image_url = await getCachedImageUrl(reward.image_id)
} catch (e) {
reward.image_url = null
}
}
}
}),
)
rewards.value = rewardList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
} finally {
loading.value = false
}),
)
rewards.value = rewardList
if (props.selectable) selectedRewards.value = []
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
rewards.value = []
if (props.selectable) selectedRewards.value = []
} finally {
loading.value = false
}
}
}
onMounted(fetchRewards)
watch(() => [props.childId, props.assignable], fetchRewards)
watch(() => [props.childId, props.assignFilter], fetchRewards)
const handleEdit = (rewardId: string) => {
emit('edit-reward', rewardId)
@@ -73,11 +119,10 @@ const handleDelete = (rewardId: string) => {
emit('delete-reward', rewardId)
}
defineExpose({ refresh: fetchRewards })
defineExpose({ refresh: fetchRewards, selectedRewards })
const ITEM_HEIGHT = 52 // px, adjust to match your .reward-list-item + margin
const listHeight = computed(() => {
// Add a little for padding, separators, etc.
const n = rewards.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
@@ -94,6 +139,14 @@ const listHeight = computed(() => {
<img v-if="reward.image_url" :src="reward.image_url" alt="Reward" class="reward-image" />
<span class="reward-name">{{ reward.name }}</span>
<span class="reward-cost"> {{ reward.cost }} pts </span>
<input
v-if="props.selectable"
type="checkbox"
class="reward-checkbox"
v-model="selectedRewards"
:value="reward.id"
@click.stop
/>
<button
v-if="props.deletable"
class="delete-btn"
@@ -121,8 +174,9 @@ const listHeight = computed(() => {
<style scoped>
.reward-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);
overflow-y: auto;
margin: 0.2rem 0 0 0;
@@ -209,4 +263,11 @@ const listHeight = computed(() => {
.delete-btn svg {
display: block;
}
.reward-checkbox {
margin-left: 1rem;
width: 1.2em;
height: 1.2em;
accent-color: #667eea;
cursor: pointer;
}
</style>

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>