This commit is contained in:
2025-11-25 16:08:10 -05:00
parent cb0f972a5f
commit 72971f6d3e
19 changed files with 1595 additions and 785 deletions

View File

@@ -0,0 +1,524 @@
<script setup lang="ts">
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
}
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 showEditDialog = ref(false)
const editingChild = ref<Child | null>(null)
const openEditDialog = (child: Child, evt?: Event) => {
evt?.stopPropagation()
editingChild.value = { ...child } // shallow copy for editing
showEditDialog.value = true
closeMenu()
}
const closeEditDialog = () => {
showEditDialog.value = false
editingChild.value = null
}
// 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)
}
}
// extracted fetch so we can refresh after delete / points edit
const fetchChildren = async () => {
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()
children.value = data.children || []
// Fetch images for each child (shared cache util)
await Promise.all(
children.value.map((child) => {
if (child.image_id) {
return fetchImage(child.image_id)
}
return Promise.resolve()
}),
)
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch children'
console.error('Error fetching children:', err)
} finally {
loading.value = false
}
}
onMounted(async () => {
await fetchChildren()
// listen for outside clicks to auto-close any open kebab menu
document.addEventListener('click', onDocClick, true)
})
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 = () => {
console.log('Closing menu')
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}`)
}
// refresh the list so points reflect the change
await fetchChildren()
} 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 class="container">
<div v-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 -->
<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="openEditDialog(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>
<ChildForm
v-if="showEditDialog"
:child="editingChild"
@close="closeEditDialog"
@updated="
async () => {
closeEditDialog()
await fetchChildren()
}
"
/>
<!-- 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 cancel"
@click="
() => {
confirmDeleteVisible = false
deletingChildId = null
}
"
>
Cancel
</button>
<button class="btn delete" @click="performDelete" :disabled="deleting">
{{ deleting ? 'Deleting…' : 'Confirm Delete' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
}
h1 {
color: white;
margin-bottom: 2rem;
font-size: 2.5rem;
text-align: center;
}
.loading,
.error,
.empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: white;
font-size: 1.2rem;
text-align: center;
}
.error {
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
border-radius: 8px;
padding: 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
flex: 1;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
overflow: visible; /* allow menu to overflow */
transition: all 0.3s ease;
display: flex;
flex-direction: column;
cursor: pointer;
position: relative; /* for kebab positioning */
}
/* 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;
}
/* 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; /* place below the button */
right: 0; /* align to kebab button's right edge */
margin: 0;
min-width: 150px;
background: white;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 30;
}
.menu-item {
padding: 0.6rem 0.9rem;
background: transparent;
border: 0;
text-align: left;
cursor: pointer;
font-weight: 600;
color: #333;
}
.menu-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.menu-item.danger {
color: #ff4d4f;
}
/* 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: #333;
margin-bottom: 0.5rem;
word-break: break-word;
text-align: center;
}
.age {
font-size: 1.1rem;
color: #666;
font-weight: 500;
text-align: center;
}
.child-image {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 50%;
margin: 0 auto 1rem auto;
}
/* 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: #fff;
color: #222;
padding: 1.25rem;
border-radius: 10px;
width: 360px;
max-width: calc(100% - 32px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
text-align: center;
}
.modal h3 {
margin-bottom: 0.5rem;
font-size: 1.05rem;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
}
.btn {
padding: 0.5rem 0.8rem;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 700;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.delete {
background: #ff4d4f;
color: white;
}
.points {
font-size: 1.05rem;
color: #444;
margin-top: 0.4rem;
font-weight: 600;
text-align: center;
}
</style>