round 1
This commit is contained in:
@@ -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):
|
||||||
|
|||||||
@@ -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
BIN
requirements.txt
Normal file
Binary file not shown.
@@ -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>
|
|
||||||
@@ -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
|
||||||
432
web/vue-app/src/components/ImagePicker.vue
Normal file
432
web/vue-app/src/components/ImagePicker.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
229
web/vue-app/src/components/child/ChildForm.vue
Normal file
229
web/vue-app/src/components/child/ChildForm.vue
Normal 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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
14
web/vue-app/src/components/reward/RewardView.vue
Normal file
14
web/vue-app/src/components/reward/RewardView.vue
Normal 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>
|
||||||
@@ -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
|
||||||
320
web/vue-app/src/components/task/TaskEditView.vue
Normal file
320
web/vue-app/src/components/task/TaskEditView.vue
Normal 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>
|
||||||
226
web/vue-app/src/components/task/TaskList.vue
Normal file
226
web/vue-app/src/components/task/TaskList.vue
Normal 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>
|
||||||
148
web/vue-app/src/components/task/TaskView.vue
Normal file
148
web/vue-app/src/components/task/TaskView.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user