initial commit
This commit is contained in:
38
web/vue-app/src/components/AssignTaskButton.vue
Normal file
38
web/vue-app/src/components/AssignTaskButton.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import AssignTasksDialog from './AssignTasksDialog.vue'
|
||||
|
||||
const props = defineProps<{ childId: string | number | null }>()
|
||||
const showDialog = ref(false)
|
||||
const openDialog = () => {
|
||||
showDialog.value = true
|
||||
}
|
||||
const closeDialog = () => {
|
||||
showDialog.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button class="assign-tasks-btn" @click="openDialog">Assign Task</button>
|
||||
<AssignTasksDialog v-if="showDialog" :child-id="props.childId" @close="closeDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.assign-tasks-btn {
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 1.4rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 2rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.assign-tasks-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
</style>
|
||||
170
web/vue-app/src/components/AssignTasksDialog.vue
Normal file
170
web/vue-app/src/components/AssignTasksDialog.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { getCachedImageUrl } from '../common/imageCache'
|
||||
|
||||
const props = defineProps<{ childId: string | number }>()
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
name: string
|
||||
is_good: boolean
|
||||
points: number
|
||||
image_id?: string | null
|
||||
image_url?: string | null
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchTasks = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.childId}/list-assignable-tasks`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
const taskList: Task[] = data.assignable_tasks || []
|
||||
|
||||
// Fetch images for each task if image_id is present
|
||||
await Promise.all(
|
||||
taskList.map(async (task) => {
|
||||
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'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
watch(() => props.childId, fetchTasks)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<h3>Assign Tasks</h3>
|
||||
<div v-if="loading" class="loading">Loading tasks...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<div v-else class="task-listbox">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-list-item"
|
||||
:class="{ good: task.is_good, bad: !task.is_good }"
|
||||
>
|
||||
<img v-if="task.image_url" :src="task.image_url" alt="Task" class="task-image" />
|
||||
<span class="task-name">{{ task.name }}</span>
|
||||
<span class="task-points">
|
||||
{{ task.is_good ? task.points : '-' + task.points }} pts
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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: 260px;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.task-listbox {
|
||||
max-height: 320px;
|
||||
min-width: 220px;
|
||||
overflow-y: auto;
|
||||
margin: 1.2rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.task-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid #38c172;
|
||||
border-radius: 8px;
|
||||
padding: 0.6rem 1rem;
|
||||
background: #f8fafc;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
transition: border 0.18s;
|
||||
}
|
||||
.task-list-item.bad {
|
||||
border-color: #e53e3e;
|
||||
background: #fff5f5;
|
||||
}
|
||||
.task-list-item.good {
|
||||
border-color: #38c172;
|
||||
background: #f0fff4;
|
||||
}
|
||||
.task-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.task-points {
|
||||
min-width: 60px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
.close-btn {
|
||||
margin-top: 1.2rem;
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.close-btn:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
.loading,
|
||||
.error {
|
||||
margin: 1.2rem 0;
|
||||
color: #888;
|
||||
}
|
||||
.error {
|
||||
color: #e53e3e;
|
||||
}
|
||||
.task-image {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
margin-right: 0.7rem;
|
||||
background: #eee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
123
web/vue-app/src/components/ChildDetailCard.vue
Normal file
123
web/vue-app/src/components/ChildDetailCard.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, toRefs, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
image_id: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
child: Child | null
|
||||
}>()
|
||||
|
||||
const { child } = toRefs(props)
|
||||
const imageUrl = ref<string | null>(null)
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const fetchImage = async (imageId: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(imageId, imageCacheName)
|
||||
imageUrl.value = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching child image:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (child.value && child.value.image_id) {
|
||||
fetchImage(child.value.image_id)
|
||||
}
|
||||
})
|
||||
|
||||
// Revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="child" class="detail-card">
|
||||
<h1>{{ child.name }}</h1>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18);
|
||||
padding: 1.2rem 1rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.detail-card h1 {
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.child-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 0.7rem auto;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 7px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
/* Even more compact on small screens */
|
||||
@media (max-width: 480px) {
|
||||
.detail-card {
|
||||
padding: 0.7rem 0.4rem;
|
||||
max-width: 98vw;
|
||||
}
|
||||
.detail-card h1 {
|
||||
font-size: 1.05rem;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.child-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.info-item {
|
||||
padding: 0.38rem 0.5rem;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
687
web/vue-app/src/components/ChildForm.vue
Normal file
687
web/vue-app/src/components/ChildForm.vue
Normal file
@@ -0,0 +1,687 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps, defineEmits, ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { getCachedImageUrl } from '../common/imageCache'
|
||||
|
||||
const props = defineProps<{
|
||||
child: { id: string | number; name: string; age: number; image_id?: string | null } | null
|
||||
}>()
|
||||
const emit = defineEmits(['close', 'updated'])
|
||||
|
||||
const name = ref(props.child?.name ?? '')
|
||||
const age = ref(props.child?.age ?? '')
|
||||
const image = ref<File | null>(null)
|
||||
|
||||
// For image selection
|
||||
const availableImageIds = ref<string[]>([])
|
||||
const availableImageUrls = ref<{ id: string; url: string }[]>([])
|
||||
const loadingImages = ref(false)
|
||||
const selectedImageId = ref<string | null>(props.child?.image_id ?? null)
|
||||
const localImageUrl = ref<string | null>(null)
|
||||
|
||||
// Camera variables
|
||||
const showCamera = ref(false)
|
||||
const cameraStream = ref<MediaStream | null>(null)
|
||||
const cameraVideo = ref<HTMLVideoElement | null>(null)
|
||||
const cameraError = ref<string | null>(null)
|
||||
const capturedImageUrl = ref<string | null>(null)
|
||||
const cameraFile = ref<File | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.child,
|
||||
(c) => {
|
||||
name.value = c?.name ?? ''
|
||||
age.value = c?.age ?? ''
|
||||
image.value = null
|
||||
selectedImageId.value = c?.image_id ?? null
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selectImage = (id: string) => {
|
||||
selectedImageId.value = id
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
let imageId = selectedImageId.value
|
||||
|
||||
// If the selected image is a local upload, upload it first
|
||||
if (imageId === 'local-upload') {
|
||||
let file: File | null = null
|
||||
|
||||
// Try to get the file from the file input
|
||||
if (fileInput.value && fileInput.value.files && fileInput.value.files.length > 0) {
|
||||
file = fileInput.value.files[0]
|
||||
} else if (cameraFile.value) {
|
||||
file = cameraFile.value
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
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.')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
alert('No image file found to upload.')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Now update the child
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${props.child?.id}/edit`, {
|
||||
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 update child')
|
||||
emit('updated')
|
||||
} catch (err) {
|
||||
alert('Failed to update child.')
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch available images on mount
|
||||
onMounted(async () => {
|
||||
loadingImages.value = true
|
||||
try {
|
||||
const resp = await fetch('/api/image/list?type=1')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
let ids = data.ids || []
|
||||
if (props.child?.image_id && ids.includes(props.child.image_id)) {
|
||||
ids = [props.child.image_id, ...ids.filter((id) => id !== props.child.image_id)]
|
||||
} else {
|
||||
// No current image, just use the list as-is
|
||||
ids = [...ids]
|
||||
}
|
||||
availableImageIds.value = ids
|
||||
// Fetch URLs for each image id
|
||||
const urls = await Promise.all(
|
||||
availableImageIds.value.map(async (id: string) => {
|
||||
try {
|
||||
const url = await getCachedImageUrl(id)
|
||||
return { id, url }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}),
|
||||
)
|
||||
availableImageUrls.value = urls.filter(Boolean) as { id: string; url: string }[]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load available images', err)
|
||||
} finally {
|
||||
loadingImages.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const addFromLocal = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const files = (event.target as HTMLInputElement).files
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0]
|
||||
// Clean up previous local object URL if any
|
||||
if (localImageUrl.value) {
|
||||
URL.revokeObjectURL(localImageUrl.value)
|
||||
}
|
||||
const url = URL.createObjectURL(file)
|
||||
localImageUrl.value = url
|
||||
|
||||
// Insert at the front of the image lists
|
||||
availableImageUrls.value = [
|
||||
{ id: 'local-upload', url },
|
||||
...availableImageUrls.value.filter((img) => img.id !== 'local-upload'),
|
||||
]
|
||||
availableImageIds.value = [
|
||||
'local-upload',
|
||||
...availableImageIds.value.filter((id) => id !== 'local-upload'),
|
||||
]
|
||||
selectedImageId.value = 'local-upload'
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up local object URL on unmount
|
||||
onBeforeUnmount(() => {
|
||||
if (localImageUrl.value) {
|
||||
URL.revokeObjectURL(localImageUrl.value)
|
||||
}
|
||||
})
|
||||
|
||||
// Open camera modal
|
||||
const addFromCamera = async () => {
|
||||
cameraError.value = null
|
||||
capturedImageUrl.value = null
|
||||
showCamera.value = true
|
||||
await nextTick()
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
|
||||
cameraStream.value = stream
|
||||
if (cameraVideo.value) {
|
||||
cameraVideo.value.srcObject = stream
|
||||
await cameraVideo.value.play()
|
||||
}
|
||||
} catch (err) {
|
||||
cameraError.value = 'Unable to access camera'
|
||||
cameraStream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Take photo
|
||||
const takePhoto = async () => {
|
||||
if (!cameraVideo.value) return
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cameraVideo.value.videoWidth
|
||||
canvas.height = cameraVideo.value.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
ctx.drawImage(cameraVideo.value, 0, 0, canvas.width, canvas.height)
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
capturedImageUrl.value = dataUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm photo
|
||||
const confirmPhoto = async () => {
|
||||
if (capturedImageUrl.value) {
|
||||
// Clean up previous local object URL if any
|
||||
if (localImageUrl.value) {
|
||||
URL.revokeObjectURL(localImageUrl.value)
|
||||
}
|
||||
|
||||
// Create an image element to load the captured data URL
|
||||
const img = new window.Image()
|
||||
img.src = capturedImageUrl.value
|
||||
|
||||
await new Promise((resolve) => {
|
||||
img.onload = resolve
|
||||
})
|
||||
|
||||
// Calculate new dimensions
|
||||
let { width, height } = img
|
||||
const maxDim = 512
|
||||
if (width > maxDim || height > maxDim) {
|
||||
if (width > height) {
|
||||
height = Math.round((height * maxDim) / width)
|
||||
width = maxDim
|
||||
} else {
|
||||
width = Math.round((width * maxDim) / height)
|
||||
height = maxDim
|
||||
}
|
||||
}
|
||||
|
||||
// Draw to canvas
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Convert canvas to blob and object URL
|
||||
const blob: Blob = await new Promise((resolve) =>
|
||||
canvas.toBlob((b) => resolve(b!), 'image/png'),
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
localImageUrl.value = url
|
||||
|
||||
// Store the File for upload
|
||||
cameraFile.value = new File([blob], 'camera.png', { type: 'image/png' })
|
||||
|
||||
availableImageUrls.value = [
|
||||
{ id: 'local-upload', url },
|
||||
...availableImageUrls.value.filter((img) => img.id !== 'local-upload'),
|
||||
]
|
||||
availableImageIds.value = [
|
||||
'local-upload',
|
||||
...availableImageIds.value.filter((id) => id !== 'local-upload'),
|
||||
]
|
||||
selectedImageId.value = 'local-upload'
|
||||
}
|
||||
closeCamera()
|
||||
}
|
||||
|
||||
// Retake photo
|
||||
const retakePhoto = async () => {
|
||||
capturedImageUrl.value = null
|
||||
cameraFile.value = null
|
||||
await resumeCameraStream()
|
||||
}
|
||||
|
||||
// Close camera and stop stream
|
||||
const closeCamera = () => {
|
||||
showCamera.value = false
|
||||
capturedImageUrl.value = null
|
||||
if (cameraStream.value) {
|
||||
cameraStream.value.getTracks().forEach((track) => track.stop())
|
||||
cameraStream.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const resumeCameraStream = async () => {
|
||||
await nextTick()
|
||||
if (cameraVideo.value && cameraStream.value) {
|
||||
cameraVideo.value.srcObject = cameraStream.value
|
||||
try {
|
||||
await cameraVideo.value.play()
|
||||
} catch (e) {
|
||||
// ignore play errors
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-backdrop">
|
||||
<div class="modal">
|
||||
<h3>Edit Child</h3>
|
||||
<form @submit.prevent="submit" class="form">
|
||||
<div class="form-group">
|
||||
<label for="child-name">Name</label>
|
||||
<input id="child-name" v-model="name" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="child-age">Age</label>
|
||||
<input id="child-age" v-model="age" type="number" min="0" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Image</label>
|
||||
</div>
|
||||
<div class="image-scroll">
|
||||
<div v-if="loadingImages" class="loading-images">Loading images...</div>
|
||||
<div v-else class="image-list">
|
||||
<img
|
||||
v-for="img in availableImageUrls"
|
||||
:key="img.id"
|
||||
:src="img.url"
|
||||
class="selectable-image"
|
||||
:class="{ selected: selectedImageId === img.id }"
|
||||
:alt="`Image ${img.id}`"
|
||||
@click="selectImage(img.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden file input for local image selection -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
|
||||
style="display: none"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
|
||||
<span class="icon">+</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
@click="addFromCamera"
|
||||
aria-label="Add from camera"
|
||||
>
|
||||
<span class="icon">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="6"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="#667eea"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="10" cy="11" r="3" stroke="#667eea" stroke-width="1.5" />
|
||||
<rect x="7" y="3" width="6" height="3" rx="1" stroke="#667eea" stroke-width="1.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn cancel" @click="emit('close')">Cancel</button>
|
||||
<button type="submit" class="btn save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Camera modal -->
|
||||
<div v-if="showCamera" class="modal-backdrop">
|
||||
<div class="modal camera-modal">
|
||||
<h3>Take a Photo</h3>
|
||||
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
|
||||
<div v-else>
|
||||
<div v-if="!capturedImageUrl">
|
||||
<video ref="cameraVideo" autoplay playsinline class="camera-video"></video>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn save" @click="takePhoto">Take Photo</button>
|
||||
<button type="button" class="btn cancel" @click="closeCamera">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<img :src="capturedImageUrl" class="captured-preview" alt="Preview" />
|
||||
<div class="actions">
|
||||
<button type="button" class="btn save" @click="confirmPhoto">Use Photo</button>
|
||||
<button type="button" class="btn cancel" @click="retakePhoto">Retake</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
z-index: 1200;
|
||||
overflow-y: auto;
|
||||
padding-top: max(3vh, env(safe-area-inset-top, 24px));
|
||||
padding-bottom: max(3vh, env(safe-area-inset-bottom, 24px));
|
||||
}
|
||||
|
||||
.modal,
|
||||
.camera-modal {
|
||||
background: #fff;
|
||||
color: #222;
|
||||
padding: 1.2rem 2rem 1.2rem 2rem;
|
||||
border-radius: 12px;
|
||||
width: 360px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
text-align: center;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* For small screens (portrait or landscape), use 90vw and reduce height */
|
||||
@media (max-width: 600px), (max-height: 480px) {
|
||||
.modal,
|
||||
.camera-modal {
|
||||
width: 90vw;
|
||||
max-width: 98vw;
|
||||
max-height: 94vh;
|
||||
padding: 0.7rem 0.7rem 0.7rem 0.7rem;
|
||||
font-size: 0.97rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* For landscape on larger screens, use 75vw but max 600px */
|
||||
@media (orientation: landscape) and (min-width: 601px) {
|
||||
.modal,
|
||||
.camera-modal {
|
||||
width: 75vw;
|
||||
max-width: 600px;
|
||||
max-height: 94vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Limit video and preview image height for all screens */
|
||||
.camera-video,
|
||||
.captured-preview {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-height: 180px;
|
||||
border-radius: 12px;
|
||||
background: #222;
|
||||
margin-bottom: 1rem;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 1.2rem;
|
||||
font-size: 1.15rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.browse-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 1.1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.18s;
|
||||
}
|
||||
.browse-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.image-scroll {
|
||||
width: 100%;
|
||||
margin: 0.7rem 0 0.2rem 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.image-list {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
min-width: min-content;
|
||||
align-items: center;
|
||||
}
|
||||
.selectable-image {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #e6e6e6;
|
||||
background: #fafbff;
|
||||
cursor: pointer;
|
||||
transition: border 0.18s;
|
||||
}
|
||||
.selectable-image:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
.selectable-image.selected {
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px #667eea55;
|
||||
}
|
||||
.loading-images {
|
||||
color: #888;
|
||||
font-size: 0.98rem;
|
||||
padding: 0.5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: #f3f3f3;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.18s;
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.07);
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: #e0e7ff;
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Camera modal styles */
|
||||
.camera-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1300;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
.camera-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.camera-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.camera-error {
|
||||
color: #ff4d4f;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 12px;
|
||||
background: #222;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.camera-actions {
|
||||
padding: 0.8rem 1rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.btn.capture {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
.btn.capture:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
.btn.confirm {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
flex: 1;
|
||||
}
|
||||
.btn.confirm:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.captured-preview {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 1rem;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
343
web/vue-app/src/components/ChildRewardList.vue
Normal file
343
web/vue-app/src/components/ChildRewardList.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
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
|
||||
}
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
childId: string | number | null
|
||||
isParentAuthenticated: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
|
||||
const rewards = ref<Reward[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const scrollWrapper = ref<HTMLDivElement | null>(null)
|
||||
const rewardRefs = ref<Record<string, HTMLElement | null>>({})
|
||||
const lastCenteredRewardId = ref<string | null>(null)
|
||||
const readyRewardId = ref<string | null>(null)
|
||||
|
||||
const fetchRewards = async (id: string | number | null) => {
|
||||
if (!id) {
|
||||
rewards.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${id}/reward-status`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
rewards.value = data.reward_status
|
||||
|
||||
// Fetch images for each reward using shared utility
|
||||
await Promise.all(rewards.value.map(fetchImage))
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch rewards'
|
||||
console.error('Error fetching rewards:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (reward: Reward) => {
|
||||
if (!reward.image_id) {
|
||||
console.log(`No image ID for reward: ${reward.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(reward.image_id, imageCacheName)
|
||||
reward.image_id = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for reward', reward.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const centerReward = async (rewardId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
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 handleRewardClick = async (rewardId: string) => {
|
||||
if (!props.isParentAuthenticated) return // Only allow if logged in
|
||||
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = rewardRefs.value[rewardId]
|
||||
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 || lastCenteredRewardId.value !== rewardId) {
|
||||
// Center the reward, but don't trigger
|
||||
await centerReward(rewardId)
|
||||
lastCenteredRewardId.value = rewardId
|
||||
readyRewardId.value = rewardId
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the reward
|
||||
await triggerReward(rewardId)
|
||||
readyRewardId.value = null
|
||||
}
|
||||
|
||||
const triggerReward = async (rewardId: string) => {
|
||||
if (!props.childId) return
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchRewards(props.childId))
|
||||
watch(
|
||||
() => props.childId,
|
||||
(v) => fetchRewards(v),
|
||||
)
|
||||
|
||||
// revoke created object URLs when component unmounts to avoid memory leaks
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
|
||||
// expose refresh method for parent component
|
||||
defineExpose({ refresh: () => fetchRewards(props.childId) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reward-list-container">
|
||||
<h3>Rewards</h3>
|
||||
|
||||
<div v-if="loading" class="loading">Loading rewards...</div>
|
||||
<div v-else-if="error" class="error">Error: {{ error }}</div>
|
||||
<div v-else-if="rewards.length === 0" class="empty">No rewards available</div>
|
||||
|
||||
<div v-else class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="reward-scroll">
|
||||
<div
|
||||
v-for="r in rewards"
|
||||
:key="r.id"
|
||||
class="reward-card"
|
||||
:class="{
|
||||
ready: readyRewardId === r.id,
|
||||
disabled: r.points_needed > 0,
|
||||
}"
|
||||
:ref="(el) => (rewardRefs[r.id] = el)"
|
||||
@click="() => handleRewardClick(r.id)"
|
||||
>
|
||||
<div class="reward-name">{{ r.name }}</div>
|
||||
<img v-if="r.image_id" :src="r.image_id" alt="Reward Image" class="reward-image" />
|
||||
<div class="reward-points" :class="{ ready: r.points_needed === 0 }">
|
||||
<template v-if="r.points_needed === 0"> REWARD READY </template>
|
||||
<template v-else> {{ r.points_needed }} pts needed </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reward-list-container {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
color: white;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-list-container h3 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Modern scrollbar styling */
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8));
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1));
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.reward-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.reward-card {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.18s ease;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.reward-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.reward-card.ready {
|
||||
box-shadow:
|
||||
0 0 0 3px #ffd166cc,
|
||||
0 0 12px #ffd16688;
|
||||
border-color: #ffd166;
|
||||
animation: ready-glow 0.7s;
|
||||
}
|
||||
|
||||
.reward-card.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #ffd16600;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px #ffd166cc,
|
||||
0 0 12px #ffd16688;
|
||||
border-color: #ffd166;
|
||||
}
|
||||
}
|
||||
|
||||
.reward-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.4rem;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
.reward-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin: 0 auto 0.4rem auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reward-points {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.reward-points.ready {
|
||||
color: #38c172; /* a nice green */
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Mobile tweaks */
|
||||
@media (max-width: 480px) {
|
||||
.reward-card {
|
||||
min-width: 110px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.reward-name {
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.reward-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.reward-points {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
343
web/vue-app/src/components/ChildTaskList.vue
Normal file
343
web/vue-app/src/components/ChildTaskList.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } 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
|
||||
}
|
||||
|
||||
const imageCacheName = 'images-v1'
|
||||
|
||||
const props = defineProps<{
|
||||
taskIds: string[]
|
||||
childId: string | number | null
|
||||
isParentAuthenticated: boolean
|
||||
}>()
|
||||
const emit = defineEmits(['points-updated'])
|
||||
|
||||
const tasks = ref<Task[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
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 () => {
|
||||
const taskPromises = props.taskIds.map((id) =>
|
||||
fetch(`/api/task/${id}`).then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}),
|
||||
)
|
||||
|
||||
try {
|
||||
const results = await Promise.all(taskPromises)
|
||||
tasks.value = results
|
||||
|
||||
// Fetch images for each task (uses shared imageCache)
|
||||
await Promise.all(tasks.value.map(fetchImage))
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
|
||||
console.error('Error fetching tasks:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchImage = async (task: Task) => {
|
||||
if (!task.image_id) {
|
||||
console.log(`No image ID for task: ${task.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await getCachedImageUrl(task.image_id, imageCacheName)
|
||||
task.image_id = url
|
||||
} catch (err) {
|
||||
console.error('Error fetching image for task', task.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
const centerTask = async (taskId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = taskRefs.value[taskId]
|
||||
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 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 handleTaskClick = async (taskId: string) => {
|
||||
await nextTick()
|
||||
const wrapper = scrollWrapper.value
|
||||
const card = taskRefs.value[taskId]
|
||||
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 || lastCenteredTaskId.value !== taskId) {
|
||||
// Center the task, but don't trigger
|
||||
await centerTask(taskId)
|
||||
lastCenteredTaskId.value = taskId
|
||||
lastCenterTime.value = Date.now()
|
||||
readyTaskId.value = taskId // <-- Add this line
|
||||
return
|
||||
}
|
||||
|
||||
// If already centered and visible, trigger the task
|
||||
triggerTask(taskId)
|
||||
readyTaskId.value = null
|
||||
}
|
||||
|
||||
onMounted(fetchTasks)
|
||||
|
||||
// revoke all created object URLs when component unmounts
|
||||
onBeforeUnmount(() => {
|
||||
revokeAllImageUrls()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="task-list-container">
|
||||
<h3>Tasks</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 class="scroll-wrapper" ref="scrollWrapper">
|
||||
<div class="task-scroll">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
:class="{ good: task.is_good, bad: !task.is_good, ready: readyTaskId === task.id }"
|
||||
:ref="(el) => (taskRefs[task.id] = el)"
|
||||
@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" />
|
||||
<div
|
||||
class="task-points"
|
||||
:class="{ 'good-points': task.is_good, 'bad-points': !task.is_good }"
|
||||
>
|
||||
{{ task.is_good ? task.points : -task.points }} pts
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-list-container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
color: white;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.task-list-container h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scroll-behavior: smooth;
|
||||
width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Modern scrollbar styling */
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 0.8), rgba(118, 75, 162, 0.8));
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(102, 126, 234, 1), rgba(118, 75, 162, 1));
|
||||
box-shadow: 0 0 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.task-scroll {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
min-width: min-content;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
min-width: 110px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid rgba(255, 255, 255, 0.15);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Outline colors depending on is_good */
|
||||
.task-card.good {
|
||||
border-color: rgba(46, 204, 113, 0.9); /* green */
|
||||
background: rgba(46, 204, 113, 0.06);
|
||||
}
|
||||
|
||||
.task-card.bad {
|
||||
border-color: rgba(255, 99, 71, 0.95); /* red */
|
||||
background: rgba(255, 99, 71, 0.03);
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.task-card.ready {
|
||||
box-shadow:
|
||||
0 0 0 3px #667eea88,
|
||||
0 0 12px #667eea44;
|
||||
border-color: #667eea;
|
||||
animation: ready-glow 0.7s;
|
||||
}
|
||||
|
||||
@keyframes ready-glow {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 #667eea00;
|
||||
border-color: inherit;
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 3px #667eea88,
|
||||
0 0 12px #667eea44;
|
||||
border-color: #667eea;
|
||||
}
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.4rem;
|
||||
word-break: break-word;
|
||||
line-height: 1.2;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.task-points {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.task-points.good-points {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.task-points.bad-points {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Image styling */
|
||||
.task-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) {
|
||||
.task-list-container {
|
||||
padding: 0.75rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.task-card {
|
||||
min-width: 90px;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
.task-name {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.task-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto 0.3rem auto;
|
||||
}
|
||||
.task-points {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar {
|
||||
height: 10px;
|
||||
}
|
||||
.scroll-wrapper::-webkit-scrollbar-thumb {
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
web/vue-app/src/components/ChildView.vue
Normal file
163
web/vue-app/src/components/ChildView.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from './ChildTaskList.vue'
|
||||
import ChildRewardList from './ChildRewardList.vue'
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.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 || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</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 class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
/>
|
||||
<ChildRewardList
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="false"
|
||||
@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>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
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;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
/* Remove grid styles */
|
||||
/* grid-template-columns: 1fr 320px; */
|
||||
/* gap: 1.5rem; */
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
524
web/vue-app/src/components/ChildrenList.vue
Normal file
524
web/vue-app/src/components/ChildrenList.vue
Normal 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 './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>
|
||||
161
web/vue-app/src/components/LoginButton.vue
Normal file
161
web/vue-app/src/components/LoginButton.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { authenticateParent, isParentAuthenticated, logout } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const show = ref(false)
|
||||
const pin = ref('')
|
||||
const error = ref('')
|
||||
const pinInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const open = async () => {
|
||||
pin.value = ''
|
||||
error.value = ''
|
||||
show.value = true
|
||||
await nextTick()
|
||||
pinInput.value?.focus()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
show.value = false
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const isDigits = /^\d{4,6}$/.test(pin.value)
|
||||
if (!isDigits) {
|
||||
error.value = 'Enter 4–6 digits'
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate parent and navigate
|
||||
authenticateParent()
|
||||
close()
|
||||
router.push('/parent')
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
router.push('/child')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-root">
|
||||
<button v-if="!isParentAuthenticated" class="login-btn" @click="open" aria-label="Parent login">
|
||||
Parent
|
||||
</button>
|
||||
<button v-else class="login-btn" @click="handleLogout" aria-label="Parent logout">
|
||||
Log out
|
||||
</button>
|
||||
|
||||
<div v-if="show" class="modal-backdrop" @click.self="close">
|
||||
<div class="modal">
|
||||
<h3>Enter parent PIN</h3>
|
||||
<form @submit.prevent="submit">
|
||||
<input
|
||||
ref="pinInput"
|
||||
v-model="pin"
|
||||
inputmode="numeric"
|
||||
pattern="\d*"
|
||||
maxlength="6"
|
||||
placeholder="4–6 digits"
|
||||
class="pin-input"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn cancel" @click="close">Cancel</button>
|
||||
<button type="submit" class="btn submit">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 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: 320px;
|
||||
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;
|
||||
}
|
||||
|
||||
.pin-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e6e6e6;
|
||||
margin-bottom: 0.6rem;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn.cancel {
|
||||
background: #f3f3f3;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn.submit {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
174
web/vue-app/src/components/ParentView.vue
Normal file
174
web/vue-app/src/components/ParentView.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { isParentAuthenticated } from '../stores/auth'
|
||||
import ChildDetailCard from './ChildDetailCard.vue'
|
||||
import ChildTaskList from './ChildTaskList.vue'
|
||||
import ChildRewardList from './ChildRewardList.vue'
|
||||
import AssignTaskButton from './AssignTaskButton.vue' // <-- Import here
|
||||
|
||||
interface Child {
|
||||
id: string | number
|
||||
name: string
|
||||
age: number
|
||||
points?: number
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const child = ref<Child | null>(null)
|
||||
const tasks = ref<string[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const rewardListRef = ref()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const resp = await fetch(`/api/child/${route.params.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 || []
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch child'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const refreshRewards = () => {
|
||||
rewardListRef.value?.refresh()
|
||||
}
|
||||
</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 class="layout">
|
||||
<div class="main">
|
||||
<ChildDetailCard :child="child" />
|
||||
<ChildTaskList
|
||||
:task-ids="tasks"
|
||||
:child-id="child ? child.id : null"
|
||||
:is-parent-authenticated="isParentAuthenticated"
|
||||
@points-updated="
|
||||
({ id, points }) => {
|
||||
if (child && child.id === id) child.points = points
|
||||
refreshRewards()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<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()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Place the AssignTaskButton here, outside .main but inside .container -->
|
||||
<AssignTaskButton :child-id="child ? child.id : null" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
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;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.error {
|
||||
color: #ff6b6b;
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
/* Remove grid styles */
|
||||
/* grid-template-columns: 1fr 320px; */
|
||||
/* gap: 1.5rem; */
|
||||
}
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 900px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user