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

@@ -128,7 +128,7 @@ def list_child_tasks(id):
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id')) ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
child_tasks.append(ct.to_dict()) child_tasks.append(ct.to_dict())
return jsonify({'child_tasks': child_tasks}), 200 return jsonify({'tasks': child_tasks}), 200
@child_api.route('/child/<id>/list-assignable-tasks', methods=['GET']) @child_api.route('/child/<id>/list-assignable-tasks', methods=['GET'])
def list_assignable_tasks(id): def list_assignable_tasks(id):
@@ -156,7 +156,7 @@ def list_assignable_tasks(id):
ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id')) ct = ChildTask(task.get('name'), task.get('is_good'), task.get('points'), task.get('image_id'), task.get('id'))
assignable_tasks.append(ct.to_dict()) assignable_tasks.append(ct.to_dict())
return jsonify({'assignable_tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200 return jsonify({'tasks': assignable_tasks, 'count': len(assignable_tasks)}), 200
@child_api.route('/child/<id>/trigger-task', methods=['POST']) @child_api.route('/child/<id>/trigger-task', methods=['POST'])
def trigger_child_task(id): def trigger_child_task(id):

View File

@@ -46,3 +46,43 @@ def delete_task(id):
child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id')) child_db.update({'tasks': tasks}, ChildQuery.id == child.get('id'))
return jsonify({'message': f'Task {id} deleted.'}), 200 return jsonify({'message': f'Task {id} deleted.'}), 200
return jsonify({'error': 'Task not found'}), 404 return jsonify({'error': 'Task not found'}), 404
@task_api.route('/task/<id>/edit', methods=['PUT'])
def edit_task(id):
TaskQuery = Query()
existing = task_db.get(TaskQuery.id == id)
if not existing:
return jsonify({'error': 'Task not found'}), 404
data = request.get_json(force=True) or {}
updates = {}
if 'name' in data:
name = data.get('name', '').strip()
if not name:
return jsonify({'error': 'Name cannot be empty'}), 400
updates['name'] = name
if 'points' in data:
points = data.get('points')
if not isinstance(points, int):
return jsonify({'error': 'Points must be an integer'}), 400
if points <= 0:
return jsonify({'error': 'Points must be a positive integer'}), 400
updates['points'] = points
if 'is_good' in data:
is_good = data.get('is_good')
if not isinstance(is_good, bool):
return jsonify({'error': 'is_good must be a boolean'}), 400
updates['is_good'] = is_good
if 'image_id' in data:
updates['image_id'] = data.get('image_id', '')
if not updates:
return jsonify({'error': 'No valid fields to update'}), 400
task_db.update(updates, TaskQuery.id == id)
updated = task_db.get(TaskQuery.id == id)
return jsonify(updated), 200

BIN
requirements.txt Normal file

Binary file not shown.

View File

@@ -1,687 +0,0 @@
<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>

View File

@@ -3,7 +3,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache' import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache'
import { isParentAuthenticated } from '../stores/auth' import { isParentAuthenticated } from '../stores/auth'
import ChildForm from './ChildForm.vue' import ChildForm from './child/ChildForm.vue'
interface Child { interface Child {
id: string | number id: string | number

View File

@@ -0,0 +1,432 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, defineProps, defineEmits, computed } from 'vue'
import { getCachedImageUrl } from '../common/imageCache'
const props = defineProps<{
modelValue?: string | null // selected image id or local-upload
imageType?: number // 1 or 2, default 1
}>()
const emit = defineEmits(['update:modelValue', 'add-image'])
const fileInput = ref<HTMLInputElement | null>(null)
const localImageUrl = ref<string | null>(null)
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)
const availableImages = ref<{ id: string; url: string }[]>([])
const loadingImages = ref(false)
const typeParam = computed(() => props.imageType ?? 1)
const selectImage = (id: string | undefined) => {
if (!id) {
console.warn('selectImage called with null id')
return
}
emit('update:modelValue', id)
}
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]
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
const url = URL.createObjectURL(file)
localImageUrl.value = url
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
if (idx === -1) {
availableImages.value.unshift({ id: 'local-upload', url }) // <-- use unshift
} else {
availableImages.value[idx].url = url
}
emit('add-image', { id: 'local-upload', url, file })
emit('update:modelValue', 'local-upload')
}
}
onBeforeUnmount(() => {
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
})
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
}
}
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
}
}
const confirmPhoto = async () => {
if (capturedImageUrl.value) {
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
const img = new window.Image()
img.src = capturedImageUrl.value
await new Promise((resolve) => {
img.onload = resolve
})
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
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx?.drawImage(img, 0, 0, width, height)
const blob: Blob = await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b!), 'image/png'),
)
const url = URL.createObjectURL(blob)
localImageUrl.value = url
cameraFile.value = new File([blob], 'camera.png', { type: 'image/png' })
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
if (idx === -1) {
availableImages.value.unshift({ id: 'local-upload', url }) // <-- use unshift
} else {
availableImages.value[idx].url = url
}
emit('add-image', { id: 'local-upload', url, file: cameraFile.value })
emit('update:modelValue', 'local-upload')
}
closeCamera()
}
const retakePhoto = async () => {
capturedImageUrl.value = null
cameraFile.value = null
await resumeCameraStream()
}
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) {}
}
}
// Fetch images on mount
onMounted(async () => {
loadingImages.value = true
try {
const resp = await fetch(`/api/image/list?type=${typeParam.value}`)
if (resp.ok) {
const data = await resp.json()
const ids = data.ids || []
// Fetch URLs for each image id using the cache
const urls = await Promise.all(
ids.map(async (id: string) => {
try {
const url = await getCachedImageUrl(id)
return { id, url }
} catch {
return null
}
}),
)
const images = urls.filter(Boolean) as { id: string; url: string }[]
// Move the selected image to the front if it exists
if (props.modelValue) {
const idx = images.findIndex((img) => img.id === props.modelValue)
if (idx > 0) {
const [selected] = images.splice(idx, 1)
images.unshift(selected)
}
}
availableImages.value = images
}
} catch (err) {
// Optionally handle error
} finally {
loadingImages.value = false
}
})
</script>
<template>
<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 availableImages"
:key="img.id"
:src="img.url"
class="selectable-image"
:class="{ selected: modelValue === img.id }"
:alt="`Image ${img.id}`"
@click="selectImage(img.id)"
/>
</div>
</div>
<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>
<!-- 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>
</template>
<style scoped>
.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;
}
/* 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;
text-align: center;
}
.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;
}
.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;
}
.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>

View File

@@ -42,13 +42,9 @@ const handleLogout = () => {
</script> </script>
<template> <template>
<div class="login-root"> <div>
<button v-if="!isParentAuthenticated" class="login-btn" @click="open" aria-label="Parent login"> <button v-if="!isParentAuthenticated" @click="open" aria-label="Parent login">Parent</button>
Parent <button v-else @click="handleLogout" aria-label="Parent logout">Log out</button>
</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 v-if="show" class="modal-backdrop" @click.self="close">
<div class="modal"> <div class="modal">
@@ -74,22 +70,7 @@ const handleLogout = () => {
</div> </div>
</template> </template>
<style scoped> <style>
.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 */
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { defineProps, toRefs, ref, onMounted, onBeforeUnmount } from 'vue' import { defineProps, toRefs, ref, onMounted, onBeforeUnmount } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache' import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Child { interface Child {
id: string | number id: string | number

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { defineProps, defineEmits, ref, watch } from 'vue'
import ImagePicker from '../ImagePicker.vue'
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 selectedImageId = ref<string | null>(props.child?.image_id ?? null)
const localImageFile = ref<File | null>(null)
watch(
() => props.child,
(c) => {
name.value = c?.name ?? ''
age.value = c?.age ?? ''
selectedImageId.value = c?.image_id ?? null
localImageFile.value = null
},
{ immediate: true },
)
// Handle new image from ImagePicker
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
const submit = async () => {
let imageId = selectedImageId.value
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '1')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
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.')
}
}
</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 image-picker-group">
<label for="child-image">Image</label>
<ImagePicker
id="child-image"
v-model="selectedImageId"
:image-type="1"
@add-image="onAddImage"
/>
</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>
</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 {
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;
}
@media (max-width: 600px), (max-height: 480px) {
.modal {
width: 90vw;
max-width: 98vw;
max-height: 94vh;
padding: 0.7rem 0.7rem 0.7rem 0.7rem;
font-size: 0.97rem;
}
}
@media (orientation: landscape) and (min-width: 601px) {
.modal {
width: 75vw;
max-width: 600px;
max-height: 94vh;
}
}
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;
}
.actions {
display: flex;
gap: 0.7rem;
justify-content: center;
margin-top: 0.5rem;
}
.btn {
padding: 0.5rem 1.1rem;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
transition: background 0.18s;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.save {
background: #667eea;
color: white;
}
.btn.save:hover {
background: #5a67d8;
}
.form-group.image-picker-group {
display: block;
text-align: left;
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from './ChildTaskList.vue' import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from './ChildRewardList.vue' import ChildRewardList from '../reward/ChildRewardList.vue'
interface Child { interface Child {
id: string | number id: string | number

View File

@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { isParentAuthenticated } from '../stores/auth' import { isParentAuthenticated } from '../../stores/auth'
import ChildDetailCard from './ChildDetailCard.vue' import ChildDetailCard from './ChildDetailCard.vue'
import ChildTaskList from './ChildTaskList.vue' import ChildTaskList from '../task/ChildTaskList.vue'
import ChildRewardList from './ChildRewardList.vue' import ChildRewardList from '../reward/ChildRewardList.vue'
import AssignTaskButton from './AssignTaskButton.vue' // <-- Import here import AssignTaskButton from '../AssignTaskButton.vue'
interface Child { interface Child {
id: string | number id: string | number

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import { defineProps, defineEmits, defineExpose } from 'vue' import { defineProps, defineEmits, defineExpose } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache' import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Reward { interface Reward {
id: string id: string

View File

@@ -0,0 +1,14 @@
<template>
<div class="reward-view-stub">
<h2>Reward View</h2>
<p>This is a stub for the Reward View.</p>
</div>
</template>
<style scoped>
.reward-view-stub {
padding: 2rem;
text-align: center;
color: #764ba2;
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { defineProps, defineEmits } from 'vue' import { defineProps, defineEmits } from 'vue'
import { getCachedImageUrl, revokeAllImageUrls } from '../common/imageCache' import { getCachedImageUrl, revokeAllImageUrls } from '../../common/imageCache'
interface Task { interface Task {
id: string id: string

View File

@@ -0,0 +1,320 @@
<script setup lang="ts">
import { ref, onMounted, computed, defineEmits, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ImagePicker from '@/components/ImagePicker.vue'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const emit = defineEmits<{
(e: 'updated'): void
}>()
// Define props
const props = defineProps<{
id?: string
}>()
const name = ref('')
const points = ref(0)
const isGood = ref(true)
const selectedImageId = ref<string | null>(null)
const localImageFile = ref<File | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
// Load task if editing
onMounted(async () => {
if (isEdit.value) {
loading.value = true
try {
const resp = await fetch(`/api/task/${props.id}`)
if (!resp.ok) throw new Error('Failed to load task')
const data = await resp.json()
name.value = data.name
points.value = Number(data.points) || 0
isGood.value = data.is_good
selectedImageId.value = data.image_id
} catch (e) {
error.value = 'Could not load task.'
} finally {
loading.value = false
// Delay focus until after DOM updates and event propagation
await nextTick()
nameInput.value?.focus()
}
} else {
// For create, also use nextTick
await nextTick()
nameInput.value?.focus()
}
})
const submit = async () => {
let imageId = selectedImageId.value
error.value = null
if (!name.value.trim()) {
error.value = 'Task name is required.'
return
}
if (points.value < 1) {
error.value = 'Points must be at least 1.'
return
}
loading.value = true
// If the selected image is a local upload, upload it first
if (imageId === 'local-upload' && localImageFile.value) {
const formData = new FormData()
formData.append('file', localImageFile.value)
formData.append('type', '2')
formData.append('permanent', 'false')
try {
const resp = await fetch('/api/image/upload', {
method: 'POST',
body: formData,
})
if (!resp.ok) throw new Error('Image upload failed')
const data = await resp.json()
imageId = data.id
} catch (err) {
alert('Failed to upload image.')
loading.value = false
return
}
}
// Now update or create the task
try {
let resp
if (isEdit.value) {
resp = await fetch(`/api/task/${props.id}/edit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
points: points.value,
is_good: isGood.value,
image_id: imageId,
}),
})
} else {
resp = await fetch('/api/task/add', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
points: points.value,
is_good: isGood.value,
image_id: imageId,
}),
})
}
if (!resp.ok) throw new Error('Failed to save task')
emit('updated')
await router.push({ name: 'TaskView' })
} catch (err) {
alert('Failed to save task.')
}
loading.value = false
}
function handleCancel() {
router.back()
}
// Handle new image from ImagePicker
function onAddImage({ id, file }: { id: string; file: File }) {
if (id === 'local-upload') {
localImageFile.value = file
}
}
</script>
<template>
<div class="task-edit-view">
<h2>{{ isEdit ? 'Edit Task' : 'Create Task' }}</h2>
<div v-if="loading" class="loading-message">Loading task...</div>
<form v-else @submit.prevent="submit" class="task-form">
<label>
Task Name
<input ref="nameInput" v-model="name" type="text" required maxlength="64" />
</label>
<label>
Task Points
<input v-model.number="points" type="number" min="1" max="100" required />
</label>
<label>
Task Type
<div class="good-bad-toggle">
<button
type="button"
:class="['toggle-btn', isGood ? 'good-active' : '']"
@click="isGood = true"
>
Good
</button>
<button
type="button"
:class="['toggle-btn', !isGood ? 'bad-active' : '']"
@click="isGood = false"
>
Bad
</button>
</div>
</label>
<div class="form-group image-picker-group">
<ImagePicker
id="task-image"
v-model="selectedImageId"
:image-type="2"
@add-image="onAddImage"
/>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div class="actions">
<button type="button" @click="handleCancel" :disabled="loading">Cancel</button>
<button type="submit" :disabled="loading">
{{ isEdit ? 'Save' : 'Create' }}
</button>
</div>
</form>
</div>
</template>
<style scoped>
.task-edit-view {
max-width: 400px;
width: 100%;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 24px #667eea22;
padding: 2rem 2.2rem 1.5rem 2.2rem;
margin: 0 auto;
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
box-sizing: border-box;
}
.task-form {
display: flex;
flex-direction: column;
min-height: 0;
}
h2 {
text-align: center;
margin-bottom: 1.5rem;
color: #667eea;
}
.task-form label {
display: block;
margin-bottom: 1.1rem;
font-weight: 500;
color: #444;
width: 100%;
}
.task-form input[type='text'],
.task-form input[type='number'] {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.5rem;
border-radius: 7px;
border: 1px solid #cbd5e1;
font-size: 1rem;
background: #f8fafc;
box-sizing: border-box;
}
button[type='submit'] {
background: #667eea;
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem 1.4rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
transition: background 0.18s;
}
button[type='submit']:hover:not(:disabled) {
background: #5a67d8;
}
button[type='button'] {
background: #f3f3f3;
color: #666;
border: none;
border-radius: 8px;
padding: 0.6rem 1.4rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
}
.good-bad-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 1.1rem;
justify-content: flex-start;
}
button.toggle-btn {
flex: 1 1 0;
padding: 0.5rem 1.2rem;
border-width: 2px;
border-radius: 7px;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
background: #f3f3f3;
color: #444;
transition:
background 0.18s,
color 0.18s,
border-style 0.18s;
outline: none;
border-style: outset; /* Default style */
border-color: #cbd5e1;
}
button.toggle-btn.good-active {
background: #38c172;
color: #fff;
box-shadow: 0 2px 8px #38c17233;
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: #38c172;
}
button.toggle-btn.bad-active {
background: #e53e3e;
color: #fff;
box-shadow: 0 2px 8px #e53e3e33;
transform: translateY(2px) scale(0.97);
border-style: ridge;
border-color: #e53e3e;
}
.actions {
margin-top: 1.2rem;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.error {
color: #e53e3e;
margin-top: 0.7rem;
text-align: center;
}
.loading-message {
text-align: center;
color: #666;
margin-bottom: 1.2rem;
}
</style>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import { getCachedImageUrl } from '../../common/imageCache'
const props = defineProps<{
childId?: string | number
assignable?: boolean
deletable?: boolean
}>()
const emit = defineEmits(['edit-task', 'delete-task'])
const tasks = ref<
{
id: string
name: string
points: number
is_good: boolean
image_id?: string | null
image_url?: string | null
}[]
>([])
const loading = ref(true)
const error = ref<string | null>(null)
const fetchTasks = async () => {
loading.value = true
error.value = null
let url = ''
if (props.childId) {
if (props.assignable) {
url = `/api/child/${props.childId}/list-assignable-tasks`
} else {
url = `/api/child/${props.childId}/list-tasks`
}
} else {
url = '/api/task/list'
}
try {
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
const taskList = data.tasks || []
// Fetch images for each task if image_id is present
await Promise.all(
taskList.map(async (task: any) => {
if (task.image_id) {
try {
task.image_url = await getCachedImageUrl(task.image_id)
} catch (e) {
task.image_url = null
}
}
}),
)
tasks.value = taskList
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to fetch tasks'
tasks.value = []
} finally {
loading.value = false
}
}
onMounted(fetchTasks)
watch(() => [props.childId, props.assignable], fetchTasks)
const handleEdit = (taskId: string) => {
emit('edit-task', taskId)
}
const handleDelete = (taskId: string) => {
emit('delete-task', taskId)
}
defineExpose({ refresh: fetchTasks })
const ITEM_HEIGHT = 52 // px, adjust to match your .task-list-item + margin
const listHeight = computed(() => {
// Add a little for padding, separators, etc.
const n = tasks.value.length
return `${Math.min(n * ITEM_HEIGHT + 8, window.innerHeight - 80)}px`
})
</script>
<template>
<div class="task-listbox" :style="{ maxHeight: `min(${listHeight}, calc(100vh - 4.5rem))` }">
<div v-if="loading" class="loading">Loading tasks...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else-if="tasks.length === 0" class="empty">No tasks found.</div>
<div v-else>
<div v-for="(task, idx) in tasks" :key="task.id">
<div
class="task-list-item"
:class="{ good: task.is_good, bad: !task.is_good }"
@click="handleEdit(task.id)"
>
<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>
<button
v-if="props.deletable"
class="delete-btn"
@click.stop="handleDelete(task.id)"
aria-label="Delete task"
type="button"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="9" fill="#fff" stroke="#ef4444" stroke-width="2" />
<path
d="M7 7l6 6M13 7l-6 6"
stroke="#ef4444"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div v-if="idx < tasks.length - 1" class="task-separator"></div>
</div>
</div>
</div>
</template>
<style scoped>
.task-listbox {
flex: 1 1 auto;
width: 100%;
max-width: 480px;
/* Subtract any header/nav height if needed, e.g. 4.5rem */
max-height: calc(100vh - 4.5rem);
overflow-y: auto;
margin: 0.2rem 0 0 0;
display: flex;
flex-direction: column;
gap: 0.7rem;
background: #fff5;
padding: 0.2rem 0.2rem 0.2rem;
border-radius: 12px;
}
.task-list-item {
display: flex;
align-items: center;
border: 2px outset #38c172;
border-radius: 8px;
padding: 0.2rem 1rem;
background: #f8fafc;
font-size: 1.05rem;
font-weight: 500;
transition: border 0.18s;
margin-bottom: 0.2rem;
margin-left: 0.2rem;
margin-right: 0.2rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
box-sizing: border-box;
}
.task-list-item.bad {
border-color: #e53e3e;
background: #fff5f5;
}
.task-list-item.good {
border-color: #38c172;
background: #f0fff4;
}
.task-image {
width: 36px;
height: 36px;
object-fit: cover;
border-radius: 8px;
margin-right: 0.7rem;
background: #eee;
flex-shrink: 0;
}
.task-name {
flex: 1;
text-align: left;
}
.task-points {
min-width: 60px;
text-align: right;
font-weight: 600;
}
.loading,
.error,
.empty {
margin: 1.2rem 0;
color: #888;
}
.error {
color: #e53e3e;
}
.task-list-item:last-child {
margin-bottom: 0;
}
.task-separator {
height: 0px;
background: #0000;
margin: 0rem 0.2rem;
border-radius: 0px;
}
.delete-btn {
background: transparent;
border: none;
border-radius: 50%;
padding: 0.15rem;
margin-left: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
transition:
background 0.15s,
box-shadow 0.15s;
width: 2rem;
height: 2rem;
opacity: 0.92;
}
.delete-btn:hover {
background: #ffeaea;
box-shadow: 0 0 0 2px #ef444422;
opacity: 1;
}
.delete-btn svg {
display: block;
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div class="task-view">
<TaskList
ref="taskListRef"
:deletable="true"
@edit-task="(taskId) => $router.push({ name: 'EditTask', params: { id: taskId } })"
@delete-task="confirmDeleteTask"
/>
<!-- Floating Action Button -->
<button class="fab" @click="createTask" aria-label="Create Task">
<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 v-if="showConfirm" class="modal-backdrop">
<div class="modal">
<p>Are you sure you want to delete this task?</p>
<div class="actions">
<button @click="deleteTask">Yes, Delete</button>
<button @click="showConfirm = false">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import TaskList from './TaskList.vue'
const $router = useRouter()
const showConfirm = ref(false)
const taskToDelete = ref<string | null>(null)
const taskListRef = ref()
function confirmDeleteTask(taskId: string) {
taskToDelete.value = taskId
showConfirm.value = true
}
const deleteTask = async () => {
if (!taskToDelete.value) return
try {
const resp = await fetch(`/api/task/${taskToDelete.value}`, {
method: 'DELETE',
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
// Refresh the task list after successful delete
taskListRef.value?.refresh()
console.log(`Task ${taskToDelete.value} deleted successfully`)
} catch (err) {
console.error('Failed to delete task:', err)
} finally {
showConfirm.value = false
taskToDelete.value = null
}
}
// New function to handle task creation
const createTask = () => {
// Route to your create task page or open a create dialog
// Example:
$router.push({ name: 'CreateTask' })
}
</script>
<style scoped>
.task-view {
display: flex;
flex-direction: column;
align-items: center;
flex: 1 1 auto;
width: 100%;
height: 100%;
padding: 0;
min-height: 0;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1200;
}
.modal {
background: #fff;
color: #222;
padding: 1.5rem 2rem;
border-radius: 12px;
min-width: 240px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
text-align: center;
}
.actions {
margin-top: 1.2rem;
display: flex;
gap: 1rem;
justify-content: center;
}
.actions button {
padding: 0.5rem 1.2rem;
border-radius: 8px;
border: none;
font-weight: 600;
cursor: pointer;
}
.actions button:first-child {
background: #ef4444;
color: #fff;
}
.actions button:last-child {
background: #f3f3f3;
color: #666;
}
.actions button:last-child:hover {
background: #e2e8f0;
}
/* Floating Action Button styles */
.fab {
position: fixed;
bottom: 2rem;
right: 2rem;
background: #667eea;
color: #fff;
border: none;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
cursor: pointer;
font-size: 24px;
z-index: 1300;
}
.fab:hover {
background: #5a67d8;
}
</style>

View File

@@ -14,18 +14,91 @@ const handleBack = () => {
} }
} }
const showBack = computed(() => route.path !== '/parent') const showBack = computed(
() => !(route.path === '/parent' || route.name === 'TaskView' || route.name === 'RewardView'),
)
</script> </script>
<template> <template>
<div class="layout-root"> <div class="layout-root">
<header class="topbar"> <header class="topbar">
<div class="topbar-inner"> <div style="height: 100%; display: flex; align-items: center">
<button v-if="showBack" class="back-btn" @click="handleBack"> Back</button> <button v-show="showBack" class="back-btn" @click="handleBack" tabindex="0"> Back</button>
<div class="spacer"></div>
<LoginButton />
</div> </div>
<nav class="view-selector">
<button
:class="{ active: ['ParentChildrenListView', 'ParentView'].includes(String(route.name)) }"
@click="router.push({ name: 'ParentChildrenListView' })"
aria-label="Children"
title="Children"
>
<!-- Children Icon: Two user portraits -->
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="8" cy="10" r="3" />
<circle cx="16" cy="10" r="3" />
<path d="M2 20c0-2.5 3-4.5 6-4.5s6 2 6 4.5" />
<path d="M10 20c0-2 2-3.5 6-3.5s6 1.5 6 3.5" />
</svg>
</button>
<button
:class="{ active: ['TaskView', 'EditTask', 'CreateTask'].includes(String(route.name)) }"
@click="router.push({ name: 'TaskView' })"
aria-label="Tasks"
title="Tasks"
>
<!-- Book Icon -->
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M20 22V6a2 2 0 0 0-2-2H6.5A2.5 2.5 0 0 0 4 6.5v13" />
<path d="M16 2v4" />
</svg>
</button>
<button
:class="{ active: route.name === 'RewardView' }"
@click="router.push({ name: 'RewardView' })"
aria-label="Rewards"
title="Rewards"
>
<!-- Trophy Icon -->
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M8 21h8" />
<path d="M12 17v4" />
<path d="M17 17a5 5 0 0 0 5-5V7h-4" />
<path d="M7 17a5 5 0 0 1-5-5V7h4" />
<rect x="7" y="2" width="10" height="15" rx="5" />
</svg>
</button>
</nav>
<LoginButton class="login-btn" />
</header> </header>
<main class="main-content"> <main class="main-content">
<router-view /> <router-view />
</main> </main>
@@ -35,31 +108,19 @@ const showBack = computed(() => route.path !== '/parent')
<style scoped> <style scoped>
.layout-root { .layout-root {
width: 100%; width: 100%;
box-sizing: border-box;
min-height: 100vh; min-height: 100vh;
height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0; /* Remove bottom padding */
/* Reduce top padding */
padding: 0.5rem 2rem 2rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
} }
/* top bar holds title and logout button */ /* top bar holds title and logout button */
.topbar { .topbar {
width: 100%; display: grid;
padding: 12px 20px; grid-template-columns: 76px 1fr 76px;
box-sizing: border-box;
display: flex;
justify-content: center;
}
.topbar-inner {
width: 100%;
max-width: 1200px;
display: flex;
align-items: center; align-items: center;
gap: 1rem;
} }
.title { .title {
@@ -68,38 +129,51 @@ const showBack = computed(() => route.path !== '/parent')
margin: 0; margin: 0;
} }
/* spacer pushes button to the right */ /* View Selector styles */
.spacer { .view-selector {
flex: 1; justify-self: center;
} }
.logout-btn { .view-selector button {
background: white; background: #fff;
color: #667eea; color: #667eea;
border: 0; border: none;
padding: 0.5rem 0.75rem; border-radius: 8px 8px 0 0;
border-radius: 8px; padding: 0.6rem 1.2rem;
cursor: pointer;
font-weight: 600; font-weight: 600;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); cursor: pointer;
transition: all 0.2s ease; transition:
background 0.18s,
color 0.18s;
font-size: 1rem;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.08);
} }
.logout-btn:hover { .view-selector button.active {
transform: translateY(-2px); background: #7257b3;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); color: #fff;
}
.view-selector button.active svg {
stroke: #fff;
}
.view-selector button:hover:not(.active) {
background: #e6eaff;
} }
/* main content remains centered */ /* main content remains centered */
.main-content { .main-content {
flex: 1 1 auto;
width: 100%; width: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: flex-start; /* content starts higher */ align-items: flex-start;
box-sizing: border-box; box-sizing: border-box;
min-height: 0;
/* Reduce top padding */ height: 0; /* Ensures children can use 100% height */
padding: 4px 20px 40px; overflow: hidden; /* Prevents parent from scrolling */
overflow-y: visible;
} }
/* back button specific styles */ /* back button specific styles */
@@ -107,24 +181,33 @@ const showBack = computed(() => route.path !== '/parent')
background: white; background: white;
border: 0; border: 0;
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
border-radius: 8px; border-radius: 8px 8px 0 0;
cursor: pointer; cursor: pointer;
margin-bottom: 0;
color: #667eea; color: #667eea;
font-weight: 600; font-weight: 600;
margin-top: 0; align-self: end;
} }
.back-btn:hover { .login-btn {
transform: translateY(-2px); align-self: end;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.back-btn { .back-btn {
padding: 0.45rem 0.75rem; padding: 0.45rem 0.75rem;
font-size: 0.95rem; font-size: 0.95rem;
margin-bottom: 0.7rem;
} }
} }
</style> </style>
<style>
.login-btn button {
background: white;
border: 0;
padding: 0.6rem 1rem;
border-radius: 8px 8px 0 0;
cursor: pointer;
color: #667eea;
font-weight: 600;
}
</style>

View File

@@ -1,25 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { isParentAuthenticated } from '../stores/auth'
import ChildLayout from '../layout/ChildLayout.vue' import ChildLayout from '../layout/ChildLayout.vue'
import ChildrenList from '../components/ChildrenList.vue'
import ChildView from '../components/ChildView.vue'
import ParentView from '../components/ParentView.vue'
import ParentLayout from '../layout/ParentLayout.vue' import ParentLayout from '../layout/ParentLayout.vue'
import ChildrenListView from '../components/ChildrenListView.vue'
import ChildView from '../components/child/ChildView.vue'
import ParentView from '../components/child/ParentView.vue'
import TaskView from '../components/task/TaskView.vue'
import RewardView from '../components/reward/RewardView.vue'
import TaskEditView from '@/components/task/TaskEditView.vue'
const routes = [ const routes = [
{ {
path: '/child', path: '/child',
name: 'ChildLayout',
component: ChildLayout, component: ChildLayout,
children: [ children: [
{ {
path: '', // /child path: '',
name: 'ChildrenList', name: 'ChildrenListView',
component: ChildrenList, component: ChildrenListView,
}, },
{ {
path: ':id', // /child/:id path: ':id',
name: 'ChildDetailView', name: 'ChildView',
component: ChildView, component: ChildView,
props: true, props: true,
}, },
@@ -27,21 +28,43 @@ const routes = [
}, },
{ {
path: '/parent', path: '/parent',
name: 'ParentLayout',
component: ParentLayout, component: ParentLayout,
meta: { requiresAuth: true }, meta: { requiresAuth: true },
children: [ children: [
{ {
path: '', // /parent path: '',
name: 'ParentChildrenList', name: 'ParentChildrenListView',
component: ChildrenList, component: ChildrenListView,
}, },
{ {
path: ':id', path: ':id',
name: 'ParentChildDetailView', name: 'ParentView',
component: ParentView, component: ParentView,
props: true, props: true,
}, },
{
path: 'tasks',
name: 'TaskView',
component: TaskView,
props: false,
},
{
path: 'tasks/create',
name: 'CreateTask',
component: TaskEditView,
},
{
path: 'tasks/:id/edit',
name: 'EditTask',
component: TaskEditView,
props: true,
},
{
path: 'rewards',
name: 'RewardView',
component: RewardView,
props: false,
},
], ],
}, },
{ {
@@ -49,9 +72,10 @@ const routes = [
redirect: '/child', redirect: '/child',
}, },
] ]
import { isParentAuthenticated } from '../stores/auth'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(),
routes, routes,
}) })