646 lines
16 KiB
Vue
646 lines
16 KiB
Vue
<script setup lang="ts">
|
|
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,
|
|
ChildModifiedEventPayload,
|
|
ChildTaskTriggeredEventPayload,
|
|
ChildRewardTriggeredEventPayload,
|
|
Event,
|
|
} from '@/common/models'
|
|
|
|
const router = useRouter()
|
|
const children = ref<Child[]>([])
|
|
const loading = ref(true)
|
|
const error = ref<string | null>(null)
|
|
const images = ref<Map<string, string>>(new Map()) // Store image URLs
|
|
|
|
const imageCacheName = 'images-v1'
|
|
|
|
// UI state for kebab menus & delete confirmation
|
|
const activeMenuFor = ref<string | number | null>(null) // which child card shows menu
|
|
const confirmDeleteVisible = ref(false)
|
|
const deletingChildId = ref<string | number | null>(null)
|
|
const deleting = ref(false)
|
|
|
|
const openChildEditor = (child: Child, evt?: Event) => {
|
|
evt?.stopPropagation()
|
|
router.push({ name: 'ChildEditView', params: { id: child.id } })
|
|
}
|
|
|
|
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
|
|
const updatingPointsFor = ref<string | number | null>(null)
|
|
|
|
const fetchImage = async (imageId: string) => {
|
|
try {
|
|
const url = await getCachedImageUrl(imageId, imageCacheName)
|
|
images.value.set(imageId, url)
|
|
} catch (err) {
|
|
console.warn('Failed to load child image', imageId, err)
|
|
}
|
|
}
|
|
|
|
const fetchChildren = async (): Promise<Child[]> => {
|
|
loading.value = true
|
|
error.value = null
|
|
images.value.clear()
|
|
|
|
try {
|
|
const response = await fetch('/api/child/list')
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`)
|
|
}
|
|
const data = await response.json()
|
|
const childList = data.children || []
|
|
|
|
// Fetch images for each child (shared cache util)
|
|
await Promise.all(
|
|
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
|
|
}
|
|
}
|
|
|
|
const createChild = () => {
|
|
router.push({ name: 'CreateChild' })
|
|
}
|
|
|
|
onMounted(async () => {
|
|
eventBus.on('child_modified', handleChildModified)
|
|
eventBus.on('child_task_triggered', handleChildTaskTriggered)
|
|
eventBus.on('child_reward_triggered', handleChildRewardTriggered)
|
|
|
|
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)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
eventBus.off('child_modified', handleChildModified)
|
|
eventBus.off('child_task_triggered', handleChildTaskTriggered)
|
|
eventBus.off('child_reward_triggered', handleChildRewardTriggered)
|
|
})
|
|
|
|
const shouldIgnoreNextCardClick = ref(false)
|
|
|
|
const onDocClick = (e: MouseEvent) => {
|
|
if (activeMenuFor.value !== null) {
|
|
const path = (e.composedPath && e.composedPath()) || (e as any).path || []
|
|
const clickedInsideKebab = path.some((node: unknown) => {
|
|
if (!(node instanceof HTMLElement)) return false
|
|
return (
|
|
node.classList.contains('kebab-wrap') ||
|
|
node.classList.contains('kebab-btn') ||
|
|
node.classList.contains('kebab-menu')
|
|
)
|
|
})
|
|
if (!clickedInsideKebab) {
|
|
activeMenuFor.value = null
|
|
// If the click was on a card, set the flag to ignore the next card click
|
|
if (
|
|
path.some((node: unknown) => node instanceof HTMLElement && node.classList.contains('card'))
|
|
) {
|
|
shouldIgnoreNextCardClick.value = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const selectChild = (childId: string | number) => {
|
|
if (shouldIgnoreNextCardClick.value) {
|
|
shouldIgnoreNextCardClick.value = false
|
|
return
|
|
}
|
|
if (activeMenuFor.value !== null) {
|
|
// If kebab menu is open, ignore card clicks
|
|
return
|
|
}
|
|
if (isParentAuthenticated.value) {
|
|
router.push(`/parent/${childId}`)
|
|
} else {
|
|
router.push(`/child/${childId}`)
|
|
}
|
|
}
|
|
|
|
// kebab menu helpers
|
|
const openMenu = (childId: string | number, evt?: Event) => {
|
|
evt?.stopPropagation()
|
|
activeMenuFor.value = childId
|
|
}
|
|
const closeMenu = () => {
|
|
activeMenuFor.value = null
|
|
}
|
|
|
|
// delete flow
|
|
const askDelete = (childId: string | number, evt?: Event) => {
|
|
evt?.stopPropagation()
|
|
deletingChildId.value = childId
|
|
confirmDeleteVisible.value = true
|
|
closeMenu()
|
|
}
|
|
|
|
const performDelete = async () => {
|
|
if (!deletingChildId.value) return
|
|
deleting.value = true
|
|
try {
|
|
const resp = await fetch(`/api/child/${deletingChildId.value}`, {
|
|
method: 'DELETE',
|
|
})
|
|
if (!resp.ok) {
|
|
throw new Error(`Delete failed: ${resp.status}`)
|
|
}
|
|
// refresh list
|
|
await fetchChildren()
|
|
} catch (err) {
|
|
console.error('Failed to delete child', deletingChildId.value, err)
|
|
} finally {
|
|
deleting.value = false
|
|
confirmDeleteVisible.value = false
|
|
deletingChildId.value = null
|
|
}
|
|
}
|
|
|
|
// Delete Points flow: set points to 0 via API and refresh points display
|
|
const deletePoints = async (childId: string | number, evt?: Event) => {
|
|
evt?.stopPropagation()
|
|
closeMenu()
|
|
updatingPointsFor.value = childId
|
|
try {
|
|
const resp = await fetch(`/api/child/${childId}/edit`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ points: 0 }),
|
|
})
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to update points: ${resp.status}`)
|
|
}
|
|
// no need to refresh since we update optimistically via eventBus
|
|
} catch (err) {
|
|
console.error('Failed to delete points for child', childId, err)
|
|
} finally {
|
|
updatingPointsFor.value = null
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', onDocClick, true)
|
|
revokeAllImageUrls()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<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 class="grid">
|
|
<div v-for="child in children" :key="child.id" class="card" @click="selectChild(child.id)">
|
|
<!-- kebab menu shown only for authenticated parent -->
|
|
<div v-if="isParentAuthenticated" class="kebab-wrap" @click.stop>
|
|
<!-- kebab button -->
|
|
<button
|
|
class="kebab-btn"
|
|
@mousedown.stop.prevent
|
|
@click="openMenu(child.id, $event)"
|
|
:aria-expanded="activeMenuFor === child.id ? 'true' : 'false'"
|
|
aria-label="Options"
|
|
>
|
|
⋮
|
|
</button>
|
|
|
|
<!-- menu items -->
|
|
<div
|
|
v-if="activeMenuFor === child.id"
|
|
class="kebab-menu"
|
|
@mousedown.stop.prevent
|
|
@click.stop
|
|
>
|
|
<button
|
|
class="menu-item"
|
|
@mousedown.stop.prevent
|
|
@click="openChildEditor(child, $event)"
|
|
>
|
|
Edit Child
|
|
</button>
|
|
<button
|
|
class="menu-item"
|
|
@mousedown.stop.prevent
|
|
@click="deletePoints(child.id, $event)"
|
|
:disabled="updatingPointsFor === child.id"
|
|
>
|
|
{{ updatingPointsFor === child.id ? 'Updating…' : 'Delete Points' }}
|
|
</button>
|
|
<button class="menu-item danger" @mousedown.stop.prevent @click="askDelete(child.id)">
|
|
Delete Child
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-content">
|
|
<h2>{{ child.name }}</h2>
|
|
<img
|
|
v-if="images.get(child.image_id)"
|
|
:src="images.get(child.image_id)"
|
|
alt="Child Image"
|
|
class="child-image"
|
|
/>
|
|
<p class="age">Age: {{ child.age }}</p>
|
|
<p class="points">Points: {{ child.points ?? 0 }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- confirmation modal -->
|
|
<div
|
|
v-if="confirmDeleteVisible"
|
|
class="modal-backdrop"
|
|
@click.self="confirmDeleteVisible = false"
|
|
>
|
|
<div class="modal">
|
|
<h3>Delete child?</h3>
|
|
<p>Are you sure you want to permanently delete this child?</p>
|
|
<div class="actions">
|
|
<button
|
|
class="btn btn-secondary"
|
|
@click="
|
|
() => {
|
|
confirmDeleteVisible = false
|
|
deletingChildId = null
|
|
}
|
|
"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button class="btn btn-danger" @click="performDelete" :disabled="deleting">
|
|
{{ deleting ? 'Deleting…' : 'Delete' }}
|
|
</button>
|
|
</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>
|
|
|
|
<style scoped>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
h1 {
|
|
color: white;
|
|
margin-bottom: 2rem;
|
|
font-size: 2.5rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.loading,
|
|
.empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 200px;
|
|
color: white;
|
|
font-size: 1.2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
gap: 1.2rem 1.2rem;
|
|
justify-items: center;
|
|
}
|
|
|
|
.card {
|
|
background: var(--card-bg);
|
|
box-shadow: var(--card-shadow);
|
|
border-radius: 12px;
|
|
overflow: visible; /* allow menu to overflow */
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
flex-direction: column;
|
|
cursor: pointer;
|
|
position: relative; /* for kebab positioning */
|
|
width: 250px;
|
|
}
|
|
|
|
/* kebab button / menu (fixed-size button, absolutely positioned menu) */
|
|
.kebab-wrap {
|
|
position: absolute;
|
|
top: 8px;
|
|
right: 8px;
|
|
z-index: 20;
|
|
/* keep the wrapper only as a positioning context */
|
|
}
|
|
|
|
.kebab-btn {
|
|
width: 36px;
|
|
height: 36px;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: transparent;
|
|
border: 0;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
color: #666;
|
|
border-radius: 6px;
|
|
box-sizing: border-box;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
/* consistent focus ring without changing layout */
|
|
.kebab-btn:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.18);
|
|
}
|
|
|
|
/* Menu overlays the card and does NOT alter flow */
|
|
.kebab-menu {
|
|
position: absolute;
|
|
top: 44px;
|
|
right: 0;
|
|
margin: 0;
|
|
min-width: 150px;
|
|
background: var(--kebab-menu-bg);
|
|
border: 1.5px solid var(--kebab-menu-border);
|
|
box-shadow: var(--kebab-menu-shadow);
|
|
backdrop-filter: blur(var(--kebab-menu-blur));
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
z-index: 30;
|
|
}
|
|
|
|
.menu-item {
|
|
padding: 1.1rem 0.9rem; /* Increase vertical padding for bigger touch area */
|
|
background: transparent;
|
|
border: 0;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
color: var(--menu-item-color);
|
|
font-size: 1.1rem; /* Slightly larger text for readability */
|
|
}
|
|
|
|
.menu-item + .menu-item {
|
|
margin-top: 0.5rem; /* Add space between menu items */
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.menu-item {
|
|
padding: 0.85rem 0.7rem;
|
|
font-size: 1rem;
|
|
}
|
|
.menu-item + .menu-item {
|
|
margin-top: 0.35rem;
|
|
}
|
|
}
|
|
|
|
.menu-item:hover {
|
|
background: var(--menu-item-hover-bg);
|
|
}
|
|
|
|
.menu-item.danger {
|
|
color: var(--menu-item-danger);
|
|
}
|
|
|
|
/* card content */
|
|
.card-content {
|
|
padding: 1.5rem;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.card h2 {
|
|
font-size: 1.5rem;
|
|
color: var(--card-title);
|
|
margin-bottom: 0.5rem;
|
|
word-break: break-word;
|
|
text-align: center;
|
|
}
|
|
|
|
.age {
|
|
font-size: 1.1rem;
|
|
color: var(--age-color);
|
|
font-weight: 500;
|
|
text-align: center;
|
|
}
|
|
|
|
.child-image {
|
|
width: 100px;
|
|
height: 100px;
|
|
object-fit: cover;
|
|
border-radius: 50%;
|
|
margin: 0 auto 1rem auto;
|
|
background: var(--child-image-bg);
|
|
}
|
|
|
|
/* modal */
|
|
.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: var(--modal-bg);
|
|
color: var(--modal-text);
|
|
padding: 1.25rem;
|
|
border-radius: 10px;
|
|
width: 360px;
|
|
max-width: calc(100% - 32px);
|
|
box-shadow: var(--modal-shadow);
|
|
text-align: center;
|
|
}
|
|
|
|
.modal h3 {
|
|
margin-bottom: 0.5rem;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.points {
|
|
font-size: 1.05rem;
|
|
color: var(--points-color);
|
|
margin-top: 0.4rem;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
}
|
|
|
|
/* Floating Action Button (FAB) */
|
|
.fab {
|
|
position: fixed;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
background: var(--fab-bg);
|
|
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: var(--fab-hover-bg);
|
|
}
|
|
|
|
.fab:active {
|
|
background: var(--fab-active-bg);
|
|
}
|
|
|
|
.no-children-message {
|
|
margin: 2rem 0;
|
|
font-size: 1.15rem;
|
|
font-weight: 600;
|
|
text-align: center;
|
|
color: var(--no-children-color);
|
|
line-height: 1.5;
|
|
}
|
|
.sub-message {
|
|
margin-top: 0.3rem;
|
|
font-size: 1rem;
|
|
font-weight: 400;
|
|
color: var(--sub-message-color);
|
|
}
|
|
.sign-in-btn {
|
|
background: var(--sign-in-btn-bg);
|
|
color: var(--sign-in-btn-color);
|
|
border: 2px solid var(--sign-in-btn-border);
|
|
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: var(--sign-in-btn-hover-bg);
|
|
color: var(--sign-in-btn-hover-color);
|
|
}
|
|
</style>
|