This commit is contained in:
2025-12-05 17:40:57 -05:00
parent 6423d1c1a2
commit fa9fabcd9f
43 changed files with 1506 additions and 529 deletions

View File

@@ -1,10 +1,16 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ref, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
import { isParentAuthenticated } from '../stores/auth'
import { eventBus } from '@/common/eventBus'
import type { Child, Event } from '@/common/models'
import type {
Child,
ChildModifiedEventPayload,
ChildTaskTriggeredEventPayload,
ChildRewardTriggeredEventPayload,
Event,
} from '@/common/models'
const router = useRouter()
const children = ref<Child[]>([])
@@ -25,8 +31,70 @@ const openChildEditor = (child: Child, evt?: Event) => {
router.push({ name: 'ChildEditView', params: { id: child.id } })
}
function handleServerChange(event: Event) {
fetchChildren()
async function handleChildModified(event: Event) {
const payload = event.payload as ChildModifiedEventPayload
const childId = payload.child_id
switch (payload.operation) {
case 'DELETE':
children.value = children.value.filter((c) => c.id !== childId)
break
case 'ADD':
try {
const list = await fetchChildren()
children.value = list
} catch (err) {
console.warn('Failed to fetch children after ADD operation:', err)
}
break
case 'EDIT':
try {
const list = await fetchChildren()
const updatedChild = list.find((c) => c.id === childId)
if (updatedChild) {
const idx = children.value.findIndex((c) => c.id === childId)
if (idx !== -1) {
children.value[idx] = updatedChild
} else {
console.warn(`EDIT operation: child with id ${childId} not found in current list.`)
}
} else {
console.warn(
`EDIT operation: updated child with id ${childId} not found in fetched list.`,
)
}
} catch (err) {
console.warn('Failed to fetch children after EDIT operation:', err)
}
break
default:
console.warn(`Unknown operation: ${payload.operation}`)
}
}
function handleChildTaskTriggered(event: Event) {
const payload = event.payload as ChildTaskTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
function handleChildRewardTriggered(event: Event) {
const payload = event.payload as ChildRewardTriggeredEventPayload
const childId = payload.child_id
const child = children.value.find((c) => c.id === childId)
if (child) {
child.points = payload.points
} else {
console.warn(`Child with id ${childId} not found when updating points.`)
}
}
// points update state
@@ -41,8 +109,7 @@ const fetchImage = async (imageId: string) => {
}
}
// extracted fetch so we can refresh after delete / points edit
const fetchChildren = async () => {
const fetchChildren = async (): Promise<Child[]> => {
loading.value = true
error.value = null
images.value.clear()
@@ -53,20 +120,22 @@ const fetchChildren = async () => {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data = await response.json()
children.value = data.children || []
const childList = data.children || []
// Fetch images for each child (shared cache util)
await Promise.all(
children.value.map((child) => {
childList.map((child) => {
if (child.image_id) {
return fetchImage(child.image_id)
}
return Promise.resolve()
}),
)
return childList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
console.error('Error fetching children:', err)
return []
} finally {
loading.value = false
}
@@ -77,21 +146,22 @@ const createChild = () => {
}
onMounted(async () => {
eventBus.on('child_update', handleServerChange)
eventBus.on('task_update', handleServerChange)
eventBus.on('reward_update', handleServerChange)
eventBus.on('child_delete', handleServerChange)
eventBus.on('child_modified', handleChildModified)
eventBus.on('child_task_triggered', handleChildTaskTriggered)
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
await fetchChildren()
const listPromise = fetchChildren()
listPromise.then((list) => {
children.value = list
})
// 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)
onUnmounted(() => {
eventBus.off('child_modified', handleChildModified)
eventBus.off('child_task_triggered', handleChildTaskTriggered)
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
})
const shouldIgnoreNextCardClick = ref(false)
@@ -188,8 +258,7 @@ const deletePoints = async (childId: string | number, evt?: Event) => {
if (!resp.ok) {
throw new Error(`Failed to update points: ${resp.status}`)
}
// refresh the list so points reflect the change
await fetchChildren()
// no need to refresh since we update optimistically via eventBus
} catch (err) {
console.error('Failed to delete points for child', childId, err)
} finally {
@@ -204,13 +273,22 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="container">
<div v-if="loading" class="loading">Loading...</div>
<div>
<div v-if="children.length === 0" class="no-children-message">
<div>No children</div>
<div class="sub-message">
<template v-if="!isParentAuthenticated">
<button class="sign-in-btn" @click="eventBus.emit('open-login')">Sign in</button> to
create a child
</template>
<span v-else><button class="sign-in-btn" @click="createChild">Create</button> a child</span>
</div>
</div>
<div v-else-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">Error: {{ error }}</div>
<div v-else-if="children.length === 0" class="empty">No children found</div>
<div v-else class="grid">
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
<!-- kebab menu shown only for authenticated parent -->
@@ -547,4 +625,37 @@ h1 {
.fab:active {
background: #4c51bf;
}
.no-children-message {
margin: 2rem 0;
font-size: 1.15rem;
font-weight: 600;
text-align: center;
color: #fdfdfd;
line-height: 1.5;
}
.sub-message {
margin-top: 0.3rem;
font-size: 1rem;
font-weight: 400;
color: #b5ccff;
}
.sign-in-btn {
background: #fff;
color: #2563eb;
border: 2px solid #2563eb;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
padding: 0.2rem 0.5rem;
margin-right: 0.1rem;
cursor: pointer;
transition:
background 0.18s,
color 0.18s;
}
.sign-in-btn:hover {
background: #2563eb;
color: #fff;
}
</style>